Files
Egor fca8d6da97 Dev (#2880)
* 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 commit 5070bb34 removed 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>
2026-04-15 14:04:16 +03:00

187 lines
6.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)