1216 lines
40 KiB
Python
1216 lines
40 KiB
Python
import asyncio
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import logging
|
|
import time
|
|
import uuid
|
|
from contextlib import asynccontextmanager, suppress
|
|
from datetime import UTC, datetime
|
|
from ipaddress import ip_address, ip_network
|
|
from typing import Annotated, Any
|
|
|
|
import httpx
|
|
import redis.asyncio as redis
|
|
import structlog
|
|
import uvicorn
|
|
from fastapi import (
|
|
Depends,
|
|
FastAPI,
|
|
Header,
|
|
Query,
|
|
Request,
|
|
Response,
|
|
WebSocket,
|
|
WebSocketDisconnect,
|
|
)
|
|
from fastapi.exceptions import RequestValidationError
|
|
from fastapi.responses import JSONResponse
|
|
from sqlalchemy import and_, desc, func, or_, select, text
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
|
|
from app.auth import AuthError, JWKSValidator, Principal
|
|
from app.db import (
|
|
Database,
|
|
Dialog,
|
|
Document,
|
|
Message,
|
|
MessageAttachment,
|
|
PopularQuestion,
|
|
TextResource,
|
|
UserIdentity,
|
|
UxSession,
|
|
)
|
|
from app.integrations import (
|
|
DependencyFailure,
|
|
OpenLinesClient,
|
|
RateLimiter,
|
|
RedisIdempotency,
|
|
S3Client,
|
|
SafetyClient,
|
|
)
|
|
from app.logging_security import redact_event
|
|
from app.metrics import AUTH_BOOTSTRAP, HTTP_DURATION, HTTP_REQUESTS, RATE_LIMIT_DECISIONS
|
|
from app.notification_routes import router as notification_router
|
|
from app.notification_service import synchronize_source_tokens
|
|
from app.realtime import RealtimeFanout
|
|
from app.schemas import (
|
|
AttachmentCompleteRequest,
|
|
AttachmentInitRequest,
|
|
BootstrapRequest,
|
|
ConsentsRequest,
|
|
MessageRequest,
|
|
OpenLinesInbox,
|
|
OtpSettingsResponse,
|
|
PublicAppConfigResponse,
|
|
SessionStartRequest,
|
|
decode_cursor,
|
|
encode_cursor,
|
|
)
|
|
from app.services import (
|
|
AuditContext,
|
|
DomainError,
|
|
SettingsSnapshot,
|
|
apply_inbox,
|
|
audit,
|
|
bootstrap,
|
|
complete_attachment,
|
|
create_dialog,
|
|
dialog_dto,
|
|
get_profile,
|
|
init_attachment,
|
|
load_settings,
|
|
message_dto,
|
|
owned_dialog,
|
|
record_consents,
|
|
resolve_user,
|
|
send_message,
|
|
start_session,
|
|
)
|
|
from app.settings import get_settings
|
|
from app.telemetry import add_trace_context, current_trace_id, init_telemetry, instrument_fastapi
|
|
|
|
|
|
def configure_logging(level: str) -> None:
|
|
logging.basicConfig(level=level, format="%(message)s")
|
|
structlog.configure(
|
|
processors=[
|
|
structlog.contextvars.merge_contextvars,
|
|
add_trace_context,
|
|
redact_event,
|
|
structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
|
|
structlog.stdlib.add_log_level,
|
|
structlog.processors.JSONRenderer(),
|
|
]
|
|
)
|
|
|
|
|
|
async def refresh_settings_cache(app: FastAPI) -> None:
|
|
while True:
|
|
try:
|
|
async with app.state.db.sessions() as db:
|
|
app.state.snapshot = await load_settings(db)
|
|
except Exception:
|
|
log.warning("settings.refresh_failed")
|
|
await asyncio.sleep(30)
|
|
|
|
|
|
async def refresh_jwks_cache(app: FastAPI) -> None:
|
|
retry_delay = 5
|
|
refresh_delay = max(30, app.state.settings.jwks_cache_ttl_seconds)
|
|
delay = refresh_delay if app.state.jwks.has_keys else retry_delay
|
|
while True:
|
|
await asyncio.sleep(delay)
|
|
try:
|
|
await app.state.jwks.refresh()
|
|
except Exception:
|
|
log.warning("jwks.refresh_failed")
|
|
delay = retry_delay
|
|
else:
|
|
delay = refresh_delay
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
settings = get_settings()
|
|
telemetry = init_telemetry()
|
|
configure_logging(settings.log_level)
|
|
app.state.settings = settings
|
|
app.state.db = Database(settings.database_url)
|
|
app.state.http = httpx.AsyncClient()
|
|
app.state.safety_http = httpx.AsyncClient(verify=settings.message_safety_ca_file)
|
|
app.state.redis = redis.from_url(settings.redis_url, decode_responses=True)
|
|
app.state.redis_rt = redis.from_url(settings.redis_realtime_url, decode_responses=True)
|
|
app.state.jwks = JWKSValidator(settings, app.state.http)
|
|
app.state.rate_limiter = RateLimiter(app.state.redis)
|
|
app.state.idempotency = RedisIdempotency(app.state.redis)
|
|
app.state.safety = SafetyClient(settings, app.state.safety_http)
|
|
app.state.openlines = OpenLinesClient(settings, app.state.http)
|
|
app.state.s3 = S3Client(settings)
|
|
app.state.realtime = RealtimeFanout(app.state.redis_rt)
|
|
app.state.snapshot = None
|
|
try:
|
|
async with app.state.db.sessions() as db:
|
|
app.state.snapshot = await load_settings(db)
|
|
await synchronize_source_tokens(db)
|
|
except Exception:
|
|
structlog.get_logger().warning("settings.warmup_failed")
|
|
settings_task = asyncio.create_task(refresh_settings_cache(app))
|
|
try:
|
|
await app.state.jwks.refresh()
|
|
except Exception:
|
|
structlog.get_logger().warning("jwks.warmup_failed")
|
|
jwks_task = asyncio.create_task(refresh_jwks_cache(app))
|
|
try:
|
|
yield
|
|
finally:
|
|
for task in (settings_task, jwks_task):
|
|
task.cancel()
|
|
with suppress(asyncio.CancelledError):
|
|
await task
|
|
await app.state.http.aclose()
|
|
await app.state.safety_http.aclose()
|
|
await app.state.redis.aclose()
|
|
await app.state.redis_rt.aclose()
|
|
await app.state.db.close()
|
|
if telemetry:
|
|
telemetry.shutdown()
|
|
|
|
|
|
EXPECTED_API_DB_REVISION = "0012_safety_v2_checkpoint"
|
|
|
|
|
|
app = FastAPI(
|
|
title="HAN Chat API",
|
|
version="1.0.0",
|
|
openapi_version="3.1.0",
|
|
docs_url=None,
|
|
redoc_url=None,
|
|
lifespan=lifespan,
|
|
)
|
|
app.include_router(notification_router)
|
|
log = structlog.get_logger()
|
|
|
|
|
|
def etag_matches(if_none_match: str | None, etag: str) -> bool:
|
|
if not if_none_match:
|
|
return False
|
|
return any(
|
|
candidate.strip().removeprefix("W/") in {"*", etag}
|
|
for candidate in if_none_match.split(",")
|
|
)
|
|
|
|
|
|
def client_ip(request: Request) -> str | None:
|
|
"""Trust forwarded client addresses only from configured reverse proxies."""
|
|
peer = request.client.host if request.client else ""
|
|
try:
|
|
peer_address = ip_address(peer)
|
|
trusted = any(
|
|
peer_address in ip_network(value.strip(), strict=False)
|
|
for value in request.app.state.settings.trusted_proxy_cidrs.split(",")
|
|
if value.strip()
|
|
)
|
|
except ValueError:
|
|
trusted = False
|
|
forwarded = request.headers.get("X-Forwarded-For", "") if trusted else ""
|
|
candidate = forwarded.split(",", 1)[0].strip() if forwarded else peer
|
|
try:
|
|
return str(ip_address(candidate))
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def request_trace_id(request: Request) -> str:
|
|
parts = request.headers.get("traceparent", "").lower().split("-")
|
|
if (
|
|
len(parts) == 4
|
|
and len(parts[0]) == 2
|
|
and len(parts[1]) == 32
|
|
and len(parts[2]) == 16
|
|
and len(parts[3]) == 2
|
|
):
|
|
try:
|
|
int("".join(parts), 16)
|
|
if parts[1] != "0" * 32 and parts[2] != "0" * 16:
|
|
return parts[1]
|
|
except ValueError:
|
|
pass
|
|
return uuid.uuid4().hex
|
|
|
|
|
|
def user_agent_hash(request: Request) -> str | None:
|
|
value = " ".join(request.headers.get("User-Agent", "").split())
|
|
return hashlib.sha256(value[:1024].encode()).hexdigest() if value else None
|
|
|
|
|
|
def audit_context(request: Request, ux_session_id: uuid.UUID | None = None) -> AuditContext:
|
|
return AuditContext(
|
|
request_id=request.state.request_id,
|
|
trace_id=request.state.trace_id,
|
|
ux_session_id=ux_session_id,
|
|
user_agent_hash=request.state.user_agent_hash,
|
|
client_ip=client_ip(request),
|
|
)
|
|
|
|
|
|
@app.middleware("http")
|
|
async def request_context(request: Request, call_next: Any) -> Response:
|
|
request_id = request.headers.get("X-Request-ID", "")
|
|
try:
|
|
request_id = str(uuid.UUID(request_id)) if request_id else str(uuid.uuid4())
|
|
except ValueError:
|
|
request_id = str(uuid.uuid4())
|
|
request.state.request_id = request_id
|
|
request.state.trace_id = current_trace_id() or request_trace_id(request)
|
|
request.state.user_agent_hash = user_agent_hash(request)
|
|
request.state.started_at = time.monotonic()
|
|
structlog.contextvars.clear_contextvars()
|
|
structlog.contextvars.bind_contextvars(
|
|
request_id=request_id,
|
|
trace_id=request.state.trace_id,
|
|
ux_session_id=request.headers.get("X-Ux-Session-Id"),
|
|
method=request.method,
|
|
**{"service.name": "api-backend"},
|
|
)
|
|
origin = request.headers.get("Origin")
|
|
cached_settings = request.app.state.snapshot
|
|
allowed_origins = (
|
|
cached_settings.strings("security.cors.allowed_origins") if cached_settings else []
|
|
)
|
|
if request.method == "OPTIONS" and origin in allowed_origins:
|
|
response = Response(status_code=204)
|
|
else:
|
|
response = await call_next(request)
|
|
if origin in allowed_origins:
|
|
response.headers["Access-Control-Allow-Origin"] = origin
|
|
response.headers["Access-Control-Allow-Headers"] = (
|
|
"Authorization,Content-Type,Idempotency-Key,X-Request-ID,X-Ux-Session-Id"
|
|
)
|
|
response.headers["Access-Control-Allow-Methods"] = "GET,POST,DELETE,OPTIONS"
|
|
response.headers["Vary"] = "Origin"
|
|
response.headers["X-Request-ID"] = request_id
|
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
response.headers["Cache-Control"] = response.headers.get("Cache-Control", "no-store")
|
|
duration_seconds = time.monotonic() - request.state.started_at
|
|
route = getattr(request.scope.get("route"), "path", "unmatched")
|
|
metric_attributes = {
|
|
"service.name": "api-backend",
|
|
"http.route": route,
|
|
"http.request.method": request.method,
|
|
"http.response.status_class": f"{response.status_code // 100}xx",
|
|
}
|
|
HTTP_REQUESTS.add(1, metric_attributes)
|
|
HTTP_DURATION.record(duration_seconds, metric_attributes)
|
|
log.info(
|
|
"request.complete",
|
|
route=route,
|
|
status_code=response.status_code,
|
|
duration_ms=round(duration_seconds * 1000, 2),
|
|
)
|
|
return response
|
|
|
|
|
|
def error_response(request: Request, code: str, message: str, status: int, details: Any = None):
|
|
return JSONResponse(
|
|
status_code=status,
|
|
content={
|
|
"error": {
|
|
"code": code,
|
|
"message": message,
|
|
"request_id": getattr(request.state, "request_id", str(uuid.uuid4())),
|
|
"details": details or {},
|
|
}
|
|
},
|
|
)
|
|
|
|
|
|
@app.exception_handler(DomainError)
|
|
async def domain_error(request: Request, exc: DomainError):
|
|
response = error_response(request, exc.code, exc.message, exc.status, exc.details)
|
|
if "retry_after" in exc.details:
|
|
response.headers["Retry-After"] = str(exc.details["retry_after"])
|
|
return response
|
|
|
|
|
|
@app.exception_handler(AuthError)
|
|
async def auth_error(request: Request, exc: AuthError):
|
|
return error_response(request, exc.code, "Authentication failed", 401)
|
|
|
|
|
|
@app.exception_handler(RequestValidationError)
|
|
async def validation_error(request: Request, exc: RequestValidationError):
|
|
details = [
|
|
{"field": ".".join(str(part) for part in item["loc"][1:]), "type": item["type"]}
|
|
for item in exc.errors()
|
|
]
|
|
return error_response(request, "validation_error", "Request validation failed", 400, details)
|
|
|
|
|
|
@app.exception_handler(StarletteHTTPException)
|
|
async def http_error(request: Request, exc: StarletteHTTPException):
|
|
code = "not_found" if exc.status_code == 404 else "forbidden"
|
|
return error_response(request, code, "Resource was not found", exc.status_code)
|
|
|
|
|
|
@app.exception_handler(Exception)
|
|
async def unhandled_error(request: Request, exc: Exception):
|
|
log.error(
|
|
"request.failed",
|
|
error_code="internal_error",
|
|
error_type=type(exc).__name__,
|
|
)
|
|
return error_response(request, "internal_error", "Internal server error", 500)
|
|
|
|
|
|
async def session(request: Request):
|
|
async for value in request.app.state.db.session():
|
|
yield value
|
|
|
|
|
|
Session = Annotated[AsyncSession, Depends(session)]
|
|
|
|
|
|
async def principal(
|
|
request: Request, authorization: Annotated[str | None, Header()] = None
|
|
) -> Principal:
|
|
if not authorization or not authorization.startswith("Bearer "):
|
|
raise AuthError()
|
|
return await request.app.state.jwks.validate(authorization.removeprefix("Bearer ").strip())
|
|
|
|
|
|
PrincipalDep = Annotated[Principal, Depends(principal)]
|
|
|
|
|
|
async def current_user(db: Session, auth: PrincipalDep) -> UserIdentity:
|
|
return await resolve_user(db, auth)
|
|
|
|
|
|
UserDep = Annotated[UserIdentity, Depends(current_user)]
|
|
|
|
|
|
async def required_user_audit_context(
|
|
request: Request,
|
|
db: Session,
|
|
user: UserDep,
|
|
x_ux_session_id: Annotated[str, Header(alias="X-Ux-Session-Id")],
|
|
) -> AuditContext:
|
|
session_id = ux_id(x_ux_session_id)
|
|
if session_id is None:
|
|
raise DomainError("validation_error", 400, "X-Ux-Session-Id is required")
|
|
exists = await db.scalar(
|
|
select(UxSession.id).where(UxSession.id == session_id, UxSession.user_id == user.id)
|
|
)
|
|
if exists is None:
|
|
raise DomainError("validation_error", 400, "X-Ux-Session-Id is invalid")
|
|
return audit_context(request, session_id)
|
|
|
|
|
|
UserAuditContextDep = Annotated[AuditContext, Depends(required_user_audit_context)]
|
|
|
|
|
|
async def snapshot(db: Session) -> SettingsSnapshot:
|
|
return await load_settings(db)
|
|
|
|
|
|
SnapshotDep = Annotated[SettingsSnapshot, Depends(snapshot)]
|
|
|
|
|
|
def ux_id(value: str | None) -> uuid.UUID | None:
|
|
try:
|
|
return uuid.UUID(value) if value else None
|
|
except ValueError:
|
|
raise DomainError("validation_error", 400, "X-Ux-Session-Id must be UUID") from None
|
|
|
|
|
|
async def service_auth(request: Request, expected: str) -> None:
|
|
authorization = request.headers.get("Authorization", "")
|
|
supplied = authorization.removeprefix("Bearer ").strip()
|
|
if not supplied or not hmac.compare_digest(supplied, expected):
|
|
raise AuthError()
|
|
|
|
|
|
async def required_idempotency(key: str | None) -> str:
|
|
if not key or len(key) > 128:
|
|
raise DomainError("validation_error", 400, "Valid Idempotency-Key is required")
|
|
return key
|
|
|
|
|
|
async def enforce_limit(
|
|
request: Request,
|
|
identity_type: str,
|
|
identity: str,
|
|
route: str,
|
|
limit_value: tuple[int, int],
|
|
*,
|
|
fail_closed: bool,
|
|
) -> None:
|
|
limit, window = limit_value
|
|
key = request.app.state.rate_limiter.key(identity_type, identity, route, window)
|
|
try:
|
|
retry_after = await request.app.state.rate_limiter.consume(key, limit, window)
|
|
except DependencyFailure:
|
|
if fail_closed:
|
|
RATE_LIMIT_DECISIONS.add(1, {"scope": identity_type, "outcome": "dependency_error"})
|
|
raise DomainError(
|
|
"dependency_unavailable", 503, "Rate limit service is unavailable"
|
|
) from None
|
|
RATE_LIMIT_DECISIONS.add(1, {"scope": identity_type, "outcome": "bypass"})
|
|
return
|
|
if retry_after:
|
|
RATE_LIMIT_DECISIONS.add(1, {"scope": identity_type, "outcome": "denied"})
|
|
raise DomainError(
|
|
"rate_limit_exceeded",
|
|
429,
|
|
"Rate limit exceeded",
|
|
{"retry_after": retry_after},
|
|
)
|
|
RATE_LIMIT_DECISIONS.add(1, {"scope": identity_type, "outcome": "allowed"})
|
|
|
|
|
|
@app.get("/health/live", tags=["health"])
|
|
async def live() -> dict[str, str]:
|
|
return {"status": "live"}
|
|
|
|
|
|
@app.get("/health/ready", tags=["health"])
|
|
async def ready(request: Request, db: Session):
|
|
components: dict[str, str] = {}
|
|
try:
|
|
await db.execute(text("SELECT 1"))
|
|
revision = await db.scalar(text("SELECT version_num FROM han_app.alembic_version LIMIT 1"))
|
|
if revision != EXPECTED_API_DB_REVISION:
|
|
raise RuntimeError("unexpected database revision")
|
|
await load_settings(db)
|
|
components["postgres"] = "ok"
|
|
components["settings"] = "ok"
|
|
except Exception:
|
|
components["postgres"] = "failed"
|
|
components["settings"] = "failed"
|
|
for name, client in (
|
|
("redis", request.app.state.redis),
|
|
("redis_realtime", request.app.state.redis_rt),
|
|
):
|
|
try:
|
|
await client.ping()
|
|
components[name] = "ok"
|
|
except Exception:
|
|
components[name] = "failed"
|
|
components["jwks"] = "ok" if request.app.state.jwks.has_keys else "failed"
|
|
components["safety"] = "ok" if await request.app.state.safety.ready() else "failed"
|
|
components["openlines"] = "ok" if await request.app.state.openlines.ready() else "degraded"
|
|
components["s3"] = "ok" if await request.app.state.s3.ready() else "degraded"
|
|
critical = {"postgres", "settings", "redis", "jwks", "safety"}
|
|
failed = any(components.get(name) == "failed" for name in critical)
|
|
status = (
|
|
"not_ready" if failed else ("degraded" if "degraded" in components.values() else "ready")
|
|
)
|
|
return JSONResponse(
|
|
{"status": status, "components": components}, status_code=503 if failed else 200
|
|
)
|
|
|
|
|
|
@app.get(
|
|
"/api/v1/public/app-config",
|
|
tags=["public"],
|
|
response_model=PublicAppConfigResponse,
|
|
responses={304: {"description": "Cached configuration is still current"}},
|
|
)
|
|
async def app_config(
|
|
request: Request,
|
|
settings: SnapshotDep,
|
|
if_none_match: Annotated[str | None, Header()] = None,
|
|
):
|
|
await enforce_limit(
|
|
request,
|
|
"ip",
|
|
client_ip(request) or "unknown",
|
|
"public",
|
|
settings.limit("rate_limit.public_endpoints.per_ip"),
|
|
fail_closed=False,
|
|
)
|
|
headers = {"Cache-Control": (
|
|
f"public, max-age={settings.integer('security.public_cache.max_age_seconds')}"
|
|
)}
|
|
etag = f'"{settings.version}"'
|
|
headers["ETag"] = etag
|
|
if etag_matches(if_none_match, etag):
|
|
return Response(status_code=304, headers=headers)
|
|
values = settings.values
|
|
body = {
|
|
"auth": {
|
|
"phone_enabled": settings.boolean("auth.phone.enabled"),
|
|
"password_enabled": settings.boolean("auth.password.enabled"),
|
|
},
|
|
"operator": {"call_phone": values["operator.call.phone"]},
|
|
"messages": {
|
|
"max_text_length": settings.integer("chat.message.max_length"),
|
|
},
|
|
"consents": {
|
|
"personal_data": {
|
|
"required": settings.boolean("consent.personal_data.required"),
|
|
"document_url": values.get("consent.personal_data.document_url"),
|
|
"privacy_policy_document_url": values.get(
|
|
"consent.privacy_policy.document_url"
|
|
),
|
|
"version": values["consent.personal_data.version"],
|
|
},
|
|
**{
|
|
name: {
|
|
"required": settings.boolean(f"consent.{name}.required"),
|
|
"document_url": values.get(f"consent.{name}.document_url"),
|
|
"version": values[f"consent.{name}.version"],
|
|
}
|
|
for name in ("user_agreement", "marketing")
|
|
},
|
|
},
|
|
"attachments": {
|
|
"allowed_extensions": settings.strings("chat.attachments.allowed_extensions"),
|
|
"allowed_mime_types": settings.strings("chat.attachments.allowed_mime_types"),
|
|
"max_size_mb": settings.integer("chat.attachments.max_size_mb"),
|
|
},
|
|
"notification": {
|
|
"carousel_autoplay_enabled": settings.boolean(
|
|
"notification.carousel.autoplay_enabled"
|
|
),
|
|
"carousel_autoplay_interval_ms": settings.integer(
|
|
"notification.carousel.autoplay_interval_ms"
|
|
),
|
|
},
|
|
"ux": {"idle_timeout_minutes": settings.integer("ux.session.idle_timeout_minutes")},
|
|
"mobile_update": {
|
|
store: {
|
|
"enabled": settings.boolean(f"mobile_update.{store}.enabled"),
|
|
"latest_build": (
|
|
settings.integer(f"mobile_update.{store}.latest_build")
|
|
if values[f"mobile_update.{store}.latest_build"]
|
|
else None
|
|
),
|
|
"minimum_build": (
|
|
settings.integer(f"mobile_update.{store}.minimum_build")
|
|
if values[f"mobile_update.{store}.minimum_build"]
|
|
else None
|
|
),
|
|
"latest_version": values[f"mobile_update.{store}.latest_version"] or None,
|
|
"store_url": values[f"mobile_update.{store}.store_url"] or None,
|
|
"release_notes": values[f"mobile_update.{store}.release_notes"] or None,
|
|
}
|
|
for store in ("google_play", "rustore", "app_store")
|
|
},
|
|
}
|
|
validated = PublicAppConfigResponse.model_validate(body)
|
|
return JSONResponse(validated.model_dump(mode="json"), headers=headers)
|
|
|
|
|
|
@app.get("/api/v1/public/content", tags=["public"])
|
|
async def content(
|
|
request: Request,
|
|
db: Session,
|
|
settings: SnapshotDep,
|
|
response: Response,
|
|
locale: str = "ru",
|
|
):
|
|
await enforce_limit(
|
|
request,
|
|
"ip",
|
|
client_ip(request) or "unknown",
|
|
"public",
|
|
settings.limit("rate_limit.public_endpoints.per_ip"),
|
|
fail_closed=False,
|
|
)
|
|
locale = "ru"
|
|
texts = (
|
|
await db.execute(
|
|
select(TextResource)
|
|
.where(TextResource.locale == locale, TextResource.record_status == "A")
|
|
.order_by(TextResource.sort_order)
|
|
)
|
|
).scalars()
|
|
questions = (
|
|
await db.execute(
|
|
select(PopularQuestion)
|
|
.where(PopularQuestion.locale == locale, PopularQuestion.record_status == "A")
|
|
.order_by(PopularQuestion.sort_order)
|
|
)
|
|
).scalars()
|
|
response.headers["ETag"] = f'"{settings.version}"'
|
|
return {
|
|
"locale": locale,
|
|
"texts": {item.mnemonic: item.text_value for item in texts},
|
|
"popular_questions": [
|
|
{"id": item.id, "mnemonic": item.mnemonic, "text": item.question_text}
|
|
for item in questions
|
|
],
|
|
"version": settings.version,
|
|
}
|
|
|
|
|
|
@app.post("/api/v1/auth/bootstrap", tags=["auth"])
|
|
async def auth_bootstrap(
|
|
body: BootstrapRequest, request: Request, db: Session, auth: PrincipalDep, settings: SnapshotDep
|
|
):
|
|
try:
|
|
result = await bootstrap(db, auth, body, settings, audit_context(request))
|
|
except Exception:
|
|
AUTH_BOOTSTRAP.add(1, {"outcome": "error"})
|
|
raise
|
|
AUTH_BOOTSTRAP.add(1, {"outcome": "success"})
|
|
return result
|
|
|
|
|
|
@app.post("/api/v1/consents", status_code=201, tags=["auth"])
|
|
async def consents(
|
|
body: ConsentsRequest,
|
|
request: Request,
|
|
db: Session,
|
|
user: UserDep,
|
|
settings: SnapshotDep,
|
|
context: UserAuditContextDep,
|
|
):
|
|
return await record_consents(db, user, body, settings, context)
|
|
|
|
|
|
@app.post("/api/v1/analytics/session-start", status_code=201, tags=["analytics"])
|
|
async def analytics_session(
|
|
body: SessionStartRequest, request: Request, db: Session, user: UserDep
|
|
):
|
|
return await start_session(db, user, body, audit_context(request))
|
|
|
|
|
|
@app.get("/api/v1/me", tags=["profile"])
|
|
async def me(db: Session, user: UserDep):
|
|
return await get_profile(db, user)
|
|
|
|
|
|
@app.get("/api/v1/me/documents", tags=["profile"])
|
|
async def documents(db: Session, user: UserDep, limit: int = Query(50, ge=1, le=100)):
|
|
items = (
|
|
(
|
|
await db.execute(
|
|
select(Document)
|
|
.where(Document.user_id == user.id, Document.record_status == "A")
|
|
.order_by(desc(Document.sent_at), desc(Document.id))
|
|
.limit(limit + 1)
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
return {"items": [document_dto(item) for item in items[:limit]], "next_cursor": None}
|
|
|
|
|
|
def document_dto(item: Document) -> dict[str, Any]:
|
|
return {
|
|
"document_id": item.id,
|
|
"name": item.name,
|
|
"mime_type": item.mime_type,
|
|
"size_bytes": item.size_bytes,
|
|
"checksum": f"sha256:{item.checksum_sha256}",
|
|
"sent_at": item.sent_at,
|
|
}
|
|
|
|
|
|
async def owned_document(db: AsyncSession, user_id: uuid.UUID, document_id: uuid.UUID):
|
|
item = (
|
|
await db.execute(
|
|
select(Document).where(
|
|
Document.id == document_id,
|
|
Document.user_id == user_id,
|
|
Document.record_status == "A",
|
|
)
|
|
)
|
|
).scalar_one_or_none()
|
|
if not item:
|
|
raise DomainError("not_found", 404, "Resource was not found")
|
|
return item
|
|
|
|
|
|
@app.get("/api/v1/documents/{document_id}", tags=["profile"])
|
|
async def document(document_id: uuid.UUID, db: Session, user: UserDep):
|
|
return document_dto(await owned_document(db, user.id, document_id))
|
|
|
|
|
|
@app.get("/api/v1/documents/{document_id}/download-url", tags=["profile"])
|
|
async def document_download(
|
|
document_id: uuid.UUID,
|
|
request: Request,
|
|
db: Session,
|
|
user: UserDep,
|
|
settings: SnapshotDep,
|
|
context: UserAuditContextDep,
|
|
):
|
|
await enforce_limit(
|
|
request,
|
|
"user",
|
|
str(user.id),
|
|
"download_url",
|
|
settings.limit("rate_limit.download_url.per_user"),
|
|
fail_closed=True,
|
|
)
|
|
item = await owned_document(db, user.id, document_id)
|
|
db.add(
|
|
audit(
|
|
"document.download_url_issued",
|
|
context,
|
|
user.id,
|
|
"document",
|
|
item.id,
|
|
metadata={"expires_in_seconds": 300},
|
|
)
|
|
)
|
|
await db.commit()
|
|
url = await request.app.state.s3.presign_get(item.storage_bucket, item.object_key)
|
|
return {"download_url": url, "expires_at": datetime.now(UTC)}
|
|
|
|
|
|
@app.post("/api/v1/dialogs", tags=["dialogs"])
|
|
async def dialogs_create(
|
|
request: Request,
|
|
db: Session,
|
|
user: UserDep,
|
|
context: UserAuditContextDep,
|
|
idempotency_key: Annotated[str | None, Header()] = None,
|
|
):
|
|
key = await required_idempotency(idempotency_key)
|
|
scope = "dialogs.create"
|
|
fingerprint = hashlib.sha256(f"POST:/api/v1/dialogs:{user.id}".encode()).hexdigest()
|
|
try:
|
|
cached = await request.app.state.idempotency.get(scope, user.id, key)
|
|
except Exception:
|
|
cached = None
|
|
if cached:
|
|
if cached["fingerprint"] != fingerprint:
|
|
raise DomainError("idempotency_key_reused", 409, "Idempotency key was reused")
|
|
return JSONResponse(cached["body"], status_code=cached["status"])
|
|
body, status = await create_dialog(db, user, context, key)
|
|
try:
|
|
await request.app.state.idempotency.put(
|
|
scope,
|
|
user.id,
|
|
key,
|
|
{
|
|
"fingerprint": fingerprint,
|
|
"status": status,
|
|
"body": json.loads(json.dumps(body, default=str)),
|
|
},
|
|
)
|
|
except Exception:
|
|
log.warning("idempotency.cache_write_failed")
|
|
return JSONResponse(json.loads(json.dumps(body, default=str)), status_code=status)
|
|
|
|
|
|
@app.get("/api/v1/dialogs", tags=["dialogs"])
|
|
async def dialogs_list(
|
|
request: Request,
|
|
db: Session,
|
|
user: UserDep,
|
|
limit: int = Query(50, ge=1, le=100),
|
|
cursor: str | None = None,
|
|
):
|
|
query = select(Dialog).where(Dialog.user_id == user.id, Dialog.record_status == "A")
|
|
if cursor:
|
|
decoded = decode_cursor(
|
|
cursor, request.app.state.settings.cursor_hmac_secret.get_secret_value().encode()
|
|
)
|
|
updated, item_id = datetime.fromisoformat(decoded["updated_at"]), uuid.UUID(decoded["id"])
|
|
query = query.where(
|
|
or_(
|
|
Dialog.updated_at < updated, and_(Dialog.updated_at == updated, Dialog.id < item_id)
|
|
)
|
|
)
|
|
items = (
|
|
(
|
|
await db.execute(
|
|
query.order_by(desc(Dialog.updated_at), desc(Dialog.id)).limit(limit + 1)
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
next_cursor = None
|
|
if len(items) > limit:
|
|
last = items[limit - 1]
|
|
next_cursor = encode_cursor(
|
|
{"updated_at": last.updated_at.isoformat(), "id": str(last.id)},
|
|
request.app.state.settings.cursor_hmac_secret.get_secret_value().encode(),
|
|
)
|
|
return {"items": [dialog_dto(item) for item in items[:limit]], "next_cursor": next_cursor}
|
|
|
|
|
|
@app.get("/api/v1/dialogs/{dialog_id}", tags=["dialogs"])
|
|
async def dialog_get(dialog_id: uuid.UUID, db: Session, user: UserDep):
|
|
return dialog_dto(await owned_dialog(db, user.id, dialog_id))
|
|
|
|
|
|
@app.get("/api/v1/dialogs/{dialog_id}/messages", tags=["dialogs"])
|
|
async def messages_list(
|
|
request: Request,
|
|
dialog_id: uuid.UUID,
|
|
db: Session,
|
|
user: UserDep,
|
|
limit: int = Query(50, ge=1, le=100),
|
|
after: str | None = None,
|
|
):
|
|
await owned_dialog(db, user.id, dialog_id)
|
|
query = select(Message).where(Message.dialog_id == dialog_id, Message.record_status == "A")
|
|
if after:
|
|
decoded = decode_cursor(
|
|
after, request.app.state.settings.cursor_hmac_secret.get_secret_value().encode()
|
|
)
|
|
created, item_id = datetime.fromisoformat(decoded["created_at"]), uuid.UUID(decoded["id"])
|
|
query = query.where(
|
|
or_(
|
|
Message.created_at > created,
|
|
and_(Message.created_at == created, Message.id > item_id),
|
|
)
|
|
)
|
|
items = (
|
|
(await db.execute(query.order_by(Message.created_at, Message.id).limit(limit + 1)))
|
|
.scalars()
|
|
.all()
|
|
)
|
|
page = items[:limit]
|
|
attachment_rows = (
|
|
(
|
|
await db.execute(
|
|
select(MessageAttachment).where(
|
|
MessageAttachment.message_id.in_([item.id for item in page]),
|
|
MessageAttachment.record_status == "A",
|
|
)
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
if page
|
|
else []
|
|
)
|
|
attachments = {item.message_id: item for item in attachment_rows}
|
|
next_cursor = None
|
|
if len(items) > limit:
|
|
last = page[-1]
|
|
next_cursor = encode_cursor(
|
|
{"created_at": last.created_at.isoformat(), "id": str(last.id)},
|
|
request.app.state.settings.cursor_hmac_secret.get_secret_value().encode(),
|
|
)
|
|
return {
|
|
"items": [
|
|
message_dto(item, [attachments[item.id]] if item.id in attachments else [])
|
|
for item in page
|
|
],
|
|
"next_cursor": next_cursor,
|
|
}
|
|
|
|
|
|
@app.post("/api/v1/dialogs/{dialog_id}/messages", status_code=201, tags=["dialogs"])
|
|
async def message_create(
|
|
dialog_id: uuid.UUID,
|
|
body: MessageRequest,
|
|
request: Request,
|
|
db: Session,
|
|
user: UserDep,
|
|
business: SnapshotDep,
|
|
context: UserAuditContextDep,
|
|
idempotency_key: Annotated[str | None, Header()] = None,
|
|
):
|
|
key = await required_idempotency(idempotency_key)
|
|
scope = f"dialogs.{dialog_id}.messages.create"
|
|
fingerprint = hashlib.sha256(
|
|
json.dumps(body.model_dump(mode="json"), sort_keys=True, separators=(",", ":")).encode()
|
|
).hexdigest()
|
|
try:
|
|
cached = await request.app.state.idempotency.get(scope, user.id, key)
|
|
except Exception:
|
|
cached = None
|
|
if cached:
|
|
if cached["fingerprint"] != fingerprint:
|
|
raise DomainError("idempotency_key_reused", 409, "Idempotency key was reused")
|
|
return cached["body"]
|
|
await enforce_limit(
|
|
request,
|
|
"user",
|
|
str(user.id),
|
|
"message_send",
|
|
business.limit("rate_limit.message_send.per_user"),
|
|
fail_closed=True,
|
|
)
|
|
await enforce_limit(
|
|
request,
|
|
"dialog",
|
|
str(dialog_id),
|
|
"message_send",
|
|
business.limit("rate_limit.message_send.per_dialog"),
|
|
fail_closed=True,
|
|
)
|
|
result = await send_message(
|
|
db,
|
|
user,
|
|
dialog_id,
|
|
body,
|
|
key,
|
|
context,
|
|
business,
|
|
request.app.state.settings,
|
|
request.app.state.safety,
|
|
request.app.state.openlines,
|
|
request.app.state.s3,
|
|
request.app.state.realtime,
|
|
)
|
|
try:
|
|
await request.app.state.idempotency.put(
|
|
scope,
|
|
user.id,
|
|
key,
|
|
{
|
|
"fingerprint": fingerprint,
|
|
"status": 201,
|
|
"body": json.loads(json.dumps(result, default=str)),
|
|
},
|
|
)
|
|
except Exception:
|
|
log.warning("idempotency.cache_write_failed")
|
|
return result
|
|
|
|
|
|
@app.post("/api/v1/dialogs/{dialog_id}/attachments/init", status_code=201, tags=["attachments"])
|
|
async def attachment_init(
|
|
dialog_id: uuid.UUID,
|
|
body: AttachmentInitRequest,
|
|
request: Request,
|
|
db: Session,
|
|
user: UserDep,
|
|
settings: SnapshotDep,
|
|
context: UserAuditContextDep,
|
|
):
|
|
await enforce_limit(
|
|
request,
|
|
"user",
|
|
str(user.id),
|
|
"attachment_init",
|
|
settings.limit("rate_limit.message_send.per_user"),
|
|
fail_closed=True,
|
|
)
|
|
return await init_attachment(db, user, dialog_id, body, settings, request.app.state.s3, context)
|
|
|
|
|
|
@app.post(
|
|
"/api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/complete",
|
|
tags=["attachments"],
|
|
)
|
|
async def attachment_complete(
|
|
dialog_id: uuid.UUID,
|
|
attachment_id: uuid.UUID,
|
|
body: AttachmentCompleteRequest,
|
|
request: Request,
|
|
db: Session,
|
|
user: UserDep,
|
|
context: UserAuditContextDep,
|
|
):
|
|
return await complete_attachment(
|
|
db, user, dialog_id, attachment_id, body, request.app.state.s3, context
|
|
)
|
|
|
|
|
|
@app.get(
|
|
"/api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/download-url",
|
|
tags=["attachments"],
|
|
)
|
|
async def attachment_download(
|
|
dialog_id: uuid.UUID,
|
|
attachment_id: uuid.UUID,
|
|
request: Request,
|
|
db: Session,
|
|
user: UserDep,
|
|
settings: SnapshotDep,
|
|
context: UserAuditContextDep,
|
|
):
|
|
await enforce_limit(
|
|
request,
|
|
"user",
|
|
str(user.id),
|
|
"download_url",
|
|
settings.limit("rate_limit.download_url.per_user"),
|
|
fail_closed=True,
|
|
)
|
|
item = (
|
|
await db.execute(
|
|
select(MessageAttachment).where(
|
|
MessageAttachment.id == attachment_id,
|
|
MessageAttachment.dialog_id == dialog_id,
|
|
MessageAttachment.owner_user_id == user.id,
|
|
MessageAttachment.record_status == "A",
|
|
MessageAttachment.scan_status == "clean",
|
|
)
|
|
)
|
|
).scalar_one_or_none()
|
|
if not item:
|
|
raise DomainError("not_found", 404, "Resource was not found")
|
|
db.add(
|
|
audit(
|
|
"attachment.download_url_issued",
|
|
context,
|
|
user.id,
|
|
"attachment",
|
|
item.id,
|
|
metadata={"expires_in_seconds": 300},
|
|
)
|
|
)
|
|
await db.commit()
|
|
return {
|
|
"download_url": await request.app.state.s3.presign_get(
|
|
item.storage_bucket, item.object_key
|
|
),
|
|
"expires_at": datetime.now(UTC),
|
|
}
|
|
|
|
|
|
@app.post("/internal/openlines/v1/inbox", tags=["internal"])
|
|
async def inbox(event: OpenLinesInbox, request: Request, db: Session, settings: SnapshotDep):
|
|
await service_auth(
|
|
request, request.app.state.settings.bitrix_api_inbox_token.get_secret_value()
|
|
)
|
|
body, status = await apply_inbox(
|
|
db,
|
|
event,
|
|
audit_context(request),
|
|
settings,
|
|
request.app.state.s3,
|
|
request.app.state.http,
|
|
request.app.state.settings,
|
|
request.app.state.realtime,
|
|
)
|
|
return JSONResponse(body, status_code=status)
|
|
|
|
|
|
@app.get(
|
|
"/internal/settings/v1/otp",
|
|
tags=["internal"],
|
|
response_model=OtpSettingsResponse,
|
|
responses={304: {"description": "Cached settings are still current"}},
|
|
)
|
|
async def otp_settings(
|
|
request: Request,
|
|
settings: SnapshotDep,
|
|
if_none_match: Annotated[str | None, Header()] = None,
|
|
):
|
|
await service_auth(
|
|
request, request.app.state.settings.keycloak_settings_bridge_token.get_secret_value()
|
|
)
|
|
etag = f'"{settings.version}"'
|
|
headers = {"ETag": etag, "Cache-Control": "private, max-age=60"}
|
|
if if_none_match == etag:
|
|
return Response(status_code=304, headers=headers)
|
|
return JSONResponse({
|
|
"max_send_attempts_per_24h": settings.integer("otp.phone.max_send_attempts_per_24h"),
|
|
"min_seconds_between_attempts": settings.integer("otp.phone.min_seconds_between_attempts"),
|
|
"max_verify_attempts": settings.integer("otp.phone.max_verify_attempts"),
|
|
"code_length": settings.integer("otp.phone.code_length"),
|
|
"ttl_seconds": settings.integer("otp.phone.ttl_seconds"),
|
|
"sms_order_timeout_ms": settings.integer("otp.phone.sms_order_timeout_ms"),
|
|
"version": settings.version,
|
|
"cache_ttl_seconds": 60,
|
|
}, headers=headers)
|
|
|
|
|
|
def websocket_token(websocket: WebSocket) -> tuple[str | None, str | None]:
|
|
offered = websocket.headers.get("sec-websocket-protocol", "")
|
|
for protocol in (part.strip() for part in offered.split(",")):
|
|
if protocol.startswith("han.jwt."):
|
|
import base64
|
|
|
|
encoded = protocol.removeprefix("han.jwt.")
|
|
try:
|
|
token = base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4)).decode()
|
|
return token, protocol
|
|
except (ValueError, UnicodeDecodeError):
|
|
return None, None
|
|
return websocket.query_params.get("access_token"), None
|
|
|
|
|
|
@app.websocket("/api/v1/realtime")
|
|
async def realtime(websocket: WebSocket):
|
|
token, protocol = websocket_token(websocket)
|
|
if not token:
|
|
await websocket.close(code=4401)
|
|
return
|
|
try:
|
|
auth = await websocket.app.state.jwks.validate(token)
|
|
async with websocket.app.state.db.sessions() as db:
|
|
user = await resolve_user(db, auth)
|
|
await websocket.accept(subprotocol=protocol)
|
|
await websocket.send_json(
|
|
{"type": "connected", "server_time": datetime.now(UTC).isoformat()}
|
|
)
|
|
event_task: asyncio.Task[Any] | None = None
|
|
receive_task: asyncio.Task[Any] | None = None
|
|
heartbeat_task: asyncio.Task[Any] | None = None
|
|
event_stream = None
|
|
while True:
|
|
receive_task = receive_task or asyncio.create_task(websocket.receive_json())
|
|
heartbeat_task = heartbeat_task or asyncio.create_task(asyncio.sleep(20))
|
|
tasks = {receive_task, heartbeat_task}
|
|
if event_task:
|
|
tasks.add(event_task)
|
|
done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
if heartbeat_task in done:
|
|
await websocket.send_json(
|
|
{"type": "ping", "server_time": datetime.now(UTC).isoformat()}
|
|
)
|
|
heartbeat_task = None
|
|
if event_task and event_task in done:
|
|
await websocket.send_json(event_task.result())
|
|
assert event_stream is not None
|
|
event_task = asyncio.create_task(anext(event_stream))
|
|
if receive_task in done:
|
|
payload = receive_task.result()
|
|
receive_task = None
|
|
if payload.get("type") == "pong":
|
|
continue
|
|
if payload.get("type") != "subscribe":
|
|
await websocket.close(code=4400)
|
|
return
|
|
ids = {uuid.UUID(value) for value in payload.get("dialog_ids", [])[:100]}
|
|
notifications = bool(payload.get("notifications", False))
|
|
count = await db.scalar(
|
|
select(func.count(Dialog.id)).where(
|
|
Dialog.id.in_(ids),
|
|
Dialog.user_id == user.id,
|
|
Dialog.record_status == "A",
|
|
)
|
|
)
|
|
if count != len(ids):
|
|
await websocket.close(code=4404)
|
|
return
|
|
if event_task:
|
|
event_task.cancel()
|
|
if event_stream:
|
|
await event_stream.aclose()
|
|
event_stream = websocket.app.state.realtime.events(
|
|
ids, user.id, notifications
|
|
)
|
|
event_task = asyncio.create_task(anext(event_stream))
|
|
await websocket.send_json(
|
|
{
|
|
"type": "subscribed",
|
|
"dialog_ids": [str(value) for value in ids],
|
|
"notifications": notifications,
|
|
}
|
|
)
|
|
except (AuthError, DomainError, ValueError):
|
|
await websocket.close(code=4401)
|
|
except WebSocketDisconnect:
|
|
return
|
|
|
|
|
|
instrument_fastapi(app)
|
|
|
|
|
|
def run() -> None:
|
|
settings = get_settings()
|
|
uvicorn.run(
|
|
"app.main:app",
|
|
host="0.0.0.0", # noqa: S104 - required container listener
|
|
port=settings.api_port,
|
|
proxy_headers=False,
|
|
)
|