Files
Vorrania/backend/app/routers/branding.py
Scarriffle 4448718d8d Logo-Upload sichtbar machen; empfohlene Abmessungen angeben
Fehler: Ein hochgeladenes Logo blieb unsichtbar, auch nach dem Neuladen.
Die Oberflaeche prueft per Anfrage, ob ein Bild hinterlegt ist, und nutzte dafuer
HEAD. FastAPI registriert fuer eine GET-Route aber - anders als Starlette - kein
HEAD; die Anfrage lief in ein 405, und jedes hinterlegte Bild galt als "nicht
vorhanden". Der Upload selbst war die ganze Zeit in Ordnung.

- Neuer Endpunkt GET /branding liefert nur den Status ({"logo": true, ...}),
  ohne Bilddaten zu uebertragen. Die Oberflaeche fragt jetzt diesen ab.
- Die Bild-Route beantwortet zusaetzlich HEAD, damit sie sich erwartungsgemaess
  verhaelt.

Ausserdem:
- Die empfohlenen Abmessungen stehen jetzt sichtbar ueber der Vorschau
  (Logo etwa 400x72 px, Favicon 64x64 px, jeweils hoechstens 512 KB).
- Nach dem Hochladen wird die tatsaechliche Bildgroesse angezeigt, damit man
  sie mit der Empfehlung vergleichen kann.

Geprueft: "npm run build" laeuft durch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 08:55:17 +02:00

126 lines
4.0 KiB
Python
Raw 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.
"""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)