Files
remnawave-bedolaga-telegram…/app/webapi/routes/users.py
T
Fringg 8175bc8bfe fix: comprehensive security hardening across payment and API layers
- CloudPayments: require webhook signature when secret configured (all 4 handlers)
- Platega: timing-safe HMAC comparison via hmac.compare_digest
- CryptoBot/Heleket: return False when API token unconfigured
- Tribute: return 503 when API key not configured
- Freekassa: use request.client.host instead of X-Forwarded-For
- Pal24: verify webhook amount matches stored payment amount
- YooKassa: reject test-mode payments in production; add YOOKASSA_TEST_MODE config
- CloudPayments: reject test-mode payments in production
- WebAPI: add upper bounds to duration_days, traffic_limit_gb, device_limit schemas
- WebAPI: bound balance update amount to ±100M kopeks
- WebAPI: sign-dispatch for balance updates (negative → subtract_user_balance)
- WebAPI miniapp: add blocked/deleted user checks, restriction_topup/subscription guards
- Admin handlers: add @admin_required and @error_handler to moderator panel
- add_user_balance: guard against negative amounts (use subtract_user_balance instead)
2026-03-23 16:44:38 +03:00

482 lines
18 KiB
Python

from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.crud.promo_group import get_promo_group_by_id
from app.database.crud.subscription import (
create_paid_subscription,
create_trial_subscription,
deactivate_subscription,
get_subscription_by_user_id,
replace_subscription,
)
from app.database.crud.user import (
add_user_balance,
create_user,
get_user_by_id,
get_user_by_referral_code,
get_user_by_telegram_id,
subtract_user_balance,
update_user,
)
from app.database.models import PaymentMethod, PromoGroup, Subscription, User, UserStatus
from app.services.subscription_service import SubscriptionService
from ..dependencies import get_db_session, require_api_token
from ..schemas.users import (
BalanceUpdateRequest,
PromoGroupSummary,
SubscriptionSummary,
UserCreateRequest,
UserListResponse,
UserResponse,
UserSubscriptionCreateRequest,
UserUpdateRequest,
)
router = APIRouter()
def _serialize_promo_group(group: PromoGroup | None) -> PromoGroupSummary | None:
if not group:
return None
return PromoGroupSummary(
id=group.id,
name=group.name,
server_discount_percent=group.server_discount_percent,
traffic_discount_percent=group.traffic_discount_percent,
device_discount_percent=group.device_discount_percent,
apply_discounts_to_addons=getattr(group, 'apply_discounts_to_addons', True),
)
def _serialize_subscription(subscription: Subscription | None) -> SubscriptionSummary | None:
if not subscription:
return None
return SubscriptionSummary(
id=subscription.id,
status=subscription.status,
actual_status=subscription.actual_status,
is_trial=subscription.is_trial,
start_date=subscription.start_date,
end_date=subscription.end_date,
traffic_limit_gb=subscription.traffic_limit_gb,
traffic_used_gb=subscription.traffic_used_gb,
device_limit=subscription.device_limit,
autopay_enabled=subscription.autopay_enabled,
autopay_days_before=subscription.autopay_days_before,
subscription_url=subscription.subscription_url,
subscription_crypto_link=subscription.subscription_crypto_link,
connected_squads=list(subscription.connected_squads or []),
)
def _serialize_user(user: User) -> UserResponse:
subscription = getattr(user, 'subscription', None)
promo_group = getattr(user, 'promo_group', None)
return UserResponse(
id=user.id,
telegram_id=user.telegram_id,
username=user.username,
first_name=user.first_name,
last_name=user.last_name,
status=user.status,
language=user.language,
balance_kopeks=user.balance_kopeks,
balance_rubles=round(user.balance_kopeks / 100, 2),
referral_code=user.referral_code,
referred_by_id=user.referred_by_id,
has_had_paid_subscription=user.has_had_paid_subscription,
has_made_first_topup=user.has_made_first_topup,
created_at=user.created_at,
updated_at=user.updated_at,
last_activity=user.last_activity,
promo_group=_serialize_promo_group(promo_group),
subscription=_serialize_subscription(subscription),
)
def _apply_search_filter(query, search: str):
search_lower = f'%{search.lower()}%'
conditions = [
func.lower(User.username).like(search_lower),
func.lower(User.first_name).like(search_lower),
func.lower(User.last_name).like(search_lower),
func.lower(User.referral_code).like(search_lower),
]
if search.isdigit():
conditions.append(User.telegram_id == int(search))
conditions.append(User.id == int(search))
return query.where(or_(*conditions))
@router.get('', response_model=UserListResponse)
async def list_users(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
status_filter: UserStatus | None = Query(default=None, alias='status'),
promo_group_id: int | None = Query(default=None),
search: str | None = Query(default=None),
) -> UserListResponse:
base_query = select(User).options(
selectinload(User.subscription),
selectinload(User.promo_group),
)
if status_filter:
base_query = base_query.where(User.status == status_filter.value)
if promo_group_id:
base_query = base_query.where(User.promo_group_id == promo_group_id)
if search:
base_query = _apply_search_filter(base_query, search)
total_query = base_query.with_only_columns(func.count()).order_by(None)
total = await db.scalar(total_query) or 0
result = await db.execute(base_query.order_by(User.created_at.desc()).offset(offset).limit(limit))
users = result.scalars().unique().all()
return UserListResponse(
items=[_serialize_user(user) for user in users],
total=int(total),
limit=limit,
offset=offset,
)
@router.get('/{user_id}', response_model=UserResponse)
async def get_user(
user_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> UserResponse:
# First check if the provided ID is a telegram_id
user = await get_user_by_telegram_id(db, user_id)
if user:
return _serialize_user(user)
# If not found as telegram_id, check as internal user ID
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found')
return _serialize_user(user)
@router.get('/by-telegram-id/{telegram_id}', response_model=UserResponse)
async def get_user_by_telegram_id_endpoint(
telegram_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> UserResponse:
"""
Get user by Telegram ID
"""
user = await get_user_by_telegram_id(db, telegram_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found')
return _serialize_user(user)
@router.post('', response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def create_user_endpoint(
payload: UserCreateRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> UserResponse:
# Check for duplicate telegram_id only if provided (skip for email-only users)
if payload.telegram_id is not None:
existing = await get_user_by_telegram_id(db, payload.telegram_id)
if existing:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'User with this telegram_id already exists')
user = await create_user(
db,
telegram_id=payload.telegram_id,
username=payload.username,
first_name=payload.first_name,
last_name=payload.last_name,
language=payload.language,
referred_by_id=payload.referred_by_id,
)
if payload.promo_group_id and payload.promo_group_id != user.promo_group_id:
promo_group = await get_promo_group_by_id(db, payload.promo_group_id)
if not promo_group:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Promo group not found')
user = await update_user(db, user, promo_group_id=promo_group.id)
user = await get_user_by_id(db, user.id)
return _serialize_user(user)
@router.patch('/{user_id}', response_model=UserResponse)
async def update_user_endpoint(
user_id: int,
payload: UserUpdateRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> UserResponse:
# First check if the provided ID is a telegram_id
user = await get_user_by_telegram_id(db, user_id)
if user:
found_user = user
else:
# If not found as telegram_id, check as internal user ID
found_user = await get_user_by_id(db, user_id)
if not found_user:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found')
updates: dict[str, Any] = {}
if payload.username is not None:
updates['username'] = payload.username
if payload.first_name is not None:
updates['first_name'] = payload.first_name
if payload.last_name is not None:
updates['last_name'] = payload.last_name
if payload.language is not None:
updates['language'] = payload.language
if payload.has_had_paid_subscription is not None:
updates['has_had_paid_subscription'] = payload.has_had_paid_subscription
if payload.has_made_first_topup is not None:
updates['has_made_first_topup'] = payload.has_made_first_topup
if payload.status is not None:
try:
status_value = UserStatus(payload.status).value
except ValueError as error:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Invalid status') from error
updates['status'] = status_value
if payload.promo_group_id is not None:
promo_group = await get_promo_group_by_id(db, payload.promo_group_id)
if not promo_group:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Promo group not found')
updates['promo_group_id'] = promo_group.id
if payload.referral_code is not None and payload.referral_code != found_user.referral_code:
existing_code_owner = await get_user_by_referral_code(db, payload.referral_code)
if existing_code_owner and existing_code_owner.id != found_user.id:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Referral code already in use')
updates['referral_code'] = payload.referral_code
if not updates:
return _serialize_user(found_user)
found_user = await update_user(db, found_user, **updates)
# Reload the user to ensure we have the latest data
if found_user.telegram_id == user_id:
found_user = await get_user_by_telegram_id(db, user_id)
else:
found_user = await get_user_by_id(db, found_user.id)
return _serialize_user(found_user)
@router.post('/{user_id}/balance', response_model=UserResponse)
async def update_balance(
user_id: int,
payload: BalanceUpdateRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> UserResponse:
if payload.amount_kopeks == 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Amount must be non-zero')
# First check if the provided ID is a telegram_id
user = await get_user_by_telegram_id(db, user_id)
if user:
found_user = user
else:
# If not found as telegram_id, check as internal user ID
found_user = await get_user_by_id(db, user_id)
if not found_user:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found')
if payload.amount_kopeks > 0:
success = await add_user_balance(
db,
found_user,
amount_kopeks=payload.amount_kopeks,
description=payload.description or 'Корректировка через веб-API',
create_transaction=payload.create_transaction,
payment_method=PaymentMethod.MANUAL,
)
else:
success = await subtract_user_balance(
db,
found_user,
amount_kopeks=abs(payload.amount_kopeks),
description=payload.description or 'Корректировка через веб-API',
create_transaction=payload.create_transaction,
payment_method=PaymentMethod.MANUAL,
)
if not success:
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Failed to update balance')
# Reload the user to ensure we have the latest data
if found_user.telegram_id == user_id:
found_user = await get_user_by_telegram_id(db, user_id)
else:
found_user = await get_user_by_id(db, found_user.id)
return _serialize_user(found_user)
async def _get_user_by_id_or_telegram_id(db: AsyncSession, user_id: int) -> User:
"""Helper function to get user by ID or telegram_id"""
user = await get_user_by_telegram_id(db, user_id)
if user:
return user
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found')
return user
@router.post('/{user_id}/subscription', response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def create_user_subscription(
user_id: int,
payload: UserSubscriptionCreateRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> UserResponse:
"""
Создать или заменить подписку для пользователя.
Поддерживает создание как триальных, так и платных подписок.
"""
user = await _get_user_by_id_or_telegram_id(db, user_id)
existing = await get_subscription_by_user_id(db, user.id)
if existing and not payload.replace_existing:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, 'User already has a subscription. Use replace_existing=true to replace it'
)
forced_devices = None
if not settings.is_devices_selection_enabled():
forced_devices = settings.get_disabled_mode_device_limit()
if payload.is_trial:
trial_device_limit = payload.device_limit
if trial_device_limit is None:
trial_device_limit = forced_devices
duration_days = payload.duration_days or settings.TRIAL_DURATION_DAYS
traffic_limit_gb = payload.traffic_limit_gb or settings.TRIAL_TRAFFIC_LIMIT_GB
if existing:
# Сохраняем существующие сквады при замене
connected_squads = list(existing.connected_squads or [])
if payload.squad_uuid:
connected_squads = [payload.squad_uuid]
elif payload.connected_squads:
connected_squads = payload.connected_squads
subscription = await replace_subscription(
db,
existing,
duration_days=duration_days,
traffic_limit_gb=traffic_limit_gb,
device_limit=(trial_device_limit if trial_device_limit is not None else settings.TRIAL_DEVICE_LIMIT),
connected_squads=connected_squads,
is_trial=True,
update_server_counters=True,
)
else:
subscription = await create_trial_subscription(
db,
user_id=user.id,
duration_days=duration_days,
traffic_limit_gb=traffic_limit_gb,
device_limit=trial_device_limit,
squad_uuid=payload.squad_uuid,
)
else:
if payload.duration_days is None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'duration_days is required for paid subscriptions')
device_limit = payload.device_limit
if device_limit is None:
if forced_devices is not None:
device_limit = forced_devices
else:
device_limit = settings.DEFAULT_DEVICE_LIMIT
if existing:
subscription = await replace_subscription(
db,
existing,
duration_days=payload.duration_days,
traffic_limit_gb=payload.traffic_limit_gb or settings.DEFAULT_TRAFFIC_LIMIT_GB,
device_limit=device_limit,
connected_squads=payload.connected_squads or [],
is_trial=False,
update_server_counters=True,
)
else:
subscription = await create_paid_subscription(
db,
user_id=user.id,
duration_days=payload.duration_days,
traffic_limit_gb=payload.traffic_limit_gb or settings.DEFAULT_TRAFFIC_LIMIT_GB,
device_limit=device_limit,
connected_squads=payload.connected_squads or [],
update_server_counters=True,
)
# Создаем пользователя в RemnaWave для платных подписок
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(db, subscription)
# Перезагружаем пользователя с подпиской
user = await get_user_by_id(db, user.id)
return _serialize_user(user)
@router.delete('/{user_id}/subscription', response_model=UserResponse)
async def delete_user_subscription(
user_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> UserResponse:
"""
Деактивировать подписку пользователя.
Подписка не удаляется физически, а помечается как DISABLED.
"""
user = await _get_user_by_id_or_telegram_id(db, user_id)
subscription = await get_subscription_by_user_id(db, user.id)
if not subscription:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User has no subscription')
await deactivate_subscription(db, subscription)
# Деактивируем пользователя в RemnaWave, если есть UUID
if user.remnawave_uuid:
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
# Перезагружаем пользователя
user = await get_user_by_id(db, user.id)
return _serialize_user(user)