up version

This commit is contained in:
Vladless
2026-04-23 00:30:12 +00:00
parent d46a1a4e3a
commit db14ba6dac
10 changed files with 186 additions and 24 deletions
+13
View File
@@ -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
+62
View File
@@ -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
+8 -4
View File
@@ -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)
+2
View File
@@ -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(
+5
View File
@@ -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
+6
View File
@@ -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="Удаление подписки отключено в настройках")
+4
View File
@@ -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="Продление подписки отключено в настройках")
+85 -19
View File
@@ -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,
)
Regular → Executable
View File
+1 -1
View File
@@ -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()}"