41 KiB
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 |
|
false |
|
|
|
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:
- Zwischenspeicher-Fehler (Vorgabe 1).
FavoriteIconinfavorites-widget.tsxlaedt immer/api-proxy/favorites/<id>/icon;getIconinfavorites.controller.tsantwortet mitCache-Controlpublic, 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. - 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 Stufedirect(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. - 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. - Texte (Vorgabe 4) in Sie-Form, Schluessel in
de.jsonUNDen.json; der heute fest verdrahtete Platzhalter „Logo-URL (optional)“ wird dabei ebenfalls uebersetzbar.
Festlegungen des Planers (Claude-Ermessen, gebunden fuer die Ausfuehrung)
- Datenmodell:
FavoriteLinkbekommt genau zwei Spalten:uploadedIconMime String?(erkannter Typ des hochgeladenen Symbols;null= keins) undiconVersion Int @default(0)(Zaehler fuer die Bildadresse). KEINE Pfadspalte: der Pfad ist aususerId,idund Endung des Typs vollstaendig ableitbar — damit gelangt auch kein Serverpfad in API-Antworten (Prisma liefert die ganze Zeile an den Client).updatedAtscheidet 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: gespeicherteiconUrlweicht vom alten Wert ab; Symbol hochgeladen; hochgeladenes Symbol entfernt. Nicht bei Titel, Link-Adresse ohne Symbolwechsel oder Reihenfolge. Bestandszeilen starten mit 0 → Adresse?v=0unterscheidet 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 auficonUrlzurueckgefallen; ohneiconUrl→ 404. - Abrufprobe: nur wenn eine NICHT leere
iconUrluebergeben wird, die vom gespeicherten Wert abweicht (beiupdate) bzw. ueberhaupt uebergeben wird (beicreate). Fehlschlag →UnprocessableEntityException(422), nichts wird geschrieben. Der Web-Klient uebersetzt 422 selbst (Schluesselfavorites.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
createFavoritewird 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-filesmit 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.tses fuer vier Formate vormacht. - Qualitaetsregeln wie bisher: in Produktionscode keine neue
any, keine neue!-Zusicherung, keinbiome-ignore;as unknown asinapps/api/srcbleibt bei hoechstens 29 (gezaehlt vom Planer anb03ffb5).biome lintauffavorites-widget.tsxmeldet 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).
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>
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, keinimage/*, groesser 1 MB, ungueltige Adresse).detectImageMime(buffer: Uint8Array): 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp' | nullausapps/api/src/dashboard/dashboard-image-rules.ts(reine Funktion, ohne Nest).UploadedFileLike { buffer: Buffer; originalname: string; mimetype: string; size: number }ausapps/api/src/auth/types/auth-user.ts.FileInterceptoraus@nestjs/platform-express; multersLIMIT_FILE_SIZEbildet Nest auf 413 ab (Musterdashboard-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,makeIconDiscoverymitfetchIconBytes-Attrappe ab Z. 196); echtes Temp-Verzeichnis per Umgebungsschalter wiedashboard-images.service.spec.tsZ. 89-93. - Testmuster Controller:
dashboard-images.controller.spec.tsZ. 1-30 (Attrappe fuerFileInterceptor) 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.
(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 vorcreate, wenndto.iconUrlnicht leer ist,assertIconUrlLoadable(dto.iconUrl).update(): im Zweig mit nicht leererdto.iconUrldie Probe nur, wenn der Wert vonlink.iconUrlabweicht; nach dem Aufbau vondatagilt: istdata.iconUrlgesetzt und ungleichlink.iconUrl, danndata.iconVersion = { increment: 1 }.- Neue Methode
uploadIcon(tenantId, id, userId, file: UploadedFileLike | undefined): ohne DateiBadRequestException('Bitte wählen Sie eine Bilddatei aus.'); Puffer groesserFAVORITE_ICON_MAX_BYTES→PayloadTooLargeException(zweites Netz); Typ perdetectFavoriteIconMime, 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 perfavoriteIconAbsolutePath(link.userId, link.id, mime)(null →InternalServerErrorException('Das Symbol konnte nicht gespeichert werden.')); Ordner rekursiv anlegen, Datei schreiben (Fehler → protokollieren, dieselbe InternalServerErrorException); dannfavoriteLink.updatemituploadedIconMime: mimeundiconVersion: { 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 UpdateuploadedIconMime: null,iconVersion: { increment: 1 }, danach Datei entfernen (Fehler schlucken). Rueckgabe: aktualisierte Zeile. remove(): nach dem Loeschen der Zeile, fallslink.uploadedIconMimegesetzt, die Datei entfernen (Fehler protokollieren und schlucken).getIconBytes(): Besitzpruefung auf!link || link.userId !== userId(404) vorziehen; istuploadedIconMimegesetzt, Datei lesen und{ contentType: link.uploadedIconMime, body }liefern; fehlt sie, warnen und weitermachen; danach wie bisher: ohneiconUrl404, sonstfetchIconBytes, 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.
(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, wennfav.iconUrloderfav.uploadedIconMimegesetzt ist;proxySrc=/api-proxy/favorites/<encodeURIComponent(id)>/icon?v=<fav.iconVersion ?? 0>. Der Remount-Key an der Aufrufstelle inFavoriteTileenthaelt zusaetzlichiconVersionunduploadedIconMime, damit nach einer Aenderung wieder mit Stufeproxybegonnen 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 | nullplus Ref auf das Datei-Feld des Hinzufuegen-Formulars (zum Zuruecksetzen). Hilfsfunktion, die einen Fehler auf einen Textschluessel abbildet:FavoriteRequestError→favorites.<reason>, sonstfavorites.error; angezeigt wirdt(schluessel). startEdit/cancelEditsetzeneditIconFile,editError,editBusyzurueck.handleSaveEdit:editBusysetzen;updateFavoritewie bisher; ist eine Datei gewaehlt, danachuploadFavoriteIcon(id, datei)und dessen Zeile verwenden; Zeile im Zustand ersetzen und Formular schliessen. Fehler:editErrorsetzen, Formular bleibt offen; istupdateFavoritegelungen und nur der Upload gescheitert, die aktualisierte Zeile trotzdem uebernehmen.editBusyim finally zuruecksetzen.- Neu
handleRemoveIcon(id):removeFavoriteIcon, Zeile ersetzen, Formular bleibt offen; Fehler →editError. handleAdd: nachcreateFavoriteund istnewIconFilegesetzt,uploadFavoriteIcon(created.id, datei); bei Erfolg dessen Zeile anhaengen, bei Fehler die angelegte Zeile anhaengen undsetError(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 mitplaceholderundaria-label=t('favorites.iconUrlPlaceholder')statt des fest verdrahteten Textes; darunter einlabelmitt('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>), Hinweiszeilet('favorites.iconUploadHint'); bei gesetztemuploadedIconMimeder Satzt('favorites.iconUploadedHint')und ein Knopft('favorites.iconRemoveButton');editErrorals<p role="alert">intext-destructiveim Formular; Speichern-Knopf waehrendeditBusydeaktiviert. 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 inUMLAUT_ALLOWLISTaufnehmen — 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.
<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> |
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>