Files
schalli 3d266418fc
Tessera CI/CD / Lint & Type Check (push) Successful in 51s
Tessera CI/CD / Tests (push) Successful in 1m17s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m11s
docs(quick-260923-lrr): Favoriten-Symbol hochladen, CHANGELOG, Zugriffsklassifikation nachgetragen
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 16:20:29 +02:00

41 KiB
Raw Permalink Blame History

phase, plan, type, wave, depends_on, subsystem, files_modified, autonomous, requirements, estimate, must_haves
phase plan type wave depends_on subsystem files_modified autonomous requirements estimate must_haves
quick-260923-lrr 01 execute 1
apps/api/src/favorites, apps/web/src/components/dashboard/widgets
apps/api/prisma/schema.prisma
apps/api/prisma/migrations/20260923160000_favorite_icon_upload/migration.sql
apps/api/src/favorites/favorite-icon-files.ts
apps/api/src/favorites/favorite-icon-files.spec.ts
apps/api/src/favorites/favorites.service.ts
apps/api/src/favorites/favorites.service.spec.ts
apps/api/src/favorites/favorites.controller.ts
apps/api/src/favorites/favorites.controller.spec.ts
docs/anleitung-betrieb.md
apps/web/src/lib/favorites-api.ts
apps/web/src/lib/favorites-api.test.ts
apps/web/src/components/dashboard/widgets/favorites-widget.tsx
apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx
apps/web/src/messages/de.json
apps/web/src/messages/en.json
CHANGELOG.md
docs/anleitung-anwender.md
false
QUICK-260923-lrr
tokens raw_tokens tasks confidence
160000 160000 3 low
truths artifacts key_links
Nach dem Speichern einer geaenderten Logo-Adresse zeigt die Favoriten-Kachel sofort das neue Symbol, nicht erst nach 24 Stunden (Bildadresse traegt ?v=<iconVersion>, iconVersion steigt bei jeder Aenderung der Symbolquelle)
Laesst sich das Bild unter einer neu eingetragenen Logo-Adresse serverseitig nicht abrufen (z. B. Cloudflare-Pruefung, 403/HTML, nicht erreichbar), wird NICHT gespeichert; das Bearbeitungsformular zeigt eine deutsche Meldung mit dem Hinweis, das Symbol hochzuladen
Im Bearbeitungsformular und im Hinzufuegen-Formular laesst sich ein eigenes Symbol hochladen (PNG, JPEG, GIF, WebP, ICO, SVG, hoechstens 512 KB); es erscheint sofort und hat Vorrang vor der Logo-Adresse
„Hochgeladenes Symbol entfernen“ loescht das hochgeladene Symbol; die Kachel faellt auf Logo-Adresse bzw. automatische Erkennung zurueck
Beim Loeschen eines Favoriten verschwindet auch seine hochgeladene Symboldatei aus user-files/favorite-icons
Hochladen, Entfernen und Abrufen eines Symbols gelingt nur dem Besitzer (Benutzer UND Mandant); fremde oder unbekannte Kennung ergibt 404
path provides
apps/api/src/favorites/favorite-icon-files.ts FAVORITE_ICON_MAX_BYTES, detectFavoriteIconMime, favoriteIconExtension, resolveFavoriteIconsDir, favoriteIconAbsolutePath
path provides
apps/api/prisma/migrations/20260923160000_favorite_icon_upload/migration.sql Spalten uploadedIconMime (TEXT NULL) und iconVersion (INTEGER NOT NULL DEFAULT 0) auf FavoriteLink
path provides
apps/api/src/favorites/favorites.service.ts uploadIcon, removeUploadedIcon, Vorrang hochgeladene Datei in getIconBytes, Dateiloeschung in remove, Abrufprobe fuer neue Logo-Adresse, iconVersion-Erhoehung
path provides
apps/api/src/favorites/favorites.controller.ts POST /favorites/:id/icon (multipart-Feld icon, 512 KB), DELETE /favorites/:id/icon, Cache-Control private
path provides
apps/web/src/lib/favorites-api.ts uploadFavoriteIcon, removeFavoriteIcon, FavoriteRequestError, FAVORITE_ICON_MAX_BYTES, Felder uploadedIconMime/iconVersion
path provides
apps/web/src/components/dashboard/widgets/favorites-widget.tsx versionierte Symbol-Adresse, Datei-Auswahl im Bearbeitungs- und Hinzufuegen-Formular, Entfernen-Knopf, Fehlermeldung im Formular
from to via
FavoriteIcon (favorites-widget.tsx) GET /favorites/:id/icon src /api-proxy/favorites/<id>/icon?v=<iconVersion>; Remount-Key enthaelt iconVersion und uploadedIconMime
from to via
FavoritesService.update/uploadIcon/removeUploadedIcon FavoriteLink.iconVersion Prisma-Update mit iconVersion increment 1 bei jeder Aenderung der Symbolquelle
from to via
FavoritesService.getIconBytes user-files/favorite-icons/<userId>/<id>.<ext> uploadedIconMime gesetzt -> Datei lesen (Vorrang), sonst iconUrl ueber IconDiscoveryService.fetchIconBytes
from to via
uploadFavoriteIcon (favorites-api.ts) POST /favorites/:id/icon FormData mit Feld icon, 413 -> iconTooLarge, 400 -> iconInvalidType
from to via
updateFavorite/createFavorite (favorites-api.ts) 422 aus der Abrufprobe FavoriteRequestError('iconUrlUnreachable') -> t('favorites.iconUrlUnreachable') im Formular

Quick-Aufgabe 260923-lrr: Favoriten — eigenes Symbol hochladen, Symbol-Zwischenspeicher nach Aenderung erneuern

Ausgangslage (vom Orchestrator und vom Planer am Code geprueft, Stand b03ffb5)

Meldung des Nutzers: Beim Bearbeiten eines Favoriten eine eigene Logo-Adresse eintragen „bewirkt nichts“; Verdacht Cloudflare-Pruefung vor dem Bild; falls nicht behebbar, soll man ein Symbol hochladen koennen.

