"""Web-Prozess: setzt die Router zusammen (→ Konzept 3, 12).""" import asyncio import logging import re import time import uuid from contextlib import asynccontextmanager, suppress from pathlib import Path from fastapi import FastAPI, Request, Response, status from fastapi.staticfiles import StaticFiles from app import web from app.ai import routes as ai_routes from app.approvals import routes as approval_routes from app.audit import routes as audit_routes from app.changes import routes as change_routes from app.chat import routes as chat_routes from app.core import health, logs, metrics from app.core.config import Settings, get_settings from app.core.crypto import Vault from app.core.database import Database from app.data_sources import routes as source_routes from app.documents import routes as document_routes from app.feedback import routes as feedback_routes from app.help import routes as help_routes from app.hugin import routes as hugin_routes from app.identity import admin_routes from app.identity import routes as identity_routes from app.mcp import routes as mcp_routes from app.notify import routes as notify_routes from app.repo import routes as repo_routes from app.tasks import routes as task_routes from app.tasks.events import EventHub from app.tools import routes as tool_routes from app.uploads import routes as upload_routes from app.usage import routes as usage_routes from app.workspaces import routes as workspace_routes STATIC = Path(__file__).parent / "static" access = logging.getLogger("aiw.access") # Übernommene Request-ID (z. B. vom Proxy) nur in harmloser Form REQUEST_ID = re.compile(r"^[A-Za-z0-9._-]{8,64}$") # Probes und Metrik-Abrufe weder zählen noch loggen (alle paar Sekunden) QUIET = {"/live", "/healthz", "/ready", "/readyz", "/metrics", "/static"} # Strenge CSP ohne 'unsafe-inline' (→ Konzept 4.5); keine externen Bilder CSP = ("default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; " "connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'") SECURITY_HEADERS = { "Content-Security-Policy": CSP, "X-Content-Type-Options": "nosniff", "Referrer-Policy": "same-origin", "X-Frame-Options": "DENY", } def create_app(settings: Settings | None = None, listen: bool = True) -> FastAPI: settings = settings or get_settings() db = Database(settings.database_url) hub = EventHub(settings.psycopg_url) @asynccontextmanager async def lifespan(_: FastAPI): task = asyncio.create_task(hub.run()) if listen else None try: yield finally: if task: task.cancel() with suppress(asyncio.CancelledError): await task db.dispose() app = FastAPI(title="Bifröst", version="0.1.0", docs_url=None, redoc_url=None, openapi_url=None, lifespan=lifespan) app.state.settings, app.state.db, app.state.hub = settings, db, hub app.state.vault = Vault(settings.secret_key) @app.middleware("http") async def headers(request: Request, call_next) -> Response: response = await call_next(request) for name, value in SECURITY_HEADERS.items(): response.headers.setdefault(name, value) if not request.url.path.startswith("/static/"): response.headers.setdefault("Cache-Control", "no-store") return response @app.middleware("http") async def observe(request: Request, call_next) -> Response: """Request-ID, eine Zugriffszeile je Request und Metriken (äußerste Schicht).""" sent = request.headers.get("X-Request-ID", "") request_id = sent if REQUEST_ID.match(sent) else uuid.uuid4().hex[:16] request.state.request_id = request_id logs.clear() logs.bind(request_id=request_id) started = time.monotonic() status_code = 500 try: response = await call_next(request) status_code = response.status_code response.headers["X-Request-ID"] = request_id return response finally: route = request.scope.get("route") template = getattr(route, "path", None) or ("/static" if request.url.path.startswith("/static/") else "unbekannt") seconds = time.monotonic() - started if template not in QUIET: metrics.http_requests.labels(request.method, template, str(status_code)).inc() metrics.http_duration.labels(request.method, template).observe(seconds) principal = getattr(request.state, "principal", None) access.info("%s %s %s", request.method, template, status_code, extra={ "method": request.method, "route": template, "status": status_code, "duration_ms": round(seconds * 1000), "user_id": principal.user_id if principal else None}) app.mount("/static", StaticFiles(directory=STATIC), name="static") for module in (identity_routes, workspace_routes, source_routes, chat_routes, task_routes, ai_routes, admin_routes, tool_routes, approval_routes, mcp_routes, upload_routes, audit_routes, usage_routes, notify_routes, change_routes, help_routes, hugin_routes, repo_routes, document_routes, feedback_routes): app.include_router(module.router()) web.install_error_pages(app) @app.get("/live", include_in_schema=False) @app.get("/healthz", include_in_schema=False) def alive() -> dict[str, str]: return {"status": "ok"} @app.get("/ready", include_in_schema=False) @app.get("/readyz", include_in_schema=False) def ready(response: Response) -> dict: report = health.ready(db, settings) if not report.ok: response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE return report.body() registry = metrics.web_registry(db, settings) @app.get("/metrics", include_in_schema=False) def metrics_endpoint(request: Request) -> Response: if not metrics.authorized(request.headers.get("Authorization"), settings): # Ohne Token ist /metrics aus (404); mit Token, aber falschem: 401 return Response(status_code=404 if not settings.metrics_token else 401) return Response(metrics.render(registry), media_type=metrics.CONTENT_TYPE) return app