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/app/cabinet/auth/oauth_providers.py b/app/cabinet/auth/oauth_providers.py new file mode 100644 index 00000000..e83cab85 --- /dev/null +++ b/app/cabinet/auth/oauth_providers.py @@ -0,0 +1,429 @@ +"""OAuth 2.0 provider implementations for cabinet authentication.""" + +import logging +import secrets +from abc import ABC, abstractmethod +from typing import Any, TypedDict + +import httpx +from pydantic import BaseModel + +from app.config import settings +from app.utils.cache import cache, cache_key + + +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] + + +# --- Models --- + + +class OAuthUserInfo(BaseModel): + """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 + + +# --- 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) + await cache.set(cache_key('oauth_state', state), provider, expire=STATE_TTL_SECONDS) + return state + + +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: str | None = await cache.get(key) + if stored_provider is None: + return False + await cache.delete(key) + if stored_provider != provider: + return False + 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) -> None: + 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) -> OAuthTokenResponse: + """Exchange authorization code for tokens.""" + + @abstractmethod + async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo: + """Fetch user info from the provider.""" + + +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: dict[str, str] = { + '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', + } + request = httpx.Request('GET', self.AUTHORIZE_URL, params=params) + return str(request.url) + + async def exchange_code(self, code: str) -> OAuthTokenResponse: + async with httpx.AsyncClient(timeout=30) as client: + response = await client.post( + self.TOKEN_URL, + 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() + data: OAuthTokenResponse = response.json() + return data + + async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo: + access_token = token_data['access_token'] + async with httpx.AsyncClient(timeout=30) as client: + response = await client.get( + self.USERINFO_URL, + headers={'Authorization': f'Bearer {access_token}'}, + ) + response.raise_for_status() + data: GoogleUserInfoResponse = 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' + + 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: dict[str, str] = { + 'client_id': self.client_id, + 'redirect_uri': self.redirect_uri, + 'response_type': 'code', + 'scope': 'login:info login:email', + 'state': state, + 'force_confirm': 'yes', + } + request = httpx.Request('GET', self.AUTHORIZE_URL, params=params) + return str(request.url) + + async def exchange_code(self, code: str) -> OAuthTokenResponse: + async with httpx.AsyncClient(timeout=30) as client: + response = await client.post( + self.TOKEN_URL, + data={ + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'code': code, + 'grant_type': 'authorization_code', + }, + ) + response.raise_for_status() + data: OAuthTokenResponse = response.json() + return data + + async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo: + access_token = token_data['access_token'] + async with httpx.AsyncClient(timeout=30) as client: + response = await client.get( + self.USERINFO_URL, + params={'format': 'json'}, + headers={'Authorization': f'OAuth {access_token}'}, + ) + response.raise_for_status() + data: YandexUserInfoResponse = 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' + + 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: dict[str, str] = { + 'client_id': self.client_id, + 'redirect_uri': self.redirect_uri, + 'response_type': 'code', + 'scope': 'identify email', + 'state': state, + 'prompt': 'consent', + } + request = httpx.Request('GET', self.AUTHORIZE_URL, params=params) + return str(request.url) + + async def exchange_code(self, code: str) -> OAuthTokenResponse: + async with httpx.AsyncClient(timeout=30) as client: + response = await client.post( + self.TOKEN_URL, + data={ + '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() + data: OAuthTokenResponse = response.json() + return data + + async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo: + access_token = token_data['access_token'] + async with httpx.AsyncClient(timeout=30) as client: + response = await client.get( + self.USERINFO_URL, + headers={'Authorization': f'Bearer {access_token}'}, + ) + response.raise_for_status() + data: DiscordUserInfoResponse = response.json() + + avatar_url: str | None = 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' + + 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: dict[str, str] = { + 'client_id': self.client_id, + 'redirect_uri': self.redirect_uri, + 'response_type': 'code', + 'scope': 'email', + 'state': state, + 'v': self.API_VERSION, + } + request = httpx.Request('GET', self.AUTHORIZE_URL, params=params) + return str(request.url) + + async def exchange_code(self, code: str) -> OAuthTokenResponse: + async with httpx.AsyncClient(timeout=30) as client: + response = await client.get( + self.TOKEN_URL, + params={ + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'code': code, + 'redirect_uri': self.redirect_uri, + }, + ) + response.raise_for_status() + data: OAuthTokenResponse = response.json() + return data + + async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo: + access_token = token_data['access_token'] + user_id: int | None = token_data.get('user_id') + # VK returns email in token response, not in userinfo + email: str | None = token_data.get('email') + + async with httpx.AsyncClient(timeout=30) as client: + response = await client.get( + self.USERINFO_URL, + params={ + 'access_token': access_token, + 'fields': 'photo_200', + 'v': self.API_VERSION, + }, + ) + response.raise_for_status() + data: VKUserInfoResponse = response.json() + + users: list[Any] = data.get('response', []) + user_data: VKUserInfoItem = users[0] if users else {} # type: ignore[assignment] + + 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'), + ) + + +# --- Provider factory --- + +_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: dict[str, OAuthProviderConfig] = 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/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}') 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 diff --git a/app/cabinet/routes/admin_users.py b/app/cabinet/routes/admin_users.py index a355b229..55a8b409 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,156 @@ 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 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: + accessible = await api.get_user_accessible_nodes(panel_user.uuid) + for node in accessible: + if node.uuid == last_node_uuid: + last_node_name = node.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, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """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( + status_code=status.HTTP_404_NOT_FOUND, + detail='User not found', + ) + + if not user.remnawave_uuid: + return UserNodeUsageResponse(items=[]) + + try: + from app.services.remnawave_service import RemnaWaveService + + service = RemnaWaveService() + if not service.is_configured: + return UserNodeUsageResponse(items=[]) + + end_date = datetime.utcnow() + start_date = end_date - timedelta(days=30) + 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 (1 API call) + 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: [daily]}, ...]} + stats = await api.get_bandwidth_stats_user(user.remnawave_uuid, start_str, end_str) + + categories: list[str] = [] + series_map: dict[str, dict] = {} + 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 + 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=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(): + if nid not in seen_uuids: + items.append( + UserNodeUsageItem( + node_uuid=nid, + 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, categories=categories) + + except Exception as e: + logger.error(f'Error getting node usage for user {user_id}: {e}') + return UserNodeUsageResponse(items=[]) + + # === Balance Management === 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/cabinet/routes/oauth.py b/app/cabinet/routes/oauth.py new file mode 100644 index 00000000..049dc8d7 --- /dev/null +++ b/app/cabinet/routes/oauth.py @@ -0,0 +1,165 @@ +"""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.oauth_providers import ( + OAuthUserInfo, + generate_oauth_state, + get_provider, + validate_oauth_state, +) +from ..dependencies import get_cabinet_db +from ..schemas.auth import AuthResponse +from .auth import _create_auth_response, _store_refresh_token + + +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 --- + + +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') + + +# --- 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 = await 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 await 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: + logger.info('OAuth login via %s for existing user %s', provider, user.id) + 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) + logger.info('OAuth login via %s linked to existing email user %s', provider, user.id) + return await _finalize_oauth_login(db, user, provider) + + # 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, + ) + logger.info('OAuth new user created via %s with id=%s', provider, user.id) + return await _finalize_oauth_login(db, user, provider) 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/cabinet/schemas/users.py b/app/cabinet/schemas/users.py index 2ce0277e..099de4f1 100644 --- a/app/cabinet/schemas/users.py +++ b/app/cabinet/schemas/users.py @@ -189,9 +189,58 @@ 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 + country_code: str = '' + total_bytes: int + daily_bytes: list[int] = [] + + +class UserNodeUsageResponse(BaseModel): + """Node usage response with 30-day daily breakdown.""" + + items: list[UserNodeUsageItem] + categories: list[str] = [] + period_days: int = 30 + # === User Actions === diff --git a/app/config.py b/app/config.py index dc35b786..eea062f1 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' @@ -236,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 @@ -695,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 @@ -1309,6 +1329,17 @@ 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 + # '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 + return False + def get_paid_subscription_user_tag(self) -> str | None: return self._normalize_user_tag( self.PAID_SUBSCRIPTION_USER_TAG, @@ -2515,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[str, str | bool]]: + """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..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 @@ -1235,3 +1235,100 @@ 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.now(UTC).replace(tzinfo=None) + 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.flush() + 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..48a7f179 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 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})')) + 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/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) 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/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/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 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' 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') 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" },