fca8d6da97
* fix: update subscription_crypto_link when syncing user from panel (#2867) * fix: update subscription_crypto_link when syncing user from panel * fix: update subscription_crypto_link when syncing user from panel * fix: use PROXY_URL for Telegram OIDC JWKS requests (#2866) * feat: add TELEGRAM_API_URL for custom Telegram Bot API server Support custom Telegram Bot API server URL via TELEGRAM_API_URL env var. Enables bot operation in regions where api.telegram.org is blocked (Cloudflare Worker, self-hosted telegram-bot-api, nginx reverse proxy). Uses native aiogram TelegramAPIServer.from_base(), works with PROXY_URL. * fix(ci): read Docker image version from release-please manifest instead of hardcoding (#2859) The x-release-please-version markers in workflow files were stuck at v3.7.0 since commit5070bb34removed them from extra-files (GitHub Actions returns 403 when release-please tries to modify .github/workflows/ via GITHUB_TOKEN). Instead of hardcoding the version, read it from .release-please-manifest.json at build time. This file is always kept in sync by release-please and does not require workflow file write permissions. --- Маркеры x-release-please-version в workflow-файлах застряли на v3.7.0 после коммита5070bb34, который удалил их из extra-files (GitHub Actions возвращает 403 при попытке release-please изменить .github/workflows/ через GITHUB_TOKEN). Вместо хардкода версии теперь читаем её из .release-please-manifest.json во время сборки. Этот файл всегда синхронизируется release-please и не требует прав на запись в workflow-файлы. * fix: format telegram_auth.py to use single quotes (ruff) * fix: remove daily tariff fallback to smallest period discount Daily tariffs (period_days=1) incorrectly inherited the discount of the smallest configured period (e.g. 90 days -> 5%). This caused daily prices to show discounts that were never intended for them. Now daily tariffs only get a discount if explicitly configured for period_days=1 in the promo group's period_discounts. * fix: use CABINET_URL for campaign web links instead of MINIAPP_CUSTOM_URL Campaign web links were generated from MINIAPP_CUSTOM_URL which is often empty, causing get_campaign_web_link() to return None. Admins and partners could only share bot links for campaigns, not cabinet links. Now prefers CABINET_URL (where the auth flow captures ?campaign= param), falling back to MINIAPP_CUSTOM_URL for backwards compatibility. This is consistent with how referral web links already use CABINET_URL. * feat: add DISPLAY_NAME_RESTRICTION_ENABLED toggle Allows disabling the display name restriction middleware via .env. Users with special characters in their Telegram name (e.g. "@") were blocked from using the bot entirely. Default: true (enabled). Set DISPLAY_NAME_RESTRICTION_ENABLED=false to disable. * fix: allow clearing all period discounts from promo groups Empty period_discounts dict was normalized to None by the schema, making it indistinguishable from "field absent" (don't update). Now empty dict passes through to CRUD which correctly sets period_discounts=None in DB, clearing all discounts. * fix: create panel user instead of update for new subscriptions in multi-tariff mode In multi-tariff mode, new subscriptions have remnawave_uuid=None. The old logic fell back to user.remnawave_uuid (from a previous subscription) and called update_remnawave_user(), which refused to work because the NEW subscription had no UUID. Now correctly: in multi-tariff mode, always CREATE if subscription has no remnawave_uuid. In single-tariff mode, use user-level UUID. Fixes: "subscription has no remnawave_uuid, cannot update panel" * fix: apply same create-vs-update fix to renewal and purchase flows Same bug as the tariff purchase fix: in multi-tariff mode, new subscriptions without remnawave_uuid incorrectly fell back to user.remnawave_uuid and called update instead of create. Fixed in subscription_renewal_service.py and purchase.py to use the same _should_create pattern based on mode. * fix: apply create-vs-update fix to all remaining tariff_purchase flows Fixed 6 more locations in tariff_purchase.py that had the same broken pattern (custom purchase, daily purchase, trial conversion, tariff switch, daily switch, instant switch). All now use _should_create based on multi-tariff mode instead of falling back to user UUID. * fix: apply create-vs-update fix to cabinet traffic/devices and monitoring Same multi-tariff create-vs-update bug in 5 more locations: - cabinet/subscription_modules/traffic.py (2 instances) - cabinet/subscription_modules/devices.py (2 instances) - services/monitoring_service.py (1 instance) All now use _should_create pattern based on subscription.remnawave_uuid in multi-tariff mode instead of falling back to user.remnawave_uuid. * fix: ruff format traffic.py and monitoring_service.py --------- Co-authored-by: Dmitry V. Lunin <49199230+BlackRaincoat@users.noreply.github.com> Co-authored-by: Gary Jarrel <gary@jarrel.com.au>
187 lines
6.2 KiB
Python
187 lines
6.2 KiB
Python
import re
|
||
from collections.abc import Awaitable, Callable
|
||
from typing import Any
|
||
|
||
import structlog
|
||
from aiogram import BaseMiddleware
|
||
from aiogram.exceptions import TelegramAPIError
|
||
from aiogram.types import (
|
||
CallbackQuery,
|
||
Message,
|
||
PreCheckoutQuery,
|
||
TelegramObject,
|
||
User as TgUser,
|
||
)
|
||
|
||
from app.config import settings
|
||
from app.localization.texts import get_texts
|
||
|
||
|
||
logger = structlog.get_logger(__name__)
|
||
|
||
|
||
ZERO_WIDTH_PATTERN = re.compile(r'[\u200B-\u200D\uFEFF]')
|
||
|
||
LINK_PATTERNS = [
|
||
re.compile(pattern, re.IGNORECASE)
|
||
for pattern in (
|
||
r't\.me/\+',
|
||
r'joinchat',
|
||
r'https?://',
|
||
r'www\.',
|
||
r'tg://',
|
||
r'telegram\.me',
|
||
r't\.me',
|
||
)
|
||
]
|
||
|
||
DOMAIN_OBFUSCATION_PATTERN = re.compile(
|
||
r'(?<![0-9a-zа-яё])(?:t|т)[\s\W_]*?(?:m|м)(?:e|е)',
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
CHAR_TRANSLATION = str.maketrans(
|
||
{
|
||
'а': 'a',
|
||
'е': 'e',
|
||
'о': 'o',
|
||
'р': 'p',
|
||
'с': 'c',
|
||
'х': 'x',
|
||
'у': 'y',
|
||
'к': 'k',
|
||
'т': 't',
|
||
'г': 'g',
|
||
'м': 'm',
|
||
'н': 'n',
|
||
'л': 'l',
|
||
'і': 'i',
|
||
'ї': 'i',
|
||
'ё': 'e',
|
||
'ь': '',
|
||
'ъ': '',
|
||
'ў': 'u',
|
||
'@': '@',
|
||
}
|
||
)
|
||
|
||
COLLAPSE_PATTERN = re.compile(r"[\s\._\-/\\|,:;•·﹒․⋅··`~'\"!?()\[\]{}<>+=]+")
|
||
|
||
|
||
class DisplayNameRestrictionMiddleware(BaseMiddleware):
|
||
"""Blocks users whose display name imitates links or official accounts."""
|
||
|
||
async def __call__(
|
||
self,
|
||
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
|
||
event: TelegramObject,
|
||
data: dict[str, Any],
|
||
) -> Any:
|
||
user: TgUser | None = None
|
||
|
||
if isinstance(event, (Message, CallbackQuery, PreCheckoutQuery)):
|
||
user = event.from_user
|
||
|
||
if not user or user.is_bot:
|
||
return await handler(event, data)
|
||
|
||
if not settings.DISPLAY_NAME_RESTRICTION_ENABLED:
|
||
return await handler(event, data)
|
||
|
||
display_name = self._build_display_name(user)
|
||
username = user.username or ''
|
||
|
||
display_suspicious = self._is_suspicious(display_name)
|
||
username_suspicious = self._is_suspicious(username)
|
||
|
||
if display_suspicious or username_suspicious:
|
||
suspicious_value = display_name if display_suspicious else username
|
||
language = self._resolve_language(user, data)
|
||
texts = get_texts(language)
|
||
warning = texts.get(
|
||
'SUSPICIOUS_DISPLAY_NAME_BLOCKED',
|
||
'🚫 Ваше отображаемое имя похоже на ссылку или служебный аккаунт. '
|
||
'Пожалуйста, измените имя и попробуйте снова.',
|
||
)
|
||
|
||
logger.warning(
|
||
"🚫 DisplayNameRestriction: user blocked due to suspicious name ''",
|
||
user_id=user.id,
|
||
suspicious_value=suspicious_value,
|
||
)
|
||
|
||
try:
|
||
if isinstance(event, Message):
|
||
await event.answer(warning)
|
||
elif isinstance(event, CallbackQuery):
|
||
await event.answer(warning, show_alert=True)
|
||
elif isinstance(event, PreCheckoutQuery):
|
||
await event.answer(ok=False, error_message=warning)
|
||
except TelegramAPIError:
|
||
pass
|
||
return None
|
||
|
||
return await handler(event, data)
|
||
|
||
@staticmethod
|
||
def _build_display_name(user: TgUser) -> str:
|
||
parts = [user.first_name or '', user.last_name or '']
|
||
return ' '.join(part for part in parts if part).strip()
|
||
|
||
@staticmethod
|
||
def _resolve_language(user: TgUser, data: dict[str, Any]) -> str:
|
||
db_user = data.get('db_user')
|
||
if db_user and getattr(db_user, 'language', None):
|
||
return db_user.language
|
||
language_code = getattr(user, 'language_code', None)
|
||
return language_code or settings.DEFAULT_LANGUAGE
|
||
|
||
def _is_suspicious(self, value: str) -> bool:
|
||
if not value:
|
||
return False
|
||
|
||
cleaned = ZERO_WIDTH_PATTERN.sub('', value)
|
||
lower_value = cleaned.lower()
|
||
|
||
if '@' in cleaned or '@' in cleaned:
|
||
return True
|
||
|
||
if any(pattern.search(lower_value) for pattern in LINK_PATTERNS):
|
||
return True
|
||
|
||
# Проверяем обфусцированные ссылки типа "t . m e" или "т м е"
|
||
# Но НЕ блокируем если это часть обычного слова/имени
|
||
domain_match = DOMAIN_OBFUSCATION_PATTERN.search(lower_value)
|
||
if domain_match:
|
||
# Проверяем контекст: если "tme" внутри слова (с буквами с обеих сторон) - пропускаем
|
||
start_pos = domain_match.start()
|
||
end_pos = domain_match.end()
|
||
|
||
# Проверяем символ ДО и ПОСЛЕ совпадения
|
||
has_letter_before = start_pos > 0 and lower_value[start_pos - 1].isalpha()
|
||
has_letter_after = end_pos < len(lower_value) and lower_value[end_pos].isalpha()
|
||
|
||
# Если с ОБЕИХ сторон буквы - скорее всего это просто имя/фамилия
|
||
if not (has_letter_before and has_letter_after):
|
||
return True
|
||
|
||
normalized = self._normalize_text(lower_value)
|
||
collapsed = COLLAPSE_PATTERN.sub('', normalized)
|
||
|
||
# Проверяем "tme" с контекстом (ловим t.me ссылки, но не случайные совпадения в именах)
|
||
# Ищем tme в начале, конце, или с пробелами/спецсимволами вокруг
|
||
if re.search(r'(?:^|[^a-zа-яё])tme(?:[^a-zа-яё]|$)', collapsed, re.IGNORECASE):
|
||
return True
|
||
|
||
banned_keywords = settings.get_display_name_banned_keywords()
|
||
|
||
# Если список пустой - не блокируем никого
|
||
if not banned_keywords:
|
||
return False
|
||
|
||
return any(keyword in normalized or keyword in collapsed for keyword in banned_keywords)
|
||
|
||
@staticmethod
|
||
def _normalize_text(value: str) -> str:
|
||
return value.translate(CHAR_TRANSLATION)
|