up version
This commit is contained in:
+13
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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="Удаление подписки отключено в настройках")
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
+1
-1
@@ -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()}"
|
||||
|
||||
Reference in New Issue
Block a user