Befund:

  1. Zwischenspeicher-Fehler (Vorgabe 1). FavoriteIcon in favorites-widget.tsx laedt immer /api-proxy/favorites/<id>/icon; getIcon in favorites.controller.ts antwortet mit Cache-Control public, 24 h. Die Adresse aendert sich bei neuer Logo-Adresse nicht, der Browser zeigt einen Tag lang das alte Bild. Der vorhandene Remount-Key (iconUrl|url) setzt nur die Stufe zurueck, nicht den HTTP-Zwischenspeicher.
  2. Cloudflare (Vorgabe 2). Die Bytes holt der Server bei JEDEM Abruf ueber IconDiscoveryService.fetchIconBytes. Eine Cloudflare-Pruefung liefert 403/HTML → 502 → die Kachel faellt auf Stufe direct (Favicon der Link-Adresse) zurueck — fuer den Nutzer sieht das wie „nichts passiert“ aus. Umgehen der Bot-Sperre ist ausgeschlossen. Stattdessen: beim Speichern einer NEUEN Logo-Adresse einmal probeweise abrufen (4 s Zeitgrenze, ICON_FETCH_TIMEOUT_MS), bei Fehlschlag 422 mit deutscher Meldung; das Formular zeigt die Meldung. Kosten: ein Aufruf einer vorhandenen Methode — billig, wird gebaut.
  3. Neu: eigenes Symbol hochladen (Vorgabe 3). Ablage im Dateibereich nach dem Muster dashboard-images.service.ts (quick-260922-hk4): user-files/favorite-icons/<userId>/<favoriteId>.<ext>, Dateiname IMMER servergeneriert.
  4. Texte (Vorgabe 4) in Sie-Form, Schluessel in de.json UND en.json; der heute fest verdrahtete Platzhalter „Logo-URL (optional)“ wird dabei ebenfalls uebersetzbar.

Festlegungen des Planers (Claude-Ermessen, gebunden fuer die Ausfuehrung)

  • Datenmodell: FavoriteLink bekommt genau zwei Spalten: uploadedIconMime String? (erkannter Typ des hochgeladenen Symbols; null = keins) und iconVersion Int @default(0) (Zaehler fuer die Bildadresse). KEINE Pfadspalte: der Pfad ist aus userId, id und Endung des Typs vollstaendig ableitbar — damit gelangt auch kein Serverpfad in API-Antworten (Prisma liefert die ganze Zeile an den Client). updatedAt scheidet als Versionsquelle aus, weil Umsortieren und Titelaenderung sonst alle Symbole neu laden liessen.
  • iconVersion steigt (Prisma { increment: 1 }) genau dann, wenn sich die angezeigte Quelle aendert: gespeicherte iconUrl weicht vom alten Wert ab; Symbol hochgeladen; hochgeladenes Symbol entfernt. Nicht bei Titel, Link-Adresse ohne Symbolwechsel oder Reihenfolge. Bestandszeilen starten mit 0 → Adresse ?v=0 unterscheidet sich von der bisherigen unversionierten, alte 24-h-Eintraege im Browser greifen also sofort nicht mehr.
  • Vorrang: hochgeladenes Symbol vor iconUrl. Fehlt die Datei trotz gesetztem Typ, wird protokolliert und auf iconUrl zurueckgefallen; ohne iconUrl → 404.
  • Abrufprobe: nur wenn eine NICHT leere iconUrl uebergeben wird, die vom gespeicherten Wert abweicht (bei update) bzw. ueberhaupt uebergeben wird (bei create). Fehlschlag → UnprocessableEntityException (422), nichts wird geschrieben. Der Web-Klient uebersetzt 422 selbst (Schluessel favorites.iconUrlUnreachable), damit die Meldung sprachrichtig ist. Interne Adressen (vom SSRF-Schutz abgewiesen) fallen ebenfalls unter 422 — konsistent, denn sie wurden auch bisher nie angezeigt; der Hinweis zeigt auf das Hochladen.
  • Hochladen im Bearbeitungsformular: Datei wird ausgewaehlt und beim Klick auf „Speichern“ hochgeladen (erst PATCH, dann Upload) — passt zu Speichern/Abbrechen. „Hochgeladenes Symbol entfernen“ wirkt sofort (wie Loeschen), das Formular bleibt offen. Im Hinzufuegen-Formular: nach createFavorite wird die gewaehlte Datei fuer die neue Kennung hochgeladen; scheitert nur der Upload, bleibt der Favorit angelegt und die Meldung erscheint.
  • Tracer-Modus aus (bewusst): die Architektur ist bereits bewiesen — Dateiablage in user-files mit Besitzpruefung (hk4), Multer-Upload mit Routen-Grenze (pi9) und der Symbol-Proxy existieren. Eine duenne Scheibe braechte keine Information; der Ende-zu-Ende-Nachweis ist Aufgabe 3 (Browser). Reihenfolge Schnittstelle zuerst: API (Aufgabe 1), dann Web (Aufgabe 2).
  • Keine neuen Pakete. Erkennung von ICO und SVG per Signatur bzw. Textpruefung, wie dashboard-image-rules.ts es fuer vier Formate vormacht.
  • Qualitaetsregeln wie bisher: in Produktionscode keine neue any, keine neue !-Zusicherung, kein biome-ignore; as unknown as in apps/api/src bleibt bei hoechstens 29 (gezaehlt vom Planer an b03ffb5). biome lint auf favorites-widget.tsx meldet heute 3 vorbestehende Warnungen (noNonNullAssertion Z. 149, zweimal noImgElement) — die Zahl darf nicht steigen, also KEIN neues <img> (keine Vorschau im Formular; die Zeile selbst zeigt das Symbol).
Favoriten-Symbole aktualisieren sich nach einer Aenderung sofort; eine nicht abrufbare Logo-Adresse wird beim Speichern klar gemeldet statt still gespeichert; je Favorit laesst sich ein eigenes Symbol hochladen und wieder entfernen, abgelegt im Dateibereich und beim Loeschen des Favoriten mit entfernt.

