diff --git a/app/handlers/admin/welcome_text.py b/app/handlers/admin/welcome_text.py index f4c2b353..1c383005 100644 --- a/app/handlers/admin/welcome_text.py +++ b/app/handlers/admin/welcome_text.py @@ -1,4 +1,5 @@ import logging +import re from aiogram import Dispatcher, types, F from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession @@ -18,6 +19,92 @@ from app.database.crud.welcome_text import ( logger = logging.getLogger(__name__) + +def validate_html_tags(text: str) -> tuple[bool, str]: + """ + Проверяет HTML-теги в тексте на соответствие требованиям Telegram API. + + Args: + text: Текст для проверки + + Returns: + Кортеж из (валидно ли, сообщение об ошибке или None) + """ + # Поддерживаемые теги в parse_mode="HTML" для Telegram API + allowed_tags = { + 'b', 'strong', # жирный + 'i', 'em', # курсив + 'u', 'ins', # подчеркнуто + 's', 'strike', 'del', # зачеркнуто + 'code', # моноширинный для коротких фрагментов + 'pre', # моноширинный блок кода + 'a' # ссылки + } + + # Убираем плейсхолдеры из строки перед проверкой тегов + # Плейсхолдеры имеют формат {ключ}, и не являются тегами + placeholder_pattern = r'\{[^{}]+\}' + clean_text = re.sub(placeholder_pattern, '', text) + + # Находим все открывающие и закрывающие теги + tag_pattern = r'<(/?)([a-zA-Z]+)(\s[^>]*)?>' + tags_with_pos = [(m.group(1), m.group(2), m.group(3), m.start(), m.end()) for m in re.finditer(tag_pattern, clean_text)] + + for closing, tag, attrs, start_pos, end_pos in tags_with_pos: + tag_lower = tag.lower() + + # Проверяем, является ли тег поддерживаемым + if tag_lower not in allowed_tags: + return False, f"Неподдерживаемый HTML-тег: <{tag}>. Используйте только теги: {', '.join(sorted(allowed_tags))}" + + # Проверяем атрибуты для тега + if tag_lower == 'a': + if closing: + continue # Для закрывающего тега не нужно проверять атрибуты + if not attrs: + return False, "Тег должен содержать атрибут href, например: ссылка" + + # Проверяем, что есть атрибут href + if 'href=' not in attrs.lower(): + return False, "Тег должен содержать атрибут href, например: ссылка" + + # Проверяем формат URL + href_match = re.search(r'href\s*=\s*[\'"]([^\'"]+)[\'"]', attrs, re.IGNORECASE) + if href_match: + url = href_match.group(1) + # Проверяем, что URL начинается с поддерживаемой схемы + if not re.match(r'^https?://|^tg://', url, re.IGNORECASE): + return False, f"URL в теге должен начинаться с http://, https:// или tg://. Найдено: {url}" + else: + return False, "Не удалось извлечь URL из атрибута href тега " + + # Проверяем парность тегов с использованием стека + stack = [] + for closing, tag, attrs, start_pos, end_pos in tags_with_pos: + tag_lower = tag.lower() + + if tag_lower not in allowed_tags: + continue + + if closing: + # Это закрывающий тег + if not stack: + return False, f"Лишний закрывающий тег: " + + last_opening_tag = stack.pop() + if last_opening_tag.lower() != tag_lower: + return False, f"Тег не соответствует открывающему тегу <{last_opening_tag}>" + else: + # Это открывающий тег + stack.append(tag) + + # Если остались незакрытые теги + if stack: + unclosed_tags = ", ".join([f"<{tag}>" for tag in stack]) + return False, f"Незакрытые теги: {unclosed_tags}" + + return True, None + def get_telegram_formatting_info() -> str: return """ 📝 Поддерживаемые теги форматирования: @@ -202,6 +289,12 @@ async def process_welcome_text_edit( await message.answer("❌ Текст слишком длинный! Максимум 4000 символов.") return + # Проверяем HTML-теги на валидность + is_valid, error_msg = validate_html_tags(new_text) + if not is_valid: + await message.answer(f"❌ Ошибка в HTML-разметке:\n\n{error_msg}") + return + success = await set_welcome_text(db, new_text, db_user.id) if success: