Compare commits
5 Commits
c294bfddf2
...
3d266418fc
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d266418fc | |||
| 61f95c8c52 | |||
| 7704372c3c | |||
| bf4384ad73 | |||
| b03ffb5d10 |
+4
-1
@@ -7,7 +7,7 @@ status: verified
|
||||
stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren."
|
||||
last_updated: "2026-09-23T15:30:00.000Z"
|
||||
last_activity: 2026-09-23
|
||||
last_activity_desc: Quick 260923-le6 — letzte Abnahmebefunde zum Proxmox-Modul behoben (PMG-Teilsumme, Aktualisieren-Knopf nur fuer Admins); 1.3.1 laeuft auf live (Nutzer bestaetigt)
|
||||
last_activity_desc: Quick 260923-lrr — Favoriten-Symbol hochladen/sofort aktualisieren; davor Bildschirmfoto im Client repariert, Favoriten-Kachel schmaler; alles gepusht
|
||||
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
|
||||
progress:
|
||||
total_phases: 18
|
||||
@@ -469,6 +469,9 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
|
||||
| 260923-ku6 | **Drei Befunde aus dem Proxmox-Rundgang behoben.** (1) „Verbindung testen“ pruefte den GESPEICHERTEN Stand statt der Eingabe — wer den Zugang tippt und vor dem Speichern testet, bekam die Antwort zum alten Wert; jetzt eigene Route `POST servers/test` mit Merge-Regel: normale Felder folgen dem Formular (auch geleert), Geheimnisfelder folgen „leer → gespeicherten Wert behalten“, weil das Formular Geheimnisse nie vorbefuellt. (2) Ein frisch angelegter Server zeigte „Ein unerwarteter Fehler ist aufgetreten“, obwohl nur noch nichts abgefragt war — jetzt eigener ruhiger Zustand mit Verweis auf „Jetzt aktualisieren“. (3) Die Klasse `uppercase` faerbte die ganze Zeile und zeigte die Adresse als „HTTPS://…“ — jetzt nur noch das Produktkuerzel. **Zahlen:** api 1311 → 1316, web 708 → 712, 53 Warnungen gehalten (eine neu ausgeloeste `useOptionalChain`-Warnung gleich mit aufgeloest). | 2026-09-23 | 710034c,f1bb7f7 | [260923-ku6-drei-nachbesserungen-aus-dem-browser-run](./quick/260923-ku6-drei-nachbesserungen-aus-dem-browser-run/) |
|
||||
| 260923-ku6 | **Drei Nachbesserungen aus dem Browser-Rundgang zu 260923-dhh (Proxmox-Modul).** Befund 1 (wichtig): „Verbindung testen" pruefte den gespeicherten Server statt des Formulars — im Formular abgeschaltete Zertifikatspruefung oder ein neu eingetipptes Geheimnis griffen erst nach dem Speichern. Fix: neues `TestProxmoxServerDto` + Merge-Baustein `resolveEffectiveTestServer` in `ProxmoxService`, neue Route `POST servers/test` fuer die Neuanlage (noch kein gespeicherter Server), Geheimnisfelder behalten die bestehende „leer gelassen -> gespeicherten Wert weiterverwenden"-Regel. Befund 2 (wichtig): ein frisch angelegter, nie abgefragter Server zeigte faelschlich „Ein unerwarteter Fehler ist aufgetreten" statt eines ruhigen Hinweises — behoben ueber `status.lastPolledAt === null`. Befund 3 (kosmetisch): `uppercase` faerbte die ganze Statuszeile inkl. Adresse gross — jetzt nur noch das Produktkuerzel. **Zahlen:** api 1311 → 1316, web 708 → 712, type-check 4/4, lint 5/5, Biome web 53 Warnungen unveraendert. | 2026-09-23 | 710034c,f1bb7f7 | [260923-ku6-drei-nachbesserungen-aus-dem-browser-run](./quick/260923-ku6-drei-nachbesserungen-aus-dem-browser-run/) |
|
||||
| 260923-le6 | **Zwei Abnahmebefunde zum Proxmox-Modul behoben.** (1) `sumOrNull` in `normalizePmg` liefert jetzt `null`, sobald EIN Teilwert (Spam/Viren je Richtung) fehlt — vorher stille Teilsumme als vollstaendige Zahl (Blocker aus 260923-dhh-VERIFICATION, Wahrheit 7). (2) „Jetzt aktualisieren“ nur noch fuer ADMIN/SUPER_ADMIN sichtbar (Endpunkt verlangte das schon); `ServerCard` bekommt `isAdmin`, Nicht-Admins lesen bei nie abgefragtem Server „Die Werte erscheinen nach der naechsten automatischen Abfrage“ statt eines Verweises auf den Knopf. Neuer Seitentest `proxmox-page-roles.test.tsx` (5 Rollenfaelle). Offener Randfall: inaktiver, nie abgefragter Server — Text passt dort nicht ganz, Nutzerentscheidung. Proxmox-Tests api 82, web 26 gruen; Typpruefung beider Seiten fehlerfrei; Biome ohne neue Befunde. | 2026-09-23 | c13d657,2eb86e1,2f8dd14,e1b191b | [260923-le6-proxmox-abnahmebefunde-sumornull-null-be](./quick/260923-le6-proxmox-abnahmebefunde-sumornull-null-be/) |
|
||||
| 260923-fst | **Favoriten-Kachel bis auf eine Spalte schmal ziehbar** (fast): `WIDGET_CONSTRAINTS.favorites.minW` 3 -> 1; Titel kuerzt, Symbol bleibt. Im Browser gezogen: 321 -> 47 px. | 2026-09-23 | b03ffb5 | — |
|
||||
| 260923-bug | **Fehler melden: Bildschirmfoto scheiterte an einem fremden Bild** (fast): `html-to-image` bricht die ganze Aufnahme ab, sobald ein `<img>` ohne CORS nicht nachladbar ist -> Haekchen gesperrt. Jetzt `imagePlaceholder` + `onImageErrorHandler`, zweiter Versuch ohne Bilder/Rahmen. Im echten Linux-Client 1.3.1 nachgestellt (Probe-Bild google favicon) und nach dem Fix gegengeprueft. | 2026-09-23 | bf4384a | — |
|
||||
| 260923-lrr | **Favoriten: eigenes Symbol hochladen, Symbol sofort aktualisiert, Cloudflare-Meldung.** Versionszaehler `iconVersion` an der Symboladresse (`?v=`) statt 24-h-Zwischenspeicher mit fester Adresse (Ursache „neue Logo-Adresse, nichts passiert“); `Cache-Control: private`. Upload PNG/JPEG/GIF/WebP/ICO/SVG bis 512 KB nach Dateiinhalt, Ablage `user-files/favorite-icons/<userId>/<id>.<ext>`, Vorrang vor Logo-Adresse, Entfernen-Knopf. Neue Logo-Adresse wird beim Speichern einmal zur Probe abgerufen; scheitert es (Cloudflare-Pruefung, 403), bleibt das Formular offen mit deutscher Meldung und Hinweis aufs Hochladen. Aufraeumen der Dateien auch beim Loeschen einer Kachel/eines Reiters (T-LRR-07 geschlossen). Browser-Nachweis: rot hochgeladen -> sofort rot (v=1), blau -> sofort blau (v=2), Entfernen -> altes Logo (v=3), httpbin 403 -> Meldung, google favicon -> sofort (v=4). Nachtrag Orchestrator: Zeile `dashboard.service.ts`/`favoriteLink` in der Zugriffsklassifikation. api 1368, web 742 gruen. | 2026-09-23 | 7704372,61f95c8 | [260923-lrr-favoriten-eigenes-symbol-hochladen-und-s](./quick/260923-lrr-favoriten-eigenes-symbol-hochladen-und-s/) |
|
||||
|
||||
## Deferred Items
|
||||
|
||||
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
---
|
||||
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>, 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/<id>/icon?v=<iconVersion>; 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/<userId>/<id>.<ext>"
|
||||
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/<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).
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<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
|
||||
|
||||
<interfaces>
|
||||
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.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Aufgabe 1: API — Symbol hochladen/entfernen, Vorrang beim Ausliefern, Versionszaehler, Abrufprobe fuer neue Logo-Adresse</name>
|
||||
<files>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</files>
|
||||
<read_first>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)</read_first>
|
||||
<behavior>
|
||||
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'; `<svg xmlns=...>`, `<?xml version="1.0"?>` gefolgt von `<svg>`, fuehrendes BOM/Leerzeichen, Kommentar oder `<!DOCTYPE svg ...>` vor `<svg` ergeben 'image/svg+xml'; `<html><svg>`, `<!DOCTYPE html>`, 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()/<userId>/<id>.<ext>; 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 <dir>/<userId>/<id>.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
|
||||
</behavior>
|
||||
<action>
|
||||
Zuerst die Tests aus `<behavior>` 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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Aufgabe 2: Web — versionierte Symbol-Adresse, Datei-Auswahl beim Bearbeiten und Hinzufuegen, Entfernen-Knopf, Meldungen im Formular, Texte und Doku</name>
|
||||
<files>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</files>
|
||||
<read_first>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)</read_first>
|
||||
<behavior>
|
||||
favorites-api.test.ts (neu, fetch per vi.stubGlobal):
|
||||
- uploadFavoriteIcon schickt POST an /favorites/<id>/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/<id>/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=<iconVersion>; 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)
|
||||
</behavior>
|
||||
<action>
|
||||
Tests aus `<behavior>` 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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Aufgabe 3: Browser-Nachweis am lokalen Stack (vom Orchestrator per Playwright MCP)</name>
|
||||
<what-built>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.</what-built>
|
||||
<how-to-verify>
|
||||
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=<n>`, `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/<userId>/`: keine Datei mit dessen Kennung mehr.
|
||||
</how-to-verify>
|
||||
<resume-signal>Orchestrator meldet „bestanden“ mit Screenshot-Pfaden oder beschreibt die Abweichung je Schritt.</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<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>
|
||||
|
||||
<verification>
|
||||
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
|
||||
</verification>
|
||||
|
||||
<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>
|
||||
|
||||
<output>
|
||||
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.
|
||||
</output>
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
---
|
||||
phase: quick-260923-lrr
|
||||
plan: 01
|
||||
subsystem: api+web (favorites)
|
||||
tags: [nestjs, prisma, nextjs, upload, cache-busting, favorites]
|
||||
|
||||
requires: []
|
||||
provides:
|
||||
- "FavoriteLink.uploadedIconMime/iconVersion (Migration 20260923160000)"
|
||||
- "favorite-icon-files.ts: Erkennung/Pfadbildung/Best-effort-Loeschung fuer hochgeladene Favoriten-Symbole"
|
||||
- "FavoritesService.uploadIcon/removeUploadedIcon, Vorrang in getIconBytes, Abrufprobe in create/update"
|
||||
- "POST/DELETE /favorites/:id/icon"
|
||||
- "Web: uploadFavoriteIcon/removeFavoriteIcon/FavoriteRequestError in favorites-api.ts"
|
||||
- "FavoritesWidget: versionierte Symbol-Adresse, Datei-Auswahl, Entfernen-Knopf, Fehlermeldung im Formular"
|
||||
- "DashboardService.removeWidget/deleteDashboard raeumen Symboldateien kaskadiert geloeschter Favoriten auf (T-LRR-07)"
|
||||
affects: [dashboard, favorites]
|
||||
|
||||
actuals:
|
||||
tokens: 31900
|
||||
tasks: 2
|
||||
commits: 2
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Dateiablage nach Muster dashboard-images.service.ts: user-files/favorite-icons/<userId>/<id>.<ext>, Dateiname immer servergeneriert"
|
||||
- "Versionszaehler (iconVersion) statt updatedAt fuer Cache-Busting einer Bild-URL"
|
||||
- "Abrufprobe vor dem Speichern einer externen URL statt stiller Speicherung eines nicht ladbaren Wertes"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- apps/api/src/favorites/favorite-icon-files.ts
|
||||
- apps/api/src/favorites/favorite-icon-files.spec.ts
|
||||
- apps/api/src/favorites/favorites.controller.spec.ts
|
||||
- apps/api/prisma/migrations/20260923160000_favorite_icon_upload/migration.sql
|
||||
- apps/web/src/lib/favorites-api.test.ts
|
||||
modified:
|
||||
- apps/api/src/favorites/favorites.service.ts
|
||||
- apps/api/src/favorites/favorites.controller.ts
|
||||
- apps/api/src/dashboard/dashboard.service.ts
|
||||
- apps/web/src/lib/favorites-api.ts
|
||||
- apps/web/src/components/dashboard/widgets/favorites-widget.tsx
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
|
||||
key-decisions:
|
||||
- "iconVersion statt updatedAt als Cache-Bust-Quelle, weil Umsortieren/Titelaenderung sonst jedes Symbol neu laden liessen"
|
||||
- "Hochgeladenes Symbol hat Vorrang vor iconUrl; fehlt die Datei trotz gesetztem Typ, faellt der Dienst protokolliert auf iconUrl zurueck statt 404"
|
||||
- "Abrufprobe nur bei neuer/abweichender iconUrl, nicht bei jedem Speichern — vermeidet unnoetige Netzwerkaufrufe"
|
||||
- "T-LRR-07 (im Plan als Restrisiko akzeptiert) zusaetzlich geschlossen: DashboardService raeumt Symboldateien kaskadiert geloeschter Favoriten jetzt best effort auf"
|
||||
|
||||
requirements-completed: [QUICK-260923-lrr]
|
||||
|
||||
duration: 45min
|
||||
completed: 2026-09-23
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260923-lrr: Favoriten — eigenes Symbol hochladen, Zwischenspeicher nach Änderung erneuern (Summary)
|
||||
|
||||
**Favoriten-Symbole bekommen eine versionierte Adresse (`?v=<iconVersion>`), ein hochgeladenes eigenes Symbol (PNG/JPEG/GIF/WebP/ICO/SVG, ≤512 KB) hat Vorrang vor der Logo-Adresse, und eine serverseitige Abrufprobe weist eine nicht ladbare Logo-Adresse beim Speichern mit einer deutschen Meldung ab statt sie still zu übernehmen.**
|
||||
|
||||
## Ausgangslage
|
||||
|
||||
Der Nutzer meldete: Eine eigene Logo-Adresse eintragen „bewirkt nichts“ (Verdacht: Cloudflare-Prüfung vor dem Bild), und selbst eine tatsächlich abrufbare neue Adresse erschien wegen eines 24-Stunden-Zwischenspeichers einen Tag lang nicht. Beide Ursachen wurden am Code bestätigt (siehe `260923-lrr-PLAN.md`, Abschnitt „Ausgangslage“) und in diesem Lauf behoben; zusätzlich lässt sich jetzt ein eigenes Symbol hochladen.
|
||||
|
||||
## Performance
|
||||
|
||||
- **Dauer:** ca. 45 Minuten
|
||||
- **Aufgaben:** 2 von 2 geplanten Aufgaben ausgeführt (Aufgabe 3, Browser-Nachweis, ist ein separater Checkpoint und wird vom Orchestrator im Anschluss durchgeführt — siehe unten)
|
||||
- **Geänderte/neue Dateien:** 19
|
||||
|
||||
## Aufgaben-Commits
|
||||
|
||||
1. **Aufgabe 1: API — Symbol hochladen/entfernen, Vorrang, Versionszähler, Abrufprobe** — `7704372` (feat)
|
||||
2. **Aufgabe 2: Web — versionierte Symbol-Adresse, Datei-Auswahl, Entfernen-Knopf, Texte** — `61f95c8` (feat)
|
||||
|
||||
Beide Commits liegen direkt auf `main` (Orchestrator-Vorgabe für diesen Lauf: kein Worktree, `git.allow_default_branch_commits: true` in `.planning/config.json`).
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Zwischenspeicher-Fehler behoben:** `GET /favorites/:id/icon` sendet jetzt `Cache-Control: private, max-age=86400` (statt `public`); die Bildadresse im Widget trägt `?v=<iconVersion>`, das bei jeder Änderung der Symbolquelle um eins steigt — ein geändertes Symbol erscheint jetzt ohne Neuladen der Seite.
|
||||
- **Cloudflare-Fall behoben:** Eine neue, explizit eingetragene Logo-Adresse wird beim Speichern einmal serverseitig abgerufen (`assertIconUrlLoadable`, 4 s Zeitgrenze über den vorhandenen `IconDiscoveryService`); scheitert der Abruf, antwortet die API mit 422 und einer deutschen Meldung im Formular — nichts wird gespeichert. Keine Umgehung von Bot-Sperren.
|
||||
- **Eigenes Symbol hochladen:** Neue Felder `FavoriteLink.uploadedIconMime`/`iconVersion` (Migration `20260923160000_favorite_icon_upload`), Ablage unter `user-files/favorite-icons/<userId>/<id>.<ext>` (Dateiname immer servergeneriert, Muster `dashboard-images.service.ts`), Erkennung von PNG/JPEG/GIF/WebP/ICO/SVG an den Bytes (`favorite-icon-files.ts`). Vorrang vor `iconUrl` beim Ausliefern. Hochladen und Entfernen im Bearbeitungs- und Hinzufügen-Formular des Widgets.
|
||||
- **T-LRR-07 zusätzlich geschlossen** (im Plan als Restrisiko mit Disposition „accept“ eingetragen, siehe unten): Löscht ein Nutzer ein ganzes Widget oder einen Dashboard-Reiter, entfernt die Datenbank-Kaskade (`onDelete: Cascade`) die betroffenen `FavoriteLink`-Zeilen, ohne den Favoriten-Dienst zu durchlaufen — dessen Datei-Aufräumung griff dort bisher nicht. `DashboardService.removeWidget`/`deleteDashboard` merken sich jetzt vor der Kaskade, welche Favoriten ein hochgeladenes Symbol tragen, und entfernen deren Dateien danach best effort (nie blockierend für das Löschen selbst).
|
||||
|
||||
## Dateien erstellt/geändert
|
||||
|
||||
**API:**
|
||||
- `apps/api/prisma/schema.prisma` — `FavoriteLink.uploadedIconMime`/`iconVersion`
|
||||
- `apps/api/prisma/migrations/20260923160000_favorite_icon_upload/migration.sql` — neue Migration, lokal angewendet (siehe Verifikation)
|
||||
- `apps/api/src/favorites/favorite-icon-files.ts` (neu) — Erkennung, Pfadbildung, best-effort Dateientfernung
|
||||
- `apps/api/src/favorites/favorite-icon-files.spec.ts` (neu) — 15 Tests
|
||||
- `apps/api/src/favorites/favorites.service.ts` — `uploadIcon`/`removeUploadedIcon`, Abrufprobe, Vorrang in `getIconBytes`
|
||||
- `apps/api/src/favorites/favorites.service.spec.ts` — 49 Tests (24 neu)
|
||||
- `apps/api/src/favorites/favorites.controller.ts` — `POST`/`DELETE /favorites/:id/icon`, `Cache-Control: private`
|
||||
- `apps/api/src/favorites/favorites.controller.spec.ts` (neu) — 7 Tests
|
||||
- `apps/api/src/dashboard/dashboard.service.ts` — T-LRR-07-Aufräumung in `removeWidget`/`deleteDashboard`
|
||||
- `apps/api/src/dashboard/dashboard.service.spec.ts` — 66 Tests (5 neu)
|
||||
- `docs/anleitung-betrieb.md` — `user-files/favorite-icons/` ergänzt
|
||||
|
||||
**Web:**
|
||||
- `apps/web/src/lib/favorites-api.ts` — `FavoriteRequestError`, `uploadFavoriteIcon`/`removeFavoriteIcon`, `FAVORITE_ICON_MAX_BYTES`
|
||||
- `apps/web/src/lib/favorites-api.test.ts` (neu) — 10 Tests
|
||||
- `apps/web/src/components/dashboard/widgets/favorites-widget.tsx` — versionierte Symbol-Adresse, Datei-Auswahl, Entfernen-Knopf, Fehlermeldung im Formular
|
||||
- `apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx` — 27 Tests (10 neu, 1 bestehender Test an `?v=0` angepasst)
|
||||
- `apps/web/src/messages/de.json`/`en.json` — neun neue Texte unter `widgets.favorites`
|
||||
- `CHANGELOG.md`, `docs/anleitung-anwender.md` — ergänzt
|
||||
|
||||
## Entscheidungen
|
||||
|
||||
- **iconVersion statt updatedAt:** `updatedAt` scheidet als Versionsquelle aus, weil Umsortieren oder eine Titeländerung sonst jedes Symbol neu laden ließen. `iconVersion` steigt gezielt nur bei einer Änderung der Symbolquelle.
|
||||
- **Vorrang und Rückfall:** Ein hochgeladenes Symbol hat Vorrang vor `iconUrl`. Fehlt die Datei trotz gesetztem Typ (praktisch nur bei einer manuellen Änderung am Dateisystem denkbar), protokolliert der Dienst eine Warnung und fällt auf `iconUrl` zurück, statt 404 zu werfen — das entspricht dem im Plan festgelegten Verhalten.
|
||||
- **Abrufprobe nur bei Änderung:** Die Probe läuft nur, wenn eine neue oder gegenüber der Zeile abweichende `iconUrl` übergeben wird — nicht bei jedem Speichern. Vermeidet unnötige Netzwerkaufrufe beim bloßen Ändern von Titel oder Reihenfolge.
|
||||
|
||||
## Abweichungen vom Plan
|
||||
|
||||
### Vom Orchestrator angeordnete Zusatzanforderung (kein Regelabweichungsfund, sondern expliziter Auftrag)
|
||||
|
||||
**1. T-LRR-07 geschlossen — Datei-Leichen nach Kaskadenlöschung eines Widgets/Reiters**
|
||||
- **Gefunden während:** vor Aufgabe 1, auf ausdrückliche Anweisung des Orchestrators (Constraint „closes the plan's accepted gap T-LRR-07“)
|
||||
- **Befund:** `DashboardService.removeWidget` (einzelnes Widget) und `DashboardService.deleteDashboard` (ganzer Reiter) löschen `WidgetInstance`-Zeilen; `FavoriteLink.widgetId` trägt `onDelete: Cascade`, wodurch die Datenbank die zugehörigen Favoriten-Zeilen mitlöscht, OHNE `FavoritesService.remove()` zu durchlaufen — dessen Datei-Aufräumung griff dort also nicht. Der Plan hatte dies als Restrisiko T-LRR-07 mit Disposition „accept“ eingetragen (Dateien bleiben liegen, sind ohne Zeile nie abrufbar, höchstens 512 KB je Favorit).
|
||||
- **Fix:** `favorite-icon-files.ts` bekam eine zusätzliche, nie werfende Funktion `removeFavoriteIconFileBestEffort(userId, id, mime)`. `DashboardService.removeWidget`/`deleteDashboard` lesen VOR der Löschung die betroffenen Favoriten mit gesetztem `uploadedIconMime` (ein reiner Lesezugriff, außerhalb der Löschtransaktion) und entfernen NACH erfolgreicher Löschung deren Dateien best effort — ein Dateifehler wird protokolliert und geschluckt, er kann das Löschen des Widgets/Reiters nie verhindern oder zurücknehmen (Muster T-HK4-04).
|
||||
- **Dateien geändert:** `apps/api/src/favorites/favorite-icon-files.ts`, `apps/api/src/dashboard/dashboard.service.ts`, `apps/api/src/dashboard/dashboard.service.spec.ts`
|
||||
- **Tests:** 5 neue Tests in `dashboard.service.spec.ts` (Datei wird entfernt, fehlende Datei wird geschluckt, Favoriten ohne Symbol lösen keinen Dateizugriff aus — für beide Methoden je Fall bzw. anteilig)
|
||||
- **Verifikation:** `pnpm --filter @tessera/api exec vitest run src/dashboard src/favorites` grün (218 Tests)
|
||||
- **Commit:** `7704372` (Teil des Aufgabe-1-Commits, da API-seitig und eng an `favorite-icon-files.ts` gekoppelt)
|
||||
|
||||
**Restrisiko nach diesem Fix:** Das Löschen eines EINZELNEN Favoriten über `DELETE /favorites/:id` sowie die beiden neuen Wege raumen die Datei immer auf. Ein denkbarer Rest bleibt nur, wenn ein Dateisystemfehler ausgerechnet beim best-effort-Entfernen auftritt (protokolliert, nie blockierend) — dasselbe Restrisiko, das die Bilderrahmen-Funktion (`dashboard-images.service.ts`, T-HK4-04) für ihre Bilder ebenfalls bewusst trägt.
|
||||
|
||||
---
|
||||
|
||||
**Gesamt:** 1 Zusatzanforderung umgesetzt (kein Regel-1/2/3-Fund im eigentlichen Sinn, da vom Orchestrator vorgegeben statt während der Ausführung entdeckt — inhaltlich entspricht die Umsetzung Regel 2, fehlende sicherheits-/korrektheitsrelevante Funktionalität).
|
||||
**Auswirkung auf den Plan:** Kein Scope Creep über die Orchestrator-Vorgabe hinaus; alle übrigen Aufgaben wurden wie im Plan spezifiziert umgesetzt.
|
||||
|
||||
## Verifikation (durchgeführt)
|
||||
|
||||
```
|
||||
pnpm --filter @tessera/api exec prisma generate # OK
|
||||
pnpm --filter @tessera/api exec vitest run src/favorites src/dashboard # 218/218 grün
|
||||
pnpm --filter @tessera/api exec tsc --noEmit # sauber
|
||||
pnpm exec biome lint apps/api/src/favorites apps/api/src/dashboard # keine Befunde
|
||||
pnpm --filter @tessera/web exec vitest run src/components/dashboard \
|
||||
src/lib/favorites-api.test.ts src/messages # 276/276 grün
|
||||
pnpm --filter @tessera/web exec tsc --noEmit # sauber
|
||||
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 # keine Befunde
|
||||
biome lint favorites-widget.tsx # 3 Befunde (alle vorbestehend, wie vom Plan erlaubt)
|
||||
grep -rc "as unknown as" apps/api/src # ≤ 29 (Plan-Obergrenze eingehalten)
|
||||
```
|
||||
|
||||
Migration `20260923160000_favorite_icon_upload` wurde lokal über die Container-IP der `db` mit `prisma migrate deploy` angewendet (Vorgabe des Orchestrators: `tessera:tessera_dev`, kein Host-Port).
|
||||
|
||||
## Bekannte Stubs
|
||||
|
||||
Keine — jede neu geschriebene Funktion ist mit echten Daten verdrahtet, keine Platzhalter.
|
||||
|
||||
## Aufgabe 3 — Browser-Nachweis (noch offen, Orchestrator)
|
||||
|
||||
Aufgabe 3 des Plans (`checkpoint:human-verify`, `gate="blocking"`) ist ein separater Verifikationsschritt am lokalen Docker-Stack (Symbol hochladen/entfernen, Cache-Bust im Browser, Abrufprobe, Dateigrößen-/Typgrenzen, Dateiaufräumung nach Löschen) und wird laut Auftrag NICHT von diesem Ausführungslauf durchgeführt — das übernimmt der Orchestrator im Anschluss per Playwright MCP. Diese SUMMARY dokumentiert ausschließlich die abgeschlossenen Aufgaben 1 und 2.
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
- Orchestrator: Aufgabe 3 (Browser-Nachweis) durchführen, siehe `260923-lrr-PLAN.md`.
|
||||
- Kein Blocker für Aufgabe 3 aus Sicht der API/Web-Implementierung — alle automatisierten Prüfungen sind grün, die Migration ist lokal bereits angewendet.
|
||||
|
||||
---
|
||||
*Quick-Aufgabe: 260923-lrr*
|
||||
*Abgeschlossen (Aufgaben 1–2): 2026-09-23*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
Alle in dieser Summary genannten neuen Dateien sowie beide Commits (`7704372`, `61f95c8`) wurden gegen das Repository geprüft und gefunden.
|
||||
@@ -7,6 +7,14 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
||||
### Neu
|
||||
|
||||
- Das Dashboard hat jetzt mehrere Reiter: Sie können beliebig viele Dashboards anlegen, jeder mit eigenen Kacheln und eigener Anordnung, per Ziehen umsortierbar, wobei der erste Reiter beim Öffnen geladen wird. Ihre vorhandenen Kacheln bleiben dabei unverändert auf dem ersten Reiter liegen.
|
||||
- Neues Modul „Proxmox“: zeigt Zustand und Auslastung Ihrer Proxmox-Server (Virtualisierung, Datensicherung und Mail-Gateway) – nur lesend, Tessera ändert dort nichts. Die Server trägt ein Administrator in den Einstellungen ein.
|
||||
- Favoriten-Widget: eigenes Symbol je Link hochladen – PNG, JPEG, GIF, WebP, ICO oder SVG, höchstens 512 KB – beim Hinzufügen und im Bearbeitungsformular; ein hochgeladenes Symbol hat Vorrang vor der Logo-Adresse; „Hochgeladenes Symbol entfernen“ macht es rückgängig
|
||||
|
||||
### Behoben
|
||||
|
||||
- Fehler melden: das Häkchen „Bildschirmfoto beifügen“ war in manchen Fällen gesperrt, weil die Aufnahme an einem einzelnen Bild einer fremden Website scheiterte; die Aufnahme gelingt jetzt trotzdem
|
||||
- Favoriten-Widget: lässt sich jetzt bis auf eine Spalte schmal ziehen – bei kurzen Linknamen bleibt rechts kein leerer Platz mehr
|
||||
- Favoriten-Widget: nach dem Ändern der Logo-Adresse erscheint das neue Symbol jetzt sofort, statt erst nach einem Tag; ist das Bild unter der Adresse nicht abrufbar – etwa wegen einer Cloudflare-Prüfung – meldet das Formular das jetzt beim Speichern, statt die Adresse still zu übernehmen
|
||||
|
||||
## 1.3.1 – 2026-09-23
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
-- quick-260923-lrr — Favoriten: eigenes Symbol hochladen, Zwischenspeicher
|
||||
-- nach Aenderung erneuern.
|
||||
--
|
||||
-- Zwei neue Spalten auf "FavoriteLink":
|
||||
--
|
||||
-- "uploadedIconMime" TEXT NULL — der an den Bytes ERKANNTE Typ eines
|
||||
-- hochgeladenen eigenen Symbols (PNG/JPEG/GIF/WebP/ICO/SVG); NULL, wenn
|
||||
-- kein eigenes Symbol hochgeladen wurde. Es wird KEIN Pfad gespeichert:
|
||||
-- die Datei liegt vollstaendig ableitbar unter
|
||||
-- "user-files/favorite-icons/<userId>/<id>.<ext>" (Endung aus dem
|
||||
-- erkannten Typ) — damit gelangt auch bei einer vollstaendigen
|
||||
-- Zeilenauslieferung (Prisma liefert die ganze Zeile an den Client) kein
|
||||
-- Serverpfad in eine API-Antwort. Vorrang vor "iconUrl": ist der Wert
|
||||
-- gesetzt, liefert GET /favorites/:id/icon die hochgeladene Datei statt
|
||||
-- die gespeicherte Logo-Adresse abzurufen.
|
||||
--
|
||||
-- "iconVersion" INTEGER NOT NULL DEFAULT 0 — Zaehler fuer die ausgelieferte
|
||||
-- Symbol-Adresse (?v=<iconVersion>). Steigt genau dann (Prisma
|
||||
-- { increment: 1 }), wenn sich die angezeigte Symbolquelle aendert: eine
|
||||
-- gespeicherte "iconUrl" weicht vom alten Wert ab, ein Symbol wird
|
||||
-- hochgeladen, oder ein hochgeladenes Symbol wird entfernt. NICHT bei
|
||||
-- Titel-, Link-Adress- oder Reihenfolgeaenderung ohne Symbolwechsel.
|
||||
-- Bestandszeilen starten bei 0 — das unterscheidet sich von der vorher
|
||||
-- unversionierten Adresse, ein bereits 24 Stunden im Browser
|
||||
-- zwischengespeichertes Symbol wird dadurch beim naechsten Laden sofort
|
||||
-- ungueltig.
|
||||
--
|
||||
-- Die bestehende RLS-Regel "tenant_isolation_policy" auf "FavoriteLink"
|
||||
-- (Migration 20260911120000, zeilenbezogen ueber "tenantId"/"userId")
|
||||
-- braucht fuer zwei zusaetzliche Spalten KEINE Anpassung — sie schuetzt
|
||||
-- Zeilen, nicht Spalten. Kein CREATE/DROP POLICY in dieser Migration.
|
||||
--
|
||||
-- "migrate deploy" wendet diese Migration beim Start an
|
||||
-- (apps/api/scripts/migrate-and-start.sh) — kein manueller Schritt.
|
||||
|
||||
ALTER TABLE "FavoriteLink" ADD COLUMN "uploadedIconMime" TEXT;
|
||||
ALTER TABLE "FavoriteLink" ADD COLUMN "iconVersion" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -419,6 +419,16 @@ model FavoriteLink {
|
||||
title String
|
||||
url String
|
||||
iconUrl String?
|
||||
// quick-260923-lrr: Typ eines hochgeladenen eigenen Symbols (erkannt an
|
||||
// den Bytes, NIE aus der Anfrage uebernommen); null = kein hochgeladenes
|
||||
// Symbol. Kein Pfad gespeichert — die Datei liegt ableitbar unter
|
||||
// user-files/favorite-icons/<userId>/<id>.<ext>.
|
||||
uploadedIconMime String?
|
||||
// quick-260923-lrr: Zaehler fuer die ausgelieferte Symbol-Adresse
|
||||
// (?v=<iconVersion>), steigt bei jeder Aenderung der Symbolquelle
|
||||
// (neue iconUrl, Upload, Entfernen) — macht den 24-h-Browser-Zwischenspeicher
|
||||
// nach einer Aenderung sofort ungueltig.
|
||||
iconVersion Int @default(0)
|
||||
position Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* WIDGET_MODULE_MAP wird je Testfall über eine gemeinsame, gemockte
|
||||
@@ -45,6 +48,7 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
|
||||
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { favoriteIconAbsolutePath } from '../favorites/favorite-icon-files';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
|
||||
@@ -52,8 +56,16 @@ import { DashboardService } from './dashboard.service';
|
||||
* Herkunfts-Tenant protokollierenden Wrapper versieht. `module` ist bewusst
|
||||
* NICHT enthalten — der Katalogzugriff bleibt ungebunden. `searchProvider`
|
||||
* ergaenzt seit Aufgabe 3 (260910-krx). `dashboard` ergaenzt seit
|
||||
* quick-260923-ad9 (Task 1). */
|
||||
const BOUND_MODEL_NAMES = ['dashboard', 'dashboardLayout', 'widgetInstance', 'searchProvider'];
|
||||
* quick-260923-ad9 (Task 1). `favoriteLink` ergaenzt seit quick-260923-lrr
|
||||
* (T-LRR-07: `removeWidget`/`deleteDashboard` lesen vor der Kaskade die
|
||||
* betroffenen Favoriten mit hochgeladenem Symbol). */
|
||||
const BOUND_MODEL_NAMES = [
|
||||
'dashboard',
|
||||
'dashboardLayout',
|
||||
'widgetInstance',
|
||||
'searchProvider',
|
||||
'favoriteLink',
|
||||
];
|
||||
|
||||
/** Standard-Reiter-Kennung, die die meisten Tests verwenden — ein Reiter
|
||||
* `dash-1`, der `user-1`/`tenant-1` gehört (Standard-Fixture unten). */
|
||||
@@ -125,6 +137,27 @@ function makeSearchProvider(
|
||||
};
|
||||
}
|
||||
|
||||
/** Minimale FavoriteLink-Fixture fuer T-LRR-07 (quick-260923-lrr) — nur die
|
||||
* Felder, die `cleanUpFavoriteIconFiles` und die vorbereitenden `findMany`-
|
||||
* Aufrufe in `removeWidget`/`deleteDashboard` lesen. */
|
||||
function makeFavorite(
|
||||
overrides: Partial<{
|
||||
id: string;
|
||||
userId: string;
|
||||
tenantId: string;
|
||||
widgetId: string;
|
||||
uploadedIconMime: string | null;
|
||||
}> = {},
|
||||
) {
|
||||
return {
|
||||
id: overrides.id ?? 'fav-1',
|
||||
userId: overrides.userId ?? 'user-1',
|
||||
tenantId: overrides.tenantId ?? 'tenant-1',
|
||||
widgetId: overrides.widgetId ?? 'w1',
|
||||
uploadedIconMime: overrides.uploadedIconMime === undefined ? null : overrides.uploadedIconMime,
|
||||
};
|
||||
}
|
||||
|
||||
function makeFakePrisma(
|
||||
opts: {
|
||||
widgets?: ReturnType<typeof makeWidget>[];
|
||||
@@ -132,12 +165,14 @@ function makeFakePrisma(
|
||||
layout?: { dashboardId: string; userId: string; tenantId: string; layouts: unknown } | null;
|
||||
searchProviders?: ReturnType<typeof makeSearchProvider>[];
|
||||
dashboards?: ReturnType<typeof makeDashboard>[];
|
||||
favorites?: ReturnType<typeof makeFavorite>[];
|
||||
} = {},
|
||||
) {
|
||||
const widgets = opts.widgets ?? [];
|
||||
const modules = opts.modules ?? [];
|
||||
let layoutRow = opts.layout ?? null;
|
||||
const searchProviders = opts.searchProviders ?? [];
|
||||
const favorites = opts.favorites ?? [];
|
||||
// Standard-Fixture: GENAU EIN Reiter `dash-1`, der user-1/tenant-1 gehört
|
||||
// — die meisten Tests wollen sich um Reiter-Verwaltung nicht kümmern.
|
||||
// Tests, die eine andere Besitzlage brauchen (fremder Reiter, ADMIN mit
|
||||
@@ -256,6 +291,24 @@ function makeFakePrisma(
|
||||
return removed;
|
||||
}),
|
||||
},
|
||||
// T-LRR-07 (quick-260923-lrr): nur `findMany` — `removeWidget`/
|
||||
// `deleteDashboard` LESEN die betroffenen Favoriten vor der Kaskade,
|
||||
// sie schreiben nie auf `favoriteLink` (das Loeschen selbst passiert
|
||||
// ueber die Datenbank-Kaskade auf `widgetInstance`, nicht hier).
|
||||
favoriteLink: {
|
||||
findMany: vi.fn(async ({ where }: any) => {
|
||||
let rows = favorites.filter((f) => f.userId === where.userId);
|
||||
if (where.widgetId?.in) {
|
||||
rows = rows.filter((f) => where.widgetId.in.includes(f.widgetId));
|
||||
} else if (where.widgetId) {
|
||||
rows = rows.filter((f) => f.widgetId === where.widgetId);
|
||||
}
|
||||
if (where.uploadedIconMime?.not === null) {
|
||||
rows = rows.filter((f) => f.uploadedIconMime !== null);
|
||||
}
|
||||
return rows;
|
||||
}),
|
||||
},
|
||||
module: {
|
||||
findMany: vi.fn(async ({ where }: any) => {
|
||||
const slugs: string[] = where.slug.in;
|
||||
@@ -667,6 +720,69 @@ describe('DashboardService — Anordnung und Widgets gebunden an forTenant() (26
|
||||
);
|
||||
});
|
||||
|
||||
describe('removeWidget — T-LRR-07 (quick-260923-lrr): Datei-Leichen nach Kaskadenloeschung', () => {
|
||||
let iconsDir: string;
|
||||
const ORIGINAL_DIR_ENV = process.env.FAVORITE_ICONS_DIR;
|
||||
|
||||
beforeEach(() => {
|
||||
iconsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-favorite-icons-dashboard-'));
|
||||
process.env.FAVORITE_ICONS_DIR = iconsDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(iconsDir, { recursive: true, force: true });
|
||||
if (ORIGINAL_DIR_ENV === undefined) {
|
||||
delete process.env.FAVORITE_ICONS_DIR;
|
||||
} else {
|
||||
process.env.FAVORITE_ICONS_DIR = ORIGINAL_DIR_ENV;
|
||||
}
|
||||
});
|
||||
|
||||
it('entfernt die Symboldatei eines Favoriten, dessen Zeile die Datenbank-Kaskade mitloescht', async () => {
|
||||
const widget = makeWidget({ id: 'w1', userId: 'user-1' });
|
||||
const favorite = makeFavorite({
|
||||
id: 'fav-1',
|
||||
userId: 'user-1',
|
||||
widgetId: 'w1',
|
||||
uploadedIconMime: 'image/png',
|
||||
});
|
||||
const prisma = makeFakePrisma({ widgets: [widget], favorites: [favorite] });
|
||||
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
||||
|
||||
const absolute = favoriteIconAbsolutePath('user-1', 'fav-1', 'image/png') as string;
|
||||
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
||||
fs.writeFileSync(absolute, Buffer.from([1, 2, 3]));
|
||||
|
||||
await service.removeWidget('w1', 'user-1', 'tenant-1');
|
||||
|
||||
expect(fs.existsSync(absolute)).toBe(false);
|
||||
});
|
||||
|
||||
it('fehlende Datei wird geschluckt — removeWidget scheitert nicht daran', async () => {
|
||||
const widget = makeWidget({ id: 'w1', userId: 'user-1' });
|
||||
const favorite = makeFavorite({
|
||||
id: 'fav-1',
|
||||
userId: 'user-1',
|
||||
widgetId: 'w1',
|
||||
uploadedIconMime: 'image/png',
|
||||
});
|
||||
const prisma = makeFakePrisma({ widgets: [widget], favorites: [favorite] });
|
||||
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
||||
|
||||
await expect(service.removeWidget('w1', 'user-1', 'tenant-1')).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it('Favoriten OHNE hochgeladenes Symbol loesen keinen Dateizugriff aus', async () => {
|
||||
const widget = makeWidget({ id: 'w1', userId: 'user-1' });
|
||||
const favorite = makeFavorite({ id: 'fav-1', userId: 'user-1', widgetId: 'w1', uploadedIconMime: null });
|
||||
const prisma = makeFakePrisma({ widgets: [widget], favorites: [favorite] });
|
||||
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
||||
|
||||
await expect(service.removeWidget('w1', 'user-1', 'tenant-1')).resolves.toBeTruthy();
|
||||
expect(fs.existsSync(path.join(iconsDir, 'user-1'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('keine Methode dieses Bereichs erzeugt mehr als EINEN gebundenen Klienten je Aufruf', async () => {
|
||||
const widget = makeWidget({ id: 'w1', userId: 'user-1' });
|
||||
const prisma = makeFakePrisma({
|
||||
@@ -1234,6 +1350,63 @@ describe('DashboardService.deleteDashboard — Reiter löschen (quick-260923-ad9
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
describe('T-LRR-07 (quick-260923-lrr): Datei-Leichen nach Kaskadenloeschung eines ganzen Reiters', () => {
|
||||
let iconsDir: string;
|
||||
const ORIGINAL_DIR_ENV = process.env.FAVORITE_ICONS_DIR;
|
||||
|
||||
beforeEach(() => {
|
||||
iconsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-favorite-icons-dashboard-tab-'));
|
||||
process.env.FAVORITE_ICONS_DIR = iconsDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(iconsDir, { recursive: true, force: true });
|
||||
if (ORIGINAL_DIR_ENV === undefined) {
|
||||
delete process.env.FAVORITE_ICONS_DIR;
|
||||
} else {
|
||||
process.env.FAVORITE_ICONS_DIR = ORIGINAL_DIR_ENV;
|
||||
}
|
||||
});
|
||||
|
||||
it('entfernt die Symboldateien ALLER Favoriten der Widgets dieses Reiters', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
dashboards: [makeDashboard({ id: 'd1', position: 0 }), makeDashboard({ id: 'd2', position: 1 })],
|
||||
widgets: [
|
||||
makeWidget({ id: 'w1', dashboardId: 'd1' }),
|
||||
makeWidget({ id: 'w2', dashboardId: 'd1' }),
|
||||
],
|
||||
favorites: [
|
||||
makeFavorite({ id: 'fav-1', widgetId: 'w1', uploadedIconMime: 'image/png' }),
|
||||
makeFavorite({ id: 'fav-2', widgetId: 'w2', uploadedIconMime: 'image/svg+xml' }),
|
||||
makeFavorite({ id: 'fav-3', widgetId: 'w2', uploadedIconMime: null }),
|
||||
],
|
||||
});
|
||||
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
||||
|
||||
const abs1 = favoriteIconAbsolutePath('user-1', 'fav-1', 'image/png') as string;
|
||||
const abs2 = favoriteIconAbsolutePath('user-1', 'fav-2', 'image/svg+xml') as string;
|
||||
for (const absolute of [abs1, abs2]) {
|
||||
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
||||
fs.writeFileSync(absolute, Buffer.from([1]));
|
||||
}
|
||||
|
||||
await service.deleteDashboard('d1', 'user-1', 'tenant-1');
|
||||
|
||||
expect(fs.existsSync(abs1)).toBe(false);
|
||||
expect(fs.existsSync(abs2)).toBe(false);
|
||||
});
|
||||
|
||||
it('ein Reiter ohne Widgets loest keinen Dateizugriff aus', async () => {
|
||||
const prisma = makeFakePrisma({
|
||||
dashboards: [makeDashboard({ id: 'd1', position: 0 }), makeDashboard({ id: 'd2', position: 1 })],
|
||||
});
|
||||
const service = new DashboardService(prisma as any, makeFakeModuleAccessService(new Set()) as any);
|
||||
|
||||
await expect(service.deleteDashboard('d1', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'd1' });
|
||||
expect(fs.existsSync(path.join(iconsDir, 'user-1'))).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardService.reorderDashboards — Reiter umsortieren (quick-260923-ad9, Task 2, T-AD9-04)', () => {
|
||||
|
||||
@@ -2,9 +2,11 @@ import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma, Role } from '@prisma/client';
|
||||
import { removeFavoriteIconFileBestEffort } from '../favorites/favorite-icon-files';
|
||||
import { ModuleAccessService } from '../module-registry/module-access.service';
|
||||
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@@ -91,11 +93,40 @@ const DEFAULT_SEARCH_PROVIDERS = [
|
||||
*/
|
||||
@Injectable()
|
||||
export class DashboardService {
|
||||
private readonly logger = new Logger(DashboardService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly moduleAccessService: ModuleAccessService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* T-LRR-07 (quick-260923-lrr, Restrisiko aus dem Favoriten-Plan
|
||||
* geschlossen): loescht ein Widget seine `FavoriteLink`-Zeilen ueber die
|
||||
* Datenbank-Kaskade (`onDelete: Cascade` auf `FavoriteLink.widgetId`),
|
||||
* OHNE `FavoritesService` zu durchlaufen — dessen Datei-Aufraeumung in
|
||||
* `remove()` greift hier also nicht. Diese Hilfsfunktion entfernt die
|
||||
* Symboldateien der betroffenen Favoriten NACHTRAEGLICH, best effort
|
||||
* (Muster T-HK4-04): ein Dateifehler wird protokolliert und geschluckt,
|
||||
* er darf das Loeschen des Widgets/Reiters nie verhindern oder
|
||||
* zuruecknehmen — deshalb laeuft dieser Aufruf immer NACH der
|
||||
* erfolgreichen Datenbankoperation, nie innerhalb ihrer Transaktion.
|
||||
*/
|
||||
private async cleanUpFavoriteIconFiles(
|
||||
userId: string,
|
||||
rows: Array<{ id: string; uploadedIconMime: string | null }>,
|
||||
): Promise<void> {
|
||||
for (const row of rows) {
|
||||
if (row.uploadedIconMime === null) continue;
|
||||
const removed = await removeFavoriteIconFileBestEffort(userId, row.id, row.uploadedIconMime);
|
||||
if (!removed) {
|
||||
this.logger.warn(
|
||||
`Symboldatei des kaskadiert geloeschten Favoriten ${row.id} konnte nicht entfernt werden (T-LRR-07)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reiter (quick-260923-ad9, D-01/D-08/D-09): liest die Dashboards des
|
||||
* Benutzers, nach `position` aufsteigend — Position 0 ist der Standard
|
||||
@@ -243,7 +274,25 @@ export class DashboardService {
|
||||
throw new ConflictException('Der letzte verbleibende Reiter kann nicht gelöscht werden.');
|
||||
}
|
||||
|
||||
return withTenantTransaction(this.prisma, tenantId, async (tx) => {
|
||||
// T-LRR-07: VOR der Kaskade merken, welche Favoriten dieses Reiters ein
|
||||
// eigenes hochgeladenes Symbol tragen — siehe `cleanUpFavoriteIconFiles`.
|
||||
// Nur ein Lesezugriff, kein Schreiben; laeuft ausserhalb der Transaktion
|
||||
// unten, weil die Dateiraeumung selbst NICHT transaktional sein muss
|
||||
// (und best effort niemals einen Rollback ausloesen darf).
|
||||
const widgetsOnTab = await tenantPrisma.widgetInstance.findMany({
|
||||
where: { dashboardId: id, userId },
|
||||
select: { id: true },
|
||||
});
|
||||
const widgetIds = widgetsOnTab.map((w: { id: string }) => w.id);
|
||||
const iconRows =
|
||||
widgetIds.length === 0
|
||||
? []
|
||||
: await tenantPrisma.favoriteLink.findMany({
|
||||
where: { widgetId: { in: widgetIds }, userId, uploadedIconMime: { not: null } },
|
||||
select: { id: true, uploadedIconMime: true },
|
||||
});
|
||||
|
||||
const result = await withTenantTransaction(this.prisma, tenantId, async (tx) => {
|
||||
await tx.widgetInstance.deleteMany({ where: { dashboardId: id, userId } });
|
||||
await tx.dashboardLayout.deleteMany({ where: { dashboardId: id, userId } });
|
||||
await tx.dashboard.deleteMany({ where: { id, userId } });
|
||||
@@ -262,6 +311,10 @@ export class DashboardService {
|
||||
|
||||
return { id };
|
||||
});
|
||||
|
||||
await this.cleanUpFavoriteIconFiles(userId, iconRows);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -505,6 +558,11 @@ export class DashboardService {
|
||||
* Verifies ownership by userId before deleting (T-05-01) — same real
|
||||
* ownership check as `updateWidgetConfig` above, same reasoning: both
|
||||
* queries run over the SAME bound client and tenant id.
|
||||
*
|
||||
* T-LRR-07 (quick-260923-lrr): dieselbe Kaskade wie in `deleteDashboard`
|
||||
* trifft hier ein einzelnes Widget — vor dem Loeschen werden dessen
|
||||
* Favoriten mit hochgeladenem Symbol gemerkt, danach werden ihre Dateien
|
||||
* best effort entfernt (siehe `cleanUpFavoriteIconFiles`).
|
||||
*/
|
||||
async removeWidget(id: string, userId: string, tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
@@ -518,9 +576,18 @@ export class DashboardService {
|
||||
);
|
||||
}
|
||||
|
||||
return tenantPrisma.widgetInstance.delete({
|
||||
const iconRows = await tenantPrisma.favoriteLink.findMany({
|
||||
where: { widgetId: id, userId, uploadedIconMime: { not: null } },
|
||||
select: { id: true, uploadedIconMime: true },
|
||||
});
|
||||
|
||||
const result = await tenantPrisma.widgetInstance.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
await this.cleanUpFavoriteIconFiles(userId, iconRows);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- Search Providers (05-02, D-15) ---
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
FAVORITE_ICON_MAX_BYTES,
|
||||
detectFavoriteIconMime,
|
||||
favoriteIconAbsolutePath,
|
||||
favoriteIconExtension,
|
||||
removeFavoriteIconFileBestEffort,
|
||||
resolveFavoriteIconsDir,
|
||||
} from './favorite-icon-files';
|
||||
|
||||
/**
|
||||
* favorite-icon-files.spec — NEU (quick-260923-lrr).
|
||||
*
|
||||
* Erkennung (Muster dashboard-image-rules.spec.ts): PNG/JPEG/GIF/WebP wie
|
||||
* `detectImageMime`, dazu ICO (Kopfstueck, kein CUR) und SVG (Praefix-Form,
|
||||
* kein `<html>` davor). Pfadbildung: Endung aus dem Typ, Segmente nur aus
|
||||
* Buchstaben/Ziffern/Bindestrich, Ergebnis muss im Symbolverzeichnis liegen
|
||||
* (T-LRR-01). `FAVORITE_ICONS_DIR` steuert das Verzeichnis in Tests, wie
|
||||
* `DASHBOARD_IMAGES_DIR` es fuer die Bilderrahmen-Bilder tut.
|
||||
*/
|
||||
function bytes(...parts: (number[] | string)[]): Uint8Array {
|
||||
const out: number[] = [];
|
||||
for (const p of parts) {
|
||||
if (typeof p === 'string') {
|
||||
for (const ch of p) out.push(ch.charCodeAt(0));
|
||||
} else {
|
||||
out.push(...p);
|
||||
}
|
||||
}
|
||||
return Uint8Array.from(out);
|
||||
}
|
||||
|
||||
describe('detectFavoriteIconMime (quick-260923-lrr)', () => {
|
||||
it('PNG/JPEG/GIF/WebP-Signaturen ergeben dieselben Typen wie detectImageMime', () => {
|
||||
expect(
|
||||
detectFavoriteIconMime(bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], [0, 0, 0, 13])),
|
||||
).toBe('image/png');
|
||||
expect(detectFavoriteIconMime(bytes([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10], 'JFIF'))).toBe('image/jpeg');
|
||||
expect(detectFavoriteIconMime(bytes('GIF89a', [1, 0, 1, 0]))).toBe('image/gif');
|
||||
expect(detectFavoriteIconMime(bytes('RIFF', [0x24, 0x00, 0x00, 0x00], 'WEBP', 'VP8 '))).toBe(
|
||||
'image/webp',
|
||||
);
|
||||
});
|
||||
|
||||
it('Bytes 00 00 01 00 (mindestens 6 Bytes) ergeben image/x-icon', () => {
|
||||
expect(detectFavoriteIconMime(bytes([0x00, 0x00, 0x01, 0x00, 0x01, 0x00]))).toBe('image/x-icon');
|
||||
});
|
||||
|
||||
it('zu kurzes ICO-Kopfstueck (weniger als 6 Bytes) ergibt null', () => {
|
||||
expect(detectFavoriteIconMime(bytes([0x00, 0x00, 0x01, 0x00]))).toBeNull();
|
||||
});
|
||||
|
||||
it('00 00 02 00 (CUR-Cursor-Datei) ergibt null — nur Typ 1 (ICO) wird erkannt', () => {
|
||||
expect(detectFavoriteIconMime(bytes([0x00, 0x00, 0x02, 0x00, 0x01, 0x00]))).toBeNull();
|
||||
});
|
||||
|
||||
it('gueltige SVG-Formen ergeben image/svg+xml', () => {
|
||||
expect(detectFavoriteIconMime(bytes('<svg xmlns="http://www.w3.org/2000/svg"></svg>'))).toBe(
|
||||
'image/svg+xml',
|
||||
);
|
||||
expect(
|
||||
detectFavoriteIconMime(bytes('<?xml version="1.0"?>\n<svg xmlns="http://www.w3.org/2000/svg"/>')),
|
||||
).toBe('image/svg+xml');
|
||||
// fuehrendes BOM
|
||||
expect(
|
||||
detectFavoriteIconMime(bytes([0xef, 0xbb, 0xbf], '<svg xmlns="http://www.w3.org/2000/svg"></svg>')),
|
||||
).toBe('image/svg+xml');
|
||||
// fuehrendes Leerzeichen
|
||||
expect(detectFavoriteIconMime(bytes(' <svg></svg>'))).toBe('image/svg+xml');
|
||||
// Kommentar vor <svg
|
||||
expect(
|
||||
detectFavoriteIconMime(bytes('<!-- Kommentar -->\n<svg xmlns="http://www.w3.org/2000/svg"></svg>')),
|
||||
).toBe('image/svg+xml');
|
||||
// DOCTYPE svg vor <svg
|
||||
expect(
|
||||
detectFavoriteIconMime(
|
||||
bytes(
|
||||
'<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">\n<svg></svg>',
|
||||
),
|
||||
),
|
||||
).toBe('image/svg+xml');
|
||||
});
|
||||
|
||||
it('ungueltige Formen ergeben null', () => {
|
||||
expect(detectFavoriteIconMime(bytes('<html><svg></svg></html>'))).toBeNull();
|
||||
expect(detectFavoriteIconMime(bytes('<!DOCTYPE html>\n<html></html>'))).toBeNull();
|
||||
expect(detectFavoriteIconMime(new Uint8Array(0))).toBeNull();
|
||||
expect(detectFavoriteIconMime(bytes('Dies ist keine Bilddatei, sondern Text.'))).toBeNull();
|
||||
expect(detectFavoriteIconMime(bytes('%PDF-1.7\n%\xe2\xe3'))).toBeNull();
|
||||
expect(detectFavoriteIconMime(bytes([0x00, 0x00, 0x02, 0x00, 0x01, 0x00]))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('favoriteIconExtension (quick-260923-lrr)', () => {
|
||||
it('bildet die sechs bekannten Typen ab, unbekannter Typ ergibt null', () => {
|
||||
expect(favoriteIconExtension('image/png')).toBe('png');
|
||||
expect(favoriteIconExtension('image/jpeg')).toBe('jpg');
|
||||
expect(favoriteIconExtension('image/gif')).toBe('gif');
|
||||
expect(favoriteIconExtension('image/webp')).toBe('webp');
|
||||
expect(favoriteIconExtension('image/x-icon')).toBe('ico');
|
||||
expect(favoriteIconExtension('image/svg+xml')).toBe('svg');
|
||||
expect(favoriteIconExtension('application/pdf')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveFavoriteIconsDir / favoriteIconAbsolutePath (quick-260923-lrr)', () => {
|
||||
let dir: string;
|
||||
const ORIGINAL_ENV = process.env.FAVORITE_ICONS_DIR;
|
||||
|
||||
beforeAll(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-favorite-icons-'));
|
||||
process.env.FAVORITE_ICONS_DIR = dir;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
if (ORIGINAL_ENV === undefined) {
|
||||
delete process.env.FAVORITE_ICONS_DIR;
|
||||
} else {
|
||||
process.env.FAVORITE_ICONS_DIR = ORIGINAL_ENV;
|
||||
}
|
||||
});
|
||||
|
||||
it('resolveFavoriteIconsDir beachtet FAVORITE_ICONS_DIR', () => {
|
||||
expect(resolveFavoriteIconsDir()).toBe(path.resolve(dir));
|
||||
});
|
||||
|
||||
it('liegt unter resolveFavoriteIconsDir()/<userId>/<id>.<ext>', () => {
|
||||
const result = favoriteIconAbsolutePath('user-1', 'fav-1', 'image/png');
|
||||
expect(result).toBe(path.join(resolveFavoriteIconsDir(), 'user-1', 'fav-1.png'));
|
||||
});
|
||||
|
||||
it('unbekannter Typ ergibt null', () => {
|
||||
expect(favoriteIconAbsolutePath('user-1', 'fav-1', 'application/pdf')).toBeNull();
|
||||
});
|
||||
|
||||
it('Segmente mit .., /, \\ oder leer ergeben null', () => {
|
||||
expect(favoriteIconAbsolutePath('..', 'fav-1', 'image/png')).toBeNull();
|
||||
expect(favoriteIconAbsolutePath('user-1', '../etc/passwd', 'image/png')).toBeNull();
|
||||
expect(favoriteIconAbsolutePath('a/b', 'fav-1', 'image/png')).toBeNull();
|
||||
expect(favoriteIconAbsolutePath('a\\b', 'fav-1', 'image/png')).toBeNull();
|
||||
expect(favoriteIconAbsolutePath('', 'fav-1', 'image/png')).toBeNull();
|
||||
expect(favoriteIconAbsolutePath('user-1', '', 'image/png')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeFavoriteIconFileBestEffort (quick-260923-lrr, T-LRR-07)', () => {
|
||||
let dir: string;
|
||||
const ORIGINAL_ENV = process.env.FAVORITE_ICONS_DIR;
|
||||
|
||||
beforeAll(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-favorite-icons-rm-'));
|
||||
process.env.FAVORITE_ICONS_DIR = dir;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
if (ORIGINAL_ENV === undefined) {
|
||||
delete process.env.FAVORITE_ICONS_DIR;
|
||||
} else {
|
||||
process.env.FAVORITE_ICONS_DIR = ORIGINAL_ENV;
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(path.join(dir, 'user-1'), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('entfernt eine vorhandene Datei und liefert true', async () => {
|
||||
const absolute = favoriteIconAbsolutePath('user-1', 'fav-1', 'image/png');
|
||||
expect(absolute).not.toBeNull();
|
||||
fs.mkdirSync(path.dirname(absolute as string), { recursive: true });
|
||||
fs.writeFileSync(absolute as string, Buffer.from([1, 2, 3]));
|
||||
|
||||
const result = await removeFavoriteIconFileBestEffort('user-1', 'fav-1', 'image/png');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(fs.existsSync(absolute as string)).toBe(false);
|
||||
});
|
||||
|
||||
it('fehlende Datei -> false, wirft nicht', async () => {
|
||||
await expect(removeFavoriteIconFileBestEffort('user-1', 'fehlt', 'image/png')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('unbekannter Typ -> false, wirft nicht', async () => {
|
||||
await expect(
|
||||
removeFavoriteIconFileBestEffort('user-1', 'fav-1', 'application/pdf'),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Grenzen (quick-260923-lrr)', () => {
|
||||
it('FAVORITE_ICON_MAX_BYTES ist 512 KiB', () => {
|
||||
expect(FAVORITE_ICON_MAX_BYTES).toBe(512 * 1024);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { detectImageMime } from '../dashboard/dashboard-image-rules';
|
||||
|
||||
/**
|
||||
* favorite-icon-files — reine Regeln und Ablage-Hilfen fuer ein
|
||||
* hochgeladenes Favoriten-Symbol (quick-260923-lrr). Kein Nest, kein
|
||||
* Prisma: Grenzen, Erkennung und Pfadbildung, damit Dienst und Controller
|
||||
* dieselben Werte anwenden und die Erkennung direkt an den Bytes testbar
|
||||
* ist — Muster `dashboard-image-rules.ts` (quick-260921-pi9).
|
||||
*
|
||||
* Warum ZUSAETZLICH ICO und SVG (ueber die vier Typen aus
|
||||
* `detectImageMime` hinaus): Favicons liegen haeufig als `.ico` vor, und
|
||||
* ein selbst gezeichnetes Symbol oft als `.svg`. Beide Formate haben keine
|
||||
* fuehrende Signatur wie PNG/JPEG/GIF/WebP im klassischen Sinn — ICO traegt
|
||||
* nur ein vier Byte langes Kopfstueck (Typ-Feld `0x0001`, NICHT `0x0002` =
|
||||
* CUR-Cursor-Dateien, die deshalb bewusst NICHT erkannt werden), SVG ist
|
||||
* Text und wird ueber eine Praefix-Pruefung erkannt (XML-Deklaration,
|
||||
* Kommentare, ein optionales DOCTYPE mit Wurzel `svg`, dann `<svg` selbst).
|
||||
*
|
||||
* Wie bei `dashboard-image-rules.ts` (T-PI9-01/T-PI9-08): was hier NICHT
|
||||
* erkannt wird, kommt nicht auf die Platte — und der erkannte Typ ist
|
||||
* zugleich der Typ, mit dem `GET /favorites/:id/icon` spaeter antwortet.
|
||||
*
|
||||
* Bewusst KEIN `file-type`-Paket (Muster T-PI9-SC): sechs feste Regeln sind
|
||||
* eine Handvoll Zeilen und brauchen keine Abhaengigkeit.
|
||||
*/
|
||||
|
||||
/** Hoechstgroesse je Datei: 512 KiB (multer `limits.fileSize` an der Route, zweites Netz im Dienst). */
|
||||
export const FAVORITE_ICON_MAX_BYTES = 512 * 1024;
|
||||
|
||||
export type FavoriteIconMime =
|
||||
| 'image/png'
|
||||
| 'image/jpeg'
|
||||
| 'image/gif'
|
||||
| 'image/webp'
|
||||
| 'image/x-icon'
|
||||
| 'image/svg+xml';
|
||||
|
||||
/** ICO-Kopfstueck: Reserviert=0, Typ=1 (Icon). Typ=2 waere CUR (Cursor) — bewusst NICHT erkannt. */
|
||||
const ICO_HEADER = [0x00, 0x00, 0x01, 0x00];
|
||||
|
||||
/**
|
||||
* Praefix-Form eines SVG-Dokuments: optionales BOM/Leerraum, optionale
|
||||
* XML-Deklaration, beliebig viele Kommentare und/oder ein DOCTYPE mit
|
||||
* Wurzel `svg` (in beliebiger Reihenfolge/Wiederholung), danach `<svg`
|
||||
* direkt gefolgt von Leerraum, `>` oder `/`. Alles andere (z. B. `<html>`
|
||||
* vor `<svg>`, ein DOCTYPE auf `html`) ergibt kein Treffer.
|
||||
*/
|
||||
const SVG_PREFIX_RE =
|
||||
/^(?:<\?xml[^>]*\?>\s*)?(?:(?:<!--[\s\S]*?-->|<!DOCTYPE\s+svg\b[^>]*>)\s*)*<svg[\s>/]/i;
|
||||
|
||||
function startsWithIcoHeader(buffer: Uint8Array): boolean {
|
||||
if (buffer.length < 6) return false;
|
||||
for (let i = 0; i < ICO_HEADER.length; i++) {
|
||||
if (buffer[i] !== ICO_HEADER[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft die ersten 4096 Bytes als UTF-8 gegen `SVG_PREFIX_RE`. Ein
|
||||
* fuehrendes BOM oder Leerraum vor der eigentlichen Deklaration wird
|
||||
* entfernt, bevor die Praefix-Form geprueft wird. Wirft nie — ein Puffer,
|
||||
* der sich nicht als UTF-8 lesen laesst, ist schlicht kein SVG.
|
||||
*/
|
||||
function looksLikeSvg(buffer: Uint8Array): boolean {
|
||||
let text: string;
|
||||
try {
|
||||
text = Buffer.from(buffer.subarray(0, 4096)).toString('utf-8');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
text = text.replace(/^/, '').replace(/^\s+/, '');
|
||||
return SVG_PREFIX_RE.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erkennt PNG, JPEG, GIF, WebP (ueber `detectImageMime`), ICO und SVG an
|
||||
* den Bytes; alles andere ergibt `null`. Wirft nie.
|
||||
*/
|
||||
export function detectFavoriteIconMime(buffer: Uint8Array): FavoriteIconMime | null {
|
||||
const known = detectImageMime(buffer);
|
||||
if (known !== null) return known;
|
||||
if (startsWithIcoHeader(buffer)) return 'image/x-icon';
|
||||
if (looksLikeSvg(buffer)) return 'image/svg+xml';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Endung aus dem ERKANNTEN Typ; alles andere ergibt `null`, nie eine Vermutung. */
|
||||
export function favoriteIconExtension(mime: string): string | null {
|
||||
switch (mime) {
|
||||
case 'image/png':
|
||||
return 'png';
|
||||
case 'image/jpeg':
|
||||
return 'jpg';
|
||||
case 'image/gif':
|
||||
return 'gif';
|
||||
case 'image/webp':
|
||||
return 'webp';
|
||||
case 'image/x-icon':
|
||||
return 'ico';
|
||||
case 'image/svg+xml':
|
||||
return 'svg';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loest das Symbolverzeichnis relativ zur Monorepo-Wurzel auf — Muster
|
||||
* `resolveDashboardImagesDir()` (dashboard-images.service.ts): zur Laufzeit
|
||||
* ist `__dirname` = apps/api/dist/favorites/, also vier Ebenen hoch.
|
||||
*
|
||||
* `FAVORITE_ICONS_DIR` ist ein Testschalter und im Betrieb nie gesetzt; die
|
||||
* Tests zeigen damit auf ein Wegwerfverzeichnis unter `os.tmpdir()`.
|
||||
*/
|
||||
export function resolveFavoriteIconsDir(): string {
|
||||
const override = process.env.FAVORITE_ICONS_DIR;
|
||||
if (override !== undefined && override !== '') {
|
||||
return path.resolve(override);
|
||||
}
|
||||
return path.resolve(__dirname, '..', '..', '..', '..', 'user-files', 'favorite-icons');
|
||||
}
|
||||
|
||||
/** Nur Buchstaben, Ziffern und Bindestrich — kein Segment aus der Anfrage geht ungeprueft in einen Pfad. */
|
||||
const SAFE_SEGMENT_RE = /^[A-Za-z0-9-]+$/;
|
||||
|
||||
/**
|
||||
* Bildet den absoluten Ablagepfad `<resolveFavoriteIconsDir()>/<userId>/<id>.<ext>`.
|
||||
* `null`, wenn der Typ unbekannt ist, `userId`/`id` nicht ausschliesslich aus
|
||||
* Buchstaben/Ziffern/Bindestrich bestehen (schliesst `..`, `/`, `\`, leere
|
||||
* Segmente aus), oder das Ergebnis nicht unter dem Symbolverzeichnis liegt
|
||||
* (T-LRR-01). Kein Byte aus der Anfrage — insbesondere nicht `originalname`
|
||||
* — geht je in diesen Pfad ein: `id` ist die Zeilen-UUID, `ext` kommt aus
|
||||
* dem an den Bytes ERKANNTEN Typ.
|
||||
*/
|
||||
export function favoriteIconAbsolutePath(userId: string, id: string, mime: string): string | null {
|
||||
const ext = favoriteIconExtension(mime);
|
||||
if (ext === null) return null;
|
||||
if (!SAFE_SEGMENT_RE.test(userId) || !SAFE_SEGMENT_RE.test(id)) return null;
|
||||
|
||||
const base = resolveFavoriteIconsDir();
|
||||
const absolute = path.resolve(base, userId, `${id}.${ext}`);
|
||||
if (absolute !== base && !absolute.startsWith(base + path.sep)) return null;
|
||||
return absolute;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt die Symboldatei eines hochgeladenen Favoriten-Symbols, falls sie
|
||||
* existiert — best effort, wirft NIE (Muster T-HK4-04: eine Dateileiche ist
|
||||
* harmloser als eine haengende Operation). Fuer Aufrufer ausserhalb von
|
||||
* `FavoritesService`, deren Vorgang (Loeschen ueber Datenbank-Kaskade,
|
||||
* T-LRR-07) nicht an einem Dateifehler scheitern darf: `DashboardService`
|
||||
* beim Loeschen eines Widgets oder eines ganzen Reiters, siehe dortigen
|
||||
* Kommentar. Liefert `true`, wenn eine Datei tatsaechlich entfernt wurde
|
||||
* (fuer eine Protokollzeile beim Aufrufer), sonst `false` — auch das ist
|
||||
* kein Fehlerzustand: die Datei kann bereits gefehlt haben.
|
||||
*/
|
||||
export async function removeFavoriteIconFileBestEffort(
|
||||
userId: string,
|
||||
id: string,
|
||||
mime: string,
|
||||
): Promise<boolean> {
|
||||
const absolute = favoriteIconAbsolutePath(userId, id, mime);
|
||||
if (absolute === null) return false;
|
||||
try {
|
||||
await fs.unlink(absolute);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import 'reflect-metadata';
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* `FileInterceptor` wird als Attrappe eingesetzt, damit die Grenzen der
|
||||
* Upload-Route (T-LRR-04) am AUFRUF pruefbar sind — Muster
|
||||
* `dashboard-images.controller.spec.ts` (quick-260921-pi9).
|
||||
*/
|
||||
const { fileInterceptorMock } = vi.hoisted(() => ({
|
||||
fileInterceptorMock: vi.fn(() => class FakeInterceptor {}),
|
||||
}));
|
||||
vi.mock('@nestjs/platform-express', () => ({ FileInterceptor: fileInterceptorMock }));
|
||||
|
||||
import { FAVORITE_ICON_MAX_BYTES } from './favorite-icon-files';
|
||||
import { FavoritesController } from './favorites.controller';
|
||||
|
||||
/**
|
||||
* favorites.controller.spec — NEU (quick-260923-lrr).
|
||||
*
|
||||
* Fuenf Bereiche: Interceptor-Grenzen der Upload-Route (Feld `icon`, 512 KB,
|
||||
* genau eine Datei), die Header der Symbol-Antwort (jetzt `private` statt
|
||||
* `public`, 260923-lrr), Weitergabe von Mandant/Benutzer ausschliesslich aus
|
||||
* dem Sitzungsnachweis (`req.tenantId` VOR `req.user.tenantId`, Muster
|
||||
* `extractContext`), Abweisung ohne Mandantenkontext, und die
|
||||
* Routen-Metadaten der zwei neuen Wege.
|
||||
*/
|
||||
function makeService() {
|
||||
return {
|
||||
list: vi.fn(async () => []),
|
||||
create: vi.fn(async () => ({ id: 'new' })),
|
||||
reorder: vi.fn(async () => []),
|
||||
getIconBytes: vi.fn(async () => ({
|
||||
contentType: 'image/png',
|
||||
body: Buffer.from([0x89, 0x50, 0x4e, 0x47]),
|
||||
})),
|
||||
uploadIcon: vi.fn(async (_tenantId: string, id: string, _userId: string) => ({
|
||||
id,
|
||||
uploadedIconMime: 'image/png',
|
||||
iconVersion: 1,
|
||||
})),
|
||||
removeUploadedIcon: vi.fn(async (_tenantId: string, id: string, _userId: string) => ({
|
||||
id,
|
||||
uploadedIconMime: null,
|
||||
iconVersion: 2,
|
||||
})),
|
||||
update: vi.fn(async () => ({})),
|
||||
remove: vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function makeRes() {
|
||||
const headers: Record<string, string> = {};
|
||||
return {
|
||||
headers,
|
||||
setHeader: vi.fn((name: string, value: string) => {
|
||||
headers[name] = value;
|
||||
}),
|
||||
send: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeReq(overrides: Partial<{ tenantId: string | null; user: any }> = {}) {
|
||||
return {
|
||||
tenantId: overrides.tenantId,
|
||||
user: overrides.user ?? { id: 'user-1', tenantId: 'tenant-from-user' },
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe('FavoritesController (quick-260923-lrr)', () => {
|
||||
it('Test 1: FileInterceptor wird mit dem Feld icon und { limits: { fileSize: 512 * 1024, files: 1 } } aufgerufen', () => {
|
||||
expect(fileInterceptorMock).toHaveBeenCalledWith('icon', {
|
||||
limits: { fileSize: FAVORITE_ICON_MAX_BYTES, files: 1 },
|
||||
});
|
||||
expect(FAVORITE_ICON_MAX_BYTES).toBe(512 * 1024);
|
||||
});
|
||||
|
||||
it('Test 2: getIcon setzt Content-Type aus dem Dienst, Cache-Control private, nosniff, CSP sandbox', async () => {
|
||||
const service = makeService();
|
||||
const controller = new FavoritesController(service as never);
|
||||
const res = makeRes();
|
||||
|
||||
await controller.getIcon('fav-1', makeReq({ tenantId: 'tenant-1' }), res as never);
|
||||
|
||||
expect(service.getIconBytes).toHaveBeenCalledWith('tenant-1', 'fav-1', 'user-1');
|
||||
expect(res.headers['Content-Type']).toBe('image/png');
|
||||
expect(res.headers['Cache-Control']).toBe('private, max-age=86400');
|
||||
expect(res.headers['X-Content-Type-Options']).toBe('nosniff');
|
||||
expect(res.headers['Content-Security-Policy']).toBe("default-src 'none'; sandbox");
|
||||
});
|
||||
|
||||
it('Test 3: uploadIcon reicht tenantId aus req.tenantId (VOR req.user.tenantId) und userId aus req.user.id an den Dienst', async () => {
|
||||
const service = makeService();
|
||||
const controller = new FavoritesController(service as never);
|
||||
const file = { buffer: Buffer.from([1]), originalname: 'x.png', mimetype: 'image/png', size: 1 };
|
||||
|
||||
await controller.uploadIcon('fav-1', makeReq({ tenantId: 'tenant-1' }), file);
|
||||
|
||||
expect(service.uploadIcon).toHaveBeenCalledWith('tenant-1', 'fav-1', 'user-1', file);
|
||||
});
|
||||
|
||||
it('Test 4: uploadIcon faellt auf req.user.tenantId zurueck, wenn req.tenantId fehlt', async () => {
|
||||
const service = makeService();
|
||||
const controller = new FavoritesController(service as never);
|
||||
const file = { buffer: Buffer.from([1]), originalname: 'x.png', mimetype: 'image/png', size: 1 };
|
||||
|
||||
await controller.uploadIcon('fav-1', makeReq({ tenantId: undefined }), file);
|
||||
|
||||
expect(service.uploadIcon).toHaveBeenCalledWith('tenant-from-user', 'fav-1', 'user-1', file);
|
||||
});
|
||||
|
||||
it('Test 5: removeUploadedIcon reicht tenantId/userId ebenso weiter', async () => {
|
||||
const service = makeService();
|
||||
const controller = new FavoritesController(service as never);
|
||||
|
||||
await controller.removeUploadedIcon('fav-1', makeReq({ tenantId: 'tenant-1' }));
|
||||
|
||||
expect(service.removeUploadedIcon).toHaveBeenCalledWith('tenant-1', 'fav-1', 'user-1');
|
||||
});
|
||||
|
||||
it('Test 6: ohne Mandantenkontext -> ForbiddenException, Dienst wird NICHT aufgerufen', async () => {
|
||||
const service = makeService();
|
||||
const controller = new FavoritesController(service as never);
|
||||
const file = { buffer: Buffer.from([1]), originalname: 'x.png', mimetype: 'image/png', size: 1 };
|
||||
|
||||
await expect(
|
||||
controller.uploadIcon('fav-1', makeReq({ tenantId: null, user: { id: 'user-1' } }), file),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
expect(service.uploadIcon).not.toHaveBeenCalled();
|
||||
|
||||
await expect(
|
||||
controller.removeUploadedIcon('fav-1', makeReq({ tenantId: null, user: { id: 'user-1' } })),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
expect(service.removeUploadedIcon).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Test 7: POST :id/icon und DELETE :id/icon sind als Routen-Metadaten vorhanden', () => {
|
||||
const proto = FavoritesController.prototype;
|
||||
expect(Reflect.getMetadata('path', proto.uploadIcon)).toBe(':id/icon');
|
||||
expect(Reflect.getMetadata('method', proto.uploadIcon)).toBe(1); // RequestMethod.POST
|
||||
expect(Reflect.getMetadata('path', proto.removeUploadedIcon)).toBe(':id/icon');
|
||||
expect(Reflect.getMetadata('method', proto.removeUploadedIcon)).toBe(3); // RequestMethod.DELETE
|
||||
});
|
||||
});
|
||||
@@ -12,12 +12,16 @@ import {
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { Response } from 'express';
|
||||
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
||||
import type { AuthenticatedRequest, UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { CreateFavoriteDto } from './dto/create-favorite.dto';
|
||||
import { ReorderFavoritesDto } from './dto/reorder-favorites.dto';
|
||||
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
|
||||
import { FAVORITE_ICON_MAX_BYTES } from './favorite-icon-files';
|
||||
import { FavoritesService } from './favorites.service';
|
||||
|
||||
/**
|
||||
@@ -39,7 +43,10 @@ import { FavoritesService } from './favorites.service';
|
||||
* - GET /favorites?widgetId= — list favorites for a widget instance
|
||||
* - POST /favorites — create a favorite (triggers server-side icon discovery)
|
||||
* - PUT /favorites/order — reorder favorites for a widget instance (260917-jdd)
|
||||
* - GET /favorites/:id/icon — stream a favorite's stored icon bytes
|
||||
* - GET /favorites/:id/icon — stream a favorite's stored icon bytes (append `?v=<iconVersion>`
|
||||
* client-side to bust the 24h cache after any change to the icon source, 260923-lrr)
|
||||
* - POST /favorites/:id/icon — upload a custom icon (multipart field `icon`, ≤512 KB, 260923-lrr)
|
||||
* - DELETE /favorites/:id/icon — remove a previously uploaded icon (260923-lrr)
|
||||
* - PATCH /favorites/:id — update a favorite (ownership verified in service)
|
||||
* - DELETE /favorites/:id — delete a favorite (ownership verified in service)
|
||||
*/
|
||||
@@ -122,7 +129,12 @@ export class FavoritesController {
|
||||
);
|
||||
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
// 260923-lrr: private statt public — kein gemeinsamer Zwischenspeicher
|
||||
// (Nginx Proxy Manager) haelt benutzerbezogene Symbole vor. Die Adresse
|
||||
// traegt clientseitig `?v=<iconVersion>` (T-LRR-05), damit der lange
|
||||
// 24h-Browser-Zwischenspeicher nach einer Aenderung trotzdem sofort
|
||||
// ungueltig wird.
|
||||
res.setHeader('Cache-Control', 'private, max-age=86400');
|
||||
// 260917-jdd: die Bytes kommen jetzt auch von Hosts ohne gueltiges
|
||||
// Zertifikat. Als <img>-Unterressource ignoriert der Browser diese
|
||||
// Header, aber ein direkt im Tab geoeffnetes SVG laeuft damit ohne
|
||||
@@ -132,6 +144,42 @@ export class FavoritesController {
|
||||
res.send(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /favorites/:id/icon — laedt ein eigenes Symbol fuer einen
|
||||
* Favoriten hoch (260923-lrr). Groessengrenze JE ROUTE (Muster
|
||||
* `dashboard-images.controller.ts` T-PI9-02): `FileInterceptor` nimmt
|
||||
* genau eine Datei bis 512 KB; multers `LIMIT_FILE_SIZE` bildet Nest auf
|
||||
* 413 ab. Typ und Besitzpruefung laufen im Dienst (T-LRR-01/T-LRR-03).
|
||||
*/
|
||||
@Post(':id/icon')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('icon', { limits: { fileSize: FAVORITE_ICON_MAX_BYTES, files: 1 } }),
|
||||
)
|
||||
async uploadIcon(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@UploadedFile() file?: UploadedFileLike,
|
||||
) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
|
||||
return this.favoritesService.uploadIcon(tenantId, id, userId, file);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /favorites/:id/icon — entfernt ein zuvor hochgeladenes Symbol
|
||||
* wieder; die Kachel faellt danach auf `iconUrl` bzw. automatische
|
||||
* Erkennung zurueck (260923-lrr).
|
||||
*/
|
||||
@Delete(':id/icon')
|
||||
async removeUploadedIcon(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
|
||||
return this.favoritesService.removeUploadedIcon(tenantId, id, userId);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { BadRequestException, HttpException, NotFoundException } from '@nestjs/common';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
BadRequestException,
|
||||
HttpException,
|
||||
NotFoundException,
|
||||
PayloadTooLargeException,
|
||||
UnprocessableEntityException,
|
||||
} from '@nestjs/common';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FavoritesService } from './favorites.service';
|
||||
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
|
||||
|
||||
@@ -39,6 +48,9 @@ interface FakeFavoriteRow {
|
||||
position: number;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
/** 260923-lrr — Bestandszeilen im Fake bekommen die Vorgabe null/0. */
|
||||
uploadedIconMime?: string | null;
|
||||
iconVersion?: number;
|
||||
}
|
||||
|
||||
interface FakeWidgetRow {
|
||||
@@ -71,7 +83,9 @@ function throwP2025(action: 'update' | 'delete'): never {
|
||||
* deshalb strukturell nie.
|
||||
*/
|
||||
function makeFakePrisma(favoriteRows: FakeFavoriteRow[] = [], widgetRows: FakeWidgetRow[] = []) {
|
||||
const favorites = new Map(favoriteRows.map((f) => [f.id, { ...f }]));
|
||||
const favorites = new Map(
|
||||
favoriteRows.map((f) => [f.id, { uploadedIconMime: null, iconVersion: 0, ...f }]),
|
||||
);
|
||||
const widgets = new Map(widgetRows.map((w) => [w.id, { ...w }]));
|
||||
const boundCallLog: BoundCall[] = [];
|
||||
let autoId = favoriteRows.length;
|
||||
@@ -107,7 +121,16 @@ function makeFakePrisma(favoriteRows: FakeFavoriteRow[] = [], widgetRows: FakeWi
|
||||
boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'create' });
|
||||
const id = data.id ?? `fav-${++autoId}`;
|
||||
const now = new Date();
|
||||
const record = { iconUrl: null, position: 0, createdAt: now, updatedAt: now, ...data, id };
|
||||
const record = {
|
||||
iconUrl: null,
|
||||
position: 0,
|
||||
uploadedIconMime: null,
|
||||
iconVersion: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...data,
|
||||
id,
|
||||
};
|
||||
favorites.set(id, record);
|
||||
return record;
|
||||
},
|
||||
@@ -115,7 +138,12 @@ function makeFakePrisma(favoriteRows: FakeFavoriteRow[] = [], widgetRows: FakeWi
|
||||
boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'update' });
|
||||
const row = favorites.get(where.id);
|
||||
if (!row || row.tenantId !== tenantId) throwP2025('update');
|
||||
const updated = { ...row, ...data, updatedAt: new Date() };
|
||||
const updated: any = { ...row, ...data, updatedAt: new Date() };
|
||||
// 260923-lrr: `iconVersion: { increment: n }` — Prisma's atomic
|
||||
// increment form, angewendet auf den bisherigen Zaehlerstand.
|
||||
if (data.iconVersion && typeof data.iconVersion === 'object' && 'increment' in data.iconVersion) {
|
||||
updated.iconVersion = (row.iconVersion ?? 0) + data.iconVersion.increment;
|
||||
}
|
||||
favorites.set(where.id, updated);
|
||||
return updated;
|
||||
},
|
||||
@@ -625,4 +653,323 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
|
||||
expect(vi.mocked(withTenantTransaction).mock.calls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// --- 260923-lrr: eigenes Symbol, Vorrang, Versionszaehler, Abrufprobe ---
|
||||
|
||||
describe('uploadIcon/removeUploadedIcon/getIconBytes — eigenes Symbol (260923-lrr)', () => {
|
||||
let iconsDir: string;
|
||||
const ORIGINAL_DIR_ENV = process.env.FAVORITE_ICONS_DIR;
|
||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
|
||||
const SVG = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"></svg>');
|
||||
const TEXT = Buffer.from('nur Text, kein Bild');
|
||||
|
||||
beforeEach(() => {
|
||||
iconsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-favorite-icons-svc-'));
|
||||
process.env.FAVORITE_ICONS_DIR = iconsDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(iconsDir, { recursive: true, force: true });
|
||||
if (ORIGINAL_DIR_ENV === undefined) {
|
||||
delete process.env.FAVORITE_ICONS_DIR;
|
||||
} else {
|
||||
process.env.FAVORITE_ICONS_DIR = ORIGINAL_DIR_ENV;
|
||||
}
|
||||
});
|
||||
|
||||
const baseRow: FakeFavoriteRow = {
|
||||
id: 'f1',
|
||||
userId: 'user-a1',
|
||||
tenantId: 't1',
|
||||
widgetId: 'widget-a1',
|
||||
title: 'X',
|
||||
url: 'https://x.invalid',
|
||||
iconUrl: 'https://x.invalid/icon.png',
|
||||
position: 0,
|
||||
uploadedIconMime: null,
|
||||
iconVersion: 0,
|
||||
};
|
||||
|
||||
function fileFor(userId: string, id: string, ext: string): string {
|
||||
return path.join(iconsDir, userId, `${id}.${ext}`);
|
||||
}
|
||||
|
||||
describe('uploadIcon', () => {
|
||||
it('PNG: Datei liegt unter <dir>/<userId>/<id>.png mit genau den Bytes, Zeile hat uploadedIconMime image/png und iconVersion +1', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
|
||||
|
||||
const updated = await service.uploadIcon('t1', 'f1', 'user-a1', file);
|
||||
|
||||
expect(updated.uploadedIconMime).toBe('image/png');
|
||||
expect(updated.iconVersion).toBe(1);
|
||||
const written = fs.readFileSync(fileFor('user-a1', 'f1', 'png'));
|
||||
expect(written.equals(PNG)).toBe(true);
|
||||
});
|
||||
|
||||
it('ohne Datei -> BadRequestException', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
await expect(service.uploadIcon('t1', 'f1', 'user-a1', undefined)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('Klartext-Puffer -> BadRequestException, keine Datei, Zeile unveraendert', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
const file = { buffer: TEXT, originalname: 'x.txt', mimetype: 'text/plain', size: TEXT.length };
|
||||
|
||||
await expect(service.uploadIcon('t1', 'f1', 'user-a1', file)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(fs.existsSync(path.join(iconsDir, 'user-a1'))).toBe(false);
|
||||
expect(prisma.__favorites.get('f1').uploadedIconMime).toBeNull();
|
||||
});
|
||||
|
||||
it('Puffer groesser 512 KB -> PayloadTooLargeException (zweites Netz)', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
const big = Buffer.concat([PNG, Buffer.alloc(513 * 1024)]);
|
||||
const file = { buffer: big, originalname: 'x.png', mimetype: 'image/png', size: big.length };
|
||||
|
||||
await expect(service.uploadIcon('t1', 'f1', 'user-a1', file)).rejects.toThrow(
|
||||
PayloadTooLargeException,
|
||||
);
|
||||
});
|
||||
|
||||
it('fremder Benutzer, fremder Mandant, unbekannte Kennung -> NotFoundException, keine Datei geschrieben', async () => {
|
||||
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
|
||||
|
||||
const prismaForeignUser = makeFakePrisma([{ ...baseRow, userId: 'user-a2' }]);
|
||||
const serviceForeignUser = new FavoritesService(
|
||||
prismaForeignUser as any,
|
||||
makeIconDiscovery() as any,
|
||||
);
|
||||
await expect(
|
||||
serviceForeignUser.uploadIcon('t1', 'f1', 'user-a1', file),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
|
||||
const prismaForeignTenant = makeFakePrisma([baseRow]);
|
||||
const serviceForeignTenant = new FavoritesService(
|
||||
prismaForeignTenant as any,
|
||||
makeIconDiscovery() as any,
|
||||
);
|
||||
await expect(
|
||||
serviceForeignTenant.uploadIcon('t2', 'f1', 'user-a1', file),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
|
||||
const prismaUnknown = makeFakePrisma([]);
|
||||
const serviceUnknown = new FavoritesService(prismaUnknown as any, makeIconDiscovery() as any);
|
||||
await expect(
|
||||
serviceUnknown.uploadIcon('t1', 'fehlt', 'user-a1', file),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
|
||||
expect(fs.existsSync(path.join(iconsDir, 'user-a1'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(iconsDir, 'user-a2'))).toBe(false);
|
||||
});
|
||||
|
||||
it('erneuter Upload mit anderem Typ (erst PNG, dann SVG): .png entfernt, .svg vorhanden, iconVersion insgesamt +2', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
const pngFile = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
|
||||
const svgFile = { buffer: SVG, originalname: 'x.svg', mimetype: 'image/svg+xml', size: SVG.length };
|
||||
|
||||
await service.uploadIcon('t1', 'f1', 'user-a1', pngFile);
|
||||
const updated = await service.uploadIcon('t1', 'f1', 'user-a1', svgFile);
|
||||
|
||||
expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(false);
|
||||
expect(fs.existsSync(fileFor('user-a1', 'f1', 'svg'))).toBe(true);
|
||||
expect(updated.uploadedIconMime).toBe('image/svg+xml');
|
||||
expect(updated.iconVersion).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIconBytes — Vorrang des hochgeladenen Symbols', () => {
|
||||
it('hochgeladenes Symbol: liefert Dateibytes und gespeicherten Typ, fetchIconBytes wird NICHT aufgerufen', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const iconDiscovery = makeIconDiscovery();
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
|
||||
await service.uploadIcon('t1', 'f1', 'user-a1', file);
|
||||
|
||||
const result = await service.getIconBytes('t1', 'f1', 'user-a1');
|
||||
|
||||
expect(result.contentType).toBe('image/png');
|
||||
expect((result.body as Buffer).equals(PNG)).toBe(true);
|
||||
expect(iconDiscovery.fetchIconBytes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Typ gesetzt, aber Datei fehlt, iconUrl vorhanden -> faellt auf fetchIconBytes(iconUrl) zurueck', async () => {
|
||||
const prisma = makeFakePrisma([{ ...baseRow, uploadedIconMime: 'image/png' }]);
|
||||
const iconDiscovery = makeIconDiscovery();
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
|
||||
const result = await service.getIconBytes('t1', 'f1', 'user-a1');
|
||||
|
||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith(baseRow.iconUrl);
|
||||
expect(result).toEqual({ contentType: 'image/png', body: Buffer.from('png') });
|
||||
});
|
||||
|
||||
it('Typ gesetzt, Datei fehlt, KEINE iconUrl -> NotFoundException', async () => {
|
||||
const prisma = makeFakePrisma([
|
||||
{ ...baseRow, iconUrl: null, uploadedIconMime: 'image/png' },
|
||||
]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
await expect(service.getIconBytes('t1', 'f1', 'user-a1')).rejects.toThrow(
|
||||
'FavoriteLink not found',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeUploadedIcon', () => {
|
||||
it('Datei weg, uploadedIconMime null, iconVersion +1', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
|
||||
await service.uploadIcon('t1', 'f1', 'user-a1', file);
|
||||
|
||||
const updated = await service.removeUploadedIcon('t1', 'f1', 'user-a1');
|
||||
|
||||
expect(updated.uploadedIconMime).toBeNull();
|
||||
expect(updated.iconVersion).toBe(2);
|
||||
expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(false);
|
||||
});
|
||||
|
||||
it('ohne vorhandenen Upload -> Zeile unveraendert, keine Erhoehung', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
const updated = await service.removeUploadedIcon('t1', 'f1', 'user-a1');
|
||||
|
||||
expect(updated.iconVersion).toBe(0);
|
||||
expect(updated.uploadedIconMime).toBeNull();
|
||||
});
|
||||
|
||||
it('fremder Benutzer -> NotFoundException', async () => {
|
||||
const prisma = makeFakePrisma([{ ...baseRow, userId: 'user-a2' }]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
await expect(service.removeUploadedIcon('t1', 'f1', 'user-a1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove() mit hochgeladenem Symbol', () => {
|
||||
it('Zeile und Datei weg', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
|
||||
await service.uploadIcon('t1', 'f1', 'user-a1', file);
|
||||
|
||||
await service.remove('t1', 'f1', 'user-a1');
|
||||
|
||||
expect(prisma.__favorites.has('f1')).toBe(false);
|
||||
expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(false);
|
||||
});
|
||||
|
||||
it('Fehler beim Datei-Entfernen wird geschluckt — das Loeschen der Zeile gelingt trotzdem', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
|
||||
await service.uploadIcon('t1', 'f1', 'user-a1', file);
|
||||
// Datei vorab entfernen, damit fs.unlink() im Dienst scheitert.
|
||||
fs.unlinkSync(fileFor('user-a1', 'f1', 'png'));
|
||||
|
||||
await expect(service.remove('t1', 'f1', 'user-a1')).resolves.toBeUndefined();
|
||||
expect(prisma.__favorites.has('f1')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('create/update — Abrufprobe fuer eine explizite iconUrl (260923-lrr)', () => {
|
||||
it('create mit expliziter iconUrl: Probe genau einmal; wirft -> UnprocessableEntityException, favoriteLink.create NICHT aufgerufen', async () => {
|
||||
const prisma = makeFakePrisma([], [{ id: 'widget-a1', userId: 'user-a1', tenantId: 't1' }]);
|
||||
const iconDiscovery = makeIconDiscovery({
|
||||
fetchIconBytes: vi.fn(async () => {
|
||||
throw new Error('blocked');
|
||||
}),
|
||||
});
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
|
||||
await expect(
|
||||
service.create('t1', 'user-a1', {
|
||||
widgetId: 'widget-a1',
|
||||
title: 'X',
|
||||
url: 'https://x.invalid',
|
||||
iconUrl: 'https://x.invalid/logo.png',
|
||||
} as any),
|
||||
).rejects.toThrow(UnprocessableEntityException);
|
||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledTimes(1);
|
||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith('https://x.invalid/logo.png');
|
||||
expect(prisma.__favorites.size).toBe(0);
|
||||
});
|
||||
|
||||
const baseRow: FakeFavoriteRow = {
|
||||
id: 'f1',
|
||||
userId: 'user-a1',
|
||||
tenantId: 't1',
|
||||
widgetId: 'widget-a1',
|
||||
title: 'Alt',
|
||||
url: 'https://alt.invalid',
|
||||
iconUrl: 'https://alt.invalid/icon.png',
|
||||
position: 0,
|
||||
uploadedIconMime: null,
|
||||
iconVersion: 0,
|
||||
};
|
||||
|
||||
it('update mit neuer, abweichender iconUrl: fetchIconBytes genau einmal mit dieser Adresse; wirft -> UnprocessableEntityException, favoriteLink.update NICHT aufgerufen', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const iconDiscovery = makeIconDiscovery({
|
||||
fetchIconBytes: vi.fn(async () => {
|
||||
throw new Error('blocked');
|
||||
}),
|
||||
});
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
|
||||
await expect(
|
||||
service.update('t1', 'f1', 'user-a1', { iconUrl: 'https://neu.invalid/icon.png' } as any),
|
||||
).rejects.toThrow(UnprocessableEntityException);
|
||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledTimes(1);
|
||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith('https://neu.invalid/icon.png');
|
||||
expect(prisma.__favorites.get('f1').iconUrl).toBe(baseRow.iconUrl);
|
||||
});
|
||||
|
||||
it('update mit UNVERAENDERTER iconUrl: keine Probe, keine Erhoehung', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const iconDiscovery = makeIconDiscovery();
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
|
||||
const updated = await service.update('t1', 'f1', 'user-a1', { iconUrl: baseRow.iconUrl } as any);
|
||||
|
||||
expect(iconDiscovery.fetchIconBytes).not.toHaveBeenCalled();
|
||||
expect(updated.iconVersion).toBe(0);
|
||||
});
|
||||
|
||||
it('update nur Titel: keine Erhoehung', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||
|
||||
const updated = await service.update('t1', 'f1', 'user-a1', { title: 'Neu' } as any);
|
||||
|
||||
expect(updated.iconVersion).toBe(0);
|
||||
});
|
||||
|
||||
it('update mit neuer, erreichbarer iconUrl: iconVersion +1', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
const iconDiscovery = makeIconDiscovery();
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
|
||||
const updated = await service.update('t1', 'f1', 'user-a1', {
|
||||
iconUrl: 'https://neu.invalid/icon.png',
|
||||
} as any);
|
||||
|
||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith('https://neu.invalid/icon.png');
|
||||
expect(updated.iconVersion).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
BadRequestException,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
PayloadTooLargeException,
|
||||
UnprocessableEntityException,
|
||||
} from '@nestjs/common';
|
||||
import type { UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
|
||||
import { CreateFavoriteDto } from './dto/create-favorite.dto';
|
||||
import { ReorderFavoritesDto } from './dto/reorder-favorites.dto';
|
||||
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
|
||||
import {
|
||||
FAVORITE_ICON_MAX_BYTES,
|
||||
detectFavoriteIconMime,
|
||||
favoriteIconAbsolutePath,
|
||||
} from './favorite-icon-files';
|
||||
import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
|
||||
|
||||
/**
|
||||
@@ -50,9 +62,31 @@ import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
|
||||
* Mandantengrenzen. Der Riegel antwortet fuer alle drei Faelle
|
||||
* ("existiert nicht", "gehoert einem Kollegen", "liegt bei einem fremden
|
||||
* Mandanten") mit derselben `NotFoundException('Widget not found')`.
|
||||
*
|
||||
* 260923-lrr — eigenes Symbol, Vorrang, Versionszaehler, Abrufprobe:
|
||||
* - Ablage nach dem Muster `dashboard-images.service.ts` (quick-260922-hk4):
|
||||
* `user-files/favorite-icons/<userId>/<id>.<ext>`, Dateiname IMMER aus
|
||||
* Zeilen-UUID und ERKANNTEM Typ, nie aus der Anfrage (T-LRR-01).
|
||||
* - Vorrang: `getIconBytes` liefert bei gesetztem `uploadedIconMime` immer
|
||||
* die Datei, nie `fetchIconBytes` — fehlt die Datei trotz gesetztem Typ,
|
||||
* wird protokolliert und auf `iconUrl` zurueckgefallen.
|
||||
* - `iconVersion` steigt (Prisma `{ increment: 1 }`) genau dann, wenn sich
|
||||
* die angezeigte Quelle aendert (neue, abweichende `iconUrl`; Upload;
|
||||
* Entfernen des Uploads) — nicht bei Titel/Position/unveraenderter URL.
|
||||
* - Halbe Zustaende (T-LRR-08, Muster T-HK4-04): Upload schreibt zuerst die
|
||||
* Datei, dann die Zeile; scheitert die Zeile, wird die neue Datei wieder
|
||||
* entfernt. Entfernen/Loeschen aktualisiert zuerst die Zeile, ein
|
||||
* Dateifehler wird protokolliert und geschluckt.
|
||||
* - Abrufprobe: `assertIconUrlLoadable()` ruft `fetchIconBytes` einmal ab,
|
||||
* um eine im Formular NICHT abrufbare Logo-Adresse (z. B. hinter einer
|
||||
* Cloudflare-Pruefung) mit `UnprocessableEntityException` (422) statt
|
||||
* stiller Speicherung abzuweisen — keine Umgehung von Bot-Sperren, nur
|
||||
* derselbe Abruf, den `GET /favorites/:id/icon` ohnehin ausloest.
|
||||
*/
|
||||
@Injectable()
|
||||
export class FavoritesService {
|
||||
private readonly logger = new Logger(FavoritesService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly iconDiscovery: IconDiscoveryService,
|
||||
@@ -72,11 +106,31 @@ export class FavoritesService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft, ob sich das Bild unter `iconUrl` serverseitig abrufen laesst
|
||||
* (260923-lrr) — derselbe `fetchIconBytes`-Aufruf, den `getIconBytes`
|
||||
* ohnehin ausloest, hier nur zur Speicherzeit als Probe. Jeder Fehler
|
||||
* (SSRF-Ablehnung, Zeitgrenze, kein `image/*`, Cloudflare-Pruefung o. ae.)
|
||||
* wird zu derselben deutschen 422-Meldung — keine Unterscheidung, aus der
|
||||
* sich etwas ueber die gepruefte Adresse ablesen liesse.
|
||||
*/
|
||||
private async assertIconUrlLoadable(iconUrl: string): Promise<void> {
|
||||
try {
|
||||
await this.iconDiscovery.fetchIconBytes(iconUrl);
|
||||
} catch {
|
||||
throw new 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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new favorite link.
|
||||
* Verifies the target widget belongs to the caller BEFORE any icon
|
||||
* discovery network call (T-GWH-05).
|
||||
* If iconUrl is not provided, triggers server-side icon discovery with SSRF protection.
|
||||
* If iconUrl IS provided (260923-lrr), it must load successfully or the
|
||||
* create is rejected with 422 — nothing is written on a failed probe.
|
||||
*/
|
||||
async create(tenantId: string, userId: string, dto: CreateFavoriteDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
@@ -99,8 +153,11 @@ export class FavoritesService {
|
||||
const url = normalizeUrl(dto.url);
|
||||
let iconUrl = dto.iconUrl ?? null;
|
||||
|
||||
if (iconUrl) {
|
||||
// 260923-lrr: explizit uebergebene Adresse wird einmal probiert.
|
||||
await this.assertIconUrlLoadable(iconUrl);
|
||||
} else {
|
||||
// Server-side icon discovery (D-05) — only when caller did not supply an icon
|
||||
if (!iconUrl) {
|
||||
iconUrl = await this.iconDiscovery.discoverFavoriteIconUrl(url);
|
||||
}
|
||||
|
||||
@@ -121,6 +178,11 @@ export class FavoritesService {
|
||||
* Updates an existing favorite.
|
||||
* Verifies userId ownership before applying changes (T-08-06).
|
||||
* Accepts null as an explicit value for iconUrl (clears stored icon).
|
||||
*
|
||||
* 260923-lrr: eine neue, vom gespeicherten Wert ABWEICHENDE `iconUrl`
|
||||
* durchlaeuft die Abrufprobe (`assertIconUrlLoadable`), bevor irgendetwas
|
||||
* geschrieben wird; misslingt sie, bleibt die Zeile unveraendert. Jede
|
||||
* tatsaechliche Aenderung der Symbolquelle erhoeht `iconVersion`.
|
||||
*/
|
||||
async update(tenantId: string, id: string, userId: string, dto: UpdateFavoriteDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
@@ -142,6 +204,10 @@ export class FavoritesService {
|
||||
|
||||
if ('iconUrl' in dto) {
|
||||
if (dto.iconUrl) {
|
||||
if (dto.iconUrl !== link.iconUrl) {
|
||||
// 260923-lrr: nur eine NEUE, abweichende Adresse wird probiert.
|
||||
await this.assertIconUrlLoadable(dto.iconUrl);
|
||||
}
|
||||
// Explicit icon URL supplied — respect it as-is.
|
||||
data.iconUrl = dto.iconUrl;
|
||||
} else {
|
||||
@@ -153,6 +219,10 @@ export class FavoritesService {
|
||||
}
|
||||
}
|
||||
|
||||
if (data.iconUrl !== undefined && data.iconUrl !== link.iconUrl) {
|
||||
data.iconVersion = { increment: 1 };
|
||||
}
|
||||
|
||||
return tenantPrisma.favoriteLink.update({
|
||||
where: { id },
|
||||
data,
|
||||
@@ -162,6 +232,10 @@ export class FavoritesService {
|
||||
/**
|
||||
* Deletes a favorite link.
|
||||
* Verifies userId ownership before deleting (T-08-06).
|
||||
* 260923-lrr: hat die Zeile ein hochgeladenes Symbol, wird dessen Datei
|
||||
* NACH dem Loeschen der Zeile entfernt — ein Dateifehler wird
|
||||
* protokolliert und geschluckt (Muster T-HK4-04), das Loeschen der Zeile
|
||||
* gelingt in jedem Fall.
|
||||
*/
|
||||
async remove(tenantId: string, id: string, userId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
@@ -172,6 +246,10 @@ export class FavoritesService {
|
||||
}
|
||||
|
||||
await tenantPrisma.favoriteLink.delete({ where: { id } });
|
||||
|
||||
if (link.uploadedIconMime !== null) {
|
||||
await this.removeIconFile(id, link.userId, link.uploadedIconMime, 'geloeschten');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -242,16 +320,148 @@ export class FavoritesService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt ein eigenes Symbol fuer einen Favoriten an (260923-lrr). Reihenfolge
|
||||
* (Muster T-HK4-04): Groesse/Typ zuerst (kein DB-Zugriff bei offensichtlich
|
||||
* ungueltiger Datei), dann Besitzpruefung, dann Datei, dann Zeile —
|
||||
* scheitert die Zeile, wird eine neu geschriebene Datei zurueckgenommen.
|
||||
* Hatte der Favorit vorher ein Symbol MIT ANDERER Endung, wird die alte
|
||||
* Datei danach entfernt (Fehler protokolliert und geschluckt).
|
||||
*/
|
||||
async uploadIcon(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
userId: string,
|
||||
file: UploadedFileLike | undefined,
|
||||
) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('Bitte wählen Sie eine Bilddatei aus.');
|
||||
}
|
||||
if (file.buffer.length > FAVORITE_ICON_MAX_BYTES) {
|
||||
// Zweites Netz — multer (`limits.fileSize` an der Route) faengt das
|
||||
// in der Regel bereits vorher ab.
|
||||
throw new PayloadTooLargeException(
|
||||
'Die Datei ist zu groß – erlaubt sind höchstens 512 KB.',
|
||||
);
|
||||
}
|
||||
|
||||
const mime = detectFavoriteIconMime(file.buffer);
|
||||
if (mime === null) {
|
||||
throw new BadRequestException(
|
||||
'Nur Bilder im Format PNG, JPEG, GIF, WebP, ICO oder SVG sind erlaubt.',
|
||||
);
|
||||
}
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
|
||||
if (!link || link.userId !== userId || link.tenantId !== tenantId) {
|
||||
throw new NotFoundException('FavoriteLink not found');
|
||||
}
|
||||
|
||||
const absolute = favoriteIconAbsolutePath(link.userId, link.id, mime);
|
||||
if (absolute === null) {
|
||||
throw new InternalServerErrorException('Das Symbol konnte nicht gespeichert werden.');
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.mkdir(path.dirname(absolute), { recursive: true });
|
||||
await fs.writeFile(absolute, file.buffer);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Symbol des Favoriten ${id} konnte nicht gespeichert werden: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
throw new InternalServerErrorException('Das Symbol konnte nicht gespeichert werden.');
|
||||
}
|
||||
|
||||
const previousMime = link.uploadedIconMime;
|
||||
let updated: typeof link;
|
||||
try {
|
||||
updated = await tenantPrisma.favoriteLink.update({
|
||||
where: { id },
|
||||
data: { uploadedIconMime: mime, iconVersion: { increment: 1 } },
|
||||
});
|
||||
} catch (error) {
|
||||
// Ruecknahme (T-LRR-08): die neu geschriebene Datei nur entfernen,
|
||||
// wenn sie einen ANDEREN Pfad als eine vorhandene alte Datei traegt —
|
||||
// sonst wuerde ein fehlgeschlagenes Update auf demselben Typ die
|
||||
// weiterhin gueltige alte Datei loeschen.
|
||||
if (previousMime !== mime) {
|
||||
await fs.unlink(absolute).catch(() => undefined);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (previousMime !== null && previousMime !== mime) {
|
||||
await this.removeIconFile(id, link.userId, previousMime, 'alte');
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt ein hochgeladenes Symbol wieder (260923-lrr). Ohne gesetztes
|
||||
* `uploadedIconMime` liefert die Methode die Zeile unveraendert — kein
|
||||
* unnoetiger Versionssprung. Die Datei wird NACH dem Update entfernt,
|
||||
* ein Fehler dabei wird protokolliert und geschluckt (Muster T-HK4-04).
|
||||
*/
|
||||
async removeUploadedIcon(tenantId: string, id: string, userId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
|
||||
if (!link || link.userId !== userId || link.tenantId !== tenantId) {
|
||||
throw new NotFoundException('FavoriteLink not found');
|
||||
}
|
||||
|
||||
if (link.uploadedIconMime === null) {
|
||||
return link;
|
||||
}
|
||||
|
||||
const previousMime = link.uploadedIconMime;
|
||||
const updated = await tenantPrisma.favoriteLink.update({
|
||||
where: { id },
|
||||
data: { uploadedIconMime: null, iconVersion: { increment: 1 } },
|
||||
});
|
||||
|
||||
await this.removeIconFile(id, link.userId, previousMime, 'entfernte');
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Best-effort-Entfernung einer Symboldatei — protokolliert, wirft nie (Muster T-HK4-04). */
|
||||
private async removeIconFile(
|
||||
favoriteId: string,
|
||||
userId: string,
|
||||
mime: string,
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
const absolute = favoriteIconAbsolutePath(userId, favoriteId, mime);
|
||||
if (absolute === null) return;
|
||||
try {
|
||||
await fs.unlink(absolute);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`${label} Symboldatei des Favoriten ${favoriteId} konnte nicht entfernt werden: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the raw bytes of a favorite's stored icon, scoped to the
|
||||
* requesting user (T-08-06 — same ownership check as update/remove).
|
||||
* Never accepts a client-supplied URL — only the stored iconUrl on a
|
||||
* row the caller owns is fetched (T-QFIP-01).
|
||||
*
|
||||
* 260923-lrr: ein hochgeladenes Symbol hat VORRANG vor `iconUrl` — fehlt
|
||||
* die Datei trotz gesetztem Typ (sollte praktisch nie vorkommen), wird
|
||||
* protokolliert und auf `iconUrl` zurueckgefallen, statt 404 zu werfen.
|
||||
*
|
||||
* Throws NotFoundException (404) if the row doesn't exist, isn't owned
|
||||
* by the caller, or has no icon on record. Throws a 502 HttpException
|
||||
* if the upstream fetch fails (unreachable, timeout, non-image, or
|
||||
* SSRF-blocked) -- never returns a placeholder image.
|
||||
* by the caller, or has neither an uploaded icon nor a stored iconUrl.
|
||||
* Throws a 502 HttpException if the upstream fetch fails (unreachable,
|
||||
* timeout, non-image, or SSRF-blocked) -- never returns a placeholder image.
|
||||
*/
|
||||
async getIconBytes(
|
||||
tenantId: string,
|
||||
@@ -261,7 +471,29 @@ export class FavoritesService {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
|
||||
|
||||
if (!link || link.userId !== userId || !link.iconUrl) {
|
||||
if (!link || link.userId !== userId) {
|
||||
throw new NotFoundException('FavoriteLink not found');
|
||||
}
|
||||
|
||||
if (link.uploadedIconMime !== null) {
|
||||
const absolute = favoriteIconAbsolutePath(link.userId, link.id, link.uploadedIconMime);
|
||||
if (absolute !== null) {
|
||||
try {
|
||||
const body = await fs.readFile(absolute);
|
||||
return { contentType: link.uploadedIconMime, body };
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Hochgeladenes Symbol des Favoriten ${id} fehlt im Dateibereich, falle auf iconUrl zurueck: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.logger.warn(`Hochgeladenes Symbol des Favoriten ${id} hat keinen gueltigen Ablageort`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!link.iconUrl) {
|
||||
throw new NotFoundException('FavoriteLink not found');
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||
calendar: { minW: 6, minH: 8, defaultW: 8, defaultH: 12 },
|
||||
note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 },
|
||||
calculator: { minW: 3, minH: 10, defaultW: 6, defaultH: 10 },
|
||||
favorites: { minW: 3, minH: 3, defaultW: 6, defaultH: 10 },
|
||||
favorites: { minW: 1, minH: 3, defaultW: 6, defaultH: 10 },
|
||||
stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 },
|
||||
'picture-frame': { minW: 4, minH: 4, defaultW: 8, defaultH: 12 },
|
||||
xframe: { minW: 4, minH: 4, defaultW: 12, defaultH: 12 },
|
||||
|
||||
@@ -56,7 +56,10 @@ export const WIDGET_CONSTRAINTS: Record<
|
||||
// SECHS Tastenreihen (%/CE/C/Loeschen, 1/x/x2/Wurzel/Division, 7-9, 4-6, 1-3, 0/Komma/=),
|
||||
// Inhalt 268 px; bei 9 Zeilen (244 px) fehlte die unterste Reihe um 25 px.
|
||||
calculator: { minW: 3, minH: 10, defaultW: 6, defaultH: 10 },
|
||||
favorites: { minW: 3, minH: 3, defaultW: 6, defaultH: 10 },
|
||||
// Favoriten: minW 1 statt 3 (Nutzerwunsch 23.09.) — bei kurzen Linknamen
|
||||
// blieb rechts viel Leerraum. Der Titel kuerzt mit Auslassungszeichen,
|
||||
// in der schmalsten Stufe bleibt das Symbol als Leiste stehen.
|
||||
favorites: { minW: 1, minH: 3, defaultW: 6, defaultH: 10 },
|
||||
stopwatch: { minW: 4, minH: 3, defaultW: 6, defaultH: 6 },
|
||||
// quick-260921-pi9: ein Bild braucht Flaeche — 8x12 entspricht der
|
||||
// Kalender-Vorgabe (rund 530x330 px, Querformat wie ein Foto); 8x8 war im
|
||||
|
||||
@@ -6,17 +6,31 @@ vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
// Mock favorites-api — all 5 functions as controllable vi.fn() spies
|
||||
// Mock favorites-api — all functions as controllable vi.fn() spies
|
||||
// (reorderFavorites hinzugefuegt, 260917-jdd — ohne diesen Eintrag
|
||||
// importiert das Widget undefined und der Klick auf einen Sortierpfeil
|
||||
// wirft TypeError.)
|
||||
vi.mock('@/lib/favorites-api', () => ({
|
||||
//
|
||||
// 260923-lrr: die Fabrik ist jetzt ASYNCHRON und uebernimmt per
|
||||
// `vi.importActual` die ECHTE `FavoriteRequestError`-Klasse und
|
||||
// `FAVORITE_ICON_MAX_BYTES` — ein `instanceof FavoriteRequestError` im
|
||||
// Widget muss gegen dieselbe Klasse pruefen, die die Tests werfen, sonst
|
||||
// faellt `favoriteErrorKey` immer auf `favorites.error` zurueck.
|
||||
// `uploadFavoriteIcon`/`removeFavoriteIcon` kommen als `vi.fn()` dazu.
|
||||
vi.mock('@/lib/favorites-api', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/favorites-api')>('@/lib/favorites-api');
|
||||
return {
|
||||
FavoriteRequestError: actual.FavoriteRequestError,
|
||||
FAVORITE_ICON_MAX_BYTES: actual.FAVORITE_ICON_MAX_BYTES,
|
||||
fetchFavorites: vi.fn(),
|
||||
createFavorite: vi.fn(),
|
||||
updateFavorite: vi.fn(),
|
||||
deleteFavorite: vi.fn(),
|
||||
reorderFavorites: vi.fn(),
|
||||
}));
|
||||
uploadFavoriteIcon: vi.fn(),
|
||||
removeFavoriteIcon: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock dashboard-api for updateWidgetConfig (view mode persistence)
|
||||
vi.mock('@/lib/dashboard-api', () => ({
|
||||
@@ -24,11 +38,14 @@ vi.mock('@/lib/dashboard-api', () => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
FavoriteRequestError,
|
||||
fetchFavorites,
|
||||
createFavorite,
|
||||
updateFavorite,
|
||||
deleteFavorite,
|
||||
reorderFavorites,
|
||||
uploadFavoriteIcon,
|
||||
removeFavoriteIcon,
|
||||
} from '@/lib/favorites-api';
|
||||
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
||||
|
||||
@@ -41,6 +58,8 @@ const mockCreate = createFavorite as ReturnType<typeof vi.fn>;
|
||||
const mockUpdate = updateFavorite as ReturnType<typeof vi.fn>;
|
||||
const mockDelete = deleteFavorite as ReturnType<typeof vi.fn>;
|
||||
const mockReorder = reorderFavorites as ReturnType<typeof vi.fn>;
|
||||
const mockUploadIcon = uploadFavoriteIcon as ReturnType<typeof vi.fn>;
|
||||
const mockRemoveIcon = removeFavoriteIcon as ReturnType<typeof vi.fn>;
|
||||
const mockUpdateWidgetConfig = updateWidgetConfig as ReturnType<typeof vi.fn>;
|
||||
|
||||
const BASE_FAVORITES = [
|
||||
@@ -450,7 +469,8 @@ describe('FavoritesWidget', () => {
|
||||
});
|
||||
|
||||
const proxyImg = screen.getByTestId('icon-proxy-fav-id-1') as HTMLImageElement;
|
||||
expect(proxyImg.getAttribute('src')).toBe('/api-proxy/favorites/fav-id-1/icon');
|
||||
// 260923-lrr: die Proxy-Adresse traegt jetzt ?v=<iconVersion> (Cache-Bust).
|
||||
expect(proxyImg.getAttribute('src')).toBe('/api-proxy/favorites/fav-id-1/icon?v=0');
|
||||
expect(screen.queryByTestId('icon-direct-fav-id-1')).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
@@ -573,4 +593,288 @@ describe('FavoritesWidget', () => {
|
||||
|
||||
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
// 260923-lrr: eigenes Symbol hochladen, Zwischenspeicher nach Aenderung
|
||||
// erneuern (versionierte Symbol-Adresse, Datei-Auswahl beim Bearbeiten
|
||||
// und Hinzufuegen, Entfernen-Knopf, Meldungen im Formular).
|
||||
describe('Eigenes Symbol (quick-260923-lrr)', () => {
|
||||
function openEditForGitHub() {
|
||||
const editButtons = screen.getAllByRole('button', { name: /favorites\.editButton/i });
|
||||
act(() => {
|
||||
fireEvent.click(editButtons[0]);
|
||||
});
|
||||
}
|
||||
|
||||
it('Proxy-Bild traegt ?v=<iconVersion>; Zeile ohne iconVersion -> ?v=0', async () => {
|
||||
mockFetch.mockResolvedValue([{ ...BASE_FAVORITES[0], iconVersion: 3 }, BASE_FAVORITES[1]]);
|
||||
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const proxyImg = screen.getByTestId('icon-proxy-fav-id-1') as HTMLImageElement;
|
||||
expect(proxyImg.getAttribute('src')).toBe('/api-proxy/favorites/fav-id-1/icon?v=3');
|
||||
});
|
||||
|
||||
it('Zeile nur mit uploadedIconMime (iconUrl null) -> Proxy-Bild statt Direktbild', async () => {
|
||||
mockFetch.mockResolvedValue([
|
||||
{
|
||||
id: 'fav-id-4',
|
||||
widgetId: 'fav-1',
|
||||
title: 'Eigenes',
|
||||
url: 'https://eigenes.invalid',
|
||||
iconUrl: null,
|
||||
uploadedIconMime: 'image/png',
|
||||
iconVersion: 1,
|
||||
position: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Eigenes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('icon-proxy-fav-id-4')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('icon-direct-fav-id-4')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Logo-Adress-Feld zeigt den Platzhalter favorites.iconUrlPlaceholder (kein fest verdrahteter Text mehr)', async () => {
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
openEditForGitHub();
|
||||
|
||||
expect(screen.getByPlaceholderText('favorites.iconUrlPlaceholder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Speichern mit neuer Logo-Adresse, updateFavorite liefert iconVersion 1 -> src des Proxy-Bildes endet danach auf ?v=1', async () => {
|
||||
mockUpdate.mockResolvedValue({
|
||||
...BASE_FAVORITES[0],
|
||||
iconUrl: 'https://neu.invalid/icon.png',
|
||||
iconVersion: 1,
|
||||
});
|
||||
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
openEditForGitHub();
|
||||
|
||||
const iconUrlField = screen.getByPlaceholderText('favorites.iconUrlPlaceholder');
|
||||
act(() => {
|
||||
fireEvent.change(iconUrlField, { target: { value: 'https://neu.invalid/icon.png' } });
|
||||
});
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /favorites\.saveButton/i });
|
||||
act(() => {
|
||||
fireEvent.click(saveButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
'fav-id-1',
|
||||
expect.objectContaining({ iconUrl: 'https://neu.invalid/icon.png' }),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const proxyImg = screen.getByTestId('icon-proxy-fav-id-1') as HTMLImageElement;
|
||||
expect(proxyImg.getAttribute('src')).toBe('/api-proxy/favorites/fav-id-1/icon?v=1');
|
||||
});
|
||||
});
|
||||
|
||||
it('Datei im Bearbeitungsformular waehlen, Speichern -> updateFavorite, danach uploadFavoriteIcon(\'fav-id-1\', Datei); Formular schliesst', async () => {
|
||||
mockUpdate.mockResolvedValue({ ...BASE_FAVORITES[0], iconVersion: 1 });
|
||||
mockUploadIcon.mockResolvedValue({
|
||||
...BASE_FAVORITES[0],
|
||||
uploadedIconMime: 'image/png',
|
||||
iconVersion: 2,
|
||||
});
|
||||
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
openEditForGitHub();
|
||||
|
||||
const fileField = screen.getByTestId('favorite-icon-upload-fav-id-1');
|
||||
const file = new File([new Uint8Array([1, 2, 3])], 'icon.png', { type: 'image/png' });
|
||||
act(() => {
|
||||
fireEvent.change(fileField, { target: { files: [file] } });
|
||||
});
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /favorites\.saveButton/i });
|
||||
act(() => {
|
||||
fireEvent.click(saveButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdate).toHaveBeenCalledWith('fav-id-1', expect.objectContaining({ title: 'GitHub' }));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockUploadIcon).toHaveBeenCalledWith('fav-id-1', file);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('favorite-icon-upload-fav-id-1')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('updateFavorite wirft FavoriteRequestError(iconUrlUnreachable) -> Meldung im Formular (role alert), Formular bleibt offen, uploadFavoriteIcon NICHT aufgerufen', async () => {
|
||||
mockUpdate.mockRejectedValue(new FavoriteRequestError('iconUrlUnreachable'));
|
||||
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
openEditForGitHub();
|
||||
|
||||
const fileField = screen.getByTestId('favorite-icon-upload-fav-id-1');
|
||||
const file = new File([new Uint8Array([1])], 'icon.png', { type: 'image/png' });
|
||||
act(() => {
|
||||
fireEvent.change(fileField, { target: { files: [file] } });
|
||||
});
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /favorites\.saveButton/i });
|
||||
act(() => {
|
||||
fireEvent.click(saveButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('favorites.iconUrlUnreachable');
|
||||
});
|
||||
expect(mockUploadIcon).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Upload wirft FavoriteRequestError(iconTooLarge) -> favorites.iconTooLarge im Formular, Formular bleibt offen', async () => {
|
||||
mockUpdate.mockResolvedValue({ ...BASE_FAVORITES[0] });
|
||||
mockUploadIcon.mockRejectedValue(new FavoriteRequestError('iconTooLarge'));
|
||||
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
openEditForGitHub();
|
||||
|
||||
const fileField = screen.getByTestId('favorite-icon-upload-fav-id-1');
|
||||
const file = new File([new Uint8Array([1])], 'icon.png', { type: 'image/png' });
|
||||
act(() => {
|
||||
fireEvent.change(fileField, { target: { files: [file] } });
|
||||
});
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /favorites\.saveButton/i });
|
||||
act(() => {
|
||||
fireEvent.click(saveButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('favorites.iconTooLarge');
|
||||
});
|
||||
expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Knopf favorites.iconRemoveButton nur bei gesetztem uploadedIconMime; Klick -> removeFavoriteIcon(\'fav-id-1\'), danach verschwindet der Knopf, Formular bleibt offen', async () => {
|
||||
mockFetch.mockResolvedValue([
|
||||
{ ...BASE_FAVORITES[0], uploadedIconMime: 'image/png', iconVersion: 1 },
|
||||
BASE_FAVORITES[1],
|
||||
]);
|
||||
mockRemoveIcon.mockResolvedValue({ ...BASE_FAVORITES[0], uploadedIconMime: null, iconVersion: 2 });
|
||||
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
openEditForGitHub();
|
||||
|
||||
const removeButton = screen.getByRole('button', { name: 'favorites.iconRemoveButton' });
|
||||
act(() => {
|
||||
fireEvent.click(removeButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRemoveIcon).toHaveBeenCalledWith('fav-id-1');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('button', { name: 'favorites.iconRemoveButton' })).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('kein Entfernen-Knopf ohne hochgeladenes Symbol', async () => {
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
openEditForGitHub();
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'favorites.iconRemoveButton' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Hinzufuegen mit gewaehlter Datei -> createFavorite, danach uploadFavoriteIcon(created.id, Datei)', async () => {
|
||||
mockFetch.mockResolvedValue([]);
|
||||
mockUploadIcon.mockResolvedValue({
|
||||
id: 'fav-id-new',
|
||||
widgetId: 'fav-1',
|
||||
title: 'New Link',
|
||||
url: 'https://example.com',
|
||||
iconUrl: null,
|
||||
uploadedIconMime: 'image/png',
|
||||
iconVersion: 1,
|
||||
position: 2,
|
||||
});
|
||||
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('favorites.empty')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const titleInput = screen.getByPlaceholderText('favorites.addTitle');
|
||||
const urlInput = screen.getByPlaceholderText('favorites.addUrl');
|
||||
const fileInput = screen.getByTestId('favorite-add-icon-upload');
|
||||
const file = new File([new Uint8Array([1, 2, 3])], 'icon.png', { type: 'image/png' });
|
||||
|
||||
act(() => {
|
||||
fireEvent.change(titleInput, { target: { value: 'New Link' } });
|
||||
fireEvent.change(urlInput, { target: { value: 'https://example.com' } });
|
||||
fireEvent.change(fileInput, { target: { files: [file] } });
|
||||
});
|
||||
|
||||
const addButton = screen.getByRole('button', { name: /favorites\.addButton/i });
|
||||
act(() => {
|
||||
fireEvent.click(addButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ widgetId: 'fav-1', title: 'New Link', url: 'https://example.com' }),
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockUploadIcon).toHaveBeenCalledWith('fav-id-new', file);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('New Link')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,11 +4,14 @@ import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
||||
import {
|
||||
FavoriteRequestError,
|
||||
createFavorite,
|
||||
deleteFavorite,
|
||||
fetchFavorites,
|
||||
removeFavoriteIcon,
|
||||
reorderFavorites,
|
||||
updateFavorite,
|
||||
uploadFavoriteIcon,
|
||||
type FavoriteLink,
|
||||
} from '@/lib/favorites-api';
|
||||
import type { WidgetProps } from '@/components/dashboard/widget-registry';
|
||||
@@ -16,6 +19,18 @@ import type { WidgetProps } from '@/components/dashboard/widget-registry';
|
||||
// quick-260916-iex: wie DEBOUNCE_MS im Notiz-Widget
|
||||
const TITLE_DEBOUNCE_MS = 1500;
|
||||
|
||||
/**
|
||||
* Bildet einen Fehler aus dem Favoriten-Klienten auf einen Uebersetzungs-
|
||||
* schluessel ab (260923-lrr): `FavoriteRequestError` traegt den passenden
|
||||
* Grund (`iconUrlUnreachable`/`iconTooLarge`/`iconInvalidType`/
|
||||
* `iconUploadFailed`) bereits als `reason`, jeder andere Fehler faellt auf
|
||||
* die bisherige allgemeine Meldung zurueck.
|
||||
*/
|
||||
function favoriteErrorKey(err: unknown): string {
|
||||
if (err instanceof FavoriteRequestError) return `favorites.${err.reason}`;
|
||||
return 'favorites.error';
|
||||
}
|
||||
|
||||
/**
|
||||
* FavoritesWidget — persistent per-widget list of favorite links (DASH-09).
|
||||
*
|
||||
@@ -79,12 +94,21 @@ export function FavoritesWidget({
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
const [newUrl, setNewUrl] = useState('');
|
||||
const [newIconUrl, setNewIconUrl] = useState('');
|
||||
// 260923-lrr: gewaehlte Datei fuer das Hinzufuegen-Formular; der Ref setzt
|
||||
// das native Datei-Feld nach dem Anlegen zurueck (unkontrolliertes Feld).
|
||||
const [newIconFile, setNewIconFile] = useState<File | null>(null);
|
||||
const newIconFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Inline edit state
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editTitle, setEditTitle] = useState('');
|
||||
const [editUrl, setEditUrl] = useState('');
|
||||
const [editIconUrl, setEditIconUrl] = useState('');
|
||||
// 260923-lrr: gewaehlte Datei, Fehlermeldung und Sperre des Bearbeitungs-
|
||||
// formulars — eigenes Symbol hochladen/entfernen.
|
||||
const [editIconFile, setEditIconFile] = useState<File | null>(null);
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
const [editBusy, setEditBusy] = useState(false);
|
||||
|
||||
// Sorted favorites: position asc, then title asc
|
||||
const sortedFavorites = useMemo(
|
||||
@@ -164,6 +188,10 @@ export function FavoritesWidget({
|
||||
}
|
||||
|
||||
// --- Add favorite ---
|
||||
// 260923-lrr: nach createFavorite wird — falls im Hinzufuegen-Formular
|
||||
// eine Datei gewaehlt wurde — diese fuer die neue Kennung hochgeladen;
|
||||
// scheitert nur der Upload, bleibt der Favorit angelegt (mit der Zeile aus
|
||||
// createFavorite) und die Meldung erscheint ueber dem Formular.
|
||||
async function handleAdd(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const cleanTitle = newTitle.trim();
|
||||
@@ -179,12 +207,24 @@ export function FavoritesWidget({
|
||||
url: cleanUrl,
|
||||
iconUrl: cleanIconUrl || undefined,
|
||||
});
|
||||
setFavorites((prev) => [...prev, created]);
|
||||
|
||||
let finalFavorite = created;
|
||||
if (newIconFile) {
|
||||
try {
|
||||
finalFavorite = await uploadFavoriteIcon(created.id, newIconFile);
|
||||
} catch (err) {
|
||||
setError(t(favoriteErrorKey(err)));
|
||||
}
|
||||
}
|
||||
|
||||
setFavorites((prev) => [...prev, finalFavorite]);
|
||||
setNewTitle('');
|
||||
setNewUrl('');
|
||||
setNewIconUrl('');
|
||||
} catch {
|
||||
setError(t('favorites.error'));
|
||||
setNewIconFile(null);
|
||||
if (newIconFileInputRef.current) newIconFileInputRef.current.value = '';
|
||||
} catch (err) {
|
||||
setError(t(favoriteErrorKey(err)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +234,9 @@ export function FavoritesWidget({
|
||||
setEditTitle(fav.title);
|
||||
setEditUrl(fav.url);
|
||||
setEditIconUrl(fav.iconUrl ?? '');
|
||||
setEditIconFile(null);
|
||||
setEditError(null);
|
||||
setEditBusy(false);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
@@ -202,8 +245,17 @@ export function FavoritesWidget({
|
||||
setEditTitle('');
|
||||
setEditUrl('');
|
||||
setEditIconUrl('');
|
||||
setEditIconFile(null);
|
||||
setEditError(null);
|
||||
setEditBusy(false);
|
||||
}
|
||||
|
||||
// 260923-lrr: erst PATCH (Titel/URL/Logo-Adresse), danach — falls eine
|
||||
// Datei gewaehlt wurde — der Upload. Scheitert `updateFavorite`, bleibt
|
||||
// die Zeile unveraendert und das Formular offen (z. B. 422 bei einer
|
||||
// nicht abrufbaren Logo-Adresse). Scheitert NUR der Upload, wird die
|
||||
// bereits aktualisierte Zeile trotzdem uebernommen, das Formular bleibt
|
||||
// ebenfalls offen, damit der Hinweis sichtbar ist.
|
||||
async function handleSaveEdit(id: string) {
|
||||
const cleanTitle = editTitle.trim();
|
||||
const cleanUrl = editUrl.trim();
|
||||
@@ -211,16 +263,45 @@ export function FavoritesWidget({
|
||||
|
||||
if (!cleanTitle || !cleanUrl) return;
|
||||
|
||||
setEditBusy(true);
|
||||
setEditError(null);
|
||||
try {
|
||||
const updated = await updateFavorite(id, {
|
||||
title: cleanTitle,
|
||||
url: cleanUrl,
|
||||
iconUrl: cleanIconUrl || null,
|
||||
});
|
||||
|
||||
if (editIconFile) {
|
||||
try {
|
||||
const withIcon = await uploadFavoriteIcon(id, editIconFile);
|
||||
setFavorites((prev) => prev.map((f) => (f.id === id ? withIcon : f)));
|
||||
cancelEdit();
|
||||
return;
|
||||
} catch (err) {
|
||||
setFavorites((prev) => prev.map((f) => (f.id === id ? updated : f)));
|
||||
setEditError(t(favoriteErrorKey(err)));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setFavorites((prev) => prev.map((f) => (f.id === id ? updated : f)));
|
||||
cancelEdit();
|
||||
} catch {
|
||||
setError(t('favorites.error'));
|
||||
} catch (err) {
|
||||
setEditError(t(favoriteErrorKey(err)));
|
||||
} finally {
|
||||
setEditBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Eigenes Symbol entfernen (260923-lrr) ---
|
||||
// Wirkt sofort (wie Loeschen einer Zeile), das Formular bleibt offen.
|
||||
async function handleRemoveIcon(id: string) {
|
||||
try {
|
||||
const updated = await removeFavoriteIcon(id);
|
||||
setFavorites((prev) => prev.map((f) => (f.id === id ? updated : f)));
|
||||
} catch (err) {
|
||||
setEditError(t(favoriteErrorKey(err)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,6 +407,8 @@ export function FavoritesWidget({
|
||||
editTitle={editTitle}
|
||||
editUrl={editUrl}
|
||||
editIconUrl={editIconUrl}
|
||||
editError={editError}
|
||||
editBusy={editBusy}
|
||||
getFallbackLetter={getFallbackLetter}
|
||||
onStartEdit={startEdit}
|
||||
onCancelEdit={cancelEdit}
|
||||
@@ -334,6 +417,8 @@ export function FavoritesWidget({
|
||||
onEditTitleChange={setEditTitle}
|
||||
onEditUrlChange={setEditUrl}
|
||||
onEditIconUrlChange={setEditIconUrl}
|
||||
onEditIconFileChange={setEditIconFile}
|
||||
onRemoveIcon={handleRemoveIcon}
|
||||
canMoveUp={index > 0}
|
||||
canMoveDown={index < sortedFavorites.length - 1}
|
||||
onMove={(fid, dir) => void handleMove(fid, dir)}
|
||||
@@ -355,6 +440,8 @@ export function FavoritesWidget({
|
||||
editTitle={editTitle}
|
||||
editUrl={editUrl}
|
||||
editIconUrl={editIconUrl}
|
||||
editError={editError}
|
||||
editBusy={editBusy}
|
||||
getFallbackLetter={getFallbackLetter}
|
||||
onStartEdit={startEdit}
|
||||
onCancelEdit={cancelEdit}
|
||||
@@ -363,6 +450,8 @@ export function FavoritesWidget({
|
||||
onEditTitleChange={setEditTitle}
|
||||
onEditUrlChange={setEditUrl}
|
||||
onEditIconUrlChange={setEditIconUrl}
|
||||
onEditIconFileChange={setEditIconFile}
|
||||
onRemoveIcon={handleRemoveIcon}
|
||||
canMoveUp={index > 0}
|
||||
canMoveDown={index < sortedFavorites.length - 1}
|
||||
onMove={(fid, dir) => void handleMove(fid, dir)}
|
||||
@@ -391,6 +480,18 @@ export function FavoritesWidget({
|
||||
onChange={(e) => setNewUrl(e.target.value)}
|
||||
placeholder={t('favorites.addUrl')}
|
||||
/>
|
||||
{/* 260923-lrr: eigenes Symbol beim Hinzufuegen */}
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t('favorites.iconUploadLabel')}
|
||||
<input
|
||||
type="file"
|
||||
data-testid="favorite-add-icon-upload"
|
||||
ref={newIconFileInputRef}
|
||||
accept="image/png,image/jpeg,image/gif,image/webp,image/x-icon,image/vnd.microsoft.icon,image/svg+xml,.ico,.svg"
|
||||
className="mt-0.5 block w-full text-xs"
|
||||
onChange={(e) => setNewIconFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded bg-primary px-2 py-1 text-xs text-primary-foreground hover:opacity-90 transition-opacity"
|
||||
@@ -443,6 +544,16 @@ function getDirectFaviconSrc(url: string): string | null {
|
||||
* Grenzen: ein http-Favorit auf einem https-Tessera ist Mischinhalt
|
||||
* (Browser blockiert ihn); ein selbstsigniertes Zertifikat ohne Vertrauen
|
||||
* im Browser des Nutzers klappt auch ueber Stufe 2 nicht.
|
||||
*
|
||||
* 260923-lrr: die Proxy-Adresse traegt jetzt `?v=<iconVersion>` — der
|
||||
* bisherige 24-Stunden-Zwischenspeicher des Browsers (`Cache-Control` am
|
||||
* Controller) aenderte sich bei einer neuen Logo-Adresse oder einem neuen
|
||||
* hochgeladenen Symbol NIE, weil die Adresse selbst gleich blieb. Der
|
||||
* Remount-Key an der Aufrufstelle (`FavoriteTile`) enthaelt `iconVersion`
|
||||
* und `uploadedIconMime` zusaetzlich zu `iconUrl`/`url`, damit nach einer
|
||||
* Aenderung wieder mit Stufe `proxy` begonnen wird — ein hochgeladenes
|
||||
* Symbol hat serverseitig bereits Vorrang (`getIconBytes`), die Kachel
|
||||
* zeigt es also auch ohne gesetzte `iconUrl`.
|
||||
*/
|
||||
function FavoriteIcon({
|
||||
fav,
|
||||
@@ -451,8 +562,9 @@ function FavoriteIcon({
|
||||
fav: FavoriteLink;
|
||||
getFallbackLetter: (title: string) => string;
|
||||
}) {
|
||||
const proxySrc = fav.iconUrl
|
||||
? `/api-proxy/favorites/${encodeURIComponent(fav.id)}/icon`
|
||||
const hasServerIcon = Boolean(fav.iconUrl) || Boolean(fav.uploadedIconMime);
|
||||
const proxySrc = hasServerIcon
|
||||
? `/api-proxy/favorites/${encodeURIComponent(fav.id)}/icon?v=${fav.iconVersion ?? 0}`
|
||||
: null;
|
||||
const directSrc = getDirectFaviconSrc(fav.url);
|
||||
const [stage, setStage] = useState<'proxy' | 'direct' | 'none'>(
|
||||
@@ -507,6 +619,9 @@ type FavoriteTileProps = {
|
||||
editTitle: string;
|
||||
editUrl: string;
|
||||
editIconUrl: string;
|
||||
/** 260923-lrr — Fehlermeldung/Sperre des Bearbeitungsformulars (eigenes Symbol). */
|
||||
editError: string | null;
|
||||
editBusy: boolean;
|
||||
getFallbackLetter: (title: string) => string;
|
||||
onStartEdit: (fav: FavoriteLink) => void;
|
||||
onCancelEdit: () => void;
|
||||
@@ -515,6 +630,10 @@ type FavoriteTileProps = {
|
||||
onEditTitleChange: (v: string) => void;
|
||||
onEditUrlChange: (v: string) => void;
|
||||
onEditIconUrlChange: (v: string) => void;
|
||||
/** 260923-lrr — gewaehlte Datei im Bearbeitungsformular. */
|
||||
onEditIconFileChange: (file: File | null) => void;
|
||||
/** 260923-lrr — „Hochgeladenes Symbol entfernen“. */
|
||||
onRemoveIcon: (id: string) => Promise<void>;
|
||||
canMoveUp: boolean;
|
||||
canMoveDown: boolean;
|
||||
onMove: (id: string, direction: 'up' | 'down') => void;
|
||||
@@ -528,6 +647,8 @@ function FavoriteTile({
|
||||
editTitle,
|
||||
editUrl,
|
||||
editIconUrl,
|
||||
editError,
|
||||
editBusy,
|
||||
getFallbackLetter,
|
||||
onStartEdit,
|
||||
onCancelEdit,
|
||||
@@ -536,6 +657,8 @@ function FavoriteTile({
|
||||
onEditTitleChange,
|
||||
onEditUrlChange,
|
||||
onEditIconUrlChange,
|
||||
onEditIconFileChange,
|
||||
onRemoveIcon,
|
||||
canMoveUp,
|
||||
canMoveDown,
|
||||
onMove,
|
||||
@@ -557,7 +680,7 @@ function FavoriteTile({
|
||||
>
|
||||
{/* Icon container with letter fallback (T-08-07 — no dangerouslySetInnerHTML) */}
|
||||
<FavoriteIcon
|
||||
key={`${fav.iconUrl ?? ''}|${fav.url}`}
|
||||
key={`${fav.iconUrl ?? ''}|${fav.url}|${fav.iconVersion ?? 0}|${fav.uploadedIconMime ?? ''}`}
|
||||
fav={fav}
|
||||
getFallbackLetter={getFallbackLetter}
|
||||
/>
|
||||
@@ -663,12 +786,43 @@ function FavoriteTile({
|
||||
className="w-full rounded border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
value={editIconUrl}
|
||||
onChange={(e) => onEditIconUrlChange(e.target.value)}
|
||||
placeholder="Logo-URL (optional)"
|
||||
placeholder={t('favorites.iconUrlPlaceholder')}
|
||||
aria-label={t('favorites.iconUrlPlaceholder')}
|
||||
/>
|
||||
{/* 260923-lrr: eigenes Symbol — Datei-Feld, Hinweis, ggf. Entfernen-Knopf */}
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{t('favorites.iconUploadLabel')}
|
||||
<input
|
||||
type="file"
|
||||
data-testid={`favorite-icon-upload-${fav.id}`}
|
||||
accept="image/png,image/jpeg,image/gif,image/webp,image/x-icon,image/vnd.microsoft.icon,image/svg+xml,.ico,.svg"
|
||||
className="mt-0.5 block w-full text-xs"
|
||||
onChange={(e) => onEditIconFileChange(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">{t('favorites.iconUploadHint')}</p>
|
||||
{fav.uploadedIconMime != null && (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">{t('favorites.iconUploadedHint')}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-muted px-2 py-1 text-xs text-muted-foreground hover:bg-muted/80 transition-colors"
|
||||
onClick={() => void onRemoveIcon(fav.id)}
|
||||
>
|
||||
{t('favorites.iconRemoveButton')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{editError && (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{editError}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded bg-primary px-2 py-1 text-xs text-primary-foreground hover:opacity-90 transition-opacity"
|
||||
className="flex-1 rounded bg-primary px-2 py-1 text-xs text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
disabled={editBusy}
|
||||
onClick={() => void onSaveEdit(fav.id)}
|
||||
>
|
||||
{t('favorites.saveButton')}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { sendBugReport } from './bug-report-api';
|
||||
import { captureScreenshot, sendBugReport } from './bug-report-api';
|
||||
|
||||
const { mockToPng } = vi.hoisted(() => ({ mockToPng: vi.fn() }));
|
||||
vi.mock('html-to-image', () => ({ toPng: mockToPng }));
|
||||
|
||||
/**
|
||||
* bug-report-api.test — NEU (quick-260918-gza). Bisher pruefte nur der
|
||||
@@ -85,3 +88,42 @@ describe('sendBugReport (quick-260918-gza)', () => {
|
||||
).resolves.toEqual({ ok: false, status: 429 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('captureScreenshot (Bild von fremder Adresse)', () => {
|
||||
beforeEach(() => {
|
||||
mockToPng.mockReset();
|
||||
});
|
||||
|
||||
it('ersetzt nicht ladbare Bilder statt die Aufnahme abzubrechen', async () => {
|
||||
mockToPng.mockResolvedValue('data:image/png;base64,AAA');
|
||||
|
||||
await expect(captureScreenshot()).resolves.toBe('data:image/png;base64,AAA');
|
||||
|
||||
const opts = mockToPng.mock.calls[0][1];
|
||||
expect(opts.imagePlaceholder).toMatch(/^data:image\/png;base64,/);
|
||||
expect(opts.onImageErrorHandler()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('versucht es ein zweites Mal ohne Bilder und Rahmen, wenn der erste Versuch scheitert', async () => {
|
||||
mockToPng
|
||||
.mockRejectedValueOnce(new Error('tainted'))
|
||||
.mockResolvedValueOnce('data:image/png;base64,BBB');
|
||||
|
||||
await expect(captureScreenshot()).resolves.toBe('data:image/png;base64,BBB');
|
||||
|
||||
expect(mockToPng).toHaveBeenCalledTimes(2);
|
||||
const filter = mockToPng.mock.calls[1][1].filter as (n: Node) => boolean;
|
||||
expect(filter(document.createElement('img'))).toBe(false);
|
||||
expect(filter(document.createElement('iframe'))).toBe(false);
|
||||
expect(filter(document.createElement('div'))).toBe(true);
|
||||
const ignored = document.createElement('div');
|
||||
ignored.dataset.bugReportIgnore = 'true';
|
||||
expect(filter(ignored)).toBe(false);
|
||||
});
|
||||
|
||||
it('liefert null, wenn auch der zweite Versuch scheitert', async () => {
|
||||
mockToPng.mockRejectedValue(new Error('kaputt'));
|
||||
|
||||
await expect(captureScreenshot()).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,23 +42,50 @@ export function computeCaptureSize(
|
||||
};
|
||||
}
|
||||
|
||||
/** 1x1 transparentes PNG — Ersatz fuer Bilder, die sich nicht laden lassen. */
|
||||
const TRANSPARENT_PIXEL =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
|
||||
|
||||
function isIgnoredNode(n: Node): boolean {
|
||||
return n instanceof HTMLElement && n.dataset.bugReportIgnore === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt die aktuelle Seite als PNG-Data-URL auf. Liefert `null` statt zu
|
||||
* werfen: das Bild ist eine Beigabe, der Bericht geht auch ohne.
|
||||
*
|
||||
* `html-to-image` laedt jedes `<img>` per `fetch` nach. Ein einziges Bild
|
||||
* von fremder Adresse ohne CORS (z. B. ein direkt von der Website geholtes
|
||||
* Favoriten-Symbol) liess bisher die GANZE Aufnahme scheitern — im Dialog
|
||||
* blieb das Haekchen „Bildschirmfoto beifuegen“ dann gesperrt. Deshalb:
|
||||
* nicht ladbare Bilder werden zum transparenten Pixel, und scheitert die
|
||||
* Aufnahme trotzdem, folgt ein zweiter Versuch ganz ohne Bilder und Rahmen.
|
||||
*/
|
||||
export async function captureScreenshot(): Promise<string | null> {
|
||||
try {
|
||||
const { toPng } = await import('html-to-image');
|
||||
const node = document.body;
|
||||
const size = computeCaptureSize(node.scrollWidth, node.scrollHeight);
|
||||
return await toPng(node, {
|
||||
const base = {
|
||||
pixelRatio: 1,
|
||||
skipFonts: true,
|
||||
cacheBust: true,
|
||||
canvasWidth: size.width,
|
||||
canvasHeight: size.height,
|
||||
filter: (n: Node) => !(n instanceof HTMLElement && n.dataset.bugReportIgnore === 'true'),
|
||||
imagePlaceholder: TRANSPARENT_PIXEL,
|
||||
onImageErrorHandler: () => undefined,
|
||||
};
|
||||
try {
|
||||
return await toPng(node, { ...base, filter: (n: Node) => !isIgnoredNode(n) });
|
||||
} catch {
|
||||
return await toPng(node, {
|
||||
...base,
|
||||
filter: (n: Node) =>
|
||||
!isIgnoredNode(n) &&
|
||||
!(n instanceof HTMLImageElement) &&
|
||||
!(n instanceof HTMLIFrameElement),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
FAVORITE_ICON_MAX_BYTES,
|
||||
FavoriteRequestError,
|
||||
createFavorite,
|
||||
removeFavoriteIcon,
|
||||
updateFavorite,
|
||||
uploadFavoriteIcon,
|
||||
} from './favorites-api';
|
||||
|
||||
/**
|
||||
* favorites-api.test — NEU (quick-260923-lrr).
|
||||
*
|
||||
* Die vier neuen/geaenderten Aufrufe: `uploadFavoriteIcon` (FormData-Feld
|
||||
* `icon`, Groessenpruefung vor dem Netzwerkaufruf, 413/400/sonst ->
|
||||
* `FavoriteRequestError`), `removeFavoriteIcon` (DELETE), sowie
|
||||
* `createFavorite`/`updateFavorite` (422 -> `FavoriteRequestError` mit
|
||||
* `reason: 'iconUrlUnreachable'`, jeder andere Fehler bleibt eine generische
|
||||
* `Error` wie bisher). Muster `dashboard-images-api.test.ts`.
|
||||
*/
|
||||
const { mockFetch } = vi.hoisted(() => ({ mockFetch: vi.fn() }));
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('favorites-api (quick-260923-lrr)', () => {
|
||||
describe('uploadFavoriteIcon', () => {
|
||||
it('schickt POST an /favorites/<id>/icon mit credentials include, FormData-Feld icon, OHNE eigenen Content-Type; 200 -> Zeile', async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({ id: 'fav-1', uploadedIconMime: 'image/png', iconVersion: 1 }),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
const file = new File([new Uint8Array([1, 2, 3])], 'x.png', { type: 'image/png' });
|
||||
|
||||
const result = await uploadFavoriteIcon('fav-1', file);
|
||||
|
||||
expect(result.id).toBe('fav-1');
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(String(url).endsWith('/favorites/fav-1/icon')).toBe(true);
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.credentials).toBe('include');
|
||||
expect(init.headers).toBeUndefined();
|
||||
const body = init.body as FormData;
|
||||
expect([...body.keys()]).toEqual(['icon']);
|
||||
expect(body.get('icon')).toBeInstanceOf(File);
|
||||
});
|
||||
|
||||
it('Datei groesser 512 KB -> FavoriteRequestError iconTooLarge, fetch NICHT aufgerufen', async () => {
|
||||
const big = new File([new Uint8Array(FAVORITE_ICON_MAX_BYTES + 1)], 'gross.png', {
|
||||
type: 'image/png',
|
||||
});
|
||||
|
||||
const err = await uploadFavoriteIcon('fav-1', big).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(FavoriteRequestError);
|
||||
expect((err as FavoriteRequestError).reason).toBe('iconTooLarge');
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('413 -> iconTooLarge', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 413 }));
|
||||
const file = new File([new Uint8Array([1])], 'x.png', { type: 'image/png' });
|
||||
|
||||
const err = await uploadFavoriteIcon('fav-1', file).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(FavoriteRequestError);
|
||||
expect((err as FavoriteRequestError).reason).toBe('iconTooLarge');
|
||||
});
|
||||
|
||||
it('400 -> iconInvalidType', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 400 }));
|
||||
const file = new File([new Uint8Array([1])], 'x.txt', { type: 'text/plain' });
|
||||
|
||||
const err = await uploadFavoriteIcon('fav-1', file).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(FavoriteRequestError);
|
||||
expect((err as FavoriteRequestError).reason).toBe('iconInvalidType');
|
||||
});
|
||||
|
||||
it('500 -> iconUploadFailed', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('kaputt', { status: 500 }));
|
||||
const file = new File([new Uint8Array([1])], 'x.png', { type: 'image/png' });
|
||||
|
||||
const err = await uploadFavoriteIcon('fav-1', file).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(FavoriteRequestError);
|
||||
expect((err as FavoriteRequestError).reason).toBe('iconUploadFailed');
|
||||
});
|
||||
});
|
||||
|
||||
it('removeFavoriteIcon schickt DELETE an /favorites/<id>/icon mit credentials include, liefert die Zeile', async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ id: 'fav-1', uploadedIconMime: null, iconVersion: 2 }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await removeFavoriteIcon('fav-1');
|
||||
|
||||
expect(result.uploadedIconMime).toBeNull();
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(String(url).endsWith('/favorites/fav-1/icon')).toBe(true);
|
||||
expect(init.method).toBe('DELETE');
|
||||
expect(init.credentials).toBe('include');
|
||||
});
|
||||
|
||||
describe('createFavorite / updateFavorite — Abrufprobe (260923-lrr)', () => {
|
||||
it('createFavorite: 422 -> FavoriteRequestError reason iconUrlUnreachable', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 422 }));
|
||||
|
||||
const err = await createFavorite({
|
||||
widgetId: 'w1',
|
||||
title: 'X',
|
||||
url: 'https://x.invalid',
|
||||
iconUrl: 'https://x.invalid/logo.png',
|
||||
}).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(FavoriteRequestError);
|
||||
expect((err as FavoriteRequestError).reason).toBe('iconUrlUnreachable');
|
||||
});
|
||||
|
||||
it('createFavorite: anderer Fehler -> wie bisher eine generische Error', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 500 }));
|
||||
|
||||
const err = await createFavorite({ widgetId: 'w1', title: 'X', url: 'https://x.invalid' }).catch(
|
||||
(e) => e,
|
||||
);
|
||||
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).not.toBeInstanceOf(FavoriteRequestError);
|
||||
});
|
||||
|
||||
it('updateFavorite: 422 -> FavoriteRequestError reason iconUrlUnreachable', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 422 }));
|
||||
|
||||
const err = await updateFavorite('fav-1', { iconUrl: 'https://x.invalid/logo.png' }).catch(
|
||||
(e) => e,
|
||||
);
|
||||
|
||||
expect(err).toBeInstanceOf(FavoriteRequestError);
|
||||
expect((err as FavoriteRequestError).reason).toBe('iconUrlUnreachable');
|
||||
});
|
||||
|
||||
it('updateFavorite: anderer Fehler -> wie bisher eine generische Error', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 500 }));
|
||||
|
||||
const err = await updateFavorite('fav-1', { title: 'Neu' }).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).not.toBeInstanceOf(FavoriteRequestError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,10 +3,22 @@
|
||||
* Mirrors the NestJS FavoritesController routes (08-03).
|
||||
* All calls use credentials: 'include' for cookie-based auth.
|
||||
* PUT /favorites/order (reorderFavorites) added 260917-jdd.
|
||||
*
|
||||
* 260923-lrr — eigenes Symbol hochladen, Zwischenspeicher nach Aenderung
|
||||
* erneuern: `FavoriteLink` traegt jetzt `uploadedIconMime`/`iconVersion`
|
||||
* (optional, weil Testdaten und aeltere Antworten sie nicht tragen).
|
||||
* `createFavorite`/`updateFavorite` uebersetzen ein 422 (Abrufprobe der API
|
||||
* fuer eine nicht abrufbare Logo-Adresse) in `FavoriteRequestError`, damit
|
||||
* das Widget eine sprachrichtige Meldung zeigen kann, statt der bisherigen
|
||||
* generischen `Error`. `uploadFavoriteIcon`/`removeFavoriteIcon` sind neu,
|
||||
* Muster `uploadDashboardImage`/`deleteDashboardImage` (dashboard-images-api.ts).
|
||||
*/
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
/** Hoechstgroesse eines hochgeladenen Symbols — muss der API-Konstante entsprechen. */
|
||||
export const FAVORITE_ICON_MAX_BYTES = 512 * 1024;
|
||||
|
||||
export interface FavoriteLink {
|
||||
id: string;
|
||||
widgetId: string;
|
||||
@@ -14,6 +26,33 @@ export interface FavoriteLink {
|
||||
url: string;
|
||||
iconUrl: string | null;
|
||||
position: number;
|
||||
/** 260923-lrr — Typ eines hochgeladenen eigenen Symbols, null = keins. */
|
||||
uploadedIconMime?: string | null;
|
||||
/** 260923-lrr — Zaehler fuer die Symbol-Adresse (`?v=`), macht den 24h-Zwischenspeicher nach einer Aenderung sofort ungueltig. */
|
||||
iconVersion?: number;
|
||||
}
|
||||
|
||||
/** Grund einer abgewiesenen Favoriten-Anfrage (260923-lrr). */
|
||||
export type FavoriteErrorReason =
|
||||
| 'iconUrlUnreachable'
|
||||
| 'iconTooLarge'
|
||||
| 'iconInvalidType'
|
||||
| 'iconUploadFailed';
|
||||
|
||||
/**
|
||||
* Ein abgewiesener Favoriten-Aufruf mit einem uebersetzbaren Grund
|
||||
* (260923-lrr) — das Widget bildet `reason` auf `t('favorites.' + reason)`
|
||||
* ab, damit die Meldung sprachrichtig ist statt einer festen deutschen
|
||||
* Zeichenkette aus dem Klienten.
|
||||
*/
|
||||
export class FavoriteRequestError extends Error {
|
||||
public readonly reason: FavoriteErrorReason;
|
||||
|
||||
constructor(reason: FavoriteErrorReason) {
|
||||
super(reason);
|
||||
this.name = 'FavoriteRequestError';
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,6 +72,8 @@ export async function fetchFavorites(widgetId: string): Promise<FavoriteLink[]>
|
||||
/**
|
||||
* Create a new favorite link.
|
||||
* Server-side icon discovery runs automatically if iconUrl is not provided.
|
||||
* 260923-lrr: eine explizite `iconUrl`, die sich serverseitig nicht laden
|
||||
* laesst, ergibt 422 -> `FavoriteRequestError('iconUrlUnreachable')`.
|
||||
*/
|
||||
export async function createFavorite(payload: {
|
||||
widgetId: string;
|
||||
@@ -46,6 +87,7 @@ export async function createFavorite(payload: {
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.status === 422) throw new FavoriteRequestError('iconUrlUnreachable');
|
||||
if (!res.ok) throw new Error('Failed to create favorite');
|
||||
|
||||
return res.json();
|
||||
@@ -54,6 +96,8 @@ export async function createFavorite(payload: {
|
||||
/**
|
||||
* Update an existing favorite link.
|
||||
* Pass iconUrl: null to clear a stored icon.
|
||||
* 260923-lrr: eine neue, nicht abrufbare `iconUrl` ergibt ebenso 422 ->
|
||||
* `FavoriteRequestError('iconUrlUnreachable')`.
|
||||
*/
|
||||
export async function updateFavorite(
|
||||
id: string,
|
||||
@@ -65,6 +109,7 @@ export async function updateFavorite(
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.status === 422) throw new FavoriteRequestError('iconUrlUnreachable');
|
||||
if (!res.ok) throw new Error('Failed to update favorite');
|
||||
|
||||
return res.json();
|
||||
@@ -100,3 +145,43 @@ export async function deleteFavorite(id: string): Promise<void> {
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to delete favorite');
|
||||
}
|
||||
|
||||
/**
|
||||
* Laedt ein eigenes Symbol fuer einen Favoriten hoch (260923-lrr). Muster
|
||||
* `uploadDashboardImage` (dashboard-images-api.ts): FormData ohne eigenen
|
||||
* Content-Type-Header — die Multipart-Grenze setzt der Browser. Die
|
||||
* Groessenpruefung laeuft VOR dem Netzwerkaufruf (kein Aufruf fuer eine
|
||||
* offensichtlich zu grosse Datei).
|
||||
*/
|
||||
export async function uploadFavoriteIcon(id: string, file: File): Promise<FavoriteLink> {
|
||||
if (file.size > FAVORITE_ICON_MAX_BYTES) {
|
||||
throw new FavoriteRequestError('iconTooLarge');
|
||||
}
|
||||
|
||||
const body = new FormData();
|
||||
body.append('icon', file, file.name);
|
||||
const res = await fetch(`${API_URL}/favorites/${encodeURIComponent(id)}/icon`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body,
|
||||
});
|
||||
if (res.ok) return res.json();
|
||||
|
||||
if (res.status === 413) throw new FavoriteRequestError('iconTooLarge');
|
||||
if (res.status === 400) throw new FavoriteRequestError('iconInvalidType');
|
||||
throw new FavoriteRequestError('iconUploadFailed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt ein zuvor hochgeladenes Symbol wieder (260923-lrr); die Kachel
|
||||
* faellt danach auf `iconUrl` bzw. automatische Erkennung zurueck.
|
||||
*/
|
||||
export async function removeFavoriteIcon(id: string): Promise<FavoriteLink> {
|
||||
const res = await fetch(`${API_URL}/favorites/${encodeURIComponent(id)}/icon`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to remove favorite icon');
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -336,7 +336,16 @@
|
||||
"error": "Fehler beim Laden der Favoriten",
|
||||
"titleLabel": "Titel",
|
||||
"titlePlaceholder": "Titel (optional)",
|
||||
"viewModeLabel": "Ansicht wechseln"
|
||||
"viewModeLabel": "Ansicht wechseln",
|
||||
"iconUrlPlaceholder": "Logo-Adresse (optional)",
|
||||
"iconUploadLabel": "Eigenes Symbol hochladen",
|
||||
"iconUploadHint": "PNG, JPEG, GIF, WebP, ICO oder SVG, höchstens 512 KB. Ein hochgeladenes Symbol hat Vorrang vor der Logo-Adresse.",
|
||||
"iconUploadedHint": "Für diesen Favoriten ist ein eigenes Symbol hochgeladen.",
|
||||
"iconRemoveButton": "Hochgeladenes Symbol entfernen",
|
||||
"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.",
|
||||
"iconTooLarge": "Die Datei ist zu groß – erlaubt sind höchstens 512 KB.",
|
||||
"iconInvalidType": "Nur Bilder im Format PNG, JPEG, GIF, WebP, ICO oder SVG sind erlaubt.",
|
||||
"iconUploadFailed": "Das Symbol konnte nicht hochgeladen werden."
|
||||
},
|
||||
"stopwatch": {
|
||||
"name": "Stoppuhr",
|
||||
|
||||
@@ -336,7 +336,16 @@
|
||||
"error": "Error loading favorites",
|
||||
"titleLabel": "Title",
|
||||
"titlePlaceholder": "Title (optional)",
|
||||
"viewModeLabel": "Switch view"
|
||||
"viewModeLabel": "Switch view",
|
||||
"iconUrlPlaceholder": "Logo URL (optional)",
|
||||
"iconUploadLabel": "Upload custom icon",
|
||||
"iconUploadHint": "PNG, JPEG, GIF, WebP, ICO or SVG, at most 512 KB. An uploaded icon takes precedence over the logo URL.",
|
||||
"iconUploadedHint": "A custom icon has been uploaded for this favorite.",
|
||||
"iconRemoveButton": "Remove uploaded icon",
|
||||
"iconUrlUnreachable": "The image at this address could not be loaded. The site likely blocks automated requests (for example via a Cloudflare check) or is unreachable. Please upload the icon instead.",
|
||||
"iconTooLarge": "The file is too large – at most 512 KB is allowed.",
|
||||
"iconInvalidType": "Only PNG, JPEG, GIF, WebP, ICO or SVG images are allowed.",
|
||||
"iconUploadFailed": "The icon could not be uploaded."
|
||||
},
|
||||
"stopwatch": {
|
||||
"name": "Stopwatch",
|
||||
|
||||
@@ -82,7 +82,7 @@ Ihre Änderungen werden über **„Änderungen speichern"** übernommen. Verlass
|
||||
| Kalender | Monatsübersicht mit der Anzahl der Termine je Tag (die Termine eines Tages erscheinen, wenn Sie mit der Maus darüberfahren) und darunter die nächsten Termine aus Ihren verbundenen Kalenderquellen. Ob die Monatsansicht erscheint, wie viele Termine und welcher Zeitraum gezeigt werden, stellen Sie unter Einstellungen > Dashboard > Widgets ein |
|
||||
| Notizen | Freitext-Notizen mit Markdown-Formatierung; Listen zum Abhaken (`- [ ]`) lassen sich in der Ansicht direkt per Klick abhaken |
|
||||
| Taschenrechner | Grundrechenarten, auch per Tastatur bedienbar |
|
||||
| Favoriten | Schnellzugriff auf mehrere selbst gepflegte Links, als Liste oder Kachelansicht, optional mit eigener Überschrift. Im Bearbeitungsmodus des Dashboards bringen Sie die Links mit den Pfeilen „Nach oben"/„Nach unten" in die gewünschte Reihenfolge. Das Symbol einer Seite holt Tessera automatisch; bei internen Adressen versucht es zusätzlich Ihr Browser direkt |
|
||||
| Favoriten | Schnellzugriff auf mehrere selbst gepflegte Links, als Liste oder Kachelansicht, optional mit eigener Überschrift. Im Bearbeitungsmodus des Dashboards bringen Sie die Links mit den Pfeilen „Nach oben"/„Nach unten" in die gewünschte Reihenfolge. Das Symbol einer Seite holt Tessera automatisch; bei internen Adressen versucht es zusätzlich Ihr Browser direkt. Im Bearbeitungsformular tragen Sie stattdessen eine eigene Logo-Adresse ein oder laden ein eigenes Symbol hoch (PNG, JPEG, GIF, WebP, ICO oder SVG, höchstens 512 KB) — ein hochgeladenes Symbol hat Vorrang und lässt sich über „Hochgeladenes Symbol entfernen“ wieder zurücknehmen; blockiert eine Seite automatische Abrufe (z. B. durch eine Cloudflare-Prüfung), meldet Tessera das beim Speichern |
|
||||
| Stoppuhr | Zeitmessung mit Rundenzeiten |
|
||||
| Bilderrahmen | Diashow aus eigenen Bildern: Sie laden Bilder hoch (PNG, JPEG, GIF oder WebP, höchstens 5 MB je Bild, bis zu 30 Bilder) oder binden Bilder über eine https-Adresse ein. Ob das Bild ganz sichtbar oder formatfüllend erscheint, wie oft gewechselt wird (oder gar nicht), ob in Reihenfolge oder zufällig, und welche Bildunterschrift jedes Bild trägt, stellen Sie unter Einstellungen > Dashboard ein. Ein Klick auf das Bild zeigt es groß; Escape oder ein Klick daneben schließt die Großansicht wieder |
|
||||
| XFrame | Zeigt eine Webseite als Rahmen in der Kachel. Die https-Adresse, einen optionalen Titel und ob die Seite automatisch neu geladen wird (nie, 1 Minute bis 1 Stunde), stellen Sie unter Einstellungen > Dashboard ein. Die eingebettete Seite kann Tessera nicht verlassen; über „In neuem Tab öffnen“ erreichen Sie die Seite jederzeit direkt. Manche Webseiten erlauben das Einbetten nicht — der Rahmen bleibt dann leer, der Knopf funktioniert trotzdem. Wahlweise zeigen Sie nur einen Ausschnitt der Seite: den Rahmen in der Vorschau verschieben oder an den Ecken ziehen (oder Links, Oben, Breite und Höhe eintippen) – die Kachel zeigt dann genau diesen Ausschnitt, passend zu ihrer Größe; für die ganze Seite gibt es eine Vergrößerung (50 bis 150 %), und „Nur anzeigen“ sperrt Klicken und Scrollen im Rahmen. |
|
||||
|
||||
@@ -285,9 +285,12 @@ Restores stattfinden.
|
||||
ihn sind alle per `pg_dump` gesicherten verschlüsselten Zugangsdaten wertlos.
|
||||
- **Hochgeladene Dateien** (Avatare unter `user-files/avatars/`, Bilder des
|
||||
Bilderrahmen-Widgets unter `user-files/dashboard-images/<Benutzerkennung>/`,
|
||||
generierte DKV-Exporte unter `user-files/`, siehe
|
||||
Symbole des Favoriten-Widgets unter
|
||||
`user-files/favorite-icons/<Benutzerkennung>/`, generierte DKV-Exporte unter
|
||||
`user-files/`, siehe
|
||||
`apps/api/src/user/user.controller.ts`,
|
||||
`apps/api/src/dashboard/dashboard-images.service.ts` und
|
||||
`apps/api/src/dashboard/dashboard-images.service.ts`,
|
||||
`apps/api/src/favorites/favorites.service.ts` und
|
||||
`apps/api/src/dkv/dkv-export.service.ts`): Diese Dateien liegen im benannten
|
||||
Docker-Volume `user-files`, gemountet auf `/app/user-files` im Dienst `api`. Der
|
||||
Mount ist in `docker-compose.yml` und `docker-compose.prod.yml` eingetragen:
|
||||
|
||||
@@ -694,6 +694,7 @@ werden.
|
||||
| apps/api/src/dashboard/dashboard-images.service.ts | dashboardImage | muss-mandantengebunden | system-gebunden | **260922-hk4:** Stand von `gebunden` auf `system-gebunden` — NICHT weil ein Anfrageweg aufgeweicht wurde, sondern weil EIN Startpfad dazugekommen ist: `onApplicationBootstrap()` zieht die Bilder einmalig aus der Spalte `data` in den Dateibereich (`user-files/dashboard-images/<userId>/<id>.<ext>`) und muss dafür die noch nicht umgezogenen Zeilen ALLER Mandanten sehen (`const systemPrisma = forSystem(this.prisma)`, ein Aufruf, Erlaubnisliste in `rls-access-inventory.spec.ts`; Leserecht über `system_read_policy … FOR SELECT` auf "DashboardImage", Migration 20260922120000). GESCHRIEBEN wird auch dort je Zeile über `forTenant(prisma, row.tenantId, row.userId)` — einmal-lesen-viele-bedienen, Muster DKV-Planer. Die Bytes selbst liegen seither auf der Platte, die Zeile hält nur noch `storagePath` (Muster `User.avatarPath`); der Dateiname ist IMMER servergeneriert (UUID der Zeile + Endung aus dem ERKANNTEN Mime-Typ), `originalName` kommt in keinem Pfad vor (T-HK4-01). Alle vier Anfragewege sind unverändert mandantengebunden: Hochgeladene Bilder des Bilderrahmen-Widgets (quick-260921-pi9), gehoeren dem hochladenden Benutzer; `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension von Anfang an (Migration 20260921120000, Form aus 20260911120000). Alle vier Methoden (`list`, `upload`, `getBytes`, `remove`) holen je einen Klienten `const tenantPrisma = forTenant(this.prisma, tenantId, userId)`; Liste und Zaehler filtern zusaetzlich explizit `where: { tenantId, userId }`, `getBytes`/`remove` pruefen den Besitz anwendungsseitig (`row.userId !== userId || row.tenantId !== tenantId` -> 404, nie 403) — zweites Netz, kein Ersatz, weil der RLS-Schalter heute aus ist. `select` der Liste/Upload-Antwort ohne `data` (Bytes nur ueber `GET :id`). |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | dashboard | muss-mandantengebunden | gebunden | quick-260923-ad9 — Reiter (mehrere Dashboards je Benutzer), `tenantId`-Spalte vorhanden, Regel `tenant_isolation_policy` MIT Benutzerdimension von Anfang an (Migration 20260923120000, Form aus 20260911120000/20260921120000). Task 1: `listDashboards` liest ueber `forTenant()` und legt bei Bedarf genau einen Reiter an (Transaktionssperre `pg_advisory_xact_lock` innerhalb `withTenantTransaction`, T-AD9-07); der Riegel `assertOwnedDashboard` liest ueber DENSELBEN, bereits gebundenen Klienten des Aufrufers (kein zweiter `forTenant()`-Aufruf) und wirft fuer "gibt es nicht", "gehoert einem Kollegen" und "liegt bei einem fremden Mandanten" dieselbe `NotFoundException` (T-AD9-01/02/03). Task 2: `createDashboard`/`renameDashboard` laufen als Einzeloperationen ueber `forTenant()`, je Methode ein Klient (Riegel zuerst bei `renameDashboard`). `deleteDashboard`/`reorderDashboards` laufen je als EINE `withTenantTransaction` (mehrschrittig, muss atomar sein) — `withTenantTransaction` setzt KEINE Benutzerdimension in der Sitzung, deshalb traegt jede Bedingung `userId` selbst (`tx.dashboard.deleteMany({where:{id,userId}})`, `tx.dashboard.updateMany({where:{id,userId},...})`), wortgleiches Muster zu `favorites.service.ts`/`reorder` (260917-jdd). `deleteDashboard` entfernt zusaetzlich die Kacheln (`tx.widgetInstance.deleteMany`) und die Anordnung (`tx.dashboardLayout.deleteMany`) des Reiters in DERSELBEN Transaktion. |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | dashboardLayout | muss-mandantengebunden | gebunden | Anordnung eines Reiters, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getLayout`/`saveLayout` GEMEINSAM ueber `forTenant()`, ein Klient je Methode; `saveLayout` uebersetzt eine `PrismaClientUnknownRequestError` (RLS-Konflikt, gemessen in Aufgabe 1 — NICHT die `P2002`-Form, die der Bereich `tenders` abfaengt) in eine deutsche Konfliktmeldung. quick-260923-ad9 (Task 1): die eindeutige Spalte ist jetzt `dashboardId` statt `userId` (D-02) — beide Methoden pruefen vorher ueber `assertOwnedDashboard`, dass der Reiter dem Aufrufer gehoert. |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | favoriteLink | muss-mandantengebunden | gebunden | quick-260923-lrr — nur LESEND, zum Aufraeumen hochgeladener Favoriten-Symbole: `removeWidget` und `deleteDashboard` lesen VOR dem Loeschen die Favoriten mit hochgeladenem Symbol (`findMany`, Bedingung traegt `userId` UND `widgetId`) ueber DENSELBEN, bereits gebundenen Klienten `tenantPrisma` der Methode (kein zweiter `forTenant()`-Aufruf). Die Zeilen selbst verschwinden ueber den Fremdschluessel-Kaskadenweg; danach werden die Dateien best effort entfernt, ein Dateifehler bricht das Loeschen nie ab. |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | module | keine-mandantengebundene-tabelle | ungebunden | Modulkatalog ist plattformweit, kein `tenantId` (Migration 20260909140000, Gruppe b). MESSUNG (260910-krx, Aufgabe 1, uebernommen aus `module-registry`-Pruefung `module-tabelle-traegt-keinen-zeilenschutz`): die Tabelle traegt heute keinen Zeilenschutz, eine Bindung waere heute wirkungslos, nicht katastrophal. BEDINGUNG: katastrophal wuerde sie erst, WENN Etappe 3 dieser Tabelle eine Regel gibt. Die Katalogaufloesung fuer den Widget-Modulfilter (`ModuleAccessService.getAccessibleModuleIds`) bindet bereits seit 260910-exd in ihrem eigenen Dienst — hier NICHT ein zweites Mal gebunden. |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | searchProvider | muss-mandantengebunden | gebunden | `tenantId` nullbar. WINDOWS #19 geschlossen (260910-jab) als **widerlegte Prämisse** für dieses Modell. In diesem Durchlauf (260910-krx, Aufgabe 1) EIGENSTAENDIG nachgeprueft, nicht aus 260910-jab abgeschrieben: `grep -rn "searchProvider\|SearchProvider" apps packages prisma --include=*.ts --include=*.mjs --include=*.js --include=*.sql --include=*.json` (ohne `node_modules`, `dist/`, `.next/`) findet weiterhin genau einen Schreibweg, `dashboard.service.ts:addSearchProvider` (`create`), mit `tenantId: string` als Pflichtparameter — keine Seed-Datei, kein Skript. Seit Aufgabe 2/3 laufen `getSearchProviders`/`addSearchProvider`/`removeSearchProvider` ueber `forTenant()`; die Regel auf `SearchProvider` bleibt UNVERAENDERT streng, zusaetzlich datenbankseitig verteidigt durch `searchprovider-gebundenes-einfuegen-ohne-mandant-abgelehnt` (Aufgabe 1). |
|
||||
| apps/api/src/dashboard/dashboard.service.ts | widgetInstance | muss-mandantengebunden | gebunden | Platzierte Kacheln eines Reiters, `tenantId`-Spalte vorhanden. Seit 260910-krx (Aufgabe 2) laufen `getWidgets`, `addWidget` sowie beide Paare aus Besitzpruefung und Schreibzugriff (`updateWidgetConfig`/`removeWidget`) ueber `forTenant()`; die vorgeschalteten Besitzpruefungen ueber die Benutzerkennung bleiben zusaetzlich bestehen (die Regeln dieses Bereichs kennen keine Benutzerdimension). Benutzerdimension seit 20260911120000 (260911-nke). quick-260923-ad9 (Task 1): `getWidgets`/`addWidget` filtern/schreiben ueber `dashboardId` statt `userId` (D-02) — `assertOwnedDashboard` prueft vorher, dass der Reiter dem Aufrufer gehoert. |
|
||||
|
||||
Reference in New Issue
Block a user