bifroest/app/main.py
2026-10-27 08:34:17 +01:00

148 lines
6.3 KiB
Python

"""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