Files
schalli ee2b0256b5
Tessera CI/CD / Lint & Type Check (push) Successful in 50s
Tessera CI/CD / Tests (push) Successful in 1m16s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m4s
docs(quick-260922-hk4): Akte - Bilder im Dateibereich, Rundgang mit Selbstheilungs-Befund
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 15:36:04 +02:00

11 KiB
Raw Permalink Blame History

phase, plan, type, autonomous, subsystem, requirements
phase plan type autonomous subsystem requirements
quick-260922-hk4 01 tdd true apps/api/src/dashboard

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

Aufgabe 1: Schema, Migration, Dienst auf Dateiablage umstellen, Bootstrap-Umzug, Tests 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 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` 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 Migration angewendet, `storagePath` gefuellt fuer die vorhandenen lokalen Zeilen (Bootstrap nachgewiesen), Dateien liegen unter `user-files/dashboard-images//`. Spalte `data` bleibt vorerst bestehen (zweistufig, siehe Plan). API-Tests ≥ 8 neue Faelle, RLS-Waechter unveraendert gruen. Keine `any`, Zaehler unveraendert. Aufgabe 2: Changelog, Betriebsanleitung, Todo fuer die DROP-Migration, Voll-Tore CHANGELOG.md, docs/anleitung-betrieb.md, .planning/todos/pending/2026-09-22-dashboard-image-data-spalte-entfernen.md 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` cd /home/vicolab/projects/tessera-ctl && grep -q 'Dateibereich' CHANGELOG.md && pnpm type-check && pnpm lint && pnpm --filter @tessera/api test 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`.

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>