148 lines
6.3 KiB
Python
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
|