feat(logos): Provider-Kette, lokaler Cache und Markenfarben

- simple-icons als kompakter Index im Repository (3.459 Marken, 2 MB gzip),
  erzeugt von scripts/vendor_simple_icons.py bzw. `make vendor-icons`
- logo.dev und Brandfetch als optionale Adapter, ohne Schlüssel übersprungen
- Favicon-Fallback und generierter Buchstaben-Avatar als Garantie
- Bei eindeutigem Offline-Treffer unterbleiben Anfragen nach außen komplett
- Cache im Dateisystem nach SHA-256, Auslieferung nur über /api/logos/{id}
  mit immutable-Header und ETag
- Markenfarbe aus SVG-Fills bzw. per k-Means (k=4) über 64x64 Pixel, dazu eine
  aufgehellte Variante mit mindestens 4,5:1 Kontrast auf dunklem Grund
- Kandidatensuche mit Vorauswahl, Auswahl, Upload und Zurücksetzen
- Bildtyp wird nur noch am Inhalt bestimmt, nicht an der gemeldeten Kopfzeile
- 60 neue Tests, insgesamt 208 grün

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
This commit is contained in:
moneyfy
2026-09-09 13:54:21 +02:00
co-authored by Claude Opus 5
parent b586d27b77
commit 8edb69fe6a
16 changed files with 2039 additions and 7 deletions
+45
View File
@@ -0,0 +1,45 @@
"""Auslieferung der zwischengespeicherten Logos.
Die Dateien kommen ausschließlich von der lokalen Platte; beim Seitenaufruf
entsteht kein Zugriff auf fremde Dienste. Der Dateiname ist der Inhaltshash,
daher darf unbegrenzt zwischengespeichert werden.
"""
from fastapi import APIRouter, Request, Response, status
from fastapi.responses import FileResponse
from app.api.deps import DbSession
from app.core.config import settings
from app.core.errors import NotFoundError
from app.models import LogoAsset
from app.schemas.common import ErrorResponse
from app.services.crud import get_or_404
from app.services.logos import cache_headers
router = APIRouter(prefix="/logos", tags=["logos"])
@router.get(
"/{asset_id}",
responses={
status.HTTP_200_OK: {
"content": {"image/svg+xml": {}, "image/png": {}, "image/jpeg": {}},
"description": "Die Logodatei aus dem lokalen Cache.",
},
status.HTTP_404_NOT_FOUND: {"model": ErrorResponse},
},
summary="Logo ausliefern",
description="Liefert die Datei mit `Cache-Control: public, max-age=31536000, immutable`.",
)
async def read_logo(asset_id: int, request: Request, session: DbSession) -> Response:
asset = await get_or_404(session, LogoAsset, asset_id)
pfad = settings.logo_storage_dir / asset.file_path
if not pfad.exists():
raise NotFoundError("Die Logodatei fehlt im Cache.", code="logo_file_missing")
headers = cache_headers(asset)
# Unveränderliche Inhalte: ein passendes ETag beantwortet die Anfrage sofort.
if request.headers.get("if-none-match") == headers["ETag"]:
return Response(status_code=status.HTTP_304_NOT_MODIFIED, headers=headers)
return FileResponse(pfad, media_type=asset.mime, headers=headers)