From 1ffb8a5b85455396006e1fcddd48f4c9a2ca2700 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 00:01:55 +0300 Subject: [PATCH 01/18] fix: pass tariff object instead of tariff_id to set_tariff_promo_groups --- app/cabinet/routes/admin_tariffs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/cabinet/routes/admin_tariffs.py b/app/cabinet/routes/admin_tariffs.py index f2accb18..e6f9afa4 100644 --- a/app/cabinet/routes/admin_tariffs.py +++ b/app/cabinet/routes/admin_tariffs.py @@ -388,7 +388,7 @@ async def update_existing_tariff( # Update promo groups separately if request.promo_group_ids is not None: - await set_tariff_promo_groups(db, tariff_id, request.promo_group_ids) + await set_tariff_promo_groups(db, tariff, request.promo_group_ids) logger.info(f'Admin {admin.id} updated tariff {tariff_id}') From c4794db1dd78f7c48b5da896bdb2f000e493e079 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 00:19:25 +0300 Subject: [PATCH 02/18] feat: add TRIAL_DISABLED_FOR setting to disable trial by user type New setting allows granular control over trial availability: - none: trial available for all (default) - email: trial disabled for email users - telegram: trial disabled for telegram users - all: trial disabled for everyone Enforced in bot handlers, cabinet API, and miniapp routes. Automatically appears in admin panel as dropdown via CHOICES. --- app/cabinet/routes/subscription.py | 20 ++++++++++++++++++++ app/config.py | 11 +++++++++++ app/handlers/subscription/purchase.py | 18 ++++++++++++++++++ app/services/system_settings_service.py | 6 ++++++ app/webapi/routes/miniapp.py | 3 +++ 5 files changed, 58 insertions(+) diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py index e93fecc0..94a56335 100644 --- a/app/cabinet/routes/subscription.py +++ b/app/cabinet/routes/subscription.py @@ -1070,6 +1070,19 @@ async def get_trial_info( """Get trial subscription info and availability.""" await db.refresh(user, ['subscription']) + # Проверяем, отключён ли триал для этого типа пользователя + if settings.is_trial_disabled_for_user(getattr(user, 'auth_type', 'telegram')): + return TrialInfoResponse( + is_available=False, + duration_days=settings.TRIAL_DURATION_DAYS, + traffic_limit_gb=settings.TRIAL_TRAFFIC_LIMIT_GB, + device_limit=settings.TRIAL_DEVICE_LIMIT, + requires_payment=bool(settings.TRIAL_PAYMENT_ENABLED), + price_kopeks=0, + price_rubles=0, + reason_unavailable='Trial is not available for your account type', + ) + duration_days = settings.TRIAL_DURATION_DAYS traffic_limit_gb = settings.TRIAL_TRAFFIC_LIMIT_GB device_limit = settings.TRIAL_DEVICE_LIMIT @@ -1148,6 +1161,13 @@ async def activate_trial( """Activate trial subscription.""" await db.refresh(user, ['subscription']) + # Проверяем, отключён ли триал для этого типа пользователя + if settings.is_trial_disabled_for_user(getattr(user, 'auth_type', 'telegram')): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Trial is not available for your account type', + ) + # Check if user already has an active subscription if user.subscription: now = datetime.utcnow() diff --git a/app/config.py b/app/config.py index dc35b786..b023e2cd 100644 --- a/app/config.py +++ b/app/config.py @@ -112,6 +112,7 @@ class Settings(BaseSettings): TRIAL_PAYMENT_ENABLED: bool = False TRIAL_ACTIVATION_PRICE: int = 0 TRIAL_USER_TAG: str | None = None + TRIAL_DISABLED_FOR: str = 'none' # none, email, telegram, all DEFAULT_TRAFFIC_LIMIT_GB: int = 100 DEFAULT_DEVICE_LIMIT: int = 1 DEFAULT_TRAFFIC_RESET_STRATEGY: str = 'MONTH' @@ -1309,6 +1310,16 @@ class Settings(BaseSettings): def get_trial_user_tag(self) -> str | None: return self._normalize_user_tag(self.TRIAL_USER_TAG, 'TRIAL_USER_TAG') + def is_trial_disabled_for_user(self, auth_type: str | None) -> bool: + disabled_for = self.TRIAL_DISABLED_FOR + if disabled_for == 'all': + return True + if disabled_for == 'email' and auth_type == 'email': + return True + if disabled_for == 'telegram' and (auth_type is None or auth_type == 'telegram'): + return True + return False + def get_paid_subscription_user_tag(self) -> str | None: return self._normalize_user_tag( self.PAID_SUBSCRIPTION_USER_TAG, diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 8f5aa6cf..0a64389d 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -560,6 +560,15 @@ async def show_trial_offer(callback: types.CallbackQuery, db_user: User, db: Asy texts = get_texts(db_user.language) + # Проверяем, отключён ли триал для этого типа пользователя + if settings.is_trial_disabled_for_user(getattr(db_user, 'auth_type', 'telegram')): + await callback.message.edit_text( + texts.t('TRIAL_DISABLED_FOR_USER_TYPE', 'Пробный период недоступен'), + reply_markup=get_back_keyboard(db_user.language), + ) + await callback.answer() + return + # Проверяем, использовал ли пользователь триал # PENDING триальные подписки не считаются - пользователь может повторить оплату trial_blocked = False @@ -752,6 +761,15 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async await callback.answer() return + # Проверяем, отключён ли триал для этого типа пользователя + if settings.is_trial_disabled_for_user(getattr(db_user, 'auth_type', 'telegram')): + await callback.message.edit_text( + texts.t('TRIAL_DISABLED_FOR_USER_TYPE', 'Пробный период недоступен'), + reply_markup=get_back_keyboard(db_user.language), + ) + await callback.answer() + return + # Проверяем, использовал ли пользователь триал # PENDING триальные подписки не считаются - пользователь может повторить оплату trial_blocked = False diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index 1b0c744a..28c4d307 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -465,6 +465,12 @@ class BotConfigurationService: ChoiceOption('ERROR', '❌ Error'), ChoiceOption('CRITICAL', '🔥 Critical'), ], + 'TRIAL_DISABLED_FOR': [ + ChoiceOption('none', '✅ Включён для всех'), + ChoiceOption('email', '📧 Отключён для Email'), + ChoiceOption('telegram', '📱 Отключён для Telegram'), + ChoiceOption('all', '🚫 Отключён для всех'), + ], } SETTING_HINTS: dict[str, dict[str, str]] = { diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 17f3977f..7e44a304 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -3047,6 +3047,9 @@ def _is_trial_available_for_user(user: User) -> bool: if settings.TRIAL_DURATION_DAYS <= 0: return False + if settings.is_trial_disabled_for_user(getattr(user, 'auth_type', 'telegram')): + return False + if getattr(user, 'has_had_paid_subscription', False): return False From 116c8453bb371b5eacf5c9d07f497eb449a355cc Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 00:34:11 +0300 Subject: [PATCH 03/18] feat: block registration with disposable email addresses Add DisposableEmailService that fetches ~72k disposable email domains from github.com/disposable/disposable-email-domains into an in-memory frozenset with 24h auto-refresh via asyncio background task. Integrated into three email entry points in cabinet auth routes: - POST /email/register (link email to Telegram account) - POST /email/register/standalone (standalone email registration) - POST /email/change (change existing email) Controlled by DISPOSABLE_EMAIL_CHECK_ENABLED setting (default: true). Falls back to allowing all emails if domain list fetch fails. --- app/cabinet/routes/auth.py | 22 +++++ app/config.py | 2 + app/services/disposable_email_service.py | 109 +++++++++++++++++++++++ app/webserver/unified_app.py | 9 ++ 4 files changed, 142 insertions(+) create mode 100644 app/services/disposable_email_service.py diff --git a/app/cabinet/routes/auth.py b/app/cabinet/routes/auth.py index 352709eb..312cc636 100644 --- a/app/cabinet/routes/auth.py +++ b/app/cabinet/routes/auth.py @@ -22,6 +22,7 @@ from app.database.crud.user import ( verify_and_apply_email_change, ) from app.database.models import CabinetRefreshToken, User +from app.services.disposable_email_service import disposable_email_service from app.services.referral_service import process_referral_registration from app.utils.timezone import panel_datetime_to_naive_utc @@ -385,6 +386,13 @@ async def register_email( Requires valid JWT token from Telegram authentication. Sends verification email to the provided address. """ + # Check for disposable email + if disposable_email_service.is_disposable(request.email): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Disposable email addresses are not allowed', + ) + # Check if email already exists existing_user = await db.execute(select(User).where(User.email == request.email)) if existing_user.scalar_one_or_none(): @@ -478,6 +486,13 @@ async def register_email_standalone( ) logger.info(f'Test email registration: {request.email}') + # Check for disposable email + if disposable_email_service.is_disposable(request.email): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Disposable email addresses are not allowed', + ) + # Проверить что email не занят existing = await db.execute(select(User).where(User.email == request.email)) if existing.scalar_one_or_none(): @@ -971,6 +986,13 @@ async def request_email_change( detail='New email is the same as current email', ) + # Check for disposable email + if disposable_email_service.is_disposable(request.new_email): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Disposable email addresses are not allowed', + ) + # Check if new email is already taken if await is_email_taken(db, request.new_email, exclude_user_id=user.id): raise HTTPException( diff --git a/app/config.py b/app/config.py index b023e2cd..0cb2a14f 100644 --- a/app/config.py +++ b/app/config.py @@ -237,6 +237,8 @@ class Settings(BaseSettings): BLACKLIST_UPDATE_INTERVAL_HOURS: int = 24 BLACKLIST_IGNORE_ADMINS: bool = True + DISPOSABLE_EMAIL_CHECK_ENABLED: bool = True + # Настройки простой покупки SIMPLE_SUBSCRIPTION_ENABLED: bool = False SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30 diff --git a/app/services/disposable_email_service.py b/app/services/disposable_email_service.py new file mode 100644 index 00000000..3bb9a370 --- /dev/null +++ b/app/services/disposable_email_service.py @@ -0,0 +1,109 @@ +"""Service for blocking disposable/temporary email domains.""" + +import asyncio +import logging +from datetime import UTC, datetime + +import aiohttp + +from app.config import settings + + +logger = logging.getLogger(__name__) + + +class DisposableEmailService: + """ + Downloads and caches a list of disposable email domains from GitHub. + + Domains are stored in a frozenset for O(1) thread-safe lookups. + The list is refreshed every 24 hours via an asyncio background task. + If the download fails, the service falls back to an empty set (no blocking). + """ + + DOMAINS_URL = 'https://raw.githubusercontent.com/disposable/disposable-email-domains/master/domains.txt' + UPDATE_INTERVAL_HOURS = 24 + + def __init__(self) -> None: + self._domains: frozenset[str] = frozenset() + self._task: asyncio.Task[None] | None = None + self._last_updated: datetime | None = None + self._domain_count: int = 0 + + async def start(self) -> None: + """Load domains and start periodic refresh task.""" + await self._update_domains() + self._task = asyncio.create_task(self._periodic_loop()) + logger.info('DisposableEmailService started (%d domains loaded)', self._domain_count) + + async def stop(self) -> None: + """Cancel periodic refresh task.""" + if self._task and not self._task.done(): + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + logger.info('DisposableEmailService stopped') + + async def _update_domains(self) -> None: + """Fetch domains.txt from GitHub and swap the in-memory set.""" + try: + async with aiohttp.ClientSession() as session, session.get(self.DOMAINS_URL) as resp: + if resp.status != 200: + logger.error( + 'Failed to fetch disposable domains: HTTP %d', + resp.status, + ) + return + + text = await resp.text() + + domains = frozenset( + line.strip().lower() for line in text.splitlines() if line.strip() and not line.startswith('#') + ) + + self._domains = domains + self._domain_count = len(domains) + self._last_updated = datetime.now(UTC) + logger.info('Disposable email domains updated: %d domains', self._domain_count) + + except Exception: + logger.exception('Error updating disposable email domains') + + async def _periodic_loop(self) -> None: + """Sleep then refresh, repeating forever until cancelled.""" + while True: + await asyncio.sleep(self.UPDATE_INTERVAL_HOURS * 3600) + await self._update_domains() + + def is_disposable(self, email: str) -> bool: + """Check if the email uses a disposable domain. + + Returns False when the feature is disabled via settings. + """ + if not getattr(settings, 'DISPOSABLE_EMAIL_CHECK_ENABLED', True): + return False + + if not self._domains: + return False + + try: + domain = email.rsplit('@', 1)[1].lower() + except IndexError: + return False + + return domain in self._domains + + def get_status(self) -> dict: + """Return service status for monitoring / health checks.""" + return { + 'enabled': getattr(settings, 'DISPOSABLE_EMAIL_CHECK_ENABLED', True), + 'domain_count': self._domain_count, + 'last_updated': self._last_updated.isoformat() if self._last_updated else None, + 'running': self._task is not None and not self._task.done(), + } + + +disposable_email_service = DisposableEmailService() diff --git a/app/webserver/unified_app.py b/app/webserver/unified_app.py index a9d761d5..0644f546 100644 --- a/app/webserver/unified_app.py +++ b/app/webserver/unified_app.py @@ -10,6 +10,7 @@ from fastapi.staticfiles import StaticFiles from app.cabinet.routes import router as cabinet_router from app.config import settings +from app.services.disposable_email_service import disposable_email_service from app.services.payment_service import PaymentService from app.webapi.app import create_web_api_app from app.webapi.docs import add_redoc_endpoint @@ -144,6 +145,14 @@ def create_unified_app( else: telegram_processor = None + @app.on_event('startup') + async def start_disposable_email_service() -> None: # pragma: no cover - event hook + await disposable_email_service.start() + + @app.on_event('shutdown') + async def stop_disposable_email_service() -> None: # pragma: no cover - event hook + await disposable_email_service.stop() + miniapp_mounted, miniapp_path = _mount_miniapp_static(app) unified_health_path = '/health/unified' if settings.is_web_api_enabled() else '/health' From 97be4afbffd809fe2786a6d248fc4d3f770cb8cf Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 01:58:55 +0300 Subject: [PATCH 04/18] feat: add OAuth 2.0 authorization (Google, Yandex, Discord, VK) - Add OAuth provider config vars and helpers to config.py - Add google_id, yandex_id, discord_id, vk_id columns to User model - Create OAuth provider service with state management and 4 providers - Add CRUD functions for OAuth user lookup, linking, and creation - Add 3 API endpoints: providers list, authorize URL, callback - Add alembic migration and universal_migration support - Fix trial disable logic to cover OAuth auth_types --- app/cabinet/auth/oauth_providers.py | 345 ++++++++++++++++++ app/cabinet/routes/__init__.py | 2 + app/cabinet/routes/oauth.py | 240 ++++++++++++ app/config.py | 54 ++- app/database/crud/user.py | 98 +++++ app/database/models.py | 10 + app/database/universal_migration.py | 73 ++++ .../g5b6c7d8e9f0_add_oauth_provider_ids.py | 45 +++ 8 files changed, 866 insertions(+), 1 deletion(-) create mode 100644 app/cabinet/auth/oauth_providers.py create mode 100644 app/cabinet/routes/oauth.py create mode 100644 migrations/alembic/versions/g5b6c7d8e9f0_add_oauth_provider_ids.py diff --git a/app/cabinet/auth/oauth_providers.py b/app/cabinet/auth/oauth_providers.py new file mode 100644 index 00000000..4bdc096d --- /dev/null +++ b/app/cabinet/auth/oauth_providers.py @@ -0,0 +1,345 @@ +"""OAuth 2.0 provider implementations for cabinet authentication.""" + +import logging +import secrets +import time +from abc import ABC, abstractmethod +from dataclasses import dataclass +from urllib.parse import urlencode + +import httpx + +from app.config import settings + + +logger = logging.getLogger(__name__) + +# In-memory CSRF state store with TTL +_oauth_states: dict[str, tuple[str, float]] = {} +STATE_TTL_SECONDS = 600 # 10 minutes + + +@dataclass +class OAuthUserInfo: + """Normalized user info from OAuth provider.""" + + provider: str + provider_id: str + email: str | None = None + email_verified: bool = False + first_name: str | None = None + last_name: str | None = None + username: str | None = None + avatar_url: str | None = None + + +def generate_oauth_state(provider: str) -> str: + """Generate a CSRF state token for OAuth flow.""" + state = secrets.token_urlsafe(32) + _oauth_states[state] = (provider, time.time()) + _cleanup_expired_states() + return state + + +def validate_oauth_state(state: str, provider: str) -> bool: + """Validate and consume a CSRF state token.""" + entry = _oauth_states.pop(state, None) + if entry is None: + return False + stored_provider, created_at = entry + if stored_provider != provider: + return False + if time.time() - created_at > STATE_TTL_SECONDS: + return False + return True + + +def _cleanup_expired_states() -> None: + """Remove expired state tokens.""" + now = time.time() + expired = [k for k, (_, ts) in _oauth_states.items() if now - ts > STATE_TTL_SECONDS] + for k in expired: + _oauth_states.pop(k, None) + + +class OAuthProvider(ABC): + """Base class for OAuth 2.0 providers.""" + + name: str + display_name: str + + def __init__(self, client_id: str, client_secret: str, redirect_uri: str): + self.client_id = client_id + self.client_secret = client_secret + self.redirect_uri = redirect_uri + + @abstractmethod + def get_authorization_url(self, state: str) -> str: + """Build the authorization URL for the provider.""" + + @abstractmethod + async def exchange_code(self, code: str) -> dict: + """Exchange authorization code for tokens.""" + + @abstractmethod + async def get_user_info(self, token_data: dict) -> OAuthUserInfo: + """Fetch user info from the provider.""" + + +class GoogleProvider(OAuthProvider): + name = 'google' + display_name = 'Google' + + def get_authorization_url(self, state: str) -> str: + params = { + 'client_id': self.client_id, + 'redirect_uri': self.redirect_uri, + 'response_type': 'code', + 'scope': 'openid email profile', + 'state': state, + 'access_type': 'offline', + 'prompt': 'select_account', + } + return f'https://accounts.google.com/o/oauth2/v2/auth?{urlencode(params)}' + + async def exchange_code(self, code: str) -> dict: + async with httpx.AsyncClient(timeout=15) as client: + response = await client.post( + 'https://oauth2.googleapis.com/token', + json={ + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'code': code, + 'grant_type': 'authorization_code', + 'redirect_uri': self.redirect_uri, + }, + ) + response.raise_for_status() + return response.json() + + async def get_user_info(self, token_data: dict) -> OAuthUserInfo: + access_token = token_data['access_token'] + async with httpx.AsyncClient(timeout=15) as client: + response = await client.get( + 'https://www.googleapis.com/oauth2/v3/userinfo', + headers={'Authorization': f'Bearer {access_token}'}, + ) + response.raise_for_status() + data = response.json() + + return OAuthUserInfo( + provider='google', + provider_id=str(data['sub']), + email=data.get('email'), + email_verified=data.get('email_verified', False), + first_name=data.get('given_name'), + last_name=data.get('family_name'), + avatar_url=data.get('picture'), + ) + + +class YandexProvider(OAuthProvider): + name = 'yandex' + display_name = 'Yandex' + + def get_authorization_url(self, state: str) -> str: + params = { + 'client_id': self.client_id, + 'redirect_uri': self.redirect_uri, + 'response_type': 'code', + 'scope': 'login:info login:email', + 'state': state, + 'force_confirm': 'yes', + } + return f'https://oauth.yandex.com/authorize?{urlencode(params)}' + + async def exchange_code(self, code: str) -> dict: + async with httpx.AsyncClient(timeout=15) as client: + response = await client.post( + 'https://oauth.yandex.com/token', + data={ + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'code': code, + 'grant_type': 'authorization_code', + }, + headers={'Content-Type': 'application/x-www-form-urlencoded'}, + ) + response.raise_for_status() + return response.json() + + async def get_user_info(self, token_data: dict) -> OAuthUserInfo: + access_token = token_data['access_token'] + async with httpx.AsyncClient(timeout=15) as client: + response = await client.get( + 'https://login.yandex.ru/info?format=json', + headers={'Authorization': f'OAuth {access_token}'}, + ) + response.raise_for_status() + data = response.json() + + default_email = data.get('default_email') + emails = data.get('emails', []) + email = default_email or (emails[0] if emails else None) + + return OAuthUserInfo( + provider='yandex', + provider_id=str(data['id']), + email=email, + email_verified=bool(email), + first_name=data.get('first_name'), + last_name=data.get('last_name'), + username=data.get('login'), + avatar_url=( + f'https://avatars.yandex.net/get-yapic/{data["default_avatar_id"]}/islands-200' + if data.get('default_avatar_id') + else None + ), + ) + + +class DiscordProvider(OAuthProvider): + name = 'discord' + display_name = 'Discord' + + def get_authorization_url(self, state: str) -> str: + params = { + 'client_id': self.client_id, + 'redirect_uri': self.redirect_uri, + 'response_type': 'code', + 'scope': 'identify email', + 'state': state, + 'prompt': 'consent', + } + return f'https://discord.com/api/oauth2/authorize?{urlencode(params)}' + + async def exchange_code(self, code: str) -> dict: + async with httpx.AsyncClient(timeout=15) as client: + response = await client.post( + 'https://discord.com/api/oauth2/token', + data={ + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'code': code, + 'grant_type': 'authorization_code', + 'redirect_uri': self.redirect_uri, + }, + headers={'Content-Type': 'application/x-www-form-urlencoded'}, + ) + response.raise_for_status() + return response.json() + + async def get_user_info(self, token_data: dict) -> OAuthUserInfo: + access_token = token_data['access_token'] + async with httpx.AsyncClient(timeout=15) as client: + response = await client.get( + 'https://discord.com/api/v10/users/@me', + headers={'Authorization': f'Bearer {access_token}'}, + ) + response.raise_for_status() + data = response.json() + + avatar_url = None + if data.get('avatar'): + avatar_url = f'https://cdn.discordapp.com/avatars/{data["id"]}/{data["avatar"]}.png' + + return OAuthUserInfo( + provider='discord', + provider_id=str(data['id']), + email=data.get('email'), + email_verified=data.get('verified', False), + first_name=data.get('global_name') or data.get('username'), + username=data.get('username'), + avatar_url=avatar_url, + ) + + +class VKProvider(OAuthProvider): + name = 'vk' + display_name = 'VK' + + def get_authorization_url(self, state: str) -> str: + params = { + 'client_id': self.client_id, + 'redirect_uri': self.redirect_uri, + 'response_type': 'code', + 'scope': 'email', + 'state': state, + 'v': '5.131', + } + return f'https://oauth.vk.com/authorize?{urlencode(params)}' + + async def exchange_code(self, code: str) -> dict: + async with httpx.AsyncClient(timeout=15) as client: + response = await client.get( + 'https://oauth.vk.com/access_token', + params={ + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'code': code, + 'redirect_uri': self.redirect_uri, + }, + ) + response.raise_for_status() + return response.json() + + async def get_user_info(self, token_data: dict) -> OAuthUserInfo: + access_token = token_data['access_token'] + user_id = token_data.get('user_id') + # VK returns email in token response, not in userinfo + email = token_data.get('email') + + async with httpx.AsyncClient(timeout=15) as client: + response = await client.get( + 'https://api.vk.com/method/users.get', + params={ + 'access_token': access_token, + 'fields': 'photo_200', + 'v': '5.131', + }, + ) + response.raise_for_status() + data = response.json() + + user_data = data.get('response', [{}])[0] + + return OAuthUserInfo( + provider='vk', + provider_id=str(user_id or user_data.get('id', '')), + email=email, + email_verified=bool(email), + first_name=user_data.get('first_name'), + last_name=user_data.get('last_name'), + avatar_url=user_data.get('photo_200'), + ) + + +_PROVIDERS: dict[str, type[OAuthProvider]] = { + 'google': GoogleProvider, + 'yandex': YandexProvider, + 'discord': DiscordProvider, + 'vk': VKProvider, +} + + +def get_provider(name: str) -> OAuthProvider | None: + """Get an OAuth provider instance if enabled. + + Returns None if the provider is not enabled or not found. + """ + providers_config = settings.get_oauth_providers_config() + config = providers_config.get(name) + if not config or not config['enabled']: + return None + + provider_class = _PROVIDERS.get(name) + if not provider_class: + return None + + redirect_uri = f'{settings.CABINET_URL}/auth/oauth/callback' + + return provider_class( + client_id=config['client_id'], + client_secret=config['client_secret'], + redirect_uri=redirect_uri, + ) diff --git a/app/cabinet/routes/__init__.py b/app/cabinet/routes/__init__.py index 2b9e654d..9f3366de 100644 --- a/app/cabinet/routes/__init__.py +++ b/app/cabinet/routes/__init__.py @@ -26,6 +26,7 @@ from .contests import router as contests_router from .info import router as info_router from .media import router as media_router from .notifications import router as notifications_router +from .oauth import router as oauth_router from .polls import router as polls_router from .promo import router as promo_router from .promocode import router as promocode_router @@ -45,6 +46,7 @@ router = APIRouter(prefix='/cabinet', tags=['Cabinet']) # Include all sub-routers router.include_router(auth_router) +router.include_router(oauth_router) router.include_router(subscription_router) router.include_router(balance_router) router.include_router(referral_router) diff --git a/app/cabinet/routes/oauth.py b/app/cabinet/routes/oauth.py new file mode 100644 index 00000000..2b4f38ef --- /dev/null +++ b/app/cabinet/routes/oauth.py @@ -0,0 +1,240 @@ +"""OAuth 2.0 authentication routes for cabinet.""" + +import logging +from datetime import UTC, datetime + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.user import ( + create_user_by_oauth, + get_user_by_email, + get_user_by_oauth_provider, + set_user_oauth_provider_id, +) +from app.database.models import User + +from ..auth import create_access_token, create_refresh_token +from ..auth.jwt_handler import get_refresh_token_expires_at +from ..auth.oauth_providers import ( + OAuthUserInfo, + generate_oauth_state, + get_provider, + validate_oauth_state, +) +from ..dependencies import get_cabinet_db +from ..schemas.auth import AuthResponse, UserResponse + + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix='/auth/oauth', tags=['Cabinet OAuth']) + + +# --- Schemas --- + + +class OAuthProviderInfo(BaseModel): + name: str + display_name: str + + +class OAuthProvidersResponse(BaseModel): + providers: list[OAuthProviderInfo] + + +class OAuthAuthorizeResponse(BaseModel): + authorize_url: str + state: str + + +class OAuthCallbackRequest(BaseModel): + code: str = Field(..., description='Authorization code from provider') + state: str = Field(..., description='CSRF state token') + + +# --- Helpers --- + + +def _user_to_response(user: User) -> UserResponse: + """Convert User model to UserResponse.""" + return UserResponse( + id=user.id, + telegram_id=user.telegram_id, + username=user.username, + first_name=user.first_name, + last_name=user.last_name, + email=user.email, + email_verified=user.email_verified, + balance_kopeks=user.balance_kopeks, + balance_rubles=user.balance_rubles, + referral_code=user.referral_code, + language=user.language, + created_at=user.created_at, + auth_type=getattr(user, 'auth_type', 'telegram'), + ) + + +def _create_auth_response(user: User) -> AuthResponse: + """Create full auth response with tokens.""" + access_token = create_access_token(user.id, user.telegram_id) + refresh_token = create_refresh_token(user.id) + expires_in = settings.get_cabinet_access_token_expire_minutes() * 60 + + return AuthResponse( + access_token=access_token, + refresh_token=refresh_token, + token_type='bearer', + expires_in=expires_in, + user=_user_to_response(user), + ) + + +async def _store_refresh_token( + db: AsyncSession, + user_id: int, + refresh_token: str, + device_info: str | None = None, +) -> None: + """Store refresh token hash in database.""" + import hashlib + + from app.database.models import CabinetRefreshToken + + token_hash = hashlib.sha256(refresh_token.encode()).hexdigest() + expires_at = get_refresh_token_expires_at() + + from sqlalchemy import select + + existing = await db.execute(select(CabinetRefreshToken).where(CabinetRefreshToken.token_hash == token_hash)) + if existing.scalar_one_or_none(): + return + + token_record = CabinetRefreshToken( + user_id=user_id, + token_hash=token_hash, + device_info=device_info, + expires_at=expires_at, + ) + db.add(token_record) + try: + await db.commit() + except Exception: + await db.rollback() + + +# --- Endpoints --- + + +@router.get('/providers', response_model=OAuthProvidersResponse) +async def get_oauth_providers(): + """Get list of enabled OAuth providers.""" + providers_config = settings.get_oauth_providers_config() + providers = [ + OAuthProviderInfo(name=name, display_name=cfg['display_name']) + for name, cfg in providers_config.items() + if cfg['enabled'] + ] + return OAuthProvidersResponse(providers=providers) + + +@router.get('/{provider}/authorize', response_model=OAuthAuthorizeResponse) +async def get_oauth_authorize_url(provider: str): + """Get authorization URL for an OAuth provider.""" + oauth_provider = get_provider(provider) + if not oauth_provider: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f'OAuth provider "{provider}" is not enabled', + ) + + state = generate_oauth_state(provider) + authorize_url = oauth_provider.get_authorization_url(state) + + return OAuthAuthorizeResponse(authorize_url=authorize_url, state=state) + + +@router.post('/{provider}/callback', response_model=AuthResponse) +async def oauth_callback( + provider: str, + request: OAuthCallbackRequest, + db: AsyncSession = Depends(get_cabinet_db), +): + """Handle OAuth callback: exchange code, find/create user, return JWT.""" + # 1. Validate CSRF state + if not validate_oauth_state(request.state, provider): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Invalid or expired OAuth state', + ) + + # 2. Get provider instance + oauth_provider = get_provider(provider) + if not oauth_provider: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f'OAuth provider "{provider}" is not enabled', + ) + + # 3. Exchange code for tokens + try: + token_data = await oauth_provider.exchange_code(request.code) + except Exception as exc: + logger.error('OAuth code exchange failed for %s: %s', provider, exc) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Failed to exchange authorization code', + ) from exc + + # 4. Fetch user info from provider + try: + user_info: OAuthUserInfo = await oauth_provider.get_user_info(token_data) + except Exception as exc: + logger.error('OAuth user info fetch failed for %s: %s', provider, exc) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Failed to fetch user information from provider', + ) from exc + + # 5. Find user by provider ID + user = await get_user_by_oauth_provider(db, provider, user_info.provider_id) + if user: + user.cabinet_last_login = datetime.now(UTC).replace(tzinfo=None) + await db.commit() + auth_response = _create_auth_response(user) + await _store_refresh_token(db, user.id, auth_response.refresh_token, device_info=f'oauth:{provider}') + logger.info('OAuth login via %s for existing user %s', provider, user.id) + return auth_response + + # 6. Find user by email (if verified) and link provider + if user_info.email and user_info.email_verified: + user = await get_user_by_email(db, user_info.email) + if user: + await set_user_oauth_provider_id(db, user, provider, user_info.provider_id) + user.cabinet_last_login = datetime.now(UTC).replace(tzinfo=None) + await db.commit() + auth_response = _create_auth_response(user) + await _store_refresh_token(db, user.id, auth_response.refresh_token, device_info=f'oauth:{provider}') + logger.info('OAuth login via %s linked to existing email user %s', provider, user.id) + return auth_response + + # 7. Create new user + user = await create_user_by_oauth( + db=db, + provider=provider, + provider_id=user_info.provider_id, + email=user_info.email if user_info.email_verified else None, + email_verified=user_info.email_verified, + first_name=user_info.first_name, + last_name=user_info.last_name, + username=user_info.username, + ) + user.cabinet_last_login = datetime.now(UTC).replace(tzinfo=None) + await db.commit() + + auth_response = _create_auth_response(user) + await _store_refresh_token(db, user.id, auth_response.refresh_token, device_info=f'oauth:{provider}') + logger.info('OAuth new user created via %s with id=%s', provider, user.id) + return auth_response diff --git a/app/config.py b/app/config.py index 0cb2a14f..e6382d12 100644 --- a/app/config.py +++ b/app/config.py @@ -698,6 +698,23 @@ class Settings(BaseSettings): CABINET_EMAIL_AUTH_ENABLED: bool = True # Enable email registration/login in cabinet CABINET_URL: str = 'https://example.com/cabinet' # Base URL for cabinet (used in verification emails) + # OAuth 2.0 provider settings for cabinet + OAUTH_GOOGLE_CLIENT_ID: str = '' + OAUTH_GOOGLE_CLIENT_SECRET: str = '' + OAUTH_GOOGLE_ENABLED: bool = False + + OAUTH_YANDEX_CLIENT_ID: str = '' + OAUTH_YANDEX_CLIENT_SECRET: str = '' + OAUTH_YANDEX_ENABLED: bool = False + + OAUTH_DISCORD_CLIENT_ID: str = '' + OAUTH_DISCORD_CLIENT_SECRET: str = '' + OAUTH_DISCORD_ENABLED: bool = False + + OAUTH_VK_CLIENT_ID: str = '' + OAUTH_VK_CLIENT_SECRET: str = '' + OAUTH_VK_ENABLED: bool = False + # SMTP settings for cabinet email SMTP_HOST: str | None = None SMTP_PORT: int = 587 @@ -1316,7 +1333,8 @@ class Settings(BaseSettings): disabled_for = self.TRIAL_DISABLED_FOR if disabled_for == 'all': return True - if disabled_for == 'email' and auth_type == 'email': + # 'email' means all non-Telegram users (email, google, yandex, discord, vk, etc.) + if disabled_for == 'email' and auth_type not in (None, 'telegram'): return True if disabled_for == 'telegram' and (auth_type is None or auth_type == 'telegram'): return True @@ -2528,6 +2546,40 @@ class Settings(BaseSettings): return self.SMTP_FROM_EMAIL return self.SMTP_USER + # OAuth helpers + def get_oauth_providers_config(self) -> dict[str, dict]: + """Return config for all OAuth providers (enabled or not).""" + return { + 'google': { + 'client_id': self.OAUTH_GOOGLE_CLIENT_ID, + 'client_secret': self.OAUTH_GOOGLE_CLIENT_SECRET, + 'enabled': self.OAUTH_GOOGLE_ENABLED, + 'display_name': 'Google', + }, + 'yandex': { + 'client_id': self.OAUTH_YANDEX_CLIENT_ID, + 'client_secret': self.OAUTH_YANDEX_CLIENT_SECRET, + 'enabled': self.OAUTH_YANDEX_ENABLED, + 'display_name': 'Yandex', + }, + 'discord': { + 'client_id': self.OAUTH_DISCORD_CLIENT_ID, + 'client_secret': self.OAUTH_DISCORD_CLIENT_SECRET, + 'enabled': self.OAUTH_DISCORD_ENABLED, + 'display_name': 'Discord', + }, + 'vk': { + 'client_id': self.OAUTH_VK_CLIENT_ID, + 'client_secret': self.OAUTH_VK_CLIENT_SECRET, + 'enabled': self.OAUTH_VK_ENABLED, + 'display_name': 'VK', + }, + } + + def get_enabled_oauth_provider_names(self) -> list[str]: + """Return list of enabled OAuth provider names.""" + return [name for name, cfg in self.get_oauth_providers_config().items() if cfg['enabled']] + # Ban System helpers def is_ban_system_enabled(self) -> bool: return bool(self.BAN_SYSTEM_ENABLED) diff --git a/app/database/crud/user.py b/app/database/crud/user.py index 63c82cf7..95d2e95d 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -1235,3 +1235,101 @@ async def clear_email_change_pending(db: AsyncSession, user: User) -> None: await db.commit() logger.info(f'Email change cancelled for user {user.id}') + + +# --- OAuth provider functions --- + +_OAUTH_PROVIDER_COLUMNS = { + 'google': 'google_id', + 'yandex': 'yandex_id', + 'discord': 'discord_id', + 'vk': 'vk_id', +} + + +async def get_user_by_oauth_provider(db: AsyncSession, provider: str, provider_id: str) -> User | None: + """Find a user by OAuth provider ID.""" + column_name = _OAUTH_PROVIDER_COLUMNS.get(provider) + if not column_name: + return None + column = getattr(User, column_name) + # VK uses BigInteger, so convert + value: str | int = int(provider_id) if provider == 'vk' else provider_id + result = await db.execute(select(User).where(column == value)) + return result.scalar_one_or_none() + + +async def set_user_oauth_provider_id(db: AsyncSession, user: User, provider: str, provider_id: str) -> None: + """Link an OAuth provider ID to an existing user.""" + column_name = _OAUTH_PROVIDER_COLUMNS.get(provider) + if not column_name: + return + value: str | int = int(provider_id) if provider == 'vk' else provider_id + setattr(user, column_name, value) + user.updated_at = datetime.utcnow() + await db.commit() + logger.info(f'Linked {provider} (id={provider_id}) to user {user.id}') + + +async def create_user_by_oauth( + db: AsyncSession, + provider: str, + provider_id: str, + email: str | None = None, + email_verified: bool = False, + first_name: str | None = None, + last_name: str | None = None, + username: str | None = None, + language: str = 'ru', +) -> User: + """Create a new user via OAuth provider.""" + referral_code = await create_unique_referral_code(db) + default_group = await _get_or_create_default_promo_group(db) + + column_name = _OAUTH_PROVIDER_COLUMNS.get(provider) + provider_value: str | int = int(provider_id) if provider == 'vk' else provider_id + + user = User( + telegram_id=None, + auth_type=provider, + email=email, + email_verified=email_verified, + password_hash=None, + username=sanitize_telegram_name(username) if username else None, + first_name=sanitize_telegram_name(first_name) if first_name else None, + last_name=sanitize_telegram_name(last_name) if last_name else None, + language=language, + referral_code=referral_code, + balance_kopeks=0, + has_had_paid_subscription=False, + has_made_first_topup=False, + promo_group_id=default_group.id, + ) + if column_name: + setattr(user, column_name, provider_value) + + db.add(user) + await db.commit() + await db.refresh(user) + + user.promo_group = default_group + logger.info(f'Created OAuth user via {provider} (provider_id={provider_id}) with id={user.id}') + + try: + from app.services.event_emitter import event_emitter + + await event_emitter.emit( + 'user.created', + { + 'user_id': user.id, + 'email': user.email, + 'auth_type': provider, + 'first_name': user.first_name, + 'referral_code': user.referral_code, + }, + db=db, + ) + except Exception as error: + logger.warning('Failed to emit user.created event: %s', error) + + return user diff --git a/app/database/models.py b/app/database/models.py index 3f32f98f..1f225162 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -995,6 +995,11 @@ class User(Base): email_change_new = Column(String(255), nullable=True) # New email pending verification email_change_code = Column(String(6), nullable=True) # 6-digit verification code email_change_expires = Column(DateTime, nullable=True) # Code expiration + # OAuth provider IDs + google_id = Column(String(255), unique=True, nullable=True, index=True) + yandex_id = Column(String(255), unique=True, nullable=True, index=True) + discord_id = Column(String(255), unique=True, nullable=True, index=True) + vk_id = Column(BigInteger, unique=True, nullable=True, index=True) broadcasts = relationship('BroadcastHistory', back_populates='admin') referrals = relationship('User', backref='referrer', remote_side=[id], foreign_keys='User.referred_by_id') subscription = relationship('Subscription', back_populates='user', uselist=False) @@ -1055,6 +1060,11 @@ class User(Base): """Пользователь зарегистрирован через email (без Telegram).""" return self.auth_type == 'email' and self.telegram_id is None + @property + def is_web_user(self) -> bool: + """Пользователь без Telegram (email, OAuth и т.д.).""" + return self.telegram_id is None + def get_primary_promo_group(self): """Возвращает промогруппу с максимальным приоритетом.""" if not self.user_promo_groups: diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index c97dddff..946c11aa 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -5094,6 +5094,58 @@ async def add_transaction_receipt_columns() -> bool: return False +async def add_oauth_provider_columns() -> bool: + """Добавить колонки OAuth провайдеров (google_id, yandex_id, discord_id, vk_id) в users.""" + try: + google_exists = await check_column_exists('users', 'google_id') + yandex_exists = await check_column_exists('users', 'yandex_id') + discord_exists = await check_column_exists('users', 'discord_id') + vk_exists = await check_column_exists('users', 'vk_id') + + if google_exists and yandex_exists and discord_exists and vk_exists: + logger.info('Колонки OAuth провайдеров уже существуют в users') + return True + + db_type = await get_database_type() + + async with engine.begin() as conn: + if not google_exists: + await conn.execute(text('ALTER TABLE users ADD COLUMN google_id VARCHAR(255)')) + logger.info('✅ Добавлена колонка google_id в users') + + if not yandex_exists: + await conn.execute(text('ALTER TABLE users ADD COLUMN yandex_id VARCHAR(255)')) + logger.info('✅ Добавлена колонка yandex_id в users') + + if not discord_exists: + await conn.execute(text('ALTER TABLE users ADD COLUMN discord_id VARCHAR(255)')) + logger.info('✅ Добавлена колонка discord_id в users') + + if not vk_exists: + if db_type == 'postgresql': + await conn.execute(text('ALTER TABLE users ADD COLUMN vk_id BIGINT')) + else: + await conn.execute(text('ALTER TABLE users ADD COLUMN vk_id INTEGER')) + logger.info('✅ Добавлена колонка vk_id в users') + + # Создаём уникальные индексы + for col in ('google_id', 'yandex_id', 'discord_id', 'vk_id'): + try: + async with engine.begin() as conn: + if db_type == 'postgresql' or db_type == 'sqlite': + await conn.execute(text(f'CREATE UNIQUE INDEX IF NOT EXISTS uq_users_{col} ON users ({col})')) + else: + await conn.execute(text(f'CREATE UNIQUE INDEX uq_users_{col} ON users ({col})')) + except Exception as idx_error: + logger.warning(f'Индекс uq_users_{col} возможно уже существует: {idx_error}') + + return True + + except Exception as error: + logger.error(f'❌ Ошибка добавления колонок OAuth провайдеров в users: {error}') + return False + + async def create_withdrawal_requests_table() -> bool: """Создаёт таблицу для заявок на вывод реферального баланса.""" try: @@ -7045,6 +7097,13 @@ async def run_universal_migration(): else: logger.warning('⚠️ Проблемы с миграцией transaction_id_cp') + logger.info('=== ДОБАВЛЕНИЕ КОЛОНОК OAUTH ПРОВАЙДЕРОВ ===') + oauth_columns_ready = await add_oauth_provider_columns() + if oauth_columns_ready: + logger.info('✅ Колонки OAuth провайдеров (google_id, yandex_id, discord_id, vk_id) готовы') + else: + logger.warning('⚠️ Проблемы с колонками OAuth провайдеров') + async with engine.begin() as conn: total_subs = await conn.execute(text('SELECT COUNT(*) FROM subscriptions')) unique_users = await conn.execute(text('SELECT COUNT(DISTINCT user_id) FROM subscriptions')) @@ -7157,6 +7216,10 @@ async def check_migration_status(): 'campaign_tariff_duration_days_column': False, 'campaign_registration_tariff_id_column': False, 'campaign_registration_tariff_duration_days_column': False, + 'users_google_id_column': False, + 'users_yandex_id_column': False, + 'users_discord_id_column': False, + 'users_vk_id_column': False, } status['has_made_first_topup_column'] = await check_column_exists('users', 'has_made_first_topup') @@ -7288,6 +7351,12 @@ async def check_migration_status(): 'transactions', 'receipt_created_at' ) + # Колонки OAuth провайдеров в users + status['users_google_id_column'] = await check_column_exists('users', 'google_id') + status['users_yandex_id_column'] = await check_column_exists('users', 'yandex_id') + status['users_discord_id_column'] = await check_column_exists('users', 'discord_id') + status['users_vk_id_column'] = await check_column_exists('users', 'vk_id') + async with engine.begin() as conn: duplicates_check = await conn.execute( text(""" @@ -7358,6 +7427,10 @@ async def check_migration_status(): 'subscription_temporary_access_table': 'Таблица subscription_temporary_access', 'transactions_receipt_uuid_column': 'Колонка receipt_uuid в transactions', 'transactions_receipt_created_at_column': 'Колонка receipt_created_at в transactions', + 'users_google_id_column': 'Колонка google_id в users', + 'users_yandex_id_column': 'Колонка yandex_id в users', + 'users_discord_id_column': 'Колонка discord_id в users', + 'users_vk_id_column': 'Колонка vk_id в users', } for check_key, check_status in status.items(): diff --git a/migrations/alembic/versions/g5b6c7d8e9f0_add_oauth_provider_ids.py b/migrations/alembic/versions/g5b6c7d8e9f0_add_oauth_provider_ids.py new file mode 100644 index 00000000..eec80908 --- /dev/null +++ b/migrations/alembic/versions/g5b6c7d8e9f0_add_oauth_provider_ids.py @@ -0,0 +1,45 @@ +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'g5b6c7d8e9f0' +down_revision: Union[str, None] = 'f4a5b6c7d8e9' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('users', sa.Column('google_id', sa.String(255), nullable=True)) + op.add_column('users', sa.Column('yandex_id', sa.String(255), nullable=True)) + op.add_column('users', sa.Column('discord_id', sa.String(255), nullable=True)) + op.add_column('users', sa.Column('vk_id', sa.BigInteger(), nullable=True)) + + op.create_unique_constraint('uq_users_google_id', 'users', ['google_id']) + op.create_unique_constraint('uq_users_yandex_id', 'users', ['yandex_id']) + op.create_unique_constraint('uq_users_discord_id', 'users', ['discord_id']) + op.create_unique_constraint('uq_users_vk_id', 'users', ['vk_id']) + + op.create_index('ix_users_google_id', 'users', ['google_id']) + op.create_index('ix_users_yandex_id', 'users', ['yandex_id']) + op.create_index('ix_users_discord_id', 'users', ['discord_id']) + op.create_index('ix_users_vk_id', 'users', ['vk_id']) + + +def downgrade() -> None: + op.drop_index('ix_users_vk_id', table_name='users') + op.drop_index('ix_users_discord_id', table_name='users') + op.drop_index('ix_users_yandex_id', table_name='users') + op.drop_index('ix_users_google_id', table_name='users') + + op.drop_constraint('uq_users_vk_id', 'users', type_='unique') + op.drop_constraint('uq_users_discord_id', 'users', type_='unique') + op.drop_constraint('uq_users_yandex_id', 'users', type_='unique') + op.drop_constraint('uq_users_google_id', 'users', type_='unique') + + op.drop_column('users', 'vk_id') + op.drop_column('users', 'discord_id') + op.drop_column('users', 'yandex_id') + op.drop_column('users', 'google_id') From e9b98b837a8552360ef4c41f6cd7a5779aa8b0a7 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 02:08:02 +0300 Subject: [PATCH 05/18] feat: migrate OAuth state storage from in-memory to Redis --- app/cabinet/auth/oauth_providers.py | 32 +++++++++-------------------- app/cabinet/routes/oauth.py | 4 ++-- 2 files changed, 12 insertions(+), 24 deletions(-) diff --git a/app/cabinet/auth/oauth_providers.py b/app/cabinet/auth/oauth_providers.py index 4bdc096d..5fb7b78f 100644 --- a/app/cabinet/auth/oauth_providers.py +++ b/app/cabinet/auth/oauth_providers.py @@ -2,7 +2,6 @@ import logging import secrets -import time from abc import ABC, abstractmethod from dataclasses import dataclass from urllib.parse import urlencode @@ -10,12 +9,11 @@ from urllib.parse import urlencode import httpx from app.config import settings +from app.utils.cache import cache, cache_key logger = logging.getLogger(__name__) -# In-memory CSRF state store with TTL -_oauth_states: dict[str, tuple[str, float]] = {} STATE_TTL_SECONDS = 600 # 10 minutes @@ -33,35 +31,25 @@ class OAuthUserInfo: avatar_url: str | None = None -def generate_oauth_state(provider: str) -> str: - """Generate a CSRF state token for OAuth flow.""" +async def generate_oauth_state(provider: str) -> str: + """Generate a CSRF state token for OAuth flow. Stored in Redis with TTL.""" state = secrets.token_urlsafe(32) - _oauth_states[state] = (provider, time.time()) - _cleanup_expired_states() + await cache.set(cache_key('oauth_state', state), provider, expire=STATE_TTL_SECONDS) return state -def validate_oauth_state(state: str, provider: str) -> bool: - """Validate and consume a CSRF state token.""" - entry = _oauth_states.pop(state, None) - if entry is None: +async def validate_oauth_state(state: str, provider: str) -> bool: + """Validate and consume a CSRF state token from Redis.""" + key = cache_key('oauth_state', state) + stored_provider = await cache.get(key) + if stored_provider is None: return False - stored_provider, created_at = entry + await cache.delete(key) if stored_provider != provider: return False - if time.time() - created_at > STATE_TTL_SECONDS: - return False return True -def _cleanup_expired_states() -> None: - """Remove expired state tokens.""" - now = time.time() - expired = [k for k, (_, ts) in _oauth_states.items() if now - ts > STATE_TTL_SECONDS] - for k in expired: - _oauth_states.pop(k, None) - - class OAuthProvider(ABC): """Base class for OAuth 2.0 providers.""" diff --git a/app/cabinet/routes/oauth.py b/app/cabinet/routes/oauth.py index 2b4f38ef..97d0892b 100644 --- a/app/cabinet/routes/oauth.py +++ b/app/cabinet/routes/oauth.py @@ -150,7 +150,7 @@ async def get_oauth_authorize_url(provider: str): detail=f'OAuth provider "{provider}" is not enabled', ) - state = generate_oauth_state(provider) + state = await generate_oauth_state(provider) authorize_url = oauth_provider.get_authorization_url(state) return OAuthAuthorizeResponse(authorize_url=authorize_url, state=state) @@ -164,7 +164,7 @@ async def oauth_callback( ): """Handle OAuth callback: exchange code, find/create user, return JWT.""" # 1. Validate CSRF state - if not validate_oauth_state(request.state, provider): + if not await validate_oauth_state(request.state, provider): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid or expired OAuth state', From 0de6418bca39fb1b72c49d7c89d1a169722ec9e8 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 02:14:37 +0300 Subject: [PATCH 06/18] refactor: add strict typing to OAuth providers, replace urlencode with httpx params --- app/cabinet/auth/oauth_providers.py | 189 +++++++++++++++++++++------- app/config.py | 2 +- 2 files changed, 144 insertions(+), 47 deletions(-) diff --git a/app/cabinet/auth/oauth_providers.py b/app/cabinet/auth/oauth_providers.py index 5fb7b78f..9309abe4 100644 --- a/app/cabinet/auth/oauth_providers.py +++ b/app/cabinet/auth/oauth_providers.py @@ -4,7 +4,7 @@ import logging import secrets from abc import ABC, abstractmethod from dataclasses import dataclass -from urllib.parse import urlencode +from typing import Any, TypedDict import httpx @@ -17,6 +17,70 @@ logger = logging.getLogger(__name__) STATE_TTL_SECONDS = 600 # 10 minutes +# --- Typed dicts for provider API responses --- + + +class OAuthProviderConfig(TypedDict): + client_id: str + client_secret: str + enabled: bool + display_name: str + + +class OAuthTokenResponse(TypedDict, total=False): + access_token: str + token_type: str + expires_in: int + refresh_token: str + scope: str + # VK-specific: email and user_id come in token response + email: str + user_id: int + + +class GoogleUserInfoResponse(TypedDict, total=False): + sub: str + email: str + email_verified: bool + given_name: str + family_name: str + picture: str + name: str + + +class YandexUserInfoResponse(TypedDict, total=False): + id: str + login: str + default_email: str + emails: list[str] + first_name: str + last_name: str + default_avatar_id: str + + +class DiscordUserInfoResponse(TypedDict, total=False): + id: str + username: str + global_name: str + email: str + verified: bool + avatar: str + + +class VKUserInfoItem(TypedDict, total=False): + id: int + first_name: str + last_name: str + photo_200: str + + +class VKUserInfoResponse(TypedDict, total=False): + response: list[VKUserInfoItem] + + +# --- Data classes --- + + @dataclass class OAuthUserInfo: """Normalized user info from OAuth provider.""" @@ -31,6 +95,9 @@ class OAuthUserInfo: avatar_url: str | None = None +# --- CSRF state management (Redis) --- + + async def generate_oauth_state(provider: str) -> str: """Generate a CSRF state token for OAuth flow. Stored in Redis with TTL.""" state = secrets.token_urlsafe(32) @@ -41,7 +108,7 @@ async def generate_oauth_state(provider: str) -> str: async def validate_oauth_state(state: str, provider: str) -> bool: """Validate and consume a CSRF state token from Redis.""" key = cache_key('oauth_state', state) - stored_provider = await cache.get(key) + stored_provider: str | None = await cache.get(key) if stored_provider is None: return False await cache.delete(key) @@ -50,13 +117,16 @@ async def validate_oauth_state(state: str, provider: str) -> bool: return True +# --- Provider implementations --- + + class OAuthProvider(ABC): """Base class for OAuth 2.0 providers.""" name: str display_name: str - def __init__(self, client_id: str, client_secret: str, redirect_uri: str): + def __init__(self, client_id: str, client_secret: str, redirect_uri: str) -> None: self.client_id = client_id self.client_secret = client_secret self.redirect_uri = redirect_uri @@ -66,11 +136,11 @@ class OAuthProvider(ABC): """Build the authorization URL for the provider.""" @abstractmethod - async def exchange_code(self, code: str) -> dict: + async def exchange_code(self, code: str) -> OAuthTokenResponse: """Exchange authorization code for tokens.""" @abstractmethod - async def get_user_info(self, token_data: dict) -> OAuthUserInfo: + async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo: """Fetch user info from the provider.""" @@ -78,8 +148,12 @@ class GoogleProvider(OAuthProvider): name = 'google' display_name = 'Google' + AUTHORIZE_URL = 'https://accounts.google.com/o/oauth2/v2/auth' + TOKEN_URL = 'https://oauth2.googleapis.com/token' + USERINFO_URL = 'https://www.googleapis.com/oauth2/v3/userinfo' + def get_authorization_url(self, state: str) -> str: - params = { + params: dict[str, str] = { 'client_id': self.client_id, 'redirect_uri': self.redirect_uri, 'response_type': 'code', @@ -88,12 +162,13 @@ class GoogleProvider(OAuthProvider): 'access_type': 'offline', 'prompt': 'select_account', } - return f'https://accounts.google.com/o/oauth2/v2/auth?{urlencode(params)}' + request = httpx.Request('GET', self.AUTHORIZE_URL, params=params) + return str(request.url) - async def exchange_code(self, code: str) -> dict: + async def exchange_code(self, code: str) -> OAuthTokenResponse: async with httpx.AsyncClient(timeout=15) as client: response = await client.post( - 'https://oauth2.googleapis.com/token', + self.TOKEN_URL, json={ 'client_id': self.client_id, 'client_secret': self.client_secret, @@ -103,17 +178,18 @@ class GoogleProvider(OAuthProvider): }, ) response.raise_for_status() - return response.json() + data: OAuthTokenResponse = response.json() + return data - async def get_user_info(self, token_data: dict) -> OAuthUserInfo: + async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo: access_token = token_data['access_token'] async with httpx.AsyncClient(timeout=15) as client: response = await client.get( - 'https://www.googleapis.com/oauth2/v3/userinfo', + self.USERINFO_URL, headers={'Authorization': f'Bearer {access_token}'}, ) response.raise_for_status() - data = response.json() + data: GoogleUserInfoResponse = response.json() return OAuthUserInfo( provider='google', @@ -130,8 +206,12 @@ class YandexProvider(OAuthProvider): name = 'yandex' display_name = 'Yandex' + AUTHORIZE_URL = 'https://oauth.yandex.com/authorize' + TOKEN_URL = 'https://oauth.yandex.com/token' + USERINFO_URL = 'https://login.yandex.ru/info' + def get_authorization_url(self, state: str) -> str: - params = { + params: dict[str, str] = { 'client_id': self.client_id, 'redirect_uri': self.redirect_uri, 'response_type': 'code', @@ -139,32 +219,34 @@ class YandexProvider(OAuthProvider): 'state': state, 'force_confirm': 'yes', } - return f'https://oauth.yandex.com/authorize?{urlencode(params)}' + request = httpx.Request('GET', self.AUTHORIZE_URL, params=params) + return str(request.url) - async def exchange_code(self, code: str) -> dict: + async def exchange_code(self, code: str) -> OAuthTokenResponse: async with httpx.AsyncClient(timeout=15) as client: response = await client.post( - 'https://oauth.yandex.com/token', + self.TOKEN_URL, data={ 'client_id': self.client_id, 'client_secret': self.client_secret, 'code': code, 'grant_type': 'authorization_code', }, - headers={'Content-Type': 'application/x-www-form-urlencoded'}, ) response.raise_for_status() - return response.json() + data: OAuthTokenResponse = response.json() + return data - async def get_user_info(self, token_data: dict) -> OAuthUserInfo: + async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo: access_token = token_data['access_token'] async with httpx.AsyncClient(timeout=15) as client: response = await client.get( - 'https://login.yandex.ru/info?format=json', + self.USERINFO_URL, + params={'format': 'json'}, headers={'Authorization': f'OAuth {access_token}'}, ) response.raise_for_status() - data = response.json() + data: YandexUserInfoResponse = response.json() default_email = data.get('default_email') emails = data.get('emails', []) @@ -190,8 +272,12 @@ class DiscordProvider(OAuthProvider): name = 'discord' display_name = 'Discord' + AUTHORIZE_URL = 'https://discord.com/api/oauth2/authorize' + TOKEN_URL = 'https://discord.com/api/oauth2/token' + USERINFO_URL = 'https://discord.com/api/v10/users/@me' + def get_authorization_url(self, state: str) -> str: - params = { + params: dict[str, str] = { 'client_id': self.client_id, 'redirect_uri': self.redirect_uri, 'response_type': 'code', @@ -199,12 +285,13 @@ class DiscordProvider(OAuthProvider): 'state': state, 'prompt': 'consent', } - return f'https://discord.com/api/oauth2/authorize?{urlencode(params)}' + request = httpx.Request('GET', self.AUTHORIZE_URL, params=params) + return str(request.url) - async def exchange_code(self, code: str) -> dict: + async def exchange_code(self, code: str) -> OAuthTokenResponse: async with httpx.AsyncClient(timeout=15) as client: response = await client.post( - 'https://discord.com/api/oauth2/token', + self.TOKEN_URL, data={ 'client_id': self.client_id, 'client_secret': self.client_secret, @@ -212,22 +299,22 @@ class DiscordProvider(OAuthProvider): 'grant_type': 'authorization_code', 'redirect_uri': self.redirect_uri, }, - headers={'Content-Type': 'application/x-www-form-urlencoded'}, ) response.raise_for_status() - return response.json() + data: OAuthTokenResponse = response.json() + return data - async def get_user_info(self, token_data: dict) -> OAuthUserInfo: + async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo: access_token = token_data['access_token'] async with httpx.AsyncClient(timeout=15) as client: response = await client.get( - 'https://discord.com/api/v10/users/@me', + self.USERINFO_URL, headers={'Authorization': f'Bearer {access_token}'}, ) response.raise_for_status() - data = response.json() + data: DiscordUserInfoResponse = response.json() - avatar_url = None + avatar_url: str | None = None if data.get('avatar'): avatar_url = f'https://cdn.discordapp.com/avatars/{data["id"]}/{data["avatar"]}.png' @@ -246,21 +333,27 @@ class VKProvider(OAuthProvider): name = 'vk' display_name = 'VK' + AUTHORIZE_URL = 'https://oauth.vk.com/authorize' + TOKEN_URL = 'https://oauth.vk.com/access_token' + USERINFO_URL = 'https://api.vk.com/method/users.get' + API_VERSION = '5.131' + def get_authorization_url(self, state: str) -> str: - params = { + params: dict[str, str] = { 'client_id': self.client_id, 'redirect_uri': self.redirect_uri, 'response_type': 'code', 'scope': 'email', 'state': state, - 'v': '5.131', + 'v': self.API_VERSION, } - return f'https://oauth.vk.com/authorize?{urlencode(params)}' + request = httpx.Request('GET', self.AUTHORIZE_URL, params=params) + return str(request.url) - async def exchange_code(self, code: str) -> dict: + async def exchange_code(self, code: str) -> OAuthTokenResponse: async with httpx.AsyncClient(timeout=15) as client: response = await client.get( - 'https://oauth.vk.com/access_token', + self.TOKEN_URL, params={ 'client_id': self.client_id, 'client_secret': self.client_secret, @@ -269,27 +362,29 @@ class VKProvider(OAuthProvider): }, ) response.raise_for_status() - return response.json() + data: OAuthTokenResponse = response.json() + return data - async def get_user_info(self, token_data: dict) -> OAuthUserInfo: + async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo: access_token = token_data['access_token'] - user_id = token_data.get('user_id') + user_id: int | None = token_data.get('user_id') # VK returns email in token response, not in userinfo - email = token_data.get('email') + email: str | None = token_data.get('email') async with httpx.AsyncClient(timeout=15) as client: response = await client.get( - 'https://api.vk.com/method/users.get', + self.USERINFO_URL, params={ 'access_token': access_token, 'fields': 'photo_200', - 'v': '5.131', + 'v': self.API_VERSION, }, ) response.raise_for_status() - data = response.json() + data: VKUserInfoResponse = response.json() - user_data = data.get('response', [{}])[0] + users: list[Any] = data.get('response', []) + user_data: VKUserInfoItem = users[0] if users else {} # type: ignore[assignment] return OAuthUserInfo( provider='vk', @@ -302,6 +397,8 @@ class VKProvider(OAuthProvider): ) +# --- Provider factory --- + _PROVIDERS: dict[str, type[OAuthProvider]] = { 'google': GoogleProvider, 'yandex': YandexProvider, @@ -315,7 +412,7 @@ def get_provider(name: str) -> OAuthProvider | None: Returns None if the provider is not enabled or not found. """ - providers_config = settings.get_oauth_providers_config() + providers_config: dict[str, OAuthProviderConfig] = settings.get_oauth_providers_config() config = providers_config.get(name) if not config or not config['enabled']: return None diff --git a/app/config.py b/app/config.py index e6382d12..eea062f1 100644 --- a/app/config.py +++ b/app/config.py @@ -2547,7 +2547,7 @@ class Settings(BaseSettings): return self.SMTP_USER # OAuth helpers - def get_oauth_providers_config(self) -> dict[str, dict]: + def get_oauth_providers_config(self) -> dict[str, dict[str, str | bool]]: """Return config for all OAuth providers (enabled or not).""" return { 'google': { From 333a3c590120a64f6b2963efab1edd861274840c Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 02:23:02 +0300 Subject: [PATCH 07/18] fix: increase OAuth HTTP timeout to 30s --- app/cabinet/auth/oauth_providers.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/cabinet/auth/oauth_providers.py b/app/cabinet/auth/oauth_providers.py index 9309abe4..443955fc 100644 --- a/app/cabinet/auth/oauth_providers.py +++ b/app/cabinet/auth/oauth_providers.py @@ -166,7 +166,7 @@ class GoogleProvider(OAuthProvider): return str(request.url) async def exchange_code(self, code: str) -> OAuthTokenResponse: - async with httpx.AsyncClient(timeout=15) as client: + async with httpx.AsyncClient(timeout=30) as client: response = await client.post( self.TOKEN_URL, json={ @@ -183,7 +183,7 @@ class GoogleProvider(OAuthProvider): async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo: access_token = token_data['access_token'] - async with httpx.AsyncClient(timeout=15) as client: + async with httpx.AsyncClient(timeout=30) as client: response = await client.get( self.USERINFO_URL, headers={'Authorization': f'Bearer {access_token}'}, @@ -223,7 +223,7 @@ class YandexProvider(OAuthProvider): return str(request.url) async def exchange_code(self, code: str) -> OAuthTokenResponse: - async with httpx.AsyncClient(timeout=15) as client: + async with httpx.AsyncClient(timeout=30) as client: response = await client.post( self.TOKEN_URL, data={ @@ -239,7 +239,7 @@ class YandexProvider(OAuthProvider): async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo: access_token = token_data['access_token'] - async with httpx.AsyncClient(timeout=15) as client: + async with httpx.AsyncClient(timeout=30) as client: response = await client.get( self.USERINFO_URL, params={'format': 'json'}, @@ -289,7 +289,7 @@ class DiscordProvider(OAuthProvider): return str(request.url) async def exchange_code(self, code: str) -> OAuthTokenResponse: - async with httpx.AsyncClient(timeout=15) as client: + async with httpx.AsyncClient(timeout=30) as client: response = await client.post( self.TOKEN_URL, data={ @@ -306,7 +306,7 @@ class DiscordProvider(OAuthProvider): async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo: access_token = token_data['access_token'] - async with httpx.AsyncClient(timeout=15) as client: + async with httpx.AsyncClient(timeout=30) as client: response = await client.get( self.USERINFO_URL, headers={'Authorization': f'Bearer {access_token}'}, @@ -351,7 +351,7 @@ class VKProvider(OAuthProvider): return str(request.url) async def exchange_code(self, code: str) -> OAuthTokenResponse: - async with httpx.AsyncClient(timeout=15) as client: + async with httpx.AsyncClient(timeout=30) as client: response = await client.get( self.TOKEN_URL, params={ @@ -371,7 +371,7 @@ class VKProvider(OAuthProvider): # VK returns email in token response, not in userinfo email: str | None = token_data.get('email') - async with httpx.AsyncClient(timeout=15) as client: + async with httpx.AsyncClient(timeout=30) as client: response = await client.get( self.USERINFO_URL, params={ From d0a9cfe6a9611749ee215377ce632da64d393216 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 02:29:01 +0300 Subject: [PATCH 08/18] refactor: replace dataclass with BaseModel for OAuthUserInfo --- app/cabinet/auth/oauth_providers.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/cabinet/auth/oauth_providers.py b/app/cabinet/auth/oauth_providers.py index 443955fc..e83cab85 100644 --- a/app/cabinet/auth/oauth_providers.py +++ b/app/cabinet/auth/oauth_providers.py @@ -3,10 +3,10 @@ import logging import secrets from abc import ABC, abstractmethod -from dataclasses import dataclass from typing import Any, TypedDict import httpx +from pydantic import BaseModel from app.config import settings from app.utils.cache import cache, cache_key @@ -78,11 +78,10 @@ class VKUserInfoResponse(TypedDict, total=False): response: list[VKUserInfoItem] -# --- Data classes --- +# --- Models --- -@dataclass -class OAuthUserInfo: +class OAuthUserInfo(BaseModel): """Normalized user info from OAuth provider.""" provider: str From ccd9ab02c5b7add1440efc6f1aafc93bb668e57a Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 02:31:56 +0300 Subject: [PATCH 09/18] refactor: remove duplicated helpers, import from auth.py --- app/cabinet/routes/oauth.py | 76 +------------------------------------ 1 file changed, 2 insertions(+), 74 deletions(-) diff --git a/app/cabinet/routes/oauth.py b/app/cabinet/routes/oauth.py index 97d0892b..f7d2c6ad 100644 --- a/app/cabinet/routes/oauth.py +++ b/app/cabinet/routes/oauth.py @@ -14,10 +14,7 @@ from app.database.crud.user import ( get_user_by_oauth_provider, set_user_oauth_provider_id, ) -from app.database.models import User -from ..auth import create_access_token, create_refresh_token -from ..auth.jwt_handler import get_refresh_token_expires_at from ..auth.oauth_providers import ( OAuthUserInfo, generate_oauth_state, @@ -25,7 +22,8 @@ from ..auth.oauth_providers import ( validate_oauth_state, ) from ..dependencies import get_cabinet_db -from ..schemas.auth import AuthResponse, UserResponse +from ..schemas.auth import AuthResponse +from .auth import _create_auth_response, _store_refresh_token logger = logging.getLogger(__name__) @@ -55,76 +53,6 @@ class OAuthCallbackRequest(BaseModel): state: str = Field(..., description='CSRF state token') -# --- Helpers --- - - -def _user_to_response(user: User) -> UserResponse: - """Convert User model to UserResponse.""" - return UserResponse( - id=user.id, - telegram_id=user.telegram_id, - username=user.username, - first_name=user.first_name, - last_name=user.last_name, - email=user.email, - email_verified=user.email_verified, - balance_kopeks=user.balance_kopeks, - balance_rubles=user.balance_rubles, - referral_code=user.referral_code, - language=user.language, - created_at=user.created_at, - auth_type=getattr(user, 'auth_type', 'telegram'), - ) - - -def _create_auth_response(user: User) -> AuthResponse: - """Create full auth response with tokens.""" - access_token = create_access_token(user.id, user.telegram_id) - refresh_token = create_refresh_token(user.id) - expires_in = settings.get_cabinet_access_token_expire_minutes() * 60 - - return AuthResponse( - access_token=access_token, - refresh_token=refresh_token, - token_type='bearer', - expires_in=expires_in, - user=_user_to_response(user), - ) - - -async def _store_refresh_token( - db: AsyncSession, - user_id: int, - refresh_token: str, - device_info: str | None = None, -) -> None: - """Store refresh token hash in database.""" - import hashlib - - from app.database.models import CabinetRefreshToken - - token_hash = hashlib.sha256(refresh_token.encode()).hexdigest() - expires_at = get_refresh_token_expires_at() - - from sqlalchemy import select - - existing = await db.execute(select(CabinetRefreshToken).where(CabinetRefreshToken.token_hash == token_hash)) - if existing.scalar_one_or_none(): - return - - token_record = CabinetRefreshToken( - user_id=user_id, - token_hash=token_hash, - device_info=device_info, - expires_at=expires_at, - ) - db.add(token_record) - try: - await db.commit() - except Exception: - await db.rollback() - - # --- Endpoints --- From 41633af7631cce084f9ff6e7ceb27b27ed340d95 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 02:35:55 +0300 Subject: [PATCH 10/18] refactor: fix transaction boundaries, extract _finalize_oauth_login, replace deprecated datetime.utcnow --- app/cabinet/routes/oauth.py | 29 +++++++++++++---------------- app/database/crud/user.py | 7 +++---- app/database/universal_migration.py | 2 +- 3 files changed, 17 insertions(+), 21 deletions(-) diff --git a/app/cabinet/routes/oauth.py b/app/cabinet/routes/oauth.py index f7d2c6ad..049dc8d7 100644 --- a/app/cabinet/routes/oauth.py +++ b/app/cabinet/routes/oauth.py @@ -14,6 +14,7 @@ from app.database.crud.user import ( get_user_by_oauth_provider, set_user_oauth_provider_id, ) +from app.database.models import User from ..auth.oauth_providers import ( OAuthUserInfo, @@ -31,6 +32,15 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix='/auth/oauth', tags=['Cabinet OAuth']) +async def _finalize_oauth_login(db: AsyncSession, user: User, provider: str) -> AuthResponse: + """Update last login, create tokens, store refresh token.""" + user.cabinet_last_login = datetime.now(UTC).replace(tzinfo=None) + await db.commit() + auth_response = _create_auth_response(user) + await _store_refresh_token(db, user.id, auth_response.refresh_token, device_info=f'oauth:{provider}') + return auth_response + + # --- Schemas --- @@ -129,24 +139,16 @@ async def oauth_callback( # 5. Find user by provider ID user = await get_user_by_oauth_provider(db, provider, user_info.provider_id) if user: - user.cabinet_last_login = datetime.now(UTC).replace(tzinfo=None) - await db.commit() - auth_response = _create_auth_response(user) - await _store_refresh_token(db, user.id, auth_response.refresh_token, device_info=f'oauth:{provider}') logger.info('OAuth login via %s for existing user %s', provider, user.id) - return auth_response + return await _finalize_oauth_login(db, user, provider) # 6. Find user by email (if verified) and link provider if user_info.email and user_info.email_verified: user = await get_user_by_email(db, user_info.email) if user: await set_user_oauth_provider_id(db, user, provider, user_info.provider_id) - user.cabinet_last_login = datetime.now(UTC).replace(tzinfo=None) - await db.commit() - auth_response = _create_auth_response(user) - await _store_refresh_token(db, user.id, auth_response.refresh_token, device_info=f'oauth:{provider}') logger.info('OAuth login via %s linked to existing email user %s', provider, user.id) - return auth_response + return await _finalize_oauth_login(db, user, provider) # 7. Create new user user = await create_user_by_oauth( @@ -159,10 +161,5 @@ async def oauth_callback( last_name=user_info.last_name, username=user_info.username, ) - user.cabinet_last_login = datetime.now(UTC).replace(tzinfo=None) - await db.commit() - - auth_response = _create_auth_response(user) - await _store_refresh_token(db, user.id, auth_response.refresh_token, device_info=f'oauth:{provider}') logger.info('OAuth new user created via %s with id=%s', provider, user.id) - return auth_response + return await _finalize_oauth_login(db, user, provider) diff --git a/app/database/crud/user.py b/app/database/crud/user.py index 95d2e95d..66f1b8af 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -1,7 +1,7 @@ import logging import secrets import string -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from sqlalchemy import and_, case, func, nullslast, or_, select, text from sqlalchemy.exc import IntegrityError @@ -1266,8 +1266,7 @@ async def set_user_oauth_provider_id(db: AsyncSession, user: User, provider: str return value: str | int = int(provider_id) if provider == 'vk' else provider_id setattr(user, column_name, value) - user.updated_at = datetime.utcnow() - await db.commit() + user.updated_at = datetime.now(UTC).replace(tzinfo=None) logger.info(f'Linked {provider} (id={provider_id}) to user {user.id}') @@ -1309,7 +1308,7 @@ async def create_user_by_oauth( setattr(user, column_name, provider_value) db.add(user) - await db.commit() + await db.flush() await db.refresh(user) user.promo_group = default_group diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 946c11aa..48a7f179 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -5132,7 +5132,7 @@ async def add_oauth_provider_columns() -> bool: for col in ('google_id', 'yandex_id', 'discord_id', 'vk_id'): try: async with engine.begin() as conn: - if db_type == 'postgresql' or db_type == 'sqlite': + if db_type in ('postgresql', 'sqlite'): await conn.execute(text(f'CREATE UNIQUE INDEX IF NOT EXISTS uq_users_{col} ON users ({col})')) else: await conn.execute(text(f'CREATE UNIQUE INDEX uq_users_{col} ON users ({col})')) From d3819c492f88794e4466c2da986fd3a928d7f3df Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 05:21:22 +0300 Subject: [PATCH 11/18] feat: add user_id filter to admin tickets endpoint Allow filtering tickets by user_id query parameter in GET /admin/tickets. --- app/cabinet/routes/admin_tickets.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/cabinet/routes/admin_tickets.py b/app/cabinet/routes/admin_tickets.py index 4d0dd398..040ddeb3 100644 --- a/app/cabinet/routes/admin_tickets.py +++ b/app/cabinet/routes/admin_tickets.py @@ -336,6 +336,7 @@ async def get_all_tickets( per_page: int = Query(20, ge=1, le=100, description='Items per page'), status_filter: str | None = Query(None, alias='status', description='Filter by status'), priority_filter: str | None = Query(None, alias='priority', description='Filter by priority'), + user_id: int | None = Query(None, description='Filter by user ID'), admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), ): @@ -355,6 +356,10 @@ async def get_all_tickets( query = query.where(Ticket.priority == priority_filter) count_query = count_query.where(Ticket.priority == priority_filter) + if user_id: + query = query.where(Ticket.user_id == user_id) + count_query = count_query.where(Ticket.user_id == user_id) + # Get total count total_result = await db.execute(count_query) total = total_result.scalar() or 0 From 070321230bcb868e4bc7a39c287ed3431a4aef4a Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 06:07:10 +0300 Subject: [PATCH 12/18] feat: add panel info, node usage endpoints and campaign to user detail - Add campaign_name/campaign_id to UserDetailResponse - Add GET /admin/users/{user_id}/panel-info endpoint (config, links, traffic, connection) - Add GET /admin/users/{user_id}/node-usage endpoint (per-node traffic breakdown) - Add UserPanelInfoResponse, UserNodeUsageItem, UserNodeUsageResponse schemas --- app/cabinet/routes/admin_users.py | 162 ++++++++++++++++++++++++++++++ app/cabinet/schemas/users.py | 46 +++++++++ 2 files changed, 208 insertions(+) diff --git a/app/cabinet/routes/admin_users.py b/app/cabinet/routes/admin_users.py index a355b229..252d9a13 100644 --- a/app/cabinet/routes/admin_users.py +++ b/app/cabinet/routes/admin_users.py @@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import Integer, and_, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession +from app.database.crud.campaign import get_campaign_registration_by_user from app.database.crud.subscription import ( extend_subscription, ) @@ -68,6 +69,9 @@ from ..schemas.users import ( UserAvailableTariffsResponse, UserDetailResponse, UserListItem, + UserNodeUsageItem, + UserNodeUsageResponse, + UserPanelInfoResponse, UserPromoGroupInfo, UserReferralInfo, UsersListResponse, @@ -525,6 +529,14 @@ async def get_user_detail( for t in transactions ] + # Get campaign info + campaign_name = None + campaign_id = None + campaign_reg = await get_campaign_registration_by_user(db, user.id) + if campaign_reg and campaign_reg.campaign: + campaign_name = campaign_reg.campaign.name + campaign_id = campaign_reg.campaign.id + return UserDetailResponse( id=user.id, telegram_id=user.telegram_id, @@ -550,6 +562,8 @@ async def get_user_detail( used_promocodes=user.used_promocodes, has_had_paid_subscription=user.has_had_paid_subscription, lifetime_used_traffic_bytes=user.lifetime_used_traffic_bytes or 0, + campaign_name=campaign_name, + campaign_id=campaign_id, restriction_topup=user.restriction_topup, restriction_subscription=user.restriction_subscription, restriction_reason=user.restriction_reason, @@ -577,6 +591,154 @@ async def get_user_by_telegram( return await get_user_detail(user.id, admin, db) +# === Panel Info === + + +@router.get('/{user_id}/panel-info', response_model=UserPanelInfoResponse) +async def get_user_panel_info( + user_id: int, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get user panel info from Remnawave (config links, traffic, connection data).""" + user = await get_user_by_id(db, user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail='User not found', + ) + + try: + from app.services.remnawave_service import RemnaWaveService + + service = RemnaWaveService() + if not service.is_configured or not user.telegram_id: + return UserPanelInfoResponse(found=False) + + async with service.get_api_client() as api: + panel_users = await api.get_user_by_telegram_id(user.telegram_id) + if not panel_users: + return UserPanelInfoResponse(found=False) + + panel_user = panel_users[0] + + # Resolve last connected node name + last_node_name = None + last_node_uuid = None + if panel_user.user_traffic and panel_user.user_traffic.last_connected_node_uuid: + last_node_uuid = panel_user.user_traffic.last_connected_node_uuid + try: + nodes = await api.get_all_nodes() + for node in nodes: + if node.uuid == last_node_uuid: + last_node_name = node.name + break + except Exception: + logger.warning(f'Failed to resolve node name for user {user_id}') + + return UserPanelInfoResponse( + found=True, + trojan_password=panel_user.trojan_password, + vless_uuid=panel_user.vless_uuid, + ss_password=panel_user.ss_password, + subscription_url=panel_user.subscription_url, + happ_link=panel_user.happ_link, + used_traffic_bytes=panel_user.used_traffic_bytes, + lifetime_used_traffic_bytes=panel_user.lifetime_used_traffic_bytes, + traffic_limit_bytes=panel_user.traffic_limit_bytes, + first_connected_at=panel_user.first_connected_at, + online_at=panel_user.online_at, + last_connected_node_uuid=last_node_uuid, + last_connected_node_name=last_node_name, + ) + + except Exception as e: + logger.error(f'Error getting panel info for user {user_id}: {e}') + return UserPanelInfoResponse(found=False) + + +@router.get('/{user_id}/node-usage', response_model=UserNodeUsageResponse) +async def get_user_node_usage( + user_id: int, + days: int = Query(7, ge=1, le=30), + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get user per-node traffic usage for a given period.""" + user = await get_user_by_id(db, user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail='User not found', + ) + + if not user.remnawave_uuid: + return UserNodeUsageResponse(items=[], period_days=days) + + try: + from app.services.remnawave_service import RemnaWaveService + + service = RemnaWaveService() + if not service.is_configured: + return UserNodeUsageResponse(items=[], period_days=days) + + end_date = datetime.utcnow() + start_date = end_date - timedelta(days=days) + + async with service.get_api_client() as api: + # Get bandwidth stats for user + stats = await api.get_bandwidth_stats_user( + user.remnawave_uuid, + start_date.strftime('%Y-%m-%dT%H:%M:%S.000Z'), + end_date.strftime('%Y-%m-%dT%H:%M:%S.000Z'), + ) + + # Get all nodes for name resolution + nodes = await api.get_all_nodes() + node_map = {n.uuid: n.name for n in nodes} + + items = [] + # Stats response contains per-node breakdown + if isinstance(stats, list): + for entry in stats: + node_uuid = entry.get('nodeUuid', '') + total = entry.get('totalBytes', 0) or entry.get('total', 0) + if node_uuid and total > 0: + items.append( + UserNodeUsageItem( + node_uuid=node_uuid, + node_name=node_map.get(node_uuid, node_uuid[:8]), + total_bytes=total, + ) + ) + elif isinstance(stats, dict): + # Handle dict format with node entries + for node_uuid, data in stats.items(): + if isinstance(data, dict): + total = data.get('totalBytes', 0) or data.get('total', 0) + elif isinstance(data, (int, float)): + total = int(data) + else: + continue + if total > 0: + items.append( + UserNodeUsageItem( + node_uuid=node_uuid, + node_name=node_map.get(node_uuid, node_uuid[:8]), + total_bytes=total, + ) + ) + + # Sort by traffic descending + items.sort(key=lambda x: x.total_bytes, reverse=True) + + return UserNodeUsageResponse(items=items, period_days=days) + + except Exception as e: + logger.error(f'Error getting node usage for user {user_id}: {e}') + return UserNodeUsageResponse(items=[], period_days=days) + + # === Balance Management === diff --git a/app/cabinet/schemas/users.py b/app/cabinet/schemas/users.py index 2ce0277e..3bb407e7 100644 --- a/app/cabinet/schemas/users.py +++ b/app/cabinet/schemas/users.py @@ -189,9 +189,55 @@ class UserDetailResponse(BaseModel): promo_offer_discount_source: str | None = None promo_offer_discount_expires_at: datetime | None = None + # Campaign + campaign_name: str | None = None + campaign_id: int | None = None + # Recent transactions recent_transactions: list[UserTransactionItem] = [] + # Remnawave UUID + remnawave_uuid: str | None = None + + +# === Panel Info === + + +class UserPanelInfoResponse(BaseModel): + """Panel info for user from Remnawave.""" + + found: bool = False + trojan_password: str | None = None + vless_uuid: str | None = None + ss_password: str | None = None + subscription_url: str | None = None + happ_link: str | None = None + used_traffic_bytes: int = 0 + lifetime_used_traffic_bytes: int = 0 + traffic_limit_bytes: int = 0 + first_connected_at: datetime | None = None + online_at: datetime | None = None + last_connected_node_uuid: str | None = None + last_connected_node_name: str | None = None + + +# === Node Usage === + + +class UserNodeUsageItem(BaseModel): + """Per-node traffic usage item.""" + + node_uuid: str + node_name: str + total_bytes: int + + +class UserNodeUsageResponse(BaseModel): + """Node usage response.""" + + items: list[UserNodeUsageItem] + period_days: int + # === User Actions === From c4da59173155e2eeb69eca21416f816fcbd1fa9c Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 06:22:07 +0300 Subject: [PATCH 13/18] fix: use accessible nodes API and fix date format for node usage - Add get_user_accessible_nodes() to fetch user's available nodes - Fix date format from ISO datetime to date-only (Y-m-d) for bandwidth stats - Show all accessible nodes (with zero traffic if no stats) - Add country_code to node usage response --- app/cabinet/routes/admin_users.py | 91 +++++++++++++++++++------------ app/cabinet/schemas/users.py | 1 + app/external/remnawave_api.py | 27 +++++++++ 3 files changed, 83 insertions(+), 36 deletions(-) diff --git a/app/cabinet/routes/admin_users.py b/app/cabinet/routes/admin_users.py index 252d9a13..c699fd9f 100644 --- a/app/cabinet/routes/admin_users.py +++ b/app/cabinet/routes/admin_users.py @@ -686,48 +686,67 @@ async def get_user_node_usage( start_date = end_date - timedelta(days=days) async with service.get_api_client() as api: - # Get bandwidth stats for user - stats = await api.get_bandwidth_stats_user( - user.remnawave_uuid, - start_date.strftime('%Y-%m-%dT%H:%M:%S.000Z'), - end_date.strftime('%Y-%m-%dT%H:%M:%S.000Z'), - ) + # Get user's accessible nodes + accessible_nodes = await api.get_user_accessible_nodes(user.remnawave_uuid) + if not accessible_nodes: + return UserNodeUsageResponse(items=[], period_days=days) - # Get all nodes for name resolution - nodes = await api.get_all_nodes() - node_map = {n.uuid: n.name for n in nodes} + node_name_map = {n.uuid: n.node_name for n in accessible_nodes} + node_cc_map = {n.uuid: n.country_code for n in accessible_nodes} + + # Get bandwidth stats for user (use date-only format) + start_str = start_date.strftime('%Y-%m-%d') + end_str = end_date.strftime('%Y-%m-%d') items = [] - # Stats response contains per-node breakdown - if isinstance(stats, list): - for entry in stats: - node_uuid = entry.get('nodeUuid', '') - total = entry.get('totalBytes', 0) or entry.get('total', 0) - if node_uuid and total > 0: - items.append( - UserNodeUsageItem( - node_uuid=node_uuid, - node_name=node_map.get(node_uuid, node_uuid[:8]), - total_bytes=total, + try: + stats = await api.get_bandwidth_stats_user(user.remnawave_uuid, start_str, end_str) + + if isinstance(stats, list): + for entry in stats: + node_uuid = entry.get('nodeUuid', '') + total = entry.get('totalBytes', 0) or entry.get('total', 0) + if node_uuid and total > 0: + items.append( + UserNodeUsageItem( + node_uuid=node_uuid, + node_name=node_name_map.get(node_uuid, node_uuid[:8]), + country_code=node_cc_map.get(node_uuid, ''), + total_bytes=total, + ) ) - ) - elif isinstance(stats, dict): - # Handle dict format with node entries - for node_uuid, data in stats.items(): - if isinstance(data, dict): - total = data.get('totalBytes', 0) or data.get('total', 0) - elif isinstance(data, (int, float)): - total = int(data) - else: - continue - if total > 0: - items.append( - UserNodeUsageItem( - node_uuid=node_uuid, - node_name=node_map.get(node_uuid, node_uuid[:8]), - total_bytes=total, + elif isinstance(stats, dict): + for node_uuid, data in stats.items(): + if isinstance(data, dict): + total = data.get('totalBytes', 0) or data.get('total', 0) + elif isinstance(data, (int, float)): + total = int(data) + else: + continue + if total > 0: + items.append( + UserNodeUsageItem( + node_uuid=node_uuid, + node_name=node_name_map.get(node_uuid, node_uuid[:8]), + country_code=node_cc_map.get(node_uuid, ''), + total_bytes=total, + ) ) + except Exception: + logger.warning(f'Failed to get bandwidth stats for user {user_id}, returning nodes without traffic') + + # Add accessible nodes with zero traffic if not in stats + seen_uuids = {item.node_uuid for item in items} + for node in accessible_nodes: + if node.uuid not in seen_uuids: + items.append( + UserNodeUsageItem( + node_uuid=node.uuid, + node_name=node.node_name, + country_code=node.country_code, + total_bytes=0, ) + ) # Sort by traffic descending items.sort(key=lambda x: x.total_bytes, reverse=True) diff --git a/app/cabinet/schemas/users.py b/app/cabinet/schemas/users.py index 3bb407e7..ffdb90c3 100644 --- a/app/cabinet/schemas/users.py +++ b/app/cabinet/schemas/users.py @@ -229,6 +229,7 @@ class UserNodeUsageItem(BaseModel): node_uuid: str node_name: str + country_code: str = '' total_bytes: int diff --git a/app/external/remnawave_api.py b/app/external/remnawave_api.py index 4e7db3ab..82bbe1ba 100644 --- a/app/external/remnawave_api.py +++ b/app/external/remnawave_api.py @@ -564,6 +564,33 @@ class RemnaWaveAPI: user = self._parse_user(response['response']) return await self.enrich_user_with_happ_link(user) + async def get_user_accessible_nodes(self, uuid: str) -> list[RemnaWaveAccessibleNode]: + """Получает список доступных нод для пользователя""" + try: + response = await self._make_request('GET', f'/api/users/{uuid}/accessible-nodes') + nodes_data = response.get('response', {}).get('activeNodes', []) + result = [] + for node in nodes_data: + # Collect inbounds from activeSquads + inbounds: list[str] = [] + for squad in node.get('activeSquads', []): + inbounds.extend(squad.get('activeInbounds', [])) + result.append( + RemnaWaveAccessibleNode( + uuid=node['uuid'], + node_name=node['nodeName'], + country_code=node['countryCode'], + config_profile_uuid=node.get('configProfileUuid', ''), + config_profile_name=node.get('configProfileName', ''), + active_inbounds=inbounds, + ) + ) + return result + except RemnaWaveAPIError as e: + if e.status_code == 404: + return [] + raise + async def get_all_users(self, start: int = 0, size: int = 100, enrich_happ_links: bool = False) -> dict[str, Any]: params = {'start': start, 'size': size} response = await self._make_request('GET', '/api/users', params=params) From 51ca3e42b75c1870c76a1b25f667629855cfe886 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 06:29:44 +0300 Subject: [PATCH 14/18] fix: query per-node legacy endpoint for user traffic breakdown The /api/bandwidth-stats/users/{uuid} endpoint rejects date params. Switch to querying each accessible node via the working legacy endpoint /api/bandwidth-stats/nodes/{uuid}/users/legacy and finding the user in the per-node results. --- app/cabinet/routes/admin_users.py | 69 +++++++++++-------------------- 1 file changed, 24 insertions(+), 45 deletions(-) diff --git a/app/cabinet/routes/admin_users.py b/app/cabinet/routes/admin_users.py index c699fd9f..26a180ca 100644 --- a/app/cabinet/routes/admin_users.py +++ b/app/cabinet/routes/admin_users.py @@ -691,54 +691,33 @@ async def get_user_node_usage( if not accessible_nodes: return UserNodeUsageResponse(items=[], period_days=days) - node_name_map = {n.uuid: n.node_name for n in accessible_nodes} - node_cc_map = {n.uuid: n.country_code for n in accessible_nodes} - - # Get bandwidth stats for user (use date-only format) - start_str = start_date.strftime('%Y-%m-%d') - end_str = end_date.strftime('%Y-%m-%d') + # Query per-node usage via legacy endpoint (proven format) + start_str = start_date.isoformat() + 'Z' + end_str = end_date.isoformat() + 'Z' items = [] - try: - stats = await api.get_bandwidth_stats_user(user.remnawave_uuid, start_str, end_str) - - if isinstance(stats, list): - for entry in stats: - node_uuid = entry.get('nodeUuid', '') - total = entry.get('totalBytes', 0) or entry.get('total', 0) - if node_uuid and total > 0: - items.append( - UserNodeUsageItem( - node_uuid=node_uuid, - node_name=node_name_map.get(node_uuid, node_uuid[:8]), - country_code=node_cc_map.get(node_uuid, ''), - total_bytes=total, - ) - ) - elif isinstance(stats, dict): - for node_uuid, data in stats.items(): - if isinstance(data, dict): - total = data.get('totalBytes', 0) or data.get('total', 0) - elif isinstance(data, (int, float)): - total = int(data) - else: - continue - if total > 0: - items.append( - UserNodeUsageItem( - node_uuid=node_uuid, - node_name=node_name_map.get(node_uuid, node_uuid[:8]), - country_code=node_cc_map.get(node_uuid, ''), - total_bytes=total, - ) - ) - except Exception: - logger.warning(f'Failed to get bandwidth stats for user {user_id}, returning nodes without traffic') - - # Add accessible nodes with zero traffic if not in stats - seen_uuids = {item.node_uuid for item in items} for node in accessible_nodes: - if node.uuid not in seen_uuids: + try: + node_stats = await api.get_bandwidth_stats_node_users_legacy( + node.uuid, start_str, end_str, + ) + # Find our user in node's user list + user_bytes = 0 + if isinstance(node_stats, list): + for entry in node_stats: + if entry.get('userUuid') == user.remnawave_uuid: + user_bytes = entry.get('total', 0) or entry.get('totalBytes', 0) + break + items.append( + UserNodeUsageItem( + node_uuid=node.uuid, + node_name=node.node_name, + country_code=node.country_code, + total_bytes=user_bytes, + ) + ) + except Exception: + logger.warning(f'Failed to get stats for node {node.uuid} user {user_id}') items.append( UserNodeUsageItem( node_uuid=node.uuid, From f00a051bb323e5ba94a3c38939870986726ed58e Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 06:36:38 +0300 Subject: [PATCH 15/18] fix: reduce node usage to 2 API calls to avoid 429 rate limit Per-node queries (8+ calls) hit Remnawave rate limit. Switch back to single get_bandwidth_stats_user call with %Y-%m-%d date format (same as traffic_monitoring_service). Add response logging to debug format. Also optimize panel-info to use accessible-nodes instead of all-nodes. --- app/cabinet/routes/admin_users.py | 78 +++++++++++++++++-------------- 1 file changed, 42 insertions(+), 36 deletions(-) diff --git a/app/cabinet/routes/admin_users.py b/app/cabinet/routes/admin_users.py index 26a180ca..876b90fa 100644 --- a/app/cabinet/routes/admin_users.py +++ b/app/cabinet/routes/admin_users.py @@ -622,16 +622,16 @@ async def get_user_panel_info( panel_user = panel_users[0] - # Resolve last connected node name + # Resolve last connected node name via accessible nodes (lighter than get_all_nodes) last_node_name = None last_node_uuid = None if panel_user.user_traffic and panel_user.user_traffic.last_connected_node_uuid: last_node_uuid = panel_user.user_traffic.last_connected_node_uuid try: - nodes = await api.get_all_nodes() - for node in nodes: + accessible = await api.get_user_accessible_nodes(panel_user.uuid) + for node in accessible: if node.uuid == last_node_uuid: - last_node_name = node.name + last_node_name = node.node_name break except Exception: logger.warning(f'Failed to resolve node name for user {user_id}') @@ -684,52 +684,58 @@ async def get_user_node_usage( end_date = datetime.utcnow() start_date = end_date - timedelta(days=days) + start_str = start_date.strftime('%Y-%m-%d') + end_str = end_date.strftime('%Y-%m-%d') async with service.get_api_client() as api: - # Get user's accessible nodes + # Get user's accessible nodes (1 API call) accessible_nodes = await api.get_user_accessible_nodes(user.remnawave_uuid) - if not accessible_nodes: - return UserNodeUsageResponse(items=[], period_days=days) + node_name_map = {n.uuid: n.node_name for n in accessible_nodes} - # Query per-node usage via legacy endpoint (proven format) - start_str = start_date.isoformat() + 'Z' - end_str = end_date.isoformat() + 'Z' + # Get user bandwidth stats (1 API call) + stats = await api.get_bandwidth_stats_user(user.remnawave_uuid, start_str, end_str) + logger.info(f'Bandwidth stats for user {user_id}: type={type(stats).__name__}, value={str(stats)[:500]}') + node_bytes: dict[str, int] = {} + if isinstance(stats, list): + for entry in stats: + nid = entry.get('nodeUuid') or entry.get('node_uuid', '') + total = entry.get('total', 0) or entry.get('totalBytes', 0) + if nid: + node_bytes[nid] = node_bytes.get(nid, 0) + int(total) + elif isinstance(stats, dict): + for key, val in stats.items(): + if isinstance(val, dict): + total = val.get('total', 0) or val.get('totalBytes', 0) + node_bytes[key] = int(total) + elif isinstance(val, (int, float)): + node_bytes[key] = int(val) + + # Build items from accessible nodes items = [] for node in accessible_nodes: - try: - node_stats = await api.get_bandwidth_stats_node_users_legacy( - node.uuid, start_str, end_str, + items.append( + UserNodeUsageItem( + node_uuid=node.uuid, + node_name=node.node_name, + country_code=node.country_code, + total_bytes=node_bytes.get(node.uuid, 0), ) - # Find our user in node's user list - user_bytes = 0 - if isinstance(node_stats, list): - for entry in node_stats: - if entry.get('userUuid') == user.remnawave_uuid: - user_bytes = entry.get('total', 0) or entry.get('totalBytes', 0) - break + ) + + # Add any nodes from stats not in accessible nodes + for nid, total in node_bytes.items(): + if nid not in node_name_map: items.append( UserNodeUsageItem( - node_uuid=node.uuid, - node_name=node.node_name, - country_code=node.country_code, - total_bytes=user_bytes, - ) - ) - except Exception: - logger.warning(f'Failed to get stats for node {node.uuid} user {user_id}') - items.append( - UserNodeUsageItem( - node_uuid=node.uuid, - node_name=node.node_name, - country_code=node.country_code, - total_bytes=0, + node_uuid=nid, + node_name=nid[:8], + country_code='', + total_bytes=total, ) ) - # Sort by traffic descending items.sort(key=lambda x: x.total_bytes, reverse=True) - return UserNodeUsageResponse(items=items, period_days=days) except Exception as e: From 462f7a99b9d5c0b7436dbc3d6ab5db6c6cfa3118 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 06:42:03 +0300 Subject: [PATCH 16/18] fix: parse bandwidth stats series format for node usage Response is {categories, series: [{uuid, name, countryCode, total}]}. Parse series array instead of treating dict keys as node UUIDs. --- app/cabinet/routes/admin_users.py | 49 ++++++++++++++----------------- 1 file changed, 22 insertions(+), 27 deletions(-) diff --git a/app/cabinet/routes/admin_users.py b/app/cabinet/routes/admin_users.py index 876b90fa..423d6be2 100644 --- a/app/cabinet/routes/admin_users.py +++ b/app/cabinet/routes/admin_users.py @@ -690,48 +690,43 @@ async def get_user_node_usage( async with service.get_api_client() as api: # Get user's accessible nodes (1 API call) accessible_nodes = await api.get_user_accessible_nodes(user.remnawave_uuid) - node_name_map = {n.uuid: n.node_name for n in accessible_nodes} # Get user bandwidth stats (1 API call) + # Response: {categories: [dates], series: [{uuid, name, countryCode, total, data}, ...]} stats = await api.get_bandwidth_stats_user(user.remnawave_uuid, start_str, end_str) - logger.info(f'Bandwidth stats for user {user_id}: type={type(stats).__name__}, value={str(stats)[:500]}') - node_bytes: dict[str, int] = {} - if isinstance(stats, list): - for entry in stats: - nid = entry.get('nodeUuid') or entry.get('node_uuid', '') - total = entry.get('total', 0) or entry.get('totalBytes', 0) - if nid: - node_bytes[nid] = node_bytes.get(nid, 0) + int(total) - elif isinstance(stats, dict): - for key, val in stats.items(): - if isinstance(val, dict): - total = val.get('total', 0) or val.get('totalBytes', 0) - node_bytes[key] = int(total) - elif isinstance(val, (int, float)): - node_bytes[key] = int(val) + # Parse series into per-node totals + series_map: dict[str, dict] = {} + if isinstance(stats, dict) and 'series' in stats: + for s in stats['series']: + series_map[s['uuid']] = { + 'name': s.get('name', ''), + 'country_code': s.get('countryCode', ''), + 'total': int(s.get('total', 0)), + } - # Build items from accessible nodes + # Build items: accessible nodes + any extra from stats items = [] + seen_uuids: set[str] = set() for node in accessible_nodes: + seen_uuids.add(node.uuid) + sr = series_map.get(node.uuid) items.append( UserNodeUsageItem( node_uuid=node.uuid, - node_name=node.node_name, - country_code=node.country_code, - total_bytes=node_bytes.get(node.uuid, 0), + node_name=sr['name'] if sr else node.node_name, + country_code=sr['country_code'] if sr else node.country_code, + total_bytes=sr['total'] if sr else 0, ) ) - - # Add any nodes from stats not in accessible nodes - for nid, total in node_bytes.items(): - if nid not in node_name_map: + for nid, sr in series_map.items(): + if nid not in seen_uuids: items.append( UserNodeUsageItem( node_uuid=nid, - node_name=nid[:8], - country_code='', - total_bytes=total, + node_name=sr['name'], + country_code=sr['country_code'], + total_bytes=sr['total'], ) ) From e4c65ca220994cf08ed3510f51d9e2808bb2d154 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 06:50:47 +0300 Subject: [PATCH 17/18] feat: return 30-day daily breakdown for node usage Always fetch 30 days with daily_bytes per node and categories. Frontend computes period totals locally without extra API calls. Removes days query param. --- app/cabinet/routes/admin_users.py | 25 ++++++++++++++----------- app/cabinet/schemas/users.py | 6 ++++-- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/app/cabinet/routes/admin_users.py b/app/cabinet/routes/admin_users.py index 423d6be2..55a8b409 100644 --- a/app/cabinet/routes/admin_users.py +++ b/app/cabinet/routes/admin_users.py @@ -660,11 +660,10 @@ async def get_user_panel_info( @router.get('/{user_id}/node-usage', response_model=UserNodeUsageResponse) async def get_user_node_usage( user_id: int, - days: int = Query(7, ge=1, le=30), admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), ): - """Get user per-node traffic usage for a given period.""" + """Get user per-node traffic usage (always 30 days with daily breakdown).""" user = await get_user_by_id(db, user_id) if not user: raise HTTPException( @@ -673,17 +672,17 @@ async def get_user_node_usage( ) if not user.remnawave_uuid: - return UserNodeUsageResponse(items=[], period_days=days) + return UserNodeUsageResponse(items=[]) try: from app.services.remnawave_service import RemnaWaveService service = RemnaWaveService() if not service.is_configured: - return UserNodeUsageResponse(items=[], period_days=days) + return UserNodeUsageResponse(items=[]) end_date = datetime.utcnow() - start_date = end_date - timedelta(days=days) + start_date = end_date - timedelta(days=30) start_str = start_date.strftime('%Y-%m-%d') end_str = end_date.strftime('%Y-%m-%d') @@ -692,17 +691,19 @@ async def get_user_node_usage( accessible_nodes = await api.get_user_accessible_nodes(user.remnawave_uuid) # Get user bandwidth stats (1 API call) - # Response: {categories: [dates], series: [{uuid, name, countryCode, total, data}, ...]} + # Response: {categories: [dates], series: [{uuid, name, countryCode, total, data: [daily]}, ...]} stats = await api.get_bandwidth_stats_user(user.remnawave_uuid, start_str, end_str) - # Parse series into per-node totals + categories: list[str] = [] series_map: dict[str, dict] = {} - if isinstance(stats, dict) and 'series' in stats: - for s in stats['series']: + if isinstance(stats, dict): + categories = stats.get('categories', []) + for s in stats.get('series', []): series_map[s['uuid']] = { 'name': s.get('name', ''), 'country_code': s.get('countryCode', ''), 'total': int(s.get('total', 0)), + 'daily': [int(v) for v in s.get('data', [])], } # Build items: accessible nodes + any extra from stats @@ -717,6 +718,7 @@ async def get_user_node_usage( node_name=sr['name'] if sr else node.node_name, country_code=sr['country_code'] if sr else node.country_code, total_bytes=sr['total'] if sr else 0, + daily_bytes=sr['daily'] if sr else [], ) ) for nid, sr in series_map.items(): @@ -727,15 +729,16 @@ async def get_user_node_usage( node_name=sr['name'], country_code=sr['country_code'], total_bytes=sr['total'], + daily_bytes=sr['daily'], ) ) items.sort(key=lambda x: x.total_bytes, reverse=True) - return UserNodeUsageResponse(items=items, period_days=days) + return UserNodeUsageResponse(items=items, categories=categories) except Exception as e: logger.error(f'Error getting node usage for user {user_id}: {e}') - return UserNodeUsageResponse(items=[], period_days=days) + return UserNodeUsageResponse(items=[]) # === Balance Management === diff --git a/app/cabinet/schemas/users.py b/app/cabinet/schemas/users.py index ffdb90c3..099de4f1 100644 --- a/app/cabinet/schemas/users.py +++ b/app/cabinet/schemas/users.py @@ -231,13 +231,15 @@ class UserNodeUsageItem(BaseModel): node_name: str country_code: str = '' total_bytes: int + daily_bytes: list[int] = [] class UserNodeUsageResponse(BaseModel): - """Node usage response.""" + """Node usage response with 30-day daily breakdown.""" items: list[UserNodeUsageItem] - period_days: int + categories: list[str] = [] + period_days: int = 30 # === User Actions === From 8b924df64f8b53785c22372aac49c04b8b0dcee3 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 07:15:15 +0300 Subject: [PATCH 18/18] chore: bump version to 3.6.0 in Dockerfile and workflows --- .github/workflows/docker-hub.yml | 6 +++--- .github/workflows/docker-registry.yml | 6 +++--- .release-please-manifest.json | 2 +- Dockerfile | 2 +- pyproject.toml | 2 +- uv.lock | 3 +-- 6 files changed, 10 insertions(+), 11 deletions(-) diff --git a/.github/workflows/docker-hub.yml b/.github/workflows/docker-hub.yml index 26a60398..653a256e 100644 --- a/.github/workflows/docker-hub.yml +++ b/.github/workflows/docker-hub.yml @@ -36,15 +36,15 @@ jobs: TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}" echo "🏷️ Собираем релизную версию: $VERSION" elif [[ $GITHUB_REF == refs/heads/main ]]; then - VERSION="v3.5.0-$(git rev-parse --short HEAD)" + VERSION="v3.6.0-$(git rev-parse --short HEAD)" TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}" echo "🚀 Собираем версию из main: $VERSION" elif [[ $GITHUB_REF == refs/heads/dev ]]; then - VERSION="v3.5.0-dev-$(git rev-parse --short HEAD)" + VERSION="v3.6.0-dev-$(git rev-parse --short HEAD)" TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}" echo "🧪 Собираем dev версию: $VERSION" else - VERSION="v3.5.0-pr-$(git rev-parse --short HEAD)" + VERSION="v3.6.0-pr-$(git rev-parse --short HEAD)" TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)" echo "🔀 Собираем PR версию: $VERSION" fi diff --git a/.github/workflows/docker-registry.yml b/.github/workflows/docker-registry.yml index 3d94856e..26419c49 100644 --- a/.github/workflows/docker-registry.yml +++ b/.github/workflows/docker-registry.yml @@ -49,13 +49,13 @@ jobs: VERSION=${GITHUB_REF#refs/tags/} echo "🏷️ Building release version: $VERSION" elif [[ $GITHUB_REF == refs/heads/main ]]; then - VERSION="v3.5.0-$(git rev-parse --short HEAD)" + VERSION="v3.6.0-$(git rev-parse --short HEAD)" echo "🚀 Building main version: $VERSION" elif [[ $GITHUB_REF == refs/heads/dev ]]; then - VERSION="v3.5.0-dev-$(git rev-parse --short HEAD)" + VERSION="v3.6.0-dev-$(git rev-parse --short HEAD)" echo "🧪 Building dev version: $VERSION" else - VERSION="v3.5.0-pr-$(git rev-parse --short HEAD)" + VERSION="v3.6.0-pr-$(git rev-parse --short HEAD)" echo "🔀 Building PR version: $VERSION" fi echo "version=$VERSION" >> $GITHUB_OUTPUT diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 3cf104e9..dc703804 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "3.5.0" + ".": "3.6.0" } diff --git a/Dockerfile b/Dockerfile index 47f2ccd8..468e8913 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \ FROM python:3.13-slim -ARG VERSION="v3.5.0" +ARG VERSION="v3.6.0" ARG BUILD_DATE ARG VCS_REF diff --git a/pyproject.toml b/pyproject.toml index b34299b0..39bb88f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = 'remnawave-bedolaga-telegram-bot' -version = "3.5.0" +version = "3.6.0" description = 'Telegram bot for RemnaWave VPN service' readme = 'README.md' license = { text = 'MIT' } diff --git a/uv.lock b/uv.lock index 2b6c3fa7..6bacc96f 100644 --- a/uv.lock +++ b/uv.lock @@ -576,7 +576,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" }, { url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" }, { url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" }, - { url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" }, { url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" }, { url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" }, { url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" }, @@ -1150,7 +1149,7 @@ wheels = [ [[package]] name = "remnawave-bedolaga-telegram-bot" -version = "3.5.0" +version = "3.6.0" source = { virtual = "." } dependencies = [ { name = "aiogram" },