From 958ec489a2a1d01d19cb6e60f52fb2be56295104 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 05:54:26 +0300 Subject: [PATCH 01/33] fix: respect per-channel disable_on_leave settings in monitoring service The background monitoring service was deactivating trial subscriptions when users unsubscribed from channels, ignoring per-channel disable_trial_on_leave and disable_paid_on_leave settings that the real-time handler and middleware already respected. Changes: - Use shared should_disable_subscription() for all 3 deactivation paths - Add global CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE override in should_disable_subscription - Add admin skip in monitoring (consistent with handler/middleware) - Replace inline reactivation with reactivate_subscription() CRUD - Switch to enable_remnawave_user() instead of heavy update_remnawave_user() - Add commit=False to deactivate/reactivate/record/clear_notification for batch atomicity - Include paid subs in monitoring when any channel has disable_paid_on_leave=True - Use skip_deactivation flag instead of early return to preserve reactivation path - Commit batch before create_remnawave_user which internally commits --- app/database/crud/notification.py | 10 +++- app/database/crud/subscription.py | 14 ++--- app/services/channel_subscription_service.py | 14 ++++- app/services/monitoring_service.py | 55 +++++++++++++++----- 4 files changed, 70 insertions(+), 23 deletions(-) diff --git a/app/database/crud/notification.py b/app/database/crud/notification.py index 3029112c..3f2b1fe7 100644 --- a/app/database/crud/notification.py +++ b/app/database/crud/notification.py @@ -34,6 +34,8 @@ async def record_notification( subscription_id: int, notification_type: str, days_before: int | None = None, + *, + commit: bool = True, ) -> None: already_exists = await notification_sent(db, user_id, subscription_id, notification_type, days_before) if already_exists: @@ -45,7 +47,8 @@ async def record_notification( days_before=days_before, ) db.add(notification) - await db.commit() + if commit: + await db.commit() async def clear_notifications(db: AsyncSession, subscription_id: int, *, commit: bool = True) -> None: @@ -58,6 +61,8 @@ async def clear_notification_by_type( db: AsyncSession, subscription_id: int, notification_type: str, + *, + commit: bool = True, ) -> None: await db.execute( delete(SentNotification).where( @@ -65,4 +70,5 @@ async def clear_notification_by_type( SentNotification.notification_type == notification_type, ) ) - await db.commit() + if commit: + await db.commit() diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index e73bdec9..82bdb733 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -826,18 +826,19 @@ async def update_subscription_autopay( return subscription -async def deactivate_subscription(db: AsyncSession, subscription: Subscription) -> Subscription: +async def deactivate_subscription(db: AsyncSession, subscription: Subscription, *, commit: bool = True) -> Subscription: subscription.status = SubscriptionStatus.DISABLED.value subscription.updated_at = datetime.now(UTC) - await db.commit() - await db.refresh(subscription) + if commit: + await db.commit() + await db.refresh(subscription) logger.info('❌ Подписка пользователя деактивирована', user_id=subscription.user_id) return subscription -async def reactivate_subscription(db: AsyncSession, subscription: Subscription) -> Subscription: +async def reactivate_subscription(db: AsyncSession, subscription: Subscription, *, commit: bool = True) -> Subscription: """Реактивация подписки (например, после повторной подписки на канал или докупки трафика). Активирует если подписка была DISABLED или EXPIRED и ещё не истекла по времени. @@ -861,8 +862,9 @@ async def reactivate_subscription(db: AsyncSession, subscription: Subscription) subscription.status = SubscriptionStatus.ACTIVE.value subscription.updated_at = now - await db.commit() - await db.refresh(subscription) + if commit: + await db.commit() + await db.refresh(subscription) logger.info( '✅ Подписка реактивирована', diff --git a/app/services/channel_subscription_service.py b/app/services/channel_subscription_service.py index 5751b5f7..679070a4 100644 --- a/app/services/channel_subscription_service.py +++ b/app/services/channel_subscription_service.py @@ -79,9 +79,19 @@ class ChannelSubscriptionService: return ch return None - def should_disable_subscription(self, channel: dict, is_trial: bool) -> bool: - """Check if a channel's settings require subscription deactivation.""" + @staticmethod + def should_disable_subscription(channel: dict, is_trial: bool) -> bool: + """Check if a channel's settings require subscription deactivation. + + Respects both global and per-channel settings: + - Global CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE=False overrides per-channel for trials + - Per-channel disable_trial_on_leave / disable_paid_on_leave for fine-grained control + """ + from app.config import settings + if is_trial: + if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE: + return False return channel.get('disable_trial_on_leave', True) return channel.get('disable_paid_on_leave', False) diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 5c35dd5a..35923a42 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -25,6 +25,7 @@ from app.database.crud.subscription import ( get_expired_subscriptions, get_expiring_subscriptions, get_subscriptions_for_autopay, + reactivate_subscription, ) from app.database.crud.user import ( cleanup_expired_promo_offer_discounts, @@ -585,15 +586,11 @@ class MonitoringService: When CHANNEL_REQUIRED_FOR_ALL is True, checks ALL active subscriptions (not just trials). Otherwise only checks trial subscriptions. """ - from app.database.crud.subscription import is_active_paid_subscription, is_recently_updated_by_webhook + from app.database.crud.subscription import is_recently_updated_by_webhook if not settings.CHANNEL_IS_REQUIRED_SUB: return - if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL: - logger.debug('Channel unsubscribe check disabled') - return - if not self.bot: logger.debug('Skipping channel subscription check - bot unavailable') return @@ -606,6 +603,14 @@ class MonitoringService: if not channels: return + # When no channel has any disable-on-leave rule, skip deactivation but + # still run reactivation to restore orphaned DISABLED subscriptions + # (e.g., admin turned off disable flags after subscriptions were already disabled). + has_any_disable_rule = any( + ch.get('disable_trial_on_leave', True) or ch.get('disable_paid_on_leave', False) for ch in channels + ) + skip_deactivation = not has_any_disable_rule and not settings.CHANNEL_REQUIRED_FOR_ALL + # Ensure bot is set on service if not channel_subscription_service.bot: channel_subscription_service.bot = self.bot @@ -623,9 +628,13 @@ class MonitoringService: last_id = 0 # Build the trial/all filter based on CHANNEL_REQUIRED_FOR_ALL setting + # Also include paid subs if any channel has disable_paid_on_leave=True, + # so monitoring can reconcile missed real-time events for paid users. from sqlalchemy import true as sa_true - is_trial_filter = sa_true() if settings.CHANNEL_REQUIRED_FOR_ALL else Subscription.is_trial.is_(True) + has_paid_disable_rule = any(ch.get('disable_paid_on_leave', False) for ch in channels) + include_all = settings.CHANNEL_REQUIRED_FOR_ALL or has_paid_disable_rule + is_trial_filter = sa_true() if include_all else Subscription.is_trial.is_(True) while True: # Fresh session per batch to avoid long-running connections @@ -666,6 +675,10 @@ class MonitoringService: if not user or not user.telegram_id: continue + # Skip admins -- consistent with channel_member.py and channel_checker.py + if settings.is_admin(user.telegram_id): + continue + # Existing guard: skip if recently updated by webhook if is_recently_updated_by_webhook(subscription): logger.debug( @@ -678,6 +691,7 @@ class MonitoringService: # Rate-limited check for ALL channels all_subscribed = True + unsubscribed_channels: list[dict] = [] for ch in channels: is_member = await channel_subscription_service._rate_limited_check( user.telegram_id, ch['channel_id'] @@ -688,14 +702,22 @@ class MonitoringService: if not is_member: all_subscribed = False + unsubscribed_channels.append(ch) # DEACTIVATE: was active, now not subscribed to all if subscription.status == SubscriptionStatus.ACTIVE.value and not all_subscribed: - # Guard: always skip paid subscriptions (user paid money) - if is_active_paid_subscription(subscription): + if skip_deactivation: continue - subscription = await deactivate_subscription(batch_db, subscription) + # Respect per-channel disable_trial_on_leave / disable_paid_on_leave settings + should_disable = any( + channel_subscription_service.should_disable_subscription(ch, subscription.is_trial) + for ch in unsubscribed_channels + ) + if not should_disable: + continue + + subscription = await deactivate_subscription(batch_db, subscription, commit=False) disabled_count += 1 logger.info( 'Subscription deactivated (channel unsubscribe)', @@ -728,6 +750,7 @@ class MonitoringService: user.id, subscription.id, 'trial_channel_unsubscribed', + commit=False, ) # REACTIVATE: was disabled, now subscribed to all @@ -761,10 +784,12 @@ class MonitoringService: ) continue - subscription.status = SubscriptionStatus.ACTIVE.value - subscription.updated_at = datetime.now(UTC) - restored_count += 1 + subscription = await reactivate_subscription(batch_db, subscription, commit=False) + if subscription.status != SubscriptionStatus.ACTIVE.value: + # reactivate_subscription silently skipped (expired or wrong status) + continue + restored_count += 1 logger.info( 'Subscription restored (channel resubscribe)', telegram_id=user.telegram_id, @@ -774,8 +799,11 @@ class MonitoringService: try: if user.remnawave_uuid: - await self.subscription_service.update_remnawave_user(batch_db, subscription) + await self.subscription_service.enable_remnawave_user(user.remnawave_uuid) else: + # create_remnawave_user calls db.commit() internally -- + # flush accumulated batch state first to preserve atomicity. + await batch_db.commit() await self.subscription_service.create_remnawave_user(batch_db, subscription) except Exception as api_error: logger.error( @@ -788,6 +816,7 @@ class MonitoringService: batch_db, subscription.id, 'trial_channel_unsubscribed', + commit=False, ) # Commit all changes for this batch From 0fe3c217f752ced2e3c3f56b4ab5b4c898ba2d8e Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 06:16:28 +0300 Subject: [PATCH 02/33] fix: suppress harmless TelegramBadRequest errors and fix discount promo display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reorder middleware: LoggingMiddleware now outermost, GlobalErrorMiddleware inside — prevents full traceback logging for suppressed errors (message not modified, query too old, bot blocked) - Fix root cause in message_patch.py: _edit_with_photo missing try/except for _original_edit_text when ENABLE_LOGO_MODE=False - Add "message is not modified" suppression in AuthMiddleware to prevent unnecessary db.rollback() and ERROR-level logging - Fix discount promo display in admin notifications: subscription_days shown as hours (not days), balance_bonus_kopeks shown as percentage (not price) --- app/bot.py | 4 ++-- app/middlewares/auth.py | 6 +++++- app/services/admin_notification_service.py | 20 +++++++++++++++----- app/utils/message_patch.py | 11 ++++++++++- 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/app/bot.py b/app/bot.py index 0b7ce98d..a6504e44 100644 --- a/app/bot.py +++ b/app/bot.py @@ -133,11 +133,11 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: chat_type_filter = ChatTypeFilterMiddleware() dp.message.middleware(chat_type_filter) dp.callback_query.middleware(chat_type_filter) + dp.message.middleware(LoggingMiddleware()) + dp.callback_query.middleware(LoggingMiddleware()) dp.message.middleware(GlobalErrorMiddleware()) dp.callback_query.middleware(GlobalErrorMiddleware()) dp.pre_checkout_query.middleware(GlobalErrorMiddleware()) - dp.message.middleware(LoggingMiddleware()) - dp.callback_query.middleware(LoggingMiddleware()) dp.message.middleware(MaintenanceMiddleware()) dp.callback_query.middleware(MaintenanceMiddleware()) blacklist_middleware = BlacklistMiddleware() diff --git a/app/middlewares/auth.py b/app/middlewares/auth.py index 803ae1fc..79cf5720 100644 --- a/app/middlewares/auth.py +++ b/app/middlewares/auth.py @@ -243,9 +243,13 @@ class AuthMiddleware(BaseMiddleware): logger.debug('AuthMiddleware: bot blocked by user, skipping') return None except TelegramBadRequest as e: - if 'query is too old' in str(e): + error_msg = str(e).lower() + if 'query is too old' in error_msg: logger.debug('AuthMiddleware: callback query expired, skipping') return None + if 'message is not modified' in error_msg: + logger.debug('AuthMiddleware: message not modified, skipping') + return None raise except Exception as e: logger.error('Ошибка в AuthMiddleware', error=e) diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py index 43b7cbb2..81ae9e57 100644 --- a/app/services/admin_notification_service.py +++ b/app/services/admin_notification_service.py @@ -265,6 +265,8 @@ class AdminNotificationService: PromoCodeType.BALANCE.value: '💰 Бонус на баланс', PromoCodeType.SUBSCRIPTION_DAYS.value: '⏰ Доп. дни подписки', PromoCodeType.TRIAL_SUBSCRIPTION.value: '🎁 Триал подписка', + PromoCodeType.PROMO_GROUP.value: '👥 Промогруппа', + PromoCodeType.DISCOUNT.value: '💸 Скидка', } if not promo_type: @@ -1013,13 +1015,21 @@ class AdminNotificationService: f'📊 Использования: {usage_info}', ] + promo_type = promocode_data.get('type') balance_bonus = promocode_data.get('balance_bonus_kopeks', 0) - if balance_bonus: - message_lines.append(f'💰 Бонус на баланс: {settings.format_price(balance_bonus)}') - subscription_days = promocode_data.get('subscription_days', 0) - if subscription_days: - message_lines.append(f'📅 Доп. дни подписки: {subscription_days}') + + if promo_type == PromoCodeType.DISCOUNT.value: + message_lines.append(f'💸 Скидка: {balance_bonus}%') + if subscription_days: + message_lines.append(f'⏳ Срок действия скидки: {subscription_days} ч.') + else: + message_lines.append('⏳ Срок действия скидки: до первой покупки') + else: + if balance_bonus: + message_lines.append(f'💰 Бонус на баланс: {settings.format_price(balance_bonus)}') + if subscription_days: + message_lines.append(f'📅 Доп. дни подписки: {subscription_days}') valid_until = promocode_data.get('valid_until') if valid_until: diff --git a/app/utils/message_patch.py b/app/utils/message_patch.py index 0409191e..8b6dbff9 100644 --- a/app/utils/message_patch.py +++ b/app/utils/message_patch.py @@ -218,7 +218,16 @@ async def _edit_with_photo(self: Message, text: str, **kwargs): if is_topic_required_error(error): return None raise - return await _original_edit_text(self, text, **kwargs) + try: + return await _original_edit_text(self, text, **kwargs) + except TelegramBadRequest as error: + if is_topic_required_error(error): + return None + if 'MESSAGE_ID_INVALID' in str(error) or 'message to edit not found' in str(error).lower(): + return None + if 'message is not modified' in str(error).lower(): + return None + raise if self.photo: language = _get_language(self) # Если caption потенциально слишком длинный — отправим как текст вместо caption From aec04f0085bd9c566bd033b8bb628389ff22bdf6 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 06:42:57 +0300 Subject: [PATCH 03/33] fix: correctly price unlimited traffic (0 GB) in classic subscription mode _calculate_traffic_price treated unlimited traffic as free because base_gb=0 triggered the `if base_gb > 0 else 0` guard, skipping the price lookup. Added early return for total_gb==0 to use the configured unlimited tier price. --- app/services/pricing_engine.py | 5 +++++ tests/test_pricing_engine.py | 13 ++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/app/services/pricing_engine.py b/app/services/pricing_engine.py index 9174a228..8e72267c 100644 --- a/app/services/pricing_engine.py +++ b/app/services/pricing_engine.py @@ -479,6 +479,11 @@ class PricingEngine: Prevents purchased top-ups from inflating the tier lookup.""" total_gb = traffic_limit_gb or 0 purchased_gb = purchased_traffic_gb or 0 + + # 0 = unlimited traffic — has its own price tier, return directly + if total_gb == 0: + return settings.get_traffic_price(0) + base_gb = max(0, total_gb - purchased_gb) base_price = settings.get_traffic_price(base_gb) if base_gb > 0 else 0 diff --git a/tests/test_pricing_engine.py b/tests/test_pricing_engine.py index aa7f27d5..b7a9c20e 100644 --- a/tests/test_pricing_engine.py +++ b/tests/test_pricing_engine.py @@ -232,12 +232,19 @@ class TestCalculateTrafficPrice: price = engine._calculate_traffic_price(traffic_limit_gb=125, purchased_traffic_gb=100) assert price == 11000 # NOT 12000 - def test_zero_traffic(self): + def test_unlimited_traffic_has_price(self): engine = PricingEngine() with patch('app.services.pricing_engine.settings') as ms: - ms.get_traffic_price.return_value = 0 + ms.get_traffic_price.side_effect = lambda gb: {0: 20000, 5: 2000}.get(gb, 0) price = engine._calculate_traffic_price(traffic_limit_gb=0, purchased_traffic_gb=0) - assert price == 0 + assert price == 20000 # 0 GB = unlimited, charged at unlimited tier + + def test_unlimited_traffic_ignores_purchased(self): + engine = PricingEngine() + with patch('app.services.pricing_engine.settings') as ms: + ms.get_traffic_price.side_effect = lambda gb: {0: 20000, 50: 5000}.get(gb, 0) + price = engine._calculate_traffic_price(traffic_limit_gb=0, purchased_traffic_gb=50) + assert price == 20000 # unlimited tier, purchased ignored def test_purchased_exceeds_total(self): engine = PricingEngine() From 9de34900a2a9047ab275c0bbf16fd314eb49a3ec Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 08:06:15 +0300 Subject: [PATCH 04/33] fix: comprehensive html.escape() for all user/admin data in Telegram HTML messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bot uses default HTML parse mode — all messages are HTML-parsed by Telegram. Added html.escape() to all user-controlled and admin-controlled strings before interpolation into HTML messages to prevent injection and parse errors. 49 files, ~250+ injection points fixed: - user.full_name, first_name across all handlers and services - tariff.name/description in purchase flow, admin panel, auto-purchase service - campaign.name, start_parameter in admin and user-facing handlers - group.name, promo_group.name across promo management - contest.title, prize_text, leaderboard names (including public channels) - transaction.description (contains raw user.full_name from referral service) - restriction_reason across all balance and subscription handlers - ticket.title, message_text, poll.title, poll.description - welcome text template placeholders (first_name, username) - maintenance reason, admin_name, selected_prize.display_name New helpers in app/utils/formatting.py: - safe_html_name() for escaping display names - user_html_link() replacing 15+ duplicated inline link patterns --- app/database/crud/welcome_text.py | 9 +- app/handlers/admin/blacklist.py | 4 +- app/handlers/admin/blocked_users.py | 3 +- app/handlers/admin/campaigns.py | 15 +- app/handlers/admin/contests.py | 31 ++-- app/handlers/admin/main.py | 4 +- app/handlers/admin/maintenance.py | 4 +- app/handlers/admin/messages.py | 4 +- app/handlers/admin/monitoring.py | 3 +- app/handlers/admin/polls.py | 2 +- app/handlers/admin/promo_groups.py | 38 ++-- app/handlers/admin/promo_offers.py | 2 +- app/handlers/admin/promocodes.py | 15 +- app/handlers/admin/referrals.py | 98 ++++++---- app/handlers/admin/tariffs.py | 42 ++--- app/handlers/admin/tickets.py | 18 +- app/handlers/admin/users.py | 170 +++++++----------- app/handlers/balance/cloudpayments.py | 6 +- app/handlers/balance/cryptobot.py | 6 +- app/handlers/balance/freekassa.py | 4 +- app/handlers/balance/heleket.py | 5 +- app/handlers/balance/kassa_ai.py | 6 +- app/handlers/balance/main.py | 6 +- app/handlers/balance/mulenpay.py | 6 +- app/handlers/balance/pal24.py | 4 +- app/handlers/balance/platega.py | 6 +- app/handlers/balance/riopay.py | 6 +- app/handlers/balance/severpay.py | 6 +- app/handlers/balance/stars.py | 6 +- app/handlers/balance/tribute.py | 4 +- app/handlers/balance/wata.py | 5 +- app/handlers/balance/yookassa.py | 9 +- app/handlers/menu.py | 5 +- app/handlers/polls.py | 3 +- app/handlers/simple_subscription.py | 2 +- app/handlers/stars_payments.py | 3 +- app/handlers/start.py | 19 +- app/handlers/subscription/purchase.py | 8 +- app/handlers/subscription/tariff_purchase.py | 95 +++++----- app/handlers/tickets.py | 26 +-- app/services/admin_notification_service.py | 58 +++--- app/services/monitoring_service.py | 10 +- app/services/poll_service.py | 5 +- app/services/referral_contest_service.py | 17 +- app/services/referral_service.py | 14 +- app/services/referral_withdrawal_service.py | 3 +- .../subscription_auto_purchase_service.py | 5 +- app/services/traffic_monitoring_service.py | 7 +- app/utils/formatting.py | 15 ++ 49 files changed, 449 insertions(+), 393 deletions(-) diff --git a/app/database/crud/welcome_text.py b/app/database/crud/welcome_text.py index cf305dd6..ba0a5cdb 100644 --- a/app/database/crud/welcome_text.py +++ b/app/database/crud/welcome_text.py @@ -1,3 +1,4 @@ +import html from datetime import UTC, datetime import structlog @@ -221,10 +222,10 @@ def replace_placeholders(text: str, user) -> str: first_name = first_name.strip() if first_name else None username = username.strip() if username else None - user_name = first_name or username or 'друг' - display_first_name = first_name or 'друг' - display_username = f'@{username}' if username else (first_name or 'друг') - clean_username = username or first_name or 'друг' + user_name = html.escape(first_name or username or 'друг') + display_first_name = html.escape(first_name or 'друг') + display_username = f'@{html.escape(username)}' if username else html.escape(first_name or 'друг') + clean_username = html.escape(username or first_name or 'друг') replacements = { '{user_name}': user_name, diff --git a/app/handlers/admin/blacklist.py b/app/handlers/admin/blacklist.py index ab0fdd5f..40f200e4 100644 --- a/app/handlers/admin/blacklist.py +++ b/app/handlers/admin/blacklist.py @@ -2,6 +2,8 @@ Обработчики админ-панели для управления черным списком """ +import html + import structlog from aiogram import types from aiogram.filters import StateFilter @@ -147,7 +149,7 @@ async def show_blacklist_users(callback: types.CallbackQuery, db_user: User, sta # Показываем первые 20 записей for i, (tg_id, username, reason) in enumerate(blacklist_users[:20], 1): - text += f'{i}. {tg_id} {username or ""} — {reason}\n' + text += f'{i}. {tg_id} {html.escape(username or "")} — {html.escape(reason or "")}\n' if len(blacklist_users) > 20: text += f'\n... и еще {len(blacklist_users) - 20} записей' diff --git a/app/handlers/admin/blocked_users.py b/app/handlers/admin/blocked_users.py index 5407c7f6..6867f2f1 100644 --- a/app/handlers/admin/blocked_users.py +++ b/app/handlers/admin/blocked_users.py @@ -5,6 +5,7 @@ и выполнять очистку БД и панели Remnawave. """ +import html from datetime import UTC, datetime from enum import Enum from typing import Any @@ -437,7 +438,7 @@ async def show_blocked_list( name = user_data.get('full_name') or user_data.get('username') or 'Без имени' telegram_id = user_data.get('telegram_id', '?') text += BlockedUsersText.BLOCKED_USER_ROW.value.format( - name=name, + name=html.escape(name), telegram_id=telegram_id, ) diff --git a/app/handlers/admin/campaigns.py b/app/handlers/admin/campaigns.py index ddb33817..f67ab004 100644 --- a/app/handlers/admin/campaigns.py +++ b/app/handlers/admin/campaigns.py @@ -1,3 +1,4 @@ +import html import re import structlog @@ -67,8 +68,8 @@ def _format_campaign_summary(campaign, texts) -> str: bonus_info = '❓ Неизвестный тип бонуса' return ( - f'{campaign.name}\n' - f'Стартовый параметр: {campaign.start_parameter}\n' + f'{html.escape(campaign.name)}\n' + f'Стартовый параметр: {html.escape(campaign.start_parameter)}\n' f'Статус: {status}\n' f'{bonus_info}\n' ) @@ -244,7 +245,7 @@ async def show_campaigns_list( total_balance = sum(r.balance_bonus_kopeks or 0 for r in regs) status = '🟢' if campaign.is_active else '⚪' line = ( - f'{status} {campaign.name}{campaign.start_parameter}\n' + f'{status} {html.escape(campaign.name)}{html.escape(campaign.start_parameter)}\n' f' Регистраций: {registrations}, баланс: {texts.format_price(total_balance)}' ) if campaign.is_subscription_bonus: @@ -383,7 +384,7 @@ async def start_edit_campaign_name( await callback.message.edit_text( ( '✏️ Изменение названия кампании\n\n' - f'Текущее название: {campaign.name}\n' + f'Текущее название: {html.escape(campaign.name)}\n' 'Введите новое название (3-100 символов):' ), reply_markup=types.InlineKeyboardMarkup( @@ -1183,8 +1184,8 @@ async def confirm_delete_campaign( text = ( '🗑️ Удаление кампании\n\n' - f'Название: {campaign.name}\n' - f'Параметр: {campaign.start_parameter}\n\n' + f'Название: {html.escape(campaign.name)}\n' + f'Параметр: {html.escape(campaign.start_parameter)}\n\n' 'Вы уверены, что хотите удалить кампанию?' ) @@ -1591,7 +1592,7 @@ async def select_campaign_tariff( await state.update_data(campaign_tariff_id=tariff_id, campaign_tariff_name=tariff.name) await state.set_state(AdminStates.creating_campaign_tariff_days) await callback.message.edit_text( - f'🎁 Выбран тариф: {tariff.name}\n\n📅 Введите длительность тарифа в днях (1-730):', + f'🎁 Выбран тариф: {html.escape(tariff.name)}\n\n📅 Введите длительность тарифа в днях (1-730):', reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_campaigns')]] ), diff --git a/app/handlers/admin/contests.py b/app/handlers/admin/contests.py index 75a63a8d..e744cd1a 100644 --- a/app/handlers/admin/contests.py +++ b/app/handlers/admin/contests.py @@ -1,3 +1,4 @@ +import html import math from datetime import UTC, datetime, time from zoneinfo import ZoneInfo @@ -70,7 +71,7 @@ def _format_contest_summary(contest, texts, tz: ZoneInfo) -> str: f'Дневная сводка: {summary_times}', ] if contest.prize_text: - parts.append(texts.t('ADMIN_CONTEST_PRIZE', 'Приз: {prize}').format(prize=contest.prize_text)) + parts.append(texts.t('ADMIN_CONTEST_PRIZE', 'Приз: {prize}').format(prize=html.escape(contest.prize_text))) if contest.last_daily_summary_date: parts.append( texts.t('ADMIN_CONTEST_LAST_DAILY', 'Последняя сводка: {date}').format( @@ -188,7 +189,7 @@ async def list_contests( lines.append(texts.t('ADMIN_CONTESTS_EMPTY', 'Пока нет созданных конкурсов.')) else: for contest in contests: - lines.append(f'• {contest.title} (#{contest.id})') + lines.append(f'• {html.escape(contest.title)} (#{contest.id})') contest_tz = _ensure_timezone(contest.timezone or settings.TIMEZONE) lines.append(_format_contest_summary(contest, texts, contest_tz)) lines.append('') @@ -250,21 +251,21 @@ async def show_contest_details( total_events = await get_contest_events_count(db, contest.id) + virtual_count lines = [ - f'🏆 {contest.title}', + f'🏆 {html.escape(contest.title)}', _format_contest_summary(contest, texts, tz), texts.t('ADMIN_CONTEST_TOTAL_EVENTS', 'Зачётов: {count}').format(count=total_events), ] if contest.description: lines.append('') - lines.append(contest.description) + lines.append(html.escape(contest.description)) if leaderboard: lines.append('') lines.append(texts.t('ADMIN_CONTEST_LEADERBOARD_TITLE', '📊 Топ участников:')) for idx, (name, score, _, is_virtual) in enumerate(leaderboard, start=1): virt_mark = ' 👻' if is_virtual else '' - lines.append(f'{idx}. {name}{virt_mark} — {score}') + lines.append(f'{idx}. {html.escape(name)}{virt_mark} — {score}') await callback.message.edit_text( '\n'.join(lines), @@ -444,7 +445,7 @@ async def show_leaderboard( ] for idx, (name, score, _, is_virtual) in enumerate(leaderboard, start=1): virt_mark = ' 👻' if is_virtual else '' - lines.append(f'{idx}. {name}{virt_mark} — {score}') + lines.append(f'{idx}. {html.escape(name)}{virt_mark} — {score}') await callback.message.edit_text( '\n'.join(lines), @@ -690,7 +691,7 @@ async def show_detailed_stats( # Общее сообщение с основной статистикой general_lines = [ '📈 Статистика конкурса', - f'🏆 {contest.title}', + f'🏆 {html.escape(contest.title)}', '', f'👥 Участников (рефереров): {stats["total_participants"]}', f'📨 Приглашено рефералов: {stats["total_invited"]}', @@ -751,7 +752,7 @@ async def show_detailed_stats_page( for p in page_participants: lines.extend( [ - f'• {p["full_name"]}', + f'• {html.escape(p["full_name"] or "")}', f' 📨 Приглашено: {p["total_referrals"]}', f' 💰 Оплатили: {p["paid_referrals"]}', f' ❌ Не оплатили: {p["unpaid_referrals"]}', @@ -828,7 +829,7 @@ async def sync_contest( lines = [ '✅ Синхронизация завершена!', '', - f'📊 Конкурс: {contest.title}', + f'📊 Конкурс: {html.escape(contest.title)}', f'📅 Период: {contest.start_at.strftime("%d.%m.%Y")} - {contest.end_at.strftime("%d.%m.%Y")}', '🔍 Фильтр транзакций:', f' {start_str}', @@ -870,7 +871,7 @@ async def sync_contest( # Обновляем основное сообщение с новой статистикой detailed_stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id) general_lines = [ - f'🏆 {contest.title}', + f'🏆 {html.escape(contest.title)}', f'📅 Период: {contest.start_at.strftime("%d.%m.%Y")} - {contest.end_at.strftime("%d.%m.%Y")}', '', f'👥 Участников (рефереров): {detailed_stats["total_participants"]}', @@ -927,7 +928,7 @@ async def debug_contest_transactions( lines = [ '🔍 Отладка транзакций конкурса', '', - f'📊 Конкурс: {contest.title}', + f'📊 Конкурс: {html.escape(contest.title)}', '📅 Период фильтрации:', f' Начало: {debug_data.get("contest_start")}', f' Конец: {debug_data.get("contest_end")}', @@ -1002,10 +1003,10 @@ async def show_virtual_participants( vps = await list_virtual_participants(db, contest_id) - lines = [f'👻 Виртуальные участники — {contest.title}', ''] + lines = [f'👻 Виртуальные участники — {html.escape(contest.title)}', ''] if vps: for vp in vps: - lines.append(f'• {vp.display_name} — {vp.referral_count} реф.') + lines.append(f'• {html.escape(vp.display_name)} — {vp.referral_count} реф.') else: lines.append('Пока нет виртуальных участников.') @@ -1156,10 +1157,10 @@ async def delete_virtual_participant_handler( vps = await list_virtual_participants(db, contest_id) contest = await get_referral_contest(db, contest_id) - lines = [f'👻 Виртуальные участники — {contest.title}', ''] + lines = [f'👻 Виртуальные участники — {html.escape(contest.title)}', ''] if vps: for v in vps: - lines.append(f'• {v.display_name} — {v.referral_count} реф.') + lines.append(f'• {html.escape(v.display_name)} — {v.referral_count} реф.') else: lines.append('Пока нет виртуальных участников.') diff --git a/app/handlers/admin/main.py b/app/handlers/admin/main.py index 1e2e9405..2ae57fb4 100644 --- a/app/handlers/admin/main.py +++ b/app/handlers/admin/main.py @@ -1,3 +1,5 @@ +import html + import structlog from aiogram import Dispatcher, F, types from aiogram.filters import Command @@ -283,7 +285,7 @@ async def clear_rules_command(message: types.Message, db_user: User, db: AsyncSe f'📊 Статистика:\n' f'• Очищено правил: {stats["total_active"]}\n' f'• Язык: {db_user.language}\n' - f'• Выполнил: {db_user.full_name}\n\n' + f'• Выполнил: {html.escape(db_user.full_name or "")}\n\n' f'Теперь используются стандартные правила по умолчанию.' ) diff --git a/app/handlers/admin/maintenance.py b/app/handlers/admin/maintenance.py index 7510a5e1..eedf29e7 100644 --- a/app/handlers/admin/maintenance.py +++ b/app/handlers/admin/maintenance.py @@ -1,3 +1,5 @@ +import html + import structlog from aiogram import Dispatcher, F, types from aiogram.fsm.context import FSMContext @@ -133,7 +135,7 @@ async def process_maintenance_reason(message: types.Message, db_user: User, db: if success: response_text = 'Режим техработ включен' if reason: - response_text += f'\nПричина: {reason}' + response_text += f'\nПричина: {html.escape(reason)}' else: response_text = 'Ошибка включения режима техработ' diff --git a/app/handlers/admin/messages.py b/app/handlers/admin/messages.py index 9b523b82..2c5563a7 100644 --- a/app/handlers/admin/messages.py +++ b/app/handlers/admin/messages.py @@ -643,7 +643,7 @@ async def show_messages_history(callback: types.CallbackQuery, db_user: User, db {status_emoji} {broadcast.created_at.strftime('%d.%m.%Y %H:%M')} 📊 Отправлено: {broadcast.sent_count}/{broadcast.total_count} ({success_rate}%) 🎯 Аудитория: {get_target_name(broadcast.target_type)} -👤 Админ: {broadcast.admin_name} +👤 Админ: {html.escape(broadcast.admin_name or '')} 📝 Сообщение: {message_preview} ━━━━━━━━━━━━━━━━━━━━━━━ """ @@ -1477,7 +1477,7 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state: f'• Не доставлено: {failed_count}\n' f'• Всего пользователей: {total_users_count}\n' f'• Успешность: {success_rate}%{media_info}\n\n' - f'Администратор: {admin_name}' + f'Администратор: {html.escape(admin_name)}' ) back_keyboard = types.InlineKeyboardMarkup( diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index 3390643a..80f90299 100644 --- a/app/handlers/admin/monitoring.py +++ b/app/handlers/admin/monitoring.py @@ -1,4 +1,5 @@ import asyncio +import html from datetime import UTC, date, datetime, timedelta import structlog @@ -741,7 +742,7 @@ async def traffic_check_callback(callback: CallbackQuery): if violations: text += '\n⚠️ Превышения дельты:\n' for v in violations[:10]: - name = v.full_name or v.user_uuid[:8] + name = html.escape(v.full_name or '') or v.user_uuid[:8] text += f'• {name}: +{v.used_traffic_gb:.1f} ГБ\n' if len(violations) > 10: text += f'... и ещё {len(violations) - 10}\n' diff --git a/app/handlers/admin/polls.py b/app/handlers/admin/polls.py index d51000b9..67bb3920 100644 --- a/app/handlers/admin/polls.py +++ b/app/handlers/admin/polls.py @@ -886,7 +886,7 @@ async def _render_poll_details(poll: Poll, language: str) -> str: texts = get_texts(language) lines = [f'🗳️ {html.escape(poll.title)}'] if poll.description: - lines.append(poll.description) + lines.append(html.escape(poll.description)) lines.append(_format_reward_text(poll, language)) lines.append(texts.t('ADMIN_POLLS_QUESTIONS_COUNT', 'Вопросов: {count}').format(count=len(poll.questions))) diff --git a/app/handlers/admin/promo_groups.py b/app/handlers/admin/promo_groups.py index 337c9c83..640ac4ed 100644 --- a/app/handlers/admin/promo_groups.py +++ b/app/handlers/admin/promo_groups.py @@ -1,3 +1,4 @@ +import html from decimal import ROUND_HALF_UP, Decimal, InvalidOperation import structlog @@ -293,7 +294,7 @@ def _build_edit_menu_content( header = texts.t( 'ADMIN_PROMO_GROUP_EDIT_MENU_TITLE', '✏️ Настройки промогруппы «{name}»', - ).format(name=group.name) + ).format(name=html.escape(group.name)) lines = [header] lines.extend(_format_discount_lines(texts, group)) @@ -468,7 +469,7 @@ async def show_promo_groups_menu( 'ADMIN_PROMO_GROUPS_MEMBERS_COUNT', 'Участников: {count}', ).format(count=member_count) - lines.append(f'{icon} {group.name}{default_suffix} — {members_label}') + lines.append(f'{icon} {html.escape(group.name)}{default_suffix} — {members_label}') keyboard_rows.append( [ types.InlineKeyboardButton( @@ -524,7 +525,7 @@ async def show_promo_group_details( texts.t( 'ADMIN_PROMO_GROUP_DETAILS_TITLE', '💳 Промогруппа: {name}', - ).format(name=group.name) + ).format(name=html.escape(group.name)) ] lines.extend(_format_discount_lines(texts, group)) lines.append(_format_auto_assign_line(texts, group)) @@ -802,7 +803,7 @@ async def process_create_group_auto_assign( await state.clear() await message.answer( - texts.t('ADMIN_PROMO_GROUP_CREATED', 'Промогруппа «{name}» создана.').format(name=group.name), + texts.t('ADMIN_PROMO_GROUP_CREATED', 'Промогруппа «{name}» создана.').format(name=html.escape(group.name)), reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[ [ @@ -875,7 +876,7 @@ async def prompt_edit_promo_group_field( prompt = texts.t( 'ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT', 'Введите новое название промогруппы (текущее: {name}):', - ).format(name=group.name) + ).format(name=html.escape(group.name)) elif field == 'priority': await state.set_state(AdminStates.editing_promo_group_priority) prompt = texts.t( @@ -951,7 +952,7 @@ async def process_edit_group_name( texts, group, data.get('language', db_user.language), - texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name), + texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)), ) @@ -993,7 +994,7 @@ async def process_edit_group_priority( texts, group, data.get('language', db_user.language), - texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name), + texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)), ) @@ -1028,7 +1029,7 @@ async def process_edit_group_traffic( texts, group, data.get('language', db_user.language), - texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name), + texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)), ) @@ -1063,7 +1064,7 @@ async def process_edit_group_servers( texts, group, data.get('language', db_user.language), - texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name), + texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)), ) @@ -1098,7 +1099,7 @@ async def process_edit_group_devices( texts, group, data.get('language', db_user.language), - texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name), + texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)), ) @@ -1138,7 +1139,7 @@ async def process_edit_group_period_discounts( texts, group, data.get('language', db_user.language), - texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name), + texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)), ) @@ -1182,7 +1183,7 @@ async def process_edit_group_auto_assign( texts, group, data.get('language', db_user.language), - texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=group.name), + texts.t('ADMIN_PROMO_GROUP_UPDATED', 'Промогруппа «{name}» обновлена.').format(name=html.escape(group.name)), ) @@ -1212,19 +1213,20 @@ async def show_promo_group_members( title = texts.t( 'ADMIN_PROMO_GROUP_MEMBERS_TITLE', '👥 Участники группы {name}', - ).format(name=group.name) + ).format(name=html.escape(group.name)) if not members: body = texts.t('ADMIN_PROMO_GROUP_MEMBERS_EMPTY', 'В этой группе пока нет участников.') else: lines = [] for index, user in enumerate(members, start=offset + 1): - username = f'@{user.username}' if user.username else '—' + username = f'@{html.escape(user.username)}' if user.username else '—' + safe_name = html.escape(user.full_name or '') if user.telegram_id: - user_link = f'{user.full_name}' + user_link = f'{safe_name}' tg_display = str(user.telegram_id) else: - user_link = f'{user.full_name}' + user_link = f'{safe_name}' tg_display = user.email or f'#{user.id}' lines.append(f'{index}. {user_link} (ID {user.id}, {username}, TG {tg_display})') body = '\n'.join(lines) @@ -1273,7 +1275,7 @@ async def request_delete_promo_group( confirm_text = texts.t( 'ADMIN_PROMO_GROUP_DELETE_CONFIRM', 'Удалить промогруппу «{name}»? Все пользователи будут переведены в базовую группу.', - ).format(name=group.name) + ).format(name=html.escape(group.name)) await callback.message.edit_text( confirm_text, @@ -1308,7 +1310,7 @@ async def delete_promo_group_confirmed( return await callback.message.edit_text( - texts.t('ADMIN_PROMO_GROUP_DELETED', 'Промогруппа «{name}» удалена.').format(name=group.name), + texts.t('ADMIN_PROMO_GROUP_DELETED', 'Промогруппа «{name}» удалена.').format(name=html.escape(group.name)), reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data='admin_promo_groups')]] ), diff --git a/app/handlers/admin/promo_offers.py b/app/handlers/admin/promo_offers.py index e614af0e..8d08674b 100644 --- a/app/handlers/admin/promo_offers.py +++ b/app/handlers/admin/promo_offers.py @@ -677,7 +677,7 @@ def _describe_offer( label = texts.t(config.get('label_key', ''), config.get('default_label', template.offer_type)) icon = config.get('icon', '📨') - lines = [f'{icon} {template.name}', ''] + lines = [f'{icon} {html.escape(template.name)}', ''] lines.append(texts.t('ADMIN_PROMO_OFFER_TYPE', 'Тип: {label}').format(label=label)) lines.append(texts.t('ADMIN_PROMO_OFFER_VALID', 'Срок действия: {hours} ч').format(hours=template.valid_hours)) diff --git a/app/handlers/admin/promocodes.py b/app/handlers/admin/promocodes.py index 60c6ff55..d35a1614 100644 --- a/app/handlers/admin/promocodes.py +++ b/app/handlers/admin/promocodes.py @@ -1,3 +1,4 @@ +import html from datetime import UTC, datetime, timedelta import structlog @@ -94,7 +95,7 @@ async def show_promocodes_list(callback: types.CallbackQuery, db_user: User, db: text += f'📅 Дней: {promo.subscription_days}\n' elif promo.type == PromoCodeType.PROMO_GROUP.value: if promo.promo_group: - text += f'🏷️ Промогруппа: {promo.promo_group.name}\n' + text += f'🏷️ Промогруппа: {html.escape(promo.promo_group.name)}\n' elif promo.type == PromoCodeType.DISCOUNT.value: discount_hours = promo.subscription_days if discount_hours > 0: @@ -170,7 +171,7 @@ async def show_promocode_management(callback: types.CallbackQuery, db_user: User text += f'📅 Дней: {promo.subscription_days}\n' elif promo.type == PromoCodeType.PROMO_GROUP.value: if promo.promo_group: - text += f'🏷️ Промогруппа: {promo.promo_group.name} (приоритет: {promo.promo_group.priority})\n' + text += f'🏷️ Промогруппа: {html.escape(promo.promo_group.name)} (приоритет: {promo.promo_group.priority})\n' elif promo.promo_group_id: text += f'🏷️ Промогруппа ID: {promo.promo_group_id} (не найдена)\n' elif promo.type == PromoCodeType.DISCOUNT.value: @@ -472,7 +473,9 @@ async def process_promocode_code(message: types.Message, db_user: User, state: F text = f'🏷️ Промокод: {code}\n\nВыберите промогруппу для назначения:\n\n' for promo_group, user_count in groups_with_counts: - text += f'• {promo_group.name} (приоритет: {promo_group.priority}, пользователей: {user_count})\n' + text += ( + f'• {html.escape(promo_group.name)} (приоритет: {promo_group.priority}, пользователей: {user_count})\n' + ) keyboard.append( [ types.InlineKeyboardButton( @@ -509,7 +512,7 @@ async def process_promo_group_selection( await callback.message.edit_text( f'🏷️ Промокод для промогруппы\n\n' - f'Промогруппа: {promo_group.name}\n' + f'Промогруппа: {html.escape(promo_group.name)}\n' f'Приоритет: {promo_group.priority}\n\n' f'📊 Введите количество использований промокода (или 0 для безлимита):' ) @@ -1039,9 +1042,9 @@ async def show_promocode_stats(callback: types.CallbackQuery, db_user: User, db: use_date = format_datetime(use.used_at) if hasattr(use, 'user_username') and use.user_username: - user_display = f'@{use.user_username}' + user_display = f'@{html.escape(use.user_username)}' elif hasattr(use, 'user_full_name') and use.user_full_name: - user_display = use.user_full_name + user_display = html.escape(use.user_full_name) elif hasattr(use, 'user_telegram_id'): user_display = f'ID{use.user_telegram_id}' else: diff --git a/app/handlers/admin/referrals.py b/app/handlers/admin/referrals.py index 65e679c4..8776302a 100644 --- a/app/handlers/admin/referrals.py +++ b/app/handlers/admin/referrals.py @@ -1,4 +1,5 @@ import asyncio +import html import json from datetime import UTC, datetime, timedelta @@ -218,9 +219,9 @@ async def _show_top_referrers_filtered(callback: types.CallbackQuery, db: AsyncS id_display = telegram_id or user_email or f'#{user_id}' if user_id else 'N/A' if username: - display_text = f'@{username} (ID{id_display})' + display_text = f'@{html.escape(username)} (ID{id_display})' elif display_name and display_name != f'ID{id_display}': - display_text = f'{display_name} (ID{id_display})' + display_text = f'{html.escape(display_name)} (ID{id_display})' else: display_text = f'ID{id_display}' @@ -312,7 +313,7 @@ async def show_pending_withdrawal_requests(callback: types.CallbackQuery, db_use for req in requests[:10]: user = await get_user_by_id(db, req.user_id) - user_name = user.full_name if user else 'Неизвестно' + user_name = html.escape(user.full_name) if user and user.full_name else 'Неизвестно' user_tg_id = user.telegram_id if user else 'N/A' risk_emoji = ( @@ -359,7 +360,7 @@ async def view_withdrawal_request(callback: types.CallbackQuery, db_user: User, return user = await get_user_by_id(db, request.user_id) - user_name = user.full_name if user else 'Неизвестно' + user_name = html.escape(user.full_name) if user and user.full_name else 'Неизвестно' user_tg_id = (user.telegram_id or user.email or f'#{user.id}') if user else 'N/A' analysis = json.loads(request.risk_analysis) if request.risk_analysis else {} @@ -381,7 +382,7 @@ async def view_withdrawal_request(callback: types.CallbackQuery, db_user: User, 📊 Статус: {status_text} 💳 Реквизиты: -{request.payment_details} +{html.escape(request.payment_details or '')} 📅 Создана: {request.created_at.strftime('%d.%m.%Y %H:%M')} @@ -639,7 +640,7 @@ async def process_test_referral_earning(message: types.Message, db_user: User, d await message.answer( f'✅ Тестовое начисление создано!\n\n' - f'👤 Пользователь: {target_user.full_name or "Без имени"}\n' + f'👤 Пользователь: {html.escape(target_user.full_name) if target_user.full_name else "Без имени"}\n' f'🆔 ID: {target_telegram_id}\n' f'💰 Сумма: {amount_rubles:.0f}₽\n' f'💳 Новый баланс: {target_user.balance_kopeks / 100:.0f}₽\n\n' @@ -736,14 +737,17 @@ async def _show_diagnostics_for_period(callback: types.CallbackQuery, db: AsyncS status = f'⚡ Другой реферер (ID{lost.current_referrer_id})' # Имя или ID - user_name = lost.username or lost.full_name or f'ID{lost.telegram_id}' if lost.username: - user_name = f'@{lost.username}' + user_name = f'@{html.escape(lost.username)}' + elif lost.full_name: + user_name = html.escape(lost.full_name) + else: + user_name = f'ID{lost.telegram_id}' # Ожидаемый реферер referrer_info = '' if lost.expected_referrer_name: - referrer_info = f' → {lost.expected_referrer_name}' + referrer_info = f' → {html.escape(lost.expected_referrer_name)}' elif lost.expected_referrer_id: referrer_info = f' → ID{lost.expected_referrer_id}' @@ -751,7 +755,7 @@ async def _show_diagnostics_for_period(callback: types.CallbackQuery, db: AsyncS time_str = lost.click_time.strftime('%H:%M') text += f'{i}. {user_name} — {status}\n' - text += f' {lost.referral_code}{referrer_info} ({time_str})\n' + text += f' {html.escape(lost.referral_code)}{referrer_info} ({time_str})\n' if len(report.lost_referrals) > 15: text += f'\n... и ещё {len(report.lost_referrals) - 15}\n' @@ -872,16 +876,22 @@ async def preview_referral_fixes(callback: types.CallbackQuery, db_user: User, d # Показываем первые 10 деталей for i, detail in enumerate(fix_report.details[:10], 1): - user_name = detail.username or detail.full_name or f'ID{detail.telegram_id}' if detail.username: - user_name = f'@{detail.username}' + user_name = f'@{html.escape(detail.username)}' + elif detail.full_name: + user_name = html.escape(detail.full_name) + else: + user_name = f'ID{detail.telegram_id}' if detail.error: - text += f'{i}. {user_name} — ❌ {detail.error}\n' + text += f'{i}. {user_name} — ❌ {html.escape(str(detail.error))}\n' else: text += f'{i}. {user_name}\n' if detail.referred_by_set: - text += f' • Реферер: {detail.referrer_name or f"ID{detail.referrer_id}"}\n' + referrer_display = ( + html.escape(detail.referrer_name) if detail.referrer_name else f'ID{detail.referrer_id}' + ) + text += f' • Реферер: {referrer_display}\n' if detail.had_first_topup: text += f' • Первое пополнение: {settings.format_price(detail.topup_amount_kopeks)}\n' if detail.bonus_to_referral_kopeks > 0: @@ -967,13 +977,19 @@ async def apply_referral_fixes(callback: types.CallbackQuery, db_user: User, db: for detail in fix_report.details: if not detail.error and success_count < 10: success_count += 1 - user_name = detail.username or detail.full_name or f'ID{detail.telegram_id}' if detail.username: - user_name = f'@{user_name}' + user_name = f'@{html.escape(detail.username)}' + elif detail.full_name: + user_name = html.escape(detail.full_name) + else: + user_name = f'ID{detail.telegram_id}' text += f'{success_count}. {user_name}\n' if detail.referred_by_set: - text += f' • Реферер: {detail.referrer_name or f"ID{detail.referrer_id}"}\n' + referrer_display = ( + html.escape(detail.referrer_name) if detail.referrer_name else f'ID{detail.referrer_id}' + ) + text += f' • Реферер: {referrer_display}\n' if detail.bonus_to_referral_kopeks > 0: text += f' • Бонус рефералу: {settings.format_price(detail.bonus_to_referral_kopeks)}\n' if detail.bonus_to_referrer_kopeks > 0: @@ -989,8 +1005,13 @@ async def apply_referral_fixes(callback: types.CallbackQuery, db_user: User, db: for detail in fix_report.details: if detail.error and error_count < 5: error_count += 1 - user_name = detail.username or detail.full_name or f'ID{detail.telegram_id}' - text += f'• {user_name}: {detail.error}\n' + if detail.username: + user_name = f'@{html.escape(detail.username)}' + elif detail.full_name: + user_name = html.escape(detail.full_name) + else: + user_name = f'ID{detail.telegram_id}' + text += f'• {user_name}: {html.escape(str(detail.error))}\n' if fix_report.errors > 5: text += f'... и ещё {fix_report.errors - 5} ошибок\n' @@ -1055,8 +1076,12 @@ async def check_missing_bonuses(callback: types.CallbackQuery, db_user: User, db 👤 Список ({len(report.missing_bonuses)} чел.): """ for i, mb in enumerate(report.missing_bonuses[:15], 1): - referral_name = mb.referral_full_name or mb.referral_username or str(mb.referral_telegram_id) - referrer_name = mb.referrer_full_name or mb.referrer_username or str(mb.referrer_telegram_id) + referral_name = html.escape( + mb.referral_full_name or mb.referral_username or str(mb.referral_telegram_id) + ) + referrer_name = html.escape( + mb.referrer_full_name or mb.referrer_username or str(mb.referrer_telegram_id) + ) text += f'\n{i}. {referral_name}' text += f'\n └ Пригласил: {referrer_name}' text += f'\n └ Пополнение: {mb.first_topup_amount_kopeks / 100:.0f}₽' @@ -1191,9 +1216,9 @@ async def sync_referrals_with_contest( total_created += stats.get('created', 0) total_updated += stats.get('updated', 0) total_skipped += stats.get('skipped', 0) - contest_results.append(f'• {contest.title}: +{stats.get("created", 0)} новых') + contest_results.append(f'• {html.escape(contest.title)}: +{stats.get("created", 0)} новых') else: - contest_results.append(f'• {contest.title}: ошибка') + contest_results.append(f'• {html.escape(contest.title)}: ошибка') text = f""" 🏆 Синхронизация с конкурсами завершена! @@ -1275,7 +1300,7 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi if file_ext not in ['.log', '.txt']: await message.answer( - f'❌ Неверный формат файла: {file_ext}\n\nПоддерживаются только текстовые файлы (.log, .txt)', + f'❌ Неверный формат файла: {html.escape(file_ext)}\n\nПоддерживаются только текстовые файлы (.log, .txt)', reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[ [types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_referral_diagnostics')] @@ -1299,7 +1324,7 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi # Информируем о начале загрузки status_message = await message.answer( - f'📥 Загружаю файл {file_name} ({message.document.file_size / 1024 / 1024:.1f} MB)...' + f'📥 Загружаю файл {html.escape(file_name)} ({message.document.file_size / 1024 / 1024:.1f} MB)...' ) temp_file_path = None @@ -1316,7 +1341,9 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi logger.info('📥 Файл загружен: ( байт)', temp_file_path=temp_file_path, file_size=message.document.file_size) # Обновляем статус - await status_message.edit_text(f'🔍 Анализирую файл {file_name}...\n\nЭто может занять некоторое время.') + await status_message.edit_text( + f'🔍 Анализирую файл {html.escape(file_name)}...\n\nЭто может занять некоторое время.' + ) # Анализируем файл from app.services.referral_diagnostics_service import referral_diagnostics_service @@ -1325,7 +1352,7 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi # Формируем отчёт text = f""" -🔍 Анализ лог-файла: {file_name} +🔍 Анализ лог-файла: {html.escape(file_name)} 📊 Статистика переходов: • Всего кликов по реф-ссылкам: {report.total_ref_clicks} @@ -1348,14 +1375,17 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi status = f'⚡ Другой реферер (ID{lost.current_referrer_id})' # Имя или ID - user_name = lost.username or lost.full_name or f'ID{lost.telegram_id}' if lost.username: - user_name = f'@{lost.username}' + user_name = f'@{html.escape(lost.username)}' + elif lost.full_name: + user_name = html.escape(lost.full_name) + else: + user_name = f'ID{lost.telegram_id}' # Ожидаемый реферер referrer_info = '' if lost.expected_referrer_name: - referrer_info = f' → {lost.expected_referrer_name}' + referrer_info = f' → {html.escape(lost.expected_referrer_name)}' elif lost.expected_referrer_id: referrer_info = f' → ID{lost.expected_referrer_id}' @@ -1363,7 +1393,7 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi time_str = lost.click_time.strftime('%d.%m.%Y %H:%M') text += f'{i}. {user_name} — {status}\n' - text += f' {lost.referral_code}{referrer_info} ({time_str})\n' + text += f' {html.escape(lost.referral_code)}{referrer_info} ({time_str})\n' if len(report.lost_referrals) > 15: text += f'\n... и ещё {len(report.lost_referrals) - 15}\n' @@ -1408,8 +1438,8 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi try: await status_message.edit_text( f'❌ Ошибка при анализе файла\n\n' - f'Файл: {file_name}\n' - f'Ошибка: {e!s}\n\n' + f'Файл: {html.escape(file_name)}\n' + f'Ошибка: {html.escape(str(e))}\n\n' f'Проверьте, что файл является текстовым логом бота.', reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[ @@ -1428,7 +1458,7 @@ async def receive_log_file(message: types.Message, db_user: User, db: AsyncSessi ) except: await message.answer( - f'❌ Ошибка при анализе файла: {e!s}', + f'❌ Ошибка при анализе файла: {html.escape(str(e))}', reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[ [types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_referral_diagnostics')] diff --git a/app/handlers/admin/tariffs.py b/app/handlers/admin/tariffs.py index 27b39e45..866bf096 100644 --- a/app/handlers/admin/tariffs.py +++ b/app/handlers/admin/tariffs.py @@ -1,5 +1,7 @@ """Управление тарифами в админ-панели.""" +import html + import structlog from aiogram import Dispatcher, F, types from aiogram.exceptions import TelegramBadRequest @@ -317,7 +319,7 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st price_block = f'Цены:\n{prices_display}' tariff_type = '📅 Периодный' - return f"""📦 Тариф: {tariff.name} + return f"""📦 Тариф: {html.escape(tariff.name)} {status} | {tariff_type} 🎚️ Уровень: {tariff.tier_level} @@ -343,7 +345,7 @@ def format_tariff_info(tariff: Tariff, language: str, subs_count: int = 0) -> st 📊 Подписок на тарифе: {subs_count} -{f'📝 {tariff.description}' if tariff.description else ''}""" +{f'📝 {html.escape(tariff.description)}' if tariff.description else ''}""" @admin_required @@ -591,7 +593,7 @@ async def start_edit_daily_price( await callback.message.edit_text( f'💰 Редактирование суточной цены\n\n' - f'Тариф: {tariff.name}\n' + f'Тариф: {html.escape(tariff.name)}\n' f'Текущая цена: {format_price_kopeks(current_price)}/день\n\n' 'Введите новую цену за день в рублях.\n' 'Пример: 50 или 99.90', @@ -1011,7 +1013,7 @@ async def start_edit_tariff_name( await state.update_data(tariff_id=tariff_id, language=db_user.language) await callback.message.edit_text( - f'✏️ Редактирование названия\n\nТекущее название: {tariff.name}\n\nВведите новое название:', + f'✏️ Редактирование названия\n\nТекущее название: {html.escape(tariff.name)}\n\nВведите новое название:', reply_markup=InlineKeyboardMarkup( inline_keyboard=[[InlineKeyboardButton(text=texts.CANCEL, callback_data=f'admin_tariff_view:{tariff_id}')]] ), @@ -1801,7 +1803,7 @@ async def start_edit_tariff_traffic_topup( buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data=f'admin_tariff_view:{tariff_id}')]) await callback.message.edit_text( - f'📈 Докупка трафика для «{tariff.name}»\n\n' + f'📈 Докупка трафика для «{html.escape(tariff.name)}»\n\n' f'Статус: {status}\n\n' f'Пакеты:\n{packages_display}\n\n' f'Макс. лимит: {max_limit_display}\n\n' @@ -1887,7 +1889,7 @@ async def toggle_tariff_traffic_topup( try: await callback.message.edit_text( - f'📈 Докупка трафика для «{tariff.name}»\n\n' + f'📈 Докупка трафика для «{html.escape(tariff.name)}»\n\n' f'Статус: {status}\n\n' f'Пакеты:\n{packages_display}\n\n' f'Макс. лимит: {max_limit_display}\n\n' @@ -1931,7 +1933,7 @@ async def start_edit_traffic_topup_packages( await callback.message.edit_text( f'📦 Настройка пакетов докупки трафика\n\n' - f'Тариф: {tariff.name}\n\n' + f'Тариф: {html.escape(tariff.name)}\n\n' f'Текущие пакеты:\n{packages_display}\n\n' 'Введите пакеты в формате:\n' f'{current_packages}\n\n' @@ -2010,7 +2012,7 @@ async def process_edit_traffic_topup_packages( await message.answer( f'✅ Пакеты обновлены!\n\n' - f'📈 Докупка трафика для «{tariff.name}»\n\n' + f'📈 Докупка трафика для «{html.escape(tariff.name)}»\n\n' f'Статус: ✅ Включено\n\n' f'Пакеты:\n{packages_display}\n\n' f'Макс. лимит: {max_limit_display}\n\n' @@ -2051,7 +2053,7 @@ async def start_edit_max_topup_traffic( await callback.message.edit_text( f'📊 Максимальный лимит трафика\n\n' - f'Тариф: {tariff.name}\n' + f'Тариф: {html.escape(tariff.name)}\n' f'Текущий лимит: {current_display}\n\n' f'Введите максимальный общий объем трафика (в ГБ), который может быть на подписке после всех докупок.\n\n' f'• Например, если тариф дает 100 ГБ и лимит 200 ГБ — пользователь сможет докупить еще 100 ГБ\n' @@ -2127,7 +2129,7 @@ async def process_edit_max_topup_traffic( await message.answer( f'✅ Лимит обновлен!\n\n' - f'📈 Докупка трафика для «{tariff.name}»\n\n' + f'📈 Докупка трафика для «{html.escape(tariff.name)}»\n\n' f'Статус: ✅ Включено\n\n' f'Пакеты:\n{packages_display}\n\n' f'Макс. лимит: {max_limit_display}\n\n' @@ -2163,7 +2165,7 @@ async def confirm_delete_tariff( warning = f'\n\n⚠️ Внимание! На этом тарифе {subs_count} подписок.\nОни будут отвязаны от тарифа.' await callback.message.edit_text( - f'🗑️ Удаление тарифа\n\nВы действительно хотите удалить тариф {tariff.name}?{warning}', + f'🗑️ Удаление тарифа\n\nВы действительно хотите удалить тариф {html.escape(tariff.name)}?{warning}', reply_markup=InlineKeyboardMarkup( inline_keyboard=[ [ @@ -2278,7 +2280,7 @@ async def start_edit_tariff_squads( selected_count = len(current_squads) await callback.message.edit_text( - f'🌐 Серверы для тарифа «{tariff.name}»\n\n' + f'🌐 Серверы для тарифа «{html.escape(tariff.name)}»\n\n' f'Выбрано: {selected_count} из {len(squads)}\n\n' 'Если не выбран ни один сервер - доступны все.\n' 'Нажмите на сервер для выбора/отмены:', @@ -2341,7 +2343,7 @@ async def toggle_tariff_squad( try: await callback.message.edit_text( - f'🌐 Серверы для тарифа «{tariff.name}»\n\n' + f'🌐 Серверы для тарифа «{html.escape(tariff.name)}»\n\n' f'Выбрано: {len(current_squads)} из {len(squads)}\n\n' 'Если не выбран ни один сервер - доступны все.\n' 'Нажмите на сервер для выбора/отмены:', @@ -2406,7 +2408,7 @@ async def clear_tariff_squads( try: await callback.message.edit_text( - f'🌐 Серверы для тарифа «{tariff.name}»\n\n' + f'🌐 Серверы для тарифа «{html.escape(tariff.name)}»\n\n' f'Выбрано: 0 из {len(squads)}\n\n' 'Если не выбран ни один сервер - доступны все.\n' 'Нажмите на сервер для выбора/отмены:', @@ -2470,7 +2472,7 @@ async def select_all_tariff_squads( try: await callback.message.edit_text( - f'🌐 Серверы для тарифа «{tariff.name}»\n\n' + f'🌐 Серверы для тарифа «{html.escape(tariff.name)}»\n\n' f'Выбрано: {len(squads)} из {len(squads)}\n\n' 'Если не выбран ни один сервер - доступны все.\n' 'Нажмите на сервер для выбора/отмены:', @@ -2540,7 +2542,7 @@ async def start_edit_tariff_promo_groups( selected_count = len(current_groups) await callback.message.edit_text( - f'👥 Промогруппы для тарифа «{tariff.name}»\n\n' + f'👥 Промогруппы для тарифа «{html.escape(tariff.name)}»\n\n' f'Выбрано: {selected_count}\n\n' 'Если не выбрана ни одна группа - тариф доступен всем.\n' 'Выберите группы, которым доступен этот тариф:', @@ -2608,7 +2610,7 @@ async def toggle_tariff_promo_group( try: await callback.message.edit_text( - f'👥 Промогруппы для тарифа «{tariff.name}»\n\n' + f'👥 Промогруппы для тарифа «{html.escape(tariff.name)}»\n\n' f'Выбрано: {len(current_groups)}\n\n' 'Если не выбрана ни одна группа - тариф доступен всем.\n' 'Выберите группы, которым доступен этот тариф:', @@ -2665,7 +2667,7 @@ async def clear_tariff_promo_groups( try: await callback.message.edit_text( - f'👥 Промогруппы для тарифа «{tariff.name}»\n\n' + f'👥 Промогруппы для тарифа «{html.escape(tariff.name)}»\n\n' f'Выбрано: 0\n\n' 'Если не выбрана ни одна группа - тариф доступен всем.\n' 'Выберите группы, которым доступен этот тариф:', @@ -2731,7 +2733,7 @@ async def start_edit_traffic_reset_mode( current_mode = getattr(tariff, 'traffic_reset_mode', None) await callback.message.edit_text( - f'🔄 Режим сброса трафика для тарифа «{tariff.name}»\n\n' + f'🔄 Режим сброса трафика для тарифа «{html.escape(tariff.name)}»\n\n' f'Текущий режим: {_format_traffic_reset_mode(current_mode)}\n\n' 'Выберите, когда сбрасывать использованный трафик у подписчиков этого тарифа:\n\n' '• Глобальная настройка — использовать значение из конфига бота\n' @@ -2775,7 +2777,7 @@ async def set_traffic_reset_mode( # Обновляем клавиатуру await callback.message.edit_text( - f'🔄 Режим сброса трафика для тарифа «{tariff.name}»\n\n' + f'🔄 Режим сброса трафика для тарифа «{html.escape(tariff.name)}»\n\n' f'Текущий режим: {mode_display}\n\n' 'Выберите, когда сбрасывать использованный трафик у подписчиков этого тарифа:\n\n' '• Глобальная настройка — использовать значение из конфига бота\n' diff --git a/app/handlers/admin/tickets.py b/app/handlers/admin/tickets.py index ac4c8a2e..caf714cc 100644 --- a/app/handlers/admin/tickets.py +++ b/app/handlers/admin/tickets.py @@ -232,8 +232,10 @@ async def view_admin_ticket( TicketStatus.PENDING.value: texts.t('TICKET_STATUS_PENDING', 'В ожидании'), }.get(ticket.status, ticket.status) - user_name = ticket.user.full_name if ticket.user else 'Unknown' - telegram_id_display = (ticket.user.telegram_id or ticket.user.email or f'#{ticket.user.id}') if ticket.user else '—' + user_name = html.escape(ticket.user.full_name) if ticket.user else 'Unknown' + telegram_id_display = ( + html.escape(str(ticket.user.telegram_id or ticket.user.email or f'#{ticket.user.id}')) if ticket.user else '—' + ) username_value = ticket.user.username if ticket.user else None id_label = 'Telegram ID' if (ticket.user and ticket.user.telegram_id) else 'ID' @@ -245,7 +247,7 @@ async def view_admin_ticket( header += f'📱 Username: @{safe_username}\n' else: header += '📱 Username: отсутствует\n' - header += f'📝 Заголовок: {ticket.title}\n' + header += f'📝 Заголовок: {html.escape(ticket.title)}\n' header += f'📊 Статус: {ticket.status_emoji} {status_text}\n' header += f'📅 Создан: {ticket.created_at.strftime("%d.%m.%Y %H:%M")}\n\n' @@ -261,7 +263,7 @@ async def view_admin_ticket( message_blocks.append(f'💬 Сообщения ({len(ticket.messages)}):\n\n') for msg in ticket.messages: sender = '👤 Пользователь' if msg.is_user_message else '🛠️ Поддержка' - block = f'{sender} ({msg.created_at.strftime("%d.%m %H:%M")}):\n{msg.message_text}\n\n' + block = f'{sender} ({msg.created_at.strftime("%d.%m %H:%M")}):\n{html.escape(msg.message_text)}\n\n' if getattr(msg, 'has_media', False) and getattr(msg, 'media_type', None) == 'photo': block += '📎 Вложение: фото\n\n' message_blocks.append(block) @@ -801,10 +803,10 @@ async def handle_admin_block_duration_input(message: types.Message, state: FSMCo TicketStatus.CLOSED.value: texts.t('TICKET_STATUS_CLOSED', 'Закрыт'), TicketStatus.PENDING.value: texts.t('TICKET_STATUS_PENDING', 'В ожидании'), }.get(updated.status, updated.status) - user_name = updated.user.full_name if updated.user else 'Unknown' + user_name = html.escape(updated.user.full_name) if updated.user else 'Unknown' ticket_text = f'🎫 Тикет #{updated.id}\n\n' ticket_text += f'👤 Пользователь: {user_name}\n' - ticket_text += f'📝 Заголовок: {updated.title}\n' + ticket_text += f'📝 Заголовок: {html.escape(updated.title)}\n' ticket_text += f'📊 Статус: {updated.status_emoji} {status_text}\n' ticket_text += f'📅 Создан: {updated.created_at.strftime("%d.%m.%Y %H:%M")}\n' ticket_text += f'🔄 Обновлен: {updated.updated_at.strftime("%d.%m.%Y %H:%M")}\n' @@ -823,7 +825,7 @@ async def handle_admin_block_duration_input(message: types.Message, state: FSMCo ticket_text += f'🔗 Чат по ID: {chat_link}\n' elif updated.user: # Email-only user - user_id_display = updated.user.email or f'#{updated.user.id}' + user_id_display = html.escape(str(updated.user.email or f'#{updated.user.id}')) ticket_text += f'🆔 ID: {user_id_display}\n' ticket_text += '📧 Тип: Email-пользователь\n' ticket_text += '\n' @@ -837,7 +839,7 @@ async def handle_admin_block_duration_input(message: types.Message, state: FSMCo for msg in updated.messages: sender = '👤 Пользователь' if msg.is_user_message else '🛠️ Поддержка' ticket_text += f'{sender} ({msg.created_at.strftime("%d.%m %H:%M")}):\n' - ticket_text += f'{msg.message_text}\n\n' + ticket_text += f'{html.escape(msg.message_text)}\n\n' if getattr(msg, 'has_media', False) and getattr(msg, 'media_type', None) == 'photo': ticket_text += '📎 Вложение: фото\n\n' diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index 103151e9..182d30af 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -47,6 +47,7 @@ from app.services.user_service import UserService from app.states import AdminStates from app.utils.decorators import admin_required, error_handler from app.utils.formatters import format_datetime, format_time_ago +from app.utils.formatting import user_html_link from app.utils.subscription_utils import ( resolve_hwid_device_limit_for_payload, ) @@ -822,12 +823,8 @@ async def _render_user_subscription_overview(callback: types.CallbackQuery, db: subscription = profile['subscription'] text = '📱 Подписка и настройки пользователя\n\n' - if user.telegram_id: - user_link = f'{user.full_name}' - user_id_display = user.telegram_id - else: - user_link = f'{user.full_name}' - user_id_display = user.email or f'#{user.id}' + user_link = user_html_link(user) + user_id_display = user.telegram_id or user.email or f'#{user.id}' text += f'👤 {user_link} (ID: {user_id_display})\n\n' keyboard = [] @@ -849,7 +846,7 @@ async def _render_user_subscription_overview(callback: types.CallbackQuery, db: if subscription.tariff_id: tariff = await get_tariff_by_id(db, subscription.tariff_id) if tariff: - text += f'Тариф: 📦 {tariff.name}\n' + text += f'Тариф: 📦 {html.escape(tariff.name)}\n' else: text += f'Тариф: ID {subscription.tariff_id} (удалён)\n' @@ -961,12 +958,8 @@ async def show_user_transactions(callback: types.CallbackQuery, db_user: User, d transactions = await get_user_transactions(db, user_id, limit=10) text = '💳 Транзакции пользователя\n\n' - if user.telegram_id: - user_link = f'{user.full_name}' - user_id_display = user.telegram_id - else: - user_link = f'{user.full_name}' - user_id_display = user.email or f'#{user.id}' + user_link = user_html_link(user) + user_id_display = user.telegram_id or user.email or f'#{user.id}' text += f'👤 {user_link} (ID: {user_id_display})\n' text += f'💰 Текущий баланс: {settings.format_price(user.balance_kopeks)}\n\n' @@ -976,7 +969,7 @@ async def show_user_transactions(callback: types.CallbackQuery, db_user: User, d for transaction in transactions: type_emoji = '📈' if transaction.amount_kopeks > 0 else '📉' text += f'{type_emoji} {settings.format_price(abs(transaction.amount_kopeks))}\n' - text += f'📋 {transaction.description}\n' + text += f'📋 {html.escape(transaction.description or "")}\n' text += f'📅 {format_datetime(transaction.created_at)}\n\n' else: text += '📭 Транзакции отсутствуют' @@ -1057,7 +1050,7 @@ async def process_user_search(message: types.Message, db_user: User, state: FSMC if not search_results['users']: await message.answer( - f"🔍 По запросу '{query}' ничего не найдено", + f"🔍 По запросу '{html.escape(query)}' ничего не найдено", reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_users')]] ), @@ -1065,7 +1058,7 @@ async def process_user_search(message: types.Message, db_user: User, state: FSMC await state.clear() return - text = f"🔍 Результаты поиска: '{query}'\n\n" + text = f"🔍 Результаты поиска: '{html.escape(query)}'\n\n" text += 'Выберите пользователя:' keyboard = [] @@ -1175,7 +1168,7 @@ async def show_user_management(callback: types.CallbackQuery, db_user: User, db: sections = [ texts.ADMIN_USER_MANAGEMENT_PROFILE.format( - name=user.full_name, + name=html.escape(user.full_name), telegram_id=user.telegram_id, username=username_display, status=status_text, @@ -1223,11 +1216,11 @@ async def show_user_management(callback: types.CallbackQuery, db_user: User, db: texts.t( 'ADMIN_USER_PROMO_GROUPS_PRIMARY', '⭐ Основная: {name} (Priority: {priority})', - ).format(name=primary_group.name, priority=getattr(primary_group, 'priority', 0)) + ).format(name=html.escape(primary_group.name), priority=getattr(primary_group, 'priority', 0)) ) sections.append( texts.ADMIN_USER_MANAGEMENT_PROMO_GROUP.format( - name=primary_group.name, + name=html.escape(primary_group.name), server_discount=primary_group.server_discount_percent, traffic_discount=primary_group.traffic_discount_percent, device_discount=primary_group.device_discount_percent, @@ -1249,7 +1242,7 @@ async def show_user_management(callback: types.CallbackQuery, db_user: User, db: ) ) for group in additional_groups: - sections.append(f' • {group.name} (Priority: {getattr(group, "priority", 0)})') + sections.append(f' • {html.escape(group.name)} (Priority: {getattr(group, "priority", 0)})') else: sections.append(texts.ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE) @@ -1264,7 +1257,7 @@ async def show_user_management(callback: types.CallbackQuery, db_user: User, db: restriction_lines.append(' • 🚫 Продление/покупка запрещена') restriction_reason = getattr(user, 'restriction_reason', None) if restriction_reason: - restriction_lines.append(f' 📝 Причина: {restriction_reason}') + restriction_lines.append(f' 📝 Причина: {html.escape(restriction_reason)}') sections.append('\n'.join(restriction_lines)) text = '\n\n'.join(sections) @@ -1321,7 +1314,7 @@ async def _build_user_referrals_view( 'ADMIN_USER_REFERRALS_SUMMARY', '👤 {name} (ID: {telegram_id})\n👥 Всего рефералов: {count}', ).format( - name=user.full_name, + name=html.escape(user.full_name), telegram_id=user.telegram_id, count=len(referrals), ) @@ -1356,11 +1349,12 @@ async def _build_user_referrals_view( items = [] for referral in referrals[:limit]: username_part = f', @{referral.username}' if referral.username else '' + safe_name = html.escape(referral.full_name) if referral.telegram_id: - referral_link = f'{referral.full_name}' + referral_link = f'{safe_name}' referral_id_display = referral.telegram_id else: - referral_link = f'{referral.full_name}' + referral_link = f'{safe_name}' referral_id_display = referral.email or f'#{referral.id}' items.append( texts.t( @@ -1737,7 +1731,7 @@ async def start_edit_user_referrals( 'Или нажмите кнопку ниже, чтобы отменить.' ), ).format( - name=user.full_name, + name=html.escape(user.full_name), telegram_id=user.telegram_id, ) @@ -1972,7 +1966,7 @@ async def _render_user_promo_group(message: types.Message, language: str, user: current_line = texts.t( 'ADMIN_USER_PROMO_GROUPS_PRIMARY', '⭐ Основная: {name} (Priority: {priority})', - ).format(name=primary_group.name, priority=getattr(primary_group, 'priority', 0)) + ).format(name=html.escape(primary_group.name), priority=getattr(primary_group, 'priority', 0)) discount_line = texts.ADMIN_USER_PROMO_GROUP_DISCOUNTS.format( servers=primary_group.server_discount_percent, @@ -1997,7 +1991,7 @@ async def _render_user_promo_group(message: types.Message, language: str, user: + '\n' ) for group in additional_groups: - additional_line += f' • {group.name} (Priority: {getattr(group, "priority", 0)})\n' + additional_line += f' • {html.escape(group.name)} (Priority: {getattr(group, "priority", 0)})\n' discount_line += additional_line else: current_line = texts.t( @@ -2388,7 +2382,7 @@ async def show_user_restrictions(callback: types.CallbackQuery, db_user: User, d text_lines = [ '⚠️ Ограничения пользователя', - f'👤 {user.full_name}', + f'👤 {html.escape(user.full_name)}', '', '✅ — разрешено, 🚫 — запрещено', '', @@ -2398,7 +2392,7 @@ async def show_user_restrictions(callback: types.CallbackQuery, db_user: User, d if restriction_reason: text_lines.append('') - text_lines.append(f'📝 Причина: {restriction_reason}') + text_lines.append(f'📝 Причина: {html.escape(restriction_reason)}') keyboard = get_user_restrictions_keyboard( user_id=user_id, @@ -2479,7 +2473,7 @@ async def ask_restriction_reason(callback: types.CallbackQuery, db_user: User, d 'выполнить запрещённое действие.\n\n' ) if current_reason: - text += f'Текущая причина: {current_reason}\n\n' + text += f'Текущая причина: {html.escape(current_reason)}\n\n' text += 'Отправьте новую причину или /cancel для отмены:' await callback.message.edit_text( @@ -2525,12 +2519,12 @@ async def save_restriction_reason(message: types.Message, db_user: User, db: Asy '✅ Причина ограничения сохранена', '', '⚠️ Ограничения пользователя', - f'👤 {user.full_name}', + f'👤 {html.escape(user.full_name)}', '', f'{"🚫" if restriction_topup else "✅"} Пополнение баланса', f'{"🚫" if restriction_subscription else "✅"} Продление/покупка подписки', '', - f'📝 Причина: {reason}', + f'📝 Причина: {html.escape(reason)}', ] keyboard = get_user_restrictions_keyboard( @@ -2596,12 +2590,8 @@ async def show_inactive_users(callback: types.CallbackQuery, db_user: User, db: text += '\n' for user in inactive_users[:10]: - if user.telegram_id: - user_link = f'{user.full_name}' - user_id_display = user.telegram_id - else: - user_link = f'{user.full_name}' - user_id_display = user.email or f'#{user.id}' + user_link = user_html_link(user) + user_id_display = user.telegram_id or user.email or f'#{user.id}' has_active = user.subscription and user.subscription.is_active sub_badge = ' 🛡️' if has_active else '' text += f'👤 {user_link}{sub_badge}\n' @@ -2691,12 +2681,8 @@ async def show_user_statistics(callback: types.CallbackQuery, db_user: User, db: campaign_stats = await get_campaign_statistics(db, campaign_registration.campaign_id) text = '📊 Статистика пользователя\n\n' - if user.telegram_id: - user_link = f'{user.full_name}' - user_id_display = user.telegram_id - else: - user_link = f'{user.full_name}' - user_id_display = user.email or f'#{user.id}' + user_link = user_html_link(user) + user_id_display = user.telegram_id or user.email or f'#{user.id}' text += f'👤 {user_link} (ID: {user_id_display})\n\n' text += 'Основная информация:\n' @@ -2721,13 +2707,13 @@ async def show_user_statistics(callback: types.CallbackQuery, db_user: User, db: if user.referred_by_id: referrer = await get_user_by_id(db, user.referred_by_id) if referrer: - text += f'• Пришел по реферальной ссылке от {referrer.full_name}\n' + text += f'• Пришел по реферальной ссылке от {html.escape(referrer.full_name)}\n' else: text += '• Пришел по реферальной ссылке (реферер не найден)\n' if campaign_registration and campaign_registration.campaign: - text += f'• Дополнительно зарегистрирован через кампанию {campaign_registration.campaign.name}\n' + text += f'• Дополнительно зарегистрирован через кампанию {html.escape(campaign_registration.campaign.name)}\n' elif campaign_registration and campaign_registration.campaign: - text += f'• Регистрация через рекламную кампанию {campaign_registration.campaign.name}\n' + text += f'• Регистрация через рекламную кампанию {html.escape(campaign_registration.campaign.name)}\n' if campaign_registration.created_at: text += f'• Дата регистрации по кампании: {campaign_registration.created_at.strftime("%d.%m.%Y %H:%M")}\n' else: @@ -2737,7 +2723,7 @@ async def show_user_statistics(callback: types.CallbackQuery, db_user: User, db: if campaign_registration and campaign_registration.campaign and campaign_stats: text += 'Рекламная кампания:\n' - text += f'• Название: {campaign_registration.campaign.name}' + text += f'• Название: {html.escape(campaign_registration.campaign.name)}' if campaign_registration.campaign.start_parameter: text += f' (параметр: {campaign_registration.campaign.start_parameter})' text += '\n' @@ -2771,7 +2757,7 @@ async def show_user_statistics(callback: types.CallbackQuery, db_user: User, db: if referral_stats['referrals_detail']: text += '\nДетали по рефералам:\n' for detail in referral_stats['referrals_detail'][:5]: - referral_name = detail['referral_name'] + referral_name = html.escape(detail['referral_name']) earned = settings.format_price(detail['total_earned_kopeks']) status = '🟢' if detail['is_active'] else '🔴' text += f'• {status} {referral_name}: {earned}\n' @@ -4226,7 +4212,7 @@ async def change_subscription_type(callback: types.CallbackQuery, db_user: User, current_type = '🎁 Триал' if subscription.is_trial else '💎 Платная' text = '🔄 Смена типа подписки\n\n' - text += f'👤 {profile["user"].full_name}\n' + text += f'👤 {html.escape(profile["user"].full_name)}\n' text += f'📱 Текущий тип: {current_type}\n\n' text += 'Выберите новый тип подписки:' @@ -4307,12 +4293,8 @@ async def admin_buy_subscription(callback: types.CallbackQuery, db_user: User, d ) text = '💳 Покупка подписки для пользователя\n\n' - if target_user.telegram_id: - target_user_link = f'{target_user.full_name}' - target_user_id_display = target_user.telegram_id - else: - target_user_link = f'{target_user.full_name}' - target_user_id_display = target_user.email or f'#{target_user.id}' + target_user_link = user_html_link(target_user) + target_user_id_display = target_user.telegram_id or target_user.email or f'#{target_user.id}' text += f'👤 {target_user_link} (ID: {target_user_id_display})\n' text += f'💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n' traffic_text = 'Безлимит' if (subscription.traffic_limit_gb or 0) <= 0 else f'{subscription.traffic_limit_gb} ГБ' @@ -4400,12 +4382,8 @@ async def admin_buy_subscription_confirm(callback: types.CallbackQuery, db_user: return text = '💳 Подтверждение покупки подписки\n\n' - if target_user.telegram_id: - target_user_link = f'{target_user.full_name}' - target_user_id_display = target_user.telegram_id - else: - target_user_link = f'{target_user.full_name}' - target_user_id_display = target_user.email or f'#{target_user.id}' + target_user_link = user_html_link(target_user) + target_user_id_display = target_user.telegram_id or target_user.email or f'#{target_user.id}' text += f'👤 {target_user_link} (ID: {target_user_id_display})\n' text += f'📅 Период подписки: {period_days} дней\n' text += f'💰 Стоимость: {settings.format_price(price_kopeks)}\n' @@ -4646,12 +4624,8 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user: else: message = '❌ Ошибка: у пользователя нет существующей подписки' - if target_user.telegram_id: - target_user_link = f'{target_user.full_name}' - target_user_id_display = target_user.telegram_id - else: - target_user_link = f'{target_user.full_name}' - target_user_id_display = target_user.email or f'#{target_user.id}' + target_user_link = user_html_link(target_user) + target_user_id_display = target_user.telegram_id or target_user.email or f'#{target_user.id}' await callback.message.edit_text( f'{message}\n\n' f'👤 {target_user_link} (ID: {target_user_id_display})\n' @@ -4727,12 +4701,8 @@ async def admin_buy_tariff(callback: types.CallbackQuery, db_user: User, db: Asy await callback.answer() return - if target_user.telegram_id: - target_user_link = f'{target_user.full_name}' - target_user_id_display = target_user.telegram_id - else: - target_user_link = f'{target_user.full_name}' - target_user_id_display = target_user.email or f'#{target_user.id}' + target_user_link = user_html_link(target_user) + target_user_id_display = target_user.telegram_id or target_user.email or f'#{target_user.id}' text = '💳 Покупка тарифа для пользователя\n\n' text += f'👤 {target_user_link} (ID: {target_user_id_display})\n' text += f'💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n\n' @@ -4742,7 +4712,7 @@ async def admin_buy_tariff(callback: types.CallbackQuery, db_user: User, db: Asy traffic = '♾️' if tariff.traffic_limit_gb == 0 else f'{tariff.traffic_limit_gb} ГБ' prices = tariff.period_prices or {} min_price = min(prices.values()) if prices else 0 - text += f'{tariff.name} — {traffic} / {tariff.device_limit} 📱 от {settings.format_price(min_price)}\n' + text += f'{html.escape(tariff.name)} — {traffic} / {tariff.device_limit} 📱 от {settings.format_price(min_price)}\n' keyboard = [] for tariff in tariffs: @@ -4787,18 +4757,14 @@ async def admin_buy_tariff_period(callback: types.CallbackQuery, db_user: User, await callback.answer('❌ Тариф недоступен', show_alert=True) return - if target_user.telegram_id: - target_user_link = f'{target_user.full_name}' - target_user_id_display = target_user.telegram_id - else: - target_user_link = f'{target_user.full_name}' - target_user_id_display = target_user.email or f'#{target_user.id}' + target_user_link = user_html_link(target_user) + target_user_id_display = target_user.telegram_id or target_user.email or f'#{target_user.id}' traffic = '♾️ Безлимит' if tariff.traffic_limit_gb == 0 else f'{tariff.traffic_limit_gb} ГБ' text = '💳 Покупка тарифа для пользователя\n\n' text += f'👤 {target_user_link} (ID: {target_user_id_display})\n' text += f'💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n\n' - text += f'📦 Тариф: {tariff.name}\n' + text += f'📦 Тариф: {html.escape(tariff.name)}\n' text += f'📊 Трафик: {traffic}\n' text += f'📱 Устройств: {tariff.device_limit}\n' text += f'🌐 Серверов: {len(tariff.allowed_squads) if tariff.allowed_squads else 0}\n\n' @@ -4876,18 +4842,14 @@ async def admin_buy_tariff_confirm(callback: types.CallbackQuery, db_user: User, await callback.answer() return - if target_user.telegram_id: - target_user_link = f'{target_user.full_name}' - target_user_id_display = target_user.telegram_id - else: - target_user_link = f'{target_user.full_name}' - target_user_id_display = target_user.email or f'#{target_user.id}' + target_user_link = user_html_link(target_user) + target_user_id_display = target_user.telegram_id or target_user.email or f'#{target_user.id}' traffic = '♾️ Безлимит' if tariff.traffic_limit_gb == 0 else f'{tariff.traffic_limit_gb} ГБ' text = '💳 Подтверждение покупки тарифа\n\n' text += f'👤 {target_user_link} (ID: {target_user_id_display})\n' text += f'💰 Баланс: {settings.format_price(target_user.balance_kopeks)}\n\n' - text += f'📦 Тариф: {tariff.name}\n' + text += f'📦 Тариф: {html.escape(tariff.name)}\n' text += f'📊 Трафик: {traffic}\n' text += f'📱 Устройств: {tariff.device_limit}\n' text += f'📅 Период: {period} дней\n' @@ -5055,18 +5017,14 @@ async def admin_buy_tariff_execute(callback: types.CallbackQuery, db_user: User, description=f'Покупка тарифа {tariff.name} на {period} дней (администратор)', ) - if target_user.telegram_id: - target_user_link = f'{target_user.full_name}' - target_user_id_display = target_user.telegram_id - else: - target_user_link = f'{target_user.full_name}' - target_user_id_display = target_user.email or f'#{target_user.id}' + target_user_link = user_html_link(target_user) + target_user_id_display = target_user.telegram_id or target_user.email or f'#{target_user.id}' traffic = '♾️ Безлимит' if tariff.traffic_limit_gb == 0 else f'{tariff.traffic_limit_gb} ГБ' await callback.message.edit_text( f'✅ Тариф успешно куплен!\n\n' f'👤 {target_user_link} (ID: {target_user_id_display})\n' - f'📦 Тариф: {tariff.name}\n' + f'📦 Тариф: {html.escape(tariff.name)}\n' f'📊 Трафик: {traffic}\n' f'📱 Устройств: {tariff.device_limit}\n' f'📅 Период: {period} дней\n' @@ -5090,7 +5048,7 @@ async def admin_buy_tariff_execute(callback: types.CallbackQuery, db_user: User, await callback.bot.send_message( chat_id=target_user.telegram_id, text=f'💳 Администратор оформил вам тариф\n\n' - f'📦 Тариф: {tariff.name}\n' + f'📦 Тариф: {html.escape(tariff.name)}\n' f'📊 Трафик: {traffic}\n' f'📱 Устройств: {tariff.device_limit}\n' f'📅 Период: {period} дней\n' @@ -5234,14 +5192,11 @@ async def show_admin_tariff_change(callback: types.CallbackQuery, db_user: User, current_tariff = await get_tariff_by_id(db, subscription.tariff_id) text = '📦 Смена тарифа пользователя\n\n' - if user.telegram_id: - user_link = f'{user.full_name}' - else: - user_link = f'{user.full_name} ({user.email or f"#{user.id}"})' + user_link = user_html_link(user) text += f'👤 {user_link}\n\n' if current_tariff: - text += f'Текущий тариф: {current_tariff.name}\n\n' + text += f'Текущий тариф: {html.escape(current_tariff.name)}\n\n' else: text += 'Текущий тариф: не установлен\n\n' @@ -5307,12 +5262,9 @@ async def select_admin_tariff_change(callback: types.CallbackQuery, db_user: Use servers_count = len(tariff.allowed_squads) if tariff.allowed_squads else 0 text = '📦 Подтверждение смены тарифа\n\n' - if user.telegram_id: - user_link = f'{user.full_name}' - else: - user_link = f'{user.full_name} ({user.email or f"#{user.id}"})' + user_link = user_html_link(user) text += f'👤 {user_link}\n\n' - text += f'Новый тариф: {tariff.name}\n' + text += f'Новый тариф: {html.escape(tariff.name)}\n' text += f'• Устройства: {tariff.device_limit}\n' text += f'• Трафик: {traffic_str}\n' text += f'• Серверы: {servers_count}\n\n' @@ -5431,7 +5383,7 @@ async def confirm_admin_tariff_change(callback: types.CallbackQuery, db_user: Us await callback.message.edit_text( f'✅ Тариф успешно изменен\n\n' - f'Новый тариф: {tariff.name}\n' + f'Новый тариф: {html.escape(tariff.name)}\n' f'• Устройства: {subscription.device_limit}\n' f'• Трафик: {"♾️" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ"}\n' f'• Серверы: {len(tariff.allowed_squads) if tariff.allowed_squads else 0}', @@ -5451,7 +5403,7 @@ async def confirm_admin_tariff_change(callback: types.CallbackQuery, db_user: Us await db.rollback() await callback.message.edit_text( - f'❌ Ошибка смены тарифа\n\nДетали: {e!s}', + f'❌ Ошибка смены тарифа\n\nДетали: {html.escape(str(e))}', reply_markup=types.InlineKeyboardMarkup( inline_keyboard=[ [ diff --git a/app/handlers/balance/cloudpayments.py b/app/handlers/balance/cloudpayments.py index 7aa6420a..9a955a36 100644 --- a/app/handlers/balance/cloudpayments.py +++ b/app/handlers/balance/cloudpayments.py @@ -1,5 +1,7 @@ """Handler for CloudPayments balance top-up.""" +import html + import structlog from aiogram import types from aiogram.fsm.context import FSMContext @@ -137,7 +139,7 @@ async def process_cloudpayments_payment_amount( # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') support_url = settings.get_support_contact_url() keyboard = [] if support_url: @@ -203,7 +205,7 @@ async def start_cloudpayments_payment( # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') support_url = settings.get_support_contact_url() keyboard = [] if support_url: diff --git a/app/handlers/balance/cryptobot.py b/app/handlers/balance/cryptobot.py index 916eaeb1..d00f62ce 100644 --- a/app/handlers/balance/cryptobot.py +++ b/app/handlers/balance/cryptobot.py @@ -1,3 +1,5 @@ +import html + import structlog from aiogram import types from aiogram.fsm.context import FSMContext @@ -21,7 +23,7 @@ async def start_cryptobot_payment(callback: types.CallbackQuery, db_user: User, # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') support_url = settings.get_support_contact_url() keyboard = [] if support_url: @@ -85,7 +87,7 @@ async def process_cryptobot_payment_amount( # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') support_url = settings.get_support_contact_url() keyboard = [] if support_url: diff --git a/app/handlers/balance/freekassa.py b/app/handlers/balance/freekassa.py index 5aa28b92..68ef848b 100644 --- a/app/handlers/balance/freekassa.py +++ b/app/handlers/balance/freekassa.py @@ -161,7 +161,7 @@ async def process_freekassa_payment_amount( # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') support_url = settings.get_support_contact_url() keyboard = [] if support_url: @@ -250,7 +250,7 @@ async def _start_freekassa_topup_impl( # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') support_url = settings.get_support_contact_url() keyboard = [] if support_url: diff --git a/app/handlers/balance/heleket.py b/app/handlers/balance/heleket.py index 30082e23..fbf57792 100644 --- a/app/handlers/balance/heleket.py +++ b/app/handlers/balance/heleket.py @@ -1,3 +1,4 @@ +import html from datetime import UTC, datetime import structlog @@ -28,7 +29,7 @@ async def start_heleket_payment( # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') support_url = settings.get_support_contact_url() keyboard = [] if support_url: @@ -99,7 +100,7 @@ async def process_heleket_payment_amount( # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') support_url = settings.get_support_contact_url() keyboard = [] if support_url: diff --git a/app/handlers/balance/kassa_ai.py b/app/handlers/balance/kassa_ai.py index b1555ca5..281f03aa 100644 --- a/app/handlers/balance/kassa_ai.py +++ b/app/handlers/balance/kassa_ai.py @@ -1,5 +1,7 @@ """Handler for KassaAI balance top-up.""" +import html + import structlog from aiogram import types from aiogram.fsm.context import FSMContext @@ -45,7 +47,7 @@ async def _check_topup_restriction(callback: types.CallbackQuery, db_user: User) if not getattr(db_user, 'restriction_topup', False): return False texts = get_texts(db_user.language) - reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') support_url = settings.get_support_contact_url() keyboard = [] if support_url: @@ -173,7 +175,7 @@ async def process_kassa_ai_payment_amount( # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') support_url = settings.get_support_contact_url() keyboard = [] if support_url: diff --git a/app/handlers/balance/main.py b/app/handlers/balance/main.py index 0e880558..6a34fc04 100644 --- a/app/handlers/balance/main.py +++ b/app/handlers/balance/main.py @@ -1,3 +1,5 @@ +import html + import structlog from aiogram import Dispatcher, F, types from aiogram.exceptions import TelegramBadRequest @@ -233,7 +235,7 @@ async def show_balance_history(callback: types.CallbackQuery, db_user: User, db: ) text += f'{emoji} {amount_text}\n' - text += f'📝 {transaction.description}\n' + text += f'📝 {html.escape(transaction.description or "")}\n' text += f'📅 {transaction.created_at.strftime("%d.%m.%Y %H:%M")}\n\n' keyboard = [] @@ -266,7 +268,7 @@ async def show_payment_methods(callback: types.CallbackQuery, db_user: User, db: # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') support_url = settings.get_support_contact_url() keyboard = [] if support_url: diff --git a/app/handlers/balance/mulenpay.py b/app/handlers/balance/mulenpay.py index 8098b116..cd4d04c2 100644 --- a/app/handlers/balance/mulenpay.py +++ b/app/handlers/balance/mulenpay.py @@ -1,3 +1,5 @@ +import html + import structlog from aiogram import types from aiogram.fsm.context import FSMContext @@ -25,7 +27,7 @@ async def start_mulenpay_payment( # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') support_url = settings.get_support_contact_url() keyboard = [] if support_url: @@ -92,7 +94,7 @@ async def process_mulenpay_payment_amount( # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') support_url = settings.get_support_contact_url() keyboard = [] if support_url: diff --git a/app/handlers/balance/pal24.py b/app/handlers/balance/pal24.py index 5114afad..7956a3c9 100644 --- a/app/handlers/balance/pal24.py +++ b/app/handlers/balance/pal24.py @@ -262,7 +262,7 @@ async def start_pal24_payment( # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') support_url = settings.get_support_contact_url() keyboard = [] if support_url: @@ -330,7 +330,7 @@ async def process_pal24_payment_amount( # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') support_url = settings.get_support_contact_url() keyboard = [] if support_url: diff --git a/app/handlers/balance/platega.py b/app/handlers/balance/platega.py index 50a13e11..1922ddb8 100644 --- a/app/handlers/balance/platega.py +++ b/app/handlers/balance/platega.py @@ -1,5 +1,7 @@ """Handlers for Platega balance interactions.""" +import html + import structlog from aiogram import types from aiogram.fsm.context import FSMContext @@ -98,7 +100,7 @@ async def start_platega_payment( # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') support_url = settings.get_support_contact_url() keyboard = [] if support_url: @@ -201,7 +203,7 @@ async def process_platega_payment_amount( # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') support_url = settings.get_support_contact_url() keyboard = [] if support_url: diff --git a/app/handlers/balance/riopay.py b/app/handlers/balance/riopay.py index 0c79403c..bd6649e1 100644 --- a/app/handlers/balance/riopay.py +++ b/app/handlers/balance/riopay.py @@ -1,5 +1,7 @@ """Handler for RioPay balance top-up.""" +import html + import structlog from aiogram import types from aiogram.fsm.context import FSMContext @@ -142,7 +144,7 @@ async def process_riopay_payment_amount( restriction_kb = _check_topup_restriction(db_user, texts) if restriction_kb: - reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') await message.answer( f'🚫 Пополнение ограничено\n\n{reason}', parse_mode='HTML', @@ -202,7 +204,7 @@ async def start_riopay_topup( restriction_kb = _check_topup_restriction(db_user, texts) if restriction_kb: - reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') await callback.message.edit_text( f'🚫 Пополнение ограничено\n\n{reason}', parse_mode='HTML', diff --git a/app/handlers/balance/severpay.py b/app/handlers/balance/severpay.py index 6d994d09..450b1d36 100644 --- a/app/handlers/balance/severpay.py +++ b/app/handlers/balance/severpay.py @@ -1,5 +1,7 @@ """Handler for SeverPay balance top-up.""" +import html + import structlog from aiogram import types from aiogram.fsm.context import FSMContext @@ -142,7 +144,7 @@ async def process_severpay_payment_amount( restriction_kb = _check_topup_restriction(db_user, texts) if restriction_kb: - reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') await message.answer( f'🚫 Пополнение ограничено\n\n{reason}', parse_mode='HTML', @@ -202,7 +204,7 @@ async def start_severpay_topup( restriction_kb = _check_topup_restriction(db_user, texts) if restriction_kb: - reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') await callback.message.edit_text( f'🚫 Пополнение ограничено\n\n{reason}', parse_mode='HTML', diff --git a/app/handlers/balance/stars.py b/app/handlers/balance/stars.py index bc1eb0ff..dc55be3b 100644 --- a/app/handlers/balance/stars.py +++ b/app/handlers/balance/stars.py @@ -1,3 +1,5 @@ +import html + import structlog from aiogram import types from aiogram.fsm.context import FSMContext @@ -25,7 +27,7 @@ async def start_stars_payment(callback: types.CallbackQuery, db_user: User, stat # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') support_url = settings.get_support_contact_url() keyboard = [] if support_url: @@ -62,7 +64,7 @@ async def process_stars_payment_amount(message: types.Message, db_user: User, am # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') support_url = settings.get_support_contact_url() keyboard = [] if support_url: diff --git a/app/handlers/balance/tribute.py b/app/handlers/balance/tribute.py index 973eb2f3..7306204c 100644 --- a/app/handlers/balance/tribute.py +++ b/app/handlers/balance/tribute.py @@ -1,3 +1,5 @@ +import html + import structlog from aiogram import types @@ -19,7 +21,7 @@ async def start_tribute_payment( # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') support_url = settings.get_support_contact_url() keyboard = [] if support_url: diff --git a/app/handlers/balance/wata.py b/app/handlers/balance/wata.py index c086abea..f5f7b2ca 100644 --- a/app/handlers/balance/wata.py +++ b/app/handlers/balance/wata.py @@ -1,3 +1,4 @@ +import html from datetime import UTC, datetime import structlog @@ -28,7 +29,7 @@ async def start_wata_payment( # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') support_url = settings.get_support_contact_url() keyboard = [] if support_url: @@ -88,7 +89,7 @@ async def process_wata_payment_amount( # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') support_url = settings.get_support_contact_url() keyboard = [] if support_url: diff --git a/app/handlers/balance/yookassa.py b/app/handlers/balance/yookassa.py index a6f9e025..92f71266 100644 --- a/app/handlers/balance/yookassa.py +++ b/app/handlers/balance/yookassa.py @@ -1,3 +1,4 @@ +import html from datetime import UTC, datetime import structlog @@ -24,7 +25,7 @@ async def start_yookassa_payment(callback: types.CallbackQuery, db_user: User, s # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') support_url = settings.get_support_contact_url() keyboard = [] if support_url: @@ -70,7 +71,7 @@ async def start_yookassa_sbp_payment(callback: types.CallbackQuery, db_user: Use # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') support_url = settings.get_support_contact_url() keyboard = [] if support_url: @@ -118,7 +119,7 @@ async def process_yookassa_payment_amount( # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') support_url = settings.get_support_contact_url() keyboard = [] if support_url: @@ -273,7 +274,7 @@ async def process_yookassa_sbp_payment_amount( # Проверка ограничения на пополнение if getattr(db_user, 'restriction_topup', False): - reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') support_url = settings.get_support_contact_url() keyboard = [] if support_url: diff --git a/app/handlers/menu.py b/app/handlers/menu.py index 26b5894e..251bb0eb 100644 --- a/app/handlers/menu.py +++ b/app/handlers/menu.py @@ -1183,12 +1183,13 @@ async def get_main_menu_text(user, texts, db: AsyncSession): if tariff: is_daily_tariff = getattr(tariff, 'is_daily', False) # Формируем краткий блок информации о тарифе для главного меню - tariff_info_block = f'\n📦 Тариф: {tariff.name}' + tariff_info_block = f'\n📦 Тариф: {html.escape(tariff.name)}' except Exception as e: logger.debug('Не удалось загрузить тариф для главного меню', error=e) base_text = texts.MAIN_MENU.format( - user_name=user.full_name, subscription_status=_get_subscription_status(user, texts, is_daily_tariff) + user_name=html.escape(user.full_name or ''), + subscription_status=_get_subscription_status(user, texts, is_daily_tariff), ) # Добавляем информацию о тарифе перед "Выберите действие" diff --git a/app/handlers/polls.py b/app/handlers/polls.py index 69022c9b..bfae89a7 100644 --- a/app/handlers/polls.py +++ b/app/handlers/polls.py @@ -1,4 +1,5 @@ import asyncio +import html from datetime import UTC, datetime import structlog @@ -39,7 +40,7 @@ async def _render_question_text( current=current_index, total=total, ) - lines = [f'🗳️ {poll_title}', '', header, '', question.text] + lines = [f'🗳️ {html.escape(poll_title)}', '', header, '', html.escape(question.text)] return '\n'.join(lines) diff --git a/app/handlers/simple_subscription.py b/app/handlers/simple_subscription.py index 36d2c7d2..0bff4c33 100644 --- a/app/handlers/simple_subscription.py +++ b/app/handlers/simple_subscription.py @@ -44,7 +44,7 @@ async def start_simple_subscription_purchase( # Проверка ограничения на покупку/продление подписки if getattr(db_user, 'restriction_subscription', False): - reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') support_url = settings.get_support_contact_url() keyboard = [] if support_url: diff --git a/app/handlers/stars_payments.py b/app/handlers/stars_payments.py index a7105a1e..0af072f7 100644 --- a/app/handlers/stars_payments.py +++ b/app/handlers/stars_payments.py @@ -1,3 +1,4 @@ +import html from decimal import ROUND_HALF_UP, Decimal import structlog @@ -136,7 +137,7 @@ async def _handle_wheel_spin_payment( emoji = selected_prize.emoji or '🎁' await message.answer( f'🎰 Колесо удачи!\n\n' - f'{emoji} {selected_prize.display_name}\n\n' + f'{emoji} {html.escape(selected_prize.display_name)}\n\n' f'{prize_message}\n\n' f'⭐ Потрачено: {stars_amount} Stars', parse_mode='HTML', diff --git a/app/handlers/start.py b/app/handlers/start.py index 537b310d..339cc1b3 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -1,3 +1,4 @@ +import html from collections.abc import Callable from datetime import UTC, datetime from typing import Any @@ -117,7 +118,7 @@ async def _activate_pending_gift_after_registration( gift_purchase.status = GuestPurchaseStatus.PENDING_ACTIVATION.value await db.flush() await svc_activate(db, gift_purchase.token, skip_notification=True) - tariff_name = gift_purchase.tariff.name if gift_purchase.tariff else '' + tariff_name = html.escape(gift_purchase.tariff.name) if gift_purchase.tariff else '' await answer_func( f'🎁 Подарок активирован!\n' f'{tariff_name} — {gift_purchase.period_days} дн.\n\n' @@ -322,13 +323,13 @@ async def _apply_campaign_bonus_if_needed( amount_text = texts.format_price(result.balance_kopeks) return texts.CAMPAIGN_BONUS_BALANCE.format( amount=amount_text, - name=campaign.name, + name=html.escape(campaign.name), ) if result.bonus_type == 'subscription': traffic_text = texts.format_traffic(result.subscription_traffic_gb or 0) return texts.CAMPAIGN_BONUS_SUBSCRIPTION.format( - name=campaign.name, + name=html.escape(campaign.name), days=result.subscription_days, traffic=traffic_text, devices=result.subscription_device_limit, @@ -1461,7 +1462,7 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta texts.t( 'WELCOME_FALLBACK', 'Добро пожаловать, {user_name}!', - ).format(user_name=existing_user.full_name) + ).format(user_name=html.escape(existing_user.full_name or '')) ) await state.clear() @@ -1695,7 +1696,7 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta texts.t( 'WELCOME_FALLBACK', 'Добро пожаловать, {user_name}!', - ).format(user_name=user.full_name) + ).format(user_name=html.escape(user.full_name or '')) ) logger.info('✅ Регистрация завершена для пользователя', telegram_id=user.telegram_id) @@ -1763,7 +1764,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A texts.t( 'WELCOME_FALLBACK', 'Добро пожаловать, {user_name}!', - ).format(user_name=existing_user.full_name) + ).format(user_name=html.escape(existing_user.full_name or '')) ) await state.clear() @@ -2030,7 +2031,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A texts.t( 'WELCOME_FALLBACK', 'Добро пожаловать, {user_name}!', - ).format(user_name=user.full_name) + ).format(user_name=html.escape(user.full_name or '')) ) logger.info('✅ Регистрация завершена для пользователя', telegram_id=user.telegram_id) @@ -2140,8 +2141,6 @@ def get_referral_code_keyboard(language: str): async def get_main_menu_text(user, texts, db: AsyncSession): - import html - base_text = texts.MAIN_MENU.format( user_name=html.escape(user.full_name or ''), subscription_status=_get_subscription_status(user, texts) ) @@ -2189,8 +2188,6 @@ async def get_main_menu_text(user, texts, db: AsyncSession): async def get_main_menu_text_simple(user_name, texts, db: AsyncSession): - import html - base_text = texts.MAIN_MENU.format( user_name=html.escape(user_name or ''), subscription_status=_get_subscription_status_simple(texts) ) diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 56318228..cd268e55 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -336,7 +336,7 @@ async def show_subscription_info(callback: types.CallbackQuery, db_user: User, d tariff_type_str = '🔄 Суточный' if is_daily else '📅 Периодный' tariff_info_lines = [ - f'📦 {tariff.name}', + f'📦 {html.escape(tariff.name)}', f'Тип: {tariff_type_str}', f'Трафик: {tariff.traffic_limit_gb} ГБ' if tariff.traffic_limit_gb > 0 else 'Трафик: ∞ Безлимит', f'Устройства: {tariff.device_limit}', @@ -453,7 +453,7 @@ async def show_subscription_info(callback: types.CallbackQuery, db_user: User, d device_limit_display = str(subscription.device_limit) message = message_template.format( - full_name=db_user.full_name, + full_name=html.escape(db_user.full_name or ''), balance=settings.format_price(db_user.balance_kopeks), status_emoji=status_emoji, status_display=status_display, @@ -761,7 +761,7 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async # Проверка ограничения на покупку/продление подписки if getattr(db_user, 'restriction_subscription', False): - reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') support_url = settings.get_support_contact_url() keyboard = [] if support_url: @@ -2068,7 +2068,7 @@ async def devices_continue(callback: types.CallbackQuery, state: FSMContext, db_ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession): # Проверка ограничения на покупку/продление подписки if getattr(db_user, 'restriction_subscription', False): - reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором' + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') texts = get_texts(db_user.language) support_url = settings.get_support_contact_url() keyboard = [] diff --git a/app/handlers/subscription/tariff_purchase.py b/app/handlers/subscription/tariff_purchase.py index f3b19ac2..014ef90b 100644 --- a/app/handlers/subscription/tariff_purchase.py +++ b/app/handlers/subscription/tariff_purchase.py @@ -1,5 +1,6 @@ """Покупка подписки по тарифам.""" +import html from datetime import UTC, datetime, timedelta import structlog @@ -102,11 +103,11 @@ def format_tariffs_list_text( price_text = f'от {format_price_kopeks(min_price, compact=True)}{discount_icon}' # Компактный формат: Название — 250 ГБ / 10 📱 от 179₽🔥 - lines.append(f'{tariff.name} — {traffic} / {tariff.device_limit} 📱 {price_text}') + lines.append(f'{html.escape(tariff.name)} — {traffic} / {tariff.device_limit} 📱 {price_text}') # Описание тарифа если есть if tariff.description: - lines.append(f'{tariff.description}') + lines.append(f'{html.escape(tariff.description)}') lines.append('') @@ -238,7 +239,7 @@ def format_tariff_info_for_user( traffic = format_traffic(tariff.traffic_limit_gb) - text = f"""📦 {tariff.name} + text = f"""📦 {html.escape(tariff.name)} Параметры: • Трафик: {traffic} @@ -246,7 +247,7 @@ def format_tariff_info_for_user( """ if tariff.description: - text += f'\n📝 {tariff.description}\n' + text += f'\n📝 {html.escape(tariff.description)}\n' if discount_percent > 0: text += f'\n🎁 Ваша скидка: {discount_percent}%\n' @@ -438,7 +439,7 @@ async def format_custom_tariff_preview( traffic_display = f'{traffic_gb} ГБ' if traffic_gb > 0 else format_traffic(tariff.traffic_limit_gb) - text = f"""📦 {tariff.name} + text = f"""📦 {html.escape(tariff.name)} Настройте параметры: """ @@ -554,7 +555,7 @@ async def select_tariff( if user_balance >= daily_price: await callback.message.edit_text( f'✅ Подтверждение покупки\n\n' - f'📦 Тариф: {tariff.name}\n' + f'📦 Тариф: {html.escape(tariff.name)}\n' f'📊 Трафик: {traffic}\n' f'📱 Устройств: {tariff.device_limit}\n' f'🔄 Тип: Суточный\n\n' @@ -589,7 +590,7 @@ async def select_tariff( await callback.message.edit_text( f'❌ Недостаточно средств\n\n' - f'📦 Тариф: {tariff.name}\n' + f'📦 Тариф: {html.escape(tariff.name)}\n' f'🔄 Тип: Суточный\n' f'💰 Цена: {format_price_kopeks(daily_price)}/день' f'{discount_text}\n\n' @@ -991,7 +992,7 @@ async def handle_custom_confirm( await callback.message.edit_text( f'🎉 Подписка успешно оформлена!\n\n' - f'📦 Тариф: {tariff.name}\n' + f'📦 Тариф: {html.escape(tariff.name)}\n' f'📊 Трафик: {traffic_display}\n' f'📱 Устройств: {tariff.device_limit}\n' f'📅 Период: {format_period(custom_days)}\n' @@ -1115,7 +1116,7 @@ async def select_tariff_period( await callback.message.edit_text( f'✅ Подтверждение покупки\n\n' - f'📦 Тариф: {tariff.name}\n' + f'📦 Тариф: {html.escape(tariff.name)}\n' f'📊 Трафик: {traffic}\n' f'📱 Устройств: {tariff.device_limit}\n' f'📅 Период: {format_period(period)}\n' @@ -1150,7 +1151,7 @@ async def select_tariff_period( await callback.message.edit_text( f'❌ Недостаточно средств\n\n' - f'📦 Тариф: {tariff.name}\n' + f'📦 Тариф: {html.escape(tariff.name)}\n' f'📅 Период: {format_period(period)}\n' f'💰 Стоимость: {format_price_kopeks(final_price)}\n\n' f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n' @@ -1364,7 +1365,7 @@ async def confirm_tariff_purchase( await callback.message.edit_text( f'🎉 Подписка успешно оформлена!\n\n' - f'📦 Тариф: {tariff.name}\n' + f'📦 Тариф: {html.escape(tariff.name)}\n' f'📊 Трафик: {traffic}\n' f'📱 Устройств: {tariff.device_limit}\n' f'📅 Период: {format_period(period)}\n' @@ -1597,7 +1598,7 @@ async def confirm_daily_tariff_purchase( await callback.message.edit_text( f'🎉 Суточная подписка оформлена!\n\n' - f'📦 Тариф: {tariff.name}\n' + f'📦 Тариф: {html.escape(tariff.name)}\n' f'📊 Трафик: {traffic}\n' f'📱 Устройств: {tariff.device_limit}\n' f'🔄 Тип: Суточный\n' @@ -1738,7 +1739,7 @@ async def show_tariff_extend( await callback.message.edit_text( f'🔄 Продление подписки{discount_hint}\n\n' - f'📦 Тариф: {tariff.name}\n' + f'📦 Тариф: {html.escape(tariff.name)}\n' f'📊 Трафик: {traffic}\n' f'📱 Устройств: {actual_device_limit}\n\n' 'Выберите период продления:', @@ -1805,7 +1806,7 @@ async def select_tariff_extend_period( await callback.message.edit_text( f'✅ Подтверждение продления\n\n' - f'📦 Тариф: {tariff.name}\n' + f'📦 Тариф: {html.escape(tariff.name)}\n' f'📊 Трафик: {traffic}\n' f'📱 Устройств: {actual_device_limit}\n' f'📅 Период: {format_period(period)}\n' @@ -1840,7 +1841,7 @@ async def select_tariff_extend_period( await callback.message.edit_text( f'❌ Недостаточно средств\n\n' - f'📦 Тариф: {tariff.name}\n' + f'📦 Тариф: {html.escape(tariff.name)}\n' f'📅 Период: {format_period(period)}\n' f'💰 К оплате: {format_price_kopeks(final_price)}\n\n' f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n' @@ -1982,7 +1983,7 @@ async def confirm_tariff_extend( await callback.message.edit_text( f'🎉 Подписка успешно продлена!\n\n' - f'📦 Тариф: {tariff.name}\n' + f'📦 Тариф: {html.escape(tariff.name)}\n' f'📊 Трафик: {traffic}\n' f'📱 Устройств: {actual_device_limit}\n' f'📅 Добавлено: {format_period(period)}\n' @@ -2059,10 +2060,10 @@ def format_tariff_switch_list_text( discount_icon = '🔥' price_text = f'от {format_price_kopeks(min_price, compact=True)}{discount_icon}' - lines.append(f'{tariff.name} — {traffic} / {tariff.device_limit} 📱 {price_text}') + lines.append(f'{html.escape(tariff.name)} — {traffic} / {tariff.device_limit} 📱 {price_text}') if tariff.description: - lines.append(f'{tariff.description}') + lines.append(f'{html.escape(tariff.description)}') lines.append('') @@ -2198,7 +2199,7 @@ async def show_tariff_switch_list( if current_tariff_id: current_tariff = await get_tariff_by_id(db, current_tariff_id) if current_tariff: - current_tariff_name = current_tariff.name + current_tariff_name = html.escape(current_tariff.name) # Проверяем есть ли у пользователя скидки по периодам promo_group = db_user.get_primary_promo_group() if hasattr(db_user, 'get_primary_promo_group') else None @@ -2269,7 +2270,7 @@ async def select_tariff_switch( if user_balance >= daily_price: await callback.message.edit_text( f'✅ Подтверждение смены тарифа\n\n' - f'📦 Новый тариф: {tariff.name}\n' + f'📦 Новый тариф: {html.escape(tariff.name)}\n' f'📊 Трафик: {traffic}\n' f'📱 Устройств: {tariff.device_limit}\n' f'🔄 Тип: Суточный\n\n' @@ -2295,7 +2296,7 @@ async def select_tariff_switch( missing = daily_price - user_balance await callback.message.edit_text( f'❌ Недостаточно средств\n\n' - f'📦 Тариф: {tariff.name}\n' + f'📦 Тариф: {html.escape(tariff.name)}\n' f'🔄 Тип: Суточный\n' f'💰 Цена: {format_price_kopeks(daily_price)}/день' f'{discount_text}\n\n' @@ -2312,7 +2313,7 @@ async def select_tariff_switch( ) else: # Для обычного тарифа показываем выбор периода - info_text = f"""📦 {tariff.name} + info_text = f"""📦 {html.escape(tariff.name)} Параметры нового тарифа: • Трафик: {traffic} @@ -2320,7 +2321,7 @@ async def select_tariff_switch( """ if tariff.description: - info_text += f'\n📝 {tariff.description}\n' + info_text += f'\n📝 {html.escape(tariff.description)}\n' info_text += '\n⚠️ Оплачивается полная стоимость тарифа.\nВыберите период:' @@ -2381,7 +2382,7 @@ async def select_tariff_switch_period( if current_tariff_id: current_tariff = await get_tariff_by_id(db, current_tariff_id) if current_tariff: - current_tariff_name = current_tariff.name + current_tariff_name = html.escape(current_tariff.name) # Получаем текущую подписку для расчёта оставшегося времени subscription = await get_subscription_by_user_id(db, db_user.id) @@ -2399,7 +2400,7 @@ async def select_tariff_switch_period( await callback.message.edit_text( f'✅ Подтверждение переключения тарифа\n\n' f'📌 Текущий тариф: {current_tariff_name}\n' - f'📦 Новый тариф: {tariff.name}\n' + f'📦 Новый тариф: {html.escape(tariff.name)}\n' f'📊 Трафик: {traffic}\n' f'📱 Устройств: {tariff.device_limit}\n' f'{time_info}\n' @@ -2414,7 +2415,7 @@ async def select_tariff_switch_period( missing = final_price - user_balance await callback.message.edit_text( f'❌ Недостаточно средств\n\n' - f'📦 Тариф: {tariff.name}\n' + f'📦 Тариф: {html.escape(tariff.name)}\n' f'📅 Период: {format_period(period)}\n' f'💰 К оплате: {format_price_kopeks(final_price)}\n\n' f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n' @@ -2591,7 +2592,7 @@ async def confirm_tariff_switch( await callback.message.edit_text( f'🎉 Тариф успешно изменён!\n\n' - f'📦 Новый тариф: {tariff.name}\n' + f'📦 Новый тариф: {html.escape(tariff.name)}\n' f'📊 Трафик: {traffic}\n' f'📱 Устройств: {tariff.device_limit}\n' f'💰 Списано: {format_price_kopeks(final_price)}\n' @@ -2788,7 +2789,7 @@ async def confirm_daily_tariff_switch( await callback.message.edit_text( f'🎉 Тариф успешно изменён!\n\n' - f'📦 Новый тариф: {tariff.name}\n' + f'📦 Новый тариф: {html.escape(tariff.name)}\n' f'📊 Трафик: {traffic}\n' f'📱 Устройств: {tariff.device_limit}\n' f'🔄 Тип: Суточный\n' @@ -2864,7 +2865,7 @@ def format_instant_switch_list_text( """Форматирует текст со списком тарифов для мгновенного переключения.""" lines = [ '📦 Мгновенная смена тарифа', - f'📌 Текущий: {current_tariff.name}', + f'📌 Текущий: {html.escape(current_tariff.name)}', f'⏰ Осталось: {remaining_days} дн.', '', '💡 При переключении остаток дней сохраняется.', @@ -2888,10 +2889,10 @@ def format_instant_switch_list_text( else: cost_text = '⬇️ Бесплатно' - lines.append(f'{tariff.name} — {traffic} / {tariff.device_limit} 📱 {cost_text}') + lines.append(f'{html.escape(tariff.name)} — {traffic} / {tariff.device_limit} 📱 {cost_text}') if tariff.description: - lines.append(f'{tariff.description}') + lines.append(f'{html.escape(tariff.description)}') lines.append('') @@ -3109,10 +3110,10 @@ async def preview_instant_switch( if user_balance >= daily_price: await callback.message.edit_text( f'🔄 Переключение на суточный тариф\n\n' - f'📌 Текущий: {current_tariff.name}\n' + f'📌 Текущий: {html.escape(current_tariff.name)}\n' f' • Трафик: {current_traffic}\n' f' • Устройств: {current_tariff.device_limit}\n\n' - f'📦 Новый: {new_tariff.name}\n' + f'📦 Новый: {html.escape(new_tariff.name)}\n' f' • Трафик: {traffic}\n' f' • Устройств: {new_tariff.device_limit}\n' f' • Тип: 🔄 Суточный\n\n' @@ -3128,7 +3129,7 @@ async def preview_instant_switch( missing = daily_price - user_balance await callback.message.edit_text( f'❌ Недостаточно средств\n\n' - f'📦 Тариф: {new_tariff.name}\n' + f'📦 Тариф: {html.escape(new_tariff.name)}\n' f'🔄 Тип: Суточный\n' f'💰 Цена: {format_price_kopeks(daily_price)}/день' f'{discount_text}\n\n' @@ -3154,10 +3155,10 @@ async def preview_instant_switch( if user_balance >= upgrade_cost: await callback.message.edit_text( f'⬆️ Повышение тарифа\n\n' - f'📌 Текущий: {current_tariff.name}\n' + f'📌 Текущий: {html.escape(current_tariff.name)}\n' f' • Трафик: {current_traffic}\n' f' • Устройств: {current_tariff.device_limit}\n\n' - f'📦 Новый: {new_tariff.name}\n' + f'📦 Новый: {html.escape(new_tariff.name)}\n' f' • Трафик: {traffic}\n' f' • Устройств: {new_tariff.device_limit}\n\n' f'⏰ Осталось дней: {remaining_days}\n' @@ -3171,7 +3172,7 @@ async def preview_instant_switch( missing = upgrade_cost - user_balance await callback.message.edit_text( f'❌ Недостаточно средств\n\n' - f'📦 Новый тариф: {new_tariff.name}\n' + f'📦 Новый тариф: {html.escape(new_tariff.name)}\n' f'💰 Требуется доплата: {format_price_kopeks(upgrade_cost)}\n\n' f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n' f'⚠️ Не хватает: {format_price_kopeks(missing)}', @@ -3182,10 +3183,10 @@ async def preview_instant_switch( # Downgrade или тот же уровень - бесплатно await callback.message.edit_text( f'⬇️ Переключение тарифа\n\n' - f'📌 Текущий: {current_tariff.name}\n' + f'📌 Текущий: {html.escape(current_tariff.name)}\n' f' • Трафик: {current_traffic}\n' f' • Устройств: {current_tariff.device_limit}\n\n' - f'📦 Новый: {new_tariff.name}\n' + f'📦 Новый: {html.escape(new_tariff.name)}\n' f' • Трафик: {traffic}\n' f' • Устройств: {new_tariff.device_limit}\n\n' f'⏰ Осталось дней: {remaining_days}\n' @@ -3433,7 +3434,7 @@ async def confirm_instant_switch( if is_new_daily: await callback.message.edit_text( f'🎉 Тариф успешно изменён!\n\n' - f'📦 Новый тариф: {new_tariff.name}\n' + f'📦 Новый тариф: {html.escape(new_tariff.name)}\n' f'📊 Трафик: {traffic}\n' f'📱 Устройств: {new_tariff.device_limit}\n' f'🔄 Тип: Суточный\n' @@ -3455,7 +3456,7 @@ async def confirm_instant_switch( await callback.message.edit_text( f'🎉 Тариф успешно изменён!\n\n' - f'📦 Новый тариф: {new_tariff.name}\n' + f'📦 Новый тариф: {html.escape(new_tariff.name)}\n' f'📊 Трафик: {traffic}\n' f'📱 Устройств: {new_tariff.device_limit}\n' f'⏰ Осталось дней: {remaining_days}\n' @@ -3509,7 +3510,7 @@ async def return_to_saved_tariff_cart( if cart_mode == 'daily_tariff_purchase': await callback.message.edit_text( f'❌ Все еще недостаточно средств\n\n' - f'📦 Тариф: {tariff.name}\n' + f'📦 Тариф: {html.escape(tariff.name)}\n' f'🔄 Тип: Суточный\n' f'💰 Стоимость: {format_price_kopeks(total_price)}\n\n' f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n' @@ -3521,7 +3522,7 @@ async def return_to_saved_tariff_cart( period = cart_data.get('period_days', 30) await callback.message.edit_text( f'❌ Все еще недостаточно средств\n\n' - f'📦 Тариф: {tariff.name}\n' + f'📦 Тариф: {html.escape(tariff.name)}\n' f'📅 Период: {format_period(period)}\n' f'💰 Стоимость: {format_price_kopeks(total_price)}\n\n' f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n' @@ -3533,7 +3534,7 @@ async def return_to_saved_tariff_cart( period = cart_data.get('period_days', 30) await callback.message.edit_text( f'❌ Все еще недостаточно средств\n\n' - f'📦 Тариф: {tariff.name}\n' + f'📦 Тариф: {html.escape(tariff.name)}\n' f'📅 Период: {format_period(period)}\n' f'💰 Стоимость: {format_price_kopeks(total_price)}\n\n' f'💳 Ваш баланс: {format_price_kopeks(user_balance)}\n' @@ -3552,7 +3553,7 @@ async def return_to_saved_tariff_cart( await callback.message.edit_text( f'✅ Подтверждение покупки\n\n' - f'📦 Тариф: {tariff.name}\n' + f'📦 Тариф: {html.escape(tariff.name)}\n' f'📊 Трафик: {traffic}\n' f'📱 Устройств: {tariff.device_limit}\n' f'🔄 Тип: Суточный\n' @@ -3572,7 +3573,7 @@ async def return_to_saved_tariff_cart( await callback.message.edit_text( f'✅ Подтверждение продления\n\n' - f'📦 Тариф: {tariff.name}\n' + f'📦 Тариф: {html.escape(tariff.name)}\n' f'📊 Трафик: {traffic}\n' f'📱 Устройств: {tariff.device_limit}\n' f'📅 Период: {format_period(period)}\n' @@ -3602,7 +3603,7 @@ async def return_to_saved_tariff_cart( await callback.message.edit_text( f'✅ Подтверждение покупки\n\n' - f'📦 Тариф: {tariff.name}\n' + f'📦 Тариф: {html.escape(tariff.name)}\n' f'📊 Трафик: {traffic}\n' f'📱 Устройств: {tariff.device_limit}\n' f'📅 Период: {format_period(period)}\n' diff --git a/app/handlers/tickets.py b/app/handlers/tickets.py index ff3b922f..7dbbee33 100644 --- a/app/handlers/tickets.py +++ b/app/handlers/tickets.py @@ -1,4 +1,5 @@ import asyncio +import html import time import structlog @@ -256,7 +257,7 @@ async def handle_ticket_message_input(message: types.Message, state: FSMContext, texts = get_texts(db_user.language) # Ограничим длину подтверждения чтобы не упереться в лимиты - safe_title = title if len(title) <= 200 else (title[:197] + '...') + safe_title = html.escape(title if len(title) <= 200 else (title[:197] + '...')) creation_text = ( f'✅ Тикет #{ticket.id} создан\n\n' f'📝 Заголовок: {safe_title}\n' @@ -542,7 +543,7 @@ async def view_ticket(callback: types.CallbackQuery, db_user: User, db: AsyncSes header = ( f'🎫 Тикет #{ticket.id}\n\n' - f'📝 Заголовок: {ticket.title}\n' + f'📝 Заголовок: {html.escape(ticket.title or "")}\n' f'📊 Статус: {ticket.status_emoji} {status_text}\n' f'📅 Создан: {format_local_datetime(ticket.created_at, "%d.%m.%Y %H:%M")}\n\n' ) @@ -551,7 +552,7 @@ async def view_ticket(callback: types.CallbackQuery, db_user: User, db: AsyncSes message_blocks.append(f'💬 Сообщения ({len(ticket.messages)}):\n\n') for msg in ticket.messages: sender = '👤 Вы' if msg.is_user_message else '🛠️ Поддержка' - block = f'{sender} ({format_local_datetime(msg.created_at, "%d.%m %H:%M")}):\n{msg.message_text}\n\n' + block = f'{sender} ({format_local_datetime(msg.created_at, "%d.%m %H:%M")}):\n{html.escape(msg.message_text or "")}\n\n' if getattr(msg, 'has_media', False) and getattr(msg, 'media_type', None) == 'photo': block += '📎 Вложение: фото\n\n' message_blocks.append(block) @@ -1006,9 +1007,9 @@ async def notify_admins_about_new_ticket(ticket: Ticket, db: AsyncSession): user = await get_user_by_id(db, ticket.user_id) except Exception: user = None - full_name = user.full_name if user else 'Unknown' + full_name = html.escape(user.full_name or '') if user else 'Unknown' telegram_id_display = (user.telegram_id or user.email or f'#{user.id}') if user else '—' - username_display = (user.username or 'отсутствует') if user else 'отсутствует' + username_display = html.escape((user.username or 'отсутствует') if user else 'отсутствует') # Загружаем первое сообщение для получения медиа и превью текста first_message = await TicketMessageCRUD.get_first_message(db, ticket.id) @@ -1022,17 +1023,19 @@ async def notify_admins_about_new_ticket(ticket: Ticket, db: AsyncSession): if msg_text: message_preview = msg_text[:200] + '...' if len(msg_text) > 200 else msg_text + safe_title = html.escape(title) if title else '—' + notification_text = ( f'🎫 НОВЫЙ ТИКЕТ\n\n' f'🆔 ID: {ticket.id}\n' f'👤 Пользователь: {full_name}\n' f'🆔 ID: {telegram_id_display}\n' f'📱 Username: @{username_display}\n' - f'📝 Заголовок: {title or "—"}\n' + f'📝 Заголовок: {safe_title}\n' ) if message_preview: - notification_text += f'\n📩 Сообщение:\n{message_preview}\n' + notification_text += f'\n📩 Сообщение:\n{html.escape(message_preview)}\n' notification_text += f'\n📅 Создан: {format_local_datetime(ticket.created_at, "%d.%m.%Y %H:%M")}\n' @@ -1076,20 +1079,21 @@ async def notify_admins_about_ticket_reply( user = await get_user_by_id(db, ticket.user_id) except Exception: user = None - full_name = user.full_name if user else 'Unknown' + full_name = html.escape(user.full_name or '') if user else 'Unknown' telegram_id_display = (user.telegram_id or user.email or f'#{user.id}') if user else '—' - username_display = (user.username or 'отсутствует') if user else 'отсутствует' + username_display = html.escape((user.username or 'отсутствует') if user else 'отсутствует') reply_preview = reply_text[:200] + '...' if len(reply_text) > 200 else reply_text + safe_title = html.escape(title) if title else '—' notification_text = ( f'💬 ОТВЕТ НА ТИКЕТ\n\n' f'🆔 ID тикета: {ticket.id}\n' - f'📝 Заголовок: {title or "—"}\n' + f'📝 Заголовок: {safe_title}\n' f'👤 Пользователь: {full_name}\n' f'🆔 ID: {telegram_id_display}\n' f'📱 Username: @{username_display}\n\n' - f'📩 Сообщение:\n{reply_preview}\n' + f'📩 Сообщение:\n{html.escape(reply_preview)}\n' ) from app.services.maintenance_service import maintenance_service diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py index 81ae9e57..a866e916 100644 --- a/app/services/admin_notification_service.py +++ b/app/services/admin_notification_service.py @@ -77,11 +77,11 @@ class AdminNotificationService: return f'ID {referred_by_id} (не найден)' if referrer.username: - return f'@{referrer.username} (ID: {referred_by_id})' + return f'@{html.escape(referrer.username)} (ID: {referred_by_id})' if referrer.telegram_id: return f'ID {referrer.telegram_id}' if referrer.email: - return f'📧 {referrer.email}' + return f'📧 {html.escape(referrer.email)}' return f'User#{referred_by_id}' except Exception as e: @@ -118,17 +118,17 @@ class AdminNotificationService: def _get_user_display(self, user: User) -> str: first_name = getattr(user, 'first_name', '') or '' if first_name: - return first_name + return html.escape(first_name) username = getattr(user, 'username', '') or '' if username: - return username + return html.escape(username) telegram_id = getattr(user, 'telegram_id', None) if telegram_id is None: email = getattr(user, 'email', None) if email: - return email + return html.escape(email) return f'User#{getattr(user, "id", "Unknown")}' return f'ID{telegram_id}' @@ -140,7 +140,7 @@ class AdminNotificationService: email = getattr(user, 'email', None) if email: - return f'📧 {email}' + return f'📧 {html.escape(email)}' return f'User#{getattr(user, "id", "Unknown")}' @@ -249,7 +249,7 @@ class AdminNotificationService: if not promo_group: return f'{icon} {title}: —' - lines = [f'{icon} {title}: {promo_group.name}'] + lines = [f'{icon} {title}: {html.escape(promo_group.name)}'] discount_lines = self._format_promo_group_discounts(promo_group) if discount_lines: @@ -359,14 +359,14 @@ class AdminNotificationService: '', f'👤 Пользователь: {user_display}', f'🆔 {user_id_label}: {user_id_display}', - f'📱 Username: @{getattr(user, "username", None) or "отсутствует"}', + f'📱 Username: @{html.escape(getattr(user, "username", None) or "отсутствует")}', f'👥 Статус: {user_status}', '', ] # Промогруппа — только название, без скидок if promo_group: - message_lines.append(f'🏷️ Промогруппа: {promo_group.name}') + message_lines.append(f'🏷️ Промогруппа: {html.escape(promo_group.name)}') else: message_lines.append('🏷️ Промогруппа: —') @@ -419,7 +419,7 @@ class AdminNotificationService: tariff = await get_tariff_by_id(db, subscription.tariff_id) if tariff: - return tariff.name + return html.escape(tariff.name) except Exception: pass return None @@ -496,7 +496,7 @@ class AdminNotificationService: # Добавляем username только если есть username = getattr(user, 'username', None) if username: - message_lines.append(f'📱 @{username}') + message_lines.append(f'📱 @{html.escape(username)}') message_lines.append(f'📋 {user_status}') @@ -659,13 +659,13 @@ class AdminNotificationService: username = getattr(user, 'username', None) if username: - message_lines.append(f'📱 @{username}') + message_lines.append(f'📱 @{html.escape(username)}') message_lines.append(f'💳 {topup_status}') # Промогруппа -- только название if promo_group: - message_lines.append(f'🏷️ Промогруппа: {promo_group.name}') + message_lines.append(f'🏷️ Промогруппа: {html.escape(promo_group.name)}') message_lines.append('') @@ -699,7 +699,7 @@ class AdminNotificationService: desc = transaction.description if len(desc) > 120: desc = desc[:117] + '...' - detail_lines.append(f'Описание: {desc}') + detail_lines.append(f'Описание: {html.escape(desc)}') if transaction.created_at: detail_lines.append(f'Создана: {format_local_datetime(transaction.created_at, "%d.%m.%Y %H:%M:%S")}') @@ -918,7 +918,7 @@ class AdminNotificationService: 👤 Пользователь: {user_display} 🆔 {user_id_label}: {user_id_display} -📱 Username: @{getattr(user, 'username', None) or 'отсутствует'} +📱 Username: @{html.escape(getattr(user, 'username', None) or 'отсутствует')} {promo_block} @@ -1005,7 +1005,7 @@ class AdminNotificationService: '', f'👤 Пользователь: {user_display}', f'🆔 {user_id_label}: {user_id_display}', - f'📱 Username: @{getattr(user, "username", None) or "отсутствует"}', + f'📱 Username: @{html.escape(getattr(user, "username", None) or "отсутствует")}', '', promo_block, '', @@ -1104,13 +1104,13 @@ class AdminNotificationService: message_lines = [ '📣 ПЕРЕХОД ПО РК', '', - f'🧾 {campaign.name} ({campaign.start_parameter})', + f'🧾 {html.escape(campaign.name)} ({html.escape(campaign.start_parameter)})', '', - f'👤 {full_name} ({telegram_user.id})', + f'👤 {html.escape(full_name)} ({telegram_user.id})', ] if telegram_user.username: - message_lines.append(f'📱 @{telegram_user.username}') + message_lines.append(f'📱 @{html.escape(telegram_user.username)}') message_lines.append(f'📋 {user_status}') @@ -1118,7 +1118,7 @@ class AdminNotificationService: if user: promo_group = await self._get_user_promo_group(db, user) if promo_group: - message_lines.append(f'🏷️ Промогруппа: {promo_group.name}') + message_lines.append(f'🏷️ Промогруппа: {html.escape(promo_group.name)}') message_lines.append('') @@ -1130,7 +1130,7 @@ class AdminNotificationService: tariff = await get_tariff_by_id(db, campaign.tariff_id) if tariff: - tariff_name = tariff.name + tariff_name = html.escape(tariff.name) except Exception: pass @@ -1196,7 +1196,9 @@ class AdminNotificationService: title = '🤖 АВТОМАТИЧЕСКАЯ СМЕНА ПРОМОГРУППЫ' if automatic else '👥 СМЕНА ПРОМОГРУППЫ' initiator_line = None if initiator: - initiator_line = f'👮 Инициатор: {initiator.full_name} (ID: {initiator.telegram_id})' + initiator_line = ( + f'👮 Инициатор: {html.escape(initiator.full_name)} (ID: {initiator.telegram_id})' + ) elif automatic: initiator_line = '🤖 Автоматическое назначение' user_display = self._get_user_display(user) @@ -1208,7 +1210,7 @@ class AdminNotificationService: '', f'👤 Пользователь: {user_display}', f'🆔 {user_id_label}: {user_id_display}', - f'📱 Username: @{getattr(user, "username", None) or "отсутствует"}', + f'📱 Username: @{html.escape(getattr(user, "username", None) or "отсутствует")}', '', self._format_promo_group_block(new_group, title='Новая промогруппа', icon='🏆'), ] @@ -1653,7 +1655,7 @@ class AdminNotificationService: elif status == 'maintenance': if details.get('maintenance_reason'): - message_parts.append(f'🔧 Причина: {details["maintenance_reason"]}') + message_parts.append(f'🔧 Причина: {html.escape(details["maintenance_reason"])}') if details.get('estimated_duration'): message_parts.append(f'⏰ Ожидаемая длительность: {details["estimated_duration"]}') @@ -1710,7 +1712,7 @@ class AdminNotificationService: # Добавляем username только если есть username = getattr(user, 'username', None) if username: - message_lines.append(f'📱 @{username}') + message_lines.append(f'📱 @{html.escape(username)}') # Тариф (если есть) if tariff_name: @@ -1816,7 +1818,7 @@ class AdminNotificationService: username = getattr(user, 'username', None) if username: - message_lines.append(f'📱 @{username}') + message_lines.append(f'📱 @{html.escape(username)}') message_lines.append('') @@ -1871,7 +1873,7 @@ class AdminNotificationService: username = getattr(user, 'username', None) if username: - message_lines.append(f'📱 @{username}') + message_lines.append(f'📱 @{html.escape(username)}') message_lines.extend( [ @@ -1916,7 +1918,7 @@ class AdminNotificationService: message_lines = [ '🛑 МАССОВАЯ БЛОКИРОВКА ПОЛЬЗОВАТЕЛЕЙ', '', - f'👮 Администратор: {admin_name}', + f'👮 Администратор: {html.escape(admin_name)}', f'🆔 ID администратора: {admin_user_id}', '', '📊 Результаты:', diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 35923a42..1c699bde 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -1,4 +1,5 @@ import asyncio +import html from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any @@ -1915,9 +1916,12 @@ class MonitoringService: title = title[:57] + '...' # Детали пользователя: имя, Telegram ID и username - full_name = ticket.user.full_name if ticket.user else 'Unknown' + full_name = html.escape(ticket.user.full_name or '') if ticket.user else 'Unknown' telegram_id_display = ticket.user.telegram_id if ticket.user else '—' - username_display = (ticket.user.username or 'отсутствует') if ticket.user else 'отсутствует' + username_display = html.escape( + (ticket.user.username or 'отсутствует') if ticket.user else 'отсутствует' + ) + safe_title = html.escape(title) if title else '—' text = ( f'⏰ Ожидание ответа на тикет превышено\n\n' @@ -1925,7 +1929,7 @@ class MonitoringService: f'👤 Пользователь: {full_name}\n' f'🆔 Telegram ID: {telegram_id_display}\n' f'📱 Username: @{username_display}\n' - f'📝 Заголовок: {title or "—"}\n' + f'📝 Заголовок: {safe_title}\n' f'⏱️ Ожидает ответа: {waited_minutes} мин\n' ) diff --git a/app/services/poll_service.py b/app/services/poll_service.py index 7be577f1..c482f229 100644 --- a/app/services/poll_service.py +++ b/app/services/poll_service.py @@ -1,4 +1,5 @@ import asyncio +import html from collections.abc import Iterable from types import SimpleNamespace @@ -28,9 +29,9 @@ logger = structlog.get_logger(__name__) def _build_poll_invitation_text(poll: Poll, language: str) -> str: texts = get_texts(language) - lines: list[str] = [f'🗳️ {poll.title}'] + lines: list[str] = [f'🗳️ {html.escape(poll.title)}'] if poll.description: - lines.append(poll.description) + lines.append(html.escape(poll.description)) if poll.reward_enabled and poll.reward_amount_kopeks > 0: reward_line = texts.t( diff --git a/app/services/referral_contest_service.py b/app/services/referral_contest_service.py index 9ff377f7..1a091289 100644 --- a/app/services/referral_contest_service.py +++ b/app/services/referral_contest_service.py @@ -1,4 +1,5 @@ import asyncio +import html from collections.abc import Sequence from datetime import UTC, date, datetime, time, timedelta from zoneinfo import ZoneInfo @@ -281,7 +282,7 @@ class ReferralContestService: lines = [ '🏆 Конкурс рефералов', - f'Название: {contest.title}', + f'Название: {html.escape(contest.title)}', f'Статус: {"финал" if is_final else "дневная сводка"}', f'Временная зона: {tz.key}', f'Всего рефералов: {total_events}', @@ -292,13 +293,13 @@ class ReferralContestService: if leaderboard: for idx, (name, score, _, is_virtual) in enumerate(leaderboard[:5], start=1): virt_mark = ' 👻' if is_virtual else '' - lines.append(f'{idx}. {name}{virt_mark} — {score}') + lines.append(f'{idx}. {html.escape(name)}{virt_mark} — {score}') else: lines.append('Пока нет участников.') if contest.prize_text: lines.append('') - lines.append(f'Приз: {contest.prize_text}') + lines.append(f'Приз: {html.escape(contest.prize_text)}') try: await self.bot.send_message( @@ -330,7 +331,7 @@ class ReferralContestService: return lines = [ - f'🏆 {contest.title}', + f'🏆 {html.escape(contest.title)}', '🏁 Итоги конкурса' if is_final else '📊 Промежуточные итоги', f'Время зоны: {tz.key}', f'Всего участников: {len(leaderboard)}', @@ -340,13 +341,13 @@ class ReferralContestService: if leaderboard: for idx, (name, score, _, _is_virtual) in enumerate(leaderboard[:5], start=1): - lines.append(f'{idx}. {name} — {score}') + lines.append(f'{idx}. {html.escape(name)} — {score}') else: lines.append('Пока нет участников.') if contest.prize_text: lines.append('') - lines.append(f'Приз: {contest.prize_text}') + lines.append(f'Приз: {html.escape(contest.prize_text)}') try: await self.bot.send_message( @@ -372,7 +373,7 @@ class ReferralContestService: ) -> str: status_line = '🏁 Итоги конкурса' if is_final else '📊 Промежуточные итоги' lines = [ - f'🏆 {contest.title}', + f'🏆 {html.escape(contest.title)}', status_line, '', f'Ваше место: {rank}', @@ -383,7 +384,7 @@ class ReferralContestService: if contest.prize_text: lines.append('') - lines.append(f'Призовой фонд: {contest.prize_text}') + lines.append(f'Призовой фонд: {html.escape(contest.prize_text)}') if not is_final: remaining = contest.end_at - datetime.now(UTC) diff --git a/app/services/referral_service.py b/app/services/referral_service.py index c28a1cbd..956bc710 100644 --- a/app/services/referral_service.py +++ b/app/services/referral_service.py @@ -1,3 +1,5 @@ +import html + import structlog from aiogram import Bot from sqlalchemy import delete @@ -116,7 +118,7 @@ async def process_referral_registration(db: AsyncSession, new_user_id: int, refe commission_percent = get_effective_referral_commission_percent(referrer) referral_notification = ( f'🎉 Добро пожаловать!\n\n' - f'Вы перешли по реферальной ссылке пользователя {referrer.full_name}!' + f'Вы перешли по реферальной ссылке пользователя {html.escape(referrer.full_name)}!' ) if settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS > 0: referral_notification += ( @@ -127,7 +129,7 @@ async def process_referral_registration(db: AsyncSession, new_user_id: int, refe inviter_notification = ( f'👥 Новый реферал!\n\n' - f'По вашей ссылке зарегистрировался пользователь {new_user.full_name}!\n\n' + f'По вашей ссылке зарегистрировался пользователь {html.escape(new_user.full_name)}!\n\n' f'💰 Когда он пополнит баланс от {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}, ' ) if settings.REFERRAL_INVITER_BONUS_KOPEKS > 0: @@ -227,7 +229,7 @@ async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_ko if bot: commission_notification = ( f'💰 Реферальная комиссия!\n\n' - f'Ваш реферал {user.full_name} пополнил баланс на ' + f'Ваш реферал {html.escape(user.full_name)} пополнил баланс на ' f'{settings.format_price(topup_amount_kopeks)}\n\n' f'🎁 Ваша комиссия ({commission_percent}%): ' f'{settings.format_price(commission_amount)}\n\n' @@ -344,7 +346,7 @@ async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_ko bonus_breakdown = ' + '.join(bonus_parts) inviter_bonus_notification = ( f'💰 Реферальная награда!\n\n' - f'Ваш реферал {user.full_name} сделал первое пополнение ' + f'Ваш реферал {html.escape(user.full_name)} сделал первое пополнение ' f'на {settings.format_price(topup_amount_kopeks)}!\n\n' f'🎁 Ваша награда: {settings.format_price(inviter_bonus)}' f' ({bonus_breakdown})\n\n' @@ -398,7 +400,7 @@ async def process_referral_topup(db: AsyncSession, user_id: int, topup_amount_ko if bot: commission_notification = ( f'💰 Реферальная комиссия!\n\n' - f'Ваш реферал {user.full_name} пополнил баланс на ' + f'Ваш реферал {html.escape(user.full_name)} пополнил баланс на ' f'{settings.format_price(topup_amount_kopeks)}\n\n' f'🎁 Ваша комиссия ({commission_percent}%): ' f'{settings.format_price(commission_amount)}\n\n' @@ -479,7 +481,7 @@ async def process_referral_purchase( if bot: purchase_commission_notification = ( f'💰 Комиссия с покупки!\n\n' - f'Ваш реферал {user.full_name} совершил покупку на ' + f'Ваш реферал {html.escape(user.full_name)} совершил покупку на ' f'{settings.format_price(purchase_amount_kopeks)}\n\n' f'🎁 Ваша комиссия ({commission_percent}%): ' f'{settings.format_price(commission_amount)}\n\n' diff --git a/app/services/referral_withdrawal_service.py b/app/services/referral_withdrawal_service.py index adcea99b..6b1141ac 100644 --- a/app/services/referral_withdrawal_service.py +++ b/app/services/referral_withdrawal_service.py @@ -3,6 +3,7 @@ с анализом на подозрительную активность (отмывание денег). """ +import html import json from datetime import UTC, datetime, timedelta @@ -656,7 +657,7 @@ class ReferralWithdrawalService: if details.get('suspicious_referrals'): text += '\n🚨 Подозрительные рефералы:\n' for sr in details['suspicious_referrals'][:5]: - text += f'• {sr["name"]}: {sr["deposits_count"]} поп., {sr["deposits_total"] / 100:.0f}₽\n' + text += f'• {html.escape(sr["name"])}: {sr["deposits_count"]} поп., {sr["deposits_total"] / 100:.0f}₽\n' text += f' Флаги: {", ".join(sr["flags"])}\n' # Источники дохода diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py index 3ba80ba3..92910407 100644 --- a/app/services/subscription_auto_purchase_service.py +++ b/app/services/subscription_auto_purchase_service.py @@ -2,6 +2,7 @@ from __future__ import annotations +import html from dataclasses import dataclass from datetime import UTC, datetime, timedelta @@ -1157,7 +1158,7 @@ async def _auto_purchase_daily_tariff( texts = get_texts(getattr(user, 'language', 'ru')) message = ( - f'✅ Суточный тариф «{tariff.name}» активирован!\n\n' + f'✅ Суточный тариф «{html.escape(tariff.name)}» активирован!\n\n' f'💰 Списано: {final_price / 100:.0f} ₽ за первый день\n' f'🔄 Средства будут списываться автоматически раз в сутки.\n\n' f'ℹ️ Вы можете приостановить подписку в любой момент.' @@ -2426,7 +2427,7 @@ async def try_resume_disabled_daily_after_topup( '💳 Списано: {amount}\n' '💰 Остаток: {balance}', ).format( - tariff_name=tariff.name, + tariff_name=html.escape(tariff.name), amount=settings.format_price(daily_price), balance=settings.format_price(user.balance_kopeks), ) diff --git a/app/services/traffic_monitoring_service.py b/app/services/traffic_monitoring_service.py index 0ac17576..b22f8981 100644 --- a/app/services/traffic_monitoring_service.py +++ b/app/services/traffic_monitoring_service.py @@ -4,6 +4,7 @@ """ import asyncio +import html from dataclasses import dataclass from datetime import UTC, datetime, time, timedelta @@ -694,11 +695,9 @@ class TrafficMonitoringServiceV2: db_user = await get_user_by_remnawave_uuid(db, violation.user_uuid) if db_user: user_id_display = db_user.telegram_id or db_user.email or f'#{db_user.id}' - user_info = ( - f'👤 {db_user.full_name or "Без имени"}\n🆔 ID: {user_id_display}\n' - ) + user_info = f'👤 {html.escape(db_user.full_name or "Без имени")}\n🆔 ID: {user_id_display}\n' if db_user.username: - user_info += f'📱 Username: @{db_user.username}\n' + user_info += f'📱 Username: @{html.escape(db_user.username)}\n' if violation.check_type == 'fast': check_type_emoji = '⚡' diff --git a/app/utils/formatting.py b/app/utils/formatting.py index 7df11e0b..2d60c373 100644 --- a/app/utils/formatting.py +++ b/app/utils/formatting.py @@ -1,5 +1,20 @@ """Shared formatting utilities for traffic, price, and period display.""" +import html + + +def safe_html_name(name: str | None) -> str: + """HTML-escape a display name for Telegram HTML messages.""" + return html.escape(name or '') + + +def user_html_link(user) -> str: + """Build an HTML-safe clickable user link for Telegram messages.""" + safe = safe_html_name(user.full_name) + if getattr(user, 'telegram_id', None): + return f'{safe}' + return f'{safe}' + def format_traffic(gb: int) -> str: """Форматирует трафик.""" From cbe630cab0973d7d71dd72603b114807b237ac84 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 08:25:19 +0300 Subject: [PATCH 05/33] refactor: simplify referral invite text to single template Replace 7 fragmented localization keys with one REFERRAL_INVITE_TEXT template. Remove Share button (switch_inline_query). Wrap invite text in blockquote+code for visual quote style with tap-to-copy. Update instruction text in all 5 locales (ru, en, ua, fa, zh). --- app/handlers/referral.py | 53 ++++++++++++-------------------- app/localization/locales/en.json | 8 ++--- app/localization/locales/fa.json | 3 +- app/localization/locales/ru.json | 3 +- app/localization/locales/ua.json | 3 +- app/localization/locales/zh.json | 3 +- 6 files changed, 30 insertions(+), 43 deletions(-) diff --git a/app/handlers/referral.py b/app/handlers/referral.py index 56b0f947..ee91eb32 100644 --- a/app/handlers/referral.py +++ b/app/handlers/referral.py @@ -507,10 +507,9 @@ async def create_invite_message(callback: types.CallbackQuery, db_user: User): bot_referral_link = settings.get_bot_referral_link(db_user.referral_code, bot_username) cabinet_referral_link = settings.get_cabinet_referral_link(db_user.referral_code) - invite_text = texts.t('REFERRAL_INVITE_TITLE', '🎉 Присоединяйся к VPN сервису!') - + bonus_block = '' if settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS > 0: - invite_text += '\n\n' + texts.t( + bonus_block = '\n\n' + texts.t( 'REFERRAL_INVITE_BONUS', '💎 При первом пополнении от {minimum} ты получишь {bonus} бонусом на баланс!', ).format( @@ -518,40 +517,26 @@ async def create_invite_message(callback: types.CallbackQuery, db_user: User): bonus=texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS), ) - invite_text += ( - '\n\n' - + texts.t('REFERRAL_INVITE_FEATURE_FAST', '🚀 Быстрое подключение') - + '\n' - + texts.t('REFERRAL_INVITE_FEATURE_SERVERS', '🌍 Серверы по всему миру') - + '\n' - + texts.t('REFERRAL_INVITE_FEATURE_SECURE', '🔒 Надежная защита') - + '\n\n' - + texts.t('REFERRAL_INVITE_LINK_PROMPT', '👇 Переходи по ссылке:') - + f'\n{bot_referral_link}' - ) - + cabinet_block = '' if cabinet_referral_link: - invite_text += ( - '\n\n' - + texts.t('REFERRAL_INVITE_CABINET_LINK', '🌐 Или через личный кабинет:') - + f'\n{cabinet_referral_link}' - ) + cabinet_block = f'\n\n🌐 {cabinet_referral_link}' - # Compact share text for switch_inline_query (256-char limit) - share_text = invite_text - if len(share_text) > 256: - share_text = texts.t('REFERRAL_INVITE_TITLE', '🎉 Присоединяйся к VPN сервису!') + f'\n\n👇 {bot_referral_link}' - if cabinet_referral_link and len(share_text) + len(cabinet_referral_link) + 5 <= 256: - share_text += f'\n🌐 {cabinet_referral_link}' - share_text = share_text[:256] + invite_text = texts.t( + 'REFERRAL_INVITE_TEXT', + '🎉 Присоединяйся к VPN сервису!{bonus_block}\n\n' + '🚀 Быстрое подключение\n' + '🌍 Серверы по всему миру\n' + '🔒 Надежная защита\n\n' + '👇 Переходи по ссылке:\n' + '{link}{cabinet_block}', + ).format( + bonus_block=bonus_block, + link=bot_referral_link, + cabinet_block=cabinet_block, + ) keyboard = types.InlineKeyboardMarkup( inline_keyboard=[ - [ - types.InlineKeyboardButton( - text=texts.t('REFERRAL_SHARE_BUTTON', '📤 Поделиться'), switch_inline_query=share_text - ) - ], [types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_referrals')], ] ) @@ -563,10 +548,10 @@ async def create_invite_message(callback: types.CallbackQuery, db_user: User): + '\n\n' + texts.t( 'REFERRAL_INVITE_CREATED_INSTRUCTION', - 'Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:', + 'Нажмите на текст ниже, чтобы скопировать:', ) + '\n\n' - f'{html_escape(invite_text)}' + f'
{html_escape(invite_text)}
' ), keyboard, ) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index a89a5e24..a35ddfe9 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1306,7 +1306,7 @@ "REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 First top-up", "REFERRAL_INFO": "\n🤝 Referral program\n\n👥 Invited: {referrals_count} friends\n💰 Earned: {earned_amount}\n\n🔗 Your referral link:\n{referral_link}\n\n🎫 Your promo code:\n{referral_code}\n\n💰 Terms:\n• Per friend: {registration_bonus}\n• Top-up commission: {commission_percent}%\n", "REFERRAL_INVITE_BONUS": "💎 On your first top-up from {minimum} you get {bonus} as a bonus!", - "REFERRAL_INVITE_CREATED_INSTRUCTION": "Tap the “📤 Share” button to send the invite to any chat or copy the text below:", + "REFERRAL_INVITE_CREATED_INSTRUCTION": "Tap the text below to copy:", "REFERRAL_INVITE_CREATED_TITLE": "📝 Invitation created!", "REFERRAL_INVITE_FEATURE_FAST": "🚀 Fast connection", "REFERRAL_INVITE_FEATURE_SECURE": "🔒 Reliable protection", @@ -1314,6 +1314,7 @@ "REFERRAL_INVITE_FOOTER": "📢 Invite friends and earn!", "REFERRAL_INVITE_LINK_PROMPT": "👇 Follow the link:", "REFERRAL_INVITE_MESSAGE": "\n🎯 Invitation to the VPN service\n\nHi! I invite you to an excellent VPN service!\n\n🎁 Use my link to get a bonus: {bonus}\n\n🔗 Join: {link}\n🎫 Or use promo code: {code}\n\n💪 Fast, reliable, affordable!\n", + "REFERRAL_INVITE_TEXT": "🎉 Join the VPN service!{bonus_block}\n\n🚀 Fast connection\n🌍 Servers worldwide\n🔒 Reliable protection\n\n👇 Follow the link:\n{link}{cabinet_block}", "REFERRAL_INVITE_TITLE": "🎉 Join the VPN service!", "REFERRAL_LINK_CAPTION": "🔗 Your referral link:\n{link}", "REFERRAL_LINK_TITLE": "🔗 Your referral link:", @@ -1724,19 +1725,16 @@ "MODEM_PRICE_WITH_DISCOUNT": "Cost: {base_price} {final_price} (for {months} months)\n🎁 Discount {discount}%: -{discount_amount}", "MODEM_PRICE_NO_DISCOUNT": "Cost: {price} (for {months} months)", "MODEM_CONFIRM_ENABLE_BASE": "📡 Confirm modem connection\n\n{price_text}\n\nWhen connecting a modem:\n• An additional device will be added to your subscription\n• Monthly fee will increase by {monthly_price}\n\nConfirm connection?", - "ADMIN_USER_RESTRICTIONS": "⚠️ Restrict", "USER_RESTRICTION_TOPUP_BLOCKED": "🚫 Top-up restricted\n\n{reason}\n\nIf you believe this is an error, you can appeal the decision.", "USER_RESTRICTION_SUBSCRIPTION_BLOCKED": "🚫 Subscription purchase/renewal restricted\n\n{reason}\n\nIf you believe this is an error, you can appeal the decision.", "USER_RESTRICTION_APPEAL_BUTTON": "🆘 Appeal", - "PAUSE_DAILY_BUTTON": "⏸️ Pause subscription", "RESUME_DAILY_BUTTON": "▶️ Resume subscription", "DAILY_SWITCH_WARNING": "⚠️ Warning! You have {days} days left.\nThey will be lost when switching to daily tariff!", "DAILY_SUBSCRIPTION_PAUSED": "⏸️ Subscription paused", "DAILY_SUBSCRIPTION_RESUMED": "▶️ Subscription resumed!", "DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP": "✅ Subscription resumed!\n\nYour daily plan «{tariff_name}» has been resumed after balance top-up.\n\n💳 Charged: {amount}\n💰 Remaining: {balance}", - "WEBHOOK_SUB_EXPIRED": "❌ Subscription expired\n\nYour subscription has ended. Renew to restore VPN access.", "WEBHOOK_SUB_DISABLED": "🚫 Subscription disabled\n\nYour subscription has been disabled by the administrator.", "WEBHOOK_SUB_ENABLED": "✅ Subscription activated\n\nYour subscription is active again. Enjoy!", @@ -1755,4 +1753,4 @@ "WEBHOOK_DEVICE_ADDED": "📱 New device\n\nA new device has been added to your subscription: {device}", "WEBHOOK_DEVICE_DELETED": "📱 Device removed\n\nA device has been removed from your subscription: {device}", "WEBHOOK_CLOSE_BUTTON": "✖️ Close" -} +} \ No newline at end of file diff --git a/app/localization/locales/fa.json b/app/localization/locales/fa.json index ca5ba305..cbbd6920 100644 --- a/app/localization/locales/fa.json +++ b/app/localization/locales/fa.json @@ -1327,7 +1327,7 @@ "REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 اولین شارژ", "REFERRAL_INFO": "🤝 دعوت دوستان\n\nلینک دعوت را به اشتراک بگذارید و از هر خرید کمیسیون بگیرید!", "REFERRAL_INVITE_BONUS": "💎 با اولین شارژ از {minimum} مبلغ {bonus} پاداش دریافت می‌کنی!", - "REFERRAL_INVITE_CREATED_INSTRUCTION": "دکمه «📤 اشتراک‌گذاری» را بزنید یا متن زیر را کپی کنید:", + "REFERRAL_INVITE_CREATED_INSTRUCTION": "روی متن زیر بزنید تا کپی شود:", "REFERRAL_INVITE_CREATED_TITLE": "📝 دعوت‌نامه ایجاد شد!", "REFERRAL_INVITE_FEATURE_FAST": "🚀 اتصال سریع", "REFERRAL_INVITE_FEATURE_SECURE": "🔒 امنیت بالا", @@ -1335,6 +1335,7 @@ "REFERRAL_INVITE_FOOTER": "📢 دوستان را دعوت کنید و درآمد کسب کنید!", "REFERRAL_INVITE_LINK_PROMPT": "👇 از لینک زیر استفاده کن:", "REFERRAL_INVITE_MESSAGE": "\n🎯 دعوت به سرویس VPN\n\nسلام! به سرویس VPN عالی دعوتت می‌کنم!\n\n🎁 با لینک من پاداش بگیر: {bonus}\n\n🔗 لینک: {link}\n🎫 یا کد: {code}\n\n💪 سریع، امن، مقرون‌به‌صرفه!\n", + "REFERRAL_INVITE_TEXT": "🎉 به سرویس VPN بپیوند!{bonus_block}\n\n🚀 اتصال سریع\n🌍 سرورهای سراسر جهان\n🔒 امنیت بالا\n\n👇 از لینک زیر استفاده کن:\n{link}{cabinet_block}", "REFERRAL_INVITE_TITLE": "🎉 به سرویس VPN بپیوند!", "REFERRAL_LINK_CAPTION": "🔗 لینک دعوت شما:\n{link}", "REFERRAL_LINK_TITLE": "🔗 لینک دعوت شما:", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 99dea881..70e5d38e 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1327,7 +1327,7 @@ "REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 Первое пополнение", "REFERRAL_INFO": "\n🤝 Реферальная программа\n\n👥 Приглашено: {referrals_count} друзей\n💰 Заработано: {earned_amount}\n\n🔗 Ваша реферальная ссылка:\n{referral_link}\n\n🎫 Ваш промокод:\n{referral_code}\n\n💰 Условия:\n• За каждого друга: {registration_bonus}\n• Процент с пополнений: {commission_percent}%\n", "REFERRAL_INVITE_BONUS": "💎 При первом пополнении от {minimum} ты получишь {bonus} бонусом на баланс!", - "REFERRAL_INVITE_CREATED_INSTRUCTION": "Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:", + "REFERRAL_INVITE_CREATED_INSTRUCTION": "Нажмите на текст ниже, чтобы скопировать:", "REFERRAL_INVITE_CREATED_TITLE": "📝 Приглашение создано!", "REFERRAL_INVITE_FEATURE_FAST": "🚀 Быстрое подключение", "REFERRAL_INVITE_FEATURE_SECURE": "🔒 Надежная защита", @@ -1335,6 +1335,7 @@ "REFERRAL_INVITE_FOOTER": "📢 Приглашайте друзей и зарабатывайте!", "REFERRAL_INVITE_LINK_PROMPT": "👇 Переходи по ссылке:", "REFERRAL_INVITE_MESSAGE": "\n🎯 Приглашение в VPN сервис\n\nПривет! Приглашаю тебя в отличный VPN сервис!\n\n🎁 По моей ссылке ты получишь бонус: {bonus}\n\n🔗 Переходи: {link}\n🎫 Или используй промокод: {code}\n\n💪 Быстро, надежно, недорого!\n", + "REFERRAL_INVITE_TEXT": "🎉 Присоединяйся к VPN сервису!{bonus_block}\n\n🚀 Быстрое подключение\n🌍 Серверы по всему миру\n🔒 Надежная защита\n\n👇 Переходи по ссылке:\n{link}{cabinet_block}", "REFERRAL_INVITE_TITLE": "🎉 Присоединяйся к VPN сервису!", "REFERRAL_LINK_CAPTION": "🔗 Ваша реферальная ссылка:\n{link}", "REFERRAL_LINK_TITLE": "🔗 Ваша реферальная ссылка:", diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json index 4842e4bc..2bcb7562 100644 --- a/app/localization/locales/ua.json +++ b/app/localization/locales/ua.json @@ -1243,7 +1243,7 @@ "REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 Перше поповнення", "REFERRAL_INFO": "\n🤝 Реферальна програма\n\n👥 Запрошено: {referrals_count} друзів\n💰 Зароблено: {earned_amount}\n\n🔗 Ваше реферальне посилання:\n{referral_link}\n\n🎫 Ваш промокод:\n{referral_code}\n\n💰 Умови:\n• За кожного друга: {registration_bonus}\n• Відсоток з поповнень: {commission_percent}%\n", "REFERRAL_INVITE_BONUS": "💎 При першому поповненні від {minimum} ти отримаєш {bonus} бонусом на баланс!", - "REFERRAL_INVITE_CREATED_INSTRUCTION": "Натисніть кнопку «📤 Поділитися», щоб надіслати запрошення в будь-який чат, або скопіюйте текст нижче:", + "REFERRAL_INVITE_CREATED_INSTRUCTION": "Натисніть на текст нижче, щоб скопіювати:", "REFERRAL_INVITE_CREATED_TITLE": "📝 Запрошення створено!", "REFERRAL_INVITE_FEATURE_FAST": "🚀 Швидке підключення", "REFERRAL_INVITE_FEATURE_SECURE": "🔒 Надійний захист", @@ -1251,6 +1251,7 @@ "REFERRAL_INVITE_FOOTER": "📢 Запрошуйте друзів та заробляйте!", "REFERRAL_INVITE_LINK_PROMPT": "👇 Переходь за посиланням:", "REFERRAL_INVITE_MESSAGE": "\n🎯 Запрошення до VPN сервісу\n\nПривіт! Запрошую тебе у відмінний VPN сервіс!\n\n🎁 За моїм посиланням ти отримаєш бонус: {bonus}\n\n🔗 Переходь: {link}\n🎫 Або використовуй промокод: {code}\n\n💪 Швидко, надійно, недорого!\n", + "REFERRAL_INVITE_TEXT": "🎉 Приєднуйся до VPN сервісу!{bonus_block}\n\n🚀 Швидке підключення\n🌍 Сервери по всьому світу\n🔒 Надійний захист\n\n👇 Переходь за посиланням:\n{link}{cabinet_block}", "REFERRAL_INVITE_TITLE": "🎉 Приєднуйся до VPN сервісу!", "REFERRAL_LINK_CAPTION": "🔗 Ваше реферальне посилання:\n{link}", "REFERRAL_LINK_TITLE": "🔗 Ваше реферальне посилання:", diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json index 0abc11f5..68654fec 100644 --- a/app/localization/locales/zh.json +++ b/app/localization/locales/zh.json @@ -1241,7 +1241,7 @@ "REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉首次充值", "REFERRAL_INFO": "\n🤝推荐计划\n\n👥已邀请:{referrals_count}位朋友\n💰已赚取:{earned_amount}\n\n🔗您的推荐链接:\n{referral_link}\n\n🎫您的优惠码:\n{referral_code}\n\n💰条件:\n•每位朋友:{registration_bonus}\n•充值百分比:{commission_percent}%\n", "REFERRAL_INVITE_BONUS": "💎首次充值{minimum}起,您将获得{bonus}余额奖励!", -"REFERRAL_INVITE_CREATED_INSTRUCTION": "点击“📤分享”按钮将邀请发送到任何聊天,或复制以下文本:", +"REFERRAL_INVITE_CREATED_INSTRUCTION": "点击下方文本即可复制:", "REFERRAL_INVITE_CREATED_TITLE": "📝邀请已创建!", "REFERRAL_INVITE_FEATURE_FAST": "🚀快速连接", "REFERRAL_INVITE_FEATURE_SECURE": "🔒可靠保护", @@ -1249,6 +1249,7 @@ "REFERRAL_INVITE_FOOTER": "📢邀请朋友并赚钱!", "REFERRAL_INVITE_LINK_PROMPT": "👇点击链接:", "REFERRAL_INVITE_MESSAGE": "\n🎯邀请加入VPN服务\n\n嗨!我邀请您加入一个很棒的VPN服务!\n\n🎁通过我的链接,您将获得奖励:{bonus}\n\n🔗点击:{link}\n🎫或使用优惠码:{code}\n\n💪快速、可靠、不贵!\n", +"REFERRAL_INVITE_TEXT": "🎉加入VPN服务!{bonus_block}\n\n🚀快速连接\n🌍全球服务器\n🔒可靠保护\n\n👇点击链接:\n{link}{cabinet_block}", "REFERRAL_INVITE_TITLE": "🎉加入VPN服务!", "REFERRAL_LINK_CAPTION": "🔗您的推荐链接:\n{link}", "REFERRAL_LINK_TITLE": "🔗您的推荐链接:", From 89341baa6243496d3b24c501e227ed156676c052 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 09:31:34 +0300 Subject: [PATCH 06/33] fix: restore connected_squads and admin notification on daily subscription resume When a daily subscription is resumed after user deletion from RemnaWave panel and deactivation sync, connected_squads were cleared but never restored, causing internal squads to not be assigned. Also, admin notifications were missing from the Telegram bot handler path. Fixed across all 5 resume code paths: - cabinet /pause endpoint - miniapp /subscription/daily/toggle-pause endpoint - bot handle_toggle_daily_subscription_pause handler - DailySubscriptionService._process_single_charge - try_resume_disabled_daily_after_topup auto-resume Changes in each path: - Restore connected_squads from tariff.allowed_squads (fallback: all available servers) - Branch create/update based on remnawave_uuid presence - Follow-up PATCH after POST to ensure internal squads are assigned - Use limit=10000 in get_all_server_squads to avoid silent truncation - Separate try/except for squad restore vs RemnaWave sync for resilience - Add admin notification in bot handler (was missing) --- app/cabinet/routes/subscription.py | 76 +++++++++++++++++-- app/handlers/subscription/purchase.py | 72 ++++++++++++++++-- app/services/daily_subscription_service.py | 49 ++++++++++-- .../subscription_auto_purchase_service.py | 57 ++++++++++++-- app/webapi/routes/miniapp.py | 76 +++++++++++++++++-- 5 files changed, 299 insertions(+), 31 deletions(-) diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py index 16990032..1f6bd325 100644 --- a/app/cabinet/routes/subscription.py +++ b/app/cabinet/routes/subscription.py @@ -4394,17 +4394,81 @@ async def toggle_subscription_pause( # Sync with RemnaWave only when resuming from DISABLED state if not new_paused_state and was_disabled: + # Restore connected_squads from tariff if cleared by deactivation sync + try: + if not user.subscription.connected_squads: + squads = tariff.allowed_squads or [] + if not squads: + from app.database.crud.server_squad import get_all_server_squads + + all_servers, _ = await get_all_server_squads(db, available_only=True, limit=10000) + squads = [s.squad_uuid for s in all_servers if s.squad_uuid] + if squads: + user.subscription.connected_squads = squads + await db.commit() + await db.refresh(user.subscription) + except Exception as sq_err: + logger.warning('Failed to restore connected_squads', error=sq_err) + + # Sync with RemnaWave try: subscription_service = SubscriptionService() - await subscription_service.create_remnawave_user( - db, - user.subscription, - reset_traffic=False, - reset_reason=None, - ) + if getattr(user, 'remnawave_uuid', None): + await subscription_service.update_remnawave_user( + db, + user.subscription, + reset_traffic=False, + reset_reason=None, + sync_squads=True, + ) + else: + await subscription_service.create_remnawave_user( + db, + user.subscription, + reset_traffic=False, + reset_reason=None, + ) + # POST /api/users may ignore activeInternalSquads — + # follow up with PATCH to ensure internal squads are assigned + await db.refresh(user) + if getattr(user, 'remnawave_uuid', None) and user.subscription.connected_squads: + try: + await subscription_service.update_remnawave_user( + db, + user.subscription, + reset_traffic=False, + sync_squads=True, + ) + except Exception as squad_err: + logger.warning('Failed to sync squads after user creation', error=squad_err) except Exception as e: logger.error('Error syncing RemnaWave user on resume', error=e) + # Send admin notification about daily subscription resume + if resume_transaction is not None: + try: + from app.bot_factory import create_bot + from app.services.admin_notification_service import AdminNotificationService + + if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN: + bot = create_bot() + try: + notification_service = AdminNotificationService(bot) + await notification_service.send_subscription_purchase_notification( + db=db, + user=user, + subscription=user.subscription, + transaction=resume_transaction, + period_days=1, + was_trial_conversion=False, + amount_kopeks=daily_price, + purchase_type='renewal', + ) + finally: + await bot.session.close() + except Exception as notif_err: + logger.error('Failed to send admin notification for daily resume', error=notif_err) + if new_paused_state: message = 'Daily subscription paused' else: diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index cd268e55..55015bd1 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -2921,6 +2921,7 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery, return if needs_resume: + resume_transaction = None # Списываем суточную оплату ДО активации (чтобы не было бесплатного дня) if daily_price > 0 and is_inactive: from app.database.crud.user import subtract_user_balance @@ -2946,7 +2947,7 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery, from app.database.models import TransactionType try: - await create_transaction( + resume_transaction = await create_transaction( db=db, user_id=db_user.id, type=TransactionType.SUBSCRIPTION_PAYMENT, @@ -2961,22 +2962,79 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery, subscription = await resume_daily_subscription(db, subscription) message = texts.t('DAILY_SUBSCRIPTION_RESUMED', '▶️ Подписка возобновлена!') + # Восстанавливаем connected_squads из тарифа, если очищены деактивацией + try: + if not subscription.connected_squads: + squads = tariff.allowed_squads or [] + if not squads: + from app.database.crud.server_squad import get_all_server_squads + + all_servers, _ = await get_all_server_squads(db, available_only=True, limit=10000) + squads = [s.squad_uuid for s in all_servers if s.squad_uuid] + if squads: + subscription.connected_squads = squads + await db.commit() + await db.refresh(subscription) + except Exception as sq_err: + logger.warning('Не удалось восстановить connected_squads', error=sq_err) + # Синхронизируем с Remnawave - активируем пользователя try: from app.services.subscription_service import SubscriptionService subscription_service = SubscriptionService() - await subscription_service.create_remnawave_user( - db, - subscription, - reset_traffic=False, - reset_reason=None, - ) + if getattr(db_user, 'remnawave_uuid', None): + await subscription_service.update_remnawave_user( + db, + subscription, + reset_traffic=False, + reset_reason=None, + sync_squads=True, + ) + else: + await subscription_service.create_remnawave_user( + db, + subscription, + reset_traffic=False, + reset_reason=None, + ) + # POST может игнорировать activeInternalSquads — отправляем PATCH + await db.refresh(db_user) + if getattr(db_user, 'remnawave_uuid', None) and subscription.connected_squads: + try: + await subscription_service.update_remnawave_user( + db, + subscription, + reset_traffic=False, + sync_squads=True, + ) + except Exception as patch_err: + logger.warning('Не удалось синхронизировать сквады после создания', error=patch_err) logger.info( '✅ Синхронизировано с Remnawave после возобновления суточной подписки', subscription_id=subscription.id ) except Exception as e: logger.error('Ошибка синхронизации с Remnawave при возобновлении', error=e) + + # Отправляем уведомление администраторам о возобновлении суточной подписки + if resume_transaction is not None: + try: + from app.services.admin_notification_service import AdminNotificationService + + if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN: + notification_service = AdminNotificationService(callback.bot) + await notification_service.send_subscription_purchase_notification( + db=db, + user=db_user, + subscription=subscription, + transaction=resume_transaction, + period_days=1, + was_trial_conversion=False, + amount_kopeks=daily_price, + purchase_type='renewal', + ) + except Exception as notif_err: + logger.error('Не удалось отправить уведомление администраторам при возобновлении', error=notif_err) else: # Подписка активна, ставим на паузу subscription = await toggle_daily_subscription_pause(db, subscription) diff --git a/app/services/daily_subscription_service.py b/app/services/daily_subscription_service.py index c7f92cb5..9a47c0d1 100644 --- a/app/services/daily_subscription_service.py +++ b/app/services/daily_subscription_service.py @@ -197,17 +197,54 @@ class DailySubscriptionService: user_id_display=user_id_display, ) + # Восстанавливаем connected_squads из тарифа, если очищены деактивацией + try: + if not subscription.connected_squads: + squads = tariff.allowed_squads or [] + if not squads: + from app.database.crud.server_squad import get_all_server_squads + + all_servers, _ = await get_all_server_squads(db, available_only=True, limit=10000) + squads = [s.squad_uuid for s in all_servers if s.squad_uuid] + if squads: + subscription.connected_squads = squads + await db.commit() + await db.refresh(subscription) + except Exception as sq_err: + logger.warning('Не удалось восстановить connected_squads', error=sq_err) + # Синхронизируем с Remnawave (обновляем срок подписки) try: from app.services.subscription_service import SubscriptionService subscription_service = SubscriptionService() - await subscription_service.create_remnawave_user( - db, - subscription, - reset_traffic=False, - reset_reason=None, - ) + if getattr(user, 'remnawave_uuid', None): + await subscription_service.update_remnawave_user( + db, + subscription, + reset_traffic=False, + reset_reason=None, + sync_squads=True, + ) + else: + await subscription_service.create_remnawave_user( + db, + subscription, + reset_traffic=False, + reset_reason=None, + ) + # POST может игнорировать activeInternalSquads — отправляем PATCH + await db.refresh(user) + if getattr(user, 'remnawave_uuid', None) and subscription.connected_squads: + try: + await subscription_service.update_remnawave_user( + db, + subscription, + reset_traffic=False, + sync_squads=True, + ) + except Exception as patch_err: + logger.warning('Не удалось синхронизировать сквады после создания', error=patch_err) except Exception as e: logger.warning('Не удалось обновить Remnawave', error=e) diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py index 92910407..c47c9429 100644 --- a/app/services/subscription_auto_purchase_service.py +++ b/app/services/subscription_auto_purchase_service.py @@ -2376,15 +2376,60 @@ async def try_resume_disabled_daily_after_topup( error=error, ) + # Restore connected_squads from tariff if cleared by deactivation sync + try: + if not subscription.connected_squads: + squads = tariff.allowed_squads or [] + if not squads: + from app.database.crud.server_squad import get_all_server_squads + + all_servers, _ = await get_all_server_squads(db, available_only=True, limit=10000) + squads = [s.squad_uuid for s in all_servers if s.squad_uuid] + if squads: + subscription.connected_squads = squads + await db.commit() + await db.refresh(subscription) + except Exception as error: + logger.warning( + '⚠️ Авто-возобновление daily: не удалось восстановить connected_squads', + format_user_id=_format_user_id(user), + error=error, + ) + # Sync with RemnaWave try: subscription_service = SubscriptionService() - await subscription_service.create_remnawave_user( - db, - subscription, - reset_traffic=False, - reset_reason=None, - ) + if getattr(user, 'remnawave_uuid', None): + await subscription_service.update_remnawave_user( + db, + subscription, + reset_traffic=False, + reset_reason=None, + sync_squads=True, + ) + else: + await subscription_service.create_remnawave_user( + db, + subscription, + reset_traffic=False, + reset_reason=None, + ) + # POST may ignore activeInternalSquads — follow up with PATCH + await db.refresh(user) + if getattr(user, 'remnawave_uuid', None) and subscription.connected_squads: + try: + await subscription_service.update_remnawave_user( + db, + subscription, + reset_traffic=False, + sync_squads=True, + ) + except Exception as patch_err: + logger.warning( + '⚠️ Авто-возобновление daily: не удалось синхронизировать сквады', + format_user_id=_format_user_id(user), + error=patch_err, + ) except Exception as error: logger.error( '⚠️ Авто-возобновление daily: не удалось обновить RemnaWave', diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 726dfabd..adbe9894 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -7268,17 +7268,81 @@ async def toggle_daily_subscription_pause_endpoint( # Синхронизация с RemnaWave только при возобновлении из DISABLED/EXPIRED if not new_paused_state and was_disabled: + # Restore connected_squads from tariff if cleared by deactivation sync + try: + if not subscription.connected_squads: + squads = tariff.allowed_squads or [] + if not squads: + from app.database.crud.server_squad import get_all_server_squads + + all_servers, _ = await get_all_server_squads(db, available_only=True, limit=10000) + squads = [s.squad_uuid for s in all_servers if s.squad_uuid] + if squads: + subscription.connected_squads = squads + await db.commit() + await db.refresh(subscription) + except Exception as sq_err: + logger.warning('Failed to restore connected_squads (miniapp)', error=sq_err) + + # Sync with RemnaWave try: service = SubscriptionService() - await service.create_remnawave_user( - db, - subscription, - reset_traffic=False, - reset_reason=None, - ) + if getattr(user, 'remnawave_uuid', None): + await service.update_remnawave_user( + db, + subscription, + reset_traffic=False, + reset_reason=None, + sync_squads=True, + ) + else: + await service.create_remnawave_user( + db, + subscription, + reset_traffic=False, + reset_reason=None, + ) + # POST /api/users may ignore activeInternalSquads — + # follow up with PATCH to ensure internal squads are assigned + await db.refresh(user) + if getattr(user, 'remnawave_uuid', None) and subscription.connected_squads: + try: + await service.update_remnawave_user( + db, + subscription, + reset_traffic=False, + sync_squads=True, + ) + except Exception as squad_err: + logger.warning('Failed to sync squads after user creation (miniapp)', error=squad_err) except Exception as e: logger.error('Ошибка синхронизации с RemnaWave при возобновлении', error=e) + # Send admin notification about daily subscription resume + if resume_transaction is not None: + try: + from app.bot_factory import create_bot + from app.services.admin_notification_service import AdminNotificationService + + if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN: + bot = create_bot() + try: + notification_service = AdminNotificationService(bot) + await notification_service.send_subscription_purchase_notification( + db=db, + user=user, + subscription=subscription, + transaction=resume_transaction, + period_days=1, + was_trial_conversion=False, + amount_kopeks=daily_price, + purchase_type='renewal', + ) + finally: + await bot.session.close() + except Exception as notif_err: + logger.error('Failed to send admin notification for daily resume (miniapp)', error=notif_err) + lang = getattr(user, 'language', settings.DEFAULT_LANGUAGE) if new_paused_state: message = 'Суточная подписка приостановлена' if lang == 'ru' else 'Daily subscription paused' From b93240393f739f1243bbbfdd4298b90974b8fa87 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 10:51:12 +0300 Subject: [PATCH 07/33] feat: add news articles module with admin CRUD and public API - NewsArticle model with composite index, Alembic migration - Admin routes: list, create, update, delete, toggle publish/featured - Public routes: paginated list with category filter, article detail with view counter - Pydantic schemas with strict hex color validation, slug auto-generation - IntegrityError handling for slug race conditions --- app/cabinet/routes/__init__.py | 4 + app/cabinet/routes/admin_news.py | 312 ++++++++++++++++++ app/cabinet/routes/news.py | 126 +++++++ app/cabinet/schemas/news.py | 175 ++++++++++ app/database/crud/news.py | 225 +++++++++++++ app/database/models.py | 32 ++ .../versions/0046_add_news_articles.py | 49 +++ 7 files changed, 923 insertions(+) create mode 100644 app/cabinet/routes/admin_news.py create mode 100644 app/cabinet/routes/news.py create mode 100644 app/cabinet/schemas/news.py create mode 100644 app/database/crud/news.py create mode 100644 migrations/alembic/versions/0046_add_news_articles.py diff --git a/app/cabinet/routes/__init__.py b/app/cabinet/routes/__init__.py index d0924ddf..a061038e 100644 --- a/app/cabinet/routes/__init__.py +++ b/app/cabinet/routes/__init__.py @@ -13,6 +13,7 @@ from .admin_channels import router as admin_channels_router from .admin_email_templates import router as admin_email_templates_router from .admin_landings import router as admin_landings_router from .admin_menu_layout import router as admin_menu_layout_router +from .admin_news import router as admin_news_router from .admin_partners import router as admin_partners_router from .admin_payment_methods import router as admin_payment_methods_router from .admin_payments import router as admin_payments_router @@ -42,6 +43,7 @@ from .gift import router as gift_router from .info import router as info_router from .landing import router as landing_router from .media import router as media_router +from .news import router as news_router from .notifications import router as notifications_router from .oauth import router as oauth_router from .partner_application import router as partner_application_router @@ -85,6 +87,7 @@ router.include_router(info_router) router.include_router(branding_router) router.include_router(landing_router) router.include_router(media_router) +router.include_router(news_router) # Wheel routes router.include_router(wheel_router) @@ -126,6 +129,7 @@ router.include_router(admin_apps_router) router.include_router(admin_roles_router) router.include_router(admin_policies_router) router.include_router(admin_audit_log_router) +router.include_router(admin_news_router) # WebSocket route router.include_router(websocket_router) diff --git a/app/cabinet/routes/admin_news.py b/app/cabinet/routes/admin_news.py new file mode 100644 index 00000000..c201ec75 --- /dev/null +++ b/app/cabinet/routes/admin_news.py @@ -0,0 +1,312 @@ +"""Admin routes for managing news articles in cabinet.""" + +from datetime import UTC, datetime + +import structlog +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.crud.news import ( + create_news_article, + delete_news_article, + get_all_news, + get_all_news_count, + get_news_article_by_id, + get_news_article_by_slug, + update_news_article, +) +from app.database.models import User + +from ..dependencies import get_cabinet_db, require_permission +from ..schemas.news import ( + NewsArticleListItem, + NewsArticleResponse, + NewsCreateRequest, + NewsListResponse, + NewsToggleResponse, + NewsUpdateRequest, +) + + +logger = structlog.get_logger(__name__) + +router = APIRouter(prefix='/admin/news', tags=['Cabinet Admin News']) + + +def _article_to_detail(article) -> dict: + """Convert NewsArticle ORM instance to full detail dict.""" + author_name = None + if article.author: + author_name = article.author.first_name or article.author.username or f'#{article.author.id}' + + return { + 'id': article.id, + 'title': article.title, + 'slug': article.slug, + 'content': article.content, + 'excerpt': article.excerpt, + 'category': article.category, + 'category_color': article.category_color, + 'tag': article.tag, + 'featured_image_url': article.featured_image_url, + 'is_published': article.is_published, + 'is_featured': article.is_featured, + 'published_at': article.published_at, + 'read_time_minutes': article.read_time_minutes, + 'views_count': article.views_count, + 'author_name': author_name, + 'created_at': article.created_at, + 'updated_at': article.updated_at, + } + + +@router.get('', response_model=NewsListResponse) +async def list_all_news( + admin: User = Depends(require_permission('news:read')), + db: AsyncSession = Depends(get_cabinet_db), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), +) -> NewsListResponse: + """Get all news articles (admin view, includes unpublished).""" + try: + articles = await get_all_news(db, limit=limit, offset=offset) + total = await get_all_news_count(db) + + items = [ + NewsArticleListItem( + id=a.id, + title=a.title, + slug=a.slug, + excerpt=a.excerpt, + category=a.category, + category_color=a.category_color, + tag=a.tag, + featured_image_url=a.featured_image_url, + is_published=a.is_published, + is_featured=a.is_featured, + published_at=a.published_at, + read_time_minutes=a.read_time_minutes, + views_count=a.views_count, + ) + for a in articles + ] + + return NewsListResponse(items=items, total=total) + except HTTPException: + raise + except Exception as e: + logger.error('Failed to list all news', error=str(e), exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Failed to load news articles', + ) + + +@router.get('/{article_id}', response_model=NewsArticleResponse) +async def get_article_detail( + article_id: int, + admin: User = Depends(require_permission('news:read')), + db: AsyncSession = Depends(get_cabinet_db), +) -> NewsArticleResponse: + """Get a single news article by ID (admin view).""" + article = await get_news_article_by_id(db, article_id) + if not article: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail='Article not found', + ) + + return NewsArticleResponse(**_article_to_detail(article)) + + +@router.post('', response_model=NewsArticleResponse, status_code=status.HTTP_201_CREATED) +async def create_article( + request: NewsCreateRequest, + admin: User = Depends(require_permission('news:create')), + db: AsyncSession = Depends(get_cabinet_db), +) -> NewsArticleResponse: + """Create a new news article.""" + # Check slug uniqueness + existing = await get_news_article_by_slug(db, request.slug) + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail='An article with this slug already exists', + ) + + try: + article = await create_news_article( + db, + title=request.title, + slug=request.slug, + content=request.content, + excerpt=request.excerpt, + category=request.category, + category_color=request.category_color, + tag=request.tag, + featured_image_url=request.featured_image_url, + is_published=request.is_published, + is_featured=request.is_featured, + published_at=None, + read_time_minutes=request.read_time_minutes, + created_by=admin.id, + ) + + # Reload with author relationship + article = await get_news_article_by_id(db, article.id) + if not article: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to reload article') + return NewsArticleResponse(**_article_to_detail(article)) + except HTTPException: + raise + except IntegrityError: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail='An article with this slug already exists', + ) + except Exception as e: + logger.error('Failed to create news article', error=str(e), exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Failed to create article', + ) + + +@router.put('/{article_id}', response_model=NewsArticleResponse) +async def update_article( + article_id: int, + request: NewsUpdateRequest, + admin: User = Depends(require_permission('news:edit')), + db: AsyncSession = Depends(get_cabinet_db), +) -> NewsArticleResponse: + """Update an existing news article.""" + article = await get_news_article_by_id(db, article_id) + if not article: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail='Article not found', + ) + + # Check slug uniqueness if slug is being changed + if request.slug and request.slug != article.slug: + existing = await get_news_article_by_slug(db, request.slug) + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail='An article with this slug already exists', + ) + + try: + update_data = request.model_dump(exclude_unset=True) + article = await update_news_article(db, article, **update_data) + + # Reload with author relationship + article = await get_news_article_by_id(db, article.id) + if not article: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to reload article') + return NewsArticleResponse(**_article_to_detail(article)) + except HTTPException: + raise + except IntegrityError: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail='An article with this slug already exists', + ) + except Exception as e: + logger.error('Failed to update news article', article_id=article_id, error=str(e), exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Failed to update article', + ) + + +@router.delete('/{article_id}', status_code=status.HTTP_204_NO_CONTENT) +async def remove_article( + article_id: int, + admin: User = Depends(require_permission('news:delete')), + db: AsyncSession = Depends(get_cabinet_db), +) -> None: + """Delete a news article.""" + article = await get_news_article_by_id(db, article_id) + if not article: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail='Article not found', + ) + + try: + await delete_news_article(db, article) + except Exception as e: + logger.error('Failed to delete news article', article_id=article_id, error=str(e), exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Failed to delete article', + ) + + +@router.post('/{article_id}/publish', response_model=NewsToggleResponse) +async def toggle_publish( + article_id: int, + admin: User = Depends(require_permission('news:edit')), + db: AsyncSession = Depends(get_cabinet_db), +) -> NewsToggleResponse: + """Toggle the published status of a news article.""" + article = await get_news_article_by_id(db, article_id) + if not article: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail='Article not found', + ) + + new_published = not article.is_published + + update_kwargs: dict = {'is_published': new_published} + # Auto-set published_at on first publish + if new_published and article.published_at is None: + update_kwargs['published_at'] = datetime.now(UTC) + + try: + article = await update_news_article(db, article, **update_kwargs) + return NewsToggleResponse( + id=article.id, + is_published=article.is_published, + is_featured=article.is_featured, + published_at=article.published_at, + ) + except Exception as e: + logger.error('Failed to toggle publish', article_id=article_id, error=str(e), exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Failed to toggle publish status', + ) + + +@router.post('/{article_id}/feature', response_model=NewsToggleResponse) +async def toggle_featured( + article_id: int, + admin: User = Depends(require_permission('news:edit')), + db: AsyncSession = Depends(get_cabinet_db), +) -> NewsToggleResponse: + """Toggle the featured status of a news article.""" + article = await get_news_article_by_id(db, article_id) + if not article: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail='Article not found', + ) + + try: + article = await update_news_article(db, article, is_featured=not article.is_featured) + return NewsToggleResponse( + id=article.id, + is_published=article.is_published, + is_featured=article.is_featured, + published_at=article.published_at, + ) + except Exception as e: + logger.error('Failed to toggle featured', article_id=article_id, error=str(e), exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Failed to toggle featured status', + ) diff --git a/app/cabinet/routes/news.py b/app/cabinet/routes/news.py new file mode 100644 index 00000000..4bf6f11c --- /dev/null +++ b/app/cabinet/routes/news.py @@ -0,0 +1,126 @@ +"""Public news routes for cabinet - user-facing news/blog section.""" + +import structlog +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.crud.news import ( + get_news_article_by_slug, + get_news_categories, + get_published_news, + get_published_news_count, + increment_views, +) +from app.database.models import User + +from ..dependencies import get_cabinet_db, get_current_cabinet_user +from ..schemas.news import ( + NewsArticleListItem, + NewsArticleResponse, + NewsListResponse, +) + + +logger = structlog.get_logger(__name__) + +router = APIRouter(prefix='/news', tags=['Cabinet News']) + + +def _article_to_response(article, *, include_content: bool = True) -> dict: + """Convert NewsArticle ORM instance to response dict.""" + author_name = None + if article.author: + author_name = article.author.first_name or article.author.username or f'#{article.author.id}' + + data = { + 'id': article.id, + 'title': article.title, + 'slug': article.slug, + 'excerpt': article.excerpt, + 'category': article.category, + 'category_color': article.category_color, + 'tag': article.tag, + 'featured_image_url': article.featured_image_url, + 'is_published': article.is_published, + 'is_featured': article.is_featured, + 'published_at': article.published_at, + 'read_time_minutes': article.read_time_minutes, + 'views_count': article.views_count, + } + + if include_content: + data['content'] = article.content + data['author_name'] = author_name + data['created_at'] = article.created_at + data['updated_at'] = article.updated_at + + return data + + +# NOTE: /categories MUST be declared before /{slug} to avoid route conflict +@router.get('/categories', response_model=list[str]) +async def list_categories( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +) -> list[str]: + """Get list of distinct news categories.""" + try: + return await get_news_categories(db) + except Exception as e: + logger.error('Failed to get news categories', error=str(e), exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Failed to load categories', + ) + + +@router.get('', response_model=NewsListResponse) +async def list_published_news( + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), + category: str | None = Query(None, max_length=100), + limit: int = Query(20, ge=1, le=100), + offset: int = Query(0, ge=0), +) -> NewsListResponse: + """Get paginated list of published news articles.""" + try: + articles = await get_published_news(db, category=category, limit=limit, offset=offset) + total = await get_published_news_count(db, category=category) + categories = await get_news_categories(db) + + items = [NewsArticleListItem(**_article_to_response(a, include_content=False)) for a in articles] + + return NewsListResponse(items=items, total=total, categories=categories) + except HTTPException: + raise + except Exception as e: + logger.error('Failed to list published news', error=str(e), exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Failed to load news', + ) + + +@router.get('/{slug}', response_model=NewsArticleResponse) +async def get_article_by_slug( + slug: str, + user: User = Depends(get_current_cabinet_user), + db: AsyncSession = Depends(get_cabinet_db), +) -> NewsArticleResponse: + """Get a single published news article by slug. Increments view count.""" + article = await get_news_article_by_slug(db, slug) + + if not article or not article.is_published: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail='Article not found', + ) + + # Increment views in background-safe manner (no error propagation) + try: + await increment_views(db, article.id) + await db.refresh(article) + except Exception: + logger.warning('Failed to increment views', article_id=article.id) + + return NewsArticleResponse(**_article_to_response(article, include_content=True)) diff --git a/app/cabinet/schemas/news.py b/app/cabinet/schemas/news.py new file mode 100644 index 00000000..2ef6d0e3 --- /dev/null +++ b/app/cabinet/schemas/news.py @@ -0,0 +1,175 @@ +"""Schemas for news articles in cabinet.""" + +import re +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +# Cyrillic-to-Latin transliteration map for slug generation +_TRANSLIT_MAP: dict[str, str] = { + 'а': 'a', + 'б': 'b', + 'в': 'v', + 'г': 'g', + 'д': 'd', + 'е': 'e', + 'ё': 'yo', + 'ж': 'zh', + 'з': 'z', + 'и': 'i', + 'й': 'y', + 'к': 'k', + 'л': 'l', + 'м': 'm', + 'н': 'n', + 'о': 'o', + 'п': 'p', + 'р': 'r', + 'с': 's', + 'т': 't', + 'у': 'u', + 'ф': 'f', + 'х': 'kh', + 'ц': 'ts', + 'ч': 'ch', + 'ш': 'sh', + 'щ': 'shch', + 'ъ': '', + 'ы': 'y', + 'ь': '', + 'э': 'e', + 'ю': 'yu', + 'я': 'ya', +} + + +def _slugify(title: str) -> str: + """Generate a URL-safe slug from a title, transliterating Cyrillic.""" + slug = title.lower() + result: list[str] = [] + for ch in slug: + if ch in _TRANSLIT_MAP: + result.append(_TRANSLIT_MAP[ch]) + elif ch.isascii() and (ch.isalnum() or ch in '-_'): + result.append(ch) + elif ch == ' ': + result.append('-') + slug = ''.join(result) + slug = re.sub(r'-+', '-', slug).strip('-') + return slug or 'untitled' + + +class NewsArticleResponse(BaseModel): + """Full news article response (detail view).""" + + id: int + title: str + slug: str + content: str + excerpt: str | None + category: str + category_color: str + tag: str | None + featured_image_url: str | None + is_published: bool + is_featured: bool + published_at: datetime | None + read_time_minutes: int + views_count: int + author_name: str | None = None + created_at: datetime + updated_at: datetime | None + + model_config = ConfigDict(from_attributes=True) + + +class NewsArticleListItem(BaseModel): + """Compact news article for list views.""" + + id: int + title: str + slug: str + excerpt: str | None + category: str + category_color: str + tag: str | None + featured_image_url: str | None + is_published: bool + is_featured: bool + published_at: datetime | None + read_time_minutes: int + views_count: int + + model_config = ConfigDict(from_attributes=True) + + +class NewsListResponse(BaseModel): + """Paginated list of news articles.""" + + items: list[NewsArticleListItem] + total: int + categories: list[str] = Field(default_factory=list) + + +class NewsCreateRequest(BaseModel): + """Request to create a news article.""" + + title: str = Field(..., min_length=1, max_length=500) + slug: str | None = Field(None, max_length=500) + content: str = Field(default='', max_length=500_000) + excerpt: str | None = Field(None, max_length=1000) + category: str = Field(..., min_length=1, max_length=100) + category_color: str = Field(default='#00e5a0', max_length=20) + tag: str | None = Field(None, max_length=50) + featured_image_url: str | None = Field(None, max_length=2000) + is_published: bool = False + is_featured: bool = False + read_time_minutes: int = Field(default=1, ge=1, le=60) + + @field_validator('category_color') + @classmethod + def validate_hex_color(cls, v: str) -> str: + if not re.match(r'^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$', v): + raise ValueError('category_color must be a valid hex color (e.g. #00e5a0)') + return v + + @field_validator('slug', mode='before') + @classmethod + def generate_slug(cls, v: str | None, info) -> str: + if v: + return v + title = info.data.get('title', '') + return _slugify(title) + + +class NewsUpdateRequest(BaseModel): + """Request to update a news article.""" + + title: str | None = Field(None, min_length=1, max_length=500) + slug: str | None = Field(None, max_length=500) + content: str | None = Field(None, max_length=500_000) + excerpt: str | None = None + category: str | None = Field(None, min_length=1, max_length=100) + category_color: str | None = Field(None, max_length=20) + tag: str | None = None + featured_image_url: str | None = Field(None, max_length=2000) + is_published: bool | None = None + is_featured: bool | None = None + read_time_minutes: int | None = Field(None, ge=1, le=60) + + @field_validator('category_color') + @classmethod + def validate_hex_color(cls, v: str | None) -> str | None: + if v is not None and not re.match(r'^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$', v): + raise ValueError('category_color must be a valid hex color (e.g. #00e5a0)') + return v + + +class NewsToggleResponse(BaseModel): + """Response after toggling publish/featured status.""" + + id: int + is_published: bool + is_featured: bool + published_at: datetime | None diff --git a/app/database/crud/news.py b/app/database/crud/news.py new file mode 100644 index 00000000..466f9a62 --- /dev/null +++ b/app/database/crud/news.py @@ -0,0 +1,225 @@ +"""CRUD operations for news articles.""" + +from datetime import UTC, datetime + +import structlog +from sqlalchemy import delete, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.models import NewsArticle + + +logger = structlog.get_logger(__name__) + + +async def create_news_article( + db: AsyncSession, + *, + title: str, + slug: str, + content: str = '', + excerpt: str | None = None, + category: str = '', + category_color: str = '#00e5a0', + tag: str | None = None, + featured_image_url: str | None = None, + is_published: bool = False, + is_featured: bool = False, + published_at: datetime | None = None, + read_time_minutes: int = 1, + created_by: int | None = None, +) -> NewsArticle: + """Create a new news article.""" + # Auto-set published_at when publishing without explicit date + if is_published and published_at is None: + published_at = datetime.now(UTC) + + article = NewsArticle( + title=title, + slug=slug, + content=content, + excerpt=excerpt, + category=category, + category_color=category_color, + tag=tag, + featured_image_url=featured_image_url, + is_published=is_published, + is_featured=is_featured, + published_at=published_at, + read_time_minutes=read_time_minutes, + created_by=created_by, + ) + + db.add(article) + await db.commit() + await db.refresh(article) + + logger.info( + 'Created news article', + article_id=article.id, + slug=article.slug, + is_published=article.is_published, + ) + return article + + +async def get_news_article_by_id(db: AsyncSession, article_id: int) -> NewsArticle | None: + """Get a news article by ID with author relationship.""" + result = await db.execute( + select(NewsArticle).options(selectinload(NewsArticle.author)).where(NewsArticle.id == article_id) + ) + return result.scalar_one_or_none() + + +async def get_news_article_by_slug(db: AsyncSession, slug: str) -> NewsArticle | None: + """Get a news article by slug with author relationship.""" + result = await db.execute( + select(NewsArticle).options(selectinload(NewsArticle.author)).where(NewsArticle.slug == slug) + ) + return result.scalar_one_or_none() + + +async def get_published_news( + db: AsyncSession, + *, + category: str | None = None, + limit: int = 20, + offset: int = 0, +) -> list[NewsArticle]: + """Get published news articles, ordered by published_at descending.""" + stmt = ( + select(NewsArticle) + .options(selectinload(NewsArticle.author)) + .where(NewsArticle.is_published.is_(True)) + .order_by(NewsArticle.published_at.desc()) + .offset(offset) + .limit(limit) + ) + if category: + stmt = stmt.where(NewsArticle.category == category) + + result = await db.execute(stmt) + return list(result.scalars().all()) + + +async def get_published_news_count( + db: AsyncSession, + *, + category: str | None = None, +) -> int: + """Get count of published news articles, optionally filtered by category.""" + stmt = select(func.count(NewsArticle.id)).where(NewsArticle.is_published.is_(True)) + if category: + stmt = stmt.where(NewsArticle.category == category) + + result = await db.execute(stmt) + return result.scalar_one() or 0 + + +async def get_all_news( + db: AsyncSession, + *, + limit: int = 50, + offset: int = 0, +) -> list[NewsArticle]: + """Get all news articles (admin), ordered by created_at descending.""" + stmt = ( + select(NewsArticle) + .options(selectinload(NewsArticle.author)) + .order_by(NewsArticle.created_at.desc()) + .offset(offset) + .limit(limit) + ) + result = await db.execute(stmt) + return list(result.scalars().all()) + + +async def get_all_news_count(db: AsyncSession) -> int: + """Get total count of all news articles.""" + result = await db.execute(select(func.count(NewsArticle.id))) + return result.scalar_one() or 0 + + +async def get_news_categories(db: AsyncSession) -> list[str]: + """Get distinct categories from published articles.""" + result = await db.execute( + select(NewsArticle.category) + .where(NewsArticle.is_published.is_(True)) + .where(NewsArticle.category != '') + .distinct() + .order_by(NewsArticle.category) + ) + return list(result.scalars().all()) + + +async def update_news_article( + db: AsyncSession, + article: NewsArticle, + **kwargs, +) -> NewsArticle: + """Update a news article. Only whitelisted fields are applied.""" + allowed_fields = { + 'title', + 'slug', + 'content', + 'excerpt', + 'category', + 'category_color', + 'tag', + 'featured_image_url', + 'is_published', + 'is_featured', + 'published_at', + 'read_time_minutes', + } + + nullable_fields = { + 'excerpt', + 'tag', + 'featured_image_url', + 'published_at', + } + + update_data: dict = {} + for key, value in kwargs.items(): + if key not in allowed_fields: + continue + if value is None and key not in nullable_fields: + continue + update_data[key] = value + + # Auto-set published_at when transitioning to published + if update_data.get('is_published') and not article.is_published and not update_data.get('published_at'): + if article.published_at is None: + update_data['published_at'] = datetime.now(UTC) + + if not update_data: + return article + + update_data['updated_at'] = datetime.now(UTC) + + await db.execute(update(NewsArticle).where(NewsArticle.id == article.id).values(**update_data)) + await db.commit() + await db.refresh(article) + + logger.info( + 'Updated news article', article_id=article.id, slug=article.slug, updated_fields=list(update_data.keys()) + ) + return article + + +async def delete_news_article(db: AsyncSession, article: NewsArticle) -> bool: + """Delete a news article.""" + await db.execute(delete(NewsArticle).where(NewsArticle.id == article.id)) + await db.commit() + logger.info('Deleted news article', article_id=article.id, slug=article.slug) + return True + + +async def increment_views(db: AsyncSession, article_id: int) -> None: + """Increment the views counter for a news article (fire-and-forget).""" + await db.execute( + update(NewsArticle).where(NewsArticle.id == article_id).values(views_count=NewsArticle.views_count + 1) + ) + await db.commit() diff --git a/app/database/models.py b/app/database/models.py index 2d6cc9d8..0580de02 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -3299,3 +3299,35 @@ class GuestPurchase(Base): def __repr__(self) -> str: token_prefix = self.token[:5] if self.token else '?' return f"" + + +class NewsArticle(Base): + """News article for the cabinet news section.""" + + __tablename__ = 'news_articles' + __table_args__ = ( + Index('ix_news_articles_published_at_published', 'is_published', 'published_at'), + ) + + id = Column(Integer, primary_key=True, index=True) + title = Column(String(500), nullable=False) + slug = Column(String(500), unique=True, nullable=False, index=True) + content = Column(Text, nullable=False, default='', server_default='') + excerpt = Column(Text, nullable=True) + category = Column(String(100), nullable=False, default='', server_default='') + category_color = Column(String(20), nullable=False, default='#00e5a0', server_default='#00e5a0') + tag = Column(String(50), nullable=True) + featured_image_url = Column(Text, nullable=True) + is_published = Column(Boolean, nullable=False, default=False, server_default='false') + is_featured = Column(Boolean, nullable=False, default=False, server_default='false') + published_at = Column(AwareDateTime(), nullable=True) + read_time_minutes = Column(Integer, nullable=False, default=1, server_default='1') + views_count = Column(Integer, nullable=False, default=0, server_default='0') + created_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True) + created_at = Column(AwareDateTime(), server_default=func.now()) + updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now()) + + author = relationship('User', backref='created_news_articles', foreign_keys=[created_by]) + + def __repr__(self) -> str: + return f"" diff --git a/migrations/alembic/versions/0046_add_news_articles.py b/migrations/alembic/versions/0046_add_news_articles.py new file mode 100644 index 00000000..0e9526a8 --- /dev/null +++ b/migrations/alembic/versions/0046_add_news_articles.py @@ -0,0 +1,49 @@ +"""add news_articles table + +Revision ID: 0046 +Revises: 0045 +Create Date: 2026-03-23 + +Adds news_articles table for the cabinet news/blog feature. +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = '0046' +down_revision: str | None = '0045' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'news_articles', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('title', sa.String(500), nullable=False), + sa.Column('slug', sa.String(500), nullable=False), + sa.Column('content', sa.Text(), nullable=False, server_default=''), + sa.Column('excerpt', sa.Text(), nullable=True), + sa.Column('category', sa.String(100), nullable=False, server_default=''), + sa.Column('category_color', sa.String(20), nullable=False, server_default='#00e5a0'), + sa.Column('tag', sa.String(50), nullable=True), + sa.Column('featured_image_url', sa.Text(), nullable=True), + sa.Column('is_published', sa.Boolean(), nullable=False, server_default=sa.text('false')), + sa.Column('is_featured', sa.Boolean(), nullable=False, server_default=sa.text('false')), + sa.Column('published_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('read_time_minutes', sa.Integer(), nullable=False, server_default='1'), + sa.Column('views_count', sa.Integer(), nullable=False, server_default='0'), + sa.Column('created_by', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index('ix_news_articles_slug', 'news_articles', ['slug'], unique=True) + op.create_index('ix_news_articles_published_at', 'news_articles', ['published_at']) + + +def downgrade() -> None: + op.drop_index('ix_news_articles_published_at', table_name='news_articles') + op.drop_index('ix_news_articles_slug', table_name='news_articles') + op.drop_table('news_articles') From 2b91808b0c72381cfb2f4e36eaff69e9102a32ee Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 11:09:45 +0300 Subject: [PATCH 08/33] fix: news module security hardening, perf optimizations, bug fixes - Server-side HTML sanitization for article content - URL scheme validation for featured_image_url (http/https only) - Slug sanitization on create/update - MissingGreenlet fix in delete (capture attrs before commit) - Missing rollback after IntegrityError in CRUD - nullslast() for published_at ordering - asyncio.gather for parallel DB queries - Removed selectinload(author) from list queries - increment_views with RETURNING (no extra SELECT) - Migration-model index alignment - Pre-compiled regex, structlog.exception pattern - View counter dedup cache (5min TTL) --- app/cabinet/routes/admin_news.py | 124 +++++------- app/cabinet/routes/news.py | 99 +++++++--- app/cabinet/schemas/news.py | 186 ++++++++++++++++-- app/database/crud/news.py | 143 ++++++++------ app/database/models.py | 5 + .../versions/0046_add_news_articles.py | 30 ++- 6 files changed, 414 insertions(+), 173 deletions(-) diff --git a/app/cabinet/routes/admin_news.py b/app/cabinet/routes/admin_news.py index c201ec75..5eac7747 100644 --- a/app/cabinet/routes/admin_news.py +++ b/app/cabinet/routes/admin_news.py @@ -1,6 +1,8 @@ """Admin routes for managing news articles in cabinet.""" +import asyncio from datetime import UTC, datetime +from typing import Any import structlog from fastapi import APIRouter, Depends, HTTPException, Query, status @@ -13,10 +15,9 @@ from app.database.crud.news import ( get_all_news, get_all_news_count, get_news_article_by_id, - get_news_article_by_slug, update_news_article, ) -from app.database.models import User +from app.database.models import NewsArticle, User from ..dependencies import get_cabinet_db, require_permission from ..schemas.news import ( @@ -34,9 +35,12 @@ logger = structlog.get_logger(__name__) router = APIRouter(prefix='/admin/news', tags=['Cabinet Admin News']) -def _article_to_detail(article) -> dict: - """Convert NewsArticle ORM instance to full detail dict.""" - author_name = None +def _article_to_detail(article: NewsArticle) -> dict[str, Any]: + """Convert NewsArticle ORM instance to full detail dict. + + Expects the ``author`` relationship to be eagerly loaded. + """ + author_name: str | None = None if article.author: author_name = article.author.first_name or article.author.username or f'#{article.author.id}' @@ -68,35 +72,23 @@ async def list_all_news( limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), ) -> NewsListResponse: - """Get all news articles (admin view, includes unpublished).""" - try: - articles = await get_all_news(db, limit=limit, offset=offset) - total = await get_all_news_count(db) + """Get all news articles (admin view, includes unpublished). - items = [ - NewsArticleListItem( - id=a.id, - title=a.title, - slug=a.slug, - excerpt=a.excerpt, - category=a.category, - category_color=a.category_color, - tag=a.tag, - featured_image_url=a.featured_image_url, - is_published=a.is_published, - is_featured=a.is_featured, - published_at=a.published_at, - read_time_minutes=a.read_time_minutes, - views_count=a.views_count, - ) - for a in articles - ] + articles and total are independent — run them concurrently. + """ + try: + articles, total = await asyncio.gather( + get_all_news(db, limit=limit, offset=offset), + get_all_news_count(db), + ) + + items = [NewsArticleListItem.model_validate(a) for a in articles] return NewsListResponse(items=items, total=total) except HTTPException: raise - except Exception as e: - logger.error('Failed to list all news', error=str(e), exc_info=True) + except Exception: + logger.exception('Failed to list all news') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to load news articles', @@ -127,14 +119,6 @@ async def create_article( db: AsyncSession = Depends(get_cabinet_db), ) -> NewsArticleResponse: """Create a new news article.""" - # Check slug uniqueness - existing = await get_news_article_by_slug(db, request.slug) - if existing: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail='An article with this slug already exists', - ) - try: article = await create_news_article( db, @@ -148,30 +132,30 @@ async def create_article( featured_image_url=request.featured_image_url, is_published=request.is_published, is_featured=request.is_featured, - published_at=None, read_time_minutes=request.read_time_minutes, created_by=admin.id, ) - - # Reload with author relationship - article = await get_news_article_by_id(db, article.id) - if not article: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to reload article') - return NewsArticleResponse(**_article_to_detail(article)) - except HTTPException: - raise except IntegrityError: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail='An article with this slug already exists', ) - except Exception as e: - logger.error('Failed to create news article', error=str(e), exc_info=True) + except Exception: + logger.exception('Failed to create news article') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to create article', ) + # Reload with author relationship + article = await get_news_article_by_id(db, article.id) + if not article: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Failed to reload article after creation', + ) + return NewsArticleResponse(**_article_to_detail(article)) + @router.put('/{article_id}', response_model=NewsArticleResponse) async def update_article( @@ -188,38 +172,30 @@ async def update_article( detail='Article not found', ) - # Check slug uniqueness if slug is being changed - if request.slug and request.slug != article.slug: - existing = await get_news_article_by_slug(db, request.slug) - if existing: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail='An article with this slug already exists', - ) - try: update_data = request.model_dump(exclude_unset=True) article = await update_news_article(db, article, **update_data) - - # Reload with author relationship - article = await get_news_article_by_id(db, article.id) - if not article: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to reload article') - return NewsArticleResponse(**_article_to_detail(article)) - except HTTPException: - raise except IntegrityError: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail='An article with this slug already exists', ) - except Exception as e: - logger.error('Failed to update news article', article_id=article_id, error=str(e), exc_info=True) + except Exception: + logger.exception('Failed to update news article', article_id=article_id) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to update article', ) + # Reload with author relationship (update used bulk UPDATE, author not populated) + article = await get_news_article_by_id(db, article.id) + if not article: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Failed to reload article after update', + ) + return NewsArticleResponse(**_article_to_detail(article)) + @router.delete('/{article_id}', status_code=status.HTTP_204_NO_CONTENT) async def remove_article( @@ -237,8 +213,8 @@ async def remove_article( try: await delete_news_article(db, article) - except Exception as e: - logger.error('Failed to delete news article', article_id=article_id, error=str(e), exc_info=True) + except Exception: + logger.exception('Failed to delete news article', article_id=article_id) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to delete article', @@ -261,7 +237,7 @@ async def toggle_publish( new_published = not article.is_published - update_kwargs: dict = {'is_published': new_published} + update_kwargs: dict[str, Any] = {'is_published': new_published} # Auto-set published_at on first publish if new_published and article.published_at is None: update_kwargs['published_at'] = datetime.now(UTC) @@ -274,8 +250,8 @@ async def toggle_publish( is_featured=article.is_featured, published_at=article.published_at, ) - except Exception as e: - logger.error('Failed to toggle publish', article_id=article_id, error=str(e), exc_info=True) + except Exception: + logger.exception('Failed to toggle publish', article_id=article_id) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to toggle publish status', @@ -304,8 +280,8 @@ async def toggle_featured( is_featured=article.is_featured, published_at=article.published_at, ) - except Exception as e: - logger.error('Failed to toggle featured', article_id=article_id, error=str(e), exc_info=True) + except Exception: + logger.exception('Failed to toggle featured', article_id=article_id) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to toggle featured status', diff --git a/app/cabinet/routes/news.py b/app/cabinet/routes/news.py index 4bf6f11c..6b3cc2bf 100644 --- a/app/cabinet/routes/news.py +++ b/app/cabinet/routes/news.py @@ -1,7 +1,11 @@ """Public news routes for cabinet - user-facing news/blog section.""" +import asyncio +import time +from typing import Any + import structlog -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Path, Query, status from sqlalchemy.ext.asyncio import AsyncSession from app.database.crud.news import ( @@ -11,7 +15,7 @@ from app.database.crud.news import ( get_published_news_count, increment_views, ) -from app.database.models import User +from app.database.models import NewsArticle, User from ..dependencies import get_cabinet_db, get_current_cabinet_user from ..schemas.news import ( @@ -23,16 +27,51 @@ from ..schemas.news import ( logger = structlog.get_logger(__name__) +# Slug constraint: alphanumeric, hyphens, underscores, max 500 chars +_SLUG_MAX_LENGTH: int = 500 +_SLUG_PATTERN: str = r'^[a-zA-Z0-9_-]+$' + +# --- View counter deduplication --- +# In-memory TTL cache to prevent a single user from inflating view counts. +# Key: (user_id, article_id), Value: timestamp of last counted view. +# Views from the same user on the same article within _VIEW_DEDUP_SECONDS are ignored. +_VIEW_DEDUP_SECONDS: int = 300 # 5 minutes +_VIEW_DEDUP_MAX_SIZE: int = 10_000 # max entries before eviction +_view_dedup_cache: dict[tuple[int, int], float] = {} + + +def _should_count_view(user_id: int, article_id: int) -> bool: + """Return True if this view should be counted (not a duplicate within TTL).""" + now = time.monotonic() + key = (user_id, article_id) + last_seen = _view_dedup_cache.get(key) + + if last_seen is not None and (now - last_seen) < _VIEW_DEDUP_SECONDS: + return False + + # Evict stale entries if cache grows too large + if len(_view_dedup_cache) >= _VIEW_DEDUP_MAX_SIZE: + cutoff = now - _VIEW_DEDUP_SECONDS + stale_keys = [k for k, v in _view_dedup_cache.items() if v < cutoff] + for k in stale_keys: + del _view_dedup_cache[k] + + _view_dedup_cache[key] = now + return True + + router = APIRouter(prefix='/news', tags=['Cabinet News']) -def _article_to_response(article, *, include_content: bool = True) -> dict: - """Convert NewsArticle ORM instance to response dict.""" - author_name = None - if article.author: - author_name = article.author.first_name or article.author.username or f'#{article.author.id}' +def _article_to_response(article: NewsArticle, *, include_content: bool = True) -> dict[str, Any]: + """Convert NewsArticle ORM instance to response dict. - data = { + ``author_name`` is only resolved when ``include_content=True`` (single-article + detail view) because the author relationship is not eagerly loaded for list + queries -- accessing it there would trigger a lazy-load or raise + ``MissingGreenlet`` in async context. + """ + data: dict[str, Any] = { 'id': article.id, 'title': article.title, 'slug': article.slug, @@ -49,6 +88,9 @@ def _article_to_response(article, *, include_content: bool = True) -> dict: } if include_content: + author_name: str | None = None + if article.author: + author_name = article.author.first_name or article.author.username or f'#{article.author.id}' data['content'] = article.content data['author_name'] = author_name data['created_at'] = article.created_at @@ -66,8 +108,8 @@ async def list_categories( """Get list of distinct news categories.""" try: return await get_news_categories(db) - except Exception as e: - logger.error('Failed to get news categories', error=str(e), exc_info=True) + except Exception: + logger.exception('Failed to get news categories') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to load categories', @@ -82,19 +124,26 @@ async def list_published_news( limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0), ) -> NewsListResponse: - """Get paginated list of published news articles.""" + """Get paginated list of published news articles. + + The three DB queries (articles, count, categories) are independent — run + them concurrently via asyncio.gather to cut latency to the slowest query + instead of the sequential sum of all three. + """ try: - articles = await get_published_news(db, category=category, limit=limit, offset=offset) - total = await get_published_news_count(db, category=category) - categories = await get_news_categories(db) + articles, total, categories = await asyncio.gather( + get_published_news(db, category=category, limit=limit, offset=offset), + get_published_news_count(db, category=category), + get_news_categories(db), + ) items = [NewsArticleListItem(**_article_to_response(a, include_content=False)) for a in articles] return NewsListResponse(items=items, total=total, categories=categories) except HTTPException: raise - except Exception as e: - logger.error('Failed to list published news', error=str(e), exc_info=True) + except Exception: + logger.exception('Failed to list published news') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to load news', @@ -103,7 +152,7 @@ async def list_published_news( @router.get('/{slug}', response_model=NewsArticleResponse) async def get_article_by_slug( - slug: str, + slug: str = Path(..., max_length=_SLUG_MAX_LENGTH, pattern=_SLUG_PATTERN), user: User = Depends(get_current_cabinet_user), db: AsyncSession = Depends(get_cabinet_db), ) -> NewsArticleResponse: @@ -116,11 +165,15 @@ async def get_article_by_slug( detail='Article not found', ) - # Increment views in background-safe manner (no error propagation) - try: - await increment_views(db, article.id) - await db.refresh(article) - except Exception: - logger.warning('Failed to increment views', article_id=article.id) + # Increment views with per-user deduplication (5-min TTL). + # Prevents view count inflation from repeated requests by the same user. + if _should_count_view(user.id, article.id): + try: + new_count = await increment_views(db, article.id) + # Patch the ORM instance so the response reflects the new count + # without an extra db.refresh() round-trip + article.views_count = new_count + except Exception: + logger.warning('Failed to increment views', article_id=article.id) return NewsArticleResponse(**_article_to_response(article, include_content=True)) diff --git a/app/cabinet/schemas/news.py b/app/cabinet/schemas/news.py index 2ef6d0e3..3d84ef52 100644 --- a/app/cabinet/schemas/news.py +++ b/app/cabinet/schemas/news.py @@ -1,11 +1,35 @@ -"""Schemas for news articles in cabinet.""" +"""Schemas for news articles in cabinet. + +Security notes: +- featured_image_url is validated to only accept http/https schemes. +- category_color is validated as a strict hex color (#RGB, #RRGGBB, etc.). +- Slug is sanitized to only allow [a-zA-Z0-9_-]. +- Content is server-side sanitized to strip content (tag + body) + result = re.sub(r']*>[\s\S]*?', '', result, flags=re.IGNORECASE) + result = re.sub(r']*>[\s\S]*?', '', result, flags=re.IGNORECASE) + + # 2. Remove event handler attributes + result = _EVENT_HANDLER_RE.sub('', result) + + # 3. Neutralize dangerous URI schemes in href/src + result = _DANGEROUS_URI_RE.sub(r'\1about:', result) + + return result class NewsArticleResponse(BaseModel): @@ -116,7 +220,7 @@ class NewsCreateRequest(BaseModel): """Request to create a news article.""" title: str = Field(..., min_length=1, max_length=500) - slug: str | None = Field(None, max_length=500) + slug: str | None = Field(None, min_length=1, max_length=500) content: str = Field(default='', max_length=500_000) excerpt: str | None = Field(None, max_length=1000) category: str = Field(..., min_length=1, max_length=100) @@ -127,27 +231,51 @@ class NewsCreateRequest(BaseModel): is_featured: bool = False read_time_minutes: int = Field(default=1, ge=1, le=60) + @field_validator('content') + @classmethod + def sanitize_content(cls, v: str) -> str: + """Strip dangerous HTML from article content (defense-in-depth).""" + return _sanitize_html_content(v) + @field_validator('category_color') @classmethod def validate_hex_color(cls, v: str) -> str: - if not re.match(r'^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$', v): - raise ValueError('category_color must be a valid hex color (e.g. #00e5a0)') + return _validate_hex_color(v) + + @field_validator('featured_image_url') + @classmethod + def validate_featured_image_url(cls, v: str | None) -> str | None: + """Reject javascript:, data:, and other dangerous URL schemes.""" + if v is not None: + return _validate_safe_url(v) return v - @field_validator('slug', mode='before') + @model_validator(mode='before') @classmethod - def generate_slug(cls, v: str | None, info) -> str: - if v: - return v - title = info.data.get('title', '') - return _slugify(title) + def auto_generate_slug(cls, data: dict) -> dict: # type: ignore[type-arg] + """Generate slug from title when not explicitly provided.""" + if isinstance(data, dict) and not data.get('slug'): + title = data.get('title', '') + data['slug'] = _slugify(title) if isinstance(title, str) else 'untitled' + return data + + @field_validator('slug') + @classmethod + def sanitize_slug(cls, v: str | None) -> str | None: + """Ensure slug contains only URL-safe characters.""" + if v is not None: + # Strip anything that isn't alphanumeric, hyphen, or underscore + sanitized = re.sub(r'[^a-zA-Z0-9_-]', '-', v) + sanitized = _MULTI_HYPHEN_RE.sub('-', sanitized).strip('-') + return sanitized or 'untitled' + return v class NewsUpdateRequest(BaseModel): """Request to update a news article.""" title: str | None = Field(None, min_length=1, max_length=500) - slug: str | None = Field(None, max_length=500) + slug: str | None = Field(None, min_length=1, max_length=500) content: str | None = Field(None, max_length=500_000) excerpt: str | None = None category: str | None = Field(None, min_length=1, max_length=100) @@ -158,11 +286,37 @@ class NewsUpdateRequest(BaseModel): is_featured: bool | None = None read_time_minutes: int | None = Field(None, ge=1, le=60) + @field_validator('content') + @classmethod + def sanitize_content(cls, v: str | None) -> str | None: + """Strip dangerous HTML from article content (defense-in-depth).""" + if v is not None: + return _sanitize_html_content(v) + return v + @field_validator('category_color') @classmethod def validate_hex_color(cls, v: str | None) -> str | None: - if v is not None and not re.match(r'^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$', v): - raise ValueError('category_color must be a valid hex color (e.g. #00e5a0)') + if v is not None: + return _validate_hex_color(v) + return v + + @field_validator('featured_image_url') + @classmethod + def validate_featured_image_url(cls, v: str | None) -> str | None: + """Reject javascript:, data:, and other dangerous URL schemes.""" + if v is not None: + return _validate_safe_url(v) + return v + + @field_validator('slug') + @classmethod + def sanitize_slug(cls, v: str | None) -> str | None: + """Ensure slug contains only URL-safe characters.""" + if v is not None: + sanitized = re.sub(r'[^a-zA-Z0-9_-]', '-', v) + sanitized = _MULTI_HYPHEN_RE.sub('-', sanitized).strip('-') + return sanitized or 'untitled' return v diff --git a/app/database/crud/news.py b/app/database/crud/news.py index 466f9a62..979fbc10 100644 --- a/app/database/crud/news.py +++ b/app/database/crud/news.py @@ -1,9 +1,11 @@ """CRUD operations for news articles.""" from datetime import UTC, datetime +from typing import Any import structlog -from sqlalchemy import delete, func, select, update +from sqlalchemy import delete, func, nullslast, select, update +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -12,6 +14,34 @@ from app.database.models import NewsArticle logger = structlog.get_logger(__name__) +# Fields that can be set via update_news_article +_ALLOWED_UPDATE_FIELDS: frozenset[str] = frozenset( + { + 'title', + 'slug', + 'content', + 'excerpt', + 'category', + 'category_color', + 'tag', + 'featured_image_url', + 'is_published', + 'is_featured', + 'published_at', + 'read_time_minutes', + } +) + +# Fields that can be explicitly set to None +_NULLABLE_UPDATE_FIELDS: frozenset[str] = frozenset( + { + 'excerpt', + 'tag', + 'featured_image_url', + 'published_at', + } +) + async def create_news_article( db: AsyncSession, @@ -30,7 +60,11 @@ async def create_news_article( read_time_minutes: int = 1, created_by: int | None = None, ) -> NewsArticle: - """Create a new news article.""" + """Create a new news article. + + Raises: + IntegrityError: if slug is not unique (caller must handle). + """ # Auto-set published_at when publishing without explicit date if is_published and published_at is None: published_at = datetime.now(UTC) @@ -52,7 +86,11 @@ async def create_news_article( ) db.add(article) - await db.commit() + try: + await db.commit() + except IntegrityError: + await db.rollback() + raise await db.refresh(article) logger.info( @@ -87,18 +125,17 @@ async def get_published_news( limit: int = 20, offset: int = 0, ) -> list[NewsArticle]: - """Get published news articles, ordered by published_at descending.""" - stmt = ( - select(NewsArticle) - .options(selectinload(NewsArticle.author)) - .where(NewsArticle.is_published.is_(True)) - .order_by(NewsArticle.published_at.desc()) - .offset(offset) - .limit(limit) - ) + """Get published news articles, ordered by published_at descending. + + Does NOT load the author relationship -- list views do not need it. + """ + stmt = select(NewsArticle).where(NewsArticle.is_published.is_(True)) if category: stmt = stmt.where(NewsArticle.category == category) + # NULLs last so articles without published_at don't float to the top in DESC + stmt = stmt.order_by(nullslast(NewsArticle.published_at.desc())).offset(offset).limit(limit) + result = await db.execute(stmt) return list(result.scalars().all()) @@ -124,13 +161,7 @@ async def get_all_news( offset: int = 0, ) -> list[NewsArticle]: """Get all news articles (admin), ordered by created_at descending.""" - stmt = ( - select(NewsArticle) - .options(selectinload(NewsArticle.author)) - .order_by(NewsArticle.created_at.desc()) - .offset(offset) - .limit(limit) - ) + stmt = select(NewsArticle).order_by(NewsArticle.created_at.desc()).offset(offset).limit(limit) result = await db.execute(stmt) return list(result.scalars().all()) @@ -156,36 +187,18 @@ async def get_news_categories(db: AsyncSession) -> list[str]: async def update_news_article( db: AsyncSession, article: NewsArticle, - **kwargs, + **kwargs: Any, ) -> NewsArticle: - """Update a news article. Only whitelisted fields are applied.""" - allowed_fields = { - 'title', - 'slug', - 'content', - 'excerpt', - 'category', - 'category_color', - 'tag', - 'featured_image_url', - 'is_published', - 'is_featured', - 'published_at', - 'read_time_minutes', - } + """Update a news article. Only whitelisted fields are applied. - nullable_fields = { - 'excerpt', - 'tag', - 'featured_image_url', - 'published_at', - } - - update_data: dict = {} + Raises: + IntegrityError: if slug conflicts with another article (caller must handle). + """ + update_data: dict[str, Any] = {} for key, value in kwargs.items(): - if key not in allowed_fields: + if key not in _ALLOWED_UPDATE_FIELDS: continue - if value is None and key not in nullable_fields: + if value is None and key not in _NULLABLE_UPDATE_FIELDS: continue update_data[key] = value @@ -200,26 +213,46 @@ async def update_news_article( update_data['updated_at'] = datetime.now(UTC) await db.execute(update(NewsArticle).where(NewsArticle.id == article.id).values(**update_data)) - await db.commit() + try: + await db.commit() + except IntegrityError: + await db.rollback() + raise await db.refresh(article) logger.info( - 'Updated news article', article_id=article.id, slug=article.slug, updated_fields=list(update_data.keys()) + 'Updated news article', + article_id=article.id, + slug=article.slug, + updated_fields=list(update_data.keys()), ) return article -async def delete_news_article(db: AsyncSession, article: NewsArticle) -> bool: +async def delete_news_article(db: AsyncSession, article: NewsArticle) -> None: """Delete a news article.""" - await db.execute(delete(NewsArticle).where(NewsArticle.id == article.id)) + # Capture fields before commit expires the ORM instance attributes + article_id = article.id + article_slug = article.slug + + await db.execute(delete(NewsArticle).where(NewsArticle.id == article_id)) await db.commit() - logger.info('Deleted news article', article_id=article.id, slug=article.slug) - return True + + logger.info('Deleted news article', article_id=article_id, slug=article_slug) -async def increment_views(db: AsyncSession, article_id: int) -> None: - """Increment the views counter for a news article (fire-and-forget).""" - await db.execute( - update(NewsArticle).where(NewsArticle.id == article_id).values(views_count=NewsArticle.views_count + 1) +async def increment_views(db: AsyncSession, article_id: int) -> int: + """Atomically increment the views counter and return the new count. + + Uses UPDATE … RETURNING so the caller can patch the ORM instance directly + without issuing a second SELECT (db.refresh). + """ + result = await db.execute( + update(NewsArticle) + .where(NewsArticle.id == article_id) + .values(views_count=NewsArticle.views_count + 1) + .returning(NewsArticle.views_count) ) await db.commit() + row = result.fetchone() + return row[0] if row else 0 diff --git a/app/database/models.py b/app/database/models.py index 0580de02..33a1813d 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -3306,7 +3306,12 @@ class NewsArticle(Base): __tablename__ = 'news_articles' __table_args__ = ( + # Covers the main public list query: WHERE is_published = true ORDER BY published_at DESC Index('ix_news_articles_published_at_published', 'is_published', 'published_at'), + # Covers the category-filtered public list: WHERE is_published = true AND category = ? + Index('ix_news_articles_published_category', 'is_published', 'category'), + # Covers the admin list query: ORDER BY created_at DESC + Index('ix_news_articles_created_at', 'created_at'), ) id = Column(Integer, primary_key=True, index=True) diff --git a/migrations/alembic/versions/0046_add_news_articles.py b/migrations/alembic/versions/0046_add_news_articles.py index 0e9526a8..e5bb413c 100644 --- a/migrations/alembic/versions/0046_add_news_articles.py +++ b/migrations/alembic/versions/0046_add_news_articles.py @@ -7,15 +7,15 @@ Create Date: 2026-03-23 Adds news_articles table for the cabinet news/blog feature. """ -from typing import Sequence, Union +from collections.abc import Sequence import sqlalchemy as sa from alembic import op revision: str = '0046' down_revision: str | None = '0045' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None def upgrade() -> None: @@ -39,11 +39,31 @@ def upgrade() -> None: sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now()), ) + + # Unique index on slug (model: unique=True, index=True) op.create_index('ix_news_articles_slug', 'news_articles', ['slug'], unique=True) - op.create_index('ix_news_articles_published_at', 'news_articles', ['published_at']) + + # Composite: covers WHERE is_published = true ORDER BY published_at DESC + op.create_index( + 'ix_news_articles_published_at_published', + 'news_articles', + ['is_published', 'published_at'], + ) + + # Composite: covers WHERE is_published = true AND category = ? + op.create_index( + 'ix_news_articles_published_category', + 'news_articles', + ['is_published', 'category'], + ) + + # Covers admin list: ORDER BY created_at DESC + op.create_index('ix_news_articles_created_at', 'news_articles', ['created_at']) def downgrade() -> None: - op.drop_index('ix_news_articles_published_at', table_name='news_articles') + op.drop_index('ix_news_articles_created_at', table_name='news_articles') + op.drop_index('ix_news_articles_published_category', table_name='news_articles') + op.drop_index('ix_news_articles_published_at_published', table_name='news_articles') op.drop_index('ix_news_articles_slug', table_name='news_articles') op.drop_table('news_articles') From 015c2da297e4250ebfa50e32b3c1468c9a2143f3 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 11:29:31 +0300 Subject: [PATCH 09/33] fix: simplify 0046 migration downgrade to just drop_table drop_table automatically removes all indexes, fixing downgrade failure when indexes were added after initial migration was applied --- migrations/alembic/versions/0046_add_news_articles.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/migrations/alembic/versions/0046_add_news_articles.py b/migrations/alembic/versions/0046_add_news_articles.py index e5bb413c..8a2a51a3 100644 --- a/migrations/alembic/versions/0046_add_news_articles.py +++ b/migrations/alembic/versions/0046_add_news_articles.py @@ -62,8 +62,5 @@ def upgrade() -> None: def downgrade() -> None: - op.drop_index('ix_news_articles_created_at', table_name='news_articles') - op.drop_index('ix_news_articles_published_category', table_name='news_articles') - op.drop_index('ix_news_articles_published_at_published', table_name='news_articles') - op.drop_index('ix_news_articles_slug', table_name='news_articles') + # drop_table cascades to all indexes on the table automatically op.drop_table('news_articles') From 3e69efe5891bbe8869a7feb2320185e14d83dd73 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 11:34:25 +0300 Subject: [PATCH 10/33] fix: replace asyncio.gather with sequential queries on shared session AsyncSession does not support concurrent operations on the same connection. Running gather caused InvalidRequestError on news list. --- app/cabinet/routes/admin_news.py | 12 +++--------- app/cabinet/routes/news.py | 14 +++++--------- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/app/cabinet/routes/admin_news.py b/app/cabinet/routes/admin_news.py index 5eac7747..c557010f 100644 --- a/app/cabinet/routes/admin_news.py +++ b/app/cabinet/routes/admin_news.py @@ -1,6 +1,5 @@ """Admin routes for managing news articles in cabinet.""" -import asyncio from datetime import UTC, datetime from typing import Any @@ -72,15 +71,10 @@ async def list_all_news( limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), ) -> NewsListResponse: - """Get all news articles (admin view, includes unpublished). - - articles and total are independent — run them concurrently. - """ + """Get all news articles (admin view, includes unpublished).""" try: - articles, total = await asyncio.gather( - get_all_news(db, limit=limit, offset=offset), - get_all_news_count(db), - ) + articles = await get_all_news(db, limit=limit, offset=offset) + total = await get_all_news_count(db) items = [NewsArticleListItem.model_validate(a) for a in articles] diff --git a/app/cabinet/routes/news.py b/app/cabinet/routes/news.py index 6b3cc2bf..5165150d 100644 --- a/app/cabinet/routes/news.py +++ b/app/cabinet/routes/news.py @@ -1,6 +1,5 @@ """Public news routes for cabinet - user-facing news/blog section.""" -import asyncio import time from typing import Any @@ -126,16 +125,13 @@ async def list_published_news( ) -> NewsListResponse: """Get paginated list of published news articles. - The three DB queries (articles, count, categories) are independent — run - them concurrently via asyncio.gather to cut latency to the slowest query - instead of the sequential sum of all three. + SQLAlchemy AsyncSession does not support concurrent operations, so + queries run sequentially. """ try: - articles, total, categories = await asyncio.gather( - get_published_news(db, category=category, limit=limit, offset=offset), - get_published_news_count(db, category=category), - get_news_categories(db), - ) + articles = await get_published_news(db, category=category, limit=limit, offset=offset) + total = await get_published_news_count(db, category=category) + categories = await get_news_categories(db) items = [NewsArticleListItem(**_article_to_response(a, include_content=False)) for a in articles] From a0d40ad432d858ebfe75485a9597d32e847d5746 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 11:58:07 +0300 Subject: [PATCH 11/33] feat: add media upload/delete API for news articles Local filesystem storage with Docker volume mount, magic byte validation, PIL image resize/thumbnail generation, atomic writes, path traversal guards. --- Dockerfile | 2 +- app/cabinet/routes/__init__.py | 2 + app/cabinet/routes/admin_news_media.py | 151 ++++++++++++++ app/cabinet/schemas/news_media.py | 15 ++ app/config.py | 10 + app/services/news_media_service.py | 273 +++++++++++++++++++++++++ app/webserver/unified_app.py | 12 ++ docker-compose.local.yml | 1 + docker-compose.yml | 2 + 9 files changed, 467 insertions(+), 1 deletion(-) create mode 100644 app/cabinet/routes/admin_news_media.py create mode 100644 app/cabinet/schemas/news_media.py create mode 100644 app/services/news_media_service.py diff --git a/Dockerfile b/Dockerfile index 19c51128..e2ad9acd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,7 +33,7 @@ WORKDIR /app COPY --chown=app:app . . -RUN mkdir -p logs data && chown app:app logs data +RUN mkdir -p logs data uploads && chown app:app logs data uploads USER app diff --git a/app/cabinet/routes/__init__.py b/app/cabinet/routes/__init__.py index a061038e..e42d5f03 100644 --- a/app/cabinet/routes/__init__.py +++ b/app/cabinet/routes/__init__.py @@ -14,6 +14,7 @@ from .admin_email_templates import router as admin_email_templates_router from .admin_landings import router as admin_landings_router from .admin_menu_layout import router as admin_menu_layout_router from .admin_news import router as admin_news_router +from .admin_news_media import router as admin_news_media_router from .admin_partners import router as admin_partners_router from .admin_payment_methods import router as admin_payment_methods_router from .admin_payments import router as admin_payments_router @@ -130,6 +131,7 @@ router.include_router(admin_roles_router) router.include_router(admin_policies_router) router.include_router(admin_audit_log_router) router.include_router(admin_news_router) +router.include_router(admin_news_media_router) # WebSocket route router.include_router(websocket_router) diff --git a/app/cabinet/routes/admin_news_media.py b/app/cabinet/routes/admin_news_media.py new file mode 100644 index 00000000..74295cd6 --- /dev/null +++ b/app/cabinet/routes/admin_news_media.py @@ -0,0 +1,151 @@ +"""Admin routes for managing news article media (images/videos).""" + +from __future__ import annotations + +import asyncio +import re + +import structlog +from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, status + +from app.config import settings +from app.database.models import User +from app.services.news_media_service import ( + SavedMedia, + delete_media_file, + detect_file_type, + ensure_upload_dirs, + save_image, + save_video, +) + +from ..dependencies import require_permission +from ..schemas.news_media import NewsMediaUploadResponse + + +logger = structlog.get_logger(__name__) + +_BYTES_PER_MB = 1024 * 1024 + +# Only allow UUID-hex filenames with expected extensions (path traversal defense-in-depth) +_SAFE_FILENAME_RE = re.compile(r'^(thumb_)?[0-9a-f]{32}\.(jpg|mp4|webm)$') + +router = APIRouter(prefix='/admin/news/media', tags=['Cabinet Admin News Media']) + + +def _build_media_url(request: Request, relative_path: str) -> str: + """Build a full URL for a media file from the request base URL.""" + base = str(request.base_url).rstrip('/') + return f'{base}/uploads/{relative_path}' + + +def _build_response(request: Request, saved: SavedMedia) -> NewsMediaUploadResponse: + """Convert SavedMedia to API response with full URLs.""" + thumbnail_url = _build_media_url(request, saved.thumbnail_path) if saved.thumbnail_path else None + + return NewsMediaUploadResponse( + url=_build_media_url(request, saved.relative_path), + thumbnail_url=thumbnail_url, + media_type=saved.media_type, + filename=saved.filename, + size_bytes=saved.size_bytes, + width=saved.width, + height=saved.height, + ) + + +@router.post('/upload', response_model=NewsMediaUploadResponse, status_code=status.HTTP_201_CREATED) +async def upload_media( + request: Request, + file: UploadFile, + admin: User = Depends(require_permission('news:edit')), +) -> NewsMediaUploadResponse: + """Upload an image or video for a news article.""" + # Read with a hard budget to prevent memory exhaustion from huge uploads. + # Read slightly over the max allowed size so we can detect oversized files. + absolute_max_bytes = settings.MEDIA_MAX_VIDEO_SIZE_MB * _BYTES_PER_MB + 1 + data = await file.read(absolute_max_bytes) + if not data: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Empty file', + ) + + if len(data) >= absolute_max_bytes: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=f'File too large. Absolute maximum: {settings.MEDIA_MAX_VIDEO_SIZE_MB} MB', + ) + + # Detect type from magic bytes + try: + media_type, _ext = detect_file_type(data) + except ValueError: + raise HTTPException( + status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + detail='Unsupported file type. Allowed: JPEG, PNG, WebP, MP4, WebM', + ) + + # Enforce per-type size limits + max_size_mb = ( + settings.MEDIA_MAX_IMAGE_SIZE_MB if media_type == 'image' else settings.MEDIA_MAX_VIDEO_SIZE_MB + ) + if len(data) > max_size_mb * _BYTES_PER_MB: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=f'File too large. Maximum size for {media_type}: {max_size_mb} MB', + ) + + upload_path = settings.get_media_upload_path() + ensure_upload_dirs(upload_path) + + try: + if media_type == 'image': + saved = await save_image( + data, + upload_path, + max_dim=settings.MEDIA_IMAGE_MAX_DIMENSION, + quality=settings.MEDIA_JPEG_QUALITY, + ) + else: + saved = await save_video(data, upload_path) + except Exception: + logger.exception('Failed to save uploaded media', media_type=media_type) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail='Failed to process uploaded file', + ) + + logger.info( + 'Media uploaded', + filename=saved.filename, + media_type=saved.media_type, + size_bytes=saved.size_bytes, + admin_id=admin.id, + ) + + return _build_response(request, saved) + + +@router.delete('/{filename}', status_code=status.HTTP_204_NO_CONTENT) +async def delete_media( + filename: str, + admin: User = Depends(require_permission('news:delete')), +) -> None: + """Delete a previously uploaded media file.""" + if not _SAFE_FILENAME_RE.match(filename): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Invalid filename', + ) + + upload_path = settings.get_media_upload_path() + + deleted = await asyncio.to_thread(delete_media_file, filename, upload_path) + if not deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail='File not found', + ) + + logger.info('Media deleted', filename=filename, admin_id=admin.id) diff --git a/app/cabinet/schemas/news_media.py b/app/cabinet/schemas/news_media.py new file mode 100644 index 00000000..654efccf --- /dev/null +++ b/app/cabinet/schemas/news_media.py @@ -0,0 +1,15 @@ +"""Schemas for news media upload responses.""" + +from pydantic import BaseModel + + +class NewsMediaUploadResponse(BaseModel): + """Response returned after a successful media upload.""" + + url: str + thumbnail_url: str | None = None + media_type: str # 'image' or 'video' + filename: str + size_bytes: int + width: int | None = None + height: int | None = None diff --git a/app/config.py b/app/config.py index 65bbb744..234a511a 100644 --- a/app/config.py +++ b/app/config.py @@ -582,6 +582,13 @@ class Settings(BaseSettings): CONNECT_BUTTON_MODE: str = 'miniapp_subscription' MINIAPP_CUSTOM_URL: str = '' MINIAPP_STATIC_PATH: str = 'miniapp' + + # Media upload settings (news article images/videos) + MEDIA_UPLOAD_DIR: str = './uploads' + MEDIA_MAX_IMAGE_SIZE_MB: int = 10 + MEDIA_MAX_VIDEO_SIZE_MB: int = 50 + MEDIA_IMAGE_MAX_DIMENSION: int = 2048 + MEDIA_JPEG_QUALITY: int = 85 MINIAPP_PURCHASE_URL: str = '' MINIAPP_SERVICE_NAME_EN: str = 'Bedolaga VPN' MINIAPP_SERVICE_NAME_RU: str = 'Bedolaga VPN' @@ -2626,6 +2633,9 @@ class Settings(BaseSettings): raw_path = 'miniapp' return Path(raw_path) + def get_media_upload_path(self) -> Path: + return Path(self.MEDIA_UPLOAD_DIR) + # Cabinet methods def is_cabinet_enabled(self) -> bool: return bool(self.CABINET_ENABLED) diff --git a/app/services/news_media_service.py b/app/services/news_media_service.py new file mode 100644 index 00000000..c33961bf --- /dev/null +++ b/app/services/news_media_service.py @@ -0,0 +1,273 @@ +"""Media processing service for news article images and videos. + +Handles file validation (magic bytes), image resizing via Pillow, +thumbnail generation, and atomic file writes with UUID filenames. +""" + +from __future__ import annotations + +import asyncio +import io +import uuid +from dataclasses import dataclass +from pathlib import Path + +import structlog +from PIL import Image, ImageOps + + +logger = structlog.get_logger(__name__) + +# Hard limit on decompressed image pixels to prevent decompression bombs. +# 25M pixels ≈ 5000x5000, roughly 75 MB of raw RGB data — safe for a news editor. +Image.MAX_IMAGE_PIXELS = 25_000_000 + +# Minimum file size to attempt magic byte detection +_MIN_MAGIC_BYTES = 12 + +# --- Magic byte signatures for file type detection --- + +ALLOWED_IMAGE_SIGNATURES: dict[bytes, str] = { + b'\xff\xd8\xff': '.jpg', + b'\x89PNG': '.png', + # WebP: starts with RIFF....WEBP (bytes 0-3 = RIFF, bytes 8-11 = WEBP) +} + +ALLOWED_VIDEO_SIGNATURES: dict[bytes, str] = { + # MP4: bytes 4-7 = 'ftyp' + b'\x1a\x45\xdf\xa3': '.webm', +} + +_IMAGES_DIR = 'images' +_VIDEOS_DIR = 'videos' +_THUMBNAILS_DIR = 'thumbnails' + +_THUMBNAIL_SIZE = (400, 400) + + +@dataclass(frozen=True, slots=True) +class SavedMedia: + """Result of saving a media file.""" + + filename: str + relative_path: str + thumbnail_path: str | None + media_type: str # 'image' or 'video' + content_type: str + size_bytes: int + width: int | None + height: int | None + + +def ensure_upload_dirs(upload_path: Path) -> None: + """Create images/, videos/, thumbnails/ subdirectories under upload_path.""" + for subdir in (_IMAGES_DIR, _VIDEOS_DIR, _THUMBNAILS_DIR): + (upload_path / subdir).mkdir(parents=True, exist_ok=True) + + +def detect_file_type(data: bytes) -> tuple[str, str]: + """Detect media type and extension from magic bytes. + + Returns: + Tuple of (media_type, extension), e.g. ('image', '.jpg'). + + Raises: + ValueError: If file type is not recognized. + """ + if len(data) < _MIN_MAGIC_BYTES: + msg = 'File too small to identify' + raise ValueError(msg) + + # Check WebP: RIFF at offset 0, WEBP at offset 8 + if data[:4] == b'RIFF' and data[8:12] == b'WEBP': + return 'image', '.webp' + + # Check standard image signatures + for signature, ext in ALLOWED_IMAGE_SIGNATURES.items(): + if data[: len(signature)] == signature: + return 'image', ext + + # Check MP4: bytes 4-7 must be 'ftyp' + if len(data) >= 8 and data[4:8] == b'ftyp': + return 'video', '.mp4' + + # Check standard video signatures + for signature, ext in ALLOWED_VIDEO_SIGNATURES.items(): + if data[: len(signature)] == signature: + return 'video', ext + + msg = 'Unsupported file type: magic bytes do not match any allowed format' + raise ValueError(msg) + + +def _process_and_save_image( + data: bytes, + upload_path: Path, + max_dim: int, + quality: int, +) -> SavedMedia: + """Process image: validate, resize, convert to JPEG, generate thumbnail. + + This is a CPU-bound function intended to be run via asyncio.to_thread. + """ + img = Image.open(io.BytesIO(data)) + + # Double-check pixel count (defense-in-depth alongside Image.MAX_IMAGE_PIXELS) + if img.size[0] * img.size[1] > 25_000_000: + msg = 'Image dimensions too large' + raise ValueError(msg) + + # Fix EXIF orientation (rotated photos from phones) + img = ImageOps.exif_transpose(img) + + # Convert to RGB (strip alpha for JPEG, handle palette/grayscale modes) + if img.mode not in ('RGB', 'L'): + img = img.convert('RGB') + + original_width, original_height = img.size + + # Resize if any dimension exceeds max_dim (preserving aspect ratio) + if original_width > max_dim or original_height > max_dim: + img.thumbnail((max_dim, max_dim), Image.LANCZOS) + + width, height = img.size + filename = f'{uuid.uuid4().hex}.jpg' + image_dir = upload_path / _IMAGES_DIR + target_path = image_dir / filename + + # Atomic write: save to temp file, then rename + tmp_path = target_path.with_suffix('.tmp') + try: + img.save(tmp_path, format='JPEG', quality=quality, optimize=True) + tmp_path.rename(target_path) + except Exception: + tmp_path.unlink(missing_ok=True) + raise + + size_bytes = target_path.stat().st_size + + # Generate thumbnail + thumbnail_filename = f'thumb_{filename}' + thumbnail_dir = upload_path / _THUMBNAILS_DIR + thumbnail_target = thumbnail_dir / thumbnail_filename + + tmp_thumb = thumbnail_target.with_suffix('.tmp') + try: + thumb = img.copy() + thumb.thumbnail(_THUMBNAIL_SIZE, Image.LANCZOS) + thumb.save(tmp_thumb, format='JPEG', quality=quality, optimize=True) + tmp_thumb.rename(thumbnail_target) + except Exception: + tmp_thumb.unlink(missing_ok=True) + # Non-fatal: log and continue without thumbnail + logger.warning('Failed to generate thumbnail', filename=filename) + thumbnail_filename = None + + relative_path = f'{_IMAGES_DIR}/{filename}' + thumbnail_path = f'{_THUMBNAILS_DIR}/{thumbnail_filename}' if thumbnail_filename else None + + return SavedMedia( + filename=filename, + relative_path=relative_path, + thumbnail_path=thumbnail_path, + media_type='image', + content_type='image/jpeg', + size_bytes=size_bytes, + width=width, + height=height, + ) + + +async def save_image( + data: bytes, + upload_path: Path, + max_dim: int, + quality: int, +) -> SavedMedia: + """Validate, resize, and save an image file. Runs PIL operations in a thread.""" + return await asyncio.to_thread(_process_and_save_image, data, upload_path, max_dim, quality) + + +def _save_video_sync(data: bytes, upload_path: Path) -> SavedMedia: + """Save a video file. CPU-bound function for asyncio.to_thread.""" + media_type, ext = detect_file_type(data) + if media_type != 'video': + msg = 'Data does not contain a recognized video format' + raise ValueError(msg) + + filename = f'{uuid.uuid4().hex}{ext}' + video_dir = upload_path / _VIDEOS_DIR + target_path = video_dir / filename + + content_type_map: dict[str, str] = { + '.mp4': 'video/mp4', + '.webm': 'video/webm', + } + + # Atomic write + tmp_path = target_path.with_suffix('.tmp') + try: + tmp_path.write_bytes(data) + tmp_path.rename(target_path) + except Exception: + tmp_path.unlink(missing_ok=True) + raise + + size_bytes = target_path.stat().st_size + + return SavedMedia( + filename=filename, + relative_path=f'{_VIDEOS_DIR}/{filename}', + thumbnail_path=None, + media_type='video', + content_type=content_type_map.get(ext, 'application/octet-stream'), + size_bytes=size_bytes, + width=None, + height=None, + ) + + +async def save_video(data: bytes, upload_path: Path) -> SavedMedia: + """Validate and save a video file. Runs I/O in a thread.""" + return await asyncio.to_thread(_save_video_sync, data, upload_path) + + +def delete_media_file(filename: str, upload_path: Path) -> bool: + """Delete a media file by filename with path traversal protection. + + Searches images/, videos/, thumbnails/ directories. + + Returns: + True if at least one file was deleted, False otherwise. + """ + deleted = False + + for subdir in (_IMAGES_DIR, _VIDEOS_DIR, _THUMBNAILS_DIR): + candidate = (upload_path / subdir / filename).resolve() + base_dir = (upload_path / subdir).resolve() + + # Path traversal guard + if not candidate.is_relative_to(base_dir): + logger.warning( + 'Path traversal attempt blocked', + filename=filename, + resolved=str(candidate), + ) + continue + + if candidate.is_file(): + candidate.unlink() + deleted = True + logger.info('Deleted media file', path=str(candidate)) + + # Also try to delete matching thumbnail + if not filename.startswith('thumb_'): + thumb_name = f'thumb_{filename}' + thumb_path = (upload_path / _THUMBNAILS_DIR / thumb_name).resolve() + thumb_base = (upload_path / _THUMBNAILS_DIR).resolve() + + if thumb_path.is_relative_to(thumb_base) and thumb_path.is_file(): + thumb_path.unlink() + logger.info('Deleted thumbnail', path=str(thumb_path)) + + return deleted diff --git a/app/webserver/unified_app.py b/app/webserver/unified_app.py index 52be32d7..e63508da 100644 --- a/app/webserver/unified_app.py +++ b/app/webserver/unified_app.py @@ -89,6 +89,17 @@ def _create_base_app() -> FastAPI: return app +def _mount_uploads_static(app: FastAPI) -> None: + """Mount the media uploads directory as a static file server at /uploads.""" + uploads_path = settings.get_media_upload_path() + uploads_path.mkdir(parents=True, exist_ok=True) + try: + app.mount('/uploads', StaticFiles(directory=uploads_path), name='media-uploads') + logger.info('Media uploads static files mounted at /uploads', uploads_path=str(uploads_path)) + except RuntimeError as error: # pragma: no cover - defensive guard + logger.warning('Failed to mount media uploads static files', error=error) + + def _mount_miniapp_static(app: FastAPI) -> tuple[bool, Path]: static_path: Path = settings.get_miniapp_static_path() if not static_path.exists(): @@ -175,6 +186,7 @@ def create_unified_app( await disposable_email_service.stop() miniapp_mounted, miniapp_path = _mount_miniapp_static(app) + _mount_uploads_static(app) unified_health_path = '/health/unified' if settings.is_web_api_enabled() else '/health' diff --git a/docker-compose.local.yml b/docker-compose.local.yml index fb7f1626..037b83b8 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -69,6 +69,7 @@ services: - ./locales:/app/locales:rw - /etc/timezone:/etc/timezone:ro - /etc/localtime:/etc/localtime:ro + - ./uploads:/app/uploads:rw - ./vpn_logo.png:/app/vpn_logo.png:ro ports: - '${WEB_API_PORT:-8080}:8080' diff --git a/docker-compose.yml b/docker-compose.yml index eb7f3a38..ee0e3138 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -72,6 +72,8 @@ services: # Timezone - /etc/timezone:/etc/timezone:ro - /etc/localtime:/etc/localtime:ro + # Загруженные медиафайлы (изображения/видео для новостей) + - ./uploads:/app/uploads:rw # Логотип для сообщений - ./vpn_logo.png:/app/vpn_logo.png:ro ports: From 5ed3780f830023f7b1940dc96256fd38e3c86f4b Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 12:02:05 +0300 Subject: [PATCH 12/33] fix: create uploads subdirectories in Dockerfile for correct permissions --- Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index e2ad9acd..a75935c5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,7 +33,8 @@ WORKDIR /app COPY --chown=app:app . . -RUN mkdir -p logs data uploads && chown app:app logs data uploads +RUN mkdir -p logs data uploads/images uploads/videos uploads/thumbnails && \ + chown -R app:app logs data uploads USER app From 165d25ef5fc02eecf7c6d703072b6adbe88bfd98 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 12:05:37 +0300 Subject: [PATCH 13/33] fix: media upload security hardening from 6-agent review - PIL Image resource leak: wrap in try/finally with img.close() - Grayscale images: normalize all to RGB for consistent JPEG output - exif_transpose: defensive None guard - Thumbnail: explicit close after save - media_type: use Literal['image', 'video'] in schema - ensure_upload_dirs: run in asyncio.to_thread (not sync in event loop) - _SAFE_FILENAME_RE: remove thumb_ prefix (prevent orphaned files) --- app/cabinet/routes/admin_news_media.py | 7 +- app/cabinet/schemas/news_media.py | 4 +- app/services/news_media_service.py | 125 +++++++++++++------------ 3 files changed, 73 insertions(+), 63 deletions(-) diff --git a/app/cabinet/routes/admin_news_media.py b/app/cabinet/routes/admin_news_media.py index 74295cd6..d126a71e 100644 --- a/app/cabinet/routes/admin_news_media.py +++ b/app/cabinet/routes/admin_news_media.py @@ -27,8 +27,9 @@ logger = structlog.get_logger(__name__) _BYTES_PER_MB = 1024 * 1024 -# Only allow UUID-hex filenames with expected extensions (path traversal defense-in-depth) -_SAFE_FILENAME_RE = re.compile(r'^(thumb_)?[0-9a-f]{32}\.(jpg|mp4|webm)$') +# Only allow UUID-hex filenames with expected extensions (path traversal defense-in-depth). +# thumb_ prefix is NOT allowed — thumbnails are cleaned up automatically when the main file is deleted. +_SAFE_FILENAME_RE = re.compile(r'^[0-9a-f]{32}\.(jpg|mp4|webm)$') router = APIRouter(prefix='/admin/news/media', tags=['Cabinet Admin News Media']) @@ -97,7 +98,7 @@ async def upload_media( ) upload_path = settings.get_media_upload_path() - ensure_upload_dirs(upload_path) + await asyncio.to_thread(ensure_upload_dirs, upload_path) try: if media_type == 'image': diff --git a/app/cabinet/schemas/news_media.py b/app/cabinet/schemas/news_media.py index 654efccf..c523eb7c 100644 --- a/app/cabinet/schemas/news_media.py +++ b/app/cabinet/schemas/news_media.py @@ -1,5 +1,7 @@ """Schemas for news media upload responses.""" +from typing import Literal + from pydantic import BaseModel @@ -8,7 +10,7 @@ class NewsMediaUploadResponse(BaseModel): url: str thumbnail_url: str | None = None - media_type: str # 'image' or 'video' + media_type: Literal['image', 'video'] filename: str size_bytes: int width: int | None = None diff --git a/app/services/news_media_service.py b/app/services/news_media_service.py index c33961bf..dd1b288d 100644 --- a/app/services/news_media_service.py +++ b/app/services/news_media_service.py @@ -111,71 +111,78 @@ def _process_and_save_image( This is a CPU-bound function intended to be run via asyncio.to_thread. """ img = Image.open(io.BytesIO(data)) - - # Double-check pixel count (defense-in-depth alongside Image.MAX_IMAGE_PIXELS) - if img.size[0] * img.size[1] > 25_000_000: - msg = 'Image dimensions too large' - raise ValueError(msg) - - # Fix EXIF orientation (rotated photos from phones) - img = ImageOps.exif_transpose(img) - - # Convert to RGB (strip alpha for JPEG, handle palette/grayscale modes) - if img.mode not in ('RGB', 'L'): - img = img.convert('RGB') - - original_width, original_height = img.size - - # Resize if any dimension exceeds max_dim (preserving aspect ratio) - if original_width > max_dim or original_height > max_dim: - img.thumbnail((max_dim, max_dim), Image.LANCZOS) - - width, height = img.size - filename = f'{uuid.uuid4().hex}.jpg' - image_dir = upload_path / _IMAGES_DIR - target_path = image_dir / filename - - # Atomic write: save to temp file, then rename - tmp_path = target_path.with_suffix('.tmp') try: - img.save(tmp_path, format='JPEG', quality=quality, optimize=True) - tmp_path.rename(target_path) - except Exception: - tmp_path.unlink(missing_ok=True) - raise + # Double-check pixel count (defense-in-depth alongside Image.MAX_IMAGE_PIXELS) + if img.size[0] * img.size[1] > 25_000_000: + msg = 'Image dimensions too large' + raise ValueError(msg) - size_bytes = target_path.stat().st_size + # Fix EXIF orientation (rotated photos from phones) + transposed = ImageOps.exif_transpose(img) + if transposed is not None: + img = transposed - # Generate thumbnail - thumbnail_filename = f'thumb_{filename}' - thumbnail_dir = upload_path / _THUMBNAILS_DIR - thumbnail_target = thumbnail_dir / thumbnail_filename + # Normalize to RGB for consistent JPEG output + if img.mode != 'RGB': + img = img.convert('RGB') - tmp_thumb = thumbnail_target.with_suffix('.tmp') - try: - thumb = img.copy() - thumb.thumbnail(_THUMBNAIL_SIZE, Image.LANCZOS) - thumb.save(tmp_thumb, format='JPEG', quality=quality, optimize=True) - tmp_thumb.rename(thumbnail_target) - except Exception: - tmp_thumb.unlink(missing_ok=True) - # Non-fatal: log and continue without thumbnail - logger.warning('Failed to generate thumbnail', filename=filename) - thumbnail_filename = None + original_width, original_height = img.size - relative_path = f'{_IMAGES_DIR}/{filename}' - thumbnail_path = f'{_THUMBNAILS_DIR}/{thumbnail_filename}' if thumbnail_filename else None + # Resize if any dimension exceeds max_dim (preserving aspect ratio) + if original_width > max_dim or original_height > max_dim: + img.thumbnail((max_dim, max_dim), Image.LANCZOS) - return SavedMedia( - filename=filename, - relative_path=relative_path, - thumbnail_path=thumbnail_path, - media_type='image', - content_type='image/jpeg', - size_bytes=size_bytes, - width=width, - height=height, - ) + width, height = img.size + filename = f'{uuid.uuid4().hex}.jpg' + image_dir = upload_path / _IMAGES_DIR + target_path = image_dir / filename + + # Atomic write: save to temp file, then rename + tmp_path = target_path.with_suffix('.tmp') + try: + img.save(tmp_path, format='JPEG', quality=quality, optimize=True) + tmp_path.rename(target_path) + except Exception: + tmp_path.unlink(missing_ok=True) + raise + + size_bytes = target_path.stat().st_size + + # Generate thumbnail + thumbnail_filename = f'thumb_{filename}' + thumbnail_dir = upload_path / _THUMBNAILS_DIR + thumbnail_target = thumbnail_dir / thumbnail_filename + + tmp_thumb = thumbnail_target.with_suffix('.tmp') + try: + thumb = img.copy() + try: + thumb.thumbnail(_THUMBNAIL_SIZE, Image.LANCZOS) + thumb.save(tmp_thumb, format='JPEG', quality=quality, optimize=True) + tmp_thumb.rename(thumbnail_target) + finally: + thumb.close() + except Exception: + tmp_thumb.unlink(missing_ok=True) + # Non-fatal: log and continue without thumbnail + logger.warning('Failed to generate thumbnail', filename=filename) + thumbnail_filename = None + + relative_path = f'{_IMAGES_DIR}/{filename}' + thumbnail_path = f'{_THUMBNAILS_DIR}/{thumbnail_filename}' if thumbnail_filename else None + + return SavedMedia( + filename=filename, + relative_path=relative_path, + thumbnail_path=thumbnail_path, + media_type='image', + content_type='image/jpeg', + size_bytes=size_bytes, + width=width, + height=height, + ) + finally: + img.close() async def save_image( From ce554cb2a8e606b6060dbf63463e64087a3a7539 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 12:31:34 +0300 Subject: [PATCH 14/33] fix: add Literal type to SavedMedia and close orphaned PIL Image objects - SavedMedia.media_type now uses Literal['image', 'video'] matching Pydantic schema - Explicitly close old Image objects after exif_transpose and convert('RGB') --- app/services/news_media_service.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/services/news_media_service.py b/app/services/news_media_service.py index dd1b288d..c5aba3fd 100644 --- a/app/services/news_media_service.py +++ b/app/services/news_media_service.py @@ -11,6 +11,7 @@ import io import uuid from dataclasses import dataclass from pathlib import Path +from typing import Literal import structlog from PIL import Image, ImageOps @@ -52,7 +53,7 @@ class SavedMedia: filename: str relative_path: str thumbnail_path: str | None - media_type: str # 'image' or 'video' + media_type: Literal['image', 'video'] content_type: str size_bytes: int width: int | None @@ -120,11 +121,15 @@ def _process_and_save_image( # Fix EXIF orientation (rotated photos from phones) transposed = ImageOps.exif_transpose(img) if transposed is not None: + old_img = img img = transposed + old_img.close() # Normalize to RGB for consistent JPEG output if img.mode != 'RGB': + old_img = img img = img.convert('RGB') + old_img.close() original_width, original_height = img.size From 7ff73e8492343be5ed77fad5eea7e730621eac76 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 12:42:21 +0300 Subject: [PATCH 15/33] fix: reject HEIC as MP4, close UploadFile, narrow exception handling - Add ftyp brand allowlist to reject HEIC/HEIF files misclassified as MP4 - Close UploadFile after read to release resources during processing - Add exception chaining (from None) on HTTPException raises - Narrow except to ValueError/OSError (let programming errors propagate) - Add exc_info=True to thumbnail failure log for debuggability - Type detect_file_type return as tuple[MediaType, str] --- app/cabinet/routes/admin_news_media.py | 11 ++++++----- app/services/news_media_service.py | 13 +++++++++---- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/app/cabinet/routes/admin_news_media.py b/app/cabinet/routes/admin_news_media.py index d126a71e..7aeb639a 100644 --- a/app/cabinet/routes/admin_news_media.py +++ b/app/cabinet/routes/admin_news_media.py @@ -66,6 +66,7 @@ async def upload_media( # Read slightly over the max allowed size so we can detect oversized files. absolute_max_bytes = settings.MEDIA_MAX_VIDEO_SIZE_MB * _BYTES_PER_MB + 1 data = await file.read(absolute_max_bytes) + await file.close() if not data: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -85,7 +86,7 @@ async def upload_media( raise HTTPException( status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail='Unsupported file type. Allowed: JPEG, PNG, WebP, MP4, WebM', - ) + ) from None # Enforce per-type size limits max_size_mb = ( @@ -110,12 +111,12 @@ async def upload_media( ) else: saved = await save_video(data, upload_path) - except Exception: - logger.exception('Failed to save uploaded media', media_type=media_type) + except (ValueError, OSError) as exc: + logger.warning('Failed to save uploaded media', media_type=media_type, error=str(exc)) raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail='Failed to process uploaded file', - ) + ) from None logger.info( 'Media uploaded', diff --git a/app/services/news_media_service.py b/app/services/news_media_service.py index c5aba3fd..c51f779a 100644 --- a/app/services/news_media_service.py +++ b/app/services/news_media_service.py @@ -66,7 +66,10 @@ def ensure_upload_dirs(upload_path: Path) -> None: (upload_path / subdir).mkdir(parents=True, exist_ok=True) -def detect_file_type(data: bytes) -> tuple[str, str]: +MediaType = Literal['image', 'video'] + + +def detect_file_type(data: bytes) -> tuple[MediaType, str]: """Detect media type and extension from magic bytes. Returns: @@ -88,8 +91,10 @@ def detect_file_type(data: bytes) -> tuple[str, str]: if data[: len(signature)] == signature: return 'image', ext - # Check MP4: bytes 4-7 must be 'ftyp' - if len(data) >= 8 and data[4:8] == b'ftyp': + # Check MP4: bytes 4-7 must be 'ftyp', bytes 8-12 must be a known video brand. + # Rejects HEIC/HEIF images (ftypheic, ftypmif1, etc.) which share the ftyp box format. + _MP4_VIDEO_BRANDS = {b'isom', b'mp41', b'mp42', b'M4V ', b'avc1', b'iso5', b'iso6', b'mmp4', b'dash', b'mp71'} + if data[4:8] == b'ftyp' and data[8:12] in _MP4_VIDEO_BRANDS: return 'video', '.mp4' # Check standard video signatures @@ -170,7 +175,7 @@ def _process_and_save_image( except Exception: tmp_thumb.unlink(missing_ok=True) # Non-fatal: log and continue without thumbnail - logger.warning('Failed to generate thumbnail', filename=filename) + logger.warning('Failed to generate thumbnail', filename=filename, exc_info=True) thumbnail_filename = None relative_path = f'{_IMAGES_DIR}/{filename}' From 172924df0e1248b27d3f18f6acf23cf115040941 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 13:05:11 +0300 Subject: [PATCH 16/33] fix: catch DecompressionBombError, hoist MP4 brands to module level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PIL.Image.DecompressionBombError to except clause (inherits from Exception, not ValueError/OSError — was escaping as unhandled 500) - Move _MP4_VIDEO_BRANDS to module-level frozenset for consistency --- app/cabinet/routes/admin_news_media.py | 3 ++- app/services/news_media_service.py | 8 +++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/app/cabinet/routes/admin_news_media.py b/app/cabinet/routes/admin_news_media.py index 7aeb639a..7090a735 100644 --- a/app/cabinet/routes/admin_news_media.py +++ b/app/cabinet/routes/admin_news_media.py @@ -7,6 +7,7 @@ import re import structlog from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, status +from PIL import Image as PILImage from app.config import settings from app.database.models import User @@ -111,7 +112,7 @@ async def upload_media( ) else: saved = await save_video(data, upload_path) - except (ValueError, OSError) as exc: + except (ValueError, OSError, PILImage.DecompressionBombError) as exc: logger.warning('Failed to save uploaded media', media_type=media_type, error=str(exc)) raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, diff --git a/app/services/news_media_service.py b/app/services/news_media_service.py index c51f779a..c6daaf12 100644 --- a/app/services/news_media_service.py +++ b/app/services/news_media_service.py @@ -39,6 +39,13 @@ ALLOWED_VIDEO_SIGNATURES: dict[bytes, str] = { b'\x1a\x45\xdf\xa3': '.webm', } +# Known ISO base media file format brands for video. +# Rejects HEIC/HEIF image brands (heic, heix, mif1, msf1, avif) that share the ftyp box format. +_MP4_VIDEO_BRANDS: frozenset[bytes] = frozenset({ + b'isom', b'mp41', b'mp42', b'M4V ', b'avc1', + b'iso5', b'iso6', b'mmp4', b'dash', b'mp71', +}) + _IMAGES_DIR = 'images' _VIDEOS_DIR = 'videos' _THUMBNAILS_DIR = 'thumbnails' @@ -93,7 +100,6 @@ def detect_file_type(data: bytes) -> tuple[MediaType, str]: # Check MP4: bytes 4-7 must be 'ftyp', bytes 8-12 must be a known video brand. # Rejects HEIC/HEIF images (ftypheic, ftypmif1, etc.) which share the ftyp box format. - _MP4_VIDEO_BRANDS = {b'isom', b'mp41', b'mp42', b'M4V ', b'avc1', b'iso5', b'iso6', b'mmp4', b'dash', b'mp71'} if data[4:8] == b'ftyp' and data[8:12] in _MP4_VIDEO_BRANDS: return 'video', '.mp4' From fad77f8c80a8fdecb0512f0ff91e5ae6d78ec8f3 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 13:59:38 +0300 Subject: [PATCH 17/33] fix: phantom user merge on claim failure, referral assignment, account merge hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix orphaned subscriptions/GuestPurchase when phantom claim fails with IntegrityError — now merges phantom into existing user across all 3 call sites - Add explicit db.commit() after merge in both active-user and registration paths - Fix remnawave_uuid transfer ordering (clear→flush→assign) to prevent unique constraint violation during flush - Clear phantom.referral_code on soft-delete to prevent unique constraint issues - Add status != DELETED filter to find_phantom_user_by_username (defense in depth) - Add WARNING-level logging on phantom claims for admin audit trail - Add functional index on lower(username) for phantom lookup performance (migration 0048) - Add ON DELETE CASCADE to subscription_servers.subscription_id (migration 0047) - Add admin endpoint POST /users/{id}/assign-referrer with recursive CTE cycle detection, self-enrichment prevention, and audit logging - Harden account_merge_service: add SubscriptionServer, RioPayPayment, SeverPayPayment, SavedPaymentMethod, GuestPurchase, NewsArticle handling - Fix logger key typo get= → error= in promocode activation --- app/cabinet/routes/admin_users.py | 117 +++++++++++++++++- app/cabinet/schemas/users.py | 15 +++ app/database/crud/user.py | 1 + app/database/models.py | 2 +- app/handlers/start.py | 72 +++++++++-- app/services/account_merge_service.py | 24 ++++ ...047_add_cascade_to_subscription_servers.py | 74 +++++++++++ .../versions/0048_add_lower_username_index.py | 35 ++++++ 8 files changed, 328 insertions(+), 12 deletions(-) create mode 100644 migrations/alembic/versions/0047_add_cascade_to_subscription_servers.py create mode 100644 migrations/alembic/versions/0048_add_lower_username_index.py diff --git a/app/cabinet/routes/admin_users.py b/app/cabinet/routes/admin_users.py index 30ec3d3c..813fb9ce 100644 --- a/app/cabinet/routes/admin_users.py +++ b/app/cabinet/routes/admin_users.py @@ -4,7 +4,7 @@ from datetime import UTC, datetime, timedelta import structlog from fastapi import APIRouter, Depends, HTTPException, Query, status -from sqlalchemy import Integer, and_, delete as sa_delete, func, or_, select +from sqlalchemy import Integer, and_, delete as sa_delete, func, literal, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -42,12 +42,15 @@ from app.database.models import ( UserPromoGroup, UserStatus, ) +from app.services.permission_service import PermissionService from app.utils.timezone import panel_datetime_to_utc from ..dependencies import get_cabinet_db, require_permission from ..schemas.users import ( AdminUserGiftItem, AdminUserGiftsResponse, + AssignReferrerRequest, + AssignReferrerResponse, DeleteDeviceResponse, DeleteUserRequest, DeleteUserResponse, @@ -1696,6 +1699,13 @@ async def update_user_referral_commission( db: AsyncSession = Depends(get_cabinet_db), ): """Update user's individual referral commission percentage.""" + # Prevent admin from modifying their own commission + if user_id == admin.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Admin cannot modify their own referral commission', + ) + user = await get_user_by_id(db, user_id) if not user: raise HTTPException( @@ -1706,6 +1716,14 @@ async def update_user_referral_commission( old_commission = user.referral_commission_percent user.referral_commission_percent = request.commission_percent user.updated_at = datetime.now(UTC) + await PermissionService.log_action( + db, + user_id=admin.id, + action='update_referral_commission', + resource_type='user', + resource_id=str(user_id), + details={'old_commission': old_commission, 'new_commission': request.commission_percent}, + ) await db.commit() logger.info( @@ -1724,6 +1742,103 @@ async def update_user_referral_commission( ) +# === Assign Referrer === + + +@router.post('/{user_id}/assign-referrer', response_model=AssignReferrerResponse) +async def assign_user_referrer( + user_id: int, + request: AssignReferrerRequest, + admin: User = Depends(require_permission('users:referral')), + db: AsyncSession = Depends(get_cabinet_db), +): + """Manually assign a referrer to a user (e.g. cabinet-registered users without telegram_id). + + Bonuses are NOT triggered immediately — they will apply on the user's next topup. + """ + user = await get_user_by_id(db, user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail='User not found', + ) + + if user_id == request.referrer_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='User cannot be their own referrer', + ) + + # Prevent admin self-enrichment + if request.referrer_id == admin.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Admin cannot assign themselves as referrer', + ) + + referrer = await get_user_by_id(db, request.referrer_id) + if not referrer: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail='Referrer user not found', + ) + + # Prevent circular referral chains of any depth via recursive CTE + if await _would_create_referral_cycle(db, user_id, request.referrer_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Circular referral: assigning this referrer would create a cycle in the referral chain', + ) + + old_referrer_id = user.referred_by_id + user.referred_by_id = request.referrer_id + user.updated_at = datetime.now(UTC) + await PermissionService.log_action( + db, + user_id=admin.id, + action='assign_referrer', + resource_type='user', + resource_id=str(user_id), + details={'old_referrer_id': old_referrer_id, 'new_referrer_id': request.referrer_id}, + ) + await db.commit() + + logger.info( + 'Admin assigned referrer to user', + admin_id=admin.id, + user_id=user_id, + old_referrer_id=old_referrer_id, + new_referrer_id=request.referrer_id, + ) + + return AssignReferrerResponse( + success=True, + old_referrer_id=old_referrer_id, + new_referrer_id=request.referrer_id, + message='Referrer assigned successfully. Bonuses will apply on next user topup.', + ) + + +async def _would_create_referral_cycle(db: AsyncSession, user_id: int, referrer_id: int) -> bool: + """Walk the referrer's ancestor chain; if user_id appears, a cycle would form.""" + max_depth = 50 + anchor = ( + select(User.id, User.referred_by_id, literal(0).label('depth')) + .where(User.id == referrer_id) + .cte(name='ancestors', recursive=True) + ) + rpart = ( + select(User.id, User.referred_by_id, (anchor.c.depth + 1).label('depth')) + .join(anchor, User.id == anchor.c.referred_by_id) + .where(anchor.c.depth < max_depth) + ) + ancestors_cte = anchor.union_all(rpart) + result = await db.execute( + select(literal(1)).where(ancestors_cte.c.id == user_id).select_from(ancestors_cte).limit(1) + ) + return result.scalar_one_or_none() is not None + + # === Devices === diff --git a/app/cabinet/schemas/users.py b/app/cabinet/schemas/users.py index 84482acc..7c1f0a8d 100644 --- a/app/cabinet/schemas/users.py +++ b/app/cabinet/schemas/users.py @@ -387,6 +387,21 @@ class UpdateReferralCommissionResponse(BaseModel): message: str +class AssignReferrerRequest(BaseModel): + """Request to manually assign a referrer to a user.""" + + referrer_id: int = Field(..., gt=0, description='ID of the referrer user') + + +class AssignReferrerResponse(BaseModel): + """Response after referrer assignment.""" + + success: bool + old_referrer_id: int | None = None + new_referrer_id: int | None = None + message: str + + class DeviceInfo(BaseModel): """Individual device info.""" diff --git a/app/database/crud/user.py b/app/database/crud/user.py index cb1570b1..f0f572a2 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -139,6 +139,7 @@ async def find_phantom_user_by_username(db: AsyncSession, username: str) -> User .where( User.telegram_id.is_(None), User.auth_type == 'telegram', + User.status != UserStatus.DELETED.value, func.lower(User.username) == normalized, ) .with_for_update() diff --git a/app/database/models.py b/app/database/models.py index 33a1813d..9ab1821b 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -2364,7 +2364,7 @@ class SubscriptionServer(Base): __tablename__ = 'subscription_servers' id = Column(Integer, primary_key=True, index=True) - subscription_id = Column(Integer, ForeignKey('subscriptions.id'), nullable=False) + subscription_id = Column(Integer, ForeignKey('subscriptions.id', ondelete='CASCADE'), nullable=False, index=True) server_squad_id = Column(Integer, ForeignKey('server_squads.id'), nullable=False) connected_at = Column(AwareDateTime(), default=func.now()) diff --git a/app/handlers/start.py b/app/handlers/start.py index 339cc1b3..02fcdc6e 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -178,10 +178,16 @@ async def _claim_phantom_user( existing = await get_user_by_telegram_id(db, telegram_id) return False, existing await db.refresh(phantom, ['subscription']) - logger.info( - 'Claimed phantom user from guest purchase', + # SECURITY NOTE: Phantom matched by username only (telegram_id was unknown at purchase time). + # Telegram usernames are changeable/reassignable, so the claimer may not be the intended + # recipient. This is logged at WARNING for admin audit. A confirmation flow would be needed + # to fully prevent username spoofing attacks on phantom claims. + logger.warning( + 'Phantom user claimed by username match (verify intended recipient)', phantom_user_id=phantom.id, telegram_id=telegram_id, + username=username, + has_subscription=phantom.subscription is not None, ) # Sync Remnawave panel with updated user data (telegram_id, username, etc.) @@ -212,10 +218,11 @@ async def _merge_phantom_into_active_user( """ from sqlalchemy import update - logger.info( - 'Merging phantom user into active user', + logger.warning( + 'Merging phantom user into active user (audit: username-only match)', phantom_id=phantom.id, active_user_id=active_user.id, + active_user_telegram_id=active_user.telegram_id, phantom_username=phantom.username, ) @@ -239,10 +246,12 @@ async def _merge_phantom_into_active_user( if phantom.subscription and not active_user.subscription: # Transfer subscription from phantom to active user phantom.subscription.user_id = active_user.id - # Transfer remnawave_uuid + # Transfer remnawave_uuid (clear first to avoid unique constraint violation on flush) if phantom.remnawave_uuid and not active_user.remnawave_uuid: - active_user.remnawave_uuid = phantom.remnawave_uuid + uuid_to_transfer = phantom.remnawave_uuid phantom.remnawave_uuid = None + await db.flush() + active_user.remnawave_uuid = uuid_to_transfer await db.flush() logger.info( 'Transferred subscription from phantom to active user', @@ -263,11 +272,12 @@ async def _merge_phantom_into_active_user( logger.warning('Failed to disable phantom Remnawave user', error=str(exc)) await decrement_subscription_server_counts(db, phantom.subscription) - # Soft-delete phantom: clear identifiers to prevent future matches, - # preserve record for audit trail and avoid CASCADE deletion of payments/transactions + # Soft-delete phantom: clear unique identifiers to prevent future matches + # and constraint violations. Preserve record for audit trail. phantom.status = UserStatus.DELETED.value phantom.username = None phantom.remnawave_uuid = None + phantom.referral_code = None await db.flush() logger.info('Phantom user merged and soft-deleted', phantom_id=phantom.id, active_user_id=active_user.id) @@ -705,6 +715,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, if phantom and phantom.id != user.id: try: await _merge_phantom_into_active_user(db, phantom, user) + await db.commit() await db.refresh(user, ['subscription']) except Exception: await db.rollback() @@ -1528,7 +1539,20 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta referrer_id=referrer_id, ) if not claimed and user: - # IntegrityError fallback — use existing user + # Phantom claim failed (IntegrityError — user with this telegram_id already exists). + # Merge phantom's subscription + GuestPurchase records into the existing user. + if phantom.id != user.id: + try: + await db.refresh(phantom, ['subscription']) + await _merge_phantom_into_active_user(db, phantom, user) + await db.commit() + except Exception: + await db.rollback() + logger.exception( + 'Failed to merge phantom into existing user during registration', + phantom_id=phantom.id, + active_user_id=user.id, + ) await db.refresh(user, ['subscription']) elif not claimed: logger.critical( @@ -1828,6 +1852,20 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A referrer_id=referrer_id, ) if not claimed and user: + # Phantom claim failed (IntegrityError — user with this telegram_id already exists). + # Merge phantom's subscription + GuestPurchase records into the existing user. + if phantom.id != user.id: + try: + await db.refresh(phantom, ['subscription']) # Re-sync after rollback in _claim_phantom_user + await _merge_phantom_into_active_user(db, phantom, user) + await db.commit() + except Exception: + await db.rollback() + logger.exception( + 'Failed to merge phantom into existing user during registration', + phantom_id=phantom.id, + active_user_id=user.id, + ) await db.refresh(user, ['subscription']) elif not claimed: logger.critical( @@ -1899,7 +1937,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A logger.warning( '⚠️ Не удалось активировать промокод', promocode_to_activate=promocode_to_activate, - get=promocode_result.get('error'), + error=promocode_result.get('error'), ) except Exception as e: logger.error('❌ Ошибка при активации промокода', promocode_to_activate=promocode_to_activate, error=e) @@ -2433,6 +2471,20 @@ async def required_sub_channel_check( referrer_id=referrer_id, ) if not claimed and user: + # Phantom claim failed (IntegrityError — user with this telegram_id already exists). + # Merge phantom's subscription + GuestPurchase records into the existing user. + if phantom.id != user.id: + try: + await db.refresh(phantom, ['subscription']) + await _merge_phantom_into_active_user(db, phantom, user) + await db.commit() + except Exception: + await db.rollback() + logger.exception( + 'Failed to merge phantom into existing user during registration', + phantom_id=phantom.id, + active_user_id=user.id, + ) await db.refresh(user, ['subscription']) elif not claimed: logger.critical( diff --git a/app/services/account_merge_service.py b/app/services/account_merge_service.py index 4e01a63d..2c70b8c4 100644 --- a/app/services/account_merge_service.py +++ b/app/services/account_merge_service.py @@ -23,9 +23,11 @@ from app.database.models import ( CryptoBotPayment, DiscountOffer, FreekassaPayment, + GuestPurchase, HeleketPayment, KassaAiPayment, MulenPayPayment, + NewsArticle, Pal24Payment, PartnerApplication, PartnerStatus, @@ -40,10 +42,14 @@ from app.database.models import ( ReferralContest, ReferralContestEvent, ReferralEarning, + RioPayPayment, + SavedPaymentMethod, SentNotification, + SeverPayPayment, Subscription, SubscriptionConversion, SubscriptionEvent, + SubscriptionServer, SupportAuditLog, Ticket, TicketMessage, @@ -78,6 +84,8 @@ _PAYMENT_MODELS: tuple[type, ...] = ( MulenPayPayment, Pal24Payment, PlategaPayment, + RioPayPayment, + SeverPayPayment, WataPayment, YooKassaPayment, ) @@ -301,6 +309,8 @@ async def _handle_subscription_merge( if primary.remnawave_uuid: await _delete_remnawave_user_with_fallback(primary.remnawave_uuid) primary.remnawave_uuid = None + # Явно удаляем subscription_servers перед подпиской (CASCADE настроен, но делаем явно для ясности) + await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == primary_sub.id)) # Удаляем запись подписки primary await db.delete(primary_sub) await db.flush() @@ -323,6 +333,8 @@ async def _handle_subscription_merge( if secondary.remnawave_uuid: await _delete_remnawave_user_with_fallback(secondary.remnawave_uuid) secondary.remnawave_uuid = None + # Явно удаляем subscription_servers перед подпиской (CASCADE настроен, но делаем явно для ясности) + await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == secondary_sub.id)) # Удаляем запись подписки secondary await db.delete(secondary_sub) await db.flush() @@ -492,6 +504,11 @@ async def execute_merge( for payment_model in _PAYMENT_MODELS: await db.execute(update(payment_model).where(payment_model.user_id == secondary.id).values(user_id=primary.id)) + # 7b. Переназначение saved_payment_methods (FK без ondelete) + await db.execute( + update(SavedPaymentMethod).where(SavedPaymentMethod.user_id == secondary.id).values(user_id=primary.id) + ) + # 8. Переназначение referral_earnings # 8a. Удаляем cross-referral записи между участниками мержа (иначе станут self-referral) await db.execute( @@ -702,6 +719,13 @@ async def execute_merge( await db.execute(update(PinnedMessage).where(PinnedMessage.created_by == secondary.id).values(created_by=None)) await db.execute(update(AdminRole).where(AdminRole.created_by == secondary.id).values(created_by=None)) await db.execute(update(AccessPolicy).where(AccessPolicy.created_by == secondary.id).values(created_by=None)) + await db.execute(update(NewsArticle).where(NewsArticle.created_by == secondary.id).values(created_by=None)) + + # 10s. Переназначение guest_purchases (оба FK — buyer_user_id и user_id) + await db.execute( + update(GuestPurchase).where(GuestPurchase.buyer_user_id == secondary.id).values(buyer_user_id=primary.id) + ) + await db.execute(update(GuestPurchase).where(GuestPurchase.user_id == secondary.id).values(user_id=primary.id)) # 11. Инвалидация refresh-токенов обоих пользователей (после мержа будет создан новый) now = datetime.now(UTC) diff --git a/migrations/alembic/versions/0047_add_cascade_to_subscription_servers.py b/migrations/alembic/versions/0047_add_cascade_to_subscription_servers.py new file mode 100644 index 00000000..0ced8d60 --- /dev/null +++ b/migrations/alembic/versions/0047_add_cascade_to_subscription_servers.py @@ -0,0 +1,74 @@ +"""add ON DELETE CASCADE and index to subscription_servers.subscription_id + +Revision ID: 0047 +Revises: 0046 +Create Date: 2026-03-23 + +Recreates the FK constraint on subscription_servers.subscription_id +with ON DELETE CASCADE so that deleting a subscription automatically +removes dependent subscription_servers rows. Also adds an index +on subscription_id for efficient CASCADE deletes and joins. +""" + +from collections.abc import Sequence + +from alembic import op +from sqlalchemy import text + +revision: str = '0047' +down_revision: str | None = '0046' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _get_actual_fk_name(connection, table: str, column: str) -> str | None: + """Look up actual FK constraint name from pg_constraint.""" + result = connection.execute( + text(""" + SELECT con.conname + FROM pg_constraint con + JOIN pg_class rel ON rel.oid = con.conrelid + JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace + JOIN pg_attribute att ON att.attrelid = con.conrelid + AND att.attnum = ANY(con.conkey) + WHERE rel.relname = :table + AND att.attname = :column + AND con.contype = 'f' + AND nsp.nspname = 'public' + LIMIT 1 + """), + {'table': table, 'column': column}, + ) + row = result.fetchone() + return row[0] if row else None + + +def upgrade() -> None: + connection = op.get_bind() + actual_fk = _get_actual_fk_name(connection, 'subscription_servers', 'subscription_id') + if actual_fk: + op.drop_constraint(actual_fk, 'subscription_servers', type_='foreignkey') + op.create_foreign_key( + 'subscription_servers_subscription_id_fkey', + 'subscription_servers', + 'subscriptions', + ['subscription_id'], + ['id'], + ondelete='CASCADE', + ) + op.create_index('ix_subscription_servers_subscription_id', 'subscription_servers', ['subscription_id']) + + +def downgrade() -> None: + op.drop_index('ix_subscription_servers_subscription_id', 'subscription_servers') + connection = op.get_bind() + actual_fk = _get_actual_fk_name(connection, 'subscription_servers', 'subscription_id') + if actual_fk: + op.drop_constraint(actual_fk, 'subscription_servers', type_='foreignkey') + op.create_foreign_key( + 'subscription_servers_subscription_id_fkey', + 'subscription_servers', + 'subscriptions', + ['subscription_id'], + ['id'], + ) diff --git a/migrations/alembic/versions/0048_add_lower_username_index.py b/migrations/alembic/versions/0048_add_lower_username_index.py new file mode 100644 index 00000000..c30000a5 --- /dev/null +++ b/migrations/alembic/versions/0048_add_lower_username_index.py @@ -0,0 +1,35 @@ +"""add functional index on lower(username) for phantom user lookup + +Revision ID: 0048 +Revises: 0047 +Create Date: 2026-03-23 + +The find_phantom_user_by_username query uses func.lower(User.username) +which cannot use a regular B-tree index on username. This adds a +functional index to avoid sequential scans on the users table. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = '0048' +down_revision: str | None = '0047' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + with op.get_context().autocommit_block(): + op.execute( + sa.text( + 'CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_users_username_lower ' + 'ON users (lower(username))' + ) + ) + + +def downgrade() -> None: + with op.get_context().autocommit_block(): + op.execute(sa.text('DROP INDEX CONCURRENTLY IF EXISTS ix_users_username_lower')) From 6658af6268c10db170e3eaabc75f857e5c664c3c Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 14:26:36 +0300 Subject: [PATCH 18/33] refactor: extract phantom service, replace lightweight merge with execute_merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract _claim_phantom_user and _merge_phantom_into_active_user from start.py into app/services/phantom_service.py - Replace lightweight 3-4 table merge with full execute_merge (30+ tables) - Add durable AdminAuditLog records for phantom claims and merges - Use begin_nested() savepoints for audit log writes (session-safe) - Move Remnawave panel sync to after commit (no HTTP inside locked txn) - Fix remnawave_uuid transfer in account_merge_service with two-flush pattern (clear→flush→assign) to prevent unique constraint violations - Add db.refresh after rollback in Path A to prevent stale object access --- app/handlers/start.py | 188 +++--------------------- app/services/account_merge_service.py | 12 +- app/services/phantom_service.py | 202 ++++++++++++++++++++++++++ 3 files changed, 233 insertions(+), 169 deletions(-) create mode 100644 app/services/phantom_service.py diff --git a/app/handlers/start.py b/app/handlers/start.py index 02fcdc6e..de6a3fd2 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -9,7 +9,6 @@ from aiogram.enums import ParseMode from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from aiogram.filters import Command, StateFilter from aiogram.fsm.context import FSMContext -from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings @@ -44,6 +43,7 @@ from app.services.admin_notification_service import AdminNotificationService from app.services.campaign_service import AdvertisingCampaignService from app.services.channel_subscription_service import channel_subscription_service from app.services.main_menu_button_service import MainMenuButtonService +from app.services.phantom_service import claim_phantom, merge_phantom_into_user, sync_remnawave_after_phantom_merge from app.services.pinned_message_service import ( deliver_pinned_message_to_user, get_active_pinned_message, @@ -132,157 +132,6 @@ async def _activate_pending_gift_after_registration( ) -async def _claim_phantom_user( - db: AsyncSession, - phantom: 'User', - *, - telegram_id: int, - username: str | None, - first_name: str | None, - last_name: str | None, - language: str, - referrer_id: int | None, -) -> tuple[bool, 'User | None']: - """Claim a phantom user by backfilling Telegram profile data. - - Returns (success, user). On IntegrityError falls back to existing user lookup. - - Note: Phantom users created when Bot.get_chat() fails at purchase time are matched - by username only. Since Telegram usernames are changeable and reassignable, this is - inherently vulnerable to username change attacks. When Bot.get_chat() succeeds at - purchase time, telegram_id is stored on the user and the phantom path is not used. - """ - from app.utils.validators import sanitize_telegram_name - - phantom.telegram_id = telegram_id - phantom.username = username - phantom.first_name = sanitize_telegram_name(first_name) - phantom.last_name = sanitize_telegram_name(last_name) - phantom.language = language - phantom.status = UserStatus.ACTIVE.value - if referrer_id and referrer_id != phantom.id: - phantom.referred_by_id = referrer_id - if not phantom.referral_code: - phantom.referral_code = await generate_unique_referral_code(db, telegram_id) - phantom.updated_at = datetime.now(UTC) - phantom.last_activity = datetime.now(UTC) - try: - await db.commit() - except IntegrityError: - await db.rollback() - logger.warning( - 'IntegrityError claiming phantom user, falling back to existing user lookup', - phantom_user_id=phantom.id, - telegram_id=telegram_id, - ) - existing = await get_user_by_telegram_id(db, telegram_id) - return False, existing - await db.refresh(phantom, ['subscription']) - # SECURITY NOTE: Phantom matched by username only (telegram_id was unknown at purchase time). - # Telegram usernames are changeable/reassignable, so the claimer may not be the intended - # recipient. This is logged at WARNING for admin audit. A confirmation flow would be needed - # to fully prevent username spoofing attacks on phantom claims. - logger.warning( - 'Phantom user claimed by username match (verify intended recipient)', - phantom_user_id=phantom.id, - telegram_id=telegram_id, - username=username, - has_subscription=phantom.subscription is not None, - ) - - # Sync Remnawave panel with updated user data (telegram_id, username, etc.) - if phantom.subscription: - try: - subscription_service = SubscriptionService() - await subscription_service.update_remnawave_user(db, phantom.subscription) - except Exception as exc: - logger.warning( - 'Failed to update Remnawave panel after phantom claim', - phantom_user_id=phantom.id, - error=str(exc), - ) - - return True, phantom - - -async def _merge_phantom_into_active_user( - db: AsyncSession, - phantom: 'User', - active_user: 'User', -) -> None: - """Merge a phantom user (created by guest landing purchase) into an existing active user. - - Transfers GuestPurchase records and handles subscription conflict. - The phantom is soft-deleted (status=DELETED, username cleared) to preserve - audit trail and avoid CASCADE deletion of payment/transaction records. - """ - from sqlalchemy import update - - logger.warning( - 'Merging phantom user into active user (audit: username-only match)', - phantom_id=phantom.id, - active_user_id=active_user.id, - active_user_telegram_id=active_user.telegram_id, - phantom_username=phantom.username, - ) - - # Transfer GuestPurchase.user_id references - await db.execute(update(GuestPurchase).where(GuestPurchase.user_id == phantom.id).values(user_id=active_user.id)) - - # Transfer GuestPurchase.buyer_user_id references - await db.execute( - update(GuestPurchase).where(GuestPurchase.buyer_user_id == phantom.id).values(buyer_user_id=active_user.id) - ) - - # Transfer balance - if phantom.balance_kopeks and phantom.balance_kopeks > 0: - active_user.balance_kopeks = (active_user.balance_kopeks or 0) + phantom.balance_kopeks - logger.info('Transferred balance from phantom', amount_kopeks=phantom.balance_kopeks) - - # Handle subscription - await db.refresh(phantom, ['subscription']) - await db.refresh(active_user, ['subscription']) - - if phantom.subscription and not active_user.subscription: - # Transfer subscription from phantom to active user - phantom.subscription.user_id = active_user.id - # Transfer remnawave_uuid (clear first to avoid unique constraint violation on flush) - if phantom.remnawave_uuid and not active_user.remnawave_uuid: - uuid_to_transfer = phantom.remnawave_uuid - phantom.remnawave_uuid = None - await db.flush() - active_user.remnawave_uuid = uuid_to_transfer - await db.flush() - logger.info( - 'Transferred subscription from phantom to active user', - subscription_id=phantom.subscription.id, - ) - elif phantom.subscription: - # Both have subscriptions — disable phantom's Remnawave user and free server slots - logger.warning( - 'Both phantom and active user have subscriptions, disabling phantom', - phantom_subscription_id=phantom.subscription.id, - active_subscription_id=active_user.subscription.id, - ) - if phantom.remnawave_uuid: - try: - subscription_service = SubscriptionService() - await subscription_service.disable_remnawave_user(phantom.remnawave_uuid) - except Exception as exc: - logger.warning('Failed to disable phantom Remnawave user', error=str(exc)) - await decrement_subscription_server_counts(db, phantom.subscription) - - # Soft-delete phantom: clear unique identifiers to prevent future matches - # and constraint violations. Preserve record for audit trail. - phantom.status = UserStatus.DELETED.value - phantom.username = None - phantom.remnawave_uuid = None - phantom.referral_code = None - await db.flush() - - logger.info('Phantom user merged and soft-deleted', phantom_id=phantom.id, active_user_id=active_user.id) - - def _calculate_subscription_flags(subscription): if not subscription: return False, False @@ -714,11 +563,14 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, phantom = await find_phantom_user_by_username(db, message.from_user.username) if phantom and phantom.id != user.id: try: - await _merge_phantom_into_active_user(db, phantom, user) + sub_transferred = await merge_phantom_into_user(db, phantom, user) await db.commit() await db.refresh(user, ['subscription']) + if sub_transferred: + await sync_remnawave_after_phantom_merge(db, user) except Exception: await db.rollback() + await db.refresh(user, ['subscription']) logger.exception( 'Failed to merge phantom user', phantom_id=phantom.id, @@ -1528,7 +1380,7 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta else None ) if phantom: - claimed, user = await _claim_phantom_user( + claimed, user = await claim_phantom( db, phantom, telegram_id=callback.from_user.id, @@ -1540,11 +1392,11 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta ) if not claimed and user: # Phantom claim failed (IntegrityError — user with this telegram_id already exists). - # Merge phantom's subscription + GuestPurchase records into the existing user. + # Merge phantom's data into the existing user via full account merge service. + sub_transferred = False if phantom.id != user.id: try: - await db.refresh(phantom, ['subscription']) - await _merge_phantom_into_active_user(db, phantom, user) + sub_transferred = await merge_phantom_into_user(db, phantom, user) await db.commit() except Exception: await db.rollback() @@ -1554,6 +1406,8 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta active_user_id=user.id, ) await db.refresh(user, ['subscription']) + if sub_transferred: + await sync_remnawave_after_phantom_merge(db, user) elif not claimed: logger.critical( 'Phantom claim failed with no fallback user, proceeding to normal registration', @@ -1841,7 +1695,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A await find_phantom_user_by_username(db, message.from_user.username) if message.from_user.username else None ) if phantom: - claimed, user = await _claim_phantom_user( + claimed, user = await claim_phantom( db, phantom, telegram_id=message.from_user.id, @@ -1853,11 +1707,11 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A ) if not claimed and user: # Phantom claim failed (IntegrityError — user with this telegram_id already exists). - # Merge phantom's subscription + GuestPurchase records into the existing user. + # Merge phantom's data into the existing user via full account merge service. + sub_transferred = False if phantom.id != user.id: try: - await db.refresh(phantom, ['subscription']) # Re-sync after rollback in _claim_phantom_user - await _merge_phantom_into_active_user(db, phantom, user) + sub_transferred = await merge_phantom_into_user(db, phantom, user) await db.commit() except Exception: await db.rollback() @@ -1867,6 +1721,8 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A active_user_id=user.id, ) await db.refresh(user, ['subscription']) + if sub_transferred: + await sync_remnawave_after_phantom_merge(db, user) elif not claimed: logger.critical( 'Phantom claim failed with no fallback user, proceeding to normal registration', @@ -2460,7 +2316,7 @@ async def required_sub_channel_check( else None ) if phantom: - claimed, user = await _claim_phantom_user( + claimed, user = await claim_phantom( db, phantom, telegram_id=query.from_user.id, @@ -2472,11 +2328,11 @@ async def required_sub_channel_check( ) if not claimed and user: # Phantom claim failed (IntegrityError — user with this telegram_id already exists). - # Merge phantom's subscription + GuestPurchase records into the existing user. + # Merge phantom's data into the existing user via full account merge service. + sub_transferred = False if phantom.id != user.id: try: - await db.refresh(phantom, ['subscription']) - await _merge_phantom_into_active_user(db, phantom, user) + sub_transferred = await merge_phantom_into_user(db, phantom, user) await db.commit() except Exception: await db.rollback() @@ -2486,6 +2342,8 @@ async def required_sub_channel_check( active_user_id=user.id, ) await db.refresh(user, ['subscription']) + if sub_transferred: + await sync_remnawave_after_phantom_merge(db, user) elif not claimed: logger.critical( 'Phantom claim failed with no fallback user, proceeding to normal registration', diff --git a/app/services/account_merge_service.py b/app/services/account_merge_service.py index 2c70b8c4..e03332c7 100644 --- a/app/services/account_merge_service.py +++ b/app/services/account_merge_service.py @@ -288,10 +288,12 @@ async def _handle_subscription_merge( if not has_primary_sub and has_secondary_sub: assert secondary_sub is not None secondary_sub.user_id = primary.id - # Переносим remnawave_uuid с secondary на primary + # Переносим remnawave_uuid (clear→flush→assign — unique constraint safety) if secondary.remnawave_uuid: - primary.remnawave_uuid = secondary.remnawave_uuid + uuid_to_transfer = secondary.remnawave_uuid secondary.remnawave_uuid = None + await db.flush() + primary.remnawave_uuid = uuid_to_transfer await db.flush() logger.info( 'Мерж подписок: перенесена подписка secondary на primary', @@ -316,10 +318,12 @@ async def _handle_subscription_merge( await db.flush() # Переносим подписку secondary на primary secondary_sub.user_id = primary.id - # Переносим remnawave_uuid + # Переносим remnawave_uuid (clear→flush→assign — unique constraint safety) if secondary.remnawave_uuid: - primary.remnawave_uuid = secondary.remnawave_uuid + uuid_to_transfer = secondary.remnawave_uuid secondary.remnawave_uuid = None + await db.flush() + primary.remnawave_uuid = uuid_to_transfer # Flush сразу — гарантируем, что DELETE предшествует UPDATE (unique constraint на subscription.user_id) await db.flush() logger.info( diff --git a/app/services/phantom_service.py b/app/services/phantom_service.py new file mode 100644 index 00000000..62ba5a23 --- /dev/null +++ b/app/services/phantom_service.py @@ -0,0 +1,202 @@ +"""Service layer for phantom user claiming and merging. + +Phantom users are created during guest landing purchases when Bot.get_chat() fails — +the user record has @username but no telegram_id. When the real user later presses /start, +we match by username and either claim or merge the phantom into their active account. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Literal + +import structlog +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.crud.rbac import AuditLogCRUD +from app.database.crud.user import get_user_by_telegram_id +from app.database.models import User, UserStatus +from app.services.account_merge_service import execute_merge +from app.services.subscription_service import SubscriptionService +from app.utils.user_utils import generate_unique_referral_code +from app.utils.validators import sanitize_telegram_name + + +logger = structlog.get_logger(__name__) + + +async def claim_phantom( + db: AsyncSession, + phantom: User, + *, + telegram_id: int, + username: str | None, + first_name: str | None, + last_name: str | None, + language: str, + referrer_id: int | None, +) -> tuple[bool, User | None]: + """Claim a phantom user by backfilling Telegram profile data. + + Commits internally on success; rolls back on IntegrityError. + Returns (success, user). On IntegrityError falls back to existing user lookup. + + Note: Phantom users created when Bot.get_chat() fails at purchase time are matched + by username only. Since Telegram usernames are changeable and reassignable, this is + inherently vulnerable to username change attacks. When Bot.get_chat() succeeds at + purchase time, telegram_id is stored on the user and the phantom path is not used. + """ + phantom.telegram_id = telegram_id + phantom.username = username + phantom.first_name = sanitize_telegram_name(first_name) + phantom.last_name = sanitize_telegram_name(last_name) + phantom.language = language + phantom.status = UserStatus.ACTIVE.value + if referrer_id and referrer_id != phantom.id: + phantom.referred_by_id = referrer_id + if not phantom.referral_code: + phantom.referral_code = await generate_unique_referral_code(db, telegram_id) + phantom.updated_at = datetime.now(UTC) + phantom.last_activity = datetime.now(UTC) + + # Write audit log in a savepoint — if it fails, the claim mutations are not affected + try: + async with db.begin_nested(): + await AuditLogCRUD.create( + db, + user_id=phantom.id, + action='phantom_claimed', + resource_type='user', + resource_id=str(phantom.id), + details={ + 'telegram_id': telegram_id, + 'username': username, + }, + status='success', + ) + except Exception: + logger.warning('Failed to write phantom claim audit log', phantom_id=phantom.id, exc_info=True) + + try: + await db.commit() + except IntegrityError: + await db.rollback() + logger.warning( + 'IntegrityError claiming phantom user, falling back to existing user lookup', + phantom_user_id=phantom.id, + telegram_id=telegram_id, + ) + existing = await get_user_by_telegram_id(db, telegram_id) + return False, existing + await db.refresh(phantom, ['subscription']) + + # SECURITY NOTE: Phantom matched by username only (telegram_id was unknown at purchase time). + # Telegram usernames are changeable/reassignable, so the claimer may not be the intended + # recipient. This is logged at WARNING for admin audit. A confirmation flow would be needed + # to fully prevent username spoofing attacks on phantom claims. + logger.warning( + 'Phantom user claimed by username match (verify intended recipient)', + phantom_user_id=phantom.id, + telegram_id=telegram_id, + username=username, + has_subscription=phantom.subscription is not None, + ) + + # Sync Remnawave panel with updated user data (telegram_id, username, etc.) + if phantom.subscription: + try: + subscription_service = SubscriptionService() + await subscription_service.update_remnawave_user(db, phantom.subscription) + except Exception: + logger.warning( + 'Failed to update Remnawave panel after phantom claim', + phantom_user_id=phantom.id, + exc_info=True, + ) + + return True, phantom + + +async def merge_phantom_into_user( + db: AsyncSession, + phantom: User, + active_user: User, +) -> bool: + """Merge phantom user into active user using the full account merge service. + + Uses execute_merge which handles 30+ tables, subscription transfer, balance, + unique constraint safety, and soft-deletion. Caller is responsible for commit/rollback. + + Returns True if a subscription was transferred from phantom (caller should sync + Remnawave panel AFTER commit via ``sync_remnawave_after_phantom_merge``). + """ + # Determine which subscription to keep: phantom's if active user has none, otherwise active's + await db.refresh(phantom, ['subscription']) + await db.refresh(active_user, ['subscription']) + keep_from: Literal['primary', 'secondary'] = ( + 'secondary' if phantom.subscription and not active_user.subscription else 'primary' + ) + + logger.warning( + 'Merging phantom user into active user via execute_merge', + phantom_id=phantom.id, + active_user_id=active_user.id, + keep_subscription_from=keep_from, + phantom_has_sub=phantom.subscription is not None, + active_has_sub=active_user.subscription is not None, + ) + + await execute_merge( + db, + primary_user_id=active_user.id, + secondary_user_id=phantom.id, + keep_subscription_from=keep_from, + provider='phantom_merge', + ) + + # Durable audit log in a savepoint — if it fails, the merge itself is not affected + try: + async with db.begin_nested(): + await AuditLogCRUD.create( + db, + user_id=active_user.id, + action='phantom_merged', + resource_type='user', + resource_id=str(phantom.id), + details={ + 'phantom_id': phantom.id, + 'active_user_id': active_user.id, + 'keep_subscription_from': keep_from, + 'phantom_username': phantom.username, + }, + status='success', + ) + except Exception: + logger.warning( + 'Failed to write phantom merge audit log', + phantom_id=phantom.id, + active_user_id=active_user.id, + exc_info=True, + ) + + return keep_from == 'secondary' + + +async def sync_remnawave_after_phantom_merge(db: AsyncSession, user: User) -> None: + """Sync Remnawave panel after a phantom merge that transferred a subscription. + + Must be called AFTER db.commit() to avoid holding FOR UPDATE locks during HTTP calls. + """ + await db.refresh(user, ['subscription']) + if not user.subscription: + return + try: + subscription_service = SubscriptionService() + await subscription_service.update_remnawave_user(db, user.subscription) + except Exception: + logger.warning( + 'Failed to update Remnawave panel after phantom merge', + user_id=user.id, + exc_info=True, + ) From 2f19c76357fe7de1636d65968cb13f784ee47c31 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 14:48:12 +0300 Subject: [PATCH 19/33] fix: add user ID to payment descriptions for all providers and fix tuple bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add telegram_user_id and user_db_id to get_balance_payment_description() across 8 cabinet balance providers (heleket, mulenpay, pal24, wata, cloudpayments, freekassa, kassa_ai, riopay), all miniapp endpoints, and recurrent payments - For email/OAuth users without telegram_id, fallback to DB ID with (U{id}) format - Fix pre-existing tuple bug in bot_configuration.py (trailing comma created tuple) - Fix typo in nalogo_queue_service.py log message ("Чек уже попыток") --- app/cabinet/routes/admin_news_media.py | 4 +- app/cabinet/routes/balance.py | 38 ++++++++++++------ app/config.py | 8 +++- app/handlers/admin/bot_configuration.py | 2 +- app/services/nalogo_queue_service.py | 11 ++++-- app/services/news_media_service.py | 18 +++++++-- app/services/payment/yookassa.py | 4 +- app/services/recurrent_payment_service.py | 4 +- app/webapi/routes/miniapp.py | 48 +++++++++++++++++------ 9 files changed, 99 insertions(+), 38 deletions(-) diff --git a/app/cabinet/routes/admin_news_media.py b/app/cabinet/routes/admin_news_media.py index 7090a735..6ebae037 100644 --- a/app/cabinet/routes/admin_news_media.py +++ b/app/cabinet/routes/admin_news_media.py @@ -90,9 +90,7 @@ async def upload_media( ) from None # Enforce per-type size limits - max_size_mb = ( - settings.MEDIA_MAX_IMAGE_SIZE_MB if media_type == 'image' else settings.MEDIA_MAX_VIDEO_SIZE_MB - ) + max_size_mb = settings.MEDIA_MAX_IMAGE_SIZE_MB if media_type == 'image' else settings.MEDIA_MAX_VIDEO_SIZE_MB if len(data) > max_size_mb * _BYTES_PER_MB: raise HTTPException( status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, diff --git a/app/cabinet/routes/balance.py b/app/cabinet/routes/balance.py index f31c0b21..603cbf38 100644 --- a/app/cabinet/routes/balance.py +++ b/app/cabinet/routes/balance.py @@ -360,7 +360,7 @@ async def create_topup( option = (request.payment_option or '').strip().lower() # Use description with telegram_id for tax receipts description = settings.get_balance_payment_description( - request.amount_kopeks, telegram_user_id=user.telegram_id + request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id ) if option == 'sbp': result = await payment_service.create_yookassa_sbp_payment( @@ -423,7 +423,7 @@ async def create_topup( amount_usd=amount_usd, asset=settings.CRYPTOBOT_DEFAULT_ASSET, description=settings.get_balance_payment_description( - request.amount_kopeks, telegram_user_id=user.telegram_id + request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id ), payload=f'cabinet_topup_{user.id}_{request.amount_kopeks}', ) @@ -484,7 +484,7 @@ async def create_topup( user_id=user.id, amount_kopeks=request.amount_kopeks, description=settings.get_balance_payment_description( - request.amount_kopeks, telegram_user_id=user.telegram_id + request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id ), language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE, payment_method_code=method_code, @@ -513,7 +513,9 @@ async def create_topup( db=db, user_id=user.id, amount_kopeks=request.amount_kopeks, - description=settings.get_balance_payment_description(request.amount_kopeks), + description=settings.get_balance_payment_description( + request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id + ), language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE, return_url=cabinet_return_url, success_url=cabinet_success_url, @@ -540,7 +542,9 @@ async def create_topup( db=db, user_id=user.id, amount_kopeks=request.amount_kopeks, - description=settings.get_balance_payment_description(request.amount_kopeks), + description=settings.get_balance_payment_description( + request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id + ), language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE, ) @@ -570,7 +574,9 @@ async def create_topup( db=db, user_id=user.id, amount_kopeks=request.amount_kopeks, - description=settings.get_balance_payment_description(request.amount_kopeks), + description=settings.get_balance_payment_description( + request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id + ), language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE, ) @@ -610,7 +616,9 @@ async def create_topup( db=db, user_id=user.id, amount_kopeks=request.amount_kopeks, - description=settings.get_balance_payment_description(request.amount_kopeks), + description=settings.get_balance_payment_description( + request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id + ), language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE, return_url=cabinet_success_url, failed_url=cabinet_failed_url, @@ -637,7 +645,9 @@ async def create_topup( db=db, user_id=user.id, amount_kopeks=request.amount_kopeks, - description=settings.get_balance_payment_description(request.amount_kopeks), + description=settings.get_balance_payment_description( + request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id + ), telegram_id=user.telegram_id, language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE, return_url=cabinet_success_url, @@ -665,7 +675,9 @@ async def create_topup( db=db, user_id=user.id, amount_kopeks=request.amount_kopeks, - description=settings.get_balance_payment_description(request.amount_kopeks), + description=settings.get_balance_payment_description( + request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id + ), language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE, ) @@ -695,7 +707,9 @@ async def create_topup( db=db, user_id=user.id, amount_kopeks=request.amount_kopeks, - description=settings.get_balance_payment_description(request.amount_kopeks), + description=settings.get_balance_payment_description( + request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id + ), email=getattr(user, 'email', None), language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE, payment_system_id=ps_id, @@ -722,7 +736,9 @@ async def create_topup( db=db, user_id=user.id, amount_kopeks=request.amount_kopeks, - description=settings.get_balance_payment_description(request.amount_kopeks), + description=settings.get_balance_payment_description( + request.amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id + ), language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE, success_url=cabinet_success_url, fail_url=cabinet_failed_url, diff --git a/app/config.py b/app/config.py index 234a511a..f8694c51 100644 --- a/app/config.py +++ b/app/config.py @@ -2214,13 +2214,17 @@ class Settings(BaseSettings): except (ValueError, AttributeError): return [30, 60, 90, 180, 360] - def get_balance_payment_description(self, amount_kopeks: int, telegram_user_id: int | None = None) -> str: + def get_balance_payment_description( + self, amount_kopeks: int, telegram_user_id: int | None = None, user_db_id: int | None = None + ) -> str: # Базовое описание description = f'{self.PAYMENT_BALANCE_DESCRIPTION} на {self.format_price(amount_kopeks)}' - # Если передан user_id, добавляем его + # Добавляем идентификатор пользователя (TG ID приоритет, fallback на DB ID) if telegram_user_id is not None: description += f' (ID {telegram_user_id})' + elif user_db_id is not None: + description += f' (U{user_db_id})' # Формируем финальную строку по шаблону return self.PAYMENT_BALANCE_TEMPLATE.format(service_name=self.PAYMENT_SERVICE_NAME, description=description) diff --git a/app/handlers/admin/bot_configuration.py b/app/handlers/admin/bot_configuration.py index a035d3c4..199086ea 100644 --- a/app/handlers/admin/bot_configuration.py +++ b/app/handlers/admin/bot_configuration.py @@ -1906,7 +1906,7 @@ async def test_payment_provider( return amount_kopeks = 10 * 100 - description = (settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id),) + description = settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id) payment_result = await payment_service.create_yookassa_payment( db=db, user_id=db_user.id, diff --git a/app/services/nalogo_queue_service.py b/app/services/nalogo_queue_service.py index 2a91b8ce..d7706821 100644 --- a/app/services/nalogo_queue_service.py +++ b/app/services/nalogo_queue_service.py @@ -158,7 +158,9 @@ class NalogoQueueService: # Логируем количество попыток (чек никогда не удаляется из очереди) if attempts >= 10: - logger.warning('Чек уже попыток, продолжаем пытаться...', payment_id=payment_id, attempts=attempts) + logger.warning( + 'Чек уже много попыток, продолжаем пытаться...', payment_id=payment_id, attempts=attempts + ) # Пытаемся отправить чек try: @@ -181,11 +183,14 @@ class NalogoQueueService: # Формируем описание заново из настроек (если есть данные) if amount_kopeks is not None: - receipt_name = settings.get_balance_payment_description(amount_kopeks, telegram_user_id) + receipt_name = settings.get_balance_payment_description( + amount_kopeks, telegram_user_id=telegram_user_id + ) else: # Fallback на сохранённое имя receipt_name = receipt_data.get( - 'name', settings.get_balance_payment_description(int(amount * 100), telegram_user_id) + 'name', + settings.get_balance_payment_description(int(amount * 100), telegram_user_id=telegram_user_id), ) receipt_uuid = await self._nalogo_service.create_receipt( diff --git a/app/services/news_media_service.py b/app/services/news_media_service.py index c6daaf12..dff5c1c2 100644 --- a/app/services/news_media_service.py +++ b/app/services/news_media_service.py @@ -41,10 +41,20 @@ ALLOWED_VIDEO_SIGNATURES: dict[bytes, str] = { # Known ISO base media file format brands for video. # Rejects HEIC/HEIF image brands (heic, heix, mif1, msf1, avif) that share the ftyp box format. -_MP4_VIDEO_BRANDS: frozenset[bytes] = frozenset({ - b'isom', b'mp41', b'mp42', b'M4V ', b'avc1', - b'iso5', b'iso6', b'mmp4', b'dash', b'mp71', -}) +_MP4_VIDEO_BRANDS: frozenset[bytes] = frozenset( + { + b'isom', + b'mp41', + b'mp42', + b'M4V ', + b'avc1', + b'iso5', + b'iso6', + b'mmp4', + b'dash', + b'mp71', + } +) _IMAGES_DIR = 'images' _VIDEOS_DIR = 'videos' diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index e95b4285..d1a8162d 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -1243,7 +1243,9 @@ class YooKassaPaymentMixin: try: amount_rubles = payment.amount_kopeks / 100 # Формируем описание из настроек (включает сумму и ID пользователя) - receipt_name = settings.get_balance_payment_description(payment.amount_kopeks, telegram_user_id) + receipt_name = settings.get_balance_payment_description( + payment.amount_kopeks, telegram_user_id=telegram_user_id + ) receipt_uuid = await self.nalogo_service.create_receipt( name=receipt_name, diff --git a/app/services/recurrent_payment_service.py b/app/services/recurrent_payment_service.py index e9155187..0b63d66c 100644 --- a/app/services/recurrent_payment_service.py +++ b/app/services/recurrent_payment_service.py @@ -277,7 +277,9 @@ async def _process_single_subscription( logger.warning('YooKassa сервис не сконфигурирован для рекуррентных платежей') return 'skipped' - description = settings.get_balance_payment_description(topup_amount_kopeks) + description = settings.get_balance_payment_description( + topup_amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id + ) metadata = { 'user_id': str(user.id), 'user_telegram_id': str(user.telegram_id) if user.telegram_id else '', diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index adbe9894..ebab6d18 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -934,7 +934,9 @@ async def create_payment_link( payment_service = PaymentService(bot) invoice_link = await payment_service.create_stars_invoice( amount_kopeks=amount_kopeks, - description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id), + description=settings.get_balance_payment_description( + amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id + ), payload=invoice_payload, stars_amount=stars_amount, ) @@ -971,7 +973,9 @@ async def create_payment_link( db=db, user_id=user.id, amount_kopeks=amount_kopeks, - description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id), + description=settings.get_balance_payment_description( + amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id + ), ) confirmation_url = result.get('confirmation_url') if result else None if not result or not confirmation_url: @@ -1009,7 +1013,9 @@ async def create_payment_link( db=db, user_id=user.id, amount_kopeks=amount_kopeks, - description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id), + description=settings.get_balance_payment_description( + amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id + ), ) if not result or not result.get('confirmation_url'): raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail='Failed to create payment') @@ -1041,7 +1047,9 @@ async def create_payment_link( db=db, user_id=user.id, amount_kopeks=amount_kopeks, - description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id), + description=settings.get_balance_payment_description( + amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id + ), language=user.language, ) if not result or not result.get('payment_url'): @@ -1083,7 +1091,9 @@ async def create_payment_link( db=db, user_id=user.id, amount_kopeks=amount_kopeks, - description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id), + description=settings.get_balance_payment_description( + amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id + ), language=user.language or settings.DEFAULT_LANGUAGE, payment_method_code=method_code, ) @@ -1121,7 +1131,9 @@ async def create_payment_link( db=db, user_id=user.id, amount_kopeks=amount_kopeks, - description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id), + description=settings.get_balance_payment_description( + amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id + ), language=user.language, ) payment_url = result.get('payment_url') if result else None @@ -1160,7 +1172,9 @@ async def create_payment_link( db=db, user_id=user.id, amount_kopeks=amount_kopeks, - description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id), + description=settings.get_balance_payment_description( + amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id + ), language=user.language or settings.DEFAULT_LANGUAGE, ) if not result: @@ -1238,7 +1252,9 @@ async def create_payment_link( user_id=user.id, amount_usd=amount_usd, asset=settings.CRYPTOBOT_DEFAULT_ASSET, - description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id), + description=settings.get_balance_payment_description( + amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id + ), payload=f'balance_{user.id}_{amount_kopeks}', ) if not result: @@ -1288,7 +1304,9 @@ async def create_payment_link( db=db, user_id=user.id, amount_kopeks=amount_kopeks, - description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id), + description=settings.get_balance_payment_description( + amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id + ), language=user.language or settings.DEFAULT_LANGUAGE, ) @@ -1333,7 +1351,9 @@ async def create_payment_link( db=db, user_id=user.id, amount_kopeks=amount_kopeks, - description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id), + description=settings.get_balance_payment_description( + amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id + ), telegram_id=user.telegram_id, language=user.language or settings.DEFAULT_LANGUAGE, ) @@ -1374,7 +1394,9 @@ async def create_payment_link( db=db, user_id=user.id, amount_kopeks=amount_kopeks, - description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=user.telegram_id), + description=settings.get_balance_payment_description( + amount_kopeks, telegram_user_id=user.telegram_id, user_db_id=user.id + ), email=getattr(user, 'email', None), language=user.language or settings.DEFAULT_LANGUAGE, ) @@ -1405,7 +1427,9 @@ async def create_payment_link( payment_url = await tribute_service.create_payment_link( user_id=user.telegram_id, amount_kopeks=amount_kopeks or 0, - description=settings.get_balance_payment_description(amount_kopeks or 0), + description=settings.get_balance_payment_description( + amount_kopeks or 0, telegram_user_id=user.telegram_id, user_db_id=user.id + ), ) finally: await bot.session.close() From b5853ec3b6769655f8d19914e51fd078ec9edccc Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 15:00:20 +0300 Subject: [PATCH 20/33] =?UTF-8?q?feat:=20enforce=20single=20featured=20new?= =?UTF-8?q?s=20article=20=E2=80=94=20unfeature=20others=20on=20toggle/crea?= =?UTF-8?q?te/update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/cabinet/routes/admin_news.py | 11 ++++++++++- app/database/crud/news.py | 12 ++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/app/cabinet/routes/admin_news.py b/app/cabinet/routes/admin_news.py index c557010f..c85af3b3 100644 --- a/app/cabinet/routes/admin_news.py +++ b/app/cabinet/routes/admin_news.py @@ -14,6 +14,7 @@ from app.database.crud.news import ( get_all_news, get_all_news_count, get_news_article_by_id, + unfeature_all_news, update_news_article, ) from app.database.models import NewsArticle, User @@ -114,6 +115,8 @@ async def create_article( ) -> NewsArticleResponse: """Create a new news article.""" try: + if request.is_featured: + await unfeature_all_news(db) article = await create_news_article( db, title=request.title, @@ -168,6 +171,8 @@ async def update_article( try: update_data = request.model_dump(exclude_unset=True) + if update_data.get('is_featured'): + await unfeature_all_news(db) article = await update_news_article(db, article, **update_data) except IntegrityError: raise HTTPException( @@ -267,7 +272,11 @@ async def toggle_featured( ) try: - article = await update_news_article(db, article, is_featured=not article.is_featured) + new_featured = not article.is_featured + # Only one article can be featured at a time — unfeature all others first + if new_featured: + await unfeature_all_news(db) + article = await update_news_article(db, article, is_featured=new_featured) return NewsToggleResponse( id=article.id, is_published=article.is_published, diff --git a/app/database/crud/news.py b/app/database/crud/news.py index 979fbc10..060c472d 100644 --- a/app/database/crud/news.py +++ b/app/database/crud/news.py @@ -184,6 +184,18 @@ async def get_news_categories(db: AsyncSession) -> list[str]: return list(result.scalars().all()) +async def unfeature_all_news(db: AsyncSession) -> None: + """Remove featured flag from all articles (so only one can be featured). + + Does NOT commit. The caller must commit the session to persist this change. + This is intentional — the caller should commit both this operation and the + subsequent feature operation atomically. + """ + await db.execute( + update(NewsArticle).where(NewsArticle.is_featured.is_(True)).values(is_featured=False) + ) + + async def update_news_article( db: AsyncSession, article: NewsArticle, From fd410096ea7e541d8443ec4336bfd72de63d03d9 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 15:04:55 +0300 Subject: [PATCH 21/33] fix: respect X-Forwarded-Proto in media URL generation to prevent mixed content --- app/cabinet/routes/admin_news_media.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/cabinet/routes/admin_news_media.py b/app/cabinet/routes/admin_news_media.py index 6ebae037..0f6b64e1 100644 --- a/app/cabinet/routes/admin_news_media.py +++ b/app/cabinet/routes/admin_news_media.py @@ -36,9 +36,10 @@ router = APIRouter(prefix='/admin/news/media', tags=['Cabinet Admin News Media'] def _build_media_url(request: Request, relative_path: str) -> str: - """Build a full URL for a media file from the request base URL.""" - base = str(request.base_url).rstrip('/') - return f'{base}/uploads/{relative_path}' + """Build a full URL for a media file, respecting reverse proxy headers.""" + proto = request.headers.get('X-Forwarded-Proto', request.url.scheme) + host = request.headers.get('X-Forwarded-Host', request.headers.get('Host', request.url.netloc)) + return f'{proto}://{host}/uploads/{relative_path}' def _build_response(request: Request, saved: SavedMedia) -> NewsMediaUploadResponse: From 0225fa155b7d4f3d1c6572a68651c206b1820db0 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 15:10:47 +0300 Subject: [PATCH 22/33] fix: remove future annotations breaking UploadFile, harden media URL generation --- app/cabinet/routes/admin_news_media.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/cabinet/routes/admin_news_media.py b/app/cabinet/routes/admin_news_media.py index 0f6b64e1..c1b9109d 100644 --- a/app/cabinet/routes/admin_news_media.py +++ b/app/cabinet/routes/admin_news_media.py @@ -1,7 +1,5 @@ """Admin routes for managing news article media (images/videos).""" -from __future__ import annotations - import asyncio import re @@ -35,10 +33,16 @@ _SAFE_FILENAME_RE = re.compile(r'^[0-9a-f]{32}\.(jpg|mp4|webm)$') router = APIRouter(prefix='/admin/news/media', tags=['Cabinet Admin News Media']) +_ALLOWED_SCHEMES = frozenset({'http', 'https'}) + + def _build_media_url(request: Request, relative_path: str) -> str: """Build a full URL for a media file, respecting reverse proxy headers.""" - proto = request.headers.get('X-Forwarded-Proto', request.url.scheme) + proto = request.headers.get('X-Forwarded-Proto', request.url.scheme).split(',')[0].strip() + if proto not in _ALLOWED_SCHEMES: + proto = 'https' host = request.headers.get('X-Forwarded-Host', request.headers.get('Host', request.url.netloc)) + host = host.split(',')[0].strip() return f'{proto}://{host}/uploads/{relative_path}' From 89bfdc8ed6bfba48cfcb61083240d5e6e870f49b Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 15:17:10 +0300 Subject: [PATCH 23/33] fix: add explicit File(...) to UploadFile param to fix 422 on media upload --- app/cabinet/routes/admin_news_media.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/cabinet/routes/admin_news_media.py b/app/cabinet/routes/admin_news_media.py index c1b9109d..26715fe8 100644 --- a/app/cabinet/routes/admin_news_media.py +++ b/app/cabinet/routes/admin_news_media.py @@ -4,7 +4,7 @@ import asyncio import re import structlog -from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, status +from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status from PIL import Image as PILImage from app.config import settings @@ -64,7 +64,7 @@ def _build_response(request: Request, saved: SavedMedia) -> NewsMediaUploadRespo @router.post('/upload', response_model=NewsMediaUploadResponse, status_code=status.HTTP_201_CREATED) async def upload_media( request: Request, - file: UploadFile, + file: UploadFile = File(...), admin: User = Depends(require_permission('news:edit')), ) -> NewsMediaUploadResponse: """Upload an image or video for a news article.""" From 51392d1918d8e2e94645acfb3a11b8e16776a5d5 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 15:29:21 +0300 Subject: [PATCH 24/33] feat: add managed news categories and tags with DB-backed CRUD - Add news_categories and news_tags tables with case-insensitive unique names - Add category_id/tag_id FK columns to news_articles (ON DELETE SET NULL) - CRUD endpoints for categories and tags (admin permissions) - Sync legacy string fields from FK entities on create/update - Clear legacy fields on category/tag deletion - Alembic migration 0049 with backfill from existing article data --- app/cabinet/routes/__init__.py | 4 + app/cabinet/routes/admin_news.py | 42 +++++- app/cabinet/routes/admin_news_categories.py | 90 +++++++++++++ app/cabinet/routes/admin_news_tags.py | 90 +++++++++++++ app/cabinet/schemas/news.py | 8 ++ app/cabinet/schemas/news_categories.py | 48 +++++++ app/cabinet/schemas/news_tags.py | 48 +++++++ app/database/crud/news.py | 32 ++++- app/database/crud/news_categories.py | 87 +++++++++++++ app/database/crud/news_tags.py | 87 +++++++++++++ app/database/models.py | 36 ++++++ .../0049_add_news_categories_and_tags.py | 121 ++++++++++++++++++ 12 files changed, 683 insertions(+), 10 deletions(-) create mode 100644 app/cabinet/routes/admin_news_categories.py create mode 100644 app/cabinet/routes/admin_news_tags.py create mode 100644 app/cabinet/schemas/news_categories.py create mode 100644 app/cabinet/schemas/news_tags.py create mode 100644 app/database/crud/news_categories.py create mode 100644 app/database/crud/news_tags.py create mode 100644 migrations/alembic/versions/0049_add_news_categories_and_tags.py diff --git a/app/cabinet/routes/__init__.py b/app/cabinet/routes/__init__.py index e42d5f03..cf5d7406 100644 --- a/app/cabinet/routes/__init__.py +++ b/app/cabinet/routes/__init__.py @@ -14,7 +14,9 @@ from .admin_email_templates import router as admin_email_templates_router from .admin_landings import router as admin_landings_router from .admin_menu_layout import router as admin_menu_layout_router from .admin_news import router as admin_news_router +from .admin_news_categories import router as admin_news_categories_router from .admin_news_media import router as admin_news_media_router +from .admin_news_tags import router as admin_news_tags_router from .admin_partners import router as admin_partners_router from .admin_payment_methods import router as admin_payment_methods_router from .admin_payments import router as admin_payments_router @@ -131,6 +133,8 @@ router.include_router(admin_roles_router) router.include_router(admin_policies_router) router.include_router(admin_audit_log_router) router.include_router(admin_news_router) +router.include_router(admin_news_categories_router) +router.include_router(admin_news_tags_router) router.include_router(admin_news_media_router) # WebSocket route diff --git a/app/cabinet/routes/admin_news.py b/app/cabinet/routes/admin_news.py index c85af3b3..26d904c3 100644 --- a/app/cabinet/routes/admin_news.py +++ b/app/cabinet/routes/admin_news.py @@ -17,6 +17,8 @@ from app.database.crud.news import ( unfeature_all_news, update_news_article, ) +from app.database.crud.news_categories import get_category_by_id +from app.database.crud.news_tags import get_tag_by_id from app.database.models import NewsArticle, User from ..dependencies import get_cabinet_db, require_permission @@ -53,6 +55,8 @@ def _article_to_detail(article: NewsArticle) -> dict[str, Any]: 'category': article.category, 'category_color': article.category_color, 'tag': article.tag, + 'category_id': article.category_id, + 'tag_id': article.tag_id, 'featured_image_url': article.featured_image_url, 'is_published': article.is_published, 'is_featured': article.is_featured, @@ -115,6 +119,22 @@ async def create_article( ) -> NewsArticleResponse: """Create a new news article.""" try: + # Resolve category from FK -- sync legacy string fields from the managed entity + category_name = request.category + category_color = request.category_color + if request.category_id is not None: + cat = await get_category_by_id(db, request.category_id) + if cat: + category_name = cat.name + category_color = cat.color + + # Resolve tag from FK -- sync legacy string field from the managed entity + tag_name = request.tag + if request.tag_id is not None: + tag_obj = await get_tag_by_id(db, request.tag_id) + if tag_obj: + tag_name = tag_obj.name + if request.is_featured: await unfeature_all_news(db) article = await create_news_article( @@ -123,9 +143,11 @@ async def create_article( slug=request.slug, content=request.content, excerpt=request.excerpt, - category=request.category, - category_color=request.category_color, - tag=request.tag, + category=category_name, + category_color=category_color, + tag=tag_name, + category_id=request.category_id, + tag_id=request.tag_id, featured_image_url=request.featured_image_url, is_published=request.is_published, is_featured=request.is_featured, @@ -171,6 +193,20 @@ async def update_article( try: update_data = request.model_dump(exclude_unset=True) + + # Resolve category from FK -- sync legacy string fields from the managed entity + if 'category_id' in update_data and update_data['category_id'] is not None: + cat = await get_category_by_id(db, update_data['category_id']) + if cat: + update_data['category'] = cat.name + update_data['category_color'] = cat.color + + # Resolve tag from FK -- sync legacy string field from the managed entity + if 'tag_id' in update_data and update_data['tag_id'] is not None: + tag_obj = await get_tag_by_id(db, update_data['tag_id']) + if tag_obj: + update_data['tag'] = tag_obj.name + if update_data.get('is_featured'): await unfeature_all_news(db) article = await update_news_article(db, article, **update_data) diff --git a/app/cabinet/routes/admin_news_categories.py b/app/cabinet/routes/admin_news_categories.py new file mode 100644 index 00000000..88a842ba --- /dev/null +++ b/app/cabinet/routes/admin_news_categories.py @@ -0,0 +1,90 @@ +"""Admin routes for managing news categories.""" + +import structlog +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.crud.news_categories import ( + create_category, + delete_category, + get_all_categories, + get_category_by_id, + update_category, +) +from app.database.models import User + +from ..dependencies import get_cabinet_db, require_permission +from ..schemas.news_categories import NewsCategoryCreate, NewsCategoryResponse, NewsCategoryUpdate + + +logger = structlog.get_logger(__name__) + +router = APIRouter(prefix='/admin/news/categories', tags=['Cabinet Admin News Categories']) + + +@router.get('', response_model=list[NewsCategoryResponse]) +async def list_categories( + admin: User = Depends(require_permission('news:read')), + db: AsyncSession = Depends(get_cabinet_db), +) -> list[NewsCategoryResponse]: + """Get all news categories.""" + categories = await get_all_categories(db) + return [NewsCategoryResponse.model_validate(c) for c in categories] + + +@router.post('', response_model=NewsCategoryResponse, status_code=status.HTTP_201_CREATED) +async def create_new_category( + request: NewsCategoryCreate, + admin: User = Depends(require_permission('news:create')), + db: AsyncSession = Depends(get_cabinet_db), +) -> NewsCategoryResponse: + """Create a new news category.""" + try: + category = await create_category(db, name=request.name, color=request.color) + except IntegrityError: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail='Category already exists', + ) + return NewsCategoryResponse.model_validate(category) + + +@router.put('/{category_id}', response_model=NewsCategoryResponse) +async def update_existing_category( + category_id: int, + request: NewsCategoryUpdate, + admin: User = Depends(require_permission('news:edit')), + db: AsyncSession = Depends(get_cabinet_db), +) -> NewsCategoryResponse: + """Update an existing news category.""" + category = await get_category_by_id(db, category_id) + if not category: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail='Category not found', + ) + try: + category = await update_category(db, category, **request.model_dump(exclude_unset=True)) + except IntegrityError: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail='Category name already exists', + ) + return NewsCategoryResponse.model_validate(category) + + +@router.delete('/{category_id}', status_code=status.HTTP_204_NO_CONTENT) +async def remove_category( + category_id: int, + admin: User = Depends(require_permission('news:delete')), + db: AsyncSession = Depends(get_cabinet_db), +) -> None: + """Delete a news category. Articles using it will have category_id set to NULL.""" + category = await get_category_by_id(db, category_id) + if not category: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail='Category not found', + ) + await delete_category(db, category) diff --git a/app/cabinet/routes/admin_news_tags.py b/app/cabinet/routes/admin_news_tags.py new file mode 100644 index 00000000..ce1b631e --- /dev/null +++ b/app/cabinet/routes/admin_news_tags.py @@ -0,0 +1,90 @@ +"""Admin routes for managing news tags.""" + +import structlog +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.crud.news_tags import ( + create_tag, + delete_tag, + get_all_tags, + get_tag_by_id, + update_tag, +) +from app.database.models import User + +from ..dependencies import get_cabinet_db, require_permission +from ..schemas.news_tags import NewsTagCreate, NewsTagResponse, NewsTagUpdate + + +logger = structlog.get_logger(__name__) + +router = APIRouter(prefix='/admin/news/tags', tags=['Cabinet Admin News Tags']) + + +@router.get('', response_model=list[NewsTagResponse]) +async def list_tags( + admin: User = Depends(require_permission('news:read')), + db: AsyncSession = Depends(get_cabinet_db), +) -> list[NewsTagResponse]: + """Get all news tags.""" + tags = await get_all_tags(db) + return [NewsTagResponse.model_validate(t) for t in tags] + + +@router.post('', response_model=NewsTagResponse, status_code=status.HTTP_201_CREATED) +async def create_new_tag( + request: NewsTagCreate, + admin: User = Depends(require_permission('news:create')), + db: AsyncSession = Depends(get_cabinet_db), +) -> NewsTagResponse: + """Create a new news tag.""" + try: + tag = await create_tag(db, name=request.name, color=request.color) + except IntegrityError: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail='Tag already exists', + ) + return NewsTagResponse.model_validate(tag) + + +@router.put('/{tag_id}', response_model=NewsTagResponse) +async def update_existing_tag( + tag_id: int, + request: NewsTagUpdate, + admin: User = Depends(require_permission('news:edit')), + db: AsyncSession = Depends(get_cabinet_db), +) -> NewsTagResponse: + """Update an existing news tag.""" + tag = await get_tag_by_id(db, tag_id) + if not tag: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail='Tag not found', + ) + try: + tag = await update_tag(db, tag, **request.model_dump(exclude_unset=True)) + except IntegrityError: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail='Tag name already exists', + ) + return NewsTagResponse.model_validate(tag) + + +@router.delete('/{tag_id}', status_code=status.HTTP_204_NO_CONTENT) +async def remove_tag( + tag_id: int, + admin: User = Depends(require_permission('news:delete')), + db: AsyncSession = Depends(get_cabinet_db), +) -> None: + """Delete a news tag. Articles using it will have tag_id set to NULL.""" + tag = await get_tag_by_id(db, tag_id) + if not tag: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail='Tag not found', + ) + await delete_tag(db, tag) diff --git a/app/cabinet/schemas/news.py b/app/cabinet/schemas/news.py index 3d84ef52..d1c7cda8 100644 --- a/app/cabinet/schemas/news.py +++ b/app/cabinet/schemas/news.py @@ -175,6 +175,8 @@ class NewsArticleResponse(BaseModel): category: str category_color: str tag: str | None + category_id: int | None = None + tag_id: int | None = None featured_image_url: str | None is_published: bool is_featured: bool @@ -198,6 +200,8 @@ class NewsArticleListItem(BaseModel): category: str category_color: str tag: str | None + category_id: int | None = None + tag_id: int | None = None featured_image_url: str | None is_published: bool is_featured: bool @@ -226,6 +230,8 @@ class NewsCreateRequest(BaseModel): category: str = Field(..., min_length=1, max_length=100) category_color: str = Field(default='#00e5a0', max_length=20) tag: str | None = Field(None, max_length=50) + category_id: int | None = None + tag_id: int | None = None featured_image_url: str | None = Field(None, max_length=2000) is_published: bool = False is_featured: bool = False @@ -281,6 +287,8 @@ class NewsUpdateRequest(BaseModel): category: str | None = Field(None, min_length=1, max_length=100) category_color: str | None = Field(None, max_length=20) tag: str | None = None + category_id: int | None = None + tag_id: int | None = None featured_image_url: str | None = Field(None, max_length=2000) is_published: bool | None = None is_featured: bool | None = None diff --git a/app/cabinet/schemas/news_categories.py b/app/cabinet/schemas/news_categories.py new file mode 100644 index 00000000..9e0c951f --- /dev/null +++ b/app/cabinet/schemas/news_categories.py @@ -0,0 +1,48 @@ +"""Schemas for news categories.""" + +import re + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +_HEX_COLOR_RE: re.Pattern[str] = re.compile(r'^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$') + + +class NewsCategoryCreate(BaseModel): + """Request to create a news category.""" + + name: str = Field(..., min_length=1, max_length=100) + color: str = Field(default='#00e5a0', max_length=20) + + @field_validator('color') + @classmethod + def validate_color(cls, v: str) -> str: + if not _HEX_COLOR_RE.match(v): + msg = 'Invalid hex color' + raise ValueError(msg) + return v + + +class NewsCategoryUpdate(BaseModel): + """Request to update a news category.""" + + name: str | None = Field(None, min_length=1, max_length=100) + color: str | None = Field(None, max_length=20) + + @field_validator('color') + @classmethod + def validate_color(cls, v: str | None) -> str | None: + if v is not None and not _HEX_COLOR_RE.match(v): + msg = 'Invalid hex color' + raise ValueError(msg) + return v + + +class NewsCategoryResponse(BaseModel): + """News category response.""" + + id: int + name: str + color: str + + model_config = ConfigDict(from_attributes=True) diff --git a/app/cabinet/schemas/news_tags.py b/app/cabinet/schemas/news_tags.py new file mode 100644 index 00000000..dcee0e27 --- /dev/null +++ b/app/cabinet/schemas/news_tags.py @@ -0,0 +1,48 @@ +"""Schemas for news tags.""" + +import re + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +_HEX_COLOR_RE: re.Pattern[str] = re.compile(r'^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$') + + +class NewsTagCreate(BaseModel): + """Request to create a news tag.""" + + name: str = Field(..., min_length=1, max_length=50) + color: str = Field(default='#94a3b8', max_length=20) + + @field_validator('color') + @classmethod + def validate_color(cls, v: str) -> str: + if not _HEX_COLOR_RE.match(v): + msg = 'Invalid hex color' + raise ValueError(msg) + return v + + +class NewsTagUpdate(BaseModel): + """Request to update a news tag.""" + + name: str | None = Field(None, min_length=1, max_length=50) + color: str | None = Field(None, max_length=20) + + @field_validator('color') + @classmethod + def validate_color(cls, v: str | None) -> str | None: + if v is not None and not _HEX_COLOR_RE.match(v): + msg = 'Invalid hex color' + raise ValueError(msg) + return v + + +class NewsTagResponse(BaseModel): + """News tag response.""" + + id: int + name: str + color: str + + model_config = ConfigDict(from_attributes=True) diff --git a/app/database/crud/news.py b/app/database/crud/news.py index 060c472d..122829b2 100644 --- a/app/database/crud/news.py +++ b/app/database/crud/news.py @@ -24,6 +24,8 @@ _ALLOWED_UPDATE_FIELDS: frozenset[str] = frozenset( 'category', 'category_color', 'tag', + 'category_id', + 'tag_id', 'featured_image_url', 'is_published', 'is_featured', @@ -37,6 +39,8 @@ _NULLABLE_UPDATE_FIELDS: frozenset[str] = frozenset( { 'excerpt', 'tag', + 'category_id', + 'tag_id', 'featured_image_url', 'published_at', } @@ -53,6 +57,8 @@ async def create_news_article( category: str = '', category_color: str = '#00e5a0', tag: str | None = None, + category_id: int | None = None, + tag_id: int | None = None, featured_image_url: str | None = None, is_published: bool = False, is_featured: bool = False, @@ -77,6 +83,8 @@ async def create_news_article( category=category, category_color=category_color, tag=tag, + category_id=category_id, + tag_id=tag_id, featured_image_url=featured_image_url, is_published=is_published, is_featured=is_featured, @@ -103,17 +111,29 @@ async def create_news_article( async def get_news_article_by_id(db: AsyncSession, article_id: int) -> NewsArticle | None: - """Get a news article by ID with author relationship.""" + """Get a news article by ID with author, category, and tag relationships.""" result = await db.execute( - select(NewsArticle).options(selectinload(NewsArticle.author)).where(NewsArticle.id == article_id) + select(NewsArticle) + .options( + selectinload(NewsArticle.author), + selectinload(NewsArticle.category_obj), + selectinload(NewsArticle.tag_obj), + ) + .where(NewsArticle.id == article_id) ) return result.scalar_one_or_none() async def get_news_article_by_slug(db: AsyncSession, slug: str) -> NewsArticle | None: - """Get a news article by slug with author relationship.""" + """Get a news article by slug with author, category, and tag relationships.""" result = await db.execute( - select(NewsArticle).options(selectinload(NewsArticle.author)).where(NewsArticle.slug == slug) + select(NewsArticle) + .options( + selectinload(NewsArticle.author), + selectinload(NewsArticle.category_obj), + selectinload(NewsArticle.tag_obj), + ) + .where(NewsArticle.slug == slug) ) return result.scalar_one_or_none() @@ -191,9 +211,7 @@ async def unfeature_all_news(db: AsyncSession) -> None: This is intentional — the caller should commit both this operation and the subsequent feature operation atomically. """ - await db.execute( - update(NewsArticle).where(NewsArticle.is_featured.is_(True)).values(is_featured=False) - ) + await db.execute(update(NewsArticle).where(NewsArticle.is_featured.is_(True)).values(is_featured=False)) async def update_news_article( diff --git a/app/database/crud/news_categories.py b/app/database/crud/news_categories.py new file mode 100644 index 00000000..5777064b --- /dev/null +++ b/app/database/crud/news_categories.py @@ -0,0 +1,87 @@ +"""CRUD operations for news categories.""" + +import structlog +from sqlalchemy import select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import NewsArticle, NewsCategory + + +logger = structlog.get_logger(__name__) + + +async def get_all_categories(db: AsyncSession) -> list[NewsCategory]: + """Get all news categories ordered by name.""" + result = await db.execute(select(NewsCategory).order_by(NewsCategory.name)) + return list(result.scalars().all()) + + +async def get_category_by_id(db: AsyncSession, category_id: int) -> NewsCategory | None: + """Get a single news category by primary key.""" + result = await db.execute(select(NewsCategory).where(NewsCategory.id == category_id)) + return result.scalar_one_or_none() + + +async def create_category(db: AsyncSession, *, name: str, color: str = '#00e5a0') -> NewsCategory: + """Create a new news category. + + Raises: + IntegrityError: if a category with the same name already exists (caller must handle). + """ + category = NewsCategory(name=name.strip(), color=color) + db.add(category) + try: + await db.commit() + except IntegrityError: + await db.rollback() + raise + await db.refresh(category) + logger.info('Created news category', category_id=category.id, name=category.name) + return category + + +async def update_category( + db: AsyncSession, + category: NewsCategory, + **kwargs: str | None, +) -> NewsCategory: + """Update an existing news category. + + Supported kwargs: name, color. + + Raises: + IntegrityError: if the new name conflicts with an existing category. + """ + update_data: dict[str, str] = {} + if 'name' in kwargs and kwargs['name'] is not None: + update_data['name'] = kwargs['name'].strip() + if 'color' in kwargs and kwargs['color'] is not None: + update_data['color'] = kwargs['color'] + + if not update_data: + return category + + await db.execute(update(NewsCategory).where(NewsCategory.id == category.id).values(**update_data)) + try: + await db.commit() + except IntegrityError: + await db.rollback() + raise + await db.refresh(category) + logger.info('Updated news category', category_id=category.id, updated_fields=list(update_data.keys())) + return category + + +async def delete_category(db: AsyncSession, category: NewsCategory) -> None: + """Delete a news category and clear category fields from all linked articles.""" + cat_id, cat_name = category.id, category.name + # Clear legacy string fields on articles that reference this category + await db.execute( + update(NewsArticle) + .where(NewsArticle.category_id == cat_id) + .values(category='', category_color='#00e5a0', category_id=None) + ) + await db.delete(category) + await db.commit() + logger.info('Deleted news category', category_id=cat_id, name=cat_name) diff --git a/app/database/crud/news_tags.py b/app/database/crud/news_tags.py new file mode 100644 index 00000000..3d5768bd --- /dev/null +++ b/app/database/crud/news_tags.py @@ -0,0 +1,87 @@ +"""CRUD operations for news tags.""" + +import structlog +from sqlalchemy import select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import NewsArticle, NewsTag + + +logger = structlog.get_logger(__name__) + + +async def get_all_tags(db: AsyncSession) -> list[NewsTag]: + """Get all news tags ordered by name.""" + result = await db.execute(select(NewsTag).order_by(NewsTag.name)) + return list(result.scalars().all()) + + +async def get_tag_by_id(db: AsyncSession, tag_id: int) -> NewsTag | None: + """Get a single news tag by primary key.""" + result = await db.execute(select(NewsTag).where(NewsTag.id == tag_id)) + return result.scalar_one_or_none() + + +async def create_tag(db: AsyncSession, *, name: str, color: str = '#94a3b8') -> NewsTag: + """Create a new news tag. + + Raises: + IntegrityError: if a tag with the same name already exists (caller must handle). + """ + tag = NewsTag(name=name.strip(), color=color) + db.add(tag) + try: + await db.commit() + except IntegrityError: + await db.rollback() + raise + await db.refresh(tag) + logger.info('Created news tag', tag_id=tag.id, name=tag.name) + return tag + + +async def update_tag( + db: AsyncSession, + tag: NewsTag, + **kwargs: str | None, +) -> NewsTag: + """Update an existing news tag. + + Supported kwargs: name, color. + + Raises: + IntegrityError: if the new name conflicts with an existing tag. + """ + update_data: dict[str, str] = {} + if 'name' in kwargs and kwargs['name'] is not None: + update_data['name'] = kwargs['name'].strip() + if 'color' in kwargs and kwargs['color'] is not None: + update_data['color'] = kwargs['color'] + + if not update_data: + return tag + + await db.execute(update(NewsTag).where(NewsTag.id == tag.id).values(**update_data)) + try: + await db.commit() + except IntegrityError: + await db.rollback() + raise + await db.refresh(tag) + logger.info('Updated news tag', tag_id=tag.id, updated_fields=list(update_data.keys())) + return tag + + +async def delete_tag(db: AsyncSession, tag: NewsTag) -> None: + """Delete a news tag and clear tag fields from all linked articles.""" + tag_id, tag_name = tag.id, tag.name + # Clear legacy string field on articles that reference this tag + await db.execute( + update(NewsArticle) + .where(NewsArticle.tag_id == tag_id) + .values(tag=None, tag_id=None) + ) + await db.delete(tag) + await db.commit() + logger.info('Deleted news tag', tag_id=tag_id, name=tag_name) diff --git a/app/database/models.py b/app/database/models.py index 9ab1821b..294a5346 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -28,6 +28,7 @@ from sqlalchemy import ( Time, TypeDecorator, UniqueConstraint, + text, ) from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.declarative import declarative_base @@ -3332,7 +3333,42 @@ class NewsArticle(Base): created_at = Column(AwareDateTime(), server_default=func.now()) updated_at = Column(AwareDateTime(), server_default=func.now(), onupdate=func.now()) + category_id = Column(Integer, ForeignKey('news_categories.id', ondelete='SET NULL'), nullable=True) + tag_id = Column(Integer, ForeignKey('news_tags.id', ondelete='SET NULL'), nullable=True) + author = relationship('User', backref='created_news_articles', foreign_keys=[created_by]) + category_obj = relationship('NewsCategory', foreign_keys=[category_id], lazy='noload') + tag_obj = relationship('NewsTag', foreign_keys=[tag_id], lazy='noload') def __repr__(self) -> str: return f"" + + +class NewsCategory(Base): + """Managed news category with a display color.""" + + __tablename__ = 'news_categories' + __table_args__ = (Index('ix_news_categories_name_lower', text('lower(name)'), unique=True),) + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100), nullable=False) + color = Column(String(20), nullable=False, server_default='#00e5a0') + created_at = Column(AwareDateTime(), server_default=func.now(), nullable=False) + + def __repr__(self) -> str: + return f"" + + +class NewsTag(Base): + """Managed news tag with a display color.""" + + __tablename__ = 'news_tags' + __table_args__ = (Index('ix_news_tags_name_lower', text('lower(name)'), unique=True),) + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(50), nullable=False) + color = Column(String(20), nullable=False, server_default='#94a3b8') + created_at = Column(AwareDateTime(), server_default=func.now(), nullable=False) + + def __repr__(self) -> str: + return f"" diff --git a/migrations/alembic/versions/0049_add_news_categories_and_tags.py b/migrations/alembic/versions/0049_add_news_categories_and_tags.py new file mode 100644 index 00000000..d368207c --- /dev/null +++ b/migrations/alembic/versions/0049_add_news_categories_and_tags.py @@ -0,0 +1,121 @@ +"""add news_categories and news_tags tables with FK columns on news_articles + +Revision ID: 0049 +Revises: 0048 +Create Date: 2026-03-23 + +Adds managed categories and tags for news articles. +- Creates news_categories table with case-insensitive unique name index +- Creates news_tags table with case-insensitive unique name index +- Adds category_id and tag_id FK columns to news_articles +- Backfills categories/tags from existing article data +- Populates FK columns via UPDATE ... FROM matching on lower(name) +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = '0049' +down_revision: str | None = '0048' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # --- news_categories --- + op.create_table( + 'news_categories', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('name', sa.String(100), nullable=False), + sa.Column('color', sa.String(20), nullable=False, server_default='#00e5a0'), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + ) + op.execute( + sa.text( + "CREATE UNIQUE INDEX ix_news_categories_name_lower ON news_categories (lower(name))" + ) + ) + + # --- news_tags --- + op.create_table( + 'news_tags', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), + sa.Column('name', sa.String(50), nullable=False), + sa.Column('color', sa.String(20), nullable=False, server_default='#94a3b8'), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + ) + op.execute( + sa.text( + "CREATE UNIQUE INDEX ix_news_tags_name_lower ON news_tags (lower(name))" + ) + ) + + # --- FK columns on news_articles --- + op.add_column('news_articles', sa.Column('category_id', sa.Integer(), nullable=True)) + op.add_column('news_articles', sa.Column('tag_id', sa.Integer(), nullable=True)) + + op.create_foreign_key( + 'fk_news_articles_category_id', + 'news_articles', + 'news_categories', + ['category_id'], + ['id'], + ondelete='SET NULL', + ) + op.create_foreign_key( + 'fk_news_articles_tag_id', + 'news_articles', + 'news_tags', + ['tag_id'], + ['id'], + ondelete='SET NULL', + ) + + # --- Backfill: seed categories from existing article data --- + op.execute( + sa.text( + "INSERT INTO news_categories (name, color) " + "SELECT DISTINCT category, category_color FROM news_articles " + "WHERE category IS NOT NULL AND category != '' " + "ON CONFLICT DO NOTHING" + ) + ) + + # --- Backfill: seed tags from existing article data --- + op.execute( + sa.text( + "INSERT INTO news_tags (name) " + "SELECT DISTINCT tag FROM news_articles " + "WHERE tag IS NOT NULL AND tag != '' " + "ON CONFLICT DO NOTHING" + ) + ) + + # --- Populate FK columns --- + op.execute( + sa.text( + "UPDATE news_articles SET category_id = nc.id " + "FROM news_categories nc " + "WHERE lower(news_articles.category) = lower(nc.name) " + "AND news_articles.category IS NOT NULL AND news_articles.category != ''" + ) + ) + op.execute( + sa.text( + "UPDATE news_articles SET tag_id = nt.id " + "FROM news_tags nt " + "WHERE lower(news_articles.tag) = lower(nt.name) " + "AND news_articles.tag IS NOT NULL AND news_articles.tag != ''" + ) + ) + + +def downgrade() -> None: + op.drop_constraint('fk_news_articles_tag_id', 'news_articles', type_='foreignkey') + op.drop_constraint('fk_news_articles_category_id', 'news_articles', type_='foreignkey') + op.drop_column('news_articles', 'tag_id') + op.drop_column('news_articles', 'category_id') + op.drop_table('news_tags') + op.drop_table('news_categories') From d9cda3a6d67c6249397e3778c1aefc6bdb6d9e4f Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 15:37:08 +0300 Subject: [PATCH 25/33] fix: register categories/tags/media routers before news to avoid route conflict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /admin/news/{article_id} was catching /admin/news/categories and /admin/news/tags requests, parsing "categories" as int → 422. --- app/cabinet/routes/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/cabinet/routes/__init__.py b/app/cabinet/routes/__init__.py index cf5d7406..5c92a825 100644 --- a/app/cabinet/routes/__init__.py +++ b/app/cabinet/routes/__init__.py @@ -132,10 +132,12 @@ router.include_router(admin_apps_router) router.include_router(admin_roles_router) router.include_router(admin_policies_router) router.include_router(admin_audit_log_router) -router.include_router(admin_news_router) +# Categories/tags/media routers MUST be before the main news router +# to avoid /admin/news/{article_id} catching /admin/news/categories etc. router.include_router(admin_news_categories_router) router.include_router(admin_news_tags_router) router.include_router(admin_news_media_router) +router.include_router(admin_news_router) # WebSocket route router.include_router(websocket_router) From f0cdd5dc904926b55be390798a71a5427a20949b Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 15:40:16 +0300 Subject: [PATCH 26/33] fix: validate FK existence, add FK indexes, expand video brand whitelist - Validate category_id/tag_id exist before creating/updating articles (422 not misleading 409) - Add indexes on news_articles.category_id and tag_id for efficient FK lookups - Expand MP4 brand whitelist: iso2-4, qt (MOV/iPhone), 3gp, M4VH/VP, MSNV, NDAS/C/H/S/M/P - Log unknown ftyp brands for debugging rejected video uploads --- app/cabinet/routes/admin_news.py | 36 +++++++++++++------ app/services/news_media_service.py | 35 +++++++++++++----- .../0049_add_news_categories_and_tags.py | 6 ++++ 3 files changed, 59 insertions(+), 18 deletions(-) diff --git a/app/cabinet/routes/admin_news.py b/app/cabinet/routes/admin_news.py index 26d904c3..924ed624 100644 --- a/app/cabinet/routes/admin_news.py +++ b/app/cabinet/routes/admin_news.py @@ -124,16 +124,24 @@ async def create_article( category_color = request.category_color if request.category_id is not None: cat = await get_category_by_id(db, request.category_id) - if cat: - category_name = cat.name - category_color = cat.color + if not cat: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f'Category with id={request.category_id} not found', + ) + category_name = cat.name + category_color = cat.color # Resolve tag from FK -- sync legacy string field from the managed entity tag_name = request.tag if request.tag_id is not None: tag_obj = await get_tag_by_id(db, request.tag_id) - if tag_obj: - tag_name = tag_obj.name + if not tag_obj: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f'Tag with id={request.tag_id} not found', + ) + tag_name = tag_obj.name if request.is_featured: await unfeature_all_news(db) @@ -197,15 +205,23 @@ async def update_article( # Resolve category from FK -- sync legacy string fields from the managed entity if 'category_id' in update_data and update_data['category_id'] is not None: cat = await get_category_by_id(db, update_data['category_id']) - if cat: - update_data['category'] = cat.name - update_data['category_color'] = cat.color + if not cat: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f'Category with id={update_data["category_id"]} not found', + ) + update_data['category'] = cat.name + update_data['category_color'] = cat.color # Resolve tag from FK -- sync legacy string field from the managed entity if 'tag_id' in update_data and update_data['tag_id'] is not None: tag_obj = await get_tag_by_id(db, update_data['tag_id']) - if tag_obj: - update_data['tag'] = tag_obj.name + if not tag_obj: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f'Tag with id={update_data["tag_id"]} not found', + ) + update_data['tag'] = tag_obj.name if update_data.get('is_featured'): await unfeature_all_news(db) diff --git a/app/services/news_media_service.py b/app/services/news_media_service.py index dff5c1c2..765b57dc 100644 --- a/app/services/news_media_service.py +++ b/app/services/news_media_service.py @@ -44,15 +44,31 @@ ALLOWED_VIDEO_SIGNATURES: dict[bytes, str] = { _MP4_VIDEO_BRANDS: frozenset[bytes] = frozenset( { b'isom', - b'mp41', - b'mp42', - b'M4V ', - b'avc1', + b'iso2', + b'iso3', + b'iso4', b'iso5', b'iso6', + b'mp41', + b'mp42', + b'mp71', + b'M4V ', + b'M4VH', + b'M4VP', + b'MSNV', + b'avc1', b'mmp4', b'dash', - b'mp71', + b'3gp4', + b'3gp5', + b'3gp6', + b'NDAS', + b'NDSC', + b'NDSH', + b'NDSS', + b'NDSM', + b'NDSP', + b'qt ', } ) @@ -108,10 +124,13 @@ def detect_file_type(data: bytes) -> tuple[MediaType, str]: if data[: len(signature)] == signature: return 'image', ext - # Check MP4: bytes 4-7 must be 'ftyp', bytes 8-12 must be a known video brand. + # Check MP4/MOV: bytes 4-7 must be 'ftyp', bytes 8-12 must be a known video brand. # Rejects HEIC/HEIF images (ftypheic, ftypmif1, etc.) which share the ftyp box format. - if data[4:8] == b'ftyp' and data[8:12] in _MP4_VIDEO_BRANDS: - return 'video', '.mp4' + if data[4:8] == b'ftyp': + brand = data[8:12] + if brand in _MP4_VIDEO_BRANDS: + return 'video', '.mp4' + logger.warning('Unknown ftyp brand rejected', brand=brand.decode('ascii', errors='replace')) # Check standard video signatures for signature, ext in ALLOWED_VIDEO_SIGNATURES.items(): diff --git a/migrations/alembic/versions/0049_add_news_categories_and_tags.py b/migrations/alembic/versions/0049_add_news_categories_and_tags.py index d368207c..dd837cda 100644 --- a/migrations/alembic/versions/0049_add_news_categories_and_tags.py +++ b/migrations/alembic/versions/0049_add_news_categories_and_tags.py @@ -73,6 +73,10 @@ def upgrade() -> None: ondelete='SET NULL', ) + # --- Indexes on FK columns for efficient lookups and ON DELETE SET NULL --- + op.create_index('ix_news_articles_category_id', 'news_articles', ['category_id']) + op.create_index('ix_news_articles_tag_id', 'news_articles', ['tag_id']) + # --- Backfill: seed categories from existing article data --- op.execute( sa.text( @@ -113,6 +117,8 @@ def upgrade() -> None: def downgrade() -> None: + op.drop_index('ix_news_articles_tag_id', 'news_articles') + op.drop_index('ix_news_articles_category_id', 'news_articles') op.drop_constraint('fk_news_articles_tag_id', 'news_articles', type_='foreignkey') op.drop_constraint('fk_news_articles_category_id', 'news_articles', type_='foreignkey') op.drop_column('news_articles', 'tag_id') From 76b1f9b036e276646546aa4c89a3fe1d2ee58a40 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 15:43:07 +0300 Subject: [PATCH 27/33] fix: use IF EXISTS in downgrade for FK indexes --- .../alembic/versions/0049_add_news_categories_and_tags.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/migrations/alembic/versions/0049_add_news_categories_and_tags.py b/migrations/alembic/versions/0049_add_news_categories_and_tags.py index dd837cda..81dd6989 100644 --- a/migrations/alembic/versions/0049_add_news_categories_and_tags.py +++ b/migrations/alembic/versions/0049_add_news_categories_and_tags.py @@ -117,8 +117,8 @@ def upgrade() -> None: def downgrade() -> None: - op.drop_index('ix_news_articles_tag_id', 'news_articles') - op.drop_index('ix_news_articles_category_id', 'news_articles') + op.execute(sa.text('DROP INDEX IF EXISTS ix_news_articles_tag_id')) + op.execute(sa.text('DROP INDEX IF EXISTS ix_news_articles_category_id')) op.drop_constraint('fk_news_articles_tag_id', 'news_articles', type_='foreignkey') op.drop_constraint('fk_news_articles_category_id', 'news_articles', type_='foreignkey') op.drop_column('news_articles', 'tag_id') From 334db53868ae2f9206fdde97fa575e953a83cbcf Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 16:04:27 +0300 Subject: [PATCH 28/33] feat: show Platega payment methods inline on main screen (#2720) Show each Platega method (SBP, cards, crypto) as a separate button on the main payment screen like YooKassa, instead of behind a sub-menu. Controlled by PLATEGA_INLINE_METHODS env var (default: true). Set to false to keep the old sub-menu behavior. --- app/config.py | 1 + app/handlers/balance/main.py | 17 ++++++++++-- app/handlers/balance/platega.py | 49 +++++++++++++++++++++++++++++++++ app/keyboards/inline.py | 26 ++++++++++++----- app/utils/payment_utils.py | 39 ++++++++++++++++++++------ 5 files changed, 114 insertions(+), 18 deletions(-) diff --git a/app/config.py b/app/config.py index f8694c51..27c3e1e4 100644 --- a/app/config.py +++ b/app/config.py @@ -462,6 +462,7 @@ class Settings(BaseSettings): PLATEGA_FAILED_URL: str | None = None PLATEGA_CURRENCY: str = 'RUB' PLATEGA_ACTIVE_METHODS: str = '2,10,11,12,13' + PLATEGA_INLINE_METHODS: bool = True PLATEGA_MIN_AMOUNT_KOPEKS: int = 10000 PLATEGA_MAX_AMOUNT_KOPEKS: int = 100000000 PLATEGA_WEBHOOK_PATH: str = '/platega-webhook' diff --git a/app/handlers/balance/main.py b/app/handlers/balance/main.py index 6a34fc04..2102f727 100644 --- a/app/handlers/balance/main.py +++ b/app/handlers/balance/main.py @@ -565,7 +565,16 @@ async def handle_topup_amount_callback( try: # Особые случаи, требующие специальной логики - if method == 'platega': + if method.startswith('platega_m'): + from app.database.database import AsyncSessionLocal + + from .platega import process_platega_payment_amount + + platega_method_code = int(method[len('platega_m'):]) + await state.update_data(payment_method='platega', platega_method=platega_method_code) + async with AsyncSessionLocal() as db: + await process_platega_payment_amount(callback.message, db_user, db, amount_kopeks, state) + elif method == 'platega': from app.database.database import AsyncSessionLocal from .platega import process_platega_payment_amount, start_platega_payment @@ -635,13 +644,17 @@ def register_balance_handlers(dp: Dispatcher): F.data.startswith('pal24_method_'), ) - from .platega import handle_platega_method_selection, start_platega_payment + from .platega import handle_platega_method_selection, start_platega_direct_method, start_platega_payment dp.callback_query.register(start_platega_payment, F.data == 'topup_platega') dp.callback_query.register( handle_platega_method_selection, F.data.startswith('platega_method_'), ) + dp.callback_query.register( + start_platega_direct_method, + F.data.regexp(r'^topup_platega_m\d+$'), + ) from .yookassa import check_yookassa_payment_status diff --git a/app/handlers/balance/platega.py b/app/handlers/balance/platega.py index 1922ddb8..f496e988 100644 --- a/app/handlers/balance/platega.py +++ b/app/handlers/balance/platega.py @@ -191,6 +191,55 @@ async def handle_platega_method_selection( await callback.answer() +@error_handler +async def start_platega_direct_method( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext, +): + """Handle direct Platega method selection from the main payment screen (inline mode).""" + texts = get_texts(db_user.language) + + try: + method_code = int(callback.data.removeprefix('topup_platega_m')) + except (ValueError, IndexError): + await callback.answer('❌ Некорректный способ оплаты', show_alert=True) + return + + if getattr(db_user, 'restriction_topup', False): + reason = html.escape(getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором') + support_url = settings.get_support_contact_url() + keyboard = [] + if support_url: + keyboard.append([types.InlineKeyboardButton(text='🆘 Обжаловать', url=support_url)]) + keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='menu_balance')]) + + await callback.message.edit_text( + f'🚫 Пополнение ограничено\n\n{reason}\n\n' + 'Если вы считаете это ошибкой, вы можете обжаловать решение.', + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), + ) + await callback.answer() + return + + if not settings.is_platega_enabled(): + await callback.answer( + texts.t( + 'PLATEGA_TEMPORARILY_UNAVAILABLE', + '❌ Оплата через Platega временно недоступна', + ), + show_alert=True, + ) + return + + if method_code not in _get_active_methods(): + await callback.answer('⚠️ Этот способ сейчас недоступен', show_alert=True) + return + + await _prompt_amount(callback.message, db_user, state, method_code) + await callback.answer() + + @error_handler async def process_platega_payment_amount( message: types.Message, diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index d9700b06..c5eaa64a 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -1612,14 +1612,26 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN if settings.is_platega_enabled() and settings.get_platega_active_methods(): platega_name = settings.get_platega_display_name() - keyboard.append( - [ - InlineKeyboardButton( - text=texts.t('PAYMENT_PLATEGA', f'💳 {platega_name}'), - callback_data=_build_callback('platega'), + if settings.PLATEGA_INLINE_METHODS: + for method_code in settings.get_platega_active_methods(): + title = settings.get_platega_method_display_title(method_code) + keyboard.append( + [ + InlineKeyboardButton( + text=f'{title} ({platega_name})', + callback_data=_build_callback(f'platega_m{method_code}'), + ) + ] ) - ] - ) + else: + keyboard.append( + [ + InlineKeyboardButton( + text=texts.t('PAYMENT_PLATEGA', f'💳 {platega_name}'), + callback_data=_build_callback('platega'), + ) + ] + ) has_direct_payment_methods = True if settings.is_cryptobot_enabled(): diff --git a/app/utils/payment_utils.py b/app/utils/payment_utils.py index c4c9c1d6..53d297aa 100644 --- a/app/utils/payment_utils.py +++ b/app/utils/payment_utils.py @@ -113,15 +113,28 @@ def get_available_payment_methods() -> list[dict[str, str]]: if settings.is_platega_enabled() and settings.get_platega_active_methods(): platega_name = settings.get_platega_display_name() - methods.append( - { - 'id': 'platega', - 'name': 'Банковская карта', - 'icon': '💳', - 'description': f'через {platega_name} (карты + СБП)', - 'callback': 'topup_platega', - } - ) + if settings.PLATEGA_INLINE_METHODS: + for method_code in settings.get_platega_active_methods(): + info = settings.get_platega_method_definitions().get(method_code, {}) + methods.append( + { + 'id': f'platega_m{method_code}', + 'name': info.get('name', f'Метод {method_code}'), + 'icon': info.get('title', '💳').split(' ', 1)[0] if info.get('title') else '💳', + 'description': f'через {platega_name}', + 'callback': f'topup_platega_m{method_code}', + } + ) + else: + methods.append( + { + 'id': 'platega', + 'name': 'Банковская карта', + 'icon': '💳', + 'description': f'через {platega_name} (карты + СБП)', + 'callback': 'topup_platega', + } + ) if settings.is_cloudpayments_enabled(): cloudpayments_name = settings.get_cloudpayments_display_name() @@ -282,6 +295,14 @@ def is_payment_method_available(method_id: str) -> bool: return settings.is_heleket_enabled() if method_id == 'platega': return settings.is_platega_enabled() and bool(settings.get_platega_active_methods()) + if method_id.startswith('platega_m'): + if not settings.is_platega_enabled(): + return False + try: + code = int(method_id[len('platega_m'):]) + except ValueError: + return False + return code in settings.get_platega_active_methods() if method_id == 'cloudpayments': return settings.is_cloudpayments_enabled() if method_id == 'freekassa': From 6d0b00359123f7c728b2a23255eed0f422680721 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 16:07:01 +0300 Subject: [PATCH 29/33] chore: ruff format --- app/handlers/balance/main.py | 2 +- app/utils/payment_utils.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/handlers/balance/main.py b/app/handlers/balance/main.py index 2102f727..57483bdb 100644 --- a/app/handlers/balance/main.py +++ b/app/handlers/balance/main.py @@ -570,7 +570,7 @@ async def handle_topup_amount_callback( from .platega import process_platega_payment_amount - platega_method_code = int(method[len('platega_m'):]) + platega_method_code = int(method[len('platega_m') :]) await state.update_data(payment_method='platega', platega_method=platega_method_code) async with AsyncSessionLocal() as db: await process_platega_payment_amount(callback.message, db_user, db, amount_kopeks, state) diff --git a/app/utils/payment_utils.py b/app/utils/payment_utils.py index 53d297aa..31fa926d 100644 --- a/app/utils/payment_utils.py +++ b/app/utils/payment_utils.py @@ -299,7 +299,7 @@ def is_payment_method_available(method_id: str) -> bool: if not settings.is_platega_enabled(): return False try: - code = int(method_id[len('platega_m'):]) + code = int(method_id[len('platega_m') :]) except ValueError: return False return code in settings.get_platega_active_methods() From 6bf41a72d041265f90dfdce635f6cc46ba6f971e Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 16:08:10 +0300 Subject: [PATCH 30/33] chore: ruff format news_tags.py --- app/database/crud/news_tags.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app/database/crud/news_tags.py b/app/database/crud/news_tags.py index 3d5768bd..28fb87a9 100644 --- a/app/database/crud/news_tags.py +++ b/app/database/crud/news_tags.py @@ -77,11 +77,7 @@ async def delete_tag(db: AsyncSession, tag: NewsTag) -> None: """Delete a news tag and clear tag fields from all linked articles.""" tag_id, tag_name = tag.id, tag.name # Clear legacy string field on articles that reference this tag - await db.execute( - update(NewsArticle) - .where(NewsArticle.tag_id == tag_id) - .values(tag=None, tag_id=None) - ) + await db.execute(update(NewsArticle).where(NewsArticle.tag_id == tag_id).values(tag=None, tag_id=None)) await db.delete(tag) await db.commit() logger.info('Deleted news tag', tag_id=tag_id, name=tag_name) From 4660ca5756f5f1ef8033f9202d68612d570ddb1a Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 16:35:18 +0300 Subject: [PATCH 31/33] fix: validate period_days against tariff in purchase-tariff and auto-purchase Critical security fix: the POST /cabinet/subscription/purchase-tariff endpoint accepted arbitrary period_days from client without validating against the tariff's configured periods. The pricing engine returned 0 for unknown periods, allowing free subscription creation. Changes: - Add period_days whitelist validation in /purchase-tariff endpoint - Add zero-price safety guard as defense in depth - Add period_days validation in _auto_purchase_tariff (saved cart) - Add period_days validation in _prepare_auto_extend_context (saved cart) --- app/cabinet/routes/subscription.py | 27 ++++++++++ .../subscription_auto_purchase_service.py | 52 +++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py index 1f6bd325..f4e1702e 100644 --- a/app/cabinet/routes/subscription.py +++ b/app/cabinet/routes/subscription.py @@ -1888,6 +1888,26 @@ async def purchase_tariff( else: period_days = request.period_days + # Validate period_days against tariff's configured periods (prevent arbitrary periods) + if tariff.period_prices: + available_periods = [int(p) for p in tariff.period_prices.keys()] + else: + available_periods = [] + + # Allow custom days only if tariff explicitly supports them + custom_days_allowed = ( + hasattr(tariff, 'can_purchase_custom_days') + and tariff.can_purchase_custom_days() + and hasattr(tariff, 'get_price_for_custom_days') + and tariff.get_price_for_custom_days(period_days) is not None + ) + + if period_days not in available_periods and not custom_days_allowed: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Selected period is not available for this tariff', + ) + # Determine traffic limit (custom traffic support) traffic_limit_gb = tariff.traffic_limit_gb custom_traffic_gb = None @@ -1921,6 +1941,13 @@ async def purchase_tariff( promo_offer_discount_value = result.promo_offer_discount price_before_promo_offer = price_kopeks + promo_offer_discount_value + # Safety guard: reject zero-price purchases for non-daily tariffs (defense in depth) + if price_kopeks <= 0 and result.base_price <= 0 and not is_daily_tariff: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Invalid tariff period or pricing configuration', + ) + # Check balance if user.balance_kopeks < price_kopeks: missing = price_kopeks - user.balance_kopeks diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py index c47c9429..efc9aea5 100644 --- a/app/services/subscription_auto_purchase_service.py +++ b/app/services/subscription_auto_purchase_service.py @@ -181,6 +181,35 @@ async def _prepare_auto_extend_context( if tariff_id: tariff_id = _safe_int(tariff_id) + # Validate period_days against tariff or global renewal periods + if tariff_id: + from app.database.crud.tariff import get_tariff_by_id as _get_tariff + + _tariff = await _get_tariff(db, tariff_id) + if _tariff and _tariff.period_prices and not getattr(_tariff, 'is_daily', False): + available_periods = [int(p) for p in _tariff.period_prices.keys()] + if period_days not in available_periods: + logger.warning( + '🔁 Автопокупка: period_days из корзины не входит в доступные периоды тарифа', + period_days=period_days, + available_periods=available_periods, + tariff_id=tariff_id, + format_user_id=_format_user_id(user), + ) + return None + else: + from app.config import settings as _settings + + available_periods = _settings.get_available_renewal_periods() + if period_days not in available_periods: + logger.warning( + '🔁 Автопокупка: period_days из корзины не входит в доступные периоды продления', + period_days=period_days, + available_periods=available_periods, + format_user_id=_format_user_id(user), + ) + return None + from app.database.crud.user import lock_user_for_pricing from app.services.pricing_engine import pricing_engine as _pricing_engine from app.utils.promo_offer import get_user_active_promo_discount_percent @@ -622,6 +651,29 @@ async def _auto_purchase_tariff( ) return False + # Validate period_days against tariff's configured periods (prevent arbitrary periods from saved cart) + is_daily_tariff = getattr(tariff, 'is_daily', False) + if not is_daily_tariff: + if tariff.period_prices: + available_periods = [int(p) for p in tariff.period_prices.keys()] + else: + available_periods = [] + custom_days_allowed = ( + hasattr(tariff, 'can_purchase_custom_days') + and tariff.can_purchase_custom_days() + and hasattr(tariff, 'get_price_for_custom_days') + and tariff.get_price_for_custom_days(period_days) is not None + ) + if period_days not in available_periods and not custom_days_allowed: + logger.warning( + '🔁 Автопокупка тарифа: period_days не входит в доступные периоды тарифа', + tariff_id=tariff_id, + period_days=period_days, + available_periods=available_periods, + format_user_id=_format_user_id(user), + ) + return False + # Lock user BEFORE price computation to prevent TOCTOU on promo offer from app.database.crud.user import lock_user_for_pricing From 8175bc8bfe56dc564a0783c45226451b695bdbd6 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 16:44:38 +0300 Subject: [PATCH 32/33] fix: comprehensive security hardening across payment and API layers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CloudPayments: require webhook signature when secret configured (all 4 handlers) - Platega: timing-safe HMAC comparison via hmac.compare_digest - CryptoBot/Heleket: return False when API token unconfigured - Tribute: return 503 when API key not configured - Freekassa: use request.client.host instead of X-Forwarded-For - Pal24: verify webhook amount matches stored payment amount - YooKassa: reject test-mode payments in production; add YOOKASSA_TEST_MODE config - CloudPayments: reject test-mode payments in production - WebAPI: add upper bounds to duration_days, traffic_limit_gb, device_limit schemas - WebAPI: bound balance update amount to ±100M kopeks - WebAPI: sign-dispatch for balance updates (negative → subtract_user_balance) - WebAPI miniapp: add blocked/deleted user checks, restriction_topup/subscription guards - Admin handlers: add @admin_required and @error_handler to moderator panel - add_user_balance: guard against negative amounts (use subtract_user_balance instead) --- app/config.py | 1 + app/database/crud/user.py | 8 +++ app/external/cryptobot.py | 4 +- app/external/heleket.py | 4 +- app/handlers/admin/main.py | 2 + app/services/payment/cloudpayments.py | 9 +++ app/services/payment/pal24.py | 18 +++++ app/services/payment/yookassa.py | 8 +++ app/webapi/routes/miniapp.py | 41 +++++++++++ app/webapi/routes/users.py | 27 ++++--- app/webapi/schemas/subscriptions.py | 12 ++-- app/webapi/schemas/users.py | 2 +- app/webserver/payments.py | 100 +++++++++++++++----------- 13 files changed, 175 insertions(+), 61 deletions(-) diff --git a/app/config.py b/app/config.py index 27c3e1e4..81c8b43d 100644 --- a/app/config.py +++ b/app/config.py @@ -367,6 +367,7 @@ class Settings(BaseSettings): YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000 YOOKASSA_RECURRENT_ENABLED: bool = False YOOKASSA_RECURRENT_REQUIRED: bool = False + YOOKASSA_TEST_MODE: bool = False SUPPORT_TOPUP_ENABLED: bool = True PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: int = 10 diff --git a/app/database/crud/user.py b/app/database/crud/user.py index f0f572a2..15c36d8d 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -460,6 +460,14 @@ async def add_user_balance( ) user = locked_result.scalar_one() + if amount_kopeks < 0: + logger.error( + 'add_user_balance вызван с отрицательной суммой — используйте subtract_user_balance', + amount_kopeks=amount_kopeks, + user_id=user.id, + ) + return False + old_balance = user.balance_kopeks user.balance_kopeks += amount_kopeks user.updated_at = datetime.now(UTC) diff --git a/app/external/cryptobot.py b/app/external/cryptobot.py index f992fa74..a4230f18 100644 --- a/app/external/cryptobot.py +++ b/app/external/cryptobot.py @@ -125,8 +125,8 @@ class CryptoBotService: # По документации CryptoBot, ключ ВСЕГДА SHA256 от API токена token = self.api_token if not token: - logger.warning('CryptoBot API token не настроен, пропуск проверки подписи') - return True + logger.error('CryptoBot API token не настроен, отклоняем webhook') + return False try: secret_hash = hashlib.sha256(token.encode()).digest() diff --git a/app/external/heleket.py b/app/external/heleket.py index 5694c94f..3cdfc6d4 100644 --- a/app/external/heleket.py +++ b/app/external/heleket.py @@ -155,8 +155,8 @@ class HeleketService: def verify_webhook_signature(self, payload: dict[str, Any]) -> bool: if not self.is_configured: - logger.warning('Heleket сервис не настроен, подпись пропускается') - return True + logger.error('Heleket сервис не настроен, отклоняем webhook') + return False if not isinstance(payload, dict): logger.error('Heleket webhook payload не dict', payload=payload) diff --git a/app/handlers/admin/main.py b/app/handlers/admin/main.py index 2ae57fb4..346df446 100644 --- a/app/handlers/admin/main.py +++ b/app/handlers/admin/main.py @@ -137,6 +137,8 @@ async def show_support_submenu(callback: types.CallbackQuery, db_user: User, db: # Moderator panel entry (from main menu quick button) +@admin_required +@error_handler async def show_moderator_panel(callback: types.CallbackQuery, db_user: User, db: AsyncSession): texts = get_texts(db_user.language) kb = InlineKeyboardMarkup( diff --git a/app/services/payment/cloudpayments.py b/app/services/payment/cloudpayments.py index f33100eb..0908291f 100644 --- a/app/services/payment/cloudpayments.py +++ b/app/services/payment/cloudpayments.py @@ -147,6 +147,15 @@ class CloudPaymentsPaymentMixin: token = webhook_data.get('token') test_mode = webhook_data.get('test_mode', False) + # Reject test-mode payments when not in test mode + if test_mode and not getattr(settings, 'CLOUDPAYMENTS_TEST_MODE', False): + logger.warning( + 'CloudPayments: rejecting test_mode payment in production', + invoice_id=invoice_id, + test_mode=test_mode, + ) + return False + if not invoice_id: logger.error('CloudPayments webhook без invoice_id') return False diff --git a/app/services/payment/pal24.py b/app/services/payment/pal24.py index d7c23c0c..5bf69e33 100644 --- a/app/services/payment/pal24.py +++ b/app/services/payment/pal24.py @@ -253,6 +253,24 @@ class Pal24PaymentMixin: return True if status in {'PAID', 'SUCCESS', 'OVERPAID'}: + # Verify payment amount matches expected + callback_amount_str = callback.get('OutSum') or callback.get('out_sum') or callback.get('Amount') + if callback_amount_str is not None: + try: + from decimal import Decimal + + received_kopeks = int(Decimal(str(callback_amount_str)) * 100) + if abs(received_kopeks - payment.amount_kopeks) > 1: + logger.error( + 'Pal24 amount mismatch', + expected_kopeks=payment.amount_kopeks, + received_kopeks=received_kopeks, + bill_id=payment.bill_id, + ) + return False + except (ValueError, TypeError) as e: + logger.warning('Pal24: не удалось распарсить сумму из callback', error=str(e)) + metadata = getattr(payment, 'metadata_json', {}) or {} if not isinstance(metadata, dict): metadata = {} diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index d1a8162d..44c49aa8 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -409,6 +409,14 @@ class YooKassaPaymentMixin: ) return True + # Reject test-mode payments in production + if getattr(payment, 'test_mode', False) and not getattr(settings, 'YOOKASSA_TEST_MODE', False): + logger.warning( + 'YooKassa: rejecting test_mode payment in production', + yookassa_payment_id=payment.yookassa_payment_id, + ) + return False + payment_module = import_module('app.services.payment_service') # Проверяем, не обрабатывается ли уже этот платеж (защита от дублирования) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index ebab6d18..51fae652 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -603,6 +603,14 @@ async def _resolve_user_from_init_data( detail='User not found', ) + # Block access for banned/deleted users + user_status = getattr(user, 'status', None) + if user_status in ('blocked', 'deleted'): + raise HTTPException( + status.HTTP_403_FORBIDDEN, + detail='Account is blocked or deleted', + ) + return user, webapp_data @@ -898,6 +906,12 @@ async def create_payment_link( ) -> MiniAppPaymentCreateResponse: user, _ = await _resolve_user_from_init_data(db, payload.init_data) + if getattr(user, 'restriction_topup', False): + raise HTTPException( + status.HTTP_403_FORBIDDEN, + detail='Balance top-up is restricted for this account', + ) + method = (payload.method or '').strip().lower() if not method: raise HTTPException( @@ -4716,6 +4730,14 @@ async def _authorize_miniapp_user( detail={'code': 'user_not_found', 'message': 'User not found'}, ) + # Block access for banned/deleted users + user_status = getattr(user, 'status', None) + if user_status in ('blocked', 'deleted'): + raise HTTPException( + status.HTTP_403_FORBIDDEN, + detail={'code': 'account_blocked', 'message': 'Account is blocked or deleted'}, + ) + return user @@ -5125,6 +5147,13 @@ async def submit_subscription_renewal_endpoint( db: AsyncSession = Depends(get_db_session), ) -> MiniAppSubscriptionRenewalResponse: user = await _authorize_miniapp_user(payload.init_data, db) + + if getattr(user, 'restriction_subscription', False): + raise HTTPException( + status.HTTP_403_FORBIDDEN, + detail={'code': 'subscription_restricted', 'message': 'Subscription purchases are restricted for this account'}, + ) + subscription = _ensure_paid_subscription( user, allowed_statuses={'active', 'trial', 'expired'}, @@ -5438,6 +5467,12 @@ async def subscription_purchase_endpoint( ) -> MiniAppSubscriptionPurchaseResponse: user = await _authorize_miniapp_user(payload.init_data, db) + if getattr(user, 'restriction_subscription', False): + raise HTTPException( + status.HTTP_403_FORBIDDEN, + detail={'code': 'subscription_restricted', 'message': 'Subscription purchases are restricted for this account'}, + ) + from app.database.crud.user import lock_user_for_pricing user = await lock_user_for_pricing(db, user.id) @@ -6387,6 +6422,12 @@ async def purchase_tariff_endpoint( """Покупка или смена тарифа.""" user = await _authorize_miniapp_user(payload.init_data, db) + if getattr(user, 'restriction_subscription', False): + raise HTTPException( + status.HTTP_403_FORBIDDEN, + detail={'code': 'subscription_restricted', 'message': 'Subscription purchases are restricted for this account'}, + ) + if not settings.is_tariffs_mode(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, diff --git a/app/webapi/routes/users.py b/app/webapi/routes/users.py index 14d14cc5..ad33c05a 100644 --- a/app/webapi/routes/users.py +++ b/app/webapi/routes/users.py @@ -22,6 +22,7 @@ from app.database.crud.user import ( get_user_by_id, get_user_by_referral_code, get_user_by_telegram_id, + subtract_user_balance, update_user, ) from app.database.models import PaymentMethod, PromoGroup, Subscription, User, UserStatus @@ -311,14 +312,24 @@ async def update_balance( if not found_user: raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found') - success = await add_user_balance( - db, - found_user, - amount_kopeks=payload.amount_kopeks, - description=payload.description or 'Корректировка через веб-API', - create_transaction=payload.create_transaction, - payment_method=PaymentMethod.MANUAL, - ) + if payload.amount_kopeks > 0: + success = await add_user_balance( + db, + found_user, + amount_kopeks=payload.amount_kopeks, + description=payload.description or 'Корректировка через веб-API', + create_transaction=payload.create_transaction, + payment_method=PaymentMethod.MANUAL, + ) + else: + success = await subtract_user_balance( + db, + found_user, + amount_kopeks=abs(payload.amount_kopeks), + description=payload.description or 'Корректировка через веб-API', + create_transaction=payload.create_transaction, + payment_method=PaymentMethod.MANUAL, + ) if not success: raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Failed to update balance') diff --git a/app/webapi/schemas/subscriptions.py b/app/webapi/schemas/subscriptions.py index 6b05c4ac..28c6d9b3 100644 --- a/app/webapi/schemas/subscriptions.py +++ b/app/webapi/schemas/subscriptions.py @@ -28,24 +28,24 @@ class SubscriptionResponse(BaseModel): class SubscriptionCreateRequest(BaseModel): user_id: int is_trial: bool = False - duration_days: int | None = None - traffic_limit_gb: int | None = None - device_limit: int | None = None + duration_days: int | None = Field(None, ge=1, le=36500) + traffic_limit_gb: int | None = Field(None, ge=0, le=1_000_000) + device_limit: int | None = Field(None, ge=1, le=10_000) squad_uuid: str | None = None connected_squads: list[str] | None = None replace_existing: bool = False class SubscriptionExtendRequest(BaseModel): - days: int = Field(..., gt=0) + days: int = Field(..., gt=0, le=36500) class SubscriptionTrafficRequest(BaseModel): - gb: int = Field(..., gt=0) + gb: int = Field(..., gt=0, le=1_000_000) class SubscriptionDevicesRequest(BaseModel): - devices: int = Field(..., gt=0) + devices: int = Field(..., gt=0, le=10_000) class SubscriptionSquadRequest(BaseModel): diff --git a/app/webapi/schemas/users.py b/app/webapi/schemas/users.py index 1154c06f..96cca72f 100644 --- a/app/webapi/schemas/users.py +++ b/app/webapi/schemas/users.py @@ -82,7 +82,7 @@ class UserUpdateRequest(BaseModel): class BalanceUpdateRequest(BaseModel): - amount_kopeks: int + amount_kopeks: int = Field(..., ge=-100_000_000, le=100_000_000) description: str | None = Field(default='Корректировка через веб-API') create_transaction: bool = True diff --git a/app/webserver/payments.py b/app/webserver/payments.py index f3ef44f9..e48cd879 100644 --- a/app/webserver/payments.py +++ b/app/webserver/payments.py @@ -203,7 +203,14 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute status_code=status.HTTP_401_UNAUTHORIZED, ) - if settings.TRIBUTE_API_KEY and not tribute_api.verify_webhook_signature(payload, signature): + if not settings.TRIBUTE_API_KEY: + logger.error('Tribute webhook received but API key is not configured, rejecting') + return JSONResponse( + {'status': 'error', 'reason': 'service_not_configured'}, + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + + if not tribute_api.verify_webhook_signature(payload, signature): return JSONResponse( {'status': 'error', 'reason': 'invalid_signature'}, status_code=status.HTTP_401_UNAUTHORIZED, @@ -313,20 +320,26 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute signature = request.headers.get('Crypto-Pay-API-Signature') secret = settings.CRYPTOBOT_API_TOKEN - if secret: - if not signature: - return JSONResponse( - {'status': 'error', 'reason': 'missing_signature'}, - status_code=status.HTTP_401_UNAUTHORIZED, - ) + if not secret: + logger.error('CryptoBot webhook received but API token is not configured, rejecting') + return JSONResponse( + {'status': 'error', 'reason': 'service_not_configured'}, + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + ) - from app.external.cryptobot import CryptoBotService + if not signature: + return JSONResponse( + {'status': 'error', 'reason': 'missing_signature'}, + status_code=status.HTTP_401_UNAUTHORIZED, + ) - if not CryptoBotService().verify_webhook_signature(payload_text, signature): - return JSONResponse( - {'status': 'error', 'reason': 'invalid_signature'}, - status_code=status.HTTP_401_UNAUTHORIZED, - ) + from app.external.cryptobot import CryptoBotService + + if not CryptoBotService().verify_webhook_signature(payload_text, signature): + return JSONResponse( + {'status': 'error', 'reason': 'invalid_signature'}, + status_code=status.HTTP_401_UNAUTHORIZED, + ) try: success = await _process_payment_service_callback( @@ -686,7 +699,10 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute if not merchant_id and not secret and not raw_body.strip(): logger.info('Platega webhook verification ping (no auth headers, empty body)') return JSONResponse({'status': 'ok'}) - if merchant_id != (settings.PLATEGA_MERCHANT_ID or '') or secret != (settings.PLATEGA_SECRET or ''): + if not ( + hmac.compare_digest(merchant_id, settings.PLATEGA_MERCHANT_ID or '') + and hmac.compare_digest(secret, settings.PLATEGA_SECRET or '') + ): return JSONResponse( {'status': 'error', 'reason': 'unauthorized'}, status_code=status.HTTP_401_UNAUTHORIZED, @@ -766,21 +782,18 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute headers=dict(request.headers), ) - # Проверяем подпись только если она пришла и API_SECRET настроен + # Проверяем подпись если API_SECRET настроен # CloudPayments использует заголовок X-Content-HMAC или Content-HMAC signature = request.headers.get('X-Content-HMAC') or request.headers.get('Content-HMAC') or '' - if settings.CLOUDPAYMENTS_API_SECRET and signature: + if settings.CLOUDPAYMENTS_API_SECRET: + if not signature: + logger.warning('CloudPayments webhook: signature header missing, rejecting') + return JSONResponse({'code': 13}) if not cloudpayments_service.verify_webhook_signature( raw_body, signature, settings.CLOUDPAYMENTS_API_SECRET ): - logger.warning( - 'CloudPayments check webhook: invalid signature, sig=...', - signature=signature[:20] if signature else 'empty', - ) - return JSONResponse({'code': 13}) # Отклонить - elif settings.CLOUDPAYMENTS_API_SECRET and not signature: - # Подпись не пришла, но API_SECRET настроен - пропускаем проверку с предупреждением - logger.warning('CloudPayments check webhook: no signature header, skipping verification') + logger.warning('CloudPayments webhook: invalid signature') + return JSONResponse({'code': 13}) # Разрешаем платёж logger.info('CloudPayments check webhook: allowing payment, returning code=0') @@ -796,13 +809,16 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute """Pay webhook - вызывается после успешной оплаты.""" raw_body = await request.body() - # Проверяем подпись только если она пришла и API_SECRET настроен + # Проверяем подпись если API_SECRET настроен signature = request.headers.get('X-Content-HMAC') or request.headers.get('Content-HMAC') or '' - if settings.CLOUDPAYMENTS_API_SECRET and signature: + if settings.CLOUDPAYMENTS_API_SECRET: + if not signature: + logger.warning('CloudPayments webhook: signature header missing, rejecting') + return JSONResponse({'code': 13}) if not cloudpayments_service.verify_webhook_signature( raw_body, signature, settings.CLOUDPAYMENTS_API_SECRET ): - logger.warning('CloudPayments pay webhook: invalid signature') + logger.warning('CloudPayments webhook: invalid signature') return JSONResponse({'code': 13}) # Парсим данные формы @@ -828,13 +844,16 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute """Fail webhook - вызывается при неуспешной оплате.""" raw_body = await request.body() - # Проверяем подпись только если она пришла и API_SECRET настроен + # Проверяем подпись если API_SECRET настроен signature = request.headers.get('X-Content-HMAC') or request.headers.get('Content-HMAC') or '' - if settings.CLOUDPAYMENTS_API_SECRET and signature: + if settings.CLOUDPAYMENTS_API_SECRET: + if not signature: + logger.warning('CloudPayments webhook: signature header missing, rejecting') + return JSONResponse({'code': 13}) if not cloudpayments_service.verify_webhook_signature( raw_body, signature, settings.CLOUDPAYMENTS_API_SECRET ): - logger.warning('CloudPayments fail webhook: invalid signature') + logger.warning('CloudPayments webhook: invalid signature') return JSONResponse({'code': 13}) # Парсим данные формы @@ -868,9 +887,12 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute headers=dict(request.headers), ) - # Проверяем подпись только если она пришла и API_SECRET настроен + # Проверяем подпись если API_SECRET настроен signature = request.headers.get('X-Content-HMAC') or request.headers.get('Content-HMAC') or '' - if settings.CLOUDPAYMENTS_API_SECRET and signature: + if settings.CLOUDPAYMENTS_API_SECRET: + if not signature: + logger.warning('CloudPayments webhook: signature header missing, rejecting') + return JSONResponse({'code': 13}) if not cloudpayments_service.verify_webhook_signature( raw_body, signature, settings.CLOUDPAYMENTS_API_SECRET ): @@ -958,16 +980,10 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute @router.post(settings.FREEKASSA_WEBHOOK_PATH) async def freekassa_webhook(request: Request) -> Response: - # Получаем IP клиента с учетом прокси - x_forwarded_for = request.headers.get('X-Forwarded-For') - if x_forwarded_for: - client_ip = x_forwarded_for.split(',')[0].strip() - else: - real_ip = request.headers.get('X-Real-IP') - if real_ip: - client_ip = real_ip.strip() - else: - client_ip = request.client.host if request.client else '127.0.0.1' + # Use transport-layer IP as primary source; only trust proxy headers + # when the direct connection comes from a known proxy. + # This prevents X-Forwarded-For spoofing by external attackers. + client_ip = request.client.host if request.client else '127.0.0.1' # Получаем данные формы try: From 1642be8bd61585d1113a544124640f62b2c916f6 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 16:49:17 +0300 Subject: [PATCH 33/33] chore: ruff format miniapp.py --- app/webapi/routes/miniapp.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 51fae652..b732030c 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -5151,7 +5151,10 @@ async def submit_subscription_renewal_endpoint( if getattr(user, 'restriction_subscription', False): raise HTTPException( status.HTTP_403_FORBIDDEN, - detail={'code': 'subscription_restricted', 'message': 'Subscription purchases are restricted for this account'}, + detail={ + 'code': 'subscription_restricted', + 'message': 'Subscription purchases are restricted for this account', + }, ) subscription = _ensure_paid_subscription( @@ -5470,7 +5473,10 @@ async def subscription_purchase_endpoint( if getattr(user, 'restriction_subscription', False): raise HTTPException( status.HTTP_403_FORBIDDEN, - detail={'code': 'subscription_restricted', 'message': 'Subscription purchases are restricted for this account'}, + detail={ + 'code': 'subscription_restricted', + 'message': 'Subscription purchases are restricted for this account', + }, ) from app.database.crud.user import lock_user_for_pricing @@ -6425,7 +6431,10 @@ async def purchase_tariff_endpoint( if getattr(user, 'restriction_subscription', False): raise HTTPException( status.HTTP_403_FORBIDDEN, - detail={'code': 'subscription_restricted', 'message': 'Subscription purchases are restricted for this account'}, + detail={ + 'code': 'subscription_restricted', + 'message': 'Subscription purchases are restricted for this account', + }, ) if not settings.is_tariffs_mode():