Accounts/ Email search + unlink/ Symmetric identity merge/ Schema v19 keys PK/ Web-app UX/ Setup wizard/ Post-payment flow
This commit is contained in:
+38
-1
@@ -2,7 +2,8 @@ import asyncio
|
||||
import os
|
||||
from time import perf_counter
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
@@ -105,6 +106,42 @@ async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
from api.depends import get_session as _get_session, verify_identity_admin as _verify_admin
|
||||
|
||||
|
||||
@app.get("/api/health/detailed", include_in_schema=False)
|
||||
async def health_detailed(
|
||||
session: AsyncSession = Depends(_get_session),
|
||||
_identity=Depends(_verify_admin),
|
||||
):
|
||||
import time
|
||||
from sqlalchemy import text
|
||||
from core.redis_cache import _get_redis
|
||||
|
||||
checks: dict[str, object] = {"status": "ok", "timestamp": int(time.time())}
|
||||
|
||||
try:
|
||||
await session.execute(text("SELECT 1"))
|
||||
checks["db"] = {"ok": True}
|
||||
except Exception as e:
|
||||
checks["db"] = {"ok": False, "error": str(e)[:200]}
|
||||
checks["status"] = "degraded"
|
||||
|
||||
try:
|
||||
client = await _get_redis()
|
||||
if client is not None:
|
||||
await client.ping()
|
||||
checks["redis"] = {"ok": True}
|
||||
else:
|
||||
checks["redis"] = {"ok": False, "error": "unavailable"}
|
||||
checks["status"] = "degraded"
|
||||
except Exception as e:
|
||||
checks["redis"] = {"ok": False, "error": str(e)[:200]}
|
||||
checks["status"] = "degraded"
|
||||
|
||||
return checks
|
||||
|
||||
|
||||
app.include_router(api_router)
|
||||
|
||||
_web_uploads_dir = "static/web_uploads"
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import (
|
||||
bind_identity_actor,
|
||||
get_session,
|
||||
set_is_admin_cookie,
|
||||
verify_identity_token,
|
||||
)
|
||||
from api.v2.routes.auth._common import _client_ip
|
||||
@@ -46,7 +47,16 @@ async def link_email_send_code(
|
||||
)
|
||||
existing = await idb.get_identity_by_email(session, email_norm)
|
||||
if existing and existing.id != identity.id:
|
||||
raise HTTPException(status_code=400, detail="Не удалось привязать email")
|
||||
our_tg = identity.tg_id
|
||||
their_tg = existing.tg_id
|
||||
can_merge = their_tg is None or (
|
||||
our_tg is not None and int(their_tg) == int(our_tg)
|
||||
)
|
||||
if not can_merge:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Этот email уже привязан к другому аккаунту",
|
||||
)
|
||||
ip = _client_ip(request)
|
||||
if not await email_link_code.try_consume_ip_budget(ip):
|
||||
raise HTTPException(
|
||||
@@ -86,6 +96,7 @@ async def link_email_send_code(
|
||||
async def link_email_confirm(
|
||||
body: LinkEmailConfirmRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
@@ -111,4 +122,5 @@ async def link_email_confirm(
|
||||
detail="Этот email уже привязан к другой идентичности",
|
||||
)
|
||||
await bind_identity_actor(request, session, result)
|
||||
set_is_admin_cookie(response, result, request)
|
||||
return IdentityResponse.model_validate(result)
|
||||
|
||||
@@ -84,6 +84,7 @@ async def login_telegram_webapp(
|
||||
async def link_telegram(
|
||||
body: LinkTelegramRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
@@ -98,4 +99,5 @@ async def link_telegram(
|
||||
detail="Этот Telegram уже привязан к другой идентичности",
|
||||
)
|
||||
await bind_identity_actor(request, session, result)
|
||||
set_is_admin_cookie(response, result, request)
|
||||
return IdentityResponse.model_validate(result)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import hashlib
|
||||
import re
|
||||
import uuid
|
||||
|
||||
@@ -22,6 +23,7 @@ from api.v2.schemas.web import (
|
||||
from database.models import (
|
||||
WebBlock,
|
||||
WebCustomElementBuild,
|
||||
WebErrorReport,
|
||||
WebFlow,
|
||||
WebFlowEvent,
|
||||
WebPage,
|
||||
@@ -712,3 +714,159 @@ async def get_flow_funnel(
|
||||
) if prev_entered > 0 else 0
|
||||
|
||||
return {"flowId": flow_id, "days": days, "funnel": funnel}
|
||||
|
||||
|
||||
# ── Error aggregation (in-house Sentry) ──
|
||||
|
||||
|
||||
def _error_signature(name: str, message: str, stack: str | None, url: str | None) -> str:
|
||||
"""Группировочная подпись: name + первая stack-frame + pathname."""
|
||||
first_frame = ""
|
||||
if stack:
|
||||
for line in stack.split("\n"):
|
||||
s = line.strip()
|
||||
if s.startswith("at ") or "webpack-internal" in s or ".tsx:" in s or ".ts:" in s or ".js:" in s:
|
||||
first_frame = s[:200]
|
||||
break
|
||||
pathname = ""
|
||||
if url:
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
pathname = urlparse(url).path[:100]
|
||||
except Exception:
|
||||
pass
|
||||
key = f"{name}|{first_frame}|{pathname}|{message[:120]}"
|
||||
return hashlib.sha256(key.encode("utf-8")).hexdigest()[:32]
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@router.post("/error-reports")
|
||||
async def ingest_error_report(
|
||||
body: ErrorReportIngest,
|
||||
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"error_report_rate:{ip}", 60)
|
||||
if not redis_ok:
|
||||
count = check_and_increment(f"error_report_rate:{ip}", 30, 60)
|
||||
if count > 30:
|
||||
raise HTTPException(status_code=429, detail="Too many error reports")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
signature = _error_signature(body.name, body.message, body.stack, body.url)
|
||||
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(WebErrorReport).where(WebErrorReport.signature == signature)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
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
|
||||
return {"ok": True, "id": existing.id, "count": existing.count, "deduplicated": True}
|
||||
|
||||
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,
|
||||
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,
|
||||
count=1,
|
||||
resolved=False,
|
||||
)
|
||||
session.add(report)
|
||||
return {"ok": True, "id": report.id, "count": 1, "deduplicated": False}
|
||||
|
||||
|
||||
@router.get("/error-reports")
|
||||
async def list_error_reports(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
resolved: bool | None = Query(None),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
q = select(WebErrorReport).order_by(WebErrorReport.last_seen_at.desc())
|
||||
if resolved is not None:
|
||||
q = q.where(WebErrorReport.resolved == resolved)
|
||||
q = q.offset(offset).limit(limit)
|
||||
rows = (await session.execute(q)).scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"signature": r.signature,
|
||||
"errorName": r.error_name,
|
||||
"errorMessage": r.error_message,
|
||||
"stack": r.stack,
|
||||
"url": r.url,
|
||||
"userAgent": r.user_agent,
|
||||
"tag": r.tag,
|
||||
"lastIdentityId": r.last_identity_id,
|
||||
"lastContext": r.last_context,
|
||||
"count": r.count,
|
||||
"resolved": r.resolved,
|
||||
"firstSeenAt": r.first_seen_at.isoformat() if r.first_seen_at else None,
|
||||
"lastSeenAt": r.last_seen_at.isoformat() if r.last_seen_at else None,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
class ErrorReportPatch(BaseModel):
|
||||
resolved: bool | None = None
|
||||
|
||||
|
||||
@router.patch("/error-reports/{report_id}")
|
||||
async def update_error_report(
|
||||
report_id: str,
|
||||
body: ErrorReportPatch,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
report = await session.get(WebErrorReport, report_id)
|
||||
if not report:
|
||||
raise HTTPException(404, "Not found")
|
||||
if body.resolved is not None:
|
||||
report.resolved = body.resolved
|
||||
return {"ok": True, "resolved": report.resolved}
|
||||
|
||||
|
||||
@router.delete("/error-reports/{report_id}")
|
||||
async def delete_error_report(
|
||||
report_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
report = await session.get(WebErrorReport, report_id)
|
||||
if not report:
|
||||
raise HTTPException(404, "Not found")
|
||||
await session.delete(report)
|
||||
return {"ok": True}
|
||||
|
||||
@@ -56,8 +56,8 @@ class LoginResponse(BaseModel):
|
||||
class SendLoginCodeRequest(BaseModel):
|
||||
email: str = Field(..., min_length=1)
|
||||
allow_register: bool = Field(
|
||||
default=False,
|
||||
description="Если true и email новый — создать идентичность и отправить код (гостевой вход с сайта)",
|
||||
default=True,
|
||||
description="Если true и email новый — создать идентичность и отправить код (passwordless flow)",
|
||||
)
|
||||
turnstile_token: str | None = Field(default=None, description="Cloudflare Turnstile CAPTCHA token")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user