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