--- phase: quick-260923-lrr plan: 01 type: execute wave: 1 depends_on: [] subsystem: apps/api/src/favorites, apps/web/src/components/dashboard/widgets files_modified: - 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 autonomous: false requirements: [QUICK-260923-lrr] estimate: tokens: 160000 raw_tokens: 160000 tasks: 3 confidence: low must_haves: truths: - "Nach dem Speichern einer geaenderten Logo-Adresse zeigt die Favoriten-Kachel sofort das neue Symbol, nicht erst nach 24 Stunden (Bildadresse traegt ?v=, 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" artifacts: - path: apps/api/src/favorites/favorite-icon-files.ts provides: "FAVORITE_ICON_MAX_BYTES, detectFavoriteIconMime, favoriteIconExtension, resolveFavoriteIconsDir, favoriteIconAbsolutePath" - path: apps/api/prisma/migrations/20260923160000_favorite_icon_upload/migration.sql provides: "Spalten uploadedIconMime (TEXT NULL) und iconVersion (INTEGER NOT NULL DEFAULT 0) auf FavoriteLink" - path: apps/api/src/favorites/favorites.service.ts provides: "uploadIcon, removeUploadedIcon, Vorrang hochgeladene Datei in getIconBytes, Dateiloeschung in remove, Abrufprobe fuer neue Logo-Adresse, iconVersion-Erhoehung" - path: apps/api/src/favorites/favorites.controller.ts provides: "POST /favorites/:id/icon (multipart-Feld icon, 512 KB), DELETE /favorites/:id/icon, Cache-Control private" - path: apps/web/src/lib/favorites-api.ts provides: "uploadFavoriteIcon, removeFavoriteIcon, FavoriteRequestError, FAVORITE_ICON_MAX_BYTES, Felder uploadedIconMime/iconVersion" - path: apps/web/src/components/dashboard/widgets/favorites-widget.tsx provides: "versionierte Symbol-Adresse, Datei-Auswahl im Bearbeitungs- und Hinzufuegen-Formular, Entfernen-Knopf, Fehlermeldung im Formular" key_links: - from: "FavoriteIcon (favorites-widget.tsx)" to: "GET /favorites/:id/icon" via: "src /api-proxy/favorites//icon?v=; Remount-Key enthaelt iconVersion und uploadedIconMime" - from: "FavoritesService.update/uploadIcon/removeUploadedIcon" to: "FavoriteLink.iconVersion" via: "Prisma-Update mit iconVersion increment 1 bei jeder Aenderung der Symbolquelle" - from: "FavoritesService.getIconBytes" to: "user-files/favorite-icons//." via: "uploadedIconMime gesetzt -> Datei lesen (Vorrang), sonst iconUrl ueber IconDiscoveryService.fetchIconBytes" - from: "uploadFavoriteIcon (favorites-api.ts)" to: "POST /favorites/:id/icon" via: "FormData mit Feld icon, 413 -> iconTooLarge, 400 -> iconInvalidType" - from: "updateFavorite/createFavorite (favorites-api.ts)" to: "422 aus der Abrufprobe" via: "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//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//.`, 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 `` (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. @~/.claude/gsd-core/workflows/execute-plan.md @~/.claude/gsd-core/templates/summary.md @.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'; ``, `` 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//.` 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 `` 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//`“ 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//icon`, `credentials: 'include'`, KEIN Content-Type-Header (Muster `uploadDashboardImage`); 413 → iconTooLarge, 400 → iconInvalidType, sonst nicht ok → iconUploadFailed. Neu `removeFavoriteIcon(id)`: DELETE `${API_URL}/favorites//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//icon?v=`. 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.`, 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 `` (`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-`), Hinweiszeile `t('favorites.iconUploadHint')`; bei gesetztem `uploadedIconMime` der Satz `t('favorites.iconUploadedHint')` und ein Knopf `t('favorites.iconRemoveButton')`; `editError` als `

` in `text-destructive` im Formular; Speichern-Knopf waehrend `editBusy` deaktiviert. Klassen im Stil der vorhandenen Felder (text-xs, border-input). KEIN neues ``. - 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. ## 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 | 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 - 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. 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.