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

295 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Gemeinsames für die HTML-Routen: Anmeldung, CSRF, Templates, Fehlerseiten."""
import logging
from pathlib import Path
from typing import Annotated, Any, NamedTuple
from urllib.parse import quote
from fastapi import Depends, FastAPI, Request
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from jinja2 import pass_context
from app.core import charts, logs, markdown
from app.core.config import Settings
from app.core.crypto import Vault
from app.core.database import Database
from app.core.errors import Forbidden, Invalid, NotFound
from app.core.security import same
from app.help.service import link_for as help_link
from app.identity import Principal
from app.identity import service as identity
from app.workspaces.models import ROLE_LABELS
COOKIE = "aiw_session"
log = logging.getLogger(__name__)
templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
templates.env.filters["markdown"] = markdown.render
templates.env.globals["chart"] = charts.render
templates.env.globals["role_labels"] = ROLE_LABELS
templates.env.globals["help_link"] = help_link
STATIC = Path(__file__).parent / "static"
@pass_context
def asset(context: dict, path: str) -> str:
"""URL einer statischen Datei mit Version (Änderungszeit und Größe): nach einem Update lädt der Browser
die neue Datei, statt eine zwischengespeicherte alte zu nehmen."""
url = context["request"].url_for("static", path=path)
try:
stat = (STATIC / path).stat()
except OSError:
return str(url)
return f"{url}?v={int(stat.st_mtime):x}{stat.st_size:x}"
templates.env.globals["asset"] = asset
def money(value: Any, places: int = 2) -> str:
"""Betrag deutsch formatiert; kleine Beträge mit mehr Stellen, damit sie nicht als 0,00 erscheinen."""
if value is None:
return "–"
value = float(value)
if 0 < abs(value) < 0.01:
places = 4
return f"{value:,.{places}f}".replace(",", "X").replace(".", ",").replace("X", ".")
def number(value: Any) -> str:
return f"{int(value or 0):,}".replace(",", ".")
templates.env.filters["money"] = money
templates.env.filters["number"] = number
templates.env.filters["combine_before"] = lambda query, before: {**query, "before": before}
def db(request: Request) -> Database:
return request.app.state.db
def settings(request: Request) -> Settings:
return request.app.state.settings
def vault(request: Request) -> Vault:
return request.app.state.vault
class LoginRequired(Exception):
pass
class CsrfFailed(Exception):
pass
class PasswordChangeRequired(Exception):
pass
# Erreichbar, solange ein neues Passwort gesetzt werden muss
PASSWORD_PATHS = ("/password", "/logout")
def current(request: Request) -> Principal | None:
if not hasattr(request.state, "principal"):
token = request.cookies.get(COOKIE, "")
with db(request).session() as session:
request.state.principal = identity.resolve(session, token)
if request.state.principal is not None:
logs.bind(user_id=request.state.principal.user_id)
return request.state.principal
def user(request: Request) -> Principal:
principal = current(request)
if principal is None:
raise LoginRequired
if principal.must_change_password and request.url.path not in PASSWORD_PATHS:
raise PasswordChangeRequired
return principal
async def checked(request: Request, principal: Annotated[Principal, Depends(user)]) -> Principal:
"""Für schreibende Anfragen: CSRF-Token aus Kopfzeile (htmx) oder Formularfeld."""
sent = request.headers.get("X-CSRF-Token")
if sent is None:
form = await request.form()
sent = str(form.get("_csrf", ""))
if not sent or not same(sent, principal.csrf):
raise CsrfFailed
return principal
User = Annotated[Principal, Depends(user)]
Checked = Annotated[Principal, Depends(checked)]
def render(request: Request, name: str, context: dict[str, Any] | None = None, status_code: int = 200) -> HTMLResponse:
context = {"p": current(request), "currency": settings(request).cost_currency,
"request_id": getattr(request.state, "request_id", ""), **(context or {})}
if context.get("current_workspace") is not None and context["p"] is not None:
context["ws_nav"] = workspace_nav(request, context["p"], context["current_workspace"].id)
context["trail"] = breadcrumbs(request.url.path, context)
return templates.TemplateResponse(request, name, context, status_code=status_code)
class Crumb(NamedTuple):
label: str
href: str | None = None
workspace: bool = False
# Wie in der Seitenleiste benannt
ADMIN_SECTIONS = {"teams": "Teams", "users": "Nutzer", "models": "Modelle und Zugänge", "tools": "Tools",
"mcp": "MCP-Server", "databases": "Datenbank-Treiber", "hugin": "Hugin",
"usage": "Nutzung und Kosten", "runs": "Runs", "audit": "Audit-Log",
"notifications": "Benachrichtigungen"}
WORKSPACE_SECTIONS = {"repo": "Repository", "data": "Daten", "memory": "Gedächtnis", "settings": "Einstellungen",
"usage": "Nutzung und Kosten", "runs": "Runs", "audit": "Audit-Log"}
HUGIN_PAGES = {"catalog": "Katalog", "import": "Aus .hugin.yaml"}
def breadcrumbs(path: str, context: dict[str, Any]) -> list[Crumb]:
"""Pfad in der Topbar (Workspaces › Workspace › Bereich › Detail); der letzte Eintrag ist die Seite selbst."""
parts = [part for part in path.split("/") if part]
head = parts[0] if parts else ""
if head == "approvals":
approval = context.get("a")
detail = [Crumb(approval.arguments.get("title") or approval.tool)] if approval is not None else []
return [Crumb("Freigaben", "/approvals"), *detail]
if head == "admin" and len(parts) > 1:
section, server = parts[1], context.get("server")
if section == "mcp" and server is not None and server.role == "database":
section = "databases"
if section not in ADMIN_SECTIONS:
return []
crumbs = [Crumb("Administration"), Crumb(ADMIN_SECTIONS[section], f"/admin/{section}")]
detail = context.get("team") if section == "teams" else server
if len(parts) > 2 and detail is not None:
crumbs.append(Crumb(detail.name if section == "teams" else detail.label))
return crumbs
if head == "hilfe":
page = context.get("page")
return [Crumb("Hilfe", "/hilfe"), *([Crumb(page.page.title)] if page else [])]
if head in ("profile", "password"):
return [Crumb("Profil", "/profile"), *([Crumb("Passwort ändern")] if head == "password" else [])]
workspace = context.get("current_workspace")
if workspace is None:
if parts == ["workspaces"]:
return [Crumb("Workspaces", "/workspaces")]
if parts == ["workspaces", "new"]:
return [Crumb("Workspaces", "/workspaces"), Crumb("Workspace anlegen")]
return []
wbase = f"/workspaces/{workspace.id}"
crumbs = [Crumb("Workspaces", "/workspaces"), Crumb(workspace.name, wbase, workspace=True)]
data = Crumb("Daten", f"{wbase}/data")
settings_ = Crumb("Einstellungen", f"{wbase}/settings")
mcp_tab = [settings_, Crumb("MCP-Server", f"{wbase}/settings?tab=mcp")]
rest = parts[2:] if head == "workspaces" else []
section = rest[0] if rest else ""
if head == "chats":
chat = context.get("chat")
crumbs += [Crumb("Chats", f"{wbase}#chats"), *([Crumb(chat.title)] if chat is not None else [])]
elif head == "sources" and context.get("source") is not None:
crumbs += [data, Crumb(f"{context['source'].name} bearbeiten")]
elif head == "folders" and context.get("folder") is not None:
crumbs += [Crumb("Daten", f"{wbase}/data#folders"), Crumb(context["folder"].name)]
elif head == "connections" and context.get("server") is not None:
crumbs += [*mcp_tab, Crumb(context["server"].label)]
elif rest == ["sources", "new"]:
crumbs += [data, Crumb("Git-Repository verbinden")]
elif rest == ["databases", "new"]:
crumbs += [*mcp_tab, Crumb("Datenbank hinzufügen")]
elif rest == ["connections", "new"]:
crumbs += [*mcp_tab, Crumb("MCP-Server hinzufügen")]
elif section == "hugin":
crumbs += [settings_, Crumb("Hugin", f"{wbase}/settings?tab=hugin"),
Crumb(HUGIN_PAGES.get(rest[1] if len(rest) > 1 else "", "Abgleichen"))]
elif section == "repo":
source, pr = context.get("source"), context.get("pr")
query = f"?source={quote(source.name)}" if source else ""
if rest[1:2] == ["compare"]:
query += ("&" if query else "?") + f"tab={'prs' if pr else 'branches'}"
crumbs.append(Crumb("Repository", f"{wbase}/repo{query}"))
if rest[1:2] == ["compare"]:
crumbs.append(Crumb(f"Pull Request #{pr}" if pr else "Vergleich"))
elif len(rest) > 1:
crumbs.append(Crumb(f"Commit {rest[-1][:7]}"))
elif section in WORKSPACE_SECTIONS:
crumbs.append(Crumb(WORKSPACE_SECTIONS[section], f"{wbase}/{section}"))
return crumbs
def workspace_nav(request: Request, principal: Principal, workspace_id: int) -> dict[str, Any]:
"""Navigation des aktiven Workspace in der Seitenleiste: Rolle, Gedächtnis, Repository, letzte Chats."""
from sqlalchemy import select
from app.chat.models import ChatSession
from app.mcp import service as mcp
from app.workspaces.models import WorkspaceMembership
with db(request).session() as session:
role = session.scalar(select(WorkspaceMembership.role).where(
WorkspaceMembership.workspace_id == workspace_id, WorkspaceMembership.user_id == principal.user_id))
chats = session.execute(select(ChatSession.id, ChatSession.title).where(
ChatSession.workspace_id == workspace_id).order_by(ChatSession.created.desc()).limit(8)).all()
memory = any(s.role == "memory" for s, _ in mcp.enabled(session, workspace_id))
from app.repo import service as repo
has_repo = repo.available(session, workspace_id)
return {"is_admin": role == "admin", "memory": memory, "chats": chats, "repo": has_repo}
def redirect(url: str) -> Response:
return RedirectResponse(url, status_code=303)
def is_htmx(request: Request) -> bool:
return request.headers.get("HX-Request") == "true"
def install_error_pages(app: FastAPI) -> None:
@app.exception_handler(LoginRequired)
async def login_required(request: Request, _: Exception) -> Response:
if is_htmx(request) or request.url.path.endswith("/events"):
return Response(status_code=401, headers={"HX-Redirect": "/login"})
target = request.url.path + (f"?{request.url.query}" if request.url.query else "")
return redirect(f"/login?next={quote(target)}")
@app.exception_handler(PasswordChangeRequired)
async def password_change_required(request: Request, _: Exception) -> Response:
if is_htmx(request) or request.url.path.endswith("/events"):
return Response(status_code=403, headers={"HX-Redirect": "/password"})
return redirect("/password")
@app.exception_handler(CsrfFailed)
async def csrf_failed(request: Request, _: Exception) -> Response:
return render(request, "error.html", {"title": "Sitzung abgelaufen",
"text": "Bitte die Seite neu laden und es erneut versuchen."}, 403)
@app.exception_handler(NotFound)
async def not_found(request: Request, _: Exception) -> Response:
return render(request, "error.html", {"title": "Nicht gefunden",
"text": "Das gibt es nicht, oder du hast keinen Zugriff."}, 404)
@app.exception_handler(Forbidden)
async def forbidden(request: Request, e: Exception) -> Response:
return render(request, "error.html", {"title": "Keine Berechtigung",
"text": str(e) or "Dafür brauchst du die Rolle Admin in diesem Workspace."},
403)
@app.exception_handler(Exception)
async def unexpected(request: Request, e: Exception) -> Response:
log.error("Unerwarteter Fehler", exc_info=e)
return templates.TemplateResponse(request, "error.html", {
"p": None, "title": "Unerwarteter Fehler", "request_id": getattr(request.state, "request_id", ""),
"text": "Das hat nicht geklappt. Bitte später erneut versuchen; wenn es bleibt, die Fehler-ID melden."},
status_code=500)
@app.exception_handler(Invalid)
async def invalid(request: Request, e: Exception) -> Response:
if is_htmx(request):
return render(request, "_alert.html", {"error": str(e)}, 422)
return render(request, "error.html", {"title": "Nicht möglich", "text": str(e)}, 400)