WEB-APP/ Optimization/ Build fix/ Hotkey edit mode/ Log rotation/ Form a11y/ E2E non-blocking

This commit is contained in:
Vladless
2026-04-13 00:38:29 +00:00
parent c1b9b20ea3
commit c693c28ee7
270 changed files with 20933 additions and 6365 deletions
+154 -21
View File
@@ -1,14 +1,16 @@
import hashlib
from urllib.parse import urlparse
from collections.abc import AsyncGenerator
from fastapi import Depends, HTTPException, Header, Query, Request
from fastapi import Depends, HTTPException, Header, Query, Request, Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from audit import set_api_actor
from database import async_session_maker, identities as idb
from database.models import Admin
from database.access.resolution import ResolvedActor, resolve_actor_from_identity
from database.models import Admin, Identity
async def get_session() -> AsyncGenerator[AsyncSession, None]:
@@ -25,6 +27,24 @@ def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
async def bind_identity_actor(
request: Request | None,
session: AsyncSession,
identity: Identity,
) -> ResolvedActor:
actor = await resolve_actor_from_identity(session, identity)
set_api_actor(request, identity_id=actor.identity_id, tg_id=actor.telegram_chat_id)
if request is not None:
request.state.actor = actor
return actor
def get_request_actor(request: Request | None) -> ResolvedActor | None:
if request is None:
return None
return getattr(request.state, "actor", None)
async def verify_admin_token(
admin_id: int = Query(..., alias="tg_id"),
token: str = Header(..., alias="X-Token"),
@@ -40,50 +60,146 @@ async def verify_admin_token(
return admin
AUTH_COOKIE_NAME = "auth_token"
IS_ADMIN_COOKIE_NAME = "is_admin"
AUTH_COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60
def _is_secure_request(request: Request | None) -> bool:
if request is None:
return False
if request.url.scheme == "https":
return True
forwarded_proto = request.headers.get("x-forwarded-proto", "").lower()
return forwarded_proto == "https"
def set_auth_cookie(response: Response, token: str, request: Request | None = None) -> None:
"""Устанавливает HttpOnly cookie с auth-токеном на ответ. Используется во всех login-ручках."""
response.set_cookie(
key=AUTH_COOKIE_NAME,
value=token,
max_age=AUTH_COOKIE_MAX_AGE_SECONDS,
path="/",
httponly=True,
secure=_is_secure_request(request),
samesite="lax",
)
def clear_auth_cookie(response: Response, request: Request | None = None) -> None:
"""Удаляет auth cookie на стороне браузера."""
response.delete_cookie(
key=AUTH_COOKIE_NAME,
path="/",
httponly=True,
secure=_is_secure_request(request),
samesite="lax",
)
clear_is_admin_cookie(response, request)
def set_is_admin_cookie(response: Response, identity: Identity, request: Request | None = None) -> None:
"""Ставит/гасит `is_admin` cookie в зависимости от текущей identity."""
if getattr(identity, "is_admin", False):
response.set_cookie(
key=IS_ADMIN_COOKIE_NAME,
value="1",
max_age=AUTH_COOKIE_MAX_AGE_SECONDS,
path="/",
httponly=True,
secure=_is_secure_request(request),
samesite="lax",
)
else:
clear_is_admin_cookie(response, request)
def clear_is_admin_cookie(response: Response, request: Request | None = None) -> None:
response.delete_cookie(
key=IS_ADMIN_COOKIE_NAME,
path="/",
httponly=True,
secure=_is_secure_request(request),
samesite="lax",
)
def _read_auth_cookie(request: Request | None) -> str | None:
if request is None:
return None
raw = request.cookies.get(AUTH_COOKIE_NAME)
if not raw:
return None
raw = raw.strip()
return raw or None
async def _identity_from_cookie(session: AsyncSession, request: Request | None) -> Identity | None:
token = _read_auth_cookie(request)
if not token:
return None
token_hash = hash_token(token)
identity = await idb.get_identity_by_token_hash(session, token_hash)
if identity is None:
return None
if idb._is_token_expired(identity):
return None
return identity
async def verify_identity_token(
x_identity_id: str = Header(..., alias="X-Identity-Id"),
token: str = Header(..., alias="X-Token"),
request: Request = None,
request: Request,
session: AsyncSession = Depends(get_session),
):
"""Проверяет пару identity_id + token; возвращает Identity. Для использования в API v2."""
identity = await idb.verify_identity_token(session, x_identity_id, token)
if not identity:
"""Проверяет токен из HttpOnly cookie `auth_token`; возвращает Identity."""
identity = await _identity_from_cookie(session, request)
if identity is None:
raise HTTPException(status_code=401, detail="Unauthorized")
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
await bind_identity_actor(request, session, identity)
return identity
async def verify_identity_admin(
x_identity_id: str = Header(..., alias="X-Identity-Id"),
token: str = Header(..., alias="X-Token"),
request: Request = None,
request: Request,
session: AsyncSession = Depends(get_session),
):
"""Проверяет identity + token и что identity.is_admin; для админских ручек v2."""
identity = await idb.verify_identity_token(session, x_identity_id, token)
if not identity:
"""Проверяет токен из cookie и что identity.is_admin; для админских ручек v2."""
identity = await _identity_from_cookie(session, request)
if identity is None:
raise HTTPException(status_code=401, detail="Unauthorized")
if not identity.is_admin:
raise HTTPException(status_code=403, detail="Forbidden")
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
await bind_identity_actor(request, session, identity)
return identity
async def verify_identity_admin_short(
x_identity_id: str = Header(..., alias="X-Identity-Id"),
token: str = Header(..., alias="X-Token"),
request: Request = None,
request: Request,
):
"""Проверка админа с короткой сессией (для broadcast и др.), чтобы не держать соединение с БД."""
identity = None
actor = None
async with async_session_maker() as session:
identity = await idb.verify_identity_token(session, x_identity_id, token)
identity = await _identity_from_cookie(session, request)
if identity:
actor = await resolve_actor_from_identity(session, identity)
await session.commit()
if not identity:
raise HTTPException(status_code=401, detail="Unauthorized")
if not identity.is_admin:
raise HTTPException(status_code=403, detail="Forbidden")
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
if actor is not None:
set_api_actor(request, identity_id=identity.id, tg_id=actor.telegram_chat_id)
if request is not None:
request.state.actor = actor
else:
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
return identity
@@ -102,3 +218,20 @@ async def verify_admin_token_short(
raise HTTPException(status_code=401, detail="Unauthorized")
set_api_actor(request, tg_id=admin.tg_id)
return admin
def validate_redirect_url(url: str, base_url: str) -> str:
"""Validate redirect URL is same-origin or relative. Returns safe URL or base_url fallback."""
url = url.strip()
if not url:
return base_url
if url.startswith("/"):
return url
try:
parsed = urlparse(url)
base_parsed = urlparse(base_url)
if parsed.scheme in ("http", "https") and parsed.netloc == base_parsed.netloc:
return url
except Exception:
pass
return base_url
+24 -5
View File
@@ -14,7 +14,8 @@ from logger import logger
if API_VERSION == 1:
from api.v1 import router as api_router, VERSION as API_DOC_VERSION
else:
from api.v2 import router as api_router, VERSION as API_DOC_VERSION
from api.v2 import VERSION as API_DOC_VERSION
from api.v2.router import router as api_router
app = FastAPI(
title=f"SoloBot API (Alpha) — API v{API_DOC_VERSION}",
@@ -25,15 +26,28 @@ app = FastAPI(
openapi_url="/api/openapi.json",
)
_cors_origins = API_CORS_ORIGINS if API_CORS_ORIGINS != ["*"] else API_CORS_ORIGINS
_cors_credentials = API_CORS_ORIGINS != ["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=API_CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_origins=_cors_origins,
allow_credentials=_cors_credentials,
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
allow_headers=["X-Identity-Id", "X-Token", "Content-Type", "Authorization"],
)
@app.middleware("http")
async def security_headers_middleware(request: Request, call_next):
response = await call_next(request)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
response.headers.setdefault("X-XSS-Protection", "1; mode=block")
return response
@app.middleware("http")
async def api_access_log_middleware(request: Request, call_next):
context = ensure_api_context(request)
@@ -86,6 +100,11 @@ async def api_access_log_middleware(request: Request, call_next):
return response
@app.get("/api/health", include_in_schema=False)
async def health():
return {"status": "ok"}
app.include_router(api_router)
_web_uploads_dir = "static/web_uploads"
+58 -11
View File
@@ -1,12 +1,22 @@
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
from sqlalchemy import inspect as sa_inspect
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy.orm.attributes import InstrumentedAttribute
from api.depends import get_session, verify_admin_token
from database.models import Admin
from database.access.resolution import resolve_user_optional
from handlers.texts import get_site_gift_link, get_telegram_gift_link
def _apply_user_relationship_loader(model: type, stmt):
if model.__name__ in ("ManualBan", "BlockedUser", "TemporaryData"):
return stmt.options(selectinload(model.user))
return stmt
def cast_identifier_type(field: InstrumentedAttribute, value: int | str):
@@ -19,6 +29,21 @@ def cast_identifier_type(field: InstrumentedAttribute, value: int | str):
def normalize_outgoing_object(obj: object) -> None:
if hasattr(obj, "vless") and getattr(obj, "vless") is None:
setattr(obj, "vless", False)
cls_name = type(obj).__name__
if cls_name == "Gift":
gift_id = getattr(obj, "gift_id", None)
if gift_id:
setattr(obj, "telegram_gift_link", get_telegram_gift_link(gift_id))
setattr(obj, "site_gift_link", get_site_gift_link(gift_id))
if cls_name in ("ManualBan", "BlockedUser", "TemporaryData"):
insp = sa_inspect(obj)
stored = getattr(obj, "tg_id", None)
if "user" in insp.unloaded:
setattr(obj, "tg_id", stored)
return
rel = getattr(obj, "user", None)
rel_tg = getattr(rel, "tg_id", None) if rel is not None else None
setattr(obj, "tg_id", stored if stored is not None else rel_tg)
def to_schema(schema_response: type, obj: object):
@@ -35,10 +60,20 @@ def generate_crud_router(
identifier_field: str = "tg_id",
parameter_name: str = "tg_id",
extra_get_by_email: bool = False,
telegram_path_to_user_id: bool = False,
enabled_methods: list[str] = ("get_all", "get_one", "get_by_email", "create", "update", "delete"),
) -> APIRouter:
router = APIRouter()
async def _path_filter(session: AsyncSession, value: int | str):
if telegram_path_to_user_id:
u = await resolve_user_optional(session, int(value))
if u is None:
return None
return getattr(model, "user_id"), u.id
field = getattr(model, identifier_field)
return field, cast_identifier_type(field, value)
if "get_all" in enabled_methods:
@router.get("/", response_model=list[schema_response])
@@ -46,7 +81,7 @@ def generate_crud_router(
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(model))
result = await session.execute(_apply_user_relationship_loader(model, select(model)))
items = result.scalars().all()
for item in items:
normalize_outgoing_object(item)
@@ -74,9 +109,13 @@ def generate_crud_router(
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = cast_identifier_type(field, value)
result = await session.execute(select(model).where(field == casted))
resolved = await _path_filter(session, value)
if resolved is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
field, casted = resolved
result = await session.execute(
_apply_user_relationship_loader(model, select(model).where(field == casted))
)
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
@@ -90,9 +129,13 @@ def generate_crud_router(
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = cast_identifier_type(field, value)
result = await session.execute(select(model).where(field == casted))
resolved = await _path_filter(session, value)
if resolved is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
field, casted = resolved
result = await session.execute(
_apply_user_relationship_loader(model, select(model).where(field == casted))
)
objs = result.scalars().all()
if not objs:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
@@ -127,8 +170,10 @@ def generate_crud_router(
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = cast_identifier_type(field, value)
resolved = await _path_filter(session, value)
if resolved is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
field, casted = resolved
result = await session.execute(select(model).where(field == casted))
obj = result.scalar_one_or_none()
if not obj:
@@ -150,8 +195,10 @@ def generate_crud_router(
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = cast_identifier_type(field, value)
resolved = await _path_filter(session, value)
if resolved is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
field, casted = resolved
result = await session.execute(select(model).where(field == casted))
obj = result.scalar_one_or_none()
if not obj:
+5 -1
View File
@@ -6,6 +6,7 @@ from api.depends import get_session, verify_admin_token
from api.v1.routes.base_crud import generate_crud_router
from api.v1.schemas import GiftBase, GiftResponse, GiftUpdate, GiftUsageResponse
from database.models import Admin, Gift, GiftUsage
from database.access.resolution import resolve_user_optional
router = APIRouter()
@@ -29,7 +30,10 @@ async def get_gifts_by_tg_id(
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(Gift).where(Gift.sender_tg_id == tg_id))
u = await resolve_user_optional(session, tg_id)
if u is None:
raise HTTPException(status_code=404, detail="Gifts not found")
result = await session.execute(select(Gift).where(Gift.sender_user_id == u.id))
gifts = result.scalars().all()
if not gifts:
raise HTTPException(status_code=404, detail="Gifts not found")
+6 -2
View File
@@ -8,7 +8,8 @@ from api.depends import get_session, verify_admin_token
from api.v1.routes.base_crud import generate_crud_router
from api.v1.schemas.keys import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate
from database.models import Admin, Key, Tariff
from handlers.keys.operations import create_key_on_cluster, delete_key_from_cluster, renew_key_in_cluster
from database.access.resolution import resolve_user_optional
from services.operations import create_key_on_cluster, delete_key_from_cluster, renew_key_in_cluster
from logger import logger
@@ -63,7 +64,10 @@ async def get_router_keys_by_tg_id(
if not tariff_ids:
return []
keys_result = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.tariff_id.in_(tariff_ids)))
u = await resolve_user_optional(session, tg_id)
if u is None:
return []
keys_result = await session.execute(select(Key).where(Key.user_id == u.id, Key.tariff_id.in_(tariff_ids)))
keys = keys_result.scalars().all()
return keys
+1 -1
View File
@@ -208,7 +208,7 @@ async def restore_trials(
update(User)
.where(
User.trial == 1,
~exists(select(Key.tg_id).where(Key.tg_id == User.tg_id)),
~exists(select(Key.user_id).where(Key.user_id == User.id)),
)
.values(trial=0)
)
+16 -5
View File
@@ -13,6 +13,7 @@ from api.v1.schemas import (
TrackingSourceResponse,
)
from database import get_tracking_source_stats
from database.access.resolution import resolve_user_optional
from database.models import (
Admin,
BlockedUser,
@@ -47,7 +48,10 @@ async def get_payments_by_tg_id(
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(Payment).where(Payment.tg_id == tg_id))
u = await resolve_user_optional(session, tg_id)
if u is None:
raise HTTPException(status_code=404, detail="Payments not found")
result = await session.execute(select(Payment).where(Payment.user_id == u.id))
payments = result.scalars().all()
if not payments:
raise HTTPException(status_code=404, detail="Payments not found")
@@ -60,7 +64,8 @@ router.include_router(
schema_response=NotificationResponse,
schema_create=None,
schema_update=None,
identifier_field="tg_id",
identifier_field="user_id",
telegram_path_to_user_id=True,
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/notifications",
@@ -75,7 +80,9 @@ router.include_router(
schema_response=ManualBanResponse,
schema_create=None,
schema_update=None,
identifier_field="tg_id",
identifier_field="user_id",
parameter_name="tg_id",
telegram_path_to_user_id=True,
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/manual-bans",
@@ -89,7 +96,9 @@ router.include_router(
schema_response=BlockedUserResponse,
schema_create=None,
schema_update=None,
identifier_field="tg_id",
identifier_field="user_id",
parameter_name="tg_id",
telegram_path_to_user_id=True,
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/blocked-users",
@@ -103,7 +112,9 @@ router.include_router(
schema_response=TemporaryDataResponse,
schema_create=None,
schema_update=None,
identifier_field="tg_id",
identifier_field="user_id",
parameter_name="tg_id",
telegram_path_to_user_id=True,
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/temporary-data",
+12 -2
View File
@@ -6,6 +6,7 @@ from api.depends import get_session, verify_admin_token
from api.v1.routes.base_crud import generate_crud_router
from api.v1.schemas import ReferralResponse
from database.models import Admin, Referral
from database.access.resolution import resolve_user_optional
router = generate_crud_router(
@@ -13,7 +14,9 @@ router = generate_crud_router(
schema_response=ReferralResponse,
schema_create=None,
schema_update=None,
identifier_field="referrer_tg_id",
identifier_field="referrer_user_id",
parameter_name="referrer_tg_id",
telegram_path_to_user_id=True,
enabled_methods=["get_all", "get_one", "get_all_by_field"],
)
@@ -25,8 +28,15 @@ async def delete_one_referral(
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
ru_ref = await resolve_user_optional(session, referrer_tg_id)
rd_ref = await resolve_user_optional(session, referred_tg_id)
if ru_ref is None or rd_ref is None:
raise HTTPException(status_code=404, detail="Referral not found")
result = await session.execute(
select(Referral).where(Referral.referrer_tg_id == referrer_tg_id, Referral.referred_tg_id == referred_tg_id)
select(Referral).where(
Referral.referrer_user_id == ru_ref.id,
Referral.referred_user_id == rd_ref.id,
)
)
obj = result.scalar_one_or_none()
if not obj:
+6 -2
View File
@@ -9,7 +9,8 @@ from api.v1.routes.base_crud import generate_crud_router
from api.v1.schemas.users import UserBase, UserResponse, UserUpdate
from database import async_session_maker, delete_user_data, get_servers
from database.models import Key, User
from handlers.keys.operations import delete_key_from_cluster
from database.access.resolution import resolve_user_optional
from services.operations import delete_key_from_cluster
from logger import logger
@@ -30,7 +31,10 @@ async def delete_user(
session: AsyncSession = Depends(get_session),
):
try:
result = await session.execute(select(Key.email, Key.client_id).where(Key.tg_id == tg_id))
u = await resolve_user_optional(session, tg_id)
if u is None:
raise HTTPException(status_code=404, detail="Пользователь не найден")
result = await session.execute(select(Key.email, Key.client_id).where(Key.user_id == u.id))
key_records = result.all()
async with async_session_maker() as s:
+7 -3
View File
@@ -4,11 +4,13 @@ from pydantic import BaseModel
class GiftBase(BaseModel):
sender_tg_id: int
recipient_tg_id: int | None = None
sender_user_id: int
recipient_user_id: int | None = None
selected_months: int | None = None
expiry_time: datetime
gift_link: str
telegram_gift_link: str | None = None
site_gift_link: str | None = None
is_used: bool = False
is_unlimited: bool | None = False
max_usages: int | None = None
@@ -33,10 +35,12 @@ class GiftUsageResponse(BaseModel):
class GiftUpdate(BaseModel):
recipient_tg_id: int | None = None
recipient_user_id: int | None = None
selected_months: int | None = None
expiry_time: datetime | None = None
gift_link: str | None = None
telegram_gift_link: str | None = None
site_gift_link: str | None = None
is_used: bool | None = None
is_unlimited: bool | None = None
max_usages: int | None = None
+2 -1
View File
@@ -2,8 +2,9 @@ from pydantic import BaseModel, Field
class KeyBase(BaseModel):
tg_id: int
user_id: int
client_id: str
tg_id: int | None = None
email: str | None = None
created_at: int | None = None
expiry_time: int
+14 -8
View File
@@ -4,7 +4,8 @@ from pydantic import BaseModel
class PaymentBase(BaseModel):
tg_id: int
user_id: int
tg_id: int | None = None
amount: float
payment_system: str
status: str
@@ -19,8 +20,8 @@ class PaymentResponse(PaymentBase):
class ReferralResponse(BaseModel):
referred_tg_id: int
referrer_tg_id: int
referred_user_id: int
referrer_user_id: int
reward_issued: bool = False
class Config:
@@ -37,11 +38,13 @@ class NotificationResponse(BaseModel):
class GiftBase(BaseModel):
sender_tg_id: int
recipient_tg_id: int | None = None
sender_user_id: int
recipient_user_id: int | None = None
selected_months: int
expiry_time: datetime
gift_link: str
telegram_gift_link: str | None = None
site_gift_link: str | None = None
is_used: bool = False
is_unlimited: bool = False
max_usages: int | None = None
@@ -66,7 +69,8 @@ class GiftUsageResponse(BaseModel):
class ManualBanResponse(BaseModel):
tg_id: int
user_id: int
tg_id: int | None = None
banned_at: datetime
reason: str
banned_by: int
@@ -77,7 +81,8 @@ class ManualBanResponse(BaseModel):
class TemporaryDataResponse(BaseModel):
tg_id: int
user_id: int
tg_id: int | None = None
state: str
data: dict
updated_at: datetime
@@ -87,7 +92,8 @@ class TemporaryDataResponse(BaseModel):
class BlockedUserResponse(BaseModel):
tg_id: int
user_id: int
tg_id: int | None = None
class Config:
from_attributes = True
+2 -2
View File
@@ -2,8 +2,8 @@ from pydantic import BaseModel
class ReferralResponse(BaseModel):
referred_tg_id: int
referrer_tg_id: int
referred_user_id: int
referrer_user_id: int
reward_issued: bool = False
class Config:
+7 -3
View File
@@ -1,5 +1,9 @@
from api.v2.router import router
VERSION = "2.0.0"
__all__ = ("router", "VERSION")
def __getattr__(name: str):
if name == "router":
from api.v2.router import router
return router
raise AttributeError(name)
+40 -12
View File
@@ -6,7 +6,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import InstrumentedAttribute
from api.depends import get_session, verify_identity_admin
from api.v1.routes.base_crud import cast_identifier_type, normalize_outgoing_object, to_schema
from api.v1.routes.base_crud import (
_apply_user_relationship_loader,
cast_identifier_type,
normalize_outgoing_object,
to_schema,
)
from database.access.resolution import resolve_user_optional
def generate_crud_router(
@@ -18,10 +24,20 @@ def generate_crud_router(
identifier_field: str = "tg_id",
parameter_name: str = "tg_id",
extra_get_by_email: bool = False,
telegram_path_to_user_id: bool = False,
enabled_methods: list[str] = ("get_all", "get_one", "get_by_email", "create", "update", "delete"),
) -> APIRouter:
router = APIRouter()
async def _path_filter(session: AsyncSession, value: int | str):
if telegram_path_to_user_id:
u = await resolve_user_optional(session, int(value))
if u is None:
return None
return getattr(model, "user_id"), u.id
field = getattr(model, identifier_field)
return field, cast_identifier_type(field, value)
if "get_all" in enabled_methods:
@router.get("/", response_model=list[schema_response])
@@ -29,7 +45,7 @@ def generate_crud_router(
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(model))
result = await session.execute(_apply_user_relationship_loader(model, select(model)))
items = result.scalars().all()
for item in items:
normalize_outgoing_object(item)
@@ -57,9 +73,13 @@ def generate_crud_router(
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = cast_identifier_type(field, value)
result = await session.execute(select(model).where(field == casted))
resolved = await _path_filter(session, value)
if resolved is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
field, casted = resolved
result = await session.execute(
_apply_user_relationship_loader(model, select(model).where(field == casted))
)
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
@@ -73,9 +93,13 @@ def generate_crud_router(
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = cast_identifier_type(field, value)
result = await session.execute(select(model).where(field == casted))
resolved = await _path_filter(session, value)
if resolved is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
field, casted = resolved
result = await session.execute(
_apply_user_relationship_loader(model, select(model).where(field == casted))
)
objs = result.scalars().all()
if not objs:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
@@ -110,8 +134,10 @@ def generate_crud_router(
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = cast_identifier_type(field, value)
resolved = await _path_filter(session, value)
if resolved is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
field, casted = resolved
result = await session.execute(select(model).where(field == casted))
obj = result.scalar_one_or_none()
if not obj:
@@ -131,8 +157,10 @@ def generate_crud_router(
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = cast_identifier_type(field, value)
resolved = await _path_filter(session, value)
if resolved is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
field, casted = resolved
result = await session.execute(select(model).where(field == casted))
obj = result.scalar_one_or_none()
if not obj:
+8 -2
View File
@@ -18,6 +18,8 @@ from api.v2.routes import (
payment_links,
identities,
web,
flows,
notifications,
)
router = APIRouter()
@@ -25,10 +27,12 @@ router = APIRouter()
router.include_router(root_router)
router.include_router(auth.router, prefix="/api")
router.include_router(users.router, prefix="/api/users", tags=["Users"])
router.include_router(keys.router, prefix="/api/keys", tags=["Keys"])
router.include_router(keys.user_router, prefix="/api/keys", tags=["Keys"])
router.include_router(keys.router, prefix="/api/admin/keys", tags=["AdminKeys"])
router.include_router(coupons.router, prefix="/api/coupons", tags=["Coupons"])
router.include_router(servers.router, prefix="/api/servers", tags=["Servers"])
router.include_router(tariffs.public_router, prefix="/api/tariffs", tags=["Tariffs"])
router.include_router(tariffs.user_tariff_router, prefix="/api/tariffs", tags=["Tariffs"])
router.include_router(tariffs.router, prefix="/api/tariffs", tags=["Tariffs"])
router.include_router(gifts.router, prefix="/api/gifts", tags=["Gifts"])
router.include_router(referrals.router, prefix="/api/referrals", tags=["Referrals"])
@@ -39,4 +43,6 @@ router.include_router(misc.router, prefix="/api")
router.include_router(modules.router, prefix="/api")
router.include_router(management.router, prefix="/api/management", tags=["Management"])
router.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
router.include_router(web.router, prefix="", tags=["Web"])
router.include_router(web.router, prefix="", tags=["Web"])
router.include_router(flows.router, prefix="/api", tags=["Flows"])
router.include_router(notifications.router, prefix="/api", tags=["Notifications"])
+8 -2
View File
@@ -1,3 +1,9 @@
from api.v2.routes.root import router as root_router
__all__ = ("root_router",)
def __getattr__(name: str):
if name == "root_router":
from api.v2.routes.root import router
return router
raise AttributeError(name)
-173
View File
@@ -1,173 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from audit import set_api_actor
from api.depends import get_session, verify_identity_token
from api.v2.schemas.identities import (
IdentityResponse,
LinkTelegramRequest,
LoginByCodeRequest,
LoginRequest,
LoginResponse,
LoginTelegramRequest,
RegisterByEmailRequest,
RegisterResponse,
SendLoginCodeRequest,
)
from config import API_TOKEN_TTL_DAYS, API_TOKEN
from database import identities as idb
from utils.telegram_login import verify_telegram_login
router = APIRouter(prefix="/auth", tags=["Auth"])
TOKEN_TTL_HINT = "бессрочно" if API_TOKEN_TTL_DAYS is None else f"{API_TOKEN_TTL_DAYS} дн."
TELEGRAM_LOGIN_MAX_AGE = 86400
@router.post("/register", response_model=RegisterResponse)
async def register_by_email(
body: RegisterByEmailRequest,
request: Request,
session: AsyncSession = Depends(get_session),
):
(
"""Регистрация по почте и паролю: создаётся идентичность, выдаётся токен. Срок действия токена: """
+ TOKEN_TTL_HINT
+ "."
)
email = body.email.strip().lower()
if not email:
raise HTTPException(status_code=400, detail="Email обязателен")
if not body.password or len(body.password) < 8:
raise HTTPException(status_code=400, detail="Пароль минимум 8 символов")
existing = await idb.get_identity_by_email(session, email)
if existing:
raise HTTPException(status_code=409, detail="Идентичность с таким email уже существует")
identity, token = await idb.create_identity_with_token(session, email=email, password=body.password)
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
return RegisterResponse(identity_id=identity.id, token=token)
@router.post("/login", response_model=LoginResponse)
async def login(
body: LoginRequest,
request: Request,
session: AsyncSession = Depends(get_session),
):
"""Вход по email и паролю. Возвращает identity_id и новый токен. Срок действия токена: """ + TOKEN_TTL_HINT + "."
email = body.email.strip().lower()
if not email:
raise HTTPException(status_code=400, detail="Email обязателен")
result = await idb.login_by_email(session, email, body.password)
if not result:
raise HTTPException(status_code=401, detail="Неверный email или пароль")
identity, token = result
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
return LoginResponse(identity_id=identity.id, token=token)
_LOGIN_CODES: dict[str, tuple[str, float]] = {}
_LOGIN_CODE_TTL = 600.0
def _clean_login_codes() -> None:
import time
now = time.time()
for k in list(_LOGIN_CODES):
if now - _LOGIN_CODES[k][1] > _LOGIN_CODE_TTL:
del _LOGIN_CODES[k]
@router.post("/send-login-code")
async def send_login_code(
body: SendLoginCodeRequest,
request: Request,
session: AsyncSession = Depends(get_session),
):
"""Отправить код входа на email. Код хранится на сервере 10 мин (для демо — без реальной отправки письма)."""
_clean_login_codes()
email = body.email.strip().lower()
if not email:
raise HTTPException(status_code=400, detail="Email обязателен")
identity = await idb.get_identity_by_email(session, email)
if not identity:
raise HTTPException(status_code=404, detail="Аккаунт с таким email не найден")
import secrets
import time
code = "".join(secrets.choice("0123456789") for _ in range(6))
_LOGIN_CODES[email] = (code, time.time())
return {"ok": True, "message": "Код отправлен на почту"}
@router.post("/login-by-code", response_model=LoginResponse)
async def login_by_code(
body: LoginByCodeRequest,
request: Request,
session: AsyncSession = Depends(get_session),
):
"""Вход по email и коду из письма."""
_clean_login_codes()
email = body.email.strip().lower()
if not email or not body.code or not body.code.strip():
raise HTTPException(status_code=400, detail="Email и код обязательны")
stored = _LOGIN_CODES.get(email)
if not stored:
raise HTTPException(status_code=400, detail="Код не найден или истёк. Запросите новый.")
code_value, _ = stored
if body.code.strip() != code_value:
raise HTTPException(status_code=401, detail="Неверный код")
del _LOGIN_CODES[email]
identity = await idb.get_identity_by_email(session, email)
if not identity:
raise HTTPException(status_code=401, detail="Аккаунт не найден")
token = await idb.issue_token_for_identity(session, identity)
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
return LoginResponse(identity_id=identity.id, token=token)
@router.post("/login-telegram", response_model=LoginResponse)
async def login_telegram(
body: LoginTelegramRequest,
request: Request,
session: AsyncSession = Depends(get_session),
):
(
"""Вход через Telegram Login Widget (кнопка на сайте). По tg_id находим или создаём Identity, выдаём токен. Срок действия токена: """
+ TOKEN_TTL_HINT
+ "."
)
payload = body.model_dump(mode="json")
if not verify_telegram_login(payload, API_TOKEN, max_age_seconds=TELEGRAM_LOGIN_MAX_AGE):
raise HTTPException(status_code=401, detail="Неверная подпись или устаревшие данные от Telegram")
identity = await idb.get_or_create_identity_for_tg(session, body.id)
token = await idb.issue_token_for_identity(session, identity)
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
return LoginResponse(identity_id=identity.id, token=token)
@router.post("/link-telegram", response_model=IdentityResponse)
async def link_telegram(
body: LinkTelegramRequest,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Привязывает Telegram к текущей идентичности. Требуется подпись от Telegram Login Widget (доказательство владения аккаунтом)."""
payload = body.model_dump(mode="json")
if not verify_telegram_login(payload, API_TOKEN, max_age_seconds=TELEGRAM_LOGIN_MAX_AGE):
raise HTTPException(status_code=401, detail="Неверная подпись или устаревшие данные от Telegram")
result = await idb.attach_telegram(session, identity.id, body.id)
if not result:
raise HTTPException(
status_code=409,
detail="Этот Telegram уже привязан к другой идентичности",
)
set_api_actor(request, identity_id=result.id, tg_id=result.tg_id)
return IdentityResponse.model_validate(result)
@router.get("/me", response_model=IdentityResponse)
async def me(
identity=Depends(verify_identity_token),
):
"""Текущая идентичность по заголовкам X-Identity-Id и X-Token."""
return IdentityResponse.model_validate(identity)
+13
View File
@@ -0,0 +1,13 @@
from fastapi import APIRouter
from api.v2.routes.auth import email_verify, link, password, session, telegram
router = APIRouter(prefix="/auth", tags=["Auth"])
router.include_router(password.router)
router.include_router(telegram.router)
router.include_router(link.router)
router.include_router(email_verify.router)
router.include_router(session.router)
__all__ = ["router"]
+112
View File
@@ -0,0 +1,112 @@
from fastapi import Request
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from config import API_TOKEN_TTL_DAYS
from logger import logger
from utils.referral_codes import encode_partner_code
TOKEN_TTL_HINT = "бессрочно" if API_TOKEN_TTL_DAYS is None else f"{API_TOKEN_TTL_DAYS} дн."
TELEGRAM_LOGIN_MAX_AGE = 86400
_TRUSTED_PROXY_CIDRS: list[str] = []
def _client_ip(request: Request) -> str:
client_host = (request.client.host if request.client else "") or ""
forwarded = request.headers.get("x-forwarded-for") or request.headers.get("X-Forwarded-For")
if not forwarded:
return client_host
if not _TRUSTED_PROXY_CIDRS and client_host not in ("127.0.0.1", "::1"):
return client_host
return forwarded.split(",")[0].strip() or client_host
async def _resolve_partner_snapshot(session: AsyncSession, billing_user_id: int) -> dict[str, object]:
partner_feature_enabled = False
default_percent = 0.0
try:
from modules.partner_program import settings as partner_settings
partner_feature_enabled = True
raw_percent = getattr(partner_settings, "PARTNER_BONUS_PERCENTAGES", {}).get(1, 0.0)
default_percent = float(raw_percent) * 100.0
except Exception:
partner_feature_enabled = False
default_percent = 0.0
payload: dict[str, object] = {
"partner_enabled": partner_feature_enabled,
"partner_code": "",
"partner_balance": 0.0,
"partner_percent": default_percent,
"partner_percent_custom": False,
"partner_referred_total": 0,
"partner_payout_method": None,
}
try:
partner_row = (
await session.execute(
text(
"""
SELECT
tg_id,
COALESCE(partner_balance, 0),
partner_percent,
COALESCE(partner_percent_custom, false),
partner_code,
payout_method
FROM users
WHERE id = :user_id
LIMIT 1
"""
),
{"user_id": int(billing_user_id)},
)
).first()
except Exception:
partner_row = None
if partner_row is None:
return payload
tg_id = int(partner_row[0]) if partner_row[0] is not None else None
balance = float(partner_row[1] or 0.0)
percent_raw = partner_row[2]
percent_custom = bool(partner_row[3])
percent_value = float(percent_raw) if (percent_custom and percent_raw is not None) else float(default_percent)
code = str(partner_row[4] or "").strip()
if (not code or code.isdigit() or code.startswith("r1_")) and int(billing_user_id) > 0:
generated_code = encode_partner_code(int(billing_user_id))
code = generated_code
try:
await session.execute(
text("UPDATE users SET partner_code = :code WHERE id = :id"),
{"code": generated_code, "id": int(billing_user_id)},
)
await session.flush()
except Exception as e:
logger.warning("[Auth] Ошибка сохранения partner_code для billing_user_id={}: {}", billing_user_id, e)
payout_method = str(partner_row[5] or "").strip() or None
referred_total = 0
if tg_id is not None:
try:
referred_total = int(
(
await session.execute(
text("SELECT COUNT(*) FROM partners WHERE partner_tg_id = :tg_id"),
{"tg_id": int(tg_id)},
)
).scalar()
or 0
)
except Exception:
referred_total = 0
payload.update({
"partner_enabled": bool(partner_feature_enabled or code or referred_total > 0 or balance > 0),
"partner_code": code,
"partner_balance": balance,
"partner_percent": percent_value,
"partner_percent_custom": percent_custom,
"partner_referred_total": referred_total,
"partner_payout_method": payout_method,
})
return payload
+43
View File
@@ -0,0 +1,43 @@
"""In-memory rate limit fallback для случаев когда Redis недоступен.
Используется только когда Redis не ответил — чтобы критичные auth-эндпоинты
не теряли защиту при кратковременных Redis-сбоях. Per-process, не шарится
между репликами — поэтому на нескольких инстансах лимит будет N*limit.
"""
import time
from collections import deque
from threading import Lock
_BUCKETS: dict[str, deque[float]] = {}
_LOCK = Lock()
_MAX_KEYS = 10000
def _prune(bucket: deque[float], window_sec: int) -> None:
threshold = time.monotonic() - window_sec
while bucket and bucket[0] < threshold:
bucket.popleft()
def _evict_if_full() -> None:
if len(_BUCKETS) < _MAX_KEYS:
return
now = time.monotonic()
dead = [k for k, b in _BUCKETS.items() if not b or b[-1] < now - 3600]
for k in dead:
del _BUCKETS[k]
if len(_BUCKETS) >= _MAX_KEYS:
oldest = min(_BUCKETS.keys(), key=lambda k: _BUCKETS[k][0] if _BUCKETS[k] else 0)
del _BUCKETS[oldest]
def check_and_increment(key: str, limit: int, window_sec: int) -> int:
"""Возвращает текущее значение счётчика после инкремента. Если >= limit — превышение."""
with _LOCK:
_evict_if_full()
bucket = _BUCKETS.setdefault(key, deque())
_prune(bucket, window_sec)
bucket.append(time.monotonic())
return len(bucket)
+79
View File
@@ -0,0 +1,79 @@
import secrets
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import (
BaseModel,
Field as PydanticField,
)
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_token
from api.v2.routes.auth._common import _client_ip
from mail import send_email_verify_code_email, smtp_configured
from utils import web_email_verify_code as verify_util
router = APIRouter()
class VerifyEmailRequest(BaseModel):
code: str = PydanticField(..., min_length=1, max_length=10)
@router.post("/send-verify-code")
async def send_email_verify_code(
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Отправить код подтверждения email. Требует авторизации."""
if not smtp_configured():
raise HTTPException(status_code=503, detail="Почтовый сервер не настроен")
email = (identity.email or "").strip().lower()
if not email:
raise HTTPException(status_code=400, detail="Email не привязан к аккаунту")
if getattr(identity, "email_verified", False):
return {"ok": True, "detail": "Email уже подтверждён"}
if not await verify_util.redis_ready():
raise HTTPException(status_code=503, detail="Сервис временно недоступен")
ip = _client_ip(request)
if not await verify_util.try_consume_ip_send_budget(ip):
raise HTTPException(status_code=429, detail="Слишком много запросов, попробуйте позже")
if not await verify_util.try_consume_email_send_budget(email):
raise HTTPException(status_code=429, detail="Слишком много запросов на этот email")
if not await verify_util.try_acquire_resend_cooldown(email):
raise HTTPException(status_code=429, detail="Подождите минуту перед повторной отправкой")
code = f"{secrets.randbelow(900000) + 100000}"
await verify_util.store_code(email, code)
try:
await send_email_verify_code_email(email, code)
except Exception:
await verify_util.delete_code(email)
raise HTTPException(status_code=503, detail="Не удалось отправить письмо")
return {"ok": True}
@router.post("/verify-email")
async def verify_email(
body: VerifyEmailRequest,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Подтвердить email по коду."""
email = (identity.email or "").strip().lower()
if not email:
raise HTTPException(status_code=400, detail="Email не привязан к аккаунту")
if getattr(identity, "email_verified", False):
return {"ok": True, "detail": "Email уже подтверждён"}
if not await verify_util.try_consume_verify_budget(email):
raise HTTPException(status_code=429, detail="Слишком много попыток, попробуйте позже")
if not await verify_util.verify_and_consume_code(email, body.code.strip()):
raise HTTPException(status_code=400, detail="Неверный или просроченный код")
from sqlalchemy import update
from database.models import Identity as IdentityModel
await session.execute(
update(IdentityModel).where(IdentityModel.id == identity.id).values(email_verified=True)
)
return {"ok": True}
+114
View File
@@ -0,0 +1,114 @@
import secrets
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import (
bind_identity_actor,
get_session,
verify_identity_token,
)
from api.v2.routes.auth._common import _client_ip
from api.v2.schemas.identities import (
IdentityResponse,
LinkEmailConfirmRequest,
LinkEmailSendCodeRequest,
)
from database import identities as idb
from mail import send_email_link_code_email, smtp_configured
from utils import web_email_link_code as email_link_code
router = APIRouter()
@router.post("/link-email/send-code")
async def link_email_send_code(
body: LinkEmailSendCodeRequest,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
email_norm = email_link_code.normalize_email(body.email)
if not email_norm:
raise HTTPException(status_code=400, detail="Укажите корректный email")
if identity.email and str(identity.email).strip().lower() == email_norm:
raise HTTPException(status_code=409, detail="Этот email уже привязан к аккаунту")
if not smtp_configured():
raise HTTPException(
status_code=503,
detail="Отправка кода недоступна: почта не настроена на сервере",
)
if not await email_link_code.redis_ready():
raise HTTPException(
status_code=503,
detail="Сервис временно недоступен. Попробуйте позже.",
)
existing = await idb.get_identity_by_email(session, email_norm)
if existing and existing.id != identity.id:
raise HTTPException(status_code=400, detail="Не удалось привязать email")
ip = _client_ip(request)
if not await email_link_code.try_consume_ip_budget(ip):
raise HTTPException(
status_code=429,
detail="Слишком много запросов с вашего адреса. Попробуйте позже.",
)
if not await email_link_code.try_consume_email_send_budget(email_norm):
raise HTTPException(
status_code=429,
detail="Слишком много запросов для этого адреса. Попробуйте позже.",
)
if not await email_link_code.try_acquire_cooldown(email_norm):
raise HTTPException(
status_code=429,
detail="Код уже отправлен. Подождите перед повторной отправкой.",
)
code = "".join(secrets.choice("0123456789") for _ in range(6))
if not await email_link_code.store_code(email_norm, code):
await email_link_code.release_cooldown(email_norm)
raise HTTPException(
status_code=503,
detail="Не удалось сохранить код. Попробуйте позже.",
)
try:
await send_email_link_code_email(email_norm, code)
except Exception:
await email_link_code.release_cooldown(email_norm)
await email_link_code.delete_code(email_norm)
raise HTTPException(
status_code=503,
detail="Не удалось отправить письмо. Попробуйте позже.",
) from None
return {"ok": True, "message": "Код подтверждения отправлен на почту"}
@router.post("/link-email/confirm", response_model=IdentityResponse)
async def link_email_confirm(
body: LinkEmailConfirmRequest,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
email_norm = email_link_code.normalize_email(body.email)
if not email_norm or not body.code or not str(body.code).strip():
raise HTTPException(status_code=400, detail="Email и код обязательны")
if not await email_link_code.redis_ready():
raise HTTPException(
status_code=503,
detail="Сервис временно недоступен. Попробуйте позже.",
)
if not await email_link_code.try_consume_email_verify_budget(email_norm):
raise HTTPException(
status_code=429,
detail="Слишком много попыток. Запросите новый код.",
)
if not await email_link_code.verify_and_consume_code(email_norm, str(body.code).strip()):
raise HTTPException(status_code=401, detail="Неверный код или срок действия истёк")
result = await idb.attach_email(session, identity.id, email_norm)
if not result:
raise HTTPException(
status_code=409,
detail="Этот email уже привязан к другой идентичности",
)
await bind_identity_actor(request, session, result)
return IdentityResponse.model_validate(result)
+389
View File
@@ -0,0 +1,389 @@
import secrets
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import (
bind_identity_actor,
get_session,
set_auth_cookie,
set_is_admin_cookie,
)
from api.v2.routes.auth._common import TOKEN_TTL_HINT, _client_ip
from api.v2.schemas.identities import (
ConfirmPasswordResetRequest,
LoginByCodeRequest,
LoginRequest,
LoginResponse,
RegisterByEmailRequest,
RegisterResponse,
SendLoginCodeRequest,
)
from database import (
add_referral,
get_referral_by_referred_id,
identities as idb,
)
from database.access.resolution import resolve_user_optional
from logger import logger
from mail import (
send_email_verify_code_email,
send_login_code_email,
send_password_reset_code_email,
smtp_configured,
)
from utils import (
web_email_verify_code as email_verify,
web_password_reset_code as pwd_reset,
)
from utils.disposable_emails import is_disposable_email
from utils.referral_codes import decode_referral_code
from utils.turnstile import turnstile_enabled, verify_turnstile_token
from utils.web_login_code import (
delete_code,
normalize_login_email,
redis_ready_for_login_codes,
release_resend_cooldown,
store_code,
try_acquire_resend_cooldown,
try_consume_email_send_budget,
try_consume_email_verify_budget,
try_consume_ip_send_budget,
verify_and_consume_code,
)
router = APIRouter()
_RESET_OK_MESSAGE = {
"ok": True,
"message": "Если для этого адреса есть аккаунт с паролем, мы отправили код. Проверьте почту.",
}
@router.post("/register", response_model=RegisterResponse)
async def register_by_email(
body: RegisterByEmailRequest,
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
):
(
"""Регистрация по почте и паролю: создаётся идентичность, выдаётся токен. Срок действия токена: """
+ TOKEN_TTL_HINT
+ "."
)
ip = _client_ip(request)
try:
from core.redis_cache import cache_incr_checked
from api.v2.routes.auth._fallback_limiter import check_and_increment
count, redis_ok = await cache_incr_checked(f"register_rate:{ip}", 3600)
if not redis_ok:
count = check_and_increment(f"register_rate:{ip}", 5, 3600)
if count > 5:
raise HTTPException(status_code=429, detail="Слишком много регистраций с этого IP. Попробуйте позже.")
except HTTPException:
raise
except Exception:
pass
if turnstile_enabled():
if not await verify_turnstile_token(body.turnstile_token, ip):
raise HTTPException(status_code=400, detail="Проверка CAPTCHA не пройдена")
email = body.email.strip().lower()
if not email:
raise HTTPException(status_code=400, detail="Email обязателен")
if is_disposable_email(email):
raise HTTPException(status_code=400, detail="Одноразовые email-адреса не поддерживаются")
if not body.password or len(body.password) < 8:
raise HTTPException(status_code=400, detail="Пароль минимум 8 символов")
existing = await idb.get_identity_by_email(session, email)
if existing:
raise HTTPException(status_code=409, detail="Идентичность с таким email уже существует")
raw_referral = str(body.referral_code or "").strip()
if "/referral/" in raw_referral:
raw_referral = raw_referral.split("/referral/", 1)[-1]
if "start=referral_" in raw_referral:
raw_referral = raw_referral.split("start=referral_", 1)[-1]
raw_referral = raw_referral.split("?", 1)[0].split("#", 1)[0].strip()
referrer_legacy = decode_referral_code(raw_referral)
referrer_user = None
if body.referral_code and referrer_legacy is None:
raise HTTPException(status_code=400, detail="Код приглашения недействителен")
if referrer_legacy is not None:
referrer_user = await resolve_user_optional(session, referrer_legacy)
if referrer_user is None:
raise HTTPException(status_code=400, detail="Код приглашения недействителен")
identity, token = await idb.create_identity_with_token(session, email=email, password=body.password)
await bind_identity_actor(request, session, identity)
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
if referrer_user is not None and not await get_referral_by_referred_id(session, billing_user_id):
await add_referral(session, billing_user_id, referrer_user.id)
if smtp_configured():
try:
code = f"{secrets.randbelow(900000) + 100000}"
await email_verify.store_code(email, code)
await send_email_verify_code_email(email, code)
except Exception as e:
logger.warning("[Auth] Не удалось отправить код подтверждения email при регистрации: {}", e)
logger.info("[Auth] Register success: identity={}, email={}, ip={}", identity.id, email, _client_ip(request))
set_auth_cookie(response, token, request)
set_is_admin_cookie(response, identity, request)
return RegisterResponse(identity_id=identity.id)
@router.post("/login", response_model=LoginResponse)
async def login(
body: LoginRequest,
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
):
"""Вход по email и паролю. Возвращает identity_id и новый токен. Срок действия токена: """ + TOKEN_TTL_HINT + "."
email = body.email.strip().lower()
if not email:
raise HTTPException(status_code=400, detail="Email обязателен")
ip = _client_ip(request)
try:
from core.redis_cache import cache_get, cache_incr_checked
from api.v2.routes.auth._fallback_limiter import check_and_increment
lockout_key = f"login_lockout:{email}"
locked = await cache_get(lockout_key)
if locked:
raise HTTPException(status_code=429, detail="Аккаунт временно заблокирован. Попробуйте через 15 минут.")
rkey = f"login_pwd_rate:{ip}"
count, redis_ok = await cache_incr_checked(rkey, 900)
if not redis_ok:
count = check_and_increment(rkey, 10, 900)
if count > 10:
raise HTTPException(status_code=429, detail="Слишком много попыток. Попробуйте позже.")
except HTTPException:
raise
except Exception as e:
logger.warning("[Auth] Ошибка rate-limit проверки для email-логина: {}", e)
result = await idb.login_by_email(session, email, body.password)
if not result:
try:
from core.redis_cache import cache_incr, cache_set
fail_key = f"login_fail:{email}"
fails = await cache_incr(fail_key, 900)
if fails >= 10:
await cache_set(f"login_lockout:{email}", "1", 900)
except Exception:
pass
raise HTTPException(status_code=401, detail="Неверный email или пароль")
try:
from core.redis_cache import cache_delete
await cache_delete(f"login_fail:{email}")
except Exception:
pass
identity, token = result
await bind_identity_actor(request, session, identity)
logger.info("[Auth] Login success: identity={}, email={}, ip={}, method=password", identity.id, email, ip)
set_auth_cookie(response, token, request)
set_is_admin_cookie(response, identity, request)
return LoginResponse(identity_id=identity.id)
@router.post("/send-login-code")
async def send_login_code(
body: SendLoginCodeRequest,
request: Request,
session: AsyncSession = Depends(get_session),
):
"""Отправить код входа на email (SMTP + Redis)."""
ip = _client_ip(request)
try:
from core.redis_cache import cache_incr_checked
from api.v2.routes.auth._fallback_limiter import check_and_increment
count, redis_ok = await cache_incr_checked(f"send_code_rate:{ip}", 3600)
if not redis_ok:
count = check_and_increment(f"send_code_rate:{ip}", 10, 3600)
if count > 10:
raise HTTPException(status_code=429, detail="Слишком много запросов кодов. Попробуйте позже.")
except HTTPException:
raise
except Exception:
pass
if turnstile_enabled():
if not await verify_turnstile_token(body.turnstile_token, ip):
raise HTTPException(status_code=400, detail="Проверка CAPTCHA не пройдена")
email_norm = normalize_login_email(body.email)
if not email_norm:
raise HTTPException(status_code=400, detail="Email обязателен")
if is_disposable_email(email_norm):
raise HTTPException(status_code=400, detail="Одноразовые email-адреса не поддерживаются")
if not smtp_configured():
raise HTTPException(
status_code=503,
detail="Отправка кода недоступна: почта не настроена на сервере",
)
if not await redis_ready_for_login_codes():
raise HTTPException(
status_code=503,
detail="Сервис временно недоступен. Попробуйте позже.",
)
identity = await idb.get_identity_by_email(session, email_norm)
if not identity:
if not body.allow_register:
return {"ok": True, "message": "Код отправлен на почту"}
identity = await idb.create_identity(session, email=email_norm)
ip = _client_ip(request)
if not await try_consume_ip_send_budget(ip):
raise HTTPException(
status_code=429,
detail="Слишком много запросов с вашего адреса. Попробуйте позже.",
)
if not await try_consume_email_send_budget(email_norm):
raise HTTPException(
status_code=429,
detail="Слишком много запросов для этого адреса. Попробуйте позже.",
)
if not await try_acquire_resend_cooldown(email_norm):
raise HTTPException(
status_code=429,
detail="Код уже отправлен. Подождите перед повторной отправкой.",
)
code = "".join(secrets.choice("0123456789") for _ in range(6))
if not await store_code(email_norm, code):
await release_resend_cooldown(email_norm)
raise HTTPException(
status_code=503,
detail="Не удалось сохранить код. Попробуйте позже.",
)
try:
await send_login_code_email(email_norm, code)
except Exception:
await release_resend_cooldown(email_norm)
await delete_code(email_norm)
raise HTTPException(
status_code=503,
detail="Не удалось отправить письмо. Попробуйте позже.",
) from None
return {"ok": True, "message": "Код отправлен на почту"}
@router.post("/login-by-code", response_model=LoginResponse)
async def login_by_code(
body: LoginByCodeRequest,
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
):
"""Вход по email и коду из письма."""
email_norm = normalize_login_email(body.email)
if not email_norm or not body.code or not body.code.strip():
raise HTTPException(status_code=400, detail="Email и код обязательны")
if not await redis_ready_for_login_codes():
raise HTTPException(
status_code=503,
detail="Сервис временно недоступен. Попробуйте позже.",
)
if not await try_consume_email_verify_budget(email_norm):
raise HTTPException(
status_code=429,
detail="Слишком много попыток. Запросите новый код.",
)
if not await verify_and_consume_code(email_norm, body.code.strip()):
raise HTTPException(status_code=401, detail="Неверный код или срок действия истёк")
identity = await idb.get_identity_by_email(session, email_norm)
if not identity:
raise HTTPException(status_code=401, detail="Аккаунт не найден")
if not getattr(identity, "email_verified", False):
from sqlalchemy import update as sa_update
from database.models import Identity as IdentityModel
await session.execute(sa_update(IdentityModel).where(IdentityModel.id == identity.id).values(email_verified=True))
await bind_identity_actor(request, session, identity)
token = await idb.issue_token_for_identity(session, identity)
logger.info("[Auth] Login success: identity={}, email={}, method=code", identity.id, email_norm)
set_auth_cookie(response, token, request)
set_is_admin_cookie(response, identity, request)
return LoginResponse(identity_id=identity.id)
@router.post("/request-password-reset")
async def request_password_reset(
body: SendLoginCodeRequest,
request: Request,
session: AsyncSession = Depends(get_session),
):
email_norm = normalize_login_email(body.email)
if not email_norm:
raise HTTPException(status_code=400, detail="Email обязателен")
if not smtp_configured() or not await pwd_reset.redis_ready():
return _RESET_OK_MESSAGE
identity = await idb.get_identity_by_email(session, email_norm)
if not identity or not identity.password_hash:
return _RESET_OK_MESSAGE
ip = _client_ip(request)
if not await pwd_reset.try_consume_ip_budget(ip):
raise HTTPException(
status_code=429,
detail="Слишком много запросов с вашего адреса. Попробуйте позже.",
)
if not await pwd_reset.try_consume_email_send_budget(email_norm):
raise HTTPException(
status_code=429,
detail="Слишком много запросов для этого адреса. Попробуйте позже.",
)
if not await pwd_reset.try_acquire_cooldown(email_norm):
raise HTTPException(
status_code=429,
detail="Код уже отправлен. Подождите перед повторной отправкой.",
)
code = "".join(secrets.choice("0123456789") for _ in range(6))
if not await pwd_reset.store_code(email_norm, code):
await pwd_reset.release_cooldown(email_norm)
raise HTTPException(
status_code=503,
detail="Не удалось сохранить код. Попробуйте позже.",
)
try:
await send_password_reset_code_email(email_norm, code)
except Exception:
await pwd_reset.release_cooldown(email_norm)
await pwd_reset.delete_code(email_norm)
raise HTTPException(
status_code=503,
detail="Не удалось отправить письмо. Попробуйте позже.",
) from None
return _RESET_OK_MESSAGE
@router.post("/confirm-password-reset", response_model=LoginResponse)
async def confirm_password_reset(
body: ConfirmPasswordResetRequest,
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
):
email_norm = normalize_login_email(body.email)
if not email_norm or not body.code or not body.code.strip():
raise HTTPException(status_code=400, detail="Email и код обязательны")
if body.password != body.password_confirm:
raise HTTPException(status_code=400, detail="Пароли не совпадают")
if len(body.password) < 8:
raise HTTPException(status_code=400, detail="Пароль минимум 8 символов")
if not await pwd_reset.redis_ready():
raise HTTPException(
status_code=503,
detail="Сервис временно недоступен. Попробуйте позже.",
)
if not await pwd_reset.try_consume_email_verify_budget(email_norm):
raise HTTPException(
status_code=429,
detail="Слишком много попыток. Запросите новый код.",
)
if not await pwd_reset.verify_and_consume_code(email_norm, body.code.strip()):
raise HTTPException(status_code=401, detail="Неверный код или срок действия истёк")
identity = await idb.get_identity_by_email(session, email_norm)
if not identity:
raise HTTPException(status_code=400, detail="Аккаунт не найден")
updated = await idb.set_password_for_identity(session, identity.id, body.password)
if not updated:
raise HTTPException(status_code=400, detail="Не удалось обновить пароль")
await bind_identity_actor(request, session, updated)
token = await idb.issue_token_for_identity(session, updated)
set_auth_cookie(response, token, request)
set_is_admin_cookie(response, updated, request)
return LoginResponse(identity_id=updated.id)
+151
View File
@@ -0,0 +1,151 @@
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import (
bind_identity_actor,
clear_auth_cookie,
get_request_actor,
get_session,
verify_identity_token,
)
from api.v2.routes.auth._common import _resolve_partner_snapshot
from api.v2.schemas.identities import (
ChangePasswordRequest,
IdentityResponse,
SetPasswordRequest,
)
from api.v2.schemas.web_public import AccountSummaryResponse
from database import (
get_balance,
get_keys,
get_trial,
identities as idb,
)
from database.models import CouponUsage, Gift, GiftUsage
from database.referrals import get_referral_stats
from database.web_notifications import count_unread_for_identity
from utils.referral_codes import encode_referral_code
router = APIRouter()
@router.get("/me", response_model=IdentityResponse)
async def me(
identity=Depends(verify_identity_token),
):
"""Текущая идентичность по HttpOnly cookie `auth_token`."""
return IdentityResponse.model_validate(identity)
@router.post("/logout")
async def logout(
request: Request,
response: Response,
):
"""Очищает auth cookie. Не требует валидной сессии — всегда возвращает ok."""
clear_auth_cookie(response, request)
return {"ok": True}
@router.get("/summary", response_model=AccountSummaryResponse)
async def auth_summary(
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
actor = get_request_actor(request)
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
if billing_user_id is None:
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
balance = float(await get_balance(session, billing_user_id))
trial_status = await get_trial(session, billing_user_id)
keys = await get_keys(session, billing_user_id)
keys_total = len(keys) if keys else 0
gifts_sent_r = await session.execute(
select(func.count()).select_from(Gift).where(Gift.sender_user_id == billing_user_id)
)
gifts_sent = gifts_sent_r.scalar_one() or 0
gifts_claimed_r = await session.execute(
select(func.count()).select_from(GiftUsage).where(GiftUsage.user_id == billing_user_id)
)
gifts_claimed = gifts_claimed_r.scalar_one() or 0
coupons_r = await session.execute(
select(func.count()).select_from(CouponUsage).where(CouponUsage.user_id == billing_user_id)
)
coupons_used = coupons_r.scalar_one() or 0
ref = await get_referral_stats(session, billing_user_id)
partner = await _resolve_partner_snapshot(session, int(billing_user_id))
unread_notifications = await count_unread_for_identity(session, identity.id)
return AccountSummaryResponse(
identity_id=identity.id,
email=identity.email,
tg_id=identity.tg_id,
linked_telegram=identity.tg_id is not None,
referral_code=encode_referral_code(int(billing_user_id)),
balance=balance,
trial_status=int(trial_status),
keys_total=keys_total,
referrals_total=int(ref.get("total_referrals") or 0),
referrals_active=int(ref.get("active_referrals") or 0),
referral_bonus_total=float(ref.get("total_referral_bonus") or 0),
gifts_sent=int(gifts_sent),
gifts_claimed=int(gifts_claimed),
coupons_used=int(coupons_used),
partner_enabled=bool(partner.get("partner_enabled", False)),
partner_code=str(partner.get("partner_code") or ""),
partner_balance=float(partner.get("partner_balance") or 0.0),
partner_percent=float(partner.get("partner_percent") or 0.0),
partner_percent_custom=bool(partner.get("partner_percent_custom", False)),
partner_referred_total=int(partner.get("partner_referred_total") or 0),
partner_payout_method=partner.get("partner_payout_method"),
unread_notifications=int(unread_notifications),
)
@router.post("/set-password")
async def set_password(
body: SetPasswordRequest,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
if body.password != body.password_confirm:
raise HTTPException(status_code=400, detail="Пароли не совпадают")
updated = await idb.set_initial_password(session, identity.id, body.password)
if not updated:
raise HTTPException(
status_code=409,
detail="Пароль уже установлен или аккаунт недоступен",
)
await bind_identity_actor(request, session, updated)
return {"ok": True}
@router.post("/change-password")
async def change_password(
body: ChangePasswordRequest,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
if body.password != body.password_confirm:
raise HTTPException(status_code=400, detail="Новые пароли не совпадают")
err = await idb.change_identity_password(
session,
identity.id,
body.current_password,
body.password,
)
if err == "no_password":
raise HTTPException(
status_code=409,
detail="Пароль ещё не установлен. Сначала задайте пароль в кабинете.",
)
if err == "wrong_password":
raise HTTPException(status_code=401, detail="Неверный текущий пароль")
refreshed = await idb.get_identity_by_id(session, identity.id)
if refreshed:
await bind_identity_actor(request, session, refreshed)
return {"ok": True}
+101
View File
@@ -0,0 +1,101 @@
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import (
BaseModel,
Field as PydanticField,
)
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import (
bind_identity_actor,
get_session,
set_auth_cookie,
set_is_admin_cookie,
verify_identity_token,
)
from api.v2.routes.auth._common import TELEGRAM_LOGIN_MAX_AGE, TOKEN_TTL_HINT, _client_ip
from api.v2.schemas.identities import (
IdentityResponse,
LinkTelegramRequest,
LoginResponse,
LoginTelegramRequest,
)
from config import API_TOKEN
from database import identities as idb
from logger import logger
from utils.telegram_login import verify_telegram_login
router = APIRouter()
class LoginTelegramWebAppRequest(BaseModel):
init_data: str = PydanticField(..., min_length=1)
@router.post("/login-telegram", response_model=LoginResponse)
async def login_telegram(
body: LoginTelegramRequest,
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
):
(
"""Вход через Telegram Login Widget (кнопка на сайте). По tg_id находим или создаём Identity, выдаём токен. Срок действия токена: """
+ TOKEN_TTL_HINT
+ "."
)
payload = body.model_dump(mode="json")
if not verify_telegram_login(payload, API_TOKEN, max_age_seconds=TELEGRAM_LOGIN_MAX_AGE):
raise HTTPException(status_code=401, detail="Неверная подпись или устаревшие данные от Telegram")
identity = await idb.get_or_create_identity_for_tg(session, body.id)
await bind_identity_actor(request, session, identity)
token = await idb.issue_token_for_identity(session, identity)
logger.info("[Auth] Login success: identity={}, tg_id={}, ip={}, method=telegram_widget", identity.id, body.id, _client_ip(request))
set_auth_cookie(response, token, request)
set_is_admin_cookie(response, identity, request)
return LoginResponse(identity_id=identity.id)
@router.post("/login-telegram-webapp", response_model=LoginResponse)
async def login_telegram_webapp(
body: LoginTelegramWebAppRequest,
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
):
"""Вход через Telegram WebApp initData. Валидирует HMAC, находит/создаёт Identity по tg_id."""
from utils.telegram_login import verify_webapp_init_data
result = verify_webapp_init_data(body.init_data, API_TOKEN)
if not result:
raise HTTPException(status_code=401, detail="Неверная подпись initData")
tg_id = result.get("user_id")
if not tg_id:
raise HTTPException(status_code=401, detail="Не удалось определить пользователя из initData")
identity = await idb.get_or_create_identity_for_tg(session, int(tg_id))
await bind_identity_actor(request, session, identity)
token = await idb.issue_token_for_identity(session, identity)
logger.info("[Auth] Login success: identity={}, tg_id={}, ip={}, method=telegram_webapp", identity.id, tg_id, _client_ip(request))
set_auth_cookie(response, token, request)
set_is_admin_cookie(response, identity, request)
return LoginResponse(identity_id=identity.id)
@router.post("/link-telegram", response_model=IdentityResponse)
async def link_telegram(
body: LinkTelegramRequest,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Привязывает Telegram к текущей идентичности. Требуется подпись от Telegram Login Widget (доказательство владения аккаунтом)."""
payload = body.model_dump(mode="json")
if not verify_telegram_login(payload, API_TOKEN, max_age_seconds=TELEGRAM_LOGIN_MAX_AGE):
raise HTTPException(status_code=401, detail="Неверная подпись или устаревшие данные от Telegram")
result = await idb.attach_telegram(session, identity.id, body.id)
if not result:
raise HTTPException(
status_code=409,
detail="Этот Telegram уже привязан к другой идентичности",
)
await bind_identity_actor(request, session, result)
return IdentityResponse.model_validate(result)
+31
View File
@@ -0,0 +1,31 @@
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from services.coupons import resolve_percent_coupon
from services.errors import ServiceError
async def resolve_percent_coupon_pricing(
session: AsyncSession,
billing_user_id: int,
base_price_rub: int,
coupon_code: str | None,
) -> tuple[int, int, int | None, str | None]:
"""Применяет процентный купон. Бросает HTTPException при ошибке."""
try:
return await resolve_percent_coupon(
session=session,
billing_user_id=billing_user_id,
base_price_rub=base_price_rub,
coupon_code=coupon_code,
)
except ServiceError as e:
status_map = {
"not_found": 404,
"limit_exceeded": 409,
"validation_error": 400,
}
raise HTTPException(
status_code=status_map.get(e.code, 400),
detail=e.message,
)
+57 -1
View File
@@ -1,8 +1,14 @@
from fastapi import APIRouter
from fastapi import Depends, HTTPException, Request
from api.v2.base_crud import generate_crud_router
from api.v2.schemas import CouponBase, CouponResponse, CouponUpdate
from api.v2.schemas.web_public import CouponApplyRequest, CouponApplyResponse
from api.depends import get_request_actor, get_session, verify_identity_token
from database import identities as idb
from database.models import Coupon
from services.coupons import apply_fixed_coupon
from services.errors import LimitExceededError, NotFoundError, ServiceError, ValidationError
from sqlalchemy.ext.asyncio import AsyncSession
router = generate_crud_router(
model=Coupon,
@@ -13,3 +19,53 @@ router = generate_crud_router(
parameter_name="code",
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
)
async def _resolve_coupon_user_id(session: AsyncSession, request: Request, identity) -> tuple[int, int | None]:
actor = get_request_actor(request)
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
if billing_user_id is None:
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
tg_id = actor.telegram_chat_id if actor else None
return int(billing_user_id), tg_id
def _service_error_to_http(e: ServiceError) -> HTTPException:
status_map = {
"not_found": 404,
"limit_exceeded": 409,
"validation_error": 400,
"forbidden": 403,
}
return HTTPException(status_code=status_map.get(e.code, 400), detail=e.message)
@router.post("/apply", response_model=CouponApplyResponse)
async def apply_coupon(
body: CouponApplyRequest,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
user_id, tg_id = await _resolve_coupon_user_id(session, request, identity)
try:
result = await apply_fixed_coupon(
session=session,
user_id=user_id,
tg_id=tg_id,
code=str(body.code or ""),
)
await session.commit()
return CouponApplyResponse(
ok=True,
message="Купон успешно активирован",
coupon_code=result.coupon_code,
amount=result.amount,
balance=result.balance,
)
except ServiceError as e:
await session.rollback()
raise _service_error_to_http(e)
except Exception:
await session.rollback()
raise HTTPException(status_code=500, detail="Ошибка активации купона")
+115
View File
@@ -0,0 +1,115 @@
from __future__ import annotations
from datetime import datetime, UTC
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin
from api.v2.schemas.flows import FlowCreate, FlowResponse, FlowUpdate
from database.models import WebFlow
router = APIRouter()
def _flow_to_response(flow: WebFlow) -> FlowResponse:
return FlowResponse(
id=flow.id,
name=flow.name,
nodes=flow.nodes or [],
edges=flow.edges or [],
entry_node_id=flow.entry_node_id,
version=flow.version,
)
@router.get("/flows/{flow_id}", response_model=FlowResponse)
async def get_flow_public(flow_id: str, session: AsyncSession = Depends(get_session)):
flow = await session.get(WebFlow, flow_id)
if not flow:
raise HTTPException(404, "Flow not found")
return _flow_to_response(flow)
@router.get("/admin/flows", response_model=list[FlowResponse])
async def list_flows(
session: AsyncSession = Depends(get_session),
_identity=Depends(verify_identity_admin),
):
result = await session.execute(select(WebFlow))
return [_flow_to_response(f) for f in result.scalars().all()]
@router.get("/admin/flows/{flow_id}", response_model=FlowResponse)
async def get_flow_admin(
flow_id: str,
session: AsyncSession = Depends(get_session),
_identity=Depends(verify_identity_admin),
):
flow = await session.get(WebFlow, flow_id)
if not flow:
raise HTTPException(404, "Flow not found")
return _flow_to_response(flow)
@router.post("/admin/flows", response_model=FlowResponse, status_code=201)
async def create_flow(
body: FlowCreate,
session: AsyncSession = Depends(get_session),
_identity=Depends(verify_identity_admin),
):
existing = await session.get(WebFlow, body.id)
if existing:
raise HTTPException(409, "Flow with this ID already exists")
flow = WebFlow(
id=body.id,
name=body.name,
nodes=[n.model_dump() for n in body.nodes],
edges=[e.model_dump() for e in body.edges],
entry_node_id=body.entry_node_id,
version=1,
updated_at=datetime.now(UTC),
)
session.add(flow)
await session.commit()
await session.refresh(flow)
return _flow_to_response(flow)
@router.put("/admin/flows/{flow_id}", response_model=FlowResponse)
async def update_flow(
flow_id: str,
body: FlowUpdate,
session: AsyncSession = Depends(get_session),
_identity=Depends(verify_identity_admin),
):
flow = await session.get(WebFlow, flow_id)
if not flow:
raise HTTPException(404, "Flow not found")
if body.name is not None:
flow.name = body.name
flow.nodes = [n.model_dump() for n in body.nodes]
flow.edges = [e.model_dump() for e in body.edges]
flow.entry_node_id = body.entry_node_id
flow.version = flow.version + 1
flow.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(flow)
return _flow_to_response(flow)
@router.delete("/admin/flows/{flow_id}", status_code=204)
async def delete_flow(
flow_id: str,
session: AsyncSession = Depends(get_session),
_identity=Depends(verify_identity_admin),
):
flow = await session.get(WebFlow, flow_id)
if not flow:
raise HTTPException(404, "Flow not found")
await session.delete(flow)
await session.commit()
+274 -5
View File
@@ -1,14 +1,251 @@
from fastapi import APIRouter, Depends, HTTPException, Path
from sqlalchemy import delete, select
from math import ceil
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin
from api.depends import (
get_request_actor,
get_session,
validate_redirect_url,
verify_identity_admin,
verify_identity_token,
)
from api.v2.base_crud import generate_crud_router
from api.v2.routes.tariffs import _resolve_default_web_payment_provider, _resolve_public_base_url
from api.v2.schemas import GiftBase, GiftResponse, GiftUpdate, GiftUsageResponse
from database.models import Gift, GiftUsage
from api.v2.schemas.web_public import (
GiftCreatePreviewResponse,
GiftCreateRequest,
GiftCreateResponse,
GiftRedeemRequest,
GiftRedeemResponse,
GiftUsageEntry,
MyGiftItem,
MyGiftsResponse,
)
from config import GIFT_BUTTON
from core.bootstrap import BUTTONS_CONFIG
from database import (
get_balance,
identities as idb,
)
from database.access.resolution import resolve_user_optional
from database.models import Gift, GiftUsage, Tariff
from database.tariffs import get_tariff_by_id
from database.temporary_data import create_temporary_data
from services.errors import NotFoundError, ValidationError
from services.formatting import get_site_gift_link
from services.gifts import create_gift as service_create_gift
from services.gifts import redeem_gift as service_redeem_gift
from services.payments.payment_links import PaymentLinkRequest, create_payment_link
from services.tariffs import calculate_config_price
router = APIRouter()
def _check_gifts_enabled():
if not bool(BUTTONS_CONFIG.get("GIFT_BUTTON_ENABLE", GIFT_BUTTON)):
raise HTTPException(status_code=403, detail="Подарки отключены")
@router.post("/create", tags=["Gifts"])
async def create_gift_for_user(
body: GiftCreateRequest,
request: Request,
preview: bool = Query(False),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
_check_gifts_enabled()
actor = get_request_actor(request)
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
if billing_user_id is None:
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
tariff = await get_tariff_by_id(session, body.tariff_id)
if not tariff or tariff.get("group_code") != "gifts" or not tariff.get("is_active", True):
raise HTTPException(status_code=404, detail="Тариф не найден")
price = int(calculate_config_price(tariff, body.selected_device_limit, body.selected_traffic_gb))
balance = float(await get_balance(session, billing_user_id))
required_amount = int(max(0, ceil(float(price) - balance)))
if preview:
return GiftCreatePreviewResponse(
ok=True,
price_rub=price,
balance_rub=balance,
sufficient_funds=balance >= price,
tariff_name=str(tariff.get("name", "")),
duration_days=int(tariff.get("duration_days") or 0),
)
if required_amount > 0:
provider_id = str(body.provider_id or _resolve_default_web_payment_provider() or "").strip().upper()
if not provider_id:
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
base_url = _resolve_public_base_url(request)
success_url = validate_redirect_url(str(body.success_url or ""), f"{base_url}/payment-success")
failure_url = validate_redirect_url(str(body.failure_url or ""), f"{base_url}/payment-failure")
payment_request = PaymentLinkRequest(
legacy_user_ref=int(billing_user_id),
amount=required_amount,
currency="RUB",
provider_id=provider_id,
success_url=success_url,
failure_url=failure_url,
metadata={
"payment_flow": "gift_create",
"tariff_id": int(body.tariff_id),
"selected_device_limit": body.selected_device_limit,
"selected_traffic_gb": body.selected_traffic_gb,
"selected_price_rub": int(price),
},
)
payment_result = await create_payment_link(session, payment_request)
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
await create_temporary_data(
session,
int(billing_user_id),
"waiting_for_payment",
{
"payment_flow": "gift_create",
"tariff_id": int(body.tariff_id),
"required_amount": int(required_amount),
"selected_price_rub": int(price),
"selected_device_limit": body.selected_device_limit,
"selected_traffic_gb": body.selected_traffic_gb,
},
)
return GiftCreateResponse(
ok=True,
message="Требуется оплата для создания подарка",
payment_required=True,
required_amount_rub=required_amount,
payment_id=payment_result.payment_id,
payment_url=payment_result.payment_url,
)
from services.errors import InsufficientFundsError, NotFoundError
try:
result = await service_create_gift(
session=session,
sender_user_ref=billing_user_id,
tariff_id=body.tariff_id,
selected_device_limit=body.selected_device_limit,
selected_traffic_gb=body.selected_traffic_gb,
selected_price_rub=price,
)
except InsufficientFundsError as e:
raise HTTPException(status_code=400, detail=str(e)) from None
except NotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from None
new_balance = float(await get_balance(session, billing_user_id))
return GiftCreateResponse(
ok=True,
message=f"Подарок создан — {result.tariff_name} на {result.duration_text}",
gift_id=result.gift_id,
site_gift_link=result.site_gift_link,
tariff_name=result.tariff_name,
duration_days=result.duration_days,
price_charged=result.price_charged,
balance_rub=new_balance,
)
@router.get("/my", response_model=MyGiftsResponse, tags=["Gifts"])
async def get_my_gifts(
request: Request,
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
_check_gifts_enabled()
actor = get_request_actor(request)
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
if billing_user_id is None:
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
base_filter = Gift.sender_user_id == billing_user_id
total = (await session.execute(select(func.count()).select_from(Gift).where(base_filter))).scalar_one()
result = await session.execute(
select(Gift).where(base_filter).order_by(Gift.created_at.desc()).limit(limit).offset(offset)
)
gifts = result.scalars().all()
tariff_ids = {g.tariff_id for g in gifts if g.tariff_id}
tariff_map: dict[int, str] = {}
duration_map: dict[int, int] = {}
if tariff_ids:
tariff_rows = await session.execute(select(Tariff).where(Tariff.id.in_(tariff_ids)))
for t in tariff_rows.scalars().all():
tariff_map[t.id] = t.name or ""
duration_map[t.id] = int(t.duration_days or 0)
gift_ids = [g.gift_id for g in gifts]
usages_map: dict[str, list[GiftUsageEntry]] = {gid: [] for gid in gift_ids}
if gift_ids:
usage_rows = await session.execute(select(GiftUsage).where(GiftUsage.gift_id.in_(gift_ids)))
for u in usage_rows.scalars().all():
usages_map.setdefault(u.gift_id, []).append(
GiftUsageEntry(
user_id=int(u.user_id),
used_at=u.used_at.isoformat() if u.used_at else None,
)
)
items = []
for g in gifts:
items.append(
MyGiftItem(
gift_id=g.gift_id,
tariff_name=tariff_map.get(g.tariff_id, ""),
duration_days=duration_map.get(g.tariff_id, 0),
price_rub=int(g.selected_price_rub or 0),
created_at=g.created_at.isoformat() if g.created_at else None,
expiry_time=g.expiry_time.isoformat() if g.expiry_time else None,
is_used=bool(g.is_used),
is_unlimited=bool(g.is_unlimited),
max_usages=g.max_usages,
site_gift_link=get_site_gift_link(g.gift_id),
usages=usages_map.get(g.gift_id, []),
)
)
return MyGiftsResponse(ok=True, gifts=items, total=total, limit=limit, offset=offset)
@router.delete("/my/{gift_id}", response_model=dict, tags=["Gifts"])
async def delete_my_gift(
request: Request,
gift_id: str = Path(...),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Удаляет свой подарок."""
actor = get_request_actor(request)
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
if billing_user_id is None:
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
result = await session.execute(select(Gift).where(Gift.gift_id == gift_id))
gift = result.scalar_one_or_none()
if not gift or gift.sender_user_id != billing_user_id:
raise HTTPException(status_code=404, detail="Подарок не найден")
await session.execute(delete(GiftUsage).where(GiftUsage.gift_id == gift_id))
await session.delete(gift)
await session.commit()
return {"ok": True, "message": "Подарок удалён"}
gift_router = generate_crud_router(
model=Gift,
schema_response=GiftResponse,
@@ -21,6 +258,35 @@ gift_router = generate_crud_router(
router.include_router(gift_router, prefix="", tags=["Gifts"])
@router.post("/redeem", response_model=GiftRedeemResponse, tags=["Gifts"])
async def redeem_gift(
body: GiftRedeemRequest,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
_check_gifts_enabled()
actor = get_request_actor(request)
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
if billing_user_id is None:
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
try:
result = await service_redeem_gift(session, body.gift_code, billing_user_id)
except ValidationError as e:
raise HTTPException(status_code=400, detail=str(e)) from None
except NotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from None
except Exception:
raise HTTPException(status_code=500, detail="Не удалось активировать подарок") from None
return GiftRedeemResponse(
ok=True,
message=result.message,
gift_id=result.gift_id,
tariff_id=result.tariff_id,
duration_days=result.duration_days,
)
@router.get("/by_tg_id/{tg_id}", response_model=list[GiftResponse], tags=["Gifts"])
async def get_gifts_by_tg_id(
tg_id: int = Path(...),
@@ -28,7 +294,10 @@ async def get_gifts_by_tg_id(
session: AsyncSession = Depends(get_session),
):
"""Список подарков по tg_id отправителя."""
result = await session.execute(select(Gift).where(Gift.sender_tg_id == tg_id))
u = await resolve_user_optional(session, tg_id)
if u is None:
raise HTTPException(status_code=404, detail="Gifts not found")
result = await session.execute(select(Gift).where(Gift.sender_user_id == u.id))
gifts = result.scalars().all()
if not gifts:
raise HTTPException(status_code=404, detail="Gifts not found")
+4
View File
@@ -0,0 +1,4 @@
from ._common import router, user_router
from . import admin, user # noqa: F401 — import triggers endpoint registration
__all__ = ["router", "user_router"]
+279
View File
@@ -0,0 +1,279 @@
import asyncio
import re
from base64 import b64encode
from datetime import datetime
from io import BytesIO
from math import ceil
from typing import Any
from urllib.parse import urlsplit
import qrcode
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import (
get_request_actor,
get_session,
validate_redirect_url,
verify_identity_admin,
verify_identity_token,
)
from api.v2.base_crud import generate_crud_router
from api.v2.routes.coupon_pricing import resolve_percent_coupon_pricing
from api.v2.schemas import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate
from api.v2.schemas.web_public import (
AccountKeyActionResponse,
AccountKeyActionsAvailability,
AccountKeyActionsConfigResponse,
AccountKeyAddonOptionResponse,
AccountKeyAddonsPreviewRequest,
AccountKeyAddonsPreviewResponse,
AccountKeyAliasUpdateRequest,
AccountKeyApplyAddonsResponse,
AccountKeyChangeLocationRequest,
AccountKeyChangeLocationResponse,
AccountKeyDetailsResponse,
AccountKeyLocationOptionResponse,
AccountKeyLocationsResponse,
AccountKeyQrResponse,
AccountKeyRenewRequest,
AccountKeyRenewResponse,
AccountKeyResetHwidResponse,
AccountKeyResponse,
)
from config import (
ENABLE_DELETE_KEY_BUTTON,
HWID_RESET_BUTTON,
INSTRUCTIONS_BUTTON,
QRCODE,
REMNAWAVE_LOGIN,
REMNAWAVE_PASSWORD,
USE_COUNTRY_SELECTION,
)
from core.bootstrap import BUTTONS_CONFIG, MODES_CONFIG, PAYMENTS_CONFIG, TARIFFS_CONFIG
from core.settings.tariffs_config import normalize_tariff_config
from database import (
check_server_name_by_cluster,
filter_cluster_by_subgroup,
get_balance,
get_key_details,
get_keys,
get_tariff_by_id,
identities as idb,
save_key_config_with_mode,
update_balance,
)
from database.access.resolution import resolve_user_optional
from database.coupons import mark_coupon_used
from database.models import Key, Server, ServerSpecialgroup, Tariff
from database.temporary_data import create_temporary_data
from handlers.buttons import CONNECT_DEVICE, ROUTER_BUTTON, TV_BUTTON
from handlers.keys.key_view import build_key_view_payload
from handlers.tariffs.addons.key_addons_pack import calc_pack_full_price_rub, get_pack_flags
from handlers.tariffs.addons.utils import calc_remaining_ratio_seconds, is_not_downgrade
from handlers.utils import ALLOWED_GROUP_CODES, is_full_remnawave_cluster
from logger import logger
from panels._3xui import delete_client, get_xui_instance
from panels.remnawave import RemnawaveAPI, get_vless_link_for_remnawave_by_username
from panels.remnawave_runtime import get_remnawave_profile, invalidate_remnawave_profile, with_remnawave_api
from services.operations import (
create_client_on_server,
create_key_on_cluster,
delete_key_from_cluster,
renew_key_in_cluster,
)
from services.operations.aggregated_links import make_aggregated_link
from services.payments.payment_links import PaymentLinkRequest, create_payment_link
from services.payments.providers import WEB_LINK_PROVIDER_IDS
from services.tariffs import calculate_config_price
from services.tariffs.tariff_display import GB, get_effective_limits_for_key, get_key_tariff_addons_state
router = generate_crud_router(
model=Key,
schema_response=KeyResponse,
schema_create=KeyBase,
schema_update=KeyUpdate,
identifier_field="tg_id",
extra_get_by_email=True,
enabled_methods=["get_all", "get_one", "get_by_email", "get_all_by_field"],
)
user_router = APIRouter()
def _key_actions_config() -> AccountKeyActionsConfigResponse:
addons_mode = str(TARIFFS_CONFIG.get("KEY_ADDONS_PACK_MODE", "") or "").strip().lower()
if addons_mode not in {"", "traffic", "devices", "all"}:
addons_mode = ""
addons_enabled_default = addons_mode in {"", "traffic", "devices", "all"}
return AccountKeyActionsConfigResponse(
renew_enabled=True,
delete_enabled=bool(BUTTONS_CONFIG.get("DELETE_KEY_BUTTON_ENABLE", ENABLE_DELETE_KEY_BUTTON)),
qr_enabled=bool(BUTTONS_CONFIG.get("QRCODE_BUTTON_ENABLE", QRCODE)),
hwid_reset_enabled=bool(BUTTONS_CONFIG.get("HWID_RESET_BUTTON_ENABLE", HWID_RESET_BUTTON)),
country_change_enabled=bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION)),
instructions_enabled=bool(BUTTONS_CONFIG.get("INSTRUCTIONS_BUTTON_ENABLE", INSTRUCTIONS_BUTTON)),
addons_enabled=addons_enabled_default,
addons_mode=addons_mode,
tv_connect_enabled=bool(BUTTONS_CONFIG.get("ANDROID_TV_BUTTON_ENABLE")),
)
def _extract_key_actions_from_markup(markup) -> AccountKeyActionsAvailability:
actions = AccountKeyActionsAvailability()
rows = getattr(markup, "inline_keyboard", None) or []
for row in rows:
for button in row:
callback_data = str(getattr(button, "callback_data", "") or "")
text = str(getattr(button, "text", "") or "")
has_url = bool(getattr(button, "url", None))
has_web_app = bool(getattr(button, "web_app", None))
if callback_data.startswith("connect_router|") or text == ROUTER_BUTTON:
actions.can_connect_router = True
if callback_data.startswith("connect_tv|") or text == TV_BUTTON:
actions.can_connect_tv = True
if callback_data.startswith("connect_device|") or (text == CONNECT_DEVICE and (has_url or has_web_app)):
actions.can_connect_device = True
if callback_data.startswith("renew_key|"):
actions.can_renew = True
if callback_data.startswith("key_addons|"):
actions.can_addons = True
if callback_data.startswith("reset_hwid|"):
actions.can_reset_hwid = True
if callback_data.startswith("show_qr|"):
actions.can_qr = True
if callback_data.startswith("delete_key|"):
actions.can_delete = True
if callback_data.startswith("change_location|"):
actions.can_change_location = True
return actions
async def _resolve_available_location_servers(session: AsyncSession, db_key: Key) -> list[str]:
current_server = str(getattr(db_key, "server_id", "") or "")
if not current_server:
return []
cluster_info = await check_server_name_by_cluster(session, current_server)
if not cluster_info:
return []
cluster_name = str(cluster_info.get("cluster_name") or "")
if not cluster_name:
return []
q = (
select(
Server.id,
Server.server_name,
Server.api_url,
Server.panel_type,
Server.enabled,
Server.max_keys,
)
.where(Server.cluster_name == cluster_name)
.where(Server.server_name != current_server)
)
servers = [dict(m) for m in (await session.execute(q)).mappings().all()]
if not servers:
return []
server_ids = [s["id"] for s in servers if s.get("id") is not None]
groups_map: dict[int, list[str]] = {}
if server_ids:
r = await session.execute(
select(ServerSpecialgroup.server_id, ServerSpecialgroup.group_code).where(
ServerSpecialgroup.server_id.in_(server_ids)
)
)
for sid, gc in r.all():
groups_map.setdefault(int(sid), []).append(gc)
for server in servers:
sid_raw = server.get("id")
sid = int(sid_raw) if sid_raw is not None else -1
server["special_groups"] = [g for g in groups_map.get(sid, []) if g in ALLOWED_GROUP_CODES]
key_tariff_id = getattr(db_key, "tariff_id", None)
subgroup_title = None
tariff_dict = None
if key_tariff_id:
tariff_dict = await get_tariff_by_id(session, int(key_tariff_id))
if tariff_dict:
subgroup_title = tariff_dict.get("subgroup_title")
available_servers = [s for s in servers if bool(s.get("enabled", True))]
if subgroup_title and available_servers:
filtered_servers = await filter_cluster_by_subgroup(
session=session,
cluster=available_servers,
target_subgroup=str(subgroup_title).strip(),
cluster_id=cluster_name,
tariff_id=int(key_tariff_id) if key_tariff_id else None,
)
if filtered_servers:
available_servers = filtered_servers
else:
available_servers = []
if available_servers and tariff_dict:
special = None
gc = str(tariff_dict.get("group_code") or "").lower()
if gc and gc in ALLOWED_GROUP_CODES:
special = gc
if special:
bound_servers = [s for s in available_servers if special in (s.get("special_groups") or [])]
if bound_servers:
available_servers = bound_servers
names = sorted(
{
str(s.get("server_name") or "").strip()
for s in available_servers
if str(s.get("server_name") or "").strip()
}
)
return names
async def _resolve_billing_user_id(request: Request, identity, session: AsyncSession) -> int:
actor = get_request_actor(request)
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
if billing_user_id is None:
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
return int(billing_user_id)
def _resolve_public_base_url(request: Request) -> str:
origin = str(request.headers.get("origin") or "").strip()
if origin.startswith(("http://", "https://")):
return origin.rstrip("/")
referer = str(request.headers.get("referer") or request.headers.get("referrer") or "").strip()
if referer.startswith(("http://", "https://")):
parsed = urlsplit(referer)
if parsed.scheme and parsed.netloc:
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
forwarded_host = str(request.headers.get("x-forwarded-host") or "").strip()
host = forwarded_host or str(request.headers.get("host") or "").strip()
forwarded_proto = str(request.headers.get("x-forwarded-proto") or "").split(",", 1)[0].strip().lower()
scheme = forwarded_proto if forwarded_proto in {"http", "https"} else request.url.scheme
if host:
return f"{scheme}://{host}".rstrip("/")
return str(request.base_url).rstrip("/")
def _resolve_default_web_payment_provider() -> str | None:
for provider_id in WEB_LINK_PROVIDER_IDS:
if bool(PAYMENTS_CONFIG.get(provider_id)):
return provider_id
return WEB_LINK_PROVIDER_IDS[0] if WEB_LINK_PROVIDER_IDS else None
def _normalize_expiry_ms(raw_value: int | float | None) -> int:
if not raw_value:
return 0
value = int(raw_value)
if value > 10**13:
value //= 1000
elif value < 10**10:
value *= 1000
return value
@@ -1,26 +1,5 @@
from datetime import datetime
from fastapi import Body, Depends, HTTPException, Path, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin
from api.v2.schemas import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate
from api.v2.base_crud import generate_crud_router
from database.models import Key, Tariff
from handlers.keys.operations import create_key_on_cluster, delete_key_from_cluster, renew_key_in_cluster
from logger import logger
router = generate_crud_router(
model=Key,
schema_response=KeyResponse,
schema_create=KeyBase,
schema_update=KeyUpdate,
identifier_field="tg_id",
extra_get_by_email=True,
enabled_methods=["get_all", "get_one", "get_by_email", "get_all_by_field"],
)
from ._common import * # noqa: F401,F403
from ._common import router, user_router # noqa: F401
@router.delete("/by_email/{email}", response_model=dict)
async def delete_key_by_email(
@@ -60,7 +39,10 @@ async def get_router_keys_by_tg_id(
tariff_ids = [row[0] for row in tariffs_result.all()]
if not tariff_ids:
return []
keys_result = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.tariff_id.in_(tariff_ids)))
u = await resolve_user_optional(session, tg_id)
if u is None:
return []
keys_result = await session.execute(select(Key).where(Key.user_id == u.id, Key.tariff_id.in_(tariff_ids)))
return keys_result.scalars().all()
@@ -132,3 +114,4 @@ async def create_key_api(
except Exception as e:
logger.error(f"[API] Ошибка при создании ключа: {e}")
raise HTTPException(status_code=500, detail="Ошибка при создании ключа")
+1
View File
@@ -0,0 +1 @@
from . import addons, core, hwid, location, renew # noqa: F401 — trigger registration
+619
View File
@@ -0,0 +1,619 @@
"""User-facing key endpoints (/api/keys/*).
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
из ``__init__.py`` запускает регистрацию декораторов.
"""
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
from .._common import (
_key_actions_config,
_resolve_available_location_servers,
_resolve_billing_user_id,
_resolve_default_web_payment_provider,
_resolve_public_base_url,
_normalize_expiry_ms,
router,
user_router,
)
@user_router.get("/{client_id}/addons-preview", response_model=AccountKeyAddonsPreviewResponse)
async def user_key_addons_preview(
client_id: str,
request: Request,
selected_device_limit: int | None = Query(None),
selected_traffic_gb: int | None = Query(None),
include_device: bool | None = Query(None),
include_traffic: bool | None = Query(None),
coupon_code: str | None = Query(None),
force_web: bool = Query(False),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
actions = _key_actions_config()
if not force_web and not actions.addons_enabled:
raise HTTPException(status_code=403, detail="Доп. опции отключены в настройках")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
)
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
tariff_id = getattr(db_key, "tariff_id", None)
if not tariff_id:
raise HTTPException(status_code=400, detail="Для подписки не назначен тариф")
tariff = await get_tariff_by_id(session, int(tariff_id))
if not tariff:
raise HTTPException(status_code=404, detail="Тариф не найден")
key_details = await get_key_details(session, str(getattr(db_key, "email", "") or ""))
if not key_details:
raise HTTPException(status_code=404, detail="Подписка не найдена")
(
_tariff_name,
_subgroup_title,
_traffic_limit_gb,
_device_limit,
_panel,
is_tariff_configurable,
addons_devices_enabled,
addons_traffic_enabled,
) = await get_key_tariff_addons_state(session=session, key_record=key_details, db_key=db_key)
if not is_tariff_configurable:
raise HTTPException(status_code=400, detail="Тариф не поддерживает доп. опции")
cfg = normalize_tariff_config(tariff)
raw_device_options = cfg.get("device_options") or tariff.get("device_options") or []
raw_traffic_options = cfg.get("traffic_options_gb") or tariff.get("traffic_options_gb") or []
device_options: list[int] = []
for value in raw_device_options:
try:
device_options.append(int(value))
except (TypeError, ValueError):
continue
traffic_options: list[int] = []
for value in raw_traffic_options:
try:
traffic_options.append(int(value))
except (TypeError, ValueError):
continue
device_options = sorted(set(device_options), key=lambda val: (int(val == 0), val))
traffic_options = sorted(set(traffic_options), key=lambda val: (int(val == 0), val))
has_device_option = bool(device_options) and bool(addons_devices_enabled)
has_traffic_option = bool(traffic_options) and bool(addons_traffic_enabled)
pack_devices, pack_traffic, pack_mode = get_pack_flags()
if pack_mode:
has_device_option = has_device_option and bool(pack_devices)
has_traffic_option = has_traffic_option and bool(pack_traffic)
if not has_device_option:
device_options = []
if not has_traffic_option:
traffic_options = []
if not has_device_option and not has_traffic_option:
raise HTTPException(status_code=400, detail="Доп. опции для этой подписки недоступны")
selected_device_limit_db = key_details.get("selected_device_limit")
selected_traffic_limit_db = key_details.get("selected_traffic_limit")
current_device_limit_db = key_details.get("current_device_limit")
current_traffic_limit_db = key_details.get("current_traffic_limit")
base_devices = tariff.get("device_limit")
base_devices = int(base_devices) if base_devices is not None else None
base_traffic_bytes = tariff.get("traffic_limit")
base_traffic_gb_from_tariff = int(base_traffic_bytes / GB) if base_traffic_bytes else None
current_device_limit = (
int(current_device_limit_db)
if current_device_limit_db is not None
else (int(selected_device_limit_db) if selected_device_limit_db is not None else base_devices)
)
current_traffic_gb = (
int(current_traffic_limit_db)
if current_traffic_limit_db is not None
else (int(selected_traffic_limit_db) if selected_traffic_limit_db is not None else base_traffic_gb_from_tariff)
)
if pack_mode and current_device_limit is not None and int(current_device_limit) == 0:
has_device_option = False
device_options = []
if pack_mode and current_traffic_gb is not None and int(current_traffic_gb) == 0:
has_traffic_option = False
traffic_options = []
if not has_device_option and not has_traffic_option:
raise HTTPException(status_code=400, detail="Доп. опции для этой подписки недоступны")
if pack_mode:
include_device_effective = bool(include_device) if include_device is not None else selected_device_limit is not None
include_traffic_effective = bool(include_traffic) if include_traffic is not None else selected_traffic_gb is not None
selected_device = (
selected_device_limit
if selected_device_limit is not None
else None
)
selected_traffic = (
selected_traffic_gb
if selected_traffic_gb is not None
else None
)
else:
include_device_effective = has_device_option
include_traffic_effective = has_traffic_option
selected_device = selected_device_limit if selected_device_limit is not None else current_device_limit
selected_traffic = selected_traffic_gb if selected_traffic_gb is not None else current_traffic_gb
if has_device_option and include_device_effective and selected_device is not None and int(selected_device) not in device_options:
raise HTTPException(status_code=400, detail="Выбранный пакет устройств недоступен")
if has_traffic_option and include_traffic_effective and selected_traffic is not None and int(selected_traffic) not in traffic_options:
raise HTTPException(status_code=400, detail="Выбранный пакет трафика недоступен")
current_devices_for_price = int(current_device_limit) if current_device_limit is not None else None
current_traffic_for_price = int(current_traffic_gb) if current_traffic_gb is not None else None
base_price_for_current = int(
calculate_config_price(
tariff=tariff,
selected_device_limit=current_devices_for_price,
selected_traffic_gb=current_traffic_for_price,
)
)
if pack_mode:
diff_full = int(
calc_pack_full_price_rub(
tariff=tariff,
has_device_option=bool(has_device_option and include_device_effective),
has_traffic_option=bool(has_traffic_option and include_traffic_effective),
selected_devices=int(selected_device) if has_device_option and include_device_effective and selected_device is not None else None,
selected_traffic_gb=int(selected_traffic) if has_traffic_option and include_traffic_effective and selected_traffic is not None else None,
)
)
recalc_enabled = bool(
MODES_CONFIG.get(
"KEY_ADDONS_RECALC_PRICE",
TARIFFS_CONFIG.get("KEY_ADDONS_RECALC_PRICE", False),
)
)
if recalc_enabled:
remaining_seconds, total_seconds = calc_remaining_ratio_seconds(
key_details.get("expiry_time"),
tariff,
)
extra_price_rub = int((diff_full * remaining_seconds + total_seconds - 1) // total_seconds)
else:
extra_price_rub = diff_full
total_price_rub = int(base_price_for_current + diff_full)
else:
total_price_rub = int(
calculate_config_price(
tariff=tariff,
selected_device_limit=int(selected_device) if has_device_option and include_device_effective and selected_device is not None else None,
selected_traffic_gb=int(selected_traffic) if has_traffic_option and include_traffic_effective and selected_traffic is not None else None,
)
)
extra_price_rub = int(max(0, total_price_rub - base_price_for_current))
final_extra_price_rub, discount_rub, _coupon_id, applied_coupon_code = await resolve_percent_coupon_pricing(
session=session,
billing_user_id=int(billing_user_id),
base_price_rub=int(max(0, extra_price_rub)),
coupon_code=coupon_code,
)
return AccountKeyAddonsPreviewResponse(
client_id=str(getattr(db_key, "client_id", "") or ""),
tariff_id=int(tariff_id),
addons_mode=str(pack_mode or ""),
has_device_option=bool(has_device_option),
has_traffic_option=bool(has_traffic_option),
current_device_limit=int(current_device_limit) if current_device_limit is not None else None,
current_traffic_gb=int(current_traffic_gb) if current_traffic_gb is not None else None,
selected_device_limit=int(selected_device) if has_device_option and include_device_effective and selected_device is not None else None,
selected_traffic_gb=int(selected_traffic) if has_traffic_option and include_traffic_effective and selected_traffic is not None else None,
device_options=[
AccountKeyAddonOptionResponse(
value=int(val),
label=(
"Безлимит устройств"
if int(val) <= 0
else (f"+{int(val)} устройств" if pack_mode else f"{int(val)} устройств")
),
)
for val in device_options
],
traffic_options=[
AccountKeyAddonOptionResponse(
value=int(val),
label=(
"Безлимит трафика"
if int(val) <= 0
else (f"+{int(val)} ГБ" if pack_mode else f"{int(val)} ГБ")
),
)
for val in traffic_options
],
total_price_rub=int(total_price_rub),
extra_price_rub=int(max(0, extra_price_rub)),
discount_rub=int(discount_rub),
final_price_rub=int(max(0, final_extra_price_rub)),
applied_coupon_code=applied_coupon_code,
balance_rub=float(await get_balance(session, int(billing_user_id))),
)
@user_router.post("/{client_id}/apply-addons", response_model=AccountKeyApplyAddonsResponse)
async def user_key_apply_addons(
client_id: str,
body: AccountKeyAddonsPreviewRequest,
request: Request,
force_web: bool = Query(False),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
actions = _key_actions_config()
if not force_web and not actions.addons_enabled:
raise HTTPException(status_code=403, detail="Доп. опции отключены в настройках")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
)
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
tariff_id = getattr(db_key, "tariff_id", None)
if not tariff_id:
raise HTTPException(status_code=400, detail="Для подписки не назначен тариф")
tariff = await get_tariff_by_id(session, int(tariff_id))
if not tariff:
raise HTTPException(status_code=404, detail="Тариф не найден")
key_details = await get_key_details(session, str(getattr(db_key, "email", "") or ""))
if not key_details:
raise HTTPException(status_code=404, detail="Подписка не найдена")
(
_tariff_name,
_subgroup_title,
_traffic_limit_gb,
_device_limit,
_panel,
is_tariff_configurable,
addons_devices_enabled,
addons_traffic_enabled,
) = await get_key_tariff_addons_state(session=session, key_record=key_details, db_key=db_key)
if not is_tariff_configurable:
raise HTTPException(status_code=400, detail="Тариф не поддерживает доп. опции")
cfg = normalize_tariff_config(tariff)
raw_device_options = cfg.get("device_options") or tariff.get("device_options") or []
raw_traffic_options = cfg.get("traffic_options_gb") or tariff.get("traffic_options_gb") or []
device_options: list[int] = []
for value in raw_device_options:
try:
device_options.append(int(value))
except (TypeError, ValueError):
continue
traffic_options: list[int] = []
for value in raw_traffic_options:
try:
traffic_options.append(int(value))
except (TypeError, ValueError):
continue
device_options = sorted(set(device_options), key=lambda val: (int(val == 0), val))
traffic_options = sorted(set(traffic_options), key=lambda val: (int(val == 0), val))
has_device_option = bool(device_options) and bool(addons_devices_enabled)
has_traffic_option = bool(traffic_options) and bool(addons_traffic_enabled)
pack_devices, pack_traffic, pack_mode = get_pack_flags()
if pack_mode:
has_device_option = has_device_option and bool(pack_devices)
has_traffic_option = has_traffic_option and bool(pack_traffic)
if not has_device_option:
device_options = []
if not has_traffic_option:
traffic_options = []
if not has_device_option and not has_traffic_option:
raise HTTPException(status_code=400, detail="Доп. опции для этой подписки недоступны")
selected_device_limit_db = key_details.get("selected_device_limit")
selected_traffic_limit_db = key_details.get("selected_traffic_limit")
current_device_limit_db = key_details.get("current_device_limit")
current_traffic_limit_db = key_details.get("current_traffic_limit")
base_devices = tariff.get("device_limit")
base_devices = int(base_devices) if base_devices is not None else None
base_traffic_bytes = tariff.get("traffic_limit")
base_traffic_gb_from_tariff = int(base_traffic_bytes / GB) if base_traffic_bytes else None
current_device_limit = (
int(current_device_limit_db)
if current_device_limit_db is not None
else (int(selected_device_limit_db) if selected_device_limit_db is not None else base_devices)
)
current_traffic_gb = (
int(current_traffic_limit_db)
if current_traffic_limit_db is not None
else (int(selected_traffic_limit_db) if selected_traffic_limit_db is not None else base_traffic_gb_from_tariff)
)
if pack_mode and current_device_limit is not None and int(current_device_limit) == 0:
has_device_option = False
device_options = []
if pack_mode and current_traffic_gb is not None and int(current_traffic_gb) == 0:
has_traffic_option = False
traffic_options = []
if not has_device_option and not has_traffic_option:
raise HTTPException(status_code=400, detail="Доп. опции для этой подписки недоступны")
if pack_mode:
include_device_effective = (
bool(body.include_device) if body.include_device is not None else body.selected_device_limit is not None
)
include_traffic_effective = (
bool(body.include_traffic) if body.include_traffic is not None else body.selected_traffic_gb is not None
)
selected_device = (
body.selected_device_limit
if body.selected_device_limit is not None
else None
)
selected_traffic = (
body.selected_traffic_gb
if body.selected_traffic_gb is not None
else None
)
else:
include_device_effective = has_device_option
include_traffic_effective = has_traffic_option
selected_device = body.selected_device_limit if body.selected_device_limit is not None else current_device_limit
selected_traffic = body.selected_traffic_gb if body.selected_traffic_gb is not None else current_traffic_gb
if has_device_option and include_device_effective and selected_device is not None and int(selected_device) not in device_options:
raise HTTPException(status_code=400, detail="Выбранный пакет устройств недоступен")
if has_traffic_option and include_traffic_effective and selected_traffic is not None and int(selected_traffic) not in traffic_options:
raise HTTPException(status_code=400, detail="Выбранный пакет трафика недоступен")
current_devices_for_price = int(current_device_limit) if current_device_limit is not None else None
current_traffic_for_price = int(current_traffic_gb) if current_traffic_gb is not None else None
base_price_for_current = int(
calculate_config_price(
tariff=tariff,
selected_device_limit=current_devices_for_price,
selected_traffic_gb=current_traffic_for_price,
)
)
total_price_after_purchase = base_price_for_current
if pack_mode:
diff_full = int(
calc_pack_full_price_rub(
tariff=tariff,
has_device_option=bool(has_device_option and include_device_effective),
has_traffic_option=bool(has_traffic_option and include_traffic_effective),
selected_devices=int(selected_device) if has_device_option and include_device_effective and selected_device is not None else None,
selected_traffic_gb=int(selected_traffic) if has_traffic_option and include_traffic_effective and selected_traffic is not None else None,
)
)
recalc_enabled = bool(
MODES_CONFIG.get(
"KEY_ADDONS_RECALC_PRICE",
TARIFFS_CONFIG.get("KEY_ADDONS_RECALC_PRICE", False),
)
)
if recalc_enabled:
remaining_seconds, total_seconds = calc_remaining_ratio_seconds(
key_details.get("expiry_time"),
tariff,
)
extra_price_rub = int((diff_full * remaining_seconds + total_seconds - 1) // total_seconds)
else:
extra_price_rub = diff_full
total_price_after_purchase = int(base_price_for_current + diff_full)
else:
selected_total_price = int(
calculate_config_price(
tariff=tariff,
selected_device_limit=int(selected_device) if has_device_option and selected_device is not None else None,
selected_traffic_gb=int(selected_traffic) if has_traffic_option and selected_traffic is not None else None,
)
)
extra_price_rub = int(max(0, selected_total_price - base_price_for_current))
total_price_after_purchase = selected_total_price
allow_downgrade = bool(TARIFFS_CONFIG.get("ALLOW_DOWNGRADE", True))
device_downgrade = (
allow_downgrade
and has_device_option
and current_device_limit is not None
and selected_device is not None
and not is_not_downgrade(current_device_limit, selected_device)
)
traffic_downgrade = (
allow_downgrade
and has_traffic_option
and current_traffic_gb is not None
and selected_traffic is not None
and not is_not_downgrade(current_traffic_gb, selected_traffic)
)
if device_downgrade or traffic_downgrade:
raise HTTPException(status_code=400, detail="Снижение параметров через сайт пока не поддерживается")
final_extra_price_rub, discount_rub, coupon_id, applied_coupon_code = await resolve_percent_coupon_pricing(
session=session,
billing_user_id=int(billing_user_id),
base_price_rub=int(max(0, extra_price_rub)),
coupon_code=body.coupon_code,
)
balance = float(await get_balance(session, int(billing_user_id)))
required_amount = int(max(0, ceil(float(final_extra_price_rub) - balance)))
if extra_price_rub <= 0:
return AccountKeyApplyAddonsResponse(
ok=True,
message="Доплата не требуется",
client_id=str(getattr(db_key, "client_id", "") or ""),
tariff_id=int(tariff_id),
total_price_rub=int(total_price_after_purchase),
extra_price_rub=0,
discount_rub=0,
final_price_rub=0,
applied_coupon_code=None,
charged_rub=0,
balance_rub=balance,
)
expiry_time = int(getattr(db_key, "expiry_time", 0) or 0)
email = str(getattr(db_key, "email", "") or "")
server_id = str(getattr(db_key, "server_id", "") or "")
if not email or not server_id:
raise HTTPException(status_code=400, detail="Некорректные данные подписки")
if required_amount > 0:
provider_id = str(body.provider_id or _resolve_default_web_payment_provider() or "").strip().upper()
if not provider_id:
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
base_url = _resolve_public_base_url(request)
success_url = validate_redirect_url(str(body.success_url or ""), f"{base_url}/payment-success")
failure_url = validate_redirect_url(str(body.failure_url or ""), f"{base_url}/payment-failure")
payment_request = PaymentLinkRequest(
legacy_user_ref=int(billing_user_id),
amount=required_amount,
currency="RUB",
provider_id=provider_id,
success_url=success_url,
failure_url=failure_url,
metadata={
"payment_flow": "key_addons",
"tariff_id": int(tariff_id),
"email": email,
"selected_device_limit": int(selected_device) if has_device_option and include_device_effective and selected_device is not None else None,
"selected_traffic_gb": int(selected_traffic) if has_traffic_option and include_traffic_effective and selected_traffic is not None else None,
"current_device_limit": int(current_device_limit) if current_device_limit is not None else None,
"current_traffic_gb": int(current_traffic_gb) if current_traffic_gb is not None else None,
"original_price": int(base_price_for_current),
"base_price_rub": int(max(0, extra_price_rub)),
"discount_rub": int(discount_rub),
"applied_coupon_code": applied_coupon_code,
"coupon_id": int(coupon_id) if coupon_id is not None else None,
},
)
payment_result = await create_payment_link(session, payment_request)
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
await create_temporary_data(
session,
int(billing_user_id),
"waiting_for_addons_payment",
{
"tariff_id": int(tariff_id),
"email": email,
"required_amount": int(required_amount),
"selected_device_limit": int(selected_device) if has_device_option and include_device_effective and selected_device is not None else None,
"selected_traffic_gb": int(selected_traffic) if has_traffic_option and include_traffic_effective and selected_traffic is not None else None,
"current_device_limit": int(current_device_limit) if current_device_limit is not None else None,
"current_traffic_gb": int(current_traffic_gb) if current_traffic_gb is not None else None,
"original_price": int(base_price_for_current),
"base_price_rub": int(max(0, extra_price_rub)),
"discount_rub": int(discount_rub),
"applied_coupon_code": applied_coupon_code,
"coupon_id": int(coupon_id) if coupon_id is not None else None,
},
)
return AccountKeyApplyAddonsResponse(
ok=True,
message="Требуется оплата для применения доп. опций",
client_id=str(getattr(db_key, "client_id", "") or ""),
tariff_id=int(tariff_id),
total_price_rub=int(total_price_after_purchase),
extra_price_rub=int(extra_price_rub),
discount_rub=int(discount_rub),
final_price_rub=int(final_extra_price_rub),
applied_coupon_code=applied_coupon_code,
charged_rub=0,
balance_rub=balance,
payment_required=True,
required_amount_rub=required_amount,
payment_id=payment_result.payment_id,
payment_url=payment_result.payment_url,
)
target_subgroup = tariff.get("subgroup_title")
current_subgroup = None
current_tariff_id = key_details.get("tariff_id")
if current_tariff_id:
current_tariff = await get_tariff_by_id(session, int(current_tariff_id))
if current_tariff:
current_subgroup = current_tariff.get("subgroup_title")
if pack_mode:
device_limit_effective_current, traffic_limit_bytes_effective_current = await get_effective_limits_for_key(
session=session,
tariff_id=int(tariff_id),
selected_device_limit=int(current_device_limit) if current_device_limit is not None else None,
selected_traffic_gb=int(current_traffic_gb) if current_traffic_gb is not None else None,
)
traffic_limit_gb_effective_current = (
int(traffic_limit_bytes_effective_current / GB) if traffic_limit_bytes_effective_current else 0
)
new_device_limit_effective = device_limit_effective_current
new_traffic_limit_gb_effective = traffic_limit_gb_effective_current
if has_device_option and include_device_effective and selected_device is not None:
pack_devices_val = int(selected_device)
if pack_devices_val <= 0 or (
new_device_limit_effective is not None and int(new_device_limit_effective) <= 0
):
new_device_limit_effective = 0
else:
if new_device_limit_effective is None:
new_device_limit_effective = pack_devices_val
else:
new_device_limit_effective = int(new_device_limit_effective) + pack_devices_val
if has_traffic_option and include_traffic_effective and selected_traffic is not None:
pack_traffic_val = int(selected_traffic)
if pack_traffic_val <= 0 or int(new_traffic_limit_gb_effective) <= 0:
new_traffic_limit_gb_effective = 0
else:
new_traffic_limit_gb_effective = int(new_traffic_limit_gb_effective) + pack_traffic_val
await renew_key_in_cluster(
cluster_id=server_id,
email=email,
client_id=str(getattr(db_key, "client_id", "") or ""),
new_expiry_time=expiry_time,
total_gb=int(new_traffic_limit_gb_effective),
session=session,
hwid_device_limit=int(new_device_limit_effective) if new_device_limit_effective is not None else 0,
reset_traffic=False,
target_subgroup=target_subgroup,
old_subgroup=current_subgroup,
plan=int(tariff_id),
)
await save_key_config_with_mode(
session=session,
email=email,
selected_devices=int(new_device_limit_effective) if new_device_limit_effective is not None else None,
selected_traffic_gb=int(new_traffic_limit_gb_effective) if new_traffic_limit_gb_effective is not None else None,
total_price=int(total_price_after_purchase),
has_device_choice=bool(has_device_option and include_device_effective),
has_traffic_choice=bool(has_traffic_option and include_traffic_effective),
config_mode="pack",
)
else:
selected_device_for_effective = int(selected_device) if has_device_option and include_device_effective and selected_device is not None else None
selected_traffic_for_effective = int(selected_traffic) if has_traffic_option and include_traffic_effective and selected_traffic is not None else 0
device_limit_effective_new, traffic_limit_bytes_effective_new = await get_effective_limits_for_key(
session=session,
tariff_id=int(tariff_id),
selected_device_limit=selected_device_for_effective,
selected_traffic_gb=selected_traffic_for_effective,
)
traffic_limit_gb_effective = int(traffic_limit_bytes_effective_new / GB) if traffic_limit_bytes_effective_new else 0
await renew_key_in_cluster(
cluster_id=server_id,
email=email,
client_id=str(getattr(db_key, "client_id", "") or ""),
new_expiry_time=expiry_time,
total_gb=int(traffic_limit_gb_effective),
session=session,
hwid_device_limit=int(device_limit_effective_new) if device_limit_effective_new is not None else 0,
reset_traffic=False,
target_subgroup=target_subgroup,
old_subgroup=current_subgroup,
plan=int(tariff_id),
)
await save_key_config_with_mode(
session=session,
email=email,
selected_devices=int(selected_device) if has_device_option and include_device_effective and selected_device is not None else None,
selected_traffic_gb=int(selected_traffic) if has_traffic_option and include_traffic_effective and selected_traffic is not None else None,
total_price=int(total_price_after_purchase),
has_device_choice=bool(has_device_option and include_device_effective),
has_traffic_choice=bool(has_traffic_option and include_traffic_effective),
config_mode="addon",
)
await update_balance(session, int(billing_user_id), -int(final_extra_price_rub))
if coupon_id is not None:
await mark_coupon_used(session, int(coupon_id), int(billing_user_id))
await session.commit()
return AccountKeyApplyAddonsResponse(
ok=True,
message="Доп. опции применены",
client_id=str(getattr(db_key, "client_id", "") or ""),
tariff_id=int(tariff_id),
total_price_rub=int(total_price_after_purchase),
extra_price_rub=int(extra_price_rub),
discount_rub=int(discount_rub),
final_price_rub=int(final_extra_price_rub),
applied_coupon_code=applied_coupon_code,
charged_rub=int(final_extra_price_rub),
balance_rub=float(await get_balance(session, int(billing_user_id))),
)
+249
View File
@@ -0,0 +1,249 @@
"""User-facing key endpoints (/api/keys/*).
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
из ``__init__.py`` запускает регистрацию декораторов.
"""
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
from .._common import (
_key_actions_config,
_resolve_available_location_servers,
_resolve_billing_user_id,
_resolve_default_web_payment_provider,
_resolve_public_base_url,
_normalize_expiry_ms,
router,
user_router,
)
@user_router.get("", response_model=list[AccountKeyResponse])
async def user_keys(
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
billing_user_id = await _resolve_billing_user_id(request, identity, session)
keys = await get_keys(session, billing_user_id)
result: list[AccountKeyResponse] = []
for key in keys:
key_actions = AccountKeyActionsAvailability()
try:
key_ref = str(getattr(key, "client_id", "") or getattr(key, "email", "") or "")
_, markup, _ = await build_key_view_payload(session, int(billing_user_id), key_ref)
key_actions = _extract_key_actions_from_markup(markup)
except Exception:
key_actions = AccountKeyActionsAvailability()
result.append(
AccountKeyResponse(
email=str(getattr(key, "email", "") or ""),
alias=getattr(key, "alias", None),
client_id=str(getattr(key, "client_id", "") or ""),
tariff_id=getattr(key, "tariff_id", None),
server_id=str(getattr(key, "server_id", "") or ""),
created_at=int(getattr(key, "created_at", 0) or 0),
expiry_time=int(getattr(key, "expiry_time", 0) or 0),
key=getattr(key, "key", None),
remnawave_link=getattr(key, "remnawave_link", None),
is_frozen=bool(getattr(key, "is_frozen", False)),
actions=key_actions,
)
)
return result
@user_router.get("/actions-config", response_model=AccountKeyActionsConfigResponse)
async def user_keys_actions_config(
identity=Depends(verify_identity_token),
):
_ = identity
return _key_actions_config()
@user_router.get("/{client_id}/details", response_model=AccountKeyDetailsResponse)
async def user_key_details(
client_id: str,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
)
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
key_details = await get_key_details(session, str(getattr(db_key, "email", "") or ""))
if not key_details:
raise HTTPException(status_code=404, detail="Подписка не найдена")
tariff_name = ""
subgroup_title = ""
traffic_limit_gb = 0
device_limit = 0
is_tariff_configurable = False
addons_devices_enabled = False
addons_traffic_enabled = False
(
tariff_name,
subgroup_title,
traffic_limit_gb,
device_limit,
_,
is_tariff_configurable,
addons_devices_enabled,
addons_traffic_enabled,
) = await get_key_tariff_addons_state(
session=session,
key_record=key_details,
db_key=db_key,
)
connected_devices = 0
used_traffic_gb = None
try:
profile = await get_remnawave_profile(
session,
str(getattr(db_key, "server_id", "") or ""),
client_id,
fallback_any=True,
)
if profile:
connected_devices = int(profile.get("hwid_count") or 0)
used_raw = profile.get("used_gb")
used_traffic_gb = float(used_raw) if used_raw is not None else None
traffic_limit_bytes_actual = profile.get("traffic_limit_bytes")
if traffic_limit_bytes_actual is not None:
try:
traffic_limit_bytes_actual = int(traffic_limit_bytes_actual)
traffic_limit_gb = int(traffic_limit_bytes_actual / GB) if traffic_limit_bytes_actual > 0 else 0
except (TypeError, ValueError):
pass
except Exception:
connected_devices = 0
used_traffic_gb = None
return AccountKeyDetailsResponse(
client_id=str(getattr(db_key, "client_id", "") or ""),
email=str(getattr(db_key, "email", "") or ""),
alias=getattr(db_key, "alias", None),
expiry_time=int(getattr(db_key, "expiry_time", 0) or 0),
is_frozen=bool(getattr(db_key, "is_frozen", False)),
tariff_name=str(tariff_name or ""),
subgroup_title=str(subgroup_title or ""),
traffic_limit_gb=int(traffic_limit_gb or 0),
used_traffic_gb=used_traffic_gb,
device_limit=int(device_limit or 0),
connected_devices=int(connected_devices or 0),
is_tariff_configurable=bool(is_tariff_configurable),
addons_devices_enabled=bool(addons_devices_enabled),
addons_traffic_enabled=bool(addons_traffic_enabled),
)
@user_router.get("/{client_id}/qr", response_model=AccountKeyQrResponse)
async def user_key_qr(
client_id: str,
request: Request,
force_web: bool = Query(False),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
actions = _key_actions_config()
if not force_web and not actions.qr_enabled:
raise HTTPException(status_code=403, detail="QR для подписок отключен в настройках")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
)
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
qr_data = str(getattr(db_key, "key", "") or "").strip() or str(getattr(db_key, "remnawave_link", "") or "").strip()
if not qr_data:
raise HTTPException(status_code=400, detail="Ссылка для подключения отсутствует")
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(qr_data)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = BytesIO()
img.save(buffer, format="PNG")
image_data = b64encode(buffer.getvalue()).decode("ascii")
return AccountKeyQrResponse(
ok=True,
message="QR-код готов",
link=qr_data,
image_data_url=f"data:image/png;base64,{image_data}",
)
@user_router.patch("/{client_id}/alias", response_model=AccountKeyResponse)
async def user_key_update_alias(
client_id: str,
body: AccountKeyAliasUpdateRequest,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
alias = str(body.alias or "").strip()
if not alias:
raise HTTPException(status_code=400, detail="Укажите alias")
if len(alias) > 10:
raise HTTPException(status_code=400, detail="Alias должен быть не длиннее 10 символов")
if not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9@._-]+$", alias):
raise HTTPException(status_code=400, detail="Alias содержит недопустимые символы")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
)
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
db_key.alias = alias
await session.commit()
return AccountKeyResponse(
email=str(getattr(db_key, "email", "") or ""),
alias=getattr(db_key, "alias", None),
client_id=str(getattr(db_key, "client_id", "") or ""),
tariff_id=getattr(db_key, "tariff_id", None),
server_id=str(getattr(db_key, "server_id", "") or ""),
created_at=int(getattr(db_key, "created_at", 0) or 0),
expiry_time=int(getattr(db_key, "expiry_time", 0) or 0),
key=getattr(db_key, "key", None),
remnawave_link=getattr(db_key, "remnawave_link", None),
is_frozen=bool(getattr(db_key, "is_frozen", False)),
)
@user_router.delete("/{client_id}", response_model=AccountKeyActionResponse)
async def user_key_delete(
client_id: str,
request: Request,
force_web: bool = Query(False),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
actions = _key_actions_config()
if not force_web and not actions.delete_enabled:
raise HTTPException(status_code=403, detail="Удаление подписки отключено в настройках")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
)
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
cluster_id = str(getattr(db_key, "server_id", "") or "")
email = str(getattr(db_key, "email", "") or "")
if cluster_id and email:
await delete_key_from_cluster(
cluster_id=cluster_id,
email=email,
client_id=client_id,
session=session,
)
await session.delete(db_key)
await session.commit()
return AccountKeyActionResponse(ok=True, message="Подписка удалена")
+75
View File
@@ -0,0 +1,75 @@
"""User-facing key endpoints (/api/keys/*).
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
из ``__init__.py`` запускает регистрацию декораторов.
"""
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
from .._common import (
_key_actions_config,
_resolve_available_location_servers,
_resolve_billing_user_id,
_resolve_default_web_payment_provider,
_resolve_public_base_url,
_normalize_expiry_ms,
router,
user_router,
)
@user_router.post("/{client_id}/reset-hwid", response_model=AccountKeyResetHwidResponse)
async def user_key_reset_hwid(
client_id: str,
request: Request,
force_web: bool = Query(False),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
actions = _key_actions_config()
if not force_web and not actions.hwid_reset_enabled:
raise HTTPException(status_code=403, detail="Сброс устройств отключен в настройках")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
)
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
server_id = str(getattr(db_key, "server_id", "") or "")
if not server_id:
raise HTTPException(status_code=400, detail="У подписки не указан сервер")
async def _reset_devices(api):
devices = await api.get_user_hwid_devices(client_id)
if not devices:
return 0, 0
reset_local = 0
for device in devices:
hwid = device.get("hwid")
if hwid and await api.delete_user_hwid_device(client_id, hwid):
reset_local += 1
return len(devices), reset_local
reset_result = await with_remnawave_api(
session,
server_id,
_reset_devices,
fallback_any=True,
timeout_sec=12.0,
)
if reset_result is None:
raise HTTPException(status_code=502, detail="Не удалось выполнить сброс устройств")
total_devices, reset_devices = reset_result
await invalidate_remnawave_profile(
session,
server_id,
str(client_id),
fallback_any=True,
)
return AccountKeyResetHwidResponse(
ok=True,
message="Устройства сброшены" if total_devices > 0 else "Устройства не были привязаны",
total_devices=int(total_devices),
reset_devices=int(reset_devices),
)
+230
View File
@@ -0,0 +1,230 @@
"""User-facing key endpoints (/api/keys/*).
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
из ``__init__.py`` запускает регистрацию декораторов.
"""
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
from .._common import (
_key_actions_config,
_resolve_available_location_servers,
_resolve_billing_user_id,
_resolve_default_web_payment_provider,
_resolve_public_base_url,
_normalize_expiry_ms,
router,
user_router,
)
@user_router.get("/{client_id}/locations", response_model=AccountKeyLocationsResponse)
async def user_key_locations(
client_id: str,
request: Request,
force_web: bool = Query(False),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
actions = _key_actions_config()
if not force_web and not actions.country_change_enabled:
raise HTTPException(status_code=403, detail="Смена локации отключена в настройках")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
)
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
names = await _resolve_available_location_servers(session, db_key)
return AccountKeyLocationsResponse(
client_id=str(getattr(db_key, "client_id", "") or ""),
current_server=str(getattr(db_key, "server_id", "") or ""),
locations=[AccountKeyLocationOptionResponse(server_name=name) for name in names],
)
@user_router.post("/{client_id}/change-location", response_model=AccountKeyChangeLocationResponse)
async def user_key_change_location(
client_id: str,
body: AccountKeyChangeLocationRequest,
request: Request,
force_web: bool = Query(False),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
actions = _key_actions_config()
if not force_web and not actions.country_change_enabled:
raise HTTPException(status_code=403, detail="Смена локации отключена в настройках")
target_server = str(body.server_name or "").strip()
if not target_server:
raise HTTPException(status_code=400, detail="Укажите целевую локацию")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
)
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
current_server = str(getattr(db_key, "server_id", "") or "")
if not current_server:
raise HTTPException(status_code=400, detail="У подписки не указан текущий сервер")
if current_server == target_server:
raise HTTPException(status_code=400, detail="Подписка уже в этой локации")
available_names = await _resolve_available_location_servers(session, db_key)
if target_server not in available_names:
raise HTTPException(status_code=400, detail="Выбранная локация недоступна")
email = str(getattr(db_key, "email", "") or "")
if not email:
raise HTTPException(status_code=400, detail="У подписки отсутствует email")
key_details = await get_key_details(session, email)
if not key_details:
raise HTTPException(status_code=404, detail="Подписка не найдена")
old_server_info = (
await session.execute(select(Server).where(Server.server_name == current_server).limit(1))
).scalar_one_or_none()
if old_server_info:
old_panel_type = str(getattr(old_server_info, "panel_type", "") or "").lower()
try:
if old_panel_type == "3x-ui":
xui = await get_xui_instance(str(getattr(old_server_info, "api_url", "") or ""))
await delete_client(
xui,
int(getattr(old_server_info, "inbound_id", 0) or 0),
email,
str(getattr(db_key, "client_id", "") or ""),
)
elif old_panel_type == "remnawave":
remna_del = RemnawaveAPI(str(getattr(old_server_info, "api_url", "") or ""))
if await remna_del.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
await remna_del.delete_user(str(getattr(db_key, "client_id", "") or ""))
except Exception:
pass
target_server_info = (
await session.execute(select(Server).where(Server.server_name == target_server).limit(1))
).scalar_one_or_none()
if target_server_info is None:
raise HTTPException(status_code=404, detail="Целевая локация не найдена")
tariff_id = getattr(db_key, "tariff_id", None)
tariff = await get_tariff_by_id(session, int(tariff_id)) if tariff_id else None
need_vless_key = bool(tariff.get("vless")) if tariff else False
external_squad_uuid = (tariff.get("external_squad") if tariff else None) or None
selected_traffic_gb = getattr(db_key, "selected_traffic_limit", None)
selected_device_limit = getattr(db_key, "selected_device_limit", None)
if selected_traffic_gb is not None:
traffic_limit_bytes = int(selected_traffic_gb) * GB
else:
raw_traffic_limit = int(tariff.get("traffic_limit") or 0) if tariff else 0
traffic_limit_bytes = raw_traffic_limit * GB if raw_traffic_limit > 0 else 0
if selected_device_limit is not None:
device_limit = int(selected_device_limit)
else:
device_limit = int(tariff.get("device_limit") or 0) if tariff else 0
key_client_id = str(getattr(db_key, "client_id", "") or "")
expiry_timestamp = int(getattr(db_key, "expiry_time", 0) or 0)
target_cluster_info = await check_server_name_by_cluster(session, target_server)
target_cluster_name = str((target_cluster_info or {}).get("cluster_name") or "")
full_remnawave_cluster = (
await is_full_remnawave_cluster(target_cluster_name, session) if target_cluster_name else False
)
panel_type = str(getattr(target_server_info, "panel_type", "") or "").lower()
remnawave_link = None
if panel_type == "remnawave" or full_remnawave_cluster:
remna = RemnawaveAPI(str(getattr(target_server_info, "api_url", "") or ""))
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
raise HTTPException(status_code=502, detail="Не удалось авторизоваться в Remnawave")
expire_at = datetime.utcfromtimestamp(expiry_timestamp / 1000).isoformat() + "Z"
user_data: dict[str, Any] = {
"username": email,
"trafficLimitStrategy": "NO_RESET",
"expireAt": expire_at,
"telegramId": int(key_details.get("tg_id") or 0),
"activeInternalSquads": [getattr(target_server_info, "inbound_id", None)],
"uuid": key_client_id,
}
if traffic_limit_bytes > 0:
user_data["trafficLimitBytes"] = traffic_limit_bytes
if device_limit > 0:
user_data["hwidDeviceLimit"] = device_limit
if external_squad_uuid:
user_data["externalSquadUuid"] = external_squad_uuid
result = await remna.create_user(user_data)
if not result:
raise HTTPException(status_code=502, detail="Не удалось создать подписку в новой локации")
key_client_id = str(result.get("uuid") or result.get("id") or key_client_id)
if need_vless_key:
try:
remnawave_link = await get_vless_link_for_remnawave_by_username(remna, email, email)
except Exception:
remnawave_link = None
if not remnawave_link:
try:
sub = await remna.get_subscription_by_username(email)
except Exception:
sub = None
if sub:
links = sub.get("links") or []
remnawave_link = (
next(
(link for link in links if isinstance(link, str) and link.lower().startswith("vless://")),
None,
)
if need_vless_key
else None
)
if not remnawave_link:
remnawave_link = sub.get("subscriptionUrl")
if panel_type == "3x-ui":
await create_client_on_server(
{
"api_url": str(getattr(target_server_info, "api_url", "") or ""),
"inbound_id": getattr(target_server_info, "inbound_id", None),
"server_name": str(getattr(target_server_info, "server_name", "") or ""),
"panel_type": str(getattr(target_server_info, "panel_type", "") or ""),
},
int(key_details.get("tg_id") or 0),
key_client_id,
email,
expiry_timestamp,
asyncio.Semaphore(1),
plan=int(tariff_id) if tariff_id else None,
session=session,
is_trial=False,
total_traffic_limit_bytes=traffic_limit_bytes,
device_limit_value=device_limit,
)
subgroup_code = tariff.get("subgroup_title") if tariff and tariff.get("subgroup_title") else None
public_link = await make_aggregated_link(
session=session,
cluster_all=[
{
"server_name": str(getattr(target_server_info, "server_name", "") or ""),
"api_url": str(getattr(target_server_info, "api_url", "") or ""),
"panel_type": str(getattr(target_server_info, "panel_type", "") or ""),
"inbound_id": getattr(target_server_info, "inbound_id", None),
"enabled": True,
"max_keys": getattr(target_server_info, "max_keys", None),
}
],
cluster_id=target_cluster_name or target_server,
email=email,
client_id=key_client_id,
tg_id=int(key_details.get("tg_id") or 0),
subgroup_code=subgroup_code,
remna_link_override=remnawave_link,
plan=int(tariff_id) if tariff_id else None,
)
db_key.server_id = target_server
db_key.client_id = key_client_id
db_key.key = public_link if isinstance(public_link, str) and public_link.strip() else None
db_key.remnawave_link = remnawave_link
await session.commit()
return AccountKeyChangeLocationResponse(
ok=True,
message="Локация успешно изменена",
client_id=str(getattr(db_key, "client_id", "") or ""),
server_id=str(getattr(db_key, "server_id", "") or ""),
link=str(getattr(db_key, "key", "") or ""),
remnawave_link=getattr(db_key, "remnawave_link", None),
)
+191
View File
@@ -0,0 +1,191 @@
"""User-facing key endpoints (/api/keys/*).
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
из ``__init__.py`` запускает регистрацию декораторов.
"""
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
from .._common import (
_key_actions_config,
_resolve_available_location_servers,
_resolve_billing_user_id,
_resolve_default_web_payment_provider,
_resolve_public_base_url,
_normalize_expiry_ms,
router,
user_router,
)
@user_router.post("/{client_id}/renew", response_model=AccountKeyRenewResponse)
async def user_key_renew(
client_id: str,
body: AccountKeyRenewRequest,
request: Request,
force_web: bool = Query(False),
preview: bool = Query(False),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
from services.errors import ServiceError
from services.keys import (
calculate_renewal_pricing,
execute_renewal,
normalize_expiry_ms as _svc_normalize_expiry,
)
actions = _key_actions_config()
if not force_web and not actions.renew_enabled:
raise HTTPException(status_code=403, detail="Продление подписки отключено в настройках")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(
select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1)
)
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
if bool(getattr(db_key, "is_frozen", False)):
raise HTTPException(status_code=400, detail="Продление для замороженной подписки недоступно")
tariff_id = getattr(db_key, "tariff_id", None)
if not tariff_id:
raise HTTPException(status_code=400, detail="Для подписки не назначен тариф")
key_email = str(getattr(db_key, "email", "") or "")
key_server_id = str(getattr(db_key, "server_id", "") or "")
try:
pricing = await calculate_renewal_pricing(
session=session,
billing_user_id=int(billing_user_id),
key_email=key_email,
tariff_id=int(tariff_id),
coupon_code=body.coupon_code,
)
except ServiceError as e:
raise HTTPException(status_code=400, detail=e.message)
if preview:
return AccountKeyRenewResponse(
ok=True,
message="Расчет обновлен",
client_id=str(client_id),
tariff_id=int(tariff_id),
charged_rub=0,
balance_rub=pricing.balance,
base_price_rub=pricing.base_price_rub,
discount_rub=pricing.discount_rub,
final_price_rub=pricing.final_price_rub,
applied_coupon_code=pricing.applied_coupon_code,
payment_required=pricing.payment_required,
required_amount_rub=pricing.required_amount,
payment_id=None,
payment_url=None,
)
if pricing.payment_required:
provider_id = str(body.provider_id or _resolve_default_web_payment_provider() or "").strip().upper()
if not provider_id:
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
base_url = _resolve_public_base_url(request)
success_url = validate_redirect_url(str(body.success_url or ""), f"{base_url}/payment-success")
failure_url = validate_redirect_url(str(body.failure_url or ""), f"{base_url}/payment-failure")
payment_request = PaymentLinkRequest(
legacy_user_ref=int(billing_user_id),
amount=pricing.required_amount,
currency="RUB",
provider_id=provider_id,
success_url=success_url,
failure_url=failure_url,
metadata={
"payment_flow": "key_renewal",
"tariff_id": int(tariff_id),
"client_id": str(client_id),
"email": key_email,
"cost": pricing.final_price_rub,
"selected_duration_days": pricing.duration_days,
"selected_device_limit": pricing.selected_device_limit,
"selected_traffic_limit": pricing.selected_traffic_limit,
"selected_price_rub": pricing.final_price_rub,
"total_gb": pricing.total_gb,
"base_price_rub": pricing.base_price_rub,
"discount_rub": pricing.discount_rub,
"applied_coupon_code": pricing.applied_coupon_code,
"coupon_id": pricing.coupon_id,
},
)
payment_result = await create_payment_link(session, payment_request)
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
await create_temporary_data(
session,
int(billing_user_id),
"waiting_for_renewal_payment",
{
"tariff_id": int(tariff_id),
"client_id": str(client_id),
"email": key_email,
"cost": pricing.final_price_rub,
"required_amount": pricing.required_amount,
"selected_duration_days": pricing.duration_days,
"selected_device_limit": pricing.selected_device_limit,
"selected_traffic_limit": pricing.selected_traffic_limit,
"selected_price_rub": pricing.final_price_rub,
"total_gb": pricing.total_gb,
"base_price_rub": pricing.base_price_rub,
"discount_rub": pricing.discount_rub,
"applied_coupon_code": pricing.applied_coupon_code,
"coupon_id": pricing.coupon_id,
},
)
return AccountKeyRenewResponse(
ok=True,
message="Требуется оплата для продления подписки",
client_id=str(client_id),
tariff_id=int(tariff_id),
charged_rub=0,
balance_rub=pricing.balance,
base_price_rub=pricing.base_price_rub,
discount_rub=pricing.discount_rub,
final_price_rub=pricing.final_price_rub,
applied_coupon_code=pricing.applied_coupon_code,
payment_required=True,
required_amount_rub=pricing.required_amount,
payment_id=payment_result.payment_id,
payment_url=payment_result.payment_url,
)
expiry_raw = _normalize_expiry_ms(getattr(db_key, "expiry_time", None))
now_ms = int(datetime.utcnow().timestamp() * 1000)
base_expiry = now_ms if expiry_raw <= now_ms else expiry_raw
new_expiry_time = int(base_expiry + pricing.duration_days * 24 * 60 * 60 * 1000)
if not key_email or not key_server_id:
raise HTTPException(status_code=400, detail="Некорректные данные подписки")
try:
result = await execute_renewal(
session=session,
billing_user_id=int(billing_user_id),
client_id=str(client_id),
key_email=key_email,
key_server_id=key_server_id,
tariff_id=int(tariff_id),
new_expiry_time=new_expiry_time,
total_gb=pricing.total_gb,
cost=float(pricing.final_price_rub),
selected_device_limit=pricing.selected_device_limit,
selected_traffic_limit=pricing.selected_traffic_limit,
selected_price_rub=pricing.final_price_rub,
coupon_id=pricing.coupon_id,
)
except ServiceError as e:
raise HTTPException(status_code=400, detail=e.message)
await session.commit()
return AccountKeyRenewResponse(
ok=True,
message="Подписка продлена",
client_id=result.client_id,
tariff_id=result.tariff_id,
charged_rub=result.charged_rub,
balance_rub=result.balance_rub,
base_price_rub=pricing.base_price_rub,
discount_rub=pricing.discount_rub,
final_price_rub=pricing.final_price_rub,
applied_coupon_code=pricing.applied_coupon_code,
)
+24 -5
View File
@@ -3,10 +3,12 @@ import os
import re
import subprocess
import sys
from datetime import datetime, timezone, timedelta
from datetime import datetime, timedelta, timezone
from typing import Literal
import psutil
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
@@ -23,10 +25,11 @@ from api.v2.schemas.audit import (
)
from audit import drain_audit_redis_to_db, get_audit_funnel, get_audit_stats, list_audit_events
from config import API_TOKEN, BOT_SERVICE
from database import async_session_maker
from core.bootstrap import MANAGEMENT_CONFIG
from core.executor import run_io
from core.redis_cache import cache_incr
from core.settings.management_config import update_management_config
from database import async_session_maker
from database.models import Key, ScheduledBroadcast, Server, User
from database.scheduled_broadcasts import (
cancel_scheduled_broadcast,
@@ -48,9 +51,18 @@ from handlers.admin.sender.scheduled_service import (
from logger import logger
from utils.backup import backup_database
router = APIRouter()
async def _admin_rate_limit(request_or_identity, action: str, max_calls: int, window_sec: int) -> None:
identity_id = getattr(request_or_identity, "id", "unknown")
key = f"admin_rl:{action}:{identity_id}"
count = await cache_incr(key, window_sec)
if count > max_calls:
raise HTTPException(status_code=429, detail="Слишком много запросов. Попробуйте позже.")
class MaintenanceUpdate(BaseModel):
enabled: bool
@@ -174,6 +186,7 @@ async def restart_bot(
identity=Depends(verify_identity_admin),
):
"""Запуск перезапуска бота в фоне."""
await _admin_rate_limit(identity, "restart", max_calls=3, window_sec=60)
background.add_task(_restart_bot)
return {"status": "restarting"}
@@ -185,6 +198,7 @@ async def change_domain(
session: AsyncSession = Depends(get_session),
):
"""Массовая замена домена в ключах и remnawave_link."""
await _admin_rate_limit(identity, "change_domain", max_calls=3, window_sec=300)
domain = payload.domain.strip()
if not domain or " " in domain or not re.fullmatch(r"[a-zA-Z0-9.-]+", domain):
raise HTTPException(status_code=400, detail="Invalid domain")
@@ -211,11 +225,12 @@ async def restore_trials(
session: AsyncSession = Depends(get_session),
):
"""Сбрасывает trial=0 у пользователей без ключей."""
await _admin_rate_limit(identity, "restore_trials", max_calls=3, window_sec=300)
stmt = (
update(User)
.where(
User.trial == 1,
~exists(select(Key.tg_id).where(Key.tg_id == User.tg_id)),
~exists(select(Key.user_id).where(Key.user_id == User.id)),
)
.values(trial=0)
)
@@ -227,6 +242,7 @@ async def restore_trials(
@router.post("/backup")
async def trigger_backup(identity=Depends(verify_identity_admin)):
"""Запуск бэкапа БД в фоне."""
await _admin_rate_limit(identity, "backup", max_calls=2, window_sec=300)
async def _run_backup() -> None:
exception = await backup_database()
@@ -357,7 +373,7 @@ async def post_audit_drain(identity=Depends(verify_identity_admin_short)):
return {"success": True, "drained": count}
except Exception as exc:
logger.warning("audit-drain failed: {}", exc)
raise HTTPException(status_code=500, detail=str(exc)) from exc
raise HTTPException(status_code=500, detail="Внутренняя ошибка при дренаже аудита") from exc
@router.post("/broadcast")
@@ -366,6 +382,7 @@ async def launch_broadcast(
identity=Depends(verify_identity_admin_short),
):
"""Запуск рассылки по выбранной аудитории. Сессия БД не держится на время рассылки."""
await _admin_rate_limit(identity, "broadcast", max_calls=5, window_sec=300)
try:
prepared = prepare_broadcast_payload(
send_to=payload.send_to,
@@ -475,14 +492,16 @@ async def send_broadcast_schedule_now(
identity=Depends(verify_identity_admin_short),
session: AsyncSession = Depends(get_session),
):
await _admin_rate_limit(identity, "broadcast_now", max_calls=5, window_sec=300)
item = await start_scheduled_broadcast(session, broadcast_id)
if item is None:
raise HTTPException(status_code=409, detail="Scheduled broadcast can no longer be sent now")
try:
result = await execute_scheduled_broadcast(item, bot=_get_broadcast_bot())
except Exception as exc:
logger.error("[Broadcast] send-now failed for {}: {}", broadcast_id, exc)
await mark_scheduled_broadcast_failed(session, broadcast_id, str(exc))
raise HTTPException(status_code=500, detail=str(exc)) from exc
raise HTTPException(status_code=500, detail="Ошибка при выполнении рассылки") from exc
if result.get("success"):
item = await mark_scheduled_broadcast_sent(session, broadcast_id, result)
else:
+17 -5
View File
@@ -13,6 +13,7 @@ from api.v2.schemas import (
)
from api.v2.base_crud import generate_crud_router
from database import get_tracking_source_stats
from database.access.resolution import resolve_user_optional
from database.models import (
BlockedUser,
ManualBan,
@@ -46,7 +47,10 @@ async def get_payments_by_tg_id(
session: AsyncSession = Depends(get_session),
):
"""Список платежей по tg_id пользователя."""
result = await session.execute(select(Payment).where(Payment.tg_id == tg_id))
u = await resolve_user_optional(session, tg_id)
if u is None:
raise HTTPException(status_code=404, detail="Payments not found")
result = await session.execute(select(Payment).where(Payment.user_id == u.id))
payments = result.scalars().all()
if not payments:
raise HTTPException(status_code=404, detail="Payments not found")
@@ -59,7 +63,9 @@ router.include_router(
schema_response=NotificationResponse,
schema_create=None,
schema_update=None,
identifier_field="tg_id",
identifier_field="user_id",
parameter_name="tg_id",
telegram_path_to_user_id=True,
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/notifications",
@@ -73,7 +79,9 @@ router.include_router(
schema_response=ManualBanResponse,
schema_create=None,
schema_update=None,
identifier_field="tg_id",
identifier_field="user_id",
parameter_name="tg_id",
telegram_path_to_user_id=True,
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/manual-bans",
@@ -87,7 +95,9 @@ router.include_router(
schema_response=BlockedUserResponse,
schema_create=None,
schema_update=None,
identifier_field="tg_id",
identifier_field="user_id",
parameter_name="tg_id",
telegram_path_to_user_id=True,
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/blocked-users",
@@ -101,7 +111,9 @@ router.include_router(
schema_response=TemporaryDataResponse,
schema_create=None,
schema_update=None,
identifier_field="tg_id",
identifier_field="user_id",
parameter_name="tg_id",
telegram_path_to_user_id=True,
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/temporary-data",
+5 -1
View File
@@ -1,4 +1,5 @@
import pkgutil
from pathlib import Path
from typing import Literal
@@ -7,9 +8,11 @@ from pydantic import BaseModel
from api.depends import verify_identity_admin
from core.executor import run_io
from logger import logger
from utils.modules_loader import _is_safe_module_name
from utils.modules_manager import manager
router = APIRouter(prefix="/modules", tags=["Modules"])
MODULES_DIR = Path(__file__).resolve().parents[3] / "modules"
@@ -117,5 +120,6 @@ async def control_module(module_name: str, payload: ModuleAction, identity=Depen
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
logger.error("[Modules] action failed for {}: {}", name, exc)
raise HTTPException(status_code=500, detail="Ошибка при выполнении операции модуля") from exc
return {"item": _module_state(name)}
+83
View File
@@ -0,0 +1,83 @@
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_token
from database.models import Identity
from database import web_notifications as wn_db
router = APIRouter()
class PushSubscribeRequest(BaseModel):
endpoint: str
keys: dict
class NotificationItem(BaseModel):
id: str
type: str
title: str
message: str
read: bool
created_at: str
data: dict | None = None
class NotificationsResponse(BaseModel):
ok: bool = True
notifications: list[NotificationItem]
unread_count: int
@router.post("/push/subscribe", tags=["Notifications"])
async def push_subscribe(
body: PushSubscribeRequest,
session: AsyncSession = Depends(get_session),
identity: Identity = Depends(verify_identity_token),
):
user_id = identity.tg_id or 0
await wn_db.upsert_push_subscription(
session,
user_id=user_id,
identity_id=identity.id,
endpoint=body.endpoint,
keys_json=body.keys,
)
return {"ok": True}
@router.get("/notifications", response_model=NotificationsResponse, tags=["Notifications"])
async def get_notifications(
limit: int = Query(20, ge=1, le=100),
session: AsyncSession = Depends(get_session),
identity: Identity = Depends(verify_identity_token),
):
notifications = await wn_db.get_notifications_for_identity(
session, identity.id, limit=limit,
)
unread_count = await wn_db.count_unread_for_identity(session, identity.id)
items = [
NotificationItem(
id=n.id,
type=n.type,
title=n.title,
message=n.message,
read=n.read,
created_at=n.created_at.isoformat() if n.created_at else "",
data=n.data,
)
for n in notifications
]
return NotificationsResponse(notifications=items, unread_count=unread_count)
@router.post("/notifications/read-all", tags=["Notifications"])
async def read_all_notifications(
session: AsyncSession = Depends(get_session),
identity: Identity = Depends(verify_identity_token),
):
count = await wn_db.mark_all_read_for_identity(session, identity.id)
return {"ok": True, "updated": count}
+465 -3
View File
@@ -1,14 +1,31 @@
import csv
from base64 import b64encode
from datetime import datetime
from io import StringIO
from io import BytesIO, StringIO
import re
from urllib.parse import urlsplit
from fastapi import APIRouter, Depends, Path, Query
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
from fastapi.responses import JSONResponse, StreamingResponse
import qrcode
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin
from api.depends import get_request_actor, get_session, verify_identity_admin, verify_identity_token
from api.v2.schemas.web_public import (
PartnerApplyRequest,
PartnerApplyResponse,
PartnerConditionsResponse,
PartnerQrResponse,
PartnerTopEntryResponse,
PartnerTopResponse,
PartnerPayoutEntryResponse,
PartnerPayoutHistoryResponse,
PartnerPayoutRequestCreate,
PartnerPayoutRequestResponse,
)
from database import identities as idb
from utils.referral_codes import decode_partner_code, encode_partner_code
try:
from modules.partner_program.settings import PARTNER_BONUS_PERCENTAGES
@@ -44,6 +61,451 @@ def _row_dt_iso(value) -> str | None:
return None
def _resolve_public_base_url(request: Request) -> str:
origin = str(request.headers.get("origin") or "").strip()
if origin.startswith("http://") or origin.startswith("https://"):
return origin.rstrip("/")
referer = str(request.headers.get("referer") or request.headers.get("referrer") or "").strip()
if referer.startswith("http://") or referer.startswith("https://"):
parsed = urlsplit(referer)
if parsed.scheme and parsed.netloc:
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
forwarded_host = str(request.headers.get("x-forwarded-host") or "").strip()
host = forwarded_host or str(request.headers.get("host") or "").strip()
forwarded_proto = str(request.headers.get("x-forwarded-proto") or "").split(",", 1)[0].strip().lower()
scheme = forwarded_proto if forwarded_proto in {"http", "https"} else request.url.scheme
if host:
return f"{scheme}://{host}".rstrip("/")
return str(request.base_url).rstrip("/")
async def _ensure_partner_code(session: AsyncSession, user_id: int, raw_code: str | None) -> str:
code = str(raw_code or "").strip()
if code and not code.isdigit() and not code.startswith("r1_"):
return code
generated = encode_partner_code(int(user_id))
try:
await session.execute(
text("UPDATE users SET partner_code = :code WHERE id = :id"),
{"code": generated, "id": int(user_id)},
)
await session.flush()
except Exception:
pass
return generated
async def _resolve_partner_user(session: AsyncSession, request: Request, identity) -> tuple[int, int]:
actor = get_request_actor(request)
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
if billing_user_id is None:
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
row = (
await session.execute(
text("SELECT id, tg_id FROM users WHERE id = :user_id LIMIT 1"),
{"user_id": int(billing_user_id)},
)
).first()
if row is None or row[1] is None:
raise HTTPException(status_code=400, detail="Партнерский профиль недоступен")
return int(row[0]), int(row[1])
async def _resolve_referrer_by_partner_code(session: AsyncSession, partner_code: str) -> tuple[int, int] | None:
code = str(partner_code or "").strip()
if not code:
return None
by_code_row = (
await session.execute(
text(
"""
SELECT id, tg_id
FROM users
WHERE lower(COALESCE(partner_code, '')) = lower(:code)
LIMIT 1
"""
),
{"code": code},
)
).first()
if by_code_row is not None:
user_id = int(by_code_row[0])
tg_id = int(by_code_row[1] if by_code_row[1] is not None else by_code_row[0])
return user_id, tg_id
decoded = decode_partner_code(code)
if decoded is None:
return None
by_id_row = (
await session.execute(
text("SELECT id, tg_id FROM users WHERE id = :id LIMIT 1"),
{"id": int(decoded)},
)
).first()
if by_id_row is not None:
user_id = int(by_id_row[0])
tg_id = int(by_id_row[1] if by_id_row[1] is not None else by_id_row[0])
return user_id, tg_id
by_tg_row = (
await session.execute(
text("SELECT id, tg_id FROM users WHERE tg_id = :tg_id LIMIT 1"),
{"tg_id": int(decoded)},
)
).first()
if by_tg_row is not None:
user_id = int(by_tg_row[0])
tg_id = int(by_tg_row[1] if by_tg_row[1] is not None else by_tg_row[0])
return user_id, tg_id
return None
@router.post("/apply", response_model=PartnerApplyResponse)
async def partner_apply(
body: PartnerApplyRequest,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
joined_user_id, joined_tg_id = await _resolve_partner_user(session, request, identity)
code_value = str(body.partner_code or "").strip()
referrer_user_id: int | None = None
referrer_tg_id: int | None = None
if code_value:
resolved = await _resolve_referrer_by_partner_code(session, code_value)
if resolved is not None:
referrer_user_id, referrer_tg_id = resolved
if referrer_tg_id is None and body.partner_tg_id is not None:
referrer_tg_id = int(body.partner_tg_id)
referrer_user_id_row = (
await session.execute(
text("SELECT id FROM users WHERE tg_id = :tg_id LIMIT 1"),
{"tg_id": int(referrer_tg_id)},
)
).first()
if referrer_user_id_row is not None:
referrer_user_id = int(referrer_user_id_row[0])
if referrer_tg_id is None:
raise HTTPException(status_code=400, detail="Партнерский код не найден")
if int(referrer_tg_id) == int(joined_tg_id):
raise HTTPException(status_code=400, detail="Нельзя применить свой партнерский код")
already_row = (
await session.execute(
text("SELECT partner_tg_id FROM partners WHERE joined_tg_id = :joined_tg_id LIMIT 1"),
{"joined_tg_id": int(joined_tg_id)},
)
).first()
if already_row is not None and already_row[0] is not None:
raise HTTPException(status_code=409, detail="Партнер уже привязан")
await session.execute(
text(
"""
INSERT INTO partners (partner_tg_id, joined_tg_id)
VALUES (:partner_tg_id, :joined_tg_id)
"""
),
{"partner_tg_id": int(referrer_tg_id), "joined_tg_id": int(joined_tg_id)},
)
await session.commit()
return PartnerApplyResponse(
ok=True,
message="Партнерский код применен",
partner_code=code_value,
partner_user_id=int(referrer_user_id or 0),
partner_tg_id=int(referrer_tg_id),
joined_user_id=int(joined_user_id),
joined_tg_id=int(joined_tg_id),
)
@router.get("/qr", response_model=PartnerQrResponse)
async def partner_qr(
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
user_id, _ = await _resolve_partner_user(session, request, identity)
code_row = (
await session.execute(
text("SELECT partner_code FROM users WHERE id = :id LIMIT 1"),
{"id": int(user_id)},
)
).first()
partner_code = await _ensure_partner_code(session, int(user_id), code_row[0] if code_row else None)
base_url = _resolve_public_base_url(request)
partner_link = f"{base_url}/partner/{partner_code}"
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(partner_link)
qr.make(fit=True)
image = qr.make_image(fill_color="black", back_color="white")
png_buffer = BytesIO()
image.save(png_buffer, format="PNG")
image_data = b64encode(png_buffer.getvalue()).decode("ascii")
return PartnerQrResponse(
ok=True,
link=partner_link,
image_data_url=f"data:image/png;base64,{image_data}",
)
@router.get("/top", response_model=PartnerTopResponse)
async def partner_top(
request: Request,
limit: int = Query(5, ge=1, le=20),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
_, joined_tg_id = await _resolve_partner_user(session, request, identity)
user_referred_count_row = (
await session.execute(
text(
"""
SELECT COUNT(DISTINCT joined_tg_id)
FROM partners
WHERE partner_tg_id = :partner_tg_id
"""
),
{"partner_tg_id": int(joined_tg_id)},
)
).first()
user_referred_count = int(user_referred_count_row[0] or 0) if user_referred_count_row else 0
user_position: int | None = None
if user_referred_count > 0:
user_position_row = (
await session.execute(
text(
"""
SELECT COUNT(*) + 1
FROM (
SELECT partner_tg_id, COUNT(DISTINCT joined_tg_id) AS referred_count
FROM partners
WHERE partner_tg_id IS NOT NULL
GROUP BY partner_tg_id
) ranked
WHERE ranked.referred_count > :referred_count
"""
),
{"referred_count": int(user_referred_count)},
)
).first()
user_position = int(user_position_row[0] or 1) if user_position_row else 1
top_rows = (
await session.execute(
text(
"""
SELECT
COALESCE(u.id, 0) AS partner_user_id,
p.partner_tg_id AS partner_tg_id,
COUNT(DISTINCT p.joined_tg_id) AS referred_count
FROM partners p
LEFT JOIN users u ON u.tg_id = p.partner_tg_id
WHERE p.partner_tg_id IS NOT NULL
GROUP BY p.partner_tg_id, u.id
ORDER BY referred_count DESC, p.partner_tg_id ASC
LIMIT :limit
"""
),
{"limit": int(limit)},
)
).all()
top: list[PartnerTopEntryResponse] = []
for index, row in enumerate(top_rows, 1):
partner_user_id = int(row[0] or 0)
partner_tg_id = int(row[1] or 0)
referred_count = int(row[2] or 0)
if partner_user_id > 0:
display_id = encode_partner_code(partner_user_id)
else:
tg_tail = str(partner_tg_id)
display_id = f"p_{tg_tail[:2]}***{tg_tail[-2:]}" if tg_tail else "p_***"
top.append(
PartnerTopEntryResponse(
position=index,
partner_user_id=partner_user_id,
referred_count=referred_count,
display_id=display_id,
)
)
return PartnerTopResponse(
user_referred_count=user_referred_count,
user_position=user_position,
top=top,
)
@router.get("/conditions", response_model=PartnerConditionsResponse)
async def partner_conditions(
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
try:
from modules.partner_program import settings as partner_settings
except Exception:
partner_settings = None
mode = str(getattr(partner_settings, "REFERRAL_REWARD_MODE", "percent_only") or "percent_only")
percent_levels_raw = getattr(partner_settings, "PARTNER_BONUS_PERCENTAGES", {}) or {}
flat_levels_raw = getattr(partner_settings, "PARTNER_FLAT_BONUSES", {}) or {}
min_payout = float(getattr(partner_settings, "MIN_PARTNER_PAYOUT", 0) or 0)
custom_amount_enabled = bool(getattr(partner_settings, "ENABLE_CUSTOM_WITHDRAW_AMOUNT", False))
method_map = [
("ENABLE_PAYOUT_CARD", "Карта"),
("ENABLE_PAYOUT_SBP", "СБП"),
("ENABLE_PAYOUT_USDT", "USDT"),
("ENABLE_PAYOUT_TON", "TON"),
]
payout_methods = [
title for key, title in method_map if bool(getattr(partner_settings, key, False))
] if partner_settings else []
level_lines: list[str] = []
all_levels = sorted({int(k) for k in [*percent_levels_raw.keys(), *flat_levels_raw.keys()] if str(k).isdigit()})
for level in all_levels:
parts: list[str] = []
if level in percent_levels_raw:
try:
parts.append(f"{float(percent_levels_raw[level]) * 100:.0f}%")
except Exception:
pass
if level in flat_levels_raw:
try:
parts.append(f"{float(flat_levels_raw[level]):.0f} RUB")
except Exception:
pass
if parts:
level_lines.append(f"{level} уровень: {' + '.join(parts)}")
if not level_lines:
level_lines = ["1 уровень: бонус определяется настройками проекта"]
mode_labels = {
"percent_only": "Процент с каждого пополнения приглашенного",
"flat_only": "Фиксированный бонус за первую оплату приглашенного",
"flat_plus_percent": "Фиксированный бонус за первую оплату и процент с пополнений",
}
rules = [
"Вознаграждение начисляется только после успешной оплаты приглашенного пользователя.",
"Самореферал и самопартнерство недоступны.",
f"Минимальная сумма вывода: {min_payout:.0f} RUB." if min_payout > 0 else "Вывод доступен по правилам проекта.",
]
if payout_methods:
rules.append(f"Доступные способы вывода: {', '.join(payout_methods)}.")
examples = [
"Пример: приглашенный пополнил на 1000 RUB, а ставка 15% — вы получаете 150 RUB.",
"Пример: приглашенный сделал несколько пополнений, бонус считается по каждой успешной операции.",
]
return PartnerConditionsResponse(
title="Условия партнерской программы",
summary="Актуальные условия и режим начислений для партнеров.",
bonus_mode=mode,
bonus_mode_label=mode_labels.get(mode, mode_labels["percent_only"]),
level_lines=level_lines,
rules=rules,
examples=examples,
min_payout_rub=min_payout,
payout_methods=payout_methods,
custom_amount_enabled=custom_amount_enabled,
)
@router.get("/payouts/me", response_model=PartnerPayoutHistoryResponse)
async def partner_payouts_me(
request: Request,
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
_, tg_id = await _resolve_partner_user(session, request, identity)
count_sql = text("SELECT COUNT(*) FROM payout_requests WHERE tg_id = :tg_id")
rows_sql = text(
"""
SELECT id, amount, status, created_at, method, destination
FROM payout_requests
WHERE tg_id = :tg_id
ORDER BY created_at DESC, id DESC
LIMIT :limit OFFSET :offset
"""
)
total = int((await session.scalar(count_sql, {"tg_id": tg_id})) or 0)
rows = (
await session.execute(rows_sql, {"tg_id": tg_id, "limit": int(limit), "offset": int(offset)})
).fetchall()
items = [
PartnerPayoutEntryResponse(
id=int(row[0]),
amount_rub=float(row[1] or 0.0),
status=str(row[2] or ""),
created_at=_row_dt_iso(row[3]),
method=row[4] or None,
destination=row[5] or None,
)
for row in rows
]
return PartnerPayoutHistoryResponse(total=total, items=items)
@router.post("/payouts/me", response_model=PartnerPayoutRequestResponse)
async def partner_create_payout_request(
body: PartnerPayoutRequestCreate,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
user_id, tg_id = await _resolve_partner_user(session, request, identity)
row = (
await session.execute(
text("SELECT COALESCE(partner_balance, 0), payout_method, card_number FROM users WHERE id = :id"),
{"id": user_id},
)
).first()
balance = float(row[0] or 0.0) if row else 0.0
requested = float(body.amount_rub)
if requested <= 0:
raise HTTPException(status_code=400, detail="Сумма должна быть больше нуля")
try:
from modules.partner_program.settings import ENABLE_CUSTOM_WITHDRAW_AMOUNT, MIN_PARTNER_PAYOUT
except Exception:
ENABLE_CUSTOM_WITHDRAW_AMOUNT = True
MIN_PARTNER_PAYOUT = 0
min_payout = float(MIN_PARTNER_PAYOUT or 0)
if requested < min_payout:
raise HTTPException(status_code=400, detail=f"Минимальная сумма вывода — {min_payout:.0f} RUB")
if not bool(ENABLE_CUSTOM_WITHDRAW_AMOUNT):
requested = balance
if requested > balance:
raise HTTPException(status_code=400, detail="Недостаточно партнерского баланса")
if requested <= 0:
raise HTTPException(status_code=400, detail="Недостаточно средств для заявки")
payout_method = (row[1] if row else None) or "card"
destination = (row[2] if row else None) or None
inserted = (
await session.execute(
text(
"""
INSERT INTO payout_requests (tg_id, amount, status, created_at, method, destination)
VALUES (:tg_id, :amount, 'pending', NOW(), :method, :destination)
RETURNING id
"""
),
{
"tg_id": int(tg_id),
"amount": float(requested),
"method": payout_method,
"destination": destination,
},
)
).scalar()
new_balance = balance - requested
await session.execute(
text("UPDATE users SET partner_balance = :balance WHERE id = :id"),
{"balance": new_balance, "id": int(user_id)},
)
await session.commit()
return PartnerPayoutRequestResponse(
ok=True,
message="Заявка на вывод создана",
request_id=int(inserted) if inserted is not None else None,
amount_rub=float(requested),
status="pending",
balance_rub=float(new_balance),
)
@router.get("/all")
async def get_all_partners(
limit: int = Query(1000, ge=1, le=10000, description="Лимит результатов"),
+240 -23
View File
@@ -1,28 +1,143 @@
import asyncio
import json
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_token
from api.v2.schemas.payment_links import PaymentLinkCreateRequest, PaymentLinkCreateResponse
from database import identities as idb
from handlers.payments import create_payment_link
from handlers.payments.payment_links import PaymentLinkRequest
from api.v2.schemas.payment_links import PaymentLinkCreateRequest, PaymentLinkCreateResponse, PaymentLinkStatusResponse
from config import REDIS_URL
from database import (
async_session_maker,
get_payment_by_payment_id,
get_payment_from_db_by_payment_id,
identities as idb,
)
from database.temporary_data import create_temporary_data
from logger import logger
from services.payments.payment_events import payment_events_channel
from services.payments.payment_links import PaymentLinkRequest, create_payment_link
router = APIRouter(tags=["PaymentLinks"])
async def _resolve_tg_id(body: PaymentLinkCreateRequest, session: AsyncSession) -> int:
"""Возвращает tg_id из body.tg_id или из identity_id; иначе исключение."""
if body.tg_id is not None:
return body.tg_id
if body.identity_id:
tg_id = await idb.resolve_tg_id(session, body.identity_id)
if tg_id is not None:
return tg_id
raise HTTPException(
status_code=400,
detail="У идентичности не привязан Telegram. Привяжите tg_id для создания платёжной ссылки.",
)
raise HTTPException(status_code=400, detail="Укажите tg_id или identity_id")
async def _store_payment_intent(
session: AsyncSession,
billing_user_ref: int,
metadata: dict | None,
amount: int | float,
) -> None:
if not isinstance(metadata, dict):
return
payment_flow = str(metadata.get("payment_flow") or "").strip().lower()
required_amount = int(round(float(amount)))
if payment_flow == "tariff_purchase":
tariff_id = metadata.get("tariff_id")
if tariff_id in (None, ""):
return
payload: dict[str, int | str] = {
"tariff_id": int(tariff_id),
"required_amount": required_amount,
"selected_price_rub": int(metadata.get("selected_price_rub") or required_amount),
}
selected_device_limit = metadata.get("selected_device_limit")
if selected_device_limit not in (None, ""):
payload["selected_device_limit"] = int(selected_device_limit)
selected_traffic_gb = metadata.get("selected_traffic_gb")
if selected_traffic_gb not in (None, ""):
payload["selected_traffic_limit_gb"] = int(selected_traffic_gb)
selected_duration_days = metadata.get("selected_duration_days")
if selected_duration_days not in (None, ""):
payload["selected_duration_days"] = int(selected_duration_days)
coupon_id = metadata.get("coupon_id")
if coupon_id not in (None, ""):
payload["coupon_id"] = int(coupon_id)
discount_rub = metadata.get("discount_rub")
if discount_rub not in (None, ""):
payload["discount_rub"] = int(discount_rub)
base_price_rub = metadata.get("base_price_rub")
if base_price_rub not in (None, ""):
payload["base_price_rub"] = int(base_price_rub)
applied_coupon_code = metadata.get("applied_coupon_code")
if applied_coupon_code not in (None, ""):
payload["applied_coupon_code"] = str(applied_coupon_code)
await create_temporary_data(session, billing_user_ref, "waiting_for_payment", payload)
return
if payment_flow == "key_renewal":
required_fields = ("tariff_id", "client_id", "email", "cost")
if any(metadata.get(field) in (None, "") for field in required_fields):
return
payload: dict[str, int | str] = {
"tariff_id": int(metadata["tariff_id"]),
"client_id": str(metadata["client_id"]),
"email": str(metadata["email"]),
"cost": int(metadata["cost"]),
"required_amount": required_amount,
"selected_price_rub": int(metadata.get("selected_price_rub") or metadata["cost"]),
}
selected_duration_days = metadata.get("selected_duration_days")
if selected_duration_days not in (None, ""):
payload["selected_duration_days"] = int(selected_duration_days)
selected_device_limit = metadata.get("selected_device_limit")
if selected_device_limit not in (None, ""):
payload["selected_device_limit"] = int(selected_device_limit)
selected_traffic_limit = metadata.get("selected_traffic_limit")
if selected_traffic_limit not in (None, ""):
payload["selected_traffic_limit"] = int(selected_traffic_limit)
total_gb = metadata.get("total_gb")
if total_gb not in (None, ""):
payload["total_gb"] = int(total_gb)
coupon_id = metadata.get("coupon_id")
if coupon_id not in (None, ""):
payload["coupon_id"] = int(coupon_id)
discount_rub = metadata.get("discount_rub")
if discount_rub not in (None, ""):
payload["discount_rub"] = int(discount_rub)
base_price_rub = metadata.get("base_price_rub")
if base_price_rub not in (None, ""):
payload["base_price_rub"] = int(base_price_rub)
applied_coupon_code = metadata.get("applied_coupon_code")
if applied_coupon_code not in (None, ""):
payload["applied_coupon_code"] = str(applied_coupon_code)
await create_temporary_data(session, billing_user_ref, "waiting_for_renewal_payment", payload)
return
if payment_flow == "key_addons":
required_fields = ("tariff_id", "email", "original_price")
if any(metadata.get(field) in (None, "") for field in required_fields):
return
payload: dict[str, int | str] = {
"tariff_id": int(metadata["tariff_id"]),
"email": str(metadata["email"]),
"original_price": int(metadata["original_price"]),
"required_amount": required_amount,
}
selected_device_limit = metadata.get("selected_device_limit")
if selected_device_limit not in (None, ""):
payload["selected_device_limit"] = int(selected_device_limit)
selected_traffic_gb = metadata.get("selected_traffic_gb")
if selected_traffic_gb not in (None, ""):
payload["selected_traffic_gb"] = int(selected_traffic_gb)
current_device_limit = metadata.get("current_device_limit")
if current_device_limit not in (None, ""):
payload["current_device_limit"] = int(current_device_limit)
current_traffic_gb = metadata.get("current_traffic_gb")
if current_traffic_gb not in (None, ""):
payload["current_traffic_gb"] = int(current_traffic_gb)
coupon_id = metadata.get("coupon_id")
if coupon_id not in (None, ""):
payload["coupon_id"] = int(coupon_id)
discount_rub = metadata.get("discount_rub")
if discount_rub not in (None, ""):
payload["discount_rub"] = int(discount_rub)
base_price_rub = metadata.get("base_price_rub")
if base_price_rub not in (None, ""):
payload["base_price_rub"] = int(base_price_rub)
applied_coupon_code = metadata.get("applied_coupon_code")
if applied_coupon_code not in (None, ""):
payload["applied_coupon_code"] = str(applied_coupon_code)
await create_temporary_data(session, billing_user_ref, "waiting_for_addons_payment", payload)
@router.post("/", response_model=PaymentLinkCreateResponse)
@@ -32,13 +147,10 @@ async def create_link(
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Создаёт платёжную ссылку через выбранную кассу (единая точка входа). Принимает identity_id или tg_id."""
try:
tg_id = await _resolve_tg_id(body, session)
except HTTPException:
raise
"""Создаёт платёжную ссылку для текущего авторизованного пользователя."""
billing_user_ref = await idb.ensure_billing_user_for_identity(session, identity)
payment_request = PaymentLinkRequest(
tg_id=tg_id,
legacy_user_ref=billing_user_ref,
amount=body.amount,
currency=body.currency or "RUB",
provider_id=body.provider_id,
@@ -47,9 +159,114 @@ async def create_link(
metadata=body.metadata,
)
result = await create_payment_link(session, payment_request)
if result.success:
await _store_payment_intent(
session=session,
billing_user_ref=billing_user_ref,
metadata=body.metadata,
amount=body.amount,
)
return PaymentLinkCreateResponse(
success=result.success,
payment_id=result.payment_id,
payment_url=result.payment_url,
error=result.error,
)
@router.get("/stream")
async def payment_events_stream(
request: Request,
x_identity_id: str = "",
token: str = "",
):
identity_id = str(request.headers.get("X-Identity-Id") or x_identity_id or "").strip()
token = str(request.headers.get("X-Token") or token or "").strip()
if not identity_id or not token:
raise HTTPException(status_code=401, detail="Unauthorized")
async with async_session_maker() as session:
identity = await idb.verify_identity_token(session, identity_id, token)
if not identity:
raise HTTPException(status_code=401, detail="Unauthorized")
billing_user_ref = await idb.ensure_billing_user_for_identity(session, identity)
await session.commit()
async def event_generator():
redis_client = None
pubsub = None
channel = payment_events_channel(int(billing_user_ref))
try:
from redis.asyncio import from_url
redis_client = from_url(REDIS_URL, encoding="utf-8", decode_responses=True, max_connections=8)
pubsub = redis_client.pubsub(ignore_subscribe_messages=True)
await pubsub.subscribe(channel)
logger.info(f"[Payments] SSE subscribed: user_ref={billing_user_ref}, channel={channel}")
yield "retry: 1500\n\n"
while True:
if await request.is_disconnected():
logger.info(f"[Payments] SSE disconnected by client: user_ref={billing_user_ref}")
break
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=15.0)
if message and message.get("type") == "message":
raw_data = message.get("data")
payload = json.loads(raw_data) if isinstance(raw_data, str) else raw_data
if isinstance(payload, dict):
logger.info(
f"[Payments] SSE emit: user_ref={billing_user_ref}, "
f"status={payload.get('status')}, flow={payload.get('flow')}"
)
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
continue
yield ": keepalive\n\n"
await asyncio.sleep(0.1)
finally:
if pubsub is not None:
try:
await pubsub.unsubscribe(channel)
await pubsub.close()
except Exception:
pass
if redis_client is not None:
try:
await redis_client.aclose()
except Exception:
pass
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.get("/{payment_id}", response_model=PaymentLinkStatusResponse)
async def get_link_status(
payment_id: str,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
billing_user_ref = await idb.ensure_billing_user_for_identity(session, identity)
payment = await get_payment_from_db_by_payment_id(session, payment_id)
if payment is None:
payment = await get_payment_by_payment_id(session, payment_id)
if not payment:
raise HTTPException(status_code=404, detail="Payment not found")
owner_ref = payment.get("user_id")
if owner_ref is None:
owner_ref = payment.get("tg_id")
if owner_ref is None or int(owner_ref) != int(billing_user_ref):
raise HTTPException(status_code=404, detail="Payment not found")
status = str(payment.get("status") or "").lower() or None
return PaymentLinkStatusResponse(
success=True,
payment_id=payment_id,
status=status,
completed=status in {"success", "failed", "cancelled"},
paid=status == "success",
)
+179 -29
View File
@@ -1,37 +1,187 @@
from fastapi import Depends, HTTPException, Query
from sqlalchemy import select
from base64 import b64encode
from io import BytesIO
from urllib.parse import urlsplit
import qrcode
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin
from api.v2.base_crud import generate_crud_router
from api.v2.schemas import ReferralResponse
from database.models import Referral
router = generate_crud_router(
model=Referral,
schema_response=ReferralResponse,
schema_create=None,
schema_update=None,
identifier_field="referrer_tg_id",
parameter_name="referrer_tg_id",
enabled_methods=["get_all", "get_one", "get_all_by_field"],
from api.depends import get_session, verify_identity_token
from api.v2.schemas.web_public import (
ReferralApplyRequest,
ReferralApplyResponse,
ReferralConditionsResponse,
ReferralQrResponse,
ReferralTopEntryResponse,
ReferralTopResponse,
)
from config import CHECK_REFERRAL_REWARD_ISSUED, REFERRAL_BONUS_PERCENTAGES, REFERRAL_BUTTON, REFERRAL_QR, TOP_REFERRAL_BUTTON
from core.bootstrap import BUTTONS_CONFIG
from database import add_referral, get_referral_by_referred_id, get_user_referral_count
from database.referrals import get_referral_position, get_top_referrals
from database import identities as idb
from database.access.resolution import resolve_user_optional
from utils.referral_codes import decode_referral_code, encode_referral_code
router = APIRouter()
@router.delete("/one")
async def delete_one_referral(
referrer_tg_id: int = Query(..., description="ID пригласившего"),
referred_tg_id: int = Query(..., description="ID приглашённого"),
identity=Depends(verify_identity_admin),
def _normalize_referrer_code(value: str | None, fallback_tg_id: int | None) -> int | None:
raw = str(value or "").strip()
if raw:
if "/referral/" in raw:
raw = raw.split("/referral/", 1)[-1]
if "start=referral_" in raw:
raw = raw.split("start=referral_", 1)[-1]
raw = raw.split("?", 1)[0].split("#", 1)[0].strip()
parsed = decode_referral_code(raw)
if parsed is not None:
return parsed
if fallback_tg_id is not None and int(fallback_tg_id) > 0:
return int(fallback_tg_id)
return None
def _resolve_public_base_url(request: Request) -> str:
origin = str(request.headers.get("origin") or "").strip()
if origin.startswith("http://") or origin.startswith("https://"):
return origin.rstrip("/")
referer = str(request.headers.get("referer") or request.headers.get("referrer") or "").strip()
if referer.startswith("http://") or referer.startswith("https://"):
parsed = urlsplit(referer)
if parsed.scheme and parsed.netloc:
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
forwarded_host = str(request.headers.get("x-forwarded-host") or "").strip()
host = forwarded_host or str(request.headers.get("host") or "").strip()
forwarded_proto = str(request.headers.get("x-forwarded-proto") or "").split(",", 1)[0].strip().lower()
scheme = forwarded_proto if forwarded_proto in {"http", "https"} else request.url.scheme
if host:
return f"{scheme}://{host}".rstrip("/")
return str(request.base_url).rstrip("/")
@router.post("/apply", response_model=ReferralApplyResponse, tags=["Referrals"])
async def apply_referral(
body: ReferralApplyRequest,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Удаляет одну связь реферала по паре referrer/referred."""
result = await session.execute(
select(Referral).where(Referral.referrer_tg_id == referrer_tg_id, Referral.referred_tg_id == referred_tg_id)
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
billing_uid = await idb.ensure_billing_user_for_identity(session, identity)
referrer_legacy = _normalize_referrer_code(body.referrer_code, body.referrer_tg_id)
if referrer_legacy is None:
raise HTTPException(status_code=400, detail="Приглашение недействительно")
referrer_u = await resolve_user_optional(session, referrer_legacy)
if referrer_u is None:
raise HTTPException(status_code=400, detail="Приглашение недействительно")
if billing_uid == referrer_u.id:
raise HTTPException(status_code=400, detail="Нельзя использовать собственную ссылку")
if await get_referral_by_referred_id(session, billing_uid):
raise HTTPException(status_code=409, detail="Реферальная связь уже сохранена")
await add_referral(session, billing_uid, referrer_u.id)
referred_u = await resolve_user_optional(session, billing_uid)
return ReferralApplyResponse(
ok=True,
message="Приглашение применено",
referrer_code=str(referrer_u.id),
referrer_user_id=int(referrer_u.id),
referrer_tg_id=referrer_u.tg_id,
referred_user_id=int(billing_uid),
referred_tg_id=referred_u.tg_id if referred_u is not None else None,
)
@router.get("/top", response_model=ReferralTopResponse, tags=["Referrals"])
async def referral_top(
limit: int = Query(5, ge=1, le=20),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
if not bool(BUTTONS_CONFIG.get("TOP_REFERRAL_BUTTON_ENABLE", TOP_REFERRAL_BUTTON)):
raise HTTPException(status_code=403, detail="Топ рефералов отключен в настройках")
billing_uid = await idb.ensure_billing_user_for_identity(session, identity)
user_referral_count = int(await get_user_referral_count(session, billing_uid))
user_position = int(await get_referral_position(session, user_referral_count)) if user_referral_count > 0 else None
top_rows = await get_top_referrals(session, limit=limit)
top: list[ReferralTopEntryResponse] = []
for index, row in enumerate(top_rows, 1):
referrer_user_id = int(row.get("referrer_user_id") or 0)
referrals_count = int(row.get("referral_count") or 0)
display_id = encode_referral_code(referrer_user_id)
top.append(
ReferralTopEntryResponse(
position=index,
referrer_user_id=referrer_user_id,
referrals_count=referrals_count,
display_id=display_id,
)
)
return ReferralTopResponse(
user_referrals_count=user_referral_count,
user_position=user_position,
top=top,
)
@router.get("/qr", response_model=ReferralQrResponse, tags=["Referrals"])
async def referral_qr(
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
if not bool(BUTTONS_CONFIG.get("REFERRAL_QR_BUTTON_ENABLE", REFERRAL_QR)):
raise HTTPException(status_code=403, detail="QR реферальной ссылки отключен в настройках")
billing_uid = await idb.ensure_billing_user_for_identity(session, identity)
base_url = _resolve_public_base_url(request)
referral_link = f"{base_url}/referral/{encode_referral_code(int(billing_uid))}"
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(referral_link)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = BytesIO()
img.save(buffer, format="PNG")
image_data = b64encode(buffer.getvalue()).decode("ascii")
return ReferralQrResponse(
ok=True,
link=referral_link,
image_data_url=f"data:image/png;base64,{image_data}",
)
@router.get("/conditions", response_model=ReferralConditionsResponse, tags=["Referrals"])
async def referral_conditions(
identity=Depends(verify_identity_token),
):
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
del identity
level_lines: list[str] = []
for level in sorted(REFERRAL_BONUS_PERCENTAGES.keys()):
value = REFERRAL_BONUS_PERCENTAGES[level]
if isinstance(value, float):
label = f"{int(value * 100)}% от суммы оплаты"
else:
label = f"{float(value):g} RUB"
level_lines.append(f"{level} уровень: {label}")
one_time_mode = bool(CHECK_REFERRAL_REWARD_ISSUED)
bonus_mode = "one_time" if one_time_mode else "each_payment"
bonus_mode_label = "Бонус за первую успешную оплату реферала" if one_time_mode else "Бонус за каждую успешную оплату реферала"
rules = [
"Бонус начисляется только за реальных приглашённых пользователей.",
"Нельзя использовать собственную реферальную ссылку.",
"Реферальную связь можно применить только один раз.",
"Размер бонуса зависит от уровня реферальной программы.",
]
return ReferralConditionsResponse(
title="Условия реферальной программы",
summary=f"Режим начисления: {bonus_mode_label}.",
bonus_mode=bonus_mode,
bonus_mode_label=bonus_mode_label,
level_lines=level_lines,
rules=rules,
)
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail="Referral not found")
await session.delete(obj)
await session.commit()
return {"status": "deleted_one"}
+138 -1
View File
@@ -1,10 +1,63 @@
from fastapi import APIRouter
from config import PROJECT_NAME, USERNAME_BOT
from config import (
BALANCE_BUTTON,
CAPTCHA_ENABLE,
CHANNEL_EXISTS,
CHANNEL_REQUIRED,
DONATIONS_ENABLE,
GIFT_BUTTON,
HAPP_CRYPTOLINK,
HWID_RESET_BUTTON,
INSTRUCTIONS_BUTTON,
PROJECT_NAME,
REFERRAL_BUTTON,
REFERRAL_QR,
REMNAWAVE_WEBAPP,
REMNAWAVE_WEBAPP_OPEN_IN_BROWSER,
TOP_REFERRAL_BUTTON,
TRIAL_TIME_DISABLE,
USE_COUNTRY_SELECTION,
USERNAME_BOT,
TELEGRAM_WEBAPP_DIRECT_LINK,
TELEGRAM_WEBAPP_SHORT_NAME,
)
from core.bootstrap import BUTTONS_CONFIG, MODES_CONFIG, MONEY_CONFIG, PAYMENTS_CONFIG
from core.settings.web_config import WEB_CONFIG
from core.settings.money_config import get_currency_mode
from services.payments.providers import PROVIDERS_BASE, TELEGRAM_ONLY_PROVIDER_IDS, WEB_LINK_PROVIDER_IDS
router = APIRouter(tags=["Root"])
def _telegram_web_app_return_base() -> str | None:
direct = str(TELEGRAM_WEBAPP_DIRECT_LINK or "").strip().rstrip("/")
if direct:
if direct.lower().startswith("http://"):
direct = "https://" + direct[7:]
if direct.lower().startswith("https://t.me/"):
return direct
bot = USERNAME_BOT.replace("@", "").strip()
sn = str(TELEGRAM_WEBAPP_SHORT_NAME or "").strip()
if bot and sn:
return f"https://t.me/{bot}/{sn}"
if bot:
return f"https://t.me/{bot}"
return None
def _partner_feature_enabled() -> bool:
try:
from modules.partner_program import settings as partner_settings
except Exception:
return False
for key in ("PARTNER_PROGRAM_ENABLED", "PARTNER_BUTTON_ENABLED", "PARTNER_ENABLED"):
value = getattr(partner_settings, key, None)
if isinstance(value, bool):
return value
return True
@router.get("/api", include_in_schema=False)
async def root():
return {"message": "SoloBot API v2", "docs": "/api/docs"}
@@ -22,3 +75,87 @@ async def telegram_widget_bot():
"bot_username": USERNAME_BOT.replace("@", ""),
"project_name": (PROJECT_NAME or "Solo").strip() if isinstance(PROJECT_NAME, str) else "Solo",
}
@router.get("/api/site-config", include_in_schema=True)
async def site_config():
"""Настройки витрины и кабинета для веб-клиента (флаги из runtime-конфигов бота)."""
bot_username = USERNAME_BOT.replace("@", "").strip()
pay_flags = {name: bool(PAYMENTS_CONFIG.get(name)) for name in PROVIDERS_BASE}
any_pay = any(pay_flags.values())
web_link_provider_ids = [provider_id for provider_id in WEB_LINK_PROVIDER_IDS if pay_flags.get(provider_id, False)]
telegram_only_provider_ids = [
provider_id for provider_id in TELEGRAM_ONLY_PROVIDER_IDS if pay_flags.get(provider_id, False)
]
currency_mode, currency_one_screen = get_currency_mode()
try:
cb_raw = MONEY_CONFIG.get("CASHBACK", 0)
cashback_percent = float(cb_raw) if cb_raw not in (None, False) else 0.0
except (TypeError, ValueError):
cashback_percent = 0.0
webapp_short = str(TELEGRAM_WEBAPP_SHORT_NAME or "").strip() or None
webapp_return_base = _telegram_web_app_return_base()
return {
"bot_username": bot_username or None,
"telegram_web_app_short_name": webapp_short,
"telegram_web_app_return_base": webapp_return_base,
"project_name": (PROJECT_NAME or "Solo").strip() if isinstance(PROJECT_NAME, str) else "Solo",
"site_mode": str(WEB_CONFIG.get("SITE_MODE", "full")).strip() or "full",
"auth": {
"telegram_login_enabled": bool(bot_username),
"email_code_login_enabled": bool(MODES_CONFIG.get("WEB_EMAIL_CODE_LOGIN_ENABLED", True)),
},
"mobile": {
"prefer_mini_app_on_telegram_mobile": bool(
MODES_CONFIG.get("PREFER_MINI_APP_ON_TELEGRAM_MOBILE", False)
),
},
"features": {
"channel_enabled": bool(BUTTONS_CONFIG.get("CHANNEL_BUTTON_ENABLE", CHANNEL_EXISTS)),
"donations_enabled": bool(BUTTONS_CONFIG.get("DONATIONS_BUTTON_ENABLE", DONATIONS_ENABLE)),
"balance_enabled": bool(BUTTONS_CONFIG.get("BALANCE_BUTTON_ENABLE", BALANCE_BUTTON)),
"referral_qr_enabled": bool(BUTTONS_CONFIG.get("REFERRAL_QR_BUTTON_ENABLE", REFERRAL_QR)),
"instructions_enabled": bool(BUTTONS_CONFIG.get("INSTRUCTIONS_BUTTON_ENABLE", INSTRUCTIONS_BUTTON)),
"gift_enabled": bool(BUTTONS_CONFIG.get("GIFT_BUTTON_ENABLE", GIFT_BUTTON)),
"referral_enabled": bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)),
"top_referral_enabled": bool(BUTTONS_CONFIG.get("TOP_REFERRAL_BUTTON_ENABLE", TOP_REFERRAL_BUTTON)),
"coupon_enabled": bool(BUTTONS_CONFIG.get("COUPON_BUTTON_ENABLE", True)),
"qr_subscription_enabled": bool(MODES_CONFIG.get("HAPP_CRYPTOLINK_ENABLED", HAPP_CRYPTOLINK)),
"hwid_reset_enabled": bool(BUTTONS_CONFIG.get("HWID_RESET_BUTTON_ENABLE", HWID_RESET_BUTTON)),
"country_selection_enabled": bool(
MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION)
),
"captcha_enabled": bool(MODES_CONFIG.get("CAPTCHA_ENABLED", CAPTCHA_ENABLE)),
"channel_check_enabled": bool(MODES_CONFIG.get("CHANNEL_CHECK_ENABLED", CHANNEL_REQUIRED)),
"trial_enabled": not bool(MODES_CONFIG.get("TRIAL_TIME_DISABLED", TRIAL_TIME_DISABLE)),
"mini_app_enabled": bool(MODES_CONFIG.get("REMNAWAVE_WEBAPP_ENABLED", REMNAWAVE_WEBAPP)),
"mini_app_open_in_browser": bool(
MODES_CONFIG.get("REMNAWAVE_WEBAPP_OPEN_IN_BROWSER", REMNAWAVE_WEBAPP_OPEN_IN_BROWSER)
),
"partner_enabled": bool(_partner_feature_enabled()),
},
"payments": {
"any_enabled": any_pay,
"any_web_link_enabled": bool(web_link_provider_ids),
"any_telegram_only_enabled": bool(telegram_only_provider_ids),
"web_link_provider_ids": web_link_provider_ids,
"telegram_only_provider_ids": telegram_only_provider_ids,
"yookassa_enabled": pay_flags.get("YOOKASSA", False),
"yoomoney_enabled": pay_flags.get("YOOMONEY", False),
"robokassa_enabled": pay_flags.get("ROBOKASSA", False),
"kassai_cards_enabled": pay_flags.get("KASSAI_CARDS", False),
"kassai_sbp_enabled": pay_flags.get("KASSAI_SBP", False),
"tribute_enabled": pay_flags.get("TRIBUTE", False),
"heleket_enabled": pay_flags.get("HELEKET", False),
"cryptobot_enabled": pay_flags.get("CRYPTOBOT", False),
"freekassa_enabled": pay_flags.get("FREEKASSA", False),
"stars_enabled": pay_flags.get("STARS", False),
},
"money": {
"currency_mode": currency_mode,
"currency_one_screen": currency_one_screen,
"cashback_enabled": cashback_percent > 0,
"cashback_percent": cashback_percent,
},
}
+378 -11
View File
@@ -1,15 +1,62 @@
from fastapi import APIRouter, Depends, Query
from datetime import datetime, timedelta
from math import ceil
from urllib.parse import urlsplit
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pytz import timezone as tz_moscow
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session
from api.depends import get_session, validate_redirect_url, verify_identity_token
from api.v2.base_crud import generate_crud_router
from api.v2.routes.coupon_pricing import resolve_percent_coupon_pricing
from api.v2.schemas import TariffBase, TariffResponse, TariffUpdate
from api.v2.schemas.tariffs import TariffGroup, TariffPublic
from api.v2.base_crud import generate_crud_router
from api.v2.schemas.web_public import (
TariffConfigPriceResponse,
TariffPurchaseRequest,
TariffPurchaseResponse,
)
from core.bootstrap import PAYMENTS_CONFIG
from core.redis_cache import cache_get, cache_key, cache_set
from database import (
get_balance,
identities as idb,
)
from database.coupons import mark_coupon_used
from database.models import Tariff
from database.tariffs import get_tariff_by_id
from database.temporary_data import create_temporary_data
from services.keys import create_vpn_key_headless
from logger import logger
from services.payments.payment_links import PaymentLinkRequest, create_payment_link
from services.payments.providers import WEB_LINK_PROVIDER_IDS
from services.tariffs import calculate_config_price
def _tariff_to_public(t: Tariff) -> TariffPublic:
dev_opts = getattr(t, "device_options", None)
tr_opts = getattr(t, "traffic_options_gb", None)
device_options: list[int] | None = None
traffic_options_gb: list[int] | None = None
if isinstance(dev_opts, list):
device_options = []
for x in dev_opts:
try:
device_options.append(int(x))
except (TypeError, ValueError):
continue
if not device_options:
device_options = None
if isinstance(tr_opts, list):
traffic_options_gb = []
for x in tr_opts:
try:
traffic_options_gb.append(int(x))
except (TypeError, ValueError):
continue
if not traffic_options_gb:
traffic_options_gb = None
return TariffPublic(
id=t.id,
name=t.name or "",
@@ -21,18 +68,57 @@ def _tariff_to_public(t: Tariff) -> TariffPublic:
subgroup_title=t.subgroup_title,
sort_order=t.sort_order,
vless=bool(getattr(t, "vless", False)),
configurable=bool(getattr(t, "configurable", False)),
device_options=device_options,
traffic_options_gb=traffic_options_gb,
)
public_router = APIRouter()
def _resolve_public_base_url(request: Request) -> str:
origin = str(request.headers.get("origin") or "").strip()
if origin.startswith(("http://", "https://")):
return origin.rstrip("/")
referer = str(request.headers.get("referer") or request.headers.get("referrer") or "").strip()
if referer.startswith(("http://", "https://")):
parsed = urlsplit(referer)
if parsed.scheme and parsed.netloc:
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
forwarded_host = str(request.headers.get("x-forwarded-host") or "").strip()
host = forwarded_host or str(request.headers.get("host") or "").strip()
forwarded_proto = str(request.headers.get("x-forwarded-proto") or "").split(",", 1)[0].strip().lower()
scheme = forwarded_proto if forwarded_proto in {"http", "https"} else request.url.scheme
if host:
return f"{scheme}://{host}".rstrip("/")
return str(request.base_url).rstrip("/")
def _resolve_default_web_payment_provider() -> str | None:
for provider_id in WEB_LINK_PROVIDER_IDS:
if bool(PAYMENTS_CONFIG.get(provider_id)):
return provider_id
return WEB_LINK_PROVIDER_IDS[0] if WEB_LINK_PROVIDER_IDS else None
def _public_tariffs_cache_key(
group_code: str | None,
tariff_ids: str | None,
filter_vless: str | None,
) -> str:
normalized_group = (group_code or "").strip().lower()
normalized_ids = ",".join(part.strip() for part in (tariff_ids or "").split(",") if part.strip())
normalized_vless = (filter_vless or "").strip().lower()
return cache_key("tariffs_public", normalized_group or "-", normalized_ids or "-", normalized_vless or "-")
@public_router.get("/groups", response_model=list[TariffGroup])
async def get_tariff_groups(session: AsyncSession = Depends(get_session)):
"""Публичный список групп тарифов — уникальные значения колонки group_code."""
q = (
select(Tariff.group_code)
.where(Tariff.is_active == True, Tariff.group_code.isnot(None), Tariff.group_code != "")
.where(Tariff.is_active is True, Tariff.group_code.isnot(None), Tariff.group_code != "")
.distinct()
.order_by(Tariff.group_code)
)
@@ -52,23 +138,304 @@ async def get_tariffs_public(
session: AsyncSession = Depends(get_session),
):
"""Публичный список активных тарифов (без авторизации)."""
q = select(Tariff).where(Tariff.is_active == True).order_by(Tariff.sort_order.asc().nulls_last(), Tariff.price_rub.asc())
cache_token = _public_tariffs_cache_key(group_code, tariff_ids, filter_vless)
cached = await cache_get(cache_token)
if isinstance(cached, list):
return cached
q = select(Tariff).where(Tariff.is_active is True).order_by(Tariff.sort_order.asc().nulls_last(), Tariff.price_rub.asc())
if tariff_ids:
try:
ids = [int(x.strip()) for x in tariff_ids.split(",") if x.strip()]
if ids:
q = q.where(Tariff.id.in_(ids))
if not ids:
return []
q = q.where(Tariff.id.in_(ids))
except ValueError:
pass
raise HTTPException(status_code=422, detail="Некорректный параметр tariff_ids")
elif group_code:
q = q.where(Tariff.group_code == group_code)
if filter_vless == "router":
q = q.where(Tariff.vless == True)
q = q.where(Tariff.vless is True)
elif filter_vless == "app":
q = q.where(Tariff.vless == False)
q = q.where(Tariff.vless is False)
result = await session.execute(q)
rows = result.scalars().all()
return [_tariff_to_public(t) for t in rows]
payload = [_tariff_to_public(t).model_dump() for t in rows]
await cache_set(cache_token, payload, 30)
return payload
@public_router.get("/config-price", response_model=TariffConfigPriceResponse)
async def get_tariff_config_price(
tariff_id: int = Query(..., ge=1),
selected_device_limit: int | None = Query(None),
selected_traffic_gb: int | None = Query(None),
session: AsyncSession = Depends(get_session),
):
tariff = await get_tariff_by_id(session, tariff_id)
if not tariff or not tariff.get("is_active", True):
raise HTTPException(status_code=404, detail="Тариф не найден")
price = int(calculate_config_price(tariff, selected_device_limit, selected_traffic_gb))
return TariffConfigPriceResponse(price_rub=price)
user_tariff_router = APIRouter()
@user_tariff_router.post("/purchase", response_model=TariffPurchaseResponse)
async def purchase_tariff_with_balance(
body: TariffPurchaseRequest,
request: Request,
preview: bool = Query(False),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
tg_id = await idb.ensure_billing_user_for_identity(session, identity)
tariff = await get_tariff_by_id(session, body.tariff_id)
if not tariff or not tariff.get("is_active", True):
raise HTTPException(status_code=404, detail="Тариф не найден")
price = int(calculate_config_price(tariff, body.selected_device_limit, body.selected_traffic_gb))
if price <= 0:
raise HTTPException(status_code=400, detail="Некорректная цена тарифа")
final_price, discount_rub, coupon_id, applied_coupon_code = await resolve_percent_coupon_pricing(
session=session,
billing_user_id=int(tg_id),
base_price_rub=int(price),
coupon_code=body.coupon_code,
)
balance = float(await get_balance(session, tg_id))
duration = int(tariff.get("duration_days") or 0)
if duration <= 0:
raise HTTPException(status_code=400, detail="Некорректная длительность тарифа")
required_amount = int(max(0, ceil(float(final_price) - balance)))
if preview:
return TariffPurchaseResponse(
ok=True,
message="Расчет обновлен",
key_email=None,
charged_rub=0,
base_price_rub=int(price),
discount_rub=int(discount_rub),
final_price_rub=int(final_price),
applied_coupon_code=applied_coupon_code,
payment_required=required_amount > 0,
required_amount_rub=int(required_amount),
payment_id=None,
payment_url=None,
)
if required_amount > 0:
provider_id = str(body.provider_id or _resolve_default_web_payment_provider() or "").strip().upper()
if not provider_id:
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
base_url = _resolve_public_base_url(request)
success_url = validate_redirect_url(str(body.success_url or ""), f"{base_url}/payment-success")
failure_url = validate_redirect_url(str(body.failure_url or ""), f"{base_url}/payment-failure")
payment_request = PaymentLinkRequest(
legacy_user_ref=int(tg_id),
amount=required_amount,
currency="RUB",
provider_id=provider_id,
success_url=success_url,
failure_url=failure_url,
metadata={
"payment_flow": "tariff_purchase",
"tariff_id": int(body.tariff_id),
"selected_device_limit": body.selected_device_limit,
"selected_traffic_gb": body.selected_traffic_gb,
"selected_duration_days": int(duration),
"selected_price_rub": int(final_price),
"base_price_rub": int(price),
"discount_rub": int(discount_rub),
"applied_coupon_code": applied_coupon_code,
"coupon_id": int(coupon_id) if coupon_id is not None else None,
},
)
payment_result = await create_payment_link(session, payment_request)
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
await create_temporary_data(
session,
int(tg_id),
"waiting_for_payment",
{
"tariff_id": int(body.tariff_id),
"required_amount": int(required_amount),
"selected_price_rub": int(final_price),
"selected_device_limit": body.selected_device_limit,
"selected_traffic_limit_gb": body.selected_traffic_gb,
"selected_duration_days": int(duration),
"base_price_rub": int(price),
"discount_rub": int(discount_rub),
"applied_coupon_code": applied_coupon_code,
"coupon_id": int(coupon_id) if coupon_id is not None else None,
},
)
return TariffPurchaseResponse(
ok=True,
message="Требуется оплата для оформления подписки",
key_email=None,
charged_rub=0,
base_price_rub=int(price),
discount_rub=int(discount_rub),
final_price_rub=int(final_price),
applied_coupon_code=applied_coupon_code,
payment_required=True,
required_amount_rub=required_amount,
payment_id=payment_result.payment_id,
payment_url=payment_result.payment_url,
)
moscow_tz = tz_moscow("Europe/Moscow")
expiry = datetime.now(moscow_tz) + timedelta(days=duration)
try:
await create_vpn_key_headless(
session=session,
tg_id=tg_id,
expiry_time=expiry,
plan=body.tariff_id,
selected_device_limit=body.selected_device_limit,
selected_traffic_gb=body.selected_traffic_gb,
selected_price_rub=final_price,
)
if coupon_id is not None:
await mark_coupon_used(session, int(coupon_id), int(tg_id))
await session.commit()
except Exception:
logger.exception("web tariff purchase failed")
raise HTTPException(status_code=500, detail="Не удалось оформить подписку") from None
return TariffPurchaseResponse(
ok=True,
message="Подписка оформлена. Ключ в разделе «Мои ключи».",
key_email=None,
charged_rub=final_price,
base_price_rub=int(price),
discount_rub=int(discount_rub),
final_price_rub=int(final_price),
applied_coupon_code=applied_coupon_code,
)
@user_tariff_router.post("/trial", response_model=TariffPurchaseResponse)
async def activate_trial(
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Активация триала (бесплатного или платного). Доступно 1 раз."""
from database import get_trial, update_trial
from database.tariffs import get_tariffs
tg_id = await idb.ensure_billing_user_for_identity(session, identity)
trial_status = await get_trial(session, tg_id)
if trial_status not in (0, -1):
raise HTTPException(status_code=409, detail="Пробная подписка уже использована")
trial_tariffs = await get_tariffs(session, group_code="trial")
if not trial_tariffs:
raise HTTPException(status_code=404, detail="Пробный тариф не найден")
tariff = trial_tariffs[0]
price = int(tariff.get("price_rub", 0) or 0)
duration = int(tariff.get("duration_days") or 0)
if duration <= 0:
raise HTTPException(status_code=400, detail="Некорректная длительность триала")
if price <= 0:
moscow_tz = tz_moscow("Europe/Moscow")
expiry = datetime.now(moscow_tz) + timedelta(days=duration)
try:
await create_vpn_key_headless(
session=session,
tg_id=tg_id,
expiry_time=expiry,
plan=int(tariff["id"]),
selected_price_rub=0,
skip_balance_charge=True,
is_trial=True,
)
await update_trial(session, tg_id, 1)
except Exception:
logger.exception("web trial activation failed")
raise HTTPException(status_code=500, detail="Ошибка активации триала") from None
return TariffPurchaseResponse(
ok=True,
message="Пробная подписка активирована!",
charged_rub=0,
base_price_rub=0,
final_price_rub=0,
)
balance = float(await get_balance(session, tg_id))
required_amount = int(max(0, ceil(float(price) - balance)))
if required_amount <= 0:
moscow_tz = tz_moscow("Europe/Moscow")
expiry = datetime.now(moscow_tz) + timedelta(days=duration)
try:
await create_vpn_key_headless(
session=session,
tg_id=tg_id,
expiry_time=expiry,
plan=int(tariff["id"]),
selected_price_rub=price,
is_trial=True,
)
await update_trial(session, tg_id, 1)
except Exception:
logger.exception("web paid trial activation failed")
raise HTTPException(status_code=500, detail="Ошибка активации триала") from None
return TariffPurchaseResponse(
ok=True,
message="Пробная подписка активирована!",
charged_rub=price,
base_price_rub=price,
final_price_rub=price,
)
provider_id = str(_resolve_default_web_payment_provider() or "").strip().upper()
if not provider_id:
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
base_url = _resolve_public_base_url(request)
payment_request = PaymentLinkRequest(
legacy_user_ref=int(tg_id),
amount=required_amount,
currency="RUB",
provider_id=provider_id,
success_url=f"{base_url}/payment-success",
failure_url=f"{base_url}/payment-failure",
metadata={
"payment_flow": "trial_purchase",
"tariff_id": int(tariff["id"]),
"selected_price_rub": price,
"selected_duration_days": duration,
},
)
payment_result = await create_payment_link(session, payment_request)
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
await create_temporary_data(
session,
int(tg_id),
"waiting_for_payment",
{
"payment_flow": "trial_purchase",
"tariff_id": int(tariff["id"]),
"required_amount": required_amount,
"selected_price_rub": price,
"selected_duration_days": duration,
},
)
return TariffPurchaseResponse(
ok=True,
message="Требуется оплата для активации пробной подписки",
charged_rub=0,
base_price_rub=price,
final_price_rub=price,
payment_required=True,
required_amount_rub=required_amount,
payment_id=payment_result.payment_id,
payment_url=payment_result.payment_url,
)
router = generate_crud_router(
+6 -2
View File
@@ -9,7 +9,8 @@ from api.v2.schemas import UserBase, UserResponse, UserUpdate
from api.v2.base_crud import generate_crud_router
from database import async_session_maker, delete_user_data, get_servers
from database.models import Key, User
from handlers.keys.operations import delete_key_from_cluster
from database.access.resolution import resolve_user_optional
from services.operations import delete_key_from_cluster
from logger import logger
router = generate_crud_router(
@@ -30,7 +31,10 @@ async def delete_user(
):
"""Удаляет пользователя и его ключи на серверах."""
try:
result = await session.execute(select(Key.email, Key.client_id).where(Key.tg_id == tg_id))
u = await resolve_user_optional(session, tg_id)
if u is None:
raise HTTPException(status_code=404, detail="Пользователь не найден")
result = await session.execute(select(Key.email, Key.client_id).where(Key.user_id == u.id))
key_records = result.all()
async with async_session_maker() as s:
+614 -42
View File
@@ -1,20 +1,72 @@
import re
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
from pydantic import BaseModel
from sqlalchemy import select, delete
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin
from api.v2.schemas import WebPageResponse, WebPageUpdate, WebBlockResponse, WebTheme
from api.v2.schemas.web import WebUploadResponse
from database.models import WebPage, WebBlock, WebTheme as WebThemeModel
from api.v2.schemas import WebBlockResponse, WebPageResponse, WebPageUpdate, WebTheme
from api.v2.schemas.web import (
WebPageVariantCreate,
WebPageVariantSummary,
WebPageVariantUpdate,
WebPageVariantsResponse,
WebUploadResponse,
)
from database.models import (
WebBlock,
WebCustomElementBuild,
WebFlow,
WebFlowEvent,
WebPage,
WebPageVariant,
WebPageVariantBlock,
WebTheme as WebThemeModel,
)
from logger import logger
UPLOAD_DIR = Path("static/web_uploads")
ALLOWED_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".mp4", ".webm"})
MAX_FILE_SIZE = 100 * 1024 * 1024
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9\-]*$")
EXTENSION_CONTENT_TYPES: dict[str, frozenset[str]] = {
".png": frozenset({"image/png"}),
".jpg": frozenset({"image/jpeg"}),
".jpeg": frozenset({"image/jpeg"}),
".gif": frozenset({"image/gif"}),
".webp": frozenset({"image/webp"}),
".svg": frozenset({"image/svg+xml", "text/xml", "application/xml", "text/plain"}),
".mp4": frozenset({"video/mp4"}),
".webm": frozenset({"video/webm"}),
}
def _sanitize_svg(data: bytes) -> bytes:
import re as _re
text = data.decode("utf-8", errors="replace")
text = _re.sub(r"<script[^>]*>.*?</script>", "", text, flags=_re.DOTALL | _re.IGNORECASE)
text = _re.sub(r"<style[^>]*>.*?</style>", "", text, flags=_re.DOTALL | _re.IGNORECASE)
text = _re.sub(r"\bon\w+\s*=\s*[\"'][^\"']*[\"']", "", text, flags=_re.IGNORECASE)
text = _re.sub(r"\bon\w+\s*=\s*\S+", "", text, flags=_re.IGNORECASE)
text = _re.sub(r"(?:href|xlink:href)\s*=\s*[\"']\s*javascript:[^\"']*[\"']", "", text, flags=_re.IGNORECASE)
text = _re.sub(r"(?:href|xlink:href)\s*=\s*[\"']\s*data:\s*text/html[^\"']*[\"']", "", text, flags=_re.IGNORECASE)
text = _re.sub(r"(?:href|xlink:href)\s*=\s*[\"']\s*vbscript:[^\"']*[\"']", "", text, flags=_re.IGNORECASE)
text = _re.sub(r"<foreignObject[^>]*>.*?</foreignObject>", "", text, flags=_re.DOTALL | _re.IGNORECASE)
text = _re.sub(r"<iframe[^>]*>.*?</iframe>", "", text, flags=_re.DOTALL | _re.IGNORECASE)
text = _re.sub(r"<embed[^>]*>", "", text, flags=_re.IGNORECASE)
text = _re.sub(r"<object[^>]*>.*?</object>", "", text, flags=_re.DOTALL | _re.IGNORECASE)
return text.encode("utf-8")
router = APIRouter(tags=["Web"])
@@ -22,7 +74,47 @@ class WebPagesListResponse(BaseModel):
slugs: list[str]
KNOWN_PAGE_SLUGS = ["landing", "tariffs", "faq", "login", "dashboard"]
KNOWN_PAGE_SLUGS = [
"landing",
"tariffs",
"faq",
"login",
"dashboard",
"checkout",
"gift-entry",
"referral-entry",
"partner-entry",
"payment-success",
"payment-failure",
"dashboard-keys",
"dashboard-profile",
"dashboard-instructions",
"dashboard-referrals",
]
DEFAULT_VARIANT_KEY = "default"
DEFAULT_VARIANT_NAME = "Основной"
def _normalize_variant_key(value: str | None) -> str:
raw = (value or "").strip().lower()
normalized = re.sub(r"[^a-z0-9]+", "-", raw).strip("-")
if not normalized:
return DEFAULT_VARIANT_KEY
return normalized[:64].strip("-") or DEFAULT_VARIANT_KEY
def _normalize_variant_name(value: str | None, fallback: str) -> str:
name = (value or "").strip()
return name[:255] if name else fallback
def _variant_summary(row: WebPageVariant) -> WebPageVariantSummary:
return WebPageVariantSummary(
key=row.variant_key,
name=row.name or row.variant_key,
is_active=bool(row.is_active),
)
@router.get("/api/web/pages", response_model=WebPagesListResponse)
@@ -46,69 +138,296 @@ async def get_or_create_page(session: AsyncSession, slug: str) -> WebPage:
return page
@router.get("/api/web/pages/{slug}", response_model=WebPageResponse)
async def get_web_page(
slug: str,
session: AsyncSession = Depends(get_session),
):
await get_or_create_page(session, slug)
async def _list_variants(session: AsyncSession, slug: str) -> list[WebPageVariant]:
result = await session.execute(
select(WebPageVariant)
.where(WebPageVariant.page_slug == slug)
.order_by(WebPageVariant.is_active.desc(), WebPageVariant.created_at, WebPageVariant.variant_key)
)
return list(result.scalars().all())
async def _get_theme_tokens_for_legacy_page(session: AsyncSession, slug: str) -> dict:
theme_result = await session.execute(select(WebThemeModel).where(WebThemeModel.page_slug == slug))
theme_row = theme_result.scalar_one_or_none()
return dict(theme_row.tokens or {}) if theme_row else {}
async def _get_legacy_blocks(session: AsyncSession, slug: str) -> list[WebBlock]:
blocks_result = await session.execute(
select(WebBlock).where(WebBlock.page_slug == slug).order_by(WebBlock.order, WebBlock.id)
)
blocks = [WebBlockResponse.model_validate(b) for b in blocks_result.scalars().all()]
return list(blocks_result.scalars().all())
theme_result = await session.execute(select(WebThemeModel).where(WebThemeModel.page_slug == slug))
theme_row = theme_result.scalar_one_or_none()
theme = WebTheme(tokens=theme_row.tokens) if theme_row else None
return WebPageResponse(slug=slug, blocks=blocks, theme=theme)
async def _ensure_page_variants(session: AsyncSession, slug: str) -> list[WebPageVariant]:
await get_or_create_page(session, slug)
variants = await _list_variants(session, slug)
if variants:
if not any(variant.is_active for variant in variants):
variants[0].is_active = True
await session.flush()
variants = await _list_variants(session, slug)
return variants
legacy_blocks = await _get_legacy_blocks(session, slug)
theme_tokens = await _get_theme_tokens_for_legacy_page(session, slug)
variant = WebPageVariant(
page_slug=slug,
variant_key=DEFAULT_VARIANT_KEY,
name=DEFAULT_VARIANT_NAME,
is_active=True,
theme_tokens=theme_tokens,
)
session.add(variant)
await session.flush()
for legacy_block in legacy_blocks:
session.add(
WebPageVariantBlock(
variant_id=variant.id,
order=legacy_block.order,
type=legacy_block.type,
data=legacy_block.data,
)
)
await session.flush()
return await _list_variants(session, slug)
async def _resolve_variant(
session: AsyncSession,
slug: str,
variant_key: str | None,
) -> tuple[WebPageVariant, list[WebPageVariant]]:
variants = await _ensure_page_variants(session, slug)
desired_key = _normalize_variant_key(variant_key) if variant_key else ""
current = None
if desired_key:
current = next((variant for variant in variants if variant.variant_key == desired_key), None)
if current is None:
raise HTTPException(404, "Вариант страницы не найден")
else:
current = next((variant for variant in variants if variant.is_active), variants[0])
return current, variants
async def _get_variant_blocks(session: AsyncSession, variant_id: str) -> list[WebBlockResponse]:
blocks_result = await session.execute(
select(WebPageVariantBlock)
.where(WebPageVariantBlock.variant_id == variant_id)
.order_by(WebPageVariantBlock.order, WebPageVariantBlock.id)
)
return [WebBlockResponse.model_validate(block) for block in blocks_result.scalars().all()]
async def _build_page_response(
session: AsyncSession,
slug: str,
current: WebPageVariant,
variants: list[WebPageVariant] | None = None,
) -> WebPageResponse:
current_variants = variants or await _list_variants(session, slug)
active = next((variant for variant in current_variants if variant.is_active), current)
blocks = await _get_variant_blocks(session, current.id)
theme = WebTheme(tokens=dict(current.theme_tokens or {}))
return WebPageResponse(
slug=slug,
blocks=blocks,
theme=theme,
variant_key=current.variant_key,
active_variant_key=active.variant_key,
variants=[_variant_summary(variant) for variant in current_variants],
)
async def _set_active_variant(session: AsyncSession, slug: str, variant_key: str) -> list[WebPageVariant]:
variants = await _list_variants(session, slug)
matched = False
for variant in variants:
is_target = variant.variant_key == variant_key
variant.is_active = is_target
matched = matched or is_target
if not matched:
raise HTTPException(404, "Вариант страницы не найден")
await session.flush()
return await _list_variants(session, slug)
def _generate_variant_key(existing_keys: set[str], requested_key: str | None, requested_name: str | None) -> str:
base = _normalize_variant_key(requested_key or requested_name)
if not base:
base = DEFAULT_VARIANT_KEY
if base not in existing_keys:
return base
suffix = 2
while True:
candidate = f"{base}-{suffix}"
if candidate not in existing_keys:
return candidate[:64]
suffix += 1
@router.get("/api/web/pages/{slug}", response_model=WebPageResponse)
async def get_web_page(
slug: str,
variant: str | None = Query(default=None),
session: AsyncSession = Depends(get_session),
):
if not slug or len(slug) > 64 or not _SLUG_RE.match(slug):
raise HTTPException(400, "Некорректный slug страницы")
current, variants = await _resolve_variant(session, slug, variant)
return await _build_page_response(session, slug, current, variants)
@router.put("/api/web/pages/{slug}", response_model=WebPageResponse)
async def update_web_page(
slug: str,
body: WebPageUpdate,
variant: str | None = Query(default=None),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
await get_or_create_page(session, slug)
await session.execute(delete(WebBlock).where(WebBlock.page_slug == slug))
current, _ = await _resolve_variant(session, slug, variant)
await session.execute(delete(WebPageVariantBlock).where(WebPageVariantBlock.variant_id == current.id))
for block in body.blocks:
session.add(
WebBlock(
page_slug=slug,
WebPageVariantBlock(
variant_id=current.id,
order=block.order,
type=block.type,
data=block.data,
)
)
theme_row = None
if body.theme is not None:
result = await session.execute(select(WebThemeModel).where(WebThemeModel.page_slug == slug))
theme_row = result.scalar_one_or_none()
if theme_row is None:
theme_row = WebThemeModel(page_slug=slug, tokens=body.theme.tokens)
session.add(theme_row)
else:
theme_row.tokens = body.theme.tokens
current.theme_tokens = body.theme.tokens
await session.flush()
refreshed_variants = await _list_variants(session, slug)
refreshed_current = next((item for item in refreshed_variants if item.id == current.id), current)
return await _build_page_response(session, slug, refreshed_current, refreshed_variants)
blocks_result = await session.execute(
select(WebBlock).where(WebBlock.page_slug == slug).order_by(WebBlock.order, WebBlock.id)
@router.get("/api/web/pages/{slug}/variants", response_model=WebPageVariantsResponse)
async def get_web_page_variants(
slug: str,
variant: str | None = Query(default=None),
session: AsyncSession = Depends(get_session),
):
current, variants = await _resolve_variant(session, slug, variant)
active = next((item for item in variants if item.is_active), current)
return WebPageVariantsResponse(
slug=slug,
active_variant_key=active.variant_key,
current_variant_key=current.variant_key,
variants=[_variant_summary(item) for item in variants],
)
blocks = [WebBlockResponse.model_validate(b) for b in blocks_result.scalars().all()]
if theme_row is None:
theme_result = await session.execute(select(WebThemeModel).where(WebThemeModel.page_slug == slug))
theme_row = theme_result.scalar_one_or_none()
theme = WebTheme(tokens=theme_row.tokens) if theme_row else None
return WebPageResponse(slug=slug, blocks=blocks, theme=theme)
@router.post("/api/web/pages/{slug}/variants", response_model=WebPageVariantsResponse)
async def create_web_page_variant(
slug: str,
body: WebPageVariantCreate,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
source_variant, variants = await _resolve_variant(session, slug, body.from_variant_key)
existing_keys = {variant.variant_key for variant in variants}
variant_key = _generate_variant_key(existing_keys, body.key, body.name)
if variant_key in existing_keys:
raise HTTPException(400, "Вариант с таким ключом уже существует")
variant_name = _normalize_variant_name(body.name, f"Вариант {len(variants) + 1}")
new_variant = WebPageVariant(
page_slug=slug,
variant_key=variant_key,
name=variant_name,
is_active=False,
theme_tokens=dict(source_variant.theme_tokens or {}),
)
session.add(new_variant)
await session.flush()
source_blocks = await _get_variant_blocks(session, source_variant.id)
for block in source_blocks:
session.add(
WebPageVariantBlock(
variant_id=new_variant.id,
order=block.order,
type=block.type,
data=block.data,
)
)
await session.flush()
refreshed = await _list_variants(session, slug)
return WebPageVariantsResponse(
slug=slug,
active_variant_key=next((item.variant_key for item in refreshed if item.is_active), DEFAULT_VARIANT_KEY),
current_variant_key=new_variant.variant_key,
variants=[_variant_summary(item) for item in refreshed],
)
@router.patch("/api/web/pages/{slug}/variants/{variant_key}", response_model=WebPageVariantsResponse)
async def update_web_page_variant(
slug: str,
variant_key: str,
body: WebPageVariantUpdate,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
current, variants = await _resolve_variant(session, slug, variant_key)
if body.name is not None:
current.name = _normalize_variant_name(body.name, current.name or current.variant_key)
if body.make_active is True:
variants = await _set_active_variant(session, slug, current.variant_key)
current = next((item for item in variants if item.variant_key == current.variant_key), current)
else:
await session.flush()
variants = await _list_variants(session, slug)
active = next((item for item in variants if item.is_active), current)
return WebPageVariantsResponse(
slug=slug,
active_variant_key=active.variant_key,
current_variant_key=current.variant_key,
variants=[_variant_summary(item) for item in variants],
)
@router.delete("/api/web/pages/{slug}/variants/{variant_key}", response_model=WebPageVariantsResponse)
async def delete_web_page_variant(
slug: str,
variant_key: str,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
current, variants = await _resolve_variant(session, slug, variant_key)
if len(variants) <= 1:
raise HTTPException(400, "Нельзя удалить единственный вариант страницы")
replacement = next((item for item in variants if item.variant_key != current.variant_key), None)
await session.execute(delete(WebPageVariant).where(WebPageVariant.id == current.id))
await session.flush()
if current.is_active and replacement is not None:
replacement_variants = await _set_active_variant(session, slug, replacement.variant_key)
else:
replacement_variants = await _list_variants(session, slug)
current_variant_key = replacement.variant_key if replacement is not None else DEFAULT_VARIANT_KEY
active_variant_key = next(
(item.variant_key for item in replacement_variants if item.is_active),
current_variant_key,
)
return WebPageVariantsResponse(
slug=slug,
active_variant_key=active_variant_key,
current_variant_key=current_variant_key,
variants=[_variant_summary(item) for item in replacement_variants],
)
@router.post("/api/web/upload", response_model=WebUploadResponse)
@@ -125,18 +444,271 @@ async def upload_media(
400,
f"Разрешены только: {', '.join(sorted(ALLOWED_EXTENSIONS))}",
)
if file.content_type:
allowed_types = EXTENSION_CONTENT_TYPES.get(ext)
if allowed_types and file.content_type.lower() not in allowed_types:
raise HTTPException(
400,
f"Тип файла ({file.content_type}) не соответствует расширению ({ext})",
)
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
chunks: list[bytes] = []
size = 0
for chunk in file.file:
size += len(chunk)
if size > MAX_FILE_SIZE:
raise HTTPException(400, f"Размер файла не более {MAX_FILE_SIZE // (1024*1024)} МБ")
await file.seek(0)
raise HTTPException(400, f"Размер файла не более {MAX_FILE_SIZE // (1024 * 1024)} МБ")
chunks.append(chunk)
name = f"{uuid.uuid4().hex}{ext}"
path = UPLOAD_DIR / name
file_data = b"".join(chunks)
if ext == ".svg":
file_data = _sanitize_svg(file_data)
with open(path, "wb") as f:
while chunk := await file.read(64 * 1024):
f.write(chunk)
f.write(file_data)
url = f"/api/web/uploads/{name}"
logger.info(
"[WebUpload] admin={} file={} -> {} ({} bytes)",
identity.id,
file.filename,
name,
len(file_data),
)
return WebUploadResponse(url=url)
# ── Custom Element Builds ──
class CustomElementBuildCreate(BaseModel):
label: str = ""
slug: str = ""
runtime: str = "react-component"
source_kind: str = "inline-code"
source_value: str = ""
export_name: str = "default"
props_schema_text: str = ""
sample_props_text: str = ""
events_text: str = ""
notes: str = ""
class CustomElementBuildUpdate(BaseModel):
status: str | None = None
summary: str | None = None
next_steps: list[str] | None = None
artifact: dict | None = None
upload_meta: dict | None = None
worker_id: str | None = None
def _build_to_dict(b: WebCustomElementBuild) -> dict:
return {
"id": b.id,
"label": b.label,
"slug": b.slug,
"runtime": b.runtime,
"sourceKind": b.source_kind,
"sourceValue": b.source_value,
"exportName": b.export_name,
"propsSchemaText": b.props_schema_text,
"samplePropsText": b.sample_props_text,
"eventsText": b.events_text,
"notes": b.notes,
"status": b.status,
"summary": b.summary,
"nextSteps": b.next_steps or [],
"artifact": b.artifact,
"upload": b.upload_meta,
"workerId": b.worker_id,
"workerClaimedAt": b.worker_claimed_at.isoformat() if b.worker_claimed_at else None,
"completedAt": b.completed_at.isoformat() if b.completed_at else None,
"createdAt": b.created_at.isoformat() if b.created_at else None,
"updatedAt": b.updated_at.isoformat() if b.updated_at else None,
}
@router.get("/custom-element-builds")
async def list_custom_element_builds(
session: AsyncSession = Depends(get_session),
_identity=Depends(verify_identity_admin),
):
result = await session.execute(
select(WebCustomElementBuild).order_by(WebCustomElementBuild.created_at.desc())
)
builds = result.scalars().all()
return [_build_to_dict(b) for b in builds]
@router.post("/custom-element-builds")
async def create_custom_element_build(
body: CustomElementBuildCreate,
session: AsyncSession = Depends(get_session),
_identity=Depends(verify_identity_admin),
):
build = WebCustomElementBuild(
id=str(uuid.uuid4()),
label=body.label,
slug=body.slug,
runtime=body.runtime,
source_kind=body.source_kind,
source_value=body.source_value,
export_name=body.export_name,
props_schema_text=body.props_schema_text,
sample_props_text=body.sample_props_text,
events_text=body.events_text,
notes=body.notes,
status="queued",
)
session.add(build)
return _build_to_dict(build)
@router.get("/custom-element-builds/{build_id}")
async def get_custom_element_build(
build_id: str,
session: AsyncSession = Depends(get_session),
_identity=Depends(verify_identity_admin),
):
build = await session.get(WebCustomElementBuild, build_id)
if not build:
raise HTTPException(404, "Build not found")
return _build_to_dict(build)
@router.patch("/custom-element-builds/{build_id}")
async def update_custom_element_build(
build_id: str,
body: CustomElementBuildUpdate,
session: AsyncSession = Depends(get_session),
_identity=Depends(verify_identity_admin),
):
build = await session.get(WebCustomElementBuild, build_id)
if not build:
raise HTTPException(404, "Build not found")
if body.status is not None:
build.status = body.status
if body.summary is not None:
build.summary = body.summary
if body.next_steps is not None:
build.next_steps = body.next_steps
if body.artifact is not None:
build.artifact = body.artifact
if body.upload_meta is not None:
build.upload_meta = body.upload_meta
if body.worker_id is not None:
build.worker_id = body.worker_id
return _build_to_dict(build)
@router.delete("/custom-element-builds/{build_id}")
async def delete_custom_element_build(
build_id: str,
session: AsyncSession = Depends(get_session),
_identity=Depends(verify_identity_admin),
):
build = await session.get(WebCustomElementBuild, build_id)
if not build:
raise HTTPException(404, "Build not found")
await session.delete(build)
return {"ok": True}
# ── Flow Analytics ──
class FlowEventBatch(BaseModel):
events: list[dict]
@router.post("/analytics/flow-events")
async def ingest_flow_events(
body: FlowEventBatch,
request: Request,
session: AsyncSession = Depends(get_session),
):
try:
from core.redis_cache import cache_incr_checked
from api.v2.routes.auth._fallback_limiter import check_and_increment
ip = (request.client.host if request.client else "") or "unknown"
count, redis_ok = await cache_incr_checked(f"analytics_rate:{ip}", 60)
if not redis_ok:
count = check_and_increment(f"analytics_rate:{ip}", 60, 60)
if count > 60:
raise HTTPException(status_code=429, detail="Too many events")
except HTTPException:
raise
except Exception:
pass
created = 0
for raw in body.events[:100]:
flow_id = str(raw.get("flowId", ""))
node_id = str(raw.get("nodeId", ""))
event_type = str(raw.get("eventType", ""))
if not flow_id or not node_id or not event_type:
continue
ev = WebFlowEvent(
id=str(uuid.uuid4()),
flow_id=flow_id,
node_id=node_id,
node_type=str(raw.get("nodeType", "")),
event_type=event_type,
ab_variant=raw.get("abVariant") or None,
device=raw.get("device") or None,
locale=raw.get("locale") or None,
authenticated=raw.get("authenticated"),
event_metadata=raw.get("collectedDataSnapshot") or None,
)
session.add(ev)
created += 1
return {"ingested": created}
@router.get("/analytics/flow-funnel/{flow_id}")
async def get_flow_funnel(
flow_id: str,
days: int = Query(default=30, ge=1, le=365),
session: AsyncSession = Depends(get_session),
_identity=Depends(verify_identity_admin),
):
since = datetime.now(timezone.utc) - timedelta(days=days)
rows = (
await session.execute(
select(
WebFlowEvent.node_id,
WebFlowEvent.node_type,
WebFlowEvent.event_type,
func.count().label("cnt"),
)
.where(WebFlowEvent.flow_id == flow_id)
.where(WebFlowEvent.created_at >= since)
.group_by(WebFlowEvent.node_id, WebFlowEvent.node_type, WebFlowEvent.event_type)
)
).all()
nodes: dict[str, dict] = {}
for node_id, node_type, event_type, cnt in rows:
if node_id not in nodes:
nodes[node_id] = {"nodeId": node_id, "nodeType": node_type, "entered": 0, "exited": 0, "completed": 0}
if event_type == "flow_step_entered":
nodes[node_id]["entered"] = cnt
elif event_type == "flow_step_exited":
nodes[node_id]["exited"] = cnt
elif event_type == "flow_completed":
nodes[node_id]["completed"] = cnt
flow = await session.get(WebFlow, flow_id)
if flow and flow.nodes:
node_order = {n["id"]: i for i, n in enumerate(flow.nodes) if isinstance(n, dict)}
else:
node_order = {}
funnel = sorted(nodes.values(), key=lambda n: node_order.get(n["nodeId"], 999))
for i, node in enumerate(funnel):
prev_entered = funnel[i - 1]["entered"] if i > 0 else node["entered"]
node["dropOff"] = round(
(1 - node["entered"] / prev_entered) * 100, 1
) if prev_entered > 0 else 0
return {"flowId": flow_id, "days": days, "funnel": funnel}
+10 -1
View File
@@ -28,4 +28,13 @@ from api.v1.schemas import (
)
from api.v1.schemas.keys import KeyBase, KeyCreateRequest, KeyUpdate
from api.v1.schemas.settings import SettingResponse, SettingUpsert
from api.v2.schemas.web import WebBlockResponse, WebTheme, WebPageResponse, WebPageUpdate
from api.v2.schemas.web import (
WebBlockResponse,
WebTheme,
WebPageResponse,
WebPageUpdate,
WebPageVariantCreate,
WebPageVariantSummary,
WebPageVariantUpdate,
WebPageVariantsResponse,
)
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel
class EdgeConditionSchema(BaseModel):
field: str
operator: str
value: Any = None
class FlowEdgeSchema(BaseModel):
id: str
source: str
target: str
condition: EdgeConditionSchema | None = None
label: str | None = None
priority: int | None = None
class FlowNodeSchema(BaseModel):
id: str
type: str
label: str
label_en: str | None = None
enabled: bool = True
page_slug: str | None = None
config: dict = {}
position: dict
class FlowResponse(BaseModel):
id: str
name: str
nodes: list[FlowNodeSchema]
edges: list[FlowEdgeSchema]
entry_node_id: str | None
version: int
class FlowUpdate(BaseModel):
name: str | None = None
nodes: list[FlowNodeSchema]
edges: list[FlowEdgeSchema]
entry_node_id: str | None = None
class FlowCreate(BaseModel):
id: str
name: str
nodes: list[FlowNodeSchema] = []
edges: list[FlowEdgeSchema] = []
entry_node_id: str | None = None
+36 -2
View File
@@ -13,6 +13,8 @@ class IdentityResponse(BaseModel):
email: str | None
tg_id: int | None
is_admin: bool = False
email_verified: bool = False
password_set: bool = False
created_at: datetime | None
updated_at: datetime | None
@@ -23,11 +25,12 @@ class IdentityResponse(BaseModel):
class RegisterByEmailRequest(BaseModel):
email: str = Field(..., min_length=1)
password: str = Field(..., min_length=8, description="Пароль (минимум 8 символов)")
referral_code: str | None = Field(None, min_length=1)
turnstile_token: str | None = Field(default=None, description="Cloudflare Turnstile CAPTCHA token")
class RegisterResponse(BaseModel):
identity_id: str
token: str
class LoginRequest(BaseModel):
@@ -35,13 +38,28 @@ class LoginRequest(BaseModel):
password: str = Field(...)
class SetPasswordRequest(BaseModel):
password: str = Field(..., min_length=8, description="Новый пароль (минимум 8 символов)")
password_confirm: str = Field(..., min_length=8)
class ChangePasswordRequest(BaseModel):
current_password: str = Field(...)
password: str = Field(..., min_length=8, description="Новый пароль (минимум 8 символов)")
password_confirm: str = Field(..., min_length=8)
class LoginResponse(BaseModel):
identity_id: str
token: str
class SendLoginCodeRequest(BaseModel):
email: str = Field(..., min_length=1)
allow_register: bool = Field(
default=False,
description="Если true и email новый — создать идентичность и отправить код (гостевой вход с сайта)",
)
turnstile_token: str | None = Field(default=None, description="Cloudflare Turnstile CAPTCHA token")
class LoginByCodeRequest(BaseModel):
@@ -49,6 +67,13 @@ class LoginByCodeRequest(BaseModel):
code: str = Field(..., min_length=1)
class ConfirmPasswordResetRequest(BaseModel):
email: str = Field(..., min_length=1)
code: str = Field(..., min_length=1)
password: str = Field(..., min_length=8)
password_confirm: str = Field(..., min_length=8)
class LoginTelegramRequest(BaseModel):
"""Данные от Telegram Login Widget (кнопка «Войти через Telegram»)."""
@@ -77,5 +102,14 @@ class IdentityAttachEmail(BaseModel):
email: str = Field(..., min_length=1)
class LinkEmailSendCodeRequest(BaseModel):
email: str = Field(..., min_length=1)
class LinkEmailConfirmRequest(BaseModel):
email: str = Field(..., min_length=1)
code: str = Field(..., min_length=1, max_length=16)
class IdentityAttachTelegram(BaseModel):
tg_id: int = Field(...)
+8
View File
@@ -22,3 +22,11 @@ class PaymentLinkCreateResponse(BaseModel):
payment_id: str | None = None
payment_url: str | None = None
error: str | None = None
class PaymentLinkStatusResponse(BaseModel):
success: bool
payment_id: str
status: str | None = None
completed: bool = False
paid: bool = False
+4 -1
View File
@@ -17,4 +17,7 @@ class TariffPublic(BaseModel):
device_limit: int | None
subgroup_title: str | None
sort_order: int | None
vless: bool = False
vless: bool = False
configurable: bool = False
device_options: list[int] | None = None
traffic_options_gb: list[int] | None = None
+71 -1
View File
@@ -1,6 +1,9 @@
import json
from typing import Any
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
_MAX_BLOCK_DATA_SIZE = 256 * 1024
class WebBlockBase(BaseModel):
@@ -8,6 +11,12 @@ class WebBlockBase(BaseModel):
order: int
data: dict[str, Any]
@model_validator(mode="after")
def _check_data_size(self) -> "WebBlockBase":
if len(json.dumps(self.data, ensure_ascii=False)) > _MAX_BLOCK_DATA_SIZE:
raise ValueError(f"Размер data блока не должен превышать {_MAX_BLOCK_DATA_SIZE // 1024} КБ")
return self
class WebBlockResponse(WebBlockBase):
id: str
@@ -20,10 +29,19 @@ class WebTheme(BaseModel):
tokens: dict[str, Any]
class WebPageVariantSummary(BaseModel):
key: str = Field(..., max_length=64)
name: str = Field(..., max_length=255)
is_active: bool = False
class WebPageResponse(BaseModel):
slug: str
blocks: list[WebBlockResponse]
theme: WebTheme | None = None
variant_key: str = "default"
active_variant_key: str = "default"
variants: list[WebPageVariantSummary] = Field(default_factory=list)
class WebPageUpdate(BaseModel):
@@ -31,6 +49,58 @@ class WebPageUpdate(BaseModel):
theme: WebTheme | None = None
class WebPageVariantCreate(BaseModel):
key: str | None = Field(default=None, max_length=64)
name: str | None = Field(default=None, max_length=255)
from_variant_key: str | None = Field(default=None, max_length=64)
class WebPageVariantUpdate(BaseModel):
name: str | None = Field(default=None, max_length=255)
make_active: bool | None = None
class WebPageVariantsResponse(BaseModel):
slug: str
active_variant_key: str = "default"
current_variant_key: str = "default"
variants: list[WebPageVariantSummary] = Field(default_factory=list)
class WebUploadResponse(BaseModel):
url: str
class FlowStepConfig(BaseModel):
provider_ids: list[str] | None = None
tariff_group_code: str | None = None
tariff_ids: list[int] | None = None
display_mode: str | None = None
skippable: bool = False
auto_advance_if_single: bool = False
class FlowStepSchema(BaseModel):
id: str = Field(..., max_length=64)
type: str = Field(..., max_length=32)
label: str = Field(..., max_length=255)
label_en: str | None = Field(default=None, max_length=255)
enabled: bool = True
page_slug: str | None = Field(default=None, max_length=64)
config: FlowStepConfig = Field(default_factory=FlowStepConfig)
class FlowDefinitionSchema(BaseModel):
id: str = Field(..., max_length=64)
name: str = Field(..., max_length=255)
steps: list[FlowStepSchema] = Field(default_factory=list)
version: int = 1
class FlowDefinitionResponse(FlowDefinitionSchema):
pass
class FlowDefinitionUpdate(BaseModel):
name: str | None = Field(default=None, max_length=255)
steps: list[FlowStepSchema] = Field(default_factory=list)
+425
View File
@@ -0,0 +1,425 @@
from pydantic import BaseModel, Field
class AccountSummaryResponse(BaseModel):
identity_id: str
email: str | None = None
tg_id: int | None = None
linked_telegram: bool = False
referral_code: str = ""
balance: float = 0.0
trial_status: int = 0
keys_total: int = 0
referrals_total: int = 0
referrals_active: int = 0
referral_bonus_total: float = 0.0
gifts_sent: int = 0
gifts_claimed: int = 0
coupons_used: int = 0
partner_enabled: bool = False
partner_code: str = ""
partner_balance: float = 0.0
partner_percent: float = 0.0
partner_percent_custom: bool = False
partner_referred_total: int = 0
partner_payout_method: str | None = None
unread_notifications: int = 0
class AccountKeyActionsAvailability(BaseModel):
can_connect_device: bool = False
can_connect_router: bool = False
can_connect_tv: bool = False
can_renew: bool = False
can_addons: bool = False
can_reset_hwid: bool = False
can_qr: bool = False
can_delete: bool = False
can_change_location: bool = False
class AccountKeyDetailsResponse(BaseModel):
client_id: str
email: str
alias: str | None = None
expiry_time: int = 0
is_frozen: bool = False
tariff_name: str = ""
subgroup_title: str = ""
traffic_limit_gb: int = 0
used_traffic_gb: float | None = None
device_limit: int = 0
connected_devices: int = 0
is_tariff_configurable: bool = False
addons_devices_enabled: bool = False
addons_traffic_enabled: bool = False
class AccountKeyResponse(BaseModel):
email: str
alias: str | None = None
client_id: str
tariff_id: int | None = None
server_id: str
created_at: int = 0
expiry_time: int = 0
key: str | None = None
remnawave_link: str | None = None
is_frozen: bool = False
actions: AccountKeyActionsAvailability | None = None
class AccountKeyAliasUpdateRequest(BaseModel):
alias: str = Field(..., min_length=1, max_length=10)
class AccountKeyActionResponse(BaseModel):
ok: bool = True
message: str = ""
class AccountKeyRenewRequest(BaseModel):
provider_id: str | None = None
success_url: str | None = None
failure_url: str | None = None
coupon_code: str | None = None
class AccountKeyRenewResponse(AccountKeyActionResponse):
client_id: str
tariff_id: int
charged_rub: int = 0
balance_rub: float = 0.0
base_price_rub: int = 0
discount_rub: int = 0
final_price_rub: int = 0
applied_coupon_code: str | None = None
payment_required: bool = False
required_amount_rub: int = 0
payment_id: str | None = None
payment_url: str | None = None
class AccountKeyResetHwidResponse(AccountKeyActionResponse):
total_devices: int = 0
reset_devices: int = 0
class AccountKeyQrResponse(AccountKeyActionResponse):
link: str = ""
image_data_url: str = ""
class AccountKeyLocationOptionResponse(BaseModel):
server_name: str
class AccountKeyLocationsResponse(BaseModel):
client_id: str
current_server: str = ""
locations: list[AccountKeyLocationOptionResponse] = []
class AccountKeyChangeLocationRequest(BaseModel):
server_name: str = Field(..., min_length=1)
class AccountKeyChangeLocationResponse(AccountKeyActionResponse):
client_id: str
server_id: str = ""
link: str = ""
remnawave_link: str | None = None
class AccountKeyAddonOptionResponse(BaseModel):
value: int
label: str = ""
class AccountKeyAddonsPreviewRequest(BaseModel):
selected_device_limit: int | None = None
selected_traffic_gb: int | None = None
include_device: bool | None = None
include_traffic: bool | None = None
provider_id: str | None = None
success_url: str | None = None
failure_url: str | None = None
coupon_code: str | None = None
class AccountKeyAddonsPreviewResponse(BaseModel):
client_id: str
tariff_id: int
addons_mode: str = ""
has_device_option: bool = False
has_traffic_option: bool = False
current_device_limit: int | None = None
current_traffic_gb: int | None = None
selected_device_limit: int | None = None
selected_traffic_gb: int | None = None
device_options: list[AccountKeyAddonOptionResponse] = []
traffic_options: list[AccountKeyAddonOptionResponse] = []
total_price_rub: int = 0
extra_price_rub: int = 0
discount_rub: int = 0
final_price_rub: int = 0
applied_coupon_code: str | None = None
balance_rub: float = 0.0
class AccountKeyApplyAddonsResponse(AccountKeyActionResponse):
client_id: str
tariff_id: int
total_price_rub: int = 0
extra_price_rub: int = 0
discount_rub: int = 0
final_price_rub: int = 0
applied_coupon_code: str | None = None
charged_rub: int = 0
balance_rub: float = 0.0
payment_required: bool = False
required_amount_rub: int = 0
payment_id: str | None = None
payment_url: str | None = None
class AccountKeyActionsConfigResponse(BaseModel):
renew_enabled: bool = True
delete_enabled: bool = False
qr_enabled: bool = False
hwid_reset_enabled: bool = False
country_change_enabled: bool = False
instructions_enabled: bool = False
addons_enabled: bool = False
addons_mode: str = ""
tv_connect_enabled: bool = False
class TariffConfigPriceResponse(BaseModel):
price_rub: int
class TariffPurchaseRequest(BaseModel):
tariff_id: int = Field(..., ge=1)
selected_device_limit: int | None = None
selected_traffic_gb: int | None = None
provider_id: str | None = None
success_url: str | None = None
failure_url: str | None = None
coupon_code: str | None = None
class TariffPurchaseResponse(BaseModel):
ok: bool = True
message: str = ""
key_email: str | None = None
charged_rub: int | None = None
base_price_rub: int = 0
discount_rub: int = 0
final_price_rub: int = 0
applied_coupon_code: str | None = None
payment_required: bool = False
required_amount_rub: int = 0
payment_id: str | None = None
payment_url: str | None = None
class GiftCreateRequest(BaseModel):
tariff_id: int = Field(..., ge=1)
selected_device_limit: int | None = None
selected_traffic_gb: int | None = None
provider_id: str | None = None
success_url: str | None = None
failure_url: str | None = None
class GiftCreatePreviewResponse(BaseModel):
ok: bool = True
price_rub: int = 0
balance_rub: float = 0.0
sufficient_funds: bool = True
tariff_name: str = ""
duration_days: int = 0
class GiftCreateResponse(BaseModel):
ok: bool = True
message: str = ""
gift_id: str = ""
site_gift_link: str = ""
tariff_name: str = ""
duration_days: int = 0
price_charged: int = 0
balance_rub: float = 0.0
payment_required: bool = False
required_amount_rub: int = 0
payment_id: str | None = None
payment_url: str | None = None
class GiftUsageEntry(BaseModel):
user_id: int
used_at: str | None = None
class MyGiftItem(BaseModel):
gift_id: str
tariff_name: str = ""
duration_days: int = 0
price_rub: int = 0
created_at: str | None = None
expiry_time: str | None = None
is_used: bool = False
is_unlimited: bool = False
max_usages: int | None = None
site_gift_link: str = ""
usages: list[GiftUsageEntry] = []
class MyGiftsResponse(BaseModel):
ok: bool = True
gifts: list[MyGiftItem] = []
total: int = 0
limit: int = 20
offset: int = 0
class GiftRedeemRequest(BaseModel):
gift_code: str = Field(..., min_length=1)
class GiftRedeemResponse(BaseModel):
ok: bool = True
message: str = ""
gift_id: str = ""
tariff_id: int = 0
duration_days: int = 0
class ReferralApplyRequest(BaseModel):
referrer_code: str | None = Field(None, min_length=1)
referrer_tg_id: int | None = Field(None, ge=1)
class ReferralApplyResponse(BaseModel):
ok: bool = True
message: str = ""
referrer_code: str = ""
referrer_user_id: int = 0
referrer_tg_id: int | None = None
referred_user_id: int = 0
referred_tg_id: int | None = None
class ReferralTopEntryResponse(BaseModel):
position: int
referrer_user_id: int
referrals_count: int
display_id: str
class ReferralTopResponse(BaseModel):
user_referrals_count: int = 0
user_position: int | None = None
top: list[ReferralTopEntryResponse] = []
class ReferralQrResponse(BaseModel):
ok: bool = True
link: str = ""
image_data_url: str = ""
class ReferralConditionsResponse(BaseModel):
title: str = ""
summary: str = ""
bonus_mode: str = ""
bonus_mode_label: str = ""
level_lines: list[str] = []
rules: list[str] = []
class PartnerConditionsResponse(BaseModel):
title: str = ""
summary: str = ""
bonus_mode: str = ""
bonus_mode_label: str = ""
level_lines: list[str] = []
rules: list[str] = []
examples: list[str] = []
min_payout_rub: float = 0.0
payout_methods: list[str] = []
custom_amount_enabled: bool = False
class PartnerQrResponse(BaseModel):
ok: bool = True
link: str = ""
image_data_url: str = ""
class PartnerApplyRequest(BaseModel):
partner_code: str | None = Field(None, min_length=1)
partner_tg_id: int | None = Field(None, ge=1)
class PartnerApplyResponse(BaseModel):
ok: bool = True
message: str = ""
partner_code: str = ""
partner_user_id: int = 0
partner_tg_id: int | None = None
joined_user_id: int = 0
joined_tg_id: int | None = None
class PartnerTopEntryResponse(BaseModel):
position: int
partner_user_id: int
referred_count: int
display_id: str
class PartnerTopResponse(BaseModel):
user_referred_count: int = 0
user_position: int | None = None
top: list[PartnerTopEntryResponse] = []
class CouponApplyRequest(BaseModel):
code: str = Field(..., min_length=1, max_length=128)
class CouponApplyResponse(BaseModel):
ok: bool = True
message: str = ""
coupon_code: str = ""
amount: int = 0
balance: float = 0.0
class PartnerPayoutRequestCreate(BaseModel):
amount_rub: float = Field(..., gt=0)
class PartnerPayoutRequestResponse(BaseModel):
ok: bool = True
message: str = ""
request_id: int | None = None
amount_rub: float = 0.0
status: str = "pending"
balance_rub: float = 0.0
class PartnerPayoutEntryResponse(BaseModel):
id: int
amount_rub: float = 0.0
status: str = ""
created_at: str | None = None
method: str | None = None
destination: str | None = None
class PartnerPayoutHistoryResponse(BaseModel):
total: int = 0
items: list[PartnerPayoutEntryResponse] = []