From db14ba6dac34e9762cf3841dfa8e89d9bac2bdb3 Mon Sep 17 00:00:00 2001 From: Vladless Date: Thu, 23 Apr 2026 00:30:12 +0000 Subject: [PATCH] up version --- api/main.py | 13 ++++ api/ratelimit.py | 62 ++++++++++++++++++ api/v2/routes/auth/telegram.py | 12 ++-- api/v2/routes/coupons.py | 2 + api/v2/routes/gifts.py | 5 ++ api/v2/routes/keys/user/core.py | 6 ++ api/v2/routes/keys/user/renew.py | 4 ++ api/v2/routes/web.py | 104 +++++++++++++++++++++++++------ main.py | 0 utils/versioning.py | 2 +- 10 files changed, 186 insertions(+), 24 deletions(-) create mode 100644 api/ratelimit.py mode change 100644 => 100755 main.py diff --git a/api/main.py b/api/main.py index 093a7892..381af118 100644 --- a/api/main.py +++ b/api/main.py @@ -45,6 +45,19 @@ app.add_middleware( app.add_middleware(GZipMiddleware, minimum_size=1024, compresslevel=6) + +@app.exception_handler(Exception) +async def _generic_exception_handler(request: Request, exc: Exception): + from audit import ensure_api_context + + context = ensure_api_context(request) + logger.exception("[API] Unhandled exception at {} {}: {}", request.method, request.url.path, exc) + return ORJSONResponse( + status_code=500, + content={"detail": "Внутренняя ошибка сервера", "request_id": context.request_id}, + ) + + _ETAG_MAX_BODY_BYTES = 256 * 1024 diff --git a/api/ratelimit.py b/api/ratelimit.py new file mode 100644 index 00000000..2a3f47c5 --- /dev/null +++ b/api/ratelimit.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from fastapi import HTTPException, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from api.depends import _identity_from_cookie + + +async def enforce_rate_limit( + request: Request, + session: AsyncSession, + *, + bucket: str, + max_per_window: int, + window_sec: int, + identity_aware: bool = True, +) -> None: + try: + from api.v2.routes.auth._fallback_limiter import check_and_increment + from core.redis_cache import cache_incr_checked + except Exception: + return + + owner = "anon" + if identity_aware: + try: + identity = await _identity_from_cookie(session, request) + if identity is not None and getattr(identity, "id", None): + owner = f"id:{identity.id}" + except Exception: + pass + + if owner == "anon": + ip = (request.client.host if request.client else "") or "unknown" + owner = f"ip:{ip}" + + key = f"rl:{bucket}:{owner}" + try: + count, redis_ok = await cache_incr_checked(key, window_sec) + if not redis_ok: + count = check_and_increment(key, max_per_window, window_sec) + except Exception: + return + + if count > max_per_window: + raise HTTPException(status_code=429, detail="Слишком много запросов, подождите и попробуйте снова") + + +def rate_limit_dependency(*, bucket: str, max_per_window: int, window_sec: int): + from api.depends import get_session + from fastapi import Depends + + async def _dep(request: Request, session: AsyncSession = Depends(get_session)) -> None: + await enforce_rate_limit( + request, + session, + bucket=bucket, + max_per_window=max_per_window, + window_sec=window_sec, + ) + + return _dep diff --git a/api/v2/routes/auth/telegram.py b/api/v2/routes/auth/telegram.py index ba503d90..7bad1c44 100644 --- a/api/v2/routes/auth/telegram.py +++ b/api/v2/routes/auth/telegram.py @@ -124,7 +124,8 @@ async def login_telegram_oidc( client_id, client_secret = _get_oidc_credentials() if not client_id or not client_secret: - raise HTTPException(status_code=503, detail="Telegram OIDC не настроен: отсутствуют TELEGRAM_CLIENT_ID / TELEGRAM_CLIENT_SECRET") + logger.warning("[Auth] Telegram OIDC credentials missing in config") + raise HTTPException(status_code=503, detail="Вход через Telegram временно недоступен") token_data = { "grant_type": "authorization_code", @@ -186,9 +187,12 @@ async def login_telegram_oidc( if not tg_id: raise HTTPException(status_code=401, detail="Не удалось определить пользователя из id_token") - tg_id_int = int(tg_id) - if tg_id_int > 2**53: - raise HTTPException(status_code=401, detail=f"Некорректный Telegram ID: {tg_id}") + try: + tg_id_int = int(tg_id) + except (TypeError, ValueError): + raise HTTPException(status_code=401, detail="Не удалось определить пользователя") from None + if tg_id_int <= 0 or tg_id_int > 2**53: + raise HTTPException(status_code=401, detail="Не удалось определить пользователя") identity = await idb.get_or_create_identity_for_tg(session, tg_id_int) await bind_identity_actor(request, session, identity) diff --git a/api/v2/routes/coupons.py b/api/v2/routes/coupons.py index 1d6551b5..17a81089 100644 --- a/api/v2/routes/coupons.py +++ b/api/v2/routes/coupons.py @@ -48,6 +48,8 @@ async def apply_coupon( session: AsyncSession = Depends(get_session), identity=Depends(verify_identity_token), ): + from api.ratelimit import enforce_rate_limit + await enforce_rate_limit(request, session, bucket="coupon_apply", max_per_window=10, window_sec=60) user_id, tg_id = await _resolve_coupon_user_id(session, request, identity) try: result = await apply_fixed_coupon( diff --git a/api/v2/routes/gifts.py b/api/v2/routes/gifts.py index c167c359..133958e4 100644 --- a/api/v2/routes/gifts.py +++ b/api/v2/routes/gifts.py @@ -60,6 +60,9 @@ async def create_gift_for_user( session: AsyncSession = Depends(get_session), identity=Depends(verify_identity_token), ): + from api.ratelimit import enforce_rate_limit + if not preview: + await enforce_rate_limit(request, session, bucket="gift_create", max_per_window=10, window_sec=60) _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 @@ -266,6 +269,8 @@ async def redeem_gift( session: AsyncSession = Depends(get_session), identity=Depends(verify_identity_token), ): + from api.ratelimit import enforce_rate_limit + await enforce_rate_limit(request, session, bucket="gift_redeem", max_per_window=10, window_sec=60) _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 diff --git a/api/v2/routes/keys/user/core.py b/api/v2/routes/keys/user/core.py index 2d95c5da..1c169212 100644 --- a/api/v2/routes/keys/user/core.py +++ b/api/v2/routes/keys/user/core.py @@ -146,6 +146,8 @@ async def user_key_qr( session: AsyncSession = Depends(get_session), identity=Depends(verify_identity_token), ): + from api.ratelimit import enforce_rate_limit + await enforce_rate_limit(request, session, bucket="key_qr", max_per_window=30, window_sec=60) actions = _key_actions_config() if not force_web and not actions.qr_enabled: raise HTTPException(status_code=403, detail="QR для подписок отключен в настройках") @@ -181,6 +183,8 @@ async def user_key_update_alias( session: AsyncSession = Depends(get_session), identity=Depends(verify_identity_token), ): + from api.ratelimit import enforce_rate_limit + await enforce_rate_limit(request, session, bucket="key_alias", max_per_window=20, window_sec=60) alias = str(body.alias or "").strip() if not alias: raise HTTPException(status_code=400, detail="Укажите alias") @@ -217,6 +221,8 @@ async def user_key_delete( session: AsyncSession = Depends(get_session), identity=Depends(verify_identity_token), ): + from api.ratelimit import enforce_rate_limit + await enforce_rate_limit(request, session, bucket="key_delete", max_per_window=10, window_sec=60) actions = _key_actions_config() if not force_web and not actions.delete_enabled: raise HTTPException(status_code=403, detail="Удаление подписки отключено в настройках") diff --git a/api/v2/routes/keys/user/renew.py b/api/v2/routes/keys/user/renew.py index f6a33d00..3071e79d 100644 --- a/api/v2/routes/keys/user/renew.py +++ b/api/v2/routes/keys/user/renew.py @@ -27,6 +27,7 @@ async def user_key_renew( session: AsyncSession = Depends(get_session), identity=Depends(verify_identity_token), ): + from api.ratelimit import enforce_rate_limit from services.errors import ServiceError from services.keys import ( calculate_renewal_pricing, @@ -34,6 +35,9 @@ async def user_key_renew( normalize_expiry_ms as _svc_normalize_expiry, ) + if not preview: + await enforce_rate_limit(request, session, bucket="key_renew", max_per_window=10, window_sec=60) + actions = _key_actions_config() if not force_web and not actions.renew_enabled: raise HTTPException(status_code=403, detail="Продление подписки отключено в настройках") diff --git a/api/v2/routes/web.py b/api/v2/routes/web.py index c347c978..ecc0fdd8 100644 --- a/api/v2/routes/web.py +++ b/api/v2/routes/web.py @@ -10,7 +10,7 @@ from pydantic import BaseModel 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 _identity_from_cookie, get_session, verify_identity_admin from database.site_revision import bump_site_revision from api.v2.schemas import WebBlockResponse, WebPageResponse, WebPageUpdate, WebTheme from api.v2.schemas.web import ( @@ -721,6 +721,29 @@ async def delete_custom_element_build( # ── Flow Analytics ── +_SENSITIVE_KEY_RE = re.compile( + r"(token|password|secret|api[_-]?key|authorization|cookie|session|auth|credential|bearer|pass|passwd|access[_-]?token|refresh[_-]?token|phone|email|hash|private|pin)", + re.IGNORECASE, +) +_REDACTED = "[redacted]" +_MAX_REDACT_DEPTH = 6 + + +def _redact_sensitive(value, depth: int = 0): + if depth >= _MAX_REDACT_DEPTH: + return _REDACTED + if isinstance(value, dict): + return { + k: (_REDACTED if isinstance(k, str) and _SENSITIVE_KEY_RE.search(k) else _redact_sensitive(v, depth + 1)) + for k, v in value.items() + } + if isinstance(value, list): + return [_redact_sensitive(v, depth + 1) for v in value[:100]] + if isinstance(value, str) and len(value) > 2000: + return value[:2000] + "…" + return value + + class FlowEventBatch(BaseModel): events: list[dict] @@ -745,24 +768,31 @@ async def ingest_flow_events( raise except Exception: pass + server_identity = await _identity_from_cookie(session, request) + server_authenticated = server_identity is not None 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", "")) + flow_id = str(raw.get("flowId", ""))[:64] + node_id = str(raw.get("nodeId", ""))[:64] + event_type = str(raw.get("eventType", ""))[:32] if not flow_id or not node_id or not event_type: continue + metadata = raw.get("collectedDataSnapshot") + if isinstance(metadata, dict): + metadata = _redact_sensitive(metadata) + else: + metadata = None ev = WebFlowEvent( id=str(uuid.uuid4()), flow_id=flow_id, node_id=node_id, - node_type=str(raw.get("nodeType", "")), + node_type=str(raw.get("nodeType", ""))[:32], 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, + ab_variant=(str(raw.get("abVariant"))[:16] if raw.get("abVariant") else None), + device=(str(raw.get("device"))[:16] if raw.get("device") else None), + locale=(str(raw.get("locale"))[:8] if raw.get("locale") else None), + authenticated=server_authenticated, + event_metadata=metadata, ) session.add(ev) created += 1 @@ -841,13 +871,24 @@ def _error_signature(name: str, message: str, stack: str | None, url: str | None return hashlib.sha256(key.encode("utf-8")).hexdigest()[:32] +def _sanitize_http_url(value: str | None) -> str | None: + if not value: + return None + trimmed = str(value).strip() + if not trimmed: + return None + lowered = trimmed.lower() + if not (lowered.startswith("http://") or lowered.startswith("https://") or lowered.startswith("/")): + return None + return trimmed[:500] + + class ErrorReportIngest(BaseModel): name: str = "" message: str stack: str | None = None url: str | None = None userAgent: str | None = None - identityId: str | None = None tag: str | None = None context: dict | None = None @@ -873,7 +914,16 @@ async def ingest_error_report( except Exception: pass - signature = _error_signature(body.name, body.message, body.stack, body.url) + server_identity = await _identity_from_cookie(session, request) + server_identity_id = getattr(server_identity, "id", None) if server_identity else None + + safe_context = None + if isinstance(body.context, dict): + safe_context = _redact_sensitive(body.context) + + safe_url = _sanitize_http_url(body.url) + + signature = _error_signature(body.name, body.message, body.stack, safe_url) existing = ( await session.execute(select(WebErrorReport).where(WebErrorReport.signature == signature)) @@ -883,23 +933,39 @@ async def ingest_error_report( existing.count += 1 existing.last_seen_at = datetime.now(timezone.utc) existing.resolved = False - if body.context: - existing.last_context = body.context - if body.identityId: - existing.last_identity_id = body.identityId + if safe_context is not None: + existing.last_context = safe_context + if server_identity_id: + existing.last_identity_id = server_identity_id[:36] return {"ok": True, "id": existing.id, "count": existing.count, "deduplicated": True} + try: + from api.v2.routes.auth._fallback_limiter import check_and_increment as _sig_check + from core.redis_cache import cache_incr_checked as _sig_cache + + ip = (request.client.host if request.client else "") or "unknown" + unique_key = f"error_sig_unique:{ip}" + count_uniq, redis_ok = await _sig_cache(unique_key, 3600) + if not redis_ok: + count_uniq = _sig_check(unique_key, 20, 3600) + if count_uniq > 20: + raise HTTPException(status_code=429, detail="Too many distinct errors") + except HTTPException: + raise + except Exception: + pass + report = WebErrorReport( id=str(uuid.uuid4()), signature=signature, error_name=body.name[:255] if body.name else "", error_message=body.message[:4000] if body.message else "", stack=body.stack[:16000] if body.stack else None, - url=body.url[:500] if body.url else None, + url=safe_url, user_agent=body.userAgent[:500] if body.userAgent else None, tag=body.tag[:64] if body.tag else None, - last_identity_id=body.identityId[:36] if body.identityId else None, - last_context=body.context, + last_identity_id=server_identity_id[:36] if server_identity_id else None, + last_context=safe_context, count=1, resolved=False, ) diff --git a/main.py b/main.py old mode 100644 new mode 100755 diff --git a/utils/versioning.py b/utils/versioning.py index d5487fa4..47063b68 100644 --- a/utils/versioning.py +++ b/utils/versioning.py @@ -92,4 +92,4 @@ def get_git_commit_number() -> str: def get_version() -> str: - return f"v.6-b2204260016 {get_git_commit_number()}" + return f"v.6-b2304260016 {get_git_commit_number()}"