Purpose: Der Nutzer hat eine Logo-Adresse hinter einer Cloudflare-Pruefung — die ist nicht abrufbar, und selbst eine abrufbare neue Adresse erschien wegen des Zwischenspeichers einen Tag lang nicht. Hochladen ist der verlaessliche Weg. Output: Prisma-Migration, Dateiablage-Hilfen, erweiterter Favoriten-Dienst/-Controller mit Tests, erweiterter Web-Klient und Widget mit Tests, Texte de/en, Changelog und Anleitungen, Browser-Nachweis.

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/STATE.md @./CLAUDE.md @apps/api/src/favorites/favorites.service.ts @apps/api/src/favorites/favorites.controller.ts @apps/api/src/dashboard/dashboard-images.service.ts @apps/api/src/dashboard/dashboard-image-rules.ts @apps/web/src/components/dashboard/widgets/favorites-widget.tsx @apps/web/src/lib/favorites-api.ts Vorhandene Vertraege, die der Ausfuehrer nutzt (am Code geprueft, nicht neu erkunden):
  • forTenant(this.prisma, tenantId, userId) aus ../prisma/prisma-tenant.extension — jede Methode des Favoriten-Dienstes laeuft ueber genau einen so gebundenen Klienten.
  • IconDiscoveryService.fetchIconBytes(iconUrl: string): Promise<{ contentType: string; body: Buffer }> — SSRF-geschuetzt, 4 s Zeitgrenze, wirft bei jedem Fehler (blockiert, Zeitgrenze, kein image/*, groesser 1 MB, ungueltige Adresse).
  • detectImageMime(buffer: Uint8Array): 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp' | null aus apps/api/src/dashboard/dashboard-image-rules.ts (reine Funktion, ohne Nest).
  • UploadedFileLike { buffer: Buffer; originalname: string; mimetype: string; size: number } aus apps/api/src/auth/types/auth-user.ts.
  • FileInterceptor aus @nestjs/platform-express; multers LIMIT_FILE_SIZE bildet Nest auf 413 ab (Muster dashboard-images.controller.ts).
  • Controller-Kontext: extractContext(req) im Favoriten-Controller (req.tenantId ?? req.user?.tenantId) — die neuen Routen nutzen DIESE Quelle, NICHT @CurrentUser() (Begruendung im Kopfkommentar des Controllers, 260911-gwh).
  • Testmuster Dienst: favorites.service.spec.ts (Zwei-Klienten-Fake, makeIconDiscovery mit fetchIconBytes-Attrappe ab Z. 196); echtes Temp-Verzeichnis per Umgebungsschalter wie dashboard-images.service.spec.ts Z. 89-93.
  • Testmuster Controller: dashboard-images.controller.spec.ts Z. 1-30 (Attrappe fuer FileInterceptor) und Test 2/3.
  • Testmuster Web-Klient: apps/web/src/lib/dashboard-images-api.test.ts.
  • Next-Rewrite /api-proxy/:path* (next.config.ts Z. 38) reicht Query-Parameter und Cookies an die API durch.
Aufgabe 1: API — Symbol hochladen/entfernen, Vorrang beim Ausliefern, Versionszaehler, Abrufprobe fuer neue Logo-Adresse apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260923160000_favorite_icon_upload/migration.sql, apps/api/src/favorites/favorite-icon-files.ts, apps/api/src/favorites/favorite-icon-files.spec.ts, apps/api/src/favorites/favorites.service.ts, apps/api/src/favorites/favorites.service.spec.ts, apps/api/src/favorites/favorites.controller.ts, apps/api/src/favorites/favorites.controller.spec.ts, docs/anleitung-betrieb.md apps/api/src/favorites/favorites.service.spec.ts (Z. 1-260, Fake-Aufbau), apps/api/src/dashboard/dashboard-images.service.spec.ts (Z. 40-110, Temp-Verzeichnis), apps/api/src/dashboard/dashboard-images.controller.spec.ts (Z. 1-100), apps/api/prisma/schema.prisma (Z. 414-430, model FavoriteLink) favorite-icon-files.spec.ts: - detectFavoriteIconMime: PNG/JPEG/GIF/WebP-Signaturen ergeben dieselben Typen wie detectImageMime; Bytes 00 00 01 00 (mindestens 6 Bytes) ergeben 'image/x-icon'; ``, `<?xml version="1.0"?>` gefolgt von ``, fuehrendes BOM/Leerzeichen, Kommentar oder `` vor ``, ``, leerer Puffer, Klartext, PDF-Signatur und 00 00 02 00 (CUR) ergeben null - favoriteIconExtension: png, jpg, gif, webp, ico, svg; unbekannter Typ ergibt null - favoriteIconAbsolutePath: liegt unter resolveFavoriteIconsDir()//.; Segmente mit '..', '/', '\\' oder leer ergeben null; unbekannter Typ ergibt null - resolveFavoriteIconsDir beachtet FAVORITE_ICONS_DIR favorites.service.spec.ts (neue describe-Bloecke, Temp-Verzeichnis ueber FAVORITE_ICONS_DIR): - uploadIcon PNG: Datei liegt unter //.png mit genau den Bytes, Zeile hat uploadedIconMime 'image/png' und iconVersion um 1 hoeher, Rueckgabe ist die aktualisierte Zeile - uploadIcon ohne Datei -> BadRequestException; Klartext-Puffer -> BadRequestException, keine Datei, Zeile unveraendert - uploadIcon Puffer groesser 512 KB -> PayloadTooLargeException (zweites Netz) - uploadIcon fremder Benutzer, fremder Mandant, unbekannte Kennung -> NotFoundException, keine Datei geschrieben - erneuter Upload mit anderem Typ (erst PNG, dann SVG): .png entfernt, .svg vorhanden, iconVersion insgesamt +2 - getIconBytes mit hochgeladenem Symbol: liefert Dateibytes und gespeicherten Typ, fetchIconBytes wird NICHT aufgerufen - getIconBytes, Typ gesetzt aber Datei fehlt, iconUrl vorhanden -> faellt auf fetchIconBytes(iconUrl) zurueck; ohne iconUrl -> NotFoundException - getIconBytes ohne Upload, ohne iconUrl -> NotFoundException (Bestandsverhalten) - removeUploadedIcon: Datei weg, uploadedIconMime null, iconVersion +1; ohne Upload -> Zeile unveraendert, keine Erhoehung; fremd -> NotFoundException - remove() eines Favoriten mit Upload: Zeile und Datei weg; Fehler beim Datei-Entfernen wird geschluckt (Loeschen gelingt trotzdem) - update mit neuer, abweichender iconUrl: fetchIconBytes genau einmal mit dieser Adresse; wirft die Probe -> UnprocessableEntityException, favoriteLink.update NICHT aufgerufen - update mit unveraenderter iconUrl: keine Probe, keine Erhoehung; update nur Titel: keine Erhoehung; update mit neuer, erreichbarer iconUrl: iconVersion +1 - create mit expliziter iconUrl: Probe; Probe wirft -> UnprocessableEntityException, favoriteLink.create NICHT aufgerufen favorites.controller.spec.ts (neu): - FileInterceptor wird mit 'icon' und { limits: { fileSize: 512 * 1024, files: 1 } } aufgerufen - getIcon setzt Content-Type aus dem Dienst, Cache-Control 'private, max-age=86400', X-Content-Type-Options nosniff, Content-Security-Policy "default-src 'none'; sandbox" - uploadIcon und removeUploadedIcon reichen tenantId aus req.tenantId (vor req.user.tenantId) und userId aus req.user.id an den Dienst; ohne Mandant -> ForbiddenException - POST ':id/icon' und DELETE ':id/icon' sind als Routen-Metadaten vorhanden Zuerst die Tests aus `` schreiben und rot sehen, dann umsetzen (Vorgaben 1-3 des Orchestrators, Festlegungen oben).

(a) Schema und Migration (Festlegung Datenmodell). In model FavoriteLink nach iconUrl die Felder uploadedIconMime String? und iconVersion Int @default(0) einfuegen, je mit kurzem Kommentar (quick-260923-lrr). Neue Migration apps/api/prisma/migrations/20260923160000_favorite_icon_upload/migration.sql: ein ALTER TABLE "FavoriteLink" mit ADD COLUMN "uploadedIconMime" TEXT und ADD COLUMN "iconVersion" INTEGER NOT NULL DEFAULT 0. Deutscher Kopfkommentar im Stil von 20260922120000: wozu die Spalten dienen, dass die Datei unter user-files/favorite-icons/<userId>/<id>.<ext> liegt und kein Pfad gespeichert wird (ableitbar), dass die bestehende RLS-Regel auf FavoriteLink zeilenbezogen ist und fuer neue Spalten nichts braucht, dass migrate deploy beim Start (apps/api/scripts/migrate-and-start.sh) die Migration anwendet. Danach pnpm --filter @tessera/api exec prisma generate (kein prisma format auf das ganze Schema — fremder Diff).

(b) Neue Datei apps/api/src/favorites/favorite-icon-files.ts (rein, ohne Nest/Prisma, Kopfkommentar deutsch nach Muster dashboard-image-rules.ts): FAVORITE_ICON_MAX_BYTES = 512 * 1024; Typ FavoriteIconMime = die vier Typen von detectImageMime plus 'image/x-icon' und 'image/svg+xml'; detectFavoriteIconMime(buffer) ruft zuerst detectImageMime (Import aus ../dashboard/dashboard-image-rules), prueft dann ICO (erste vier Bytes 00 00 01 00, Puffer mindestens 6 Bytes), dann SVG: die ersten 4096 Bytes als UTF-8, BOM und fuehrende Leerzeichen entfernen; der Anfang darf nur aus optionaler XML-Deklaration, beliebig vielen Kommentaren und optionalem DOCTYPE mit Wurzel svg bestehen, dann muss <svg folgen, direkt gefolgt von Leerzeichen, > oder / (ein regulaerer Ausdruck, gross/klein egal); alles andere null, die Funktion wirft nie. favoriteIconExtension(mime) bildet auf png/jpg/gif/webp/ico/svg ab, sonst null. resolveFavoriteIconsDir() nach Muster resolveDashboardImagesDir(): Umgebungsschalter FAVORITE_ICONS_DIR (nur Tests), sonst path.resolve(__dirname, '..', '..', '..', '..', 'user-files', 'favorite-icons'). favoriteIconAbsolutePath(userId, id, mime): null, wenn Endung unbekannt oder ein Segment nicht nur aus Buchstaben, Ziffern und Bindestrich besteht; sonst path.resolve(base, userId, id + '.' + ext) mit Pruefung, dass das Ergebnis unter base + path.sep liegt (sonst null). Kein Byte aus der Anfrage (insbesondere nicht originalname) geht je in einen Pfad.

(c) favorites.service.ts: Logger ergaenzen. Kopfkommentar um einen Absatz 260923-lrr erweitern (Ablage, Vorrang, Versionszaehler, halbe Zustaende, Abrufprobe, 404 statt 403). Neue private Hilfe assertIconUrlLoadable(iconUrl): ruft this.iconDiscovery.fetchIconBytes(iconUrl), jeder Fehler wird zu UnprocessableEntityException('Das Bild unter dieser Adresse konnte nicht geladen werden. Die Seite blockiert vermutlich automatische Abrufe (zum Beispiel durch eine Cloudflare-Prüfung) oder ist nicht erreichbar. Bitte laden Sie das Symbol stattdessen hoch.'). Keine Umgehung von Bot-Sperren, keine anderen Header als die vorhandenen.

  • create(): nach der Widget-Besitzpruefung und vor create, wenn dto.iconUrl nicht leer ist, assertIconUrlLoadable(dto.iconUrl).
  • update(): im Zweig mit nicht leerer dto.iconUrl die Probe nur, wenn der Wert von link.iconUrl abweicht; nach dem Aufbau von data gilt: ist data.iconUrl gesetzt und ungleich link.iconUrl, dann data.iconVersion = { increment: 1 }.
  • Neue Methode uploadIcon(tenantId, id, userId, file: UploadedFileLike | undefined): ohne Datei BadRequestException('Bitte wählen Sie eine Bilddatei aus.'); Puffer groesser FAVORITE_ICON_MAX_BYTES → PayloadTooLargeException (zweites Netz); Typ per detectFavoriteIconMime, null → BadRequestException('Nur Bilder im Format PNG, JPEG, GIF, WebP, ICO oder SVG sind erlaubt.'); Zeile ueber den gebundenen Klienten holen, !link || link.userId !== userId || link.tenantId !== tenantId → NotFoundException('FavoriteLink not found'); Zielpfad per favoriteIconAbsolutePath(link.userId, link.id, mime) (null → InternalServerErrorException('Das Symbol konnte nicht gespeichert werden.')); Ordner rekursiv anlegen, Datei schreiben (Fehler → protokollieren, dieselbe InternalServerErrorException); dann favoriteLink.update mit uploadedIconMime: mime und iconVersion: { increment: 1 }. Scheitert dieses Update und weicht der neue Pfad vom alten ab, die neue Datei wieder entfernen (Fehler schlucken) und neu werfen. Nach Erfolg: hatte die Zeile vorher einen anderen Typ mit anderem Pfad, die alte Datei entfernen (Fehler protokollieren und schlucken, Muster T-HK4-04). Rueckgabe: aktualisierte Zeile.
  • Neue Methode removeUploadedIcon(tenantId, id, userId): gleiche Besitzpruefung; uploadedIconMime === null → Zeile unveraendert zurueck; sonst Update uploadedIconMime: null, iconVersion: { increment: 1 }, danach Datei entfernen (Fehler schlucken). Rueckgabe: aktualisierte Zeile.
  • remove(): nach dem Loeschen der Zeile, falls link.uploadedIconMime gesetzt, die Datei entfernen (Fehler protokollieren und schlucken).
  • getIconBytes(): Besitzpruefung auf !link || link.userId !== userId (404) vorziehen; ist uploadedIconMime gesetzt, Datei lesen und { contentType: link.uploadedIconMime, body } liefern; fehlt sie, warnen und weitermachen; danach wie bisher: ohne iconUrl 404, sonst fetchIconBytes, Fehler → 502. Den Doc-Kommentar anpassen.

(d) favorites.controller.ts: zwei neue Routen direkt nach getIcon und vor @Patch(':id'): @Post(':id/icon') mit @UseInterceptors(FileInterceptor('icon', { limits: { fileSize: FAVORITE_ICON_MAX_BYTES, files: 1 } })), Parameter @Param('id', ParseUUIDPipe), @Req(), @UploadedFile() file?: UploadedFileLike → uploadIcon; @Delete(':id/icon') mit ParseUUIDPipe → removeUploadedIcon. Beide ueber extractContext. In getIcon den bisherigen Wert mit public durch 'private, max-age=86400' ersetzen (die Adresse ist jetzt versioniert, benutzerbezogene Inhalte gehoeren in keinen gemeinsamen Zwischenspeicher wie Nginx Proxy Manager); nosniff und die CSP mit sandbox bleiben unveraendert — sie decken auch hochgeladene SVG ab. Kopfkommentar-Routenliste um die zwei Routen und den Hinweis ?v= ergaenzen.

(e) Tests: favorites.service.spec.ts erweitern — der Fake braucht fuer favoriteLink.update die Behandlung von { increment: n } bei iconVersion, Bestandszeilen im Fake bekommen uploadedIconMime: null und iconVersion: 0; bestehende Tests, die create/update mit expliziter iconUrl aufrufen, brauchen eine aufloesende fetchIconBytes-Attrappe (nur so weit anpassen, wie noetig). Temp-Verzeichnis pro Test ueber FAVORITE_ICONS_DIR, danach Umgebung wiederherstellen und Verzeichnis loeschen. Neue Datei favorites.controller.spec.ts nach Muster dashboard-images.controller.spec.ts.

(f) docs/anleitung-betrieb.md Kap. 6 (Z. ~286, Liste „Hochgeladene Dateien“): „Symbole des Favoriten-Widgets unter user-files/favorite-icons/<Benutzerkennung>/“ ergaenzen. cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec prisma generate && pnpm --filter @tessera/api exec vitest run src/favorites src/dashboard && pnpm --filter @tessera/api exec tsc --noEmit && pnpm exec biome lint apps/api/src/favorites && test "$(grep -rc 'as unknown as' apps/api/src --include=*.ts | awk -F: '{s+=$2} END {print s}')" -le 29 && grep -c "'private, max-age=86400'" apps/api/src/favorites/favorites.controller.ts Alle neuen und bestehenden Tests in src/favorites und src/dashboard gruen; tsc ohne Fehler; biome lint auf apps/api/src/favorites ohne Befund; Migration 20260923160000_favorite_icon_upload vorhanden; as unknown as in apps/api/src hoechstens 29; Betriebsanleitung nennt user-files/favorite-icons.

Aufgabe 2: Web — versionierte Symbol-Adresse, Datei-Auswahl beim Bearbeiten und Hinzufuegen, Entfernen-Knopf, Meldungen im Formular, Texte und Doku apps/web/src/lib/favorites-api.ts, apps/web/src/lib/favorites-api.test.ts, apps/web/src/components/dashboard/widgets/favorites-widget.tsx, apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, CHANGELOG.md, docs/anleitung-anwender.md apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx (Z. 1-120 und 426-490), apps/web/src/lib/dashboard-images-api.test.ts, apps/web/src/lib/dashboard-images-api.ts (Muster Upload und readMessage) favorites-api.test.ts (neu, fetch per vi.stubGlobal): - uploadFavoriteIcon schickt POST an /favorites//icon mit credentials include, FormData mit genau dem Feld icon, OHNE eigenen Content-Type-Header; 200 -> Zeile - uploadFavoriteIcon: Datei groesser 512 KB -> FavoriteRequestError reason iconTooLarge, fetch NICHT aufgerufen; 413 -> iconTooLarge; 400 -> iconInvalidType; 500 -> iconUploadFailed - removeFavoriteIcon schickt DELETE an /favorites//icon, liefert die Zeile - updateFavorite und createFavorite: 422 -> FavoriteRequestError reason iconUrlUnreachable; anderer Fehler -> wie bisher Error favorites-widget.test.tsx (neue describe 'Eigenes Symbol (quick-260923-lrr)'): - Proxy-Bild traegt ?v=; Zeile ohne iconVersion -> ?v=0 - Speichern mit neuer Logo-Adresse, updateFavorite liefert iconVersion 1 -> src des Proxy-Bildes endet danach auf ?v=1 (Cache-Bust sichtbar) - Zeile nur mit uploadedIconMime (iconUrl null) -> Proxy-Bild statt Direktbild - Datei im Bearbeitungsformular waehlen, Speichern -> updateFavorite, danach uploadFavoriteIcon('fav-id-1', Datei); Zeile zeigt Ergebnis (neues ?v=), Formular schliesst - updateFavorite wirft FavoriteRequestError('iconUrlUnreachable') -> Text 'favorites.iconUrlUnreachable' INNERHALB des Formulars (role alert), Formular bleibt offen, uploadFavoriteIcon nicht aufgerufen - Upload wirft FavoriteRequestError('iconTooLarge') -> 'favorites.iconTooLarge' im Formular, Formular bleibt offen - Knopf 'favorites.iconRemoveButton' nur bei gesetztem uploadedIconMime; Klick -> removeFavoriteIcon('fav-id-1'), danach verschwindet der Knopf, Formular bleibt offen - Hinzufuegen mit gewaehlter Datei -> createFavorite, danach uploadFavoriteIcon(created.id, Datei) - Logo-Adress-Feld zeigt den Platzhalter 'favorites.iconUrlPlaceholder' (kein fest verdrahteter Text mehr) Tests aus `` zuerst, dann umsetzen (Vorgaben 1, 3, 4; Festlegungen oben).

(a) favorites-api.ts: FavoriteLink um optionale Felder uploadedIconMime?: string | null und iconVersion?: number erweitern (optional, weil Testdaten und aeltere Antworten sie nicht tragen). Export FAVORITE_ICON_MAX_BYTES = 512 * 1024. Export type FavoriteErrorReason = 'iconUrlUnreachable' | 'iconTooLarge' | 'iconInvalidType' | 'iconUploadFailed' und class FavoriteRequestError extends Error mit oeffentlichem, schreibgeschuetztem reason. createFavorite/updateFavorite: bei Status 422 FavoriteRequestError('iconUrlUnreachable'), sonst unveraendert. Neu uploadFavoriteIcon(id, file): zuerst Groessenpruefung gegen die Konstante (iconTooLarge, ohne Anfrage), dann FormData mit Feld icon (Dateiname mitgeben), POST an ${API_URL}/favorites/<id>/icon, credentials: 'include', KEIN Content-Type-Header (Muster uploadDashboardImage); 413 → iconTooLarge, 400 → iconInvalidType, sonst nicht ok → iconUploadFailed. Neu removeFavoriteIcon(id): DELETE ${API_URL}/favorites/<id>/icon, bei Fehler Error('Failed to remove favorite icon'). Kopfkommentar um 260923-lrr ergaenzen.

(b) favorites-widget.tsx:

  • FavoriteIcon: Server-Symbol vorhanden, wenn fav.iconUrl oder fav.uploadedIconMime gesetzt ist; proxySrc = /api-proxy/favorites/<encodeURIComponent(id)>/icon?v=<fav.iconVersion ?? 0>. Der Remount-Key an der Aufrufstelle in FavoriteTile enthaelt zusaetzlich iconVersion und uploadedIconMime, damit nach einer Aenderung wieder mit Stufe proxy begonnen wird. Kopfkommentar: warum ?v= (24-h-Zwischenspeicher, Adresse muss sich mit der Quelle aendern).
  • Neuer Zustand im Widget: editIconFile: File | null, editError: string | null, editBusy: boolean, newIconFile: File | null plus Ref auf das Datei-Feld des Hinzufuegen-Formulars (zum Zuruecksetzen). Hilfsfunktion, die einen Fehler auf einen Textschluessel abbildet: FavoriteRequestError → favorites.<reason>, sonst favorites.error; angezeigt wird t(schluessel).
  • startEdit/cancelEdit setzen editIconFile, editError, editBusy zurueck.
  • handleSaveEdit: editBusy setzen; updateFavorite wie bisher; ist eine Datei gewaehlt, danach uploadFavoriteIcon(id, datei) und dessen Zeile verwenden; Zeile im Zustand ersetzen und Formular schliessen. Fehler: editError setzen, Formular bleibt offen; ist updateFavorite gelungen und nur der Upload gescheitert, die aktualisierte Zeile trotzdem uebernehmen. editBusy im finally zuruecksetzen.
  • Neu handleRemoveIcon(id): removeFavoriteIcon, Zeile ersetzen, Formular bleibt offen; Fehler → editError.
  • handleAdd: nach createFavorite und ist newIconFile gesetzt, uploadFavoriteIcon(created.id, datei); bei Erfolg dessen Zeile anhaengen, bei Fehler die angelegte Zeile anhaengen und setError(t(schluessel)). Danach Felder und Datei-Feld (per Ref, value = '') zuruecksetzen.
  • Hinzufuegen-Formular: zwischen URL-Feld und Knopf ein Datei-Feld mit sichtbarer Beschriftung t('favorites.iconUploadLabel'), data-testid="favorite-add-icon-upload".
  • Bearbeitungsformular in FavoriteTile (neue Props an BEIDEN Aufrufstellen, Liste und Kacheln): Logo-Adress-Feld mit placeholder und aria-label = t('favorites.iconUrlPlaceholder') statt des fest verdrahteten Textes; darunter ein label mit t('favorites.iconUploadLabel') und <input type="file"> (accept = image/png,image/jpeg,image/gif,image/webp,image/x-icon,image/vnd.microsoft.icon,image/svg+xml,.ico,.svg; data-testid = favorite-icon-upload-<id>), Hinweiszeile t('favorites.iconUploadHint'); bei gesetztem uploadedIconMime der Satz t('favorites.iconUploadedHint') und ein Knopf t('favorites.iconRemoveButton'); editError als <p role="alert"> in text-destructive im Formular; Speichern-Knopf waehrend editBusy deaktiviert. Klassen im Stil der vorhandenen Felder (text-xs, border-input). KEIN neues <img>.
  • Kopfkommentar der Datei um einen Punkt 260923-lrr ergaenzen.

(c) Testattrappe in favorites-widget.test.tsx: die Fabrik fuer @/lib/favorites-api wird asynchron und uebernimmt per vi.importActual die echte FavoriteRequestError und FAVORITE_ICON_MAX_BYTES; uploadFavoriteIcon und removeFavoriteIcon kommen als vi.fn() dazu. Datei-Auswahl per fireEvent.change(feld, { target: { files: [datei] } }).

(d) Texte in widgets.favorites (de mit echten Umlauten, en sinngleich):

  • iconUrlPlaceholder: „Logo-Adresse (optional)“ / „Logo URL (optional)“
  • iconUploadLabel: „Eigenes Symbol hochladen“ / „Upload custom icon“
  • iconUploadHint: „PNG, JPEG, GIF, WebP, ICO oder SVG, höchstens 512 KB. Ein hochgeladenes Symbol hat Vorrang vor der Logo-Adresse.“ / „PNG, JPEG, GIF, WebP, ICO or SVG, at most 512 KB. An uploaded icon takes precedence over the logo URL.“
  • iconUploadedHint: „Für diesen Favoriten ist ein eigenes Symbol hochgeladen.“ / „A custom icon has been uploaded for this favorite.“
  • iconRemoveButton: „Hochgeladenes Symbol entfernen“ / „Remove uploaded icon“
  • iconUrlUnreachable: „Das Bild unter dieser Adresse konnte nicht geladen werden. Die Seite blockiert vermutlich automatische Abrufe (zum Beispiel durch eine Cloudflare-Prüfung) oder ist nicht erreichbar. Bitte laden Sie das Symbol stattdessen hoch.“ / englische Entsprechung
  • iconTooLarge: „Die Datei ist zu groß – erlaubt sind höchstens 512 KB.“ / „The file is too large – at most 512 KB is allowed.“
  • iconInvalidType: „Nur Bilder im Format PNG, JPEG, GIF, WebP, ICO oder SVG sind erlaubt.“ / „Only PNG, JPEG, GIF, WebP, ICO or SVG images are allowed.“
  • iconUploadFailed: „Das Symbol konnte nicht hochgeladen werden.“ / „The icon could not be uploaded.“ Meldet der Umlaut-Waechter (src/messages) ein korrekt geschriebenes Wort, dieses Wort in UMLAUT_ALLOWLIST aufnehmen — nie die Schreibweise verbiegen.

(e) CHANGELOG.md, Abschnitt „Unveröffentlicht“: unter „### Neu“ einen Punkt (Favoriten-Widget: eigenes Symbol je Link hochladen — PNG, JPEG, GIF, WebP, ICO oder SVG, höchstens 512 KB — beim Hinzufügen und im Bearbeitungsformular; Vorrang vor der Logo-Adresse; „Hochgeladenes Symbol entfernen“); neuen Unterabschnitt „### Behoben“ mit einem Punkt (nach Ändern der Logo-Adresse erscheint das neue Symbol sofort statt erst nach einem Tag; ist das Bild unter der Adresse nicht abrufbar, etwa wegen einer Cloudflare-Prüfung, sagt das Formular das jetzt, statt still zu speichern). Einfache Worte, Stil der vorhandenen Eintraege. docs/anleitung-anwender.md Z. 85 (Tabellenzeile Favoriten, bleibt EINE Zeile): ergaenzen, dass man im Bearbeitungsformular eine Logo-Adresse eintragen oder ein eigenes Symbol hochladen kann (Formate, 512 KB, Vorrang, Entfernen-Knopf) und dass Tessera beim Speichern meldet, wenn eine Seite automatische Abrufe blockiert. cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/dashboard src/lib/favorites-api.test.ts src/messages && pnpm --filter @tessera/web exec tsc --noEmit && pnpm exec biome lint apps/web/src/lib/favorites-api.ts apps/web/src/lib/favorites-api.test.ts apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx && test "$(pnpm exec biome lint apps/web/src/components/dashboard/widgets/favorites-widget.tsx 2>&1 | grep -cE 'favorites-widget.tsx:[0-9]+:[0-9]+ lint/')" -le 3 Web-Tests in src/components/dashboard, src/lib/favorites-api.test.ts und src/messages gruen (inkl. Umlaut-Waechter); tsc ohne Fehler; biome lint ohne neue Befunde (favorites-widget.tsx hoechstens die 3 vorbestehenden Warnungen); de.json und en.json tragen alle neun neuen Schluessel; CHANGELOG und Anwenderhandbuch ergaenzt.

Aufgabe 3: Browser-Nachweis am lokalen Stack (vom Orchestrator per Playwright MCP) Symbol-Upload je Favorit mit Entfernen, versionierte Symbol-Adresse (sofortige Aktualisierung), Abrufprobe mit deutscher Meldung fuer nicht abrufbare Logo-Adressen, Dateiloeschung beim Loeschen des Favoriten. Durchgefuehrt vom Orchestrator, nicht vom Nutzer. Nur lokal — nie auf dem Testserver deployen. 1. `docker compose up -d --build web api`; in `docker compose logs api` muss die Migration `20260923160000_favorite_icon_upload` als angewendet erscheinen (migrate-and-start.sh). 2. Anmelden, Dashboard in den Bearbeitungsmodus, Favoriten-Kachel (falls keine vorhanden: hinzufuegen, einen Favoriten anlegen). 3. Favorit bearbeiten, kleine PNG-Datei waehlen, Speichern: Das Symbol wechselt OHNE Neuladen der Seite. Nachweis ueber das gerenderte `img` (Attribut `src` endet auf `?v=`, `naturalWidth > 0`) und Screenshot — NICHT per `fetch` aus der Seite messen (Fetch-Falle, siehe Memory). 4. Erneut bearbeiten, SVG hochladen: Symbol wechselt sofort, `?v=` ist gestiegen. 5. „Hochgeladenes Symbol entfernen“: Knopf verschwindet, Kachel zeigt wieder Logo-Adresse bzw. erkanntes Favicon. 6. Logo-Adresse auf eine andere oeffentlich abrufbare Bildadresse aendern (z. B. von `https://github.com/favicon.ico` auf `https://www.google.com/favicon.ico`), Speichern: Symbol wechselt sofort. 7. Logo-Adresse auf eine nicht abrufbare Adresse setzen (z. B. `https://example.invalid/logo.png`, oder eine Adresse hinter Cloudflare-Pruefung, falls der Nutzer eine nennt), Speichern: deutsche Meldung „Das Bild unter dieser Adresse konnte nicht geladen werden …“ im Formular, Formular bleibt offen, nichts gespeichert. 8. Datei ueber 512 KB waehlen: Meldung „Die Datei ist zu groß …“; umbenannte Textdatei als .png: Meldung „Nur Bilder im Format …“. 9. Neuen Favoriten mit gewaehlter Datei hinzufuegen: erscheint direkt mit dem hochgeladenen Symbol. 10. Favoriten mit hochgeladenem Symbol loeschen, danach `docker compose exec api ls -la /app/user-files/favorite-icons//`: keine Datei mit dessen Kennung mehr. Orchestrator meldet „bestanden“ mit Screenshot-Pfaden oder beschreibt die Abweichung je Schritt.

<threat_model>

Trust Boundaries

Boundary Description
Browser → API (POST /favorites/:id/icon) nicht vertrauenswuerdige Datei (Bytes, Name, behaupteter Typ) ueberquert hier
API → Dateisystem (user-files/favorite-icons) aus Zeilendaten abgeleiteter Pfad wird geschrieben/gelesen/geloescht
API → fremder Webserver (Abrufprobe, Icon-Proxy) serverseitiger Abruf einer vom Nutzer genannten Adresse
API → Browser (GET /favorites/:id/icon) gespeicherte, evtl. aktive Inhalte (SVG) werden ausgeliefert

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-LRR-01 Tampering favoriteIconAbsolutePath high mitigate Dateiname nur aus Zeilen-UUID + Endung des an den Bytes ERKANNTEN Typs; Segmente nur [A-Za-z0-9-]; Ergebnis muss unter resolveFavoriteIconsDir() liegen, sonst null; originalname geht in keinen Pfad
T-LRR-02 Elevation of Privilege GET /favorites/:id/icon mit hochgeladenem SVG high mitigate Typ aus Magic Bytes/SVG-Pruefung statt file.mimetype; Auslieferung behaelt X-Content-Type-Options nosniff und CSP "default-src 'none'; sandbox"; Anzeige nur als img (kein Skript)
T-LRR-03 Information Disclosure uploadIcon/removeUploadedIcon/getIconBytes high mitigate forTenant-Bindung plus Vergleich userId UND tenantId gegen den Sitzungsnachweis, fremd/unbekannt -> 404 (nie 403, kein Existenzorakel); Tests fuer fremden Benutzer und fremden Mandanten
T-LRR-04 Denial of Service POST /favorites/:id/icon medium mitigate multer limits fileSize 512 KB, files 1 (413); zweites Netz im Dienst; genau eine Datei je Favorit (Ueberschreiben, alte Endung wird entfernt)
T-LRR-05 Information Disclosure Cache-Control der Symbolantwort medium mitigate private statt public — kein gemeinsamer Zwischenspeicher (Nginx Proxy Manager) haelt benutzerbezogene Symbole; Versionierung per ?v= macht lange Browser-Zwischenspeicherung trotzdem korrekt
T-LRR-06 Spoofing Abrufprobe beim Speichern low accept nutzt unveraendert fetchIconBytes mit bestehendem SSRF-Schutz (isPublicHttpUrl, Weiterleitungswaechter, 4 s, 1 MB) — derselbe Abruf, den GET /favorites/:id/icon ohnehin ausloest; keine neue Angriffsflaeche, keine Umgehung von Bot-Sperren
T-LRR-07 Denial of Service Datei-Leichen nach Loeschen eines Widgets/Reiters low accept FavoriteLink faellt dort per Datenbank-Kaskade, am Dienst vorbei; Dateien bleiben liegen, sind ohne Zeile nie abrufbar, je Favorit hoechstens 512 KB im eigenen Benutzerordner. Einzelloeschung raeumt auf (Vorgabe erfuellt); Restrisiko im SUMMARY benennen
T-LRR-08 Repudiation halbe Zustaende Upload/Loeschen low mitigate Upload: Datei zuerst, Zeile danach, bei Zeilenfehler neue Datei zurueckgenommen; Loeschen: Zeile zuerst, Dateifehler protokolliert und geschluckt (Muster T-HK4-04)
T-LRR-SC Tampering Paketinstallationen low accept keine neuen Pakete; ICO/SVG-Erkennung ohne Abhaengigkeit
</threat_model>
Nach allen Aufgaben (Ausgangslage vom Planer an b03ffb5 gemessen: api src/favorites 49/49 gruen, web favorites-widget + src/messages 23/23 gruen, beide tsc sauber, biome lint auf den Favoriten-Dateien 3 vorbestehende Warnungen in favorites-widget.tsx): - `pnpm --filter @tessera/api exec vitest run src/favorites src/dashboard` gruen - `pnpm --filter @tessera/web exec vitest run src/components/dashboard src/lib/favorites-api.test.ts src/messages` gruen - `pnpm --filter @tessera/api exec tsc --noEmit` und `pnpm --filter @tessera/web exec tsc --noEmit` sauber - `pnpm exec biome lint` auf allen beruehrten TS-Dateien: keine neuen Befunde - Browser-Nachweis (Aufgabe 3) bestanden

<success_criteria>

  • Geaenderte Logo-Adresse und hochgeladenes Symbol erscheinen ohne Seiten-Neuladen (versionierte Adresse).
  • Nicht abrufbare Logo-Adresse wird mit deutscher Meldung im Formular abgewiesen.
  • Upload (PNG, JPEG, GIF, WebP, ICO, SVG, ≤ 512 KB) im Bearbeitungs- und Hinzufuegen-Formular; Entfernen faellt zurueck; Loeschen des Favoriten entfernt die Datei.
  • Besitzpruefung Benutzer + Mandant fuer alle neuen Wege, 404 fuer Fremdes.
  • Texte de/en vollstaendig, Umlaut-Waechter gruen; Changelog, Anwender- und Betriebsanleitung ergaenzt. </success_criteria>
Create `.planning/quick/260923-lrr-favoriten-eigenes-symbol-hochladen-und-s/260923-lrr-SUMMARY.md` when done — mit Restrisiko T-LRR-07 (Datei-Leichen bei Widget-/Reiter-Loeschung) als offenem Punkt.