docs(quick-260922-hk4): Akte - Bilder im Dateibereich, Rundgang mit Selbstheilungs-Befund
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
---
|
||||
phase: quick-260922-hk4
|
||||
plan: 01
|
||||
type: tdd
|
||||
autonomous: true
|
||||
subsystem: apps/api/src/dashboard
|
||||
requirements: []
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260922-hk4: Bilderrahmen-Bilder auf die Festplatte statt in die Datenbank
|
||||
|
||||
## Warum (Entscheidung des Nutzers, 22.09.2026)
|
||||
|
||||
Der Bilderrahmen legte die Bilddaten als `bytea` in der Datenbank ab
|
||||
(quick-260921-pi9). Der Nutzer hat nach der Freigabe 1.3.0 gefragt, ob das auf
|
||||
Dauer sinnvoll ist. Befund und Entscheidung:
|
||||
|
||||
- **Geschwindigkeit ist NICHT das Argument.** Ein Bild wird je Browser einmal
|
||||
taeglich geladen (`Cache-Control: private, max-age=86400`); ein paar hundert
|
||||
Kilobyte aus Postgres kosten nichts gegen die uebrige Last.
|
||||
- **Die Sicherung ist das Argument.** Gesichert wird von Hand per `pg_dump`
|
||||
(docs/anleitung-betrieb.md Kap. 6). Jedes Bild waechst in diesen Abzug hinein:
|
||||
30 Bilder à 5 MiB je Benutzer sind im Extremfall 150 MB **pro Benutzer**. Die
|
||||
alpha-Datenbank ist heute 18 MB gross (gemessen 22.09.), da faellt das sofort auf.
|
||||
- **Einheitlichkeit.** Tessera speichert Dateien laengst im Volume `user-files`:
|
||||
Profilbilder unter `user-files/avatars/<userId>.<ext>` mit `User.avatarPath` in
|
||||
der Datenbank (`user.controller.ts`), DKV-Exporte daneben mit ausschliesslich
|
||||
servergenerierten Dateinamen (`dkv-export.service.ts`). Der Bilderrahmen war der
|
||||
Ausreisser.
|
||||
- **Ein eigener Ordner je Benutzer ist KEIN Schutz.** Wer welches Bild sehen darf,
|
||||
entscheidet weiterhin der Server (Besitzpruefung + RLS-Regel). Getrennte Ordner
|
||||
bringen zusaetzlich die Gefahr von Dateinamen, die aus dem Ordner herausfuehren —
|
||||
dagegen hilft nur, was DKV schon macht: der Server vergibt den Dateinamen, nie
|
||||
der Client.
|
||||
|
||||
Bestand: alpha 3 Bilder / 1,8 MB, Live 0 (noch nicht gezogen), lokal 1–2. Der
|
||||
Umzug ist jetzt praktisch kostenlos.
|
||||
|
||||
## Gebundene Entscheidungen (Orchestrator)
|
||||
|
||||
1. **Ablage:** `user-files/dashboard-images/<userId>/<imageId>.<ext>` — ein Ordner
|
||||
je Benutzer, Dateiname ist die UUID der Datenbankzeile plus Endung aus dem
|
||||
ERKANNTEN Mime-Typ (`png|jpg|gif|webp`). Kein Byte aus der Anfrage geht in den
|
||||
Pfad. Verzeichnis-Aufloesung nach dem Muster `resolveAvatarsDir()`
|
||||
(`path.resolve(__dirname, '..', '..', '..', '..', 'user-files', ...)`), als
|
||||
eigene Funktion `resolveDashboardImagesDir()` im Dienst.
|
||||
2. **Datenbank:** Spalte `data Bytes` entfaellt, neu `storagePath String` (relativ
|
||||
zur Monorepo-Wurzel, wie `User.avatarPath`: `user-files/dashboard-images/...`).
|
||||
Rest der Zeile unveraendert (id, userId, tenantId, originalName, mimeType, size,
|
||||
createdAt), RLS-Regel und Indizes bleiben.
|
||||
3. **Migration `20260922120000_dashboard_image_to_disk`** in zwei Schritten, weil
|
||||
die vorhandenen Bytes nicht verloren gehen duerfen:
|
||||
- SQL-Migration: `ALTER TABLE "DashboardImage" ADD COLUMN "storagePath" TEXT;`
|
||||
(erst NULLbar), **nicht** sofort `DROP COLUMN "data"`.
|
||||
- Einmal-Skript `apps/api/scripts/migrate-dashboard-images-to-disk.ts`
|
||||
(ausfuehrbar per `pnpm --filter @tessera/api exec tsx scripts/...`, tsx ist
|
||||
vorhanden — sonst `ts-node`/kompiliertes JS; pruefen): liest alle Zeilen mit
|
||||
`data IS NOT NULL`, schreibt die Datei, setzt `storagePath`, laesst `data`
|
||||
stehen. Idempotent (vorhandene Datei + gesetzter `storagePath` = ueberspringen).
|
||||
- Zweite SQL-Migration `20260922120100_dashboard_image_drop_data`:
|
||||
`ALTER TABLE "DashboardImage" ALTER COLUMN "storagePath" SET NOT NULL;` und
|
||||
`ALTER TABLE "DashboardImage" DROP COLUMN "data";`.
|
||||
**Reihenfolge fuer den Betrieb dokumentieren:** beide Migrationen laufen beim
|
||||
Start automatisch (`migrate deploy`), das Umzugs-Skript liegt DAZWISCHEN. Damit
|
||||
das ohne Handarbeit klappt, macht der Dienst den Umzug selbst: siehe Punkt 4.
|
||||
4. **Automatischer Umzug beim Start statt Handarbeit** (der Nutzer soll nichts
|
||||
ausfuehren muessen): `DashboardImagesService` bekommt `onApplicationBootstrap()`,
|
||||
das alle Zeilen ohne `storagePath` einsammelt, die Bytes per rohem SQL liest
|
||||
(`$queryRaw` auf `data`, weil die Spalte dann nicht mehr im Prisma-Modell steht —
|
||||
deshalb liegt der DROP in einer SPAETEREN Migration, die erst in der naechsten
|
||||
Freigabe scharf geschaltet wird), die Datei schreibt und `storagePath` setzt.
|
||||
**Konsequenz fuer diese Aufgabe: die DROP-Migration wird NICHT mitgeliefert.**
|
||||
Sie bekommt einen Platzhalter-Eintrag in `.planning/todos/pending/` und kommt,
|
||||
wenn alle Server einmal mit dieser Version gelaufen sind. Begruendung im
|
||||
Migrations-Kommentar festhalten (Muster: zweistufige Umstellung).
|
||||
Der Bootstrap laeuft ueber den Systemkontext (`forSystem()`, Muster
|
||||
`dkv`-Scheduler), nicht ueber einen Mandantenklienten, und protokolliert
|
||||
„N Bilder auf die Festplatte umgezogen" bzw. schweigt bei 0.
|
||||
5. **Dienst:** `upload` schreibt die Datei (`fs.promises.mkdir(..., {recursive:true})`
|
||||
+ `writeFile`) NACH dem erfolgreichen `create` (Reihenfolge: Zeile zuerst, damit
|
||||
die UUID feststeht; schlaegt das Schreiben fehl, Zeile wieder loeschen und
|
||||
`InternalServerErrorException`). `getBytes` liest die Datei und liefert
|
||||
`{ mimeType, data }` wie bisher; fehlt die Datei, `NotFoundException` (Kachel
|
||||
zeigt dann „Bild nicht verfügbar", schon gebaut). `remove` loescht Zeile und
|
||||
Datei (Datei-Fehler werden geschluckt und protokolliert — eine Dateileiche ist
|
||||
harmloser als eine haengende Loeschung). `list` unveraendert.
|
||||
Der Controller bleibt unveraendert (gleiche Routen, gleiche fuenf Header).
|
||||
6. **Betriebsanleitung:** in Kapitel 6 den Satz zu `user-files` um die
|
||||
Bilderrahmen-Bilder ergaenzen (dort steht schon, wie das Volume gesichert wird);
|
||||
im Anwenderhandbuch nichts aendern (fuer Anwender aendert sich nichts).
|
||||
CHANGELOG unter „Unveröffentlicht → Geändert": „Bilderrahmen: hochgeladene
|
||||
Bilder liegen jetzt im Dateibereich des Servers statt in der Datenbank — die
|
||||
Datenbanksicherung bleibt dadurch klein; vorhandene Bilder ziehen beim ersten
|
||||
Start automatisch um" (kein Fliesstext).
|
||||
7. **Tests:** Dienst-Tests mit `memfs` ODER einem temporaeren Verzeichnis
|
||||
(`fs.mkdtempSync(os.tmpdir())`) — pruefen, was im Repo schon genutzt wird
|
||||
(`user.controller.spec.ts` fuer Avatare ansehen und demselben Muster folgen).
|
||||
Mindestens: Upload legt Datei unter `<dir>/<userId>/<id>.png` an und speichert
|
||||
`storagePath`; Upload mit fehlschlagendem Schreiben loescht die Zeile wieder;
|
||||
`getBytes` liefert den Dateiinhalt; fehlende Datei → 404; fremder Benutzer → 404
|
||||
(unveraendert); `remove` loescht Zeile und Datei; Dateiname enthaelt NIE
|
||||
`originalName`; Bootstrap-Umzug schreibt Datei und setzt `storagePath`,
|
||||
ueberspringt bereits umgezogene Zeilen.
|
||||
|
||||
## Aufgaben
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Aufgabe 1: Schema, Migration, Dienst auf Dateiablage umstellen, Bootstrap-Umzug, Tests</name>
|
||||
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260922120000_dashboard_image_to_disk/migration.sql, apps/api/src/dashboard/dashboard-images.service.ts, apps/api/src/dashboard/dashboard-images.service.spec.ts, apps/api/src/dashboard/dashboard-images.controller.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md</files>
|
||||
<action>
|
||||
Entscheidungen 1–5 umsetzen. Reihenfolge: Schema + Migration, `prisma migrate deploy` + `generate` gegen die lokale Container-DB ([BLOCKING], Befehle in den Executor-Hinweisen), dann Tests rot, dann Dienst.
|
||||
Das Klassifikationsdokument braucht keine neue Zeile (Modell unveraendert gebunden), aber die Begruendungsspalte erwaehnt jetzt, dass die Bytes auf der Platte liegen und die Zeile den Pfad haelt — Zahlen nachmessen wie dort beschrieben.
|
||||
Commit: `refactor(quick-260922-hk4): Bilderrahmen-Bilder in user-files statt in der Datenbank`
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/dashboard src/prisma && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/api lint</automated>
|
||||
</verify>
|
||||
<done>Migration angewendet, `storagePath` gefuellt fuer die vorhandenen lokalen Zeilen (Bootstrap nachgewiesen), Dateien liegen unter `user-files/dashboard-images/<userId>/`. Spalte `data` bleibt vorerst bestehen (zweistufig, siehe Plan). API-Tests ≥ 8 neue Faelle, RLS-Waechter unveraendert gruen. Keine `any`, Zaehler unveraendert.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Aufgabe 2: Changelog, Betriebsanleitung, Todo fuer die DROP-Migration, Voll-Tore</name>
|
||||
<files>CHANGELOG.md, docs/anleitung-betrieb.md, .planning/todos/pending/2026-09-22-dashboard-image-data-spalte-entfernen.md</files>
|
||||
<action>
|
||||
Entscheidung 6 umsetzen. Das Todo nennt: DROP der Spalte `data` erst, wenn alpha UND live einmal mit einer Version ≥ dieser gelaufen sind (Bootstrap-Umzug erledigt), Migrationsname `20260922120100_dashboard_image_drop_data`, plus `ALTER COLUMN "storagePath" SET NOT NULL`.
|
||||
Volle Tore: `pnpm type-check`, `pnpm lint`, `pnpm --filter @tessera/api test`, `pnpm --filter @tessera/web test`.
|
||||
Commit: `docs(quick-260922-hk4): Changelog, Betriebsanleitung und Todo zur data-Spalte`
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && grep -q 'Dateibereich' CHANGELOG.md && pnpm type-check && pnpm lint && pnpm --filter @tessera/api test</automated>
|
||||
</verify>
|
||||
<done>Changelog-Zeile steht unter „Unveröffentlicht → Geändert"; Betriebsanleitung Kap. 6 nennt die Bilderrahmen-Bilder beim `user-files`-Volume; Todo angelegt; alle Tore gruen; genau zwei Commits mit Scope `quick-260922-hk4`.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
## Hinweise fuer den Executor
|
||||
|
||||
- Lokale Migration: `IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1)`, dann
|
||||
`DATABASE_URL="postgresql://tessera:tessera_dev@$IP:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy` und `prisma generate`.
|
||||
- Testserver NICHT anfassen.
|
||||
- Commits: Conventional Commits, Scope `quick-260922-hk4`, deutscher Betreff im Stil von `git log --oneline -15`, jede Commit-Nachricht endet mit
|
||||
`Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>`
|
||||
- `.planning/**` NICHT committen ausser der Todo-Datei in Aufgabe 2.
|
||||
- Qualitaetsregeln wie bisher: keine neue `any`, `as unknown as` api bleibt 27, keine `!`, kein `biome-ignore`.
|
||||
|
||||
<threat_model>
|
||||
ASVS 1, block on high.
|
||||
|
||||
| ID | Bedrohung | Schwere | Disposition |
|
||||
|---|---|---|---|
|
||||
| T-HK4-01 | Pfad-Ausbruch ueber `originalName` oder Kennung aus der Anfrage | high | Dateiname = UUID der Zeile + Endung aus dem ERKANNTEN Mime-Typ; `originalName` geht nie in den Pfad (Muster DKV T-07-09). Mitigiert. |
|
||||
| T-HK4-02 | Fremdzugriff auf Bilder ueber geratene Pfade | high | Die Datei wird nie direkt ausgeliefert; nur ueber `GET /dashboard/images/:id` mit Besitzpruefung (Mandant + Benutzer) und 404 fuer Fremde. Das Volume ist nicht im Webserver eingehaengt. Mitigiert. |
|
||||
| T-HK4-03 | Datenverlust beim Umzug | high | Zweistufig: `data` bleibt vorerst stehen, Umzug ist idempotent, DROP erst nach nachgewiesenem Lauf auf beiden Servern (Todo). Mitigiert. |
|
||||
| T-HK4-04 | Halbe Zustaende (Zeile ohne Datei / Datei ohne Zeile) | medium | Upload: Zeile zuerst, bei Schreibfehler Zeile loeschen; Loeschen: Zeile zuerst, Dateifehler wird protokolliert (Dateileiche statt haengender Loeschung); fehlende Datei = 404, die Kachel zeigt „Bild nicht verfügbar". Akzeptiert und benannt. |
|
||||
| T-HK4-05 | Volume geht verloren, Datenbank ueberlebt | low | Bewusst akzeptiert (Entscheidung des Nutzers); Betriebsanleitung nennt die Sicherung des Volumes. |
|
||||
</threat_model>
|
||||
Reference in New Issue
Block a user