Merge pull request #2555 from BEDOLAGA-DEV/dev

chore: sync dev → main (v3.6.0)
This commit is contained in:
Egor
2026-02-07 07:23:01 +03:00
committed by GitHub
26 changed files with 1330 additions and 13 deletions
+3 -3
View File
@@ -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
+3 -3
View File
@@ -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
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.5.0"
".": "3.6.0"
}
+1 -1
View File
@@ -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
+429
View File
@@ -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,
)
+2
View File
@@ -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)
+1 -1
View File
@@ -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}')
+5
View File
@@ -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
+164
View File
@@ -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 ===
+22
View File
@@ -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(
+165
View File
@@ -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)
+20
View File
@@ -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()
+49
View File
@@ -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 ===
+65
View File
@@ -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)
+98 -1
View File
@@ -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
+10
View File
@@ -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:
+73
View File
@@ -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():
+27
View File
@@ -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)
+18
View File
@@ -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
+109
View File
@@ -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()
+6
View File
@@ -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]] = {
+3
View File
@@ -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
+9
View File
@@ -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'
@@ -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')
+1 -1
View File
@@ -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' }
Generated
+1 -2
View File
@@ -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" },