Files
han-app/VM1_app/codebase/backend/api-backend/app/main.py
T

1230 lines
41 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 (
TelemetryRuntime,
add_trace_context,
current_trace_id,
init_telemetry,
instrument_fastapi,
)
def configure_logging(level: str, telemetry: TelemetryRuntime | None = None) -> None:
logging.basicConfig(level=level, format="%(message)s")
if telemetry and telemetry.logging_handler not in logging.getLogger().handlers:
telemetry.logging_handler.addFilter(
lambda record: not record.name.startswith("opentelemetry")
)
logging.getLogger().addHandler(telemetry.logging_handler)
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(),
],
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
)
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, telemetry)
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 = "0013_delivery_trace_context"
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,
)