"""Eigenes Logo und Favicon der Installation. Die Bilder liegen in der Datenbank (siehe models.BrandingAsset). Das Abrufen ist bewusst ohne Anmeldung möglich – das Favicon wird vom Browser schon vor dem Login geladen, und es handelt sich um nichts Schützenswertes. Hochladen und Entfernen dürfen nur Administratoren. """ from __future__ import annotations from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status from fastapi.responses import Response from sqlalchemy.orm import Session from ..database import get_db from ..deps import require_admin from ..models import BrandingAsset, User router = APIRouter(prefix="/branding", tags=["branding"]) KINDS = ("logo", "favicon") # Bewusst eng: genau die Formate, die ein Browser als Logo/Favicon darstellt. ALLOWED_TYPES = { "image/png": "png", "image/jpeg": "jpg", "image/svg+xml": "svg", "image/webp": "webp", "image/x-icon": "ico", "image/vnd.microsoft.icon": "ico", "image/gif": "gif", } MAX_BYTES = 512 * 1024 # 512 KB reichen für ein Logo deutlich aus def _check_kind(kind: str) -> str: if kind not in KINDS: raise HTTPException(status.HTTP_404_NOT_FOUND, "Unbekannte Bildart") return kind @router.get("") def branding_status(db: Session = Depends(get_db)) -> dict[str, bool]: """Welche eigenen Bilder hinterlegt sind – ohne sie zu übertragen. Vorher fragte die Oberfläche das per HEAD auf das Bild ab. FastAPI registriert für eine GET-Route aber kein HEAD (anders als Starlette), die Anfrage lief also in ein 405 und jedes hinterlegte Logo galt als "nicht da". """ vorhanden = {asset.kind for asset in db.query(BrandingAsset).all()} return {kind: kind in vorhanden for kind in KINDS} @router.api_route("/{kind}", methods=["GET", "HEAD"]) def get_branding(kind: str, db: Session = Depends(get_db)) -> Response: """Liefert das hinterlegte Bild – oder 404, wenn keines gesetzt ist. Die Oberfläche fällt bei 404 auf ihr eingebautes Zeichen zurück. """ _check_kind(kind) asset = db.get(BrandingAsset, kind) if asset is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Kein eigenes Bild hinterlegt") return Response( content=asset.data, media_type=asset.content_type, headers={ # Kurz cachen, damit ein Wechsel schnell sichtbar wird. "Cache-Control": "public, max-age=60", "ETag": f'"{kind}-{int(asset.updated_at.timestamp())}"', }, ) @router.put("/{kind}") async def put_branding( kind: str, file: UploadFile = File(...), db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> Response: _check_kind(kind) if file.content_type not in ALLOWED_TYPES: erlaubt = ", ".join(sorted(set(ALLOWED_TYPES.values()))) raise HTTPException( status.HTTP_400_BAD_REQUEST, f"Dieses Dateiformat wird nicht unterstützt. Erlaubt sind: {erlaubt}.", ) data = await file.read() if not data: raise HTTPException(status.HTTP_400_BAD_REQUEST, "Die Datei ist leer.") if len(data) > MAX_BYTES: raise HTTPException( status.HTTP_400_BAD_REQUEST, f"Die Datei ist zu groß ({len(data) // 1024} KB). " f"Erlaubt sind höchstens {MAX_BYTES // 1024} KB.", ) asset = db.get(BrandingAsset, kind) if asset is None: asset = BrandingAsset(kind=kind, content_type=file.content_type, data=data) db.add(asset) else: asset.content_type = file.content_type asset.data = data db.commit() return Response(status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{kind}") def delete_branding( kind: str, db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> Response: """Zurück auf das eingebaute Zeichen.""" _check_kind(kind) asset = db.get(BrandingAsset, kind) if asset is not None: db.delete(asset) db.commit() return Response(status_code=status.HTTP_204_NO_CONTENT)