refactor(quick-260922-hk4): Bilderrahmen-Bilder in user-files statt in der Datenbank

- Bytes liegen unter user-files/dashboard-images/<userId>/<id>.<ext>, die
  Zeile haelt nur noch storagePath (Muster User.avatarPath)
- Dateiname immer servergeneriert: UUID der Zeile + Endung aus dem
  ERKANNTEN Mime-Typ, originalName kommt in keinem Pfad vor (T-HK4-01)
- Migration 20260922120000: storagePath dazu, data wird NULLbar, kein DROP
  (zweistufig, T-HK4-03); system_read_policy fuer den Umzug
- onApplicationBootstrap zieht Altbestand automatisch um: systemgebunden
  lesen, je Zeile mandantengebunden schreiben (Muster DKV-Planer)
- Upload nimmt die Zeile bei fehlgeschlagenem Schreiben zurueck, Loeschen
  entfernt die Datei mit, fehlende Datei -> 404 (T-HK4-04)
- 11 neue Dienst-Tests gegen ein echtes Temp-Verzeichnis (kein fs-Mock)
- Zugriffsklassifikation: Stand system-gebunden, Zahlen nachgemessen

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-22 15:22:47 +02:00
parent 441854af72
commit 9039cea686
6 changed files with 658 additions and 79 deletions
@@ -0,0 +1,59 @@
-- quick-260922-hk4 — Bilderrahmen-Bilder wandern aus der Datenbank in den
-- Dateibereich (Volume `user-files`).
--
-- Warum: 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, gegen eine heute 18 MB
-- grosse Datenbank (gemessen 22.09.2026 auf alpha). Die Bytes liegen ab
-- dieser Version unter
-- `user-files/dashboard-images/<userId>/<id>.<png|jpg|gif|webp>`; die Zeile
-- haelt nur noch den relativen Pfad in "storagePath" — dasselbe Muster wie
-- `User.avatarPath` (user.controller.ts) und die DKV-Ausfuhren
-- (dkv-export.service.ts). Der Dateiname ist IMMER servergeneriert (die
-- UUID der Zeile plus die Endung aus dem an den Magic Bytes ERKANNTEN
-- Mime-Typ); kein Byte aus der Anfrage, insbesondere nicht
-- "originalName", geht je in einen Pfad (T-HK4-01, Muster T-07-09).
--
-- ZWEISTUFIG, UND WARUM DIESE MIGRATION "data" NICHT LOESCHT (T-HK4-03):
-- Vorhandene Zeilen tragen ihre Bytes noch in "data". Der Umzug auf die
-- Platte passiert beim ersten Start dieser Version automatisch
-- (DashboardImagesService.onApplicationBootstrap, liest systemgebunden ueber
-- alle Mandanten, schreibt je Zeile mandantengebunden zurueck) — der Nutzer
-- muss nichts ausfuehren. Wuerde diese Migration die Spalte sofort
-- loeschen, laufen Migration und Umzug im selben Start in der falschen
-- Reihenfolge ("migrate deploy" laeuft VOR dem Anwendungsstart) und die
-- Bytes waeren weg, bevor sie jemand gelesen hat. Deshalb:
-- Stufe 1 (diese Migration): "storagePath" dazu (NULLbar), "data" bleibt
-- stehen und wird NULLbar, damit neue Uploads sie leer lassen.
-- Stufe 2 (spaetere Freigabe, Migration
-- 20260922120100_dashboard_image_drop_data, vorgemerkt in
-- .planning/todos/pending/): "storagePath" SET NOT NULL und
-- DROP COLUMN "data" — erst, wenn alpha UND live einmal mit
-- einer Version >= dieser gelaufen sind.
--
-- Das Prisma-Modell behaelt in Stufe 1 bewusst `data Bytes?` (optional).
-- Damit bleibt der Bootstrap-Umzug typisiert und braucht kein rohes SQL;
-- die Spalte verschwindet aus Modell und Tabelle gemeinsam in Stufe 2.
--
-- Rechte/Regeln: "tenant_isolation_policy" aus 20260921120000 bleibt
-- unveraendert. Kein DROP POLICY.
-- Relativer Pfad zur Monorepo-Wurzel, z. B.
-- "user-files/dashboard-images/<userId>/<id>.png". Stufe 2 macht die Spalte
-- NOT NULL.
ALTER TABLE "DashboardImage" ADD COLUMN "storagePath" TEXT;
-- Neue Uploads schreiben keine Bytes mehr in die Zeile; die Spalte bleibt
-- fuer die Dauer von Stufe 1 als Sicherheitsnetz erhalten.
ALTER TABLE "DashboardImage" ALTER COLUMN "data" DROP NOT NULL;
-- Systemkontext-Leserecht (Muster 20260914120000_rls_system_context_read):
-- der Bootstrap-Umzug liest die noch nicht umgezogenen Zeilen ueber ALLE
-- Mandanten (`forSystem()`), bevor er je Zeile mandantengebunden
-- zurueckschreibt. Ohne diese Regel saehe er nach dem Scharfschalten der
-- Datenbankrolle (Etappe 4, Schalter heute AUS) NULL Zeilen und stellte die
-- Arbeit stumm ein — genau die Falle, die 20260914120000 fuer die fuenf
-- Hintergrunddienst-Tabellen geschlossen hat. Permissiv und NUR FOR SELECT:
-- Schreiben bleibt allein der Mandantenregel unterstellt.
CREATE POLICY system_read_policy ON "DashboardImage"
FOR SELECT USING (is_system_context());
+15 -4
View File
@@ -212,12 +212,16 @@ model WidgetInstance {
@@index([tenantId])
}
// Bilderrahmen-Widget (quick-260921-pi9): hochgeladene Bilder eines Benutzers,
// als bytea in der Datenbank (kein Docker-Volume, die Sicherung deckt es mit
// ab). Keine Relation — wie WidgetInstance. Grenzen (5 MiB je Datei, 30 je
// Bilderrahmen-Widget (quick-260921-pi9): hochgeladene Bilder eines Benutzers.
// Keine Relation — wie WidgetInstance. Grenzen (5 MiB je Datei, 30 je
// Benutzer) und die Magic-Byte-Erkennung leben in
// src/dashboard/dashboard-image-rules.ts; Besitz = gleicher Mandant UND
// gleicher Benutzer (Regel in Migration 20260921120000 mit Benutzerdimension).
//
// quick-260922-hk4: die Bytes liegen jetzt im Dateibereich
// (user-files/dashboard-images/<userId>/<id>.<ext>), die Zeile haelt nur
// noch den relativen Pfad — Muster User.avatarPath. Die Datenbanksicherung
// (pg_dump) bleibt dadurch klein.
model DashboardImage {
id String @id @default(uuid())
userId String
@@ -225,7 +229,14 @@ model DashboardImage {
originalName String
mimeType String
size Int
data Bytes
// Stufe 1 der zweistufigen Umstellung (Migration 20260922120000): die
// Spalte bleibt NULLbar stehen, bis der Bootstrap-Umzug auf allen Servern
// gelaufen ist. Neue Uploads schreiben sie nie. DROP kommt mit
// 20260922120100 (vorgemerkt in .planning/todos/pending/).
data Bytes?
// Relativ zur Monorepo-Wurzel; NULL nur fuer Zeilen, die der
// Bootstrap-Umzug noch nicht angefasst hat. Wird in Stufe 2 NOT NULL.
storagePath String?
createdAt DateTime @default(now())
@@index([userId])