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.realtime import RealtimeFanout from app.schemas import ( AttachmentCompleteRequest, AttachmentInitRequest, BootstrapRequest, ConsentsRequest, MessageRequest, OpenLinesInbox, OtpSettingsResponse, 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 def configure_logging(level: str) -> None: logging.basicConfig(level=level, format="%(message)s") structlog.configure( processors=[ structlog.contextvars.merge_contextvars, 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) @asynccontextmanager async def lifespan(app: FastAPI): settings = get_settings() configure_logging(settings.log_level) app.state.settings = settings app.state.db = Database(settings.database_url) app.state.http = httpx.AsyncClient() 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.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) 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") yield settings_task.cancel() with suppress(asyncio.CancelledError): await settings_task await app.state.http.aclose() await app.state.redis.aclose() await app.state.redis_rt.aclose() await app.state.db.close() app = FastAPI( title="HAN Chat API", version="1.0.0", openapi_version="3.1.0", docs_url=None, redoc_url=None, lifespan=lifespan, ) log = structlog.get_logger() 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 = 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, route=request.url.path, **{"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,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") log.info( "request.complete", status_code=response.status_code, duration_ms=round((time.monotonic() - request.state.started_at) * 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.exception("request.failed", error_code="internal_error") 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: raise DomainError( "dependency_unavailable", 503, "Rate limit service is unavailable" ) from None return if retry_after: raise DomainError( "rate_limit_exceeded", 429, "Rate limit exceeded", {"retry_after": retry_after}, ) @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 != "0005_otp_settings": 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._keys else "failed" try: safety_response = await request.app.state.http.get( f"{str(request.app.state.settings.message_safety_url).rstrip('/')}/health/ready", timeout=2, ) components["safety"] = "ok" if safety_response.is_success else "failed" except httpx.HTTPError: components["safety"] = "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"]) async def app_config(request: Request, response: Response, settings: SnapshotDep): await enforce_limit( request, "ip", client_ip(request) or "unknown", "public", settings.limit("rate_limit.public_endpoints.per_ip"), fail_closed=False, ) response.headers["Cache-Control"] = ( f"public, max-age={settings.integer('security.public_cache.max_age_seconds')}" ) response.headers["ETag"] = f'"{settings.version}"' values = settings.values return { "auth": { "phone_enabled": settings.boolean("auth.phone.enabled"), "password_enabled": settings.boolean("auth.password.enabled"), }, "operator": {"call_phone": values["operator.call.phone"]}, "consents": { 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 ("personal_data", "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"), }, "ux": {"idle_timeout_minutes": settings.integer("ux.session.idle_timeout_minutes")}, } @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 ): return await bootstrap(db, auth, body, settings, audit_context(request)) @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, 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]} 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) event_task = asyncio.create_task(anext(event_stream)) await websocket.send_json( {"type": "subscribed", "dialog_ids": [str(value) for value in ids]} ) except (AuthError, DomainError, ValueError): await websocket.close(code=4401) except WebSocketDisconnect: return 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, )