Files
Fringg 9de34900a2 fix: comprehensive html.escape() for all user/admin data in Telegram HTML messages
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
2026-03-23 08:06:15 +03:00

244 lines
8.6 KiB
Python

import asyncio
import html
from datetime import UTC, datetime
import structlog
from aiogram import Dispatcher, F, types
from aiogram.exceptions import TelegramBadRequest
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.poll import (
get_poll_response_by_id,
record_poll_answer,
)
from app.database.models import PollQuestion, User
from app.localization.texts import get_texts
from app.services.poll_service import get_next_question, get_question_option, reward_user_for_poll
logger = structlog.get_logger(__name__)
async def _delete_message_later(bot, chat_id: int, message_id: int, delay: int = 10) -> None:
try:
await asyncio.sleep(delay)
await bot.delete_message(chat_id, message_id)
except Exception as error: # pragma: no cover - cleanup best effort
logger.debug('Не удалось удалить сообщение опроса', message_id=message_id, error=error)
async def _render_question_text(
poll_title: str,
question: PollQuestion,
current_index: int,
total: int,
language: str,
) -> str:
texts = get_texts(language)
header = texts.t('POLL_QUESTION_HEADER', '<b>Вопрос {current}/{total}</b>').format(
current=current_index,
total=total,
)
lines = [f'🗳️ <b>{html.escape(poll_title)}</b>', '', header, '', html.escape(question.text)]
return '\n'.join(lines)
async def _update_poll_message(
message: types.Message,
text: str,
*,
reply_markup: types.InlineKeyboardMarkup | None = None,
parse_mode: str | None = 'HTML',
) -> bool:
try:
await message.edit_text(
text,
reply_markup=reply_markup,
parse_mode=parse_mode,
)
return True
except TelegramBadRequest as error:
error_text = str(error).lower()
if 'message is not modified' in error_text:
logger.debug('Опросное сообщение уже актуально, пропускаем обновление', error=error)
return True
logger.warning('Не удалось обновить сообщение опроса', message_id=message.message_id, error=error)
except Exception as error: # pragma: no cover - defensive logging
logger.exception(
'Непредвиденная ошибка при обновлении сообщения опроса', message_id=message.message_id, error=error
)
return False
def _build_options_keyboard(response_id: int, question: PollQuestion) -> types.InlineKeyboardMarkup:
buttons: list[list[types.InlineKeyboardButton]] = []
for option in sorted(question.options, key=lambda o: o.order):
buttons.append(
[
types.InlineKeyboardButton(
text=option.text,
callback_data=f'poll_answer:{response_id}:{question.id}:{option.id}',
)
]
)
return types.InlineKeyboardMarkup(inline_keyboard=buttons)
async def handle_poll_start(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
try:
response_id = int(callback.data.split(':')[1])
except (IndexError, ValueError):
await callback.answer('❌ Опрос не найден', show_alert=True)
return
response = await get_poll_response_by_id(db, response_id)
if not response or response.user_id != db_user.id:
await callback.answer('❌ Опрос не найден', show_alert=True)
return
texts = get_texts(db_user.language)
if response.completed_at:
await callback.answer(texts.t('POLL_ALREADY_COMPLETED', 'Вы уже прошли этот опрос.'), show_alert=True)
return
if not response.poll or not response.poll.questions:
await callback.answer(texts.t('POLL_EMPTY', 'Опрос пока недоступен.'), show_alert=True)
return
if not response.started_at:
response.started_at = datetime.now(UTC)
await db.commit()
index, question = await get_next_question(response)
if not question:
await callback.answer(texts.t('POLL_ERROR', 'Не удалось загрузить вопросы.'), show_alert=True)
return
question_text = await _render_question_text(
response.poll.title,
question,
index,
len(response.poll.questions),
db_user.language,
)
if not await _update_poll_message(
callback.message,
question_text,
reply_markup=_build_options_keyboard(response.id, question),
):
await callback.answer(texts.t('POLL_ERROR', 'Не удалось показать вопрос.'), show_alert=True)
return
await callback.answer()
async def handle_poll_answer(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
try:
_, response_id, question_id, option_id = callback.data.split(':', 3)
response_id = int(response_id)
question_id = int(question_id)
option_id = int(option_id)
except (ValueError, IndexError):
await callback.answer('❌ Некорректные данные', show_alert=True)
return
response = await get_poll_response_by_id(db, response_id)
texts = get_texts(db_user.language)
if not response or response.user_id != db_user.id:
await callback.answer('❌ Опрос не найден', show_alert=True)
return
if not response.poll:
await callback.answer(texts.t('POLL_ERROR', 'Опрос недоступен.'), show_alert=True)
return
if response.completed_at:
await callback.answer(texts.t('POLL_ALREADY_COMPLETED', 'Вы уже прошли этот опрос.'), show_alert=True)
return
question = next((q for q in response.poll.questions if q.id == question_id), None)
if not question:
await callback.answer(texts.t('POLL_ERROR', 'Вопрос не найден.'), show_alert=True)
return
option = await get_question_option(question, option_id)
if not option:
await callback.answer(texts.t('POLL_ERROR', 'Вариант ответа не найден.'), show_alert=True)
return
await record_poll_answer(
db,
response_id=response.id,
question_id=question.id,
option_id=option.id,
)
try:
await db.refresh(response, attribute_names=['answers'])
except Exception as error: # pragma: no cover - defensive cache busting
logger.debug('Не удалось обновить локальные ответы опроса', response_id=response.id, error=error)
response = await get_poll_response_by_id(db, response.id)
if not response:
await callback.answer(texts.t('POLL_ERROR', 'Опрос недоступен.'), show_alert=True)
return
index, next_question = await get_next_question(response)
if next_question:
question_text = await _render_question_text(
response.poll.title,
next_question,
index,
len(response.poll.questions),
db_user.language,
)
if not await _update_poll_message(
callback.message,
question_text,
reply_markup=_build_options_keyboard(response.id, next_question),
):
await callback.answer(texts.t('POLL_ERROR', 'Не удалось показать вопрос.'), show_alert=True)
return
await callback.answer()
return
response.completed_at = datetime.now(UTC)
await db.commit()
reward_amount = await reward_user_for_poll(db, response)
thanks_lines = [texts.t('POLL_COMPLETED', '🙏 Спасибо за участие в опросе!')]
if reward_amount:
thanks_lines.append(
texts.t(
'POLL_REWARD_GRANTED',
'Награда {amount} зачислена на ваш баланс.',
).format(amount=settings.format_price(reward_amount))
)
if not await _update_poll_message(
callback.message,
'\n\n'.join(thanks_lines),
):
await callback.answer(texts.t('POLL_COMPLETED', '🙏 Спасибо за участие в опросе!'))
return
asyncio.create_task(_delete_message_later(callback.bot, callback.message.chat.id, callback.message.message_id))
await callback.answer()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(handle_poll_start, F.data.startswith('poll_start:'))
dp.callback_query.register(handle_poll_answer, F.data.startswith('poll_answer:'))