From 9de34900a2a9047ab275c0bbf16fd314eb49a3ec Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 23 Mar 2026 08:06:15 +0300 Subject: [PATCH] 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: """Форматирует трафик."""