Zum Hauptinhalt springen

File Storage — Architektur

Überblick​

Das File Storage System ist fertig implementiert und produktiv: @vercel/blob ist als Dependency installiert (^2.3.1), die Prisma-Migration 20251128215910_add_file_storage ist eingespielt (Tabellen files und file_access_logs), und die DSGVO-Cron-Jobs sind über vercel.json aktiv.

Implementierte Bereiche: Datenbankschema, Storage-Abstraction, Core Services, API-Routes, DSGVO-Automatisierung (Cron-Jobs), Frontend-Komponenten und Dokumentation.

Für den Betrieb müssen nur noch die laufzeitbezogenen Secrets gesetzt sein (BLOB_READ_WRITE_TOKEN, BLOB_PRIVATE_READ_WRITE_TOKEN, FILE_ENCRYPTION_KEY, CRON_SECRET) — siehe Setup & Deployment.

Architektur​

Das System setzt auf einen Storage-Abstraction-Layer mit TypeScript-Interfaces, der Provider-Unabhängigkeit gewährleistet. Der aktive Provider ist Vercel Blob, eingebunden über ein Factory Pattern:

  • IStorageProvider — zentrales Interface für alle Storage-Operationen
  • vercelBlobProvider.ts — vollständige Implementierung: upload(), download(), getSignedUrl(), delete(), exists(), getMetadata()
  • storageFactory.ts — createStorageProvider() / getStorageProvider() (Singleton) / resetStorageProvider() für Tests
  • fileValidation.ts — MIME-Type-Prüfung, Größenlimit (4 MB für Vercel Hobby-Plan), sanitizeFilename(), generateStoragePath()

Durch den Abstraction-Layer ist eine spätere Migration zu einem anderen Provider (z. B. Cloudflare R2) ohne Änderungen an Services oder API-Routes möglich.

Dateischutz​

Vercel legt je Store fest, ob Dateien privat oder öffentlich sind. Es gibt daher zwei Stores:

DateienStoreTokenInhalt
isPublic: trueöffentlichBLOB_READ_WRITE_TOKENunverschlüsselt
alle anderenprivatBLOB_PRIVATE_READ_WRITE_TOKENverschlüsselt

lib/file/file-content.ts ist der einzige Weg zu Dateiinhalten: storeFileContent(), readFileContent() und deleteFileContent() wählen Store und Verschlüsselung selbst. Routen rufen den Storage-Provider nicht direkt für nicht öffentliche Dateien auf. Die Rechteprüfung bleibt Aufgabe des Aufrufers. Private Dateien liefert immer der Server aus, es gibt keine Direkt-URLs.

Verschlüsselung​

lib/storage/file-encryption.ts nutzt AES-256-GCM. Jede Datei bekommt einen eigenen Dateischlüssel, der mit dem Hauptschlüssel verschlüsselt in File.encryptedKey liegt. Inhalt und Dateischlüssel sind an die Datei-ID gebunden.

File.encryptedKey entscheidet über die Ablage: gesetzt bedeutet privater Store, null bedeutet öffentliche Datei oder noch nicht umgezogener Altbestand.

Schutzumfang: Wer Dateispeicher und Datenbank erlangt, kann die Dateien ohne Hauptschlüssel nicht lesen. Wer das Vercel-Konto übernimmt, kann mit eigenem Code an den Hauptschlüssel kommen. Das ist bewusst in Kauf genommen (siehe Ticket #127).

Hauptschlüssel​

UmgebungVariable
Production, lokalFILE_ENCRYPTION_KEY
PreviewDEVELOP_FILE_ENCRYPTION_KEY

Jede Umgebung hat einen eigenen Schlüssel: 32 zufällige Bytes als Base64, erzeugt mit openssl rand -base64 32, in Vercel als Secret gespeichert. Eine Kopie muss außerhalb von Vercel liegen. Geht der Hauptschlüssel verloren, sind alle damit verschlüsselten Dateien verloren.

Austausch:

  1. Bisherigen Schlüssel zusätzlich als FILE_ENCRYPTION_KEY_V1 hinterlegen (Preview: DEVELOP_FILE_ENCRYPTION_KEY_V1).
  2. Neuen Schlüssel als FILE_ENCRYPTION_KEY hinterlegen und FILE_ENCRYPTION_KEY_VERSION=2 setzen.
  3. Neu deployen. Neue Dateien nutzen den neuen Schlüssel, vorhandene bleiben über File.keyVersion lesbar.

Der alte Schlüssel muss erhalten bleiben, solange Dateien damit verschlüsselt sind. Ein Nachverschlüsseln vorhandener Dateischlüssel gibt es noch nicht, ein Austausch schützt also nur neue Dateien.

Wiederherstellung:

Fehlt der Hauptschlüssel in Vercel oder wurde er versehentlich überschrieben, schlagen Downloads verschlüsselter Dateien fehl. Der Inhalt selbst ist dadurch nicht verloren, solange die Kopie außerhalb von Vercel existiert.

  1. Kopie aus dem Passwortmanager unter demselben Variablennamen und für dieselbe Version wieder hinterlegen, als Typ Secret. Jede Umgebung nutzt ihren eigenen Schlüssel, und jede in File.keyVersion vorkommende Version wird gebraucht.
  2. Neu deployen, Variablen wirken erst danach.
  3. Eine Bestandsdatei herunterladen. Kommt ein Fehler, passt der Schlüssel nicht zu Umgebung oder Version.

Datenbank und privater Store gehören zusammen: Der Dateischlüssel liegt in File.encryptedKey, der Inhalt im Store. Wird die Datenbank auf einen älteren Stand zurückgesetzt, sind danach hochgeladene Dateien nicht mehr lesbar. Ihr Dateischlüssel fehlt dann, auch wenn der Inhalt noch im Store liegt.

Altbestand umziehen​

pnpm files:migrate-private

Das zählt nur. Mit pnpm files:migrate-private --apply zieht das Skript die Dateien um: Es liest, verschlüsselt, schreibt in den privaten Store, prüft das Ergebnis und stellt erst dann den File-Eintrag um. Zum Schluss entfernt es die Kopien aus dem öffentlichen Store. Der Lauf ist wiederholbar und beendet sich bei Fehlern mit Exit-Code 1.

Vor dem Lauf muss der neue Code in der Umgebung live sein. Variablen kommen aus der Shell oder aus webapp/.env.

Projektstruktur​

webapp/
├── prisma/
│ └── schema.prisma ✅ (File + FileAccessLog Models)
│
├── lib/
│ ├── storage/
│ │ ├── types.ts ✅
│ │ ├── storageFactory.ts ✅
│ │ ├── fileValidation.ts ✅
│ │ └── providers/
│ │ └── vercelBlobProvider.ts ✅
│ │
│ ├── file/
│ │ ├── fileUploadService.ts ✅
│ │ ├── fileDownloadService.ts ✅
│ │ └── filePermissionService.ts ✅
│ │
│ └── environment-handling.ts ✅ (erweitert)
│
├── app/
│ ├── api/
│ │ ├── files/
│ │ │ ├── route.ts ✅ (List)
│ │ │ ├── upload/route.ts ✅
│ │ │ ├── [id]/download/route.ts ✅
│ │ │ └── public/[id]/route.ts ✅
│ │ │
│ │ ├── admin/files/
│ │ │ ├── route.ts ✅
│ │ │ └── [id]/
│ │ │ ├── route.ts ✅ (Delete)
│ │ │ └── archive/route.ts ✅
│ │ │
│ │ └── cron/
│ │ ├── check-expired-files/route.ts ✅
│ │ └── delete-non-critical-files/route.ts ✅
│ │
│ └── admin/files/page.tsx ⚙️ (UI noch nicht angelegt — optional)
│
├── components/file/
│ ├── FileUploadDialog.tsx ✅
│ └── FileList.tsx ✅
│
└── vercel.json ✅ (Cron-Config)

Datenmodell​

Prisma-Modelle​

File — Kernobjekt für jede hochgeladene Datei. Relationen zu User, Student, SchoolClass, Tenant. Indexierung auf häufig abgefragte Felder.

FileAccessLog — Audit-Trail jedes Zugriffs (Download, Upload, Delete, Archive). Speichert IP-Adresse und User-Agent.

Enums​

EnumWerte
FileCategorySTUDENT_DOCUMENT, TEACHER_DOCUMENT, CLASS_DOCUMENT, GUARDIAN_DOCUMENT, PROFILE_IMAGE, TEMP_UPLOAD, GENERAL
FileVisibilityPRIVATE, TEACHERS_ONLY, CLASS, SCHOOL, PUBLIC
FileAccessActionUPLOAD, DOWNLOAD, DELETE, ARCHIVE, VIEW

Retention-Konfiguration​

Retention-Fristen werden über Environment-Variablen gesteuert (Standardwerte in Klammern):

RETENTION_STUDENT_DOCS=3650   # 10 Jahre
RETENTION_TEACHER_DOCS=1825 # 5 Jahre
RETENTION_CLASS_DOCS=1095 # 3 Jahre
RETENTION_GUARDIAN_DOCS=3650 # 10 Jahre
RETENTION_PROFILE_IMAGE=730 # 2 Jahre
RETENTION_TEMP_UPLOAD=1 # 1 Tag
RETENTION_GENERAL=730 # 2 Jahre

Dateikategorien & Aufbewahrung​

KategorieKritischAufbewahrungAuto-Delete
STUDENT_DOCUMENT✅ Ja10 JahreNein
TEACHER_DOCUMENT✅ Ja5 JahreNein
CLASS_DOCUMENT⚙️ Semi3 JahreNein
GUARDIAN_DOCUMENT✅ Ja10 JahreNein
PROFILE_IMAGENein2 Jahre✅ Ja
TEMP_UPLOADNein1 Tag✅ Ja
GENERAL⚙️ Konfigurierbar2 Jahre⚙️ Konfigurierbar

Kritische Dateien werden nur per Soft-Delete markiert, niemals automatisch physisch gelöscht.

API-Endpoints​

MethodePfadBeschreibung
POST/api/files/uploadDatei hochladen (FormData, max. 4,5 MB Body)
GET/api/filesDateiliste mit Filtering (category, studentId, classId) und Pagination
GET/api/files/:id/downloadDatei herunterladen (mit Permission-Check)
GET/api/files/public/:idÖffentliche Datei abrufen (anonym, Redirect zur Blob-URL)
GET/api/admin/filesAdmin: Alle Dateien auflisten (Filter: expired, critical, category)
DELETE/api/admin/files/:idAdmin: Datei löschen
POST/api/admin/files/:id/archiveAdmin: Datei archivieren
GET/api/cron/check-expired-filesCron: Abgelaufene Dateien markieren (täglich 02:00)
GET/api/cron/delete-non-critical-filesCron: Unkritische Dateien löschen (täglich 03:00)

DSGVO-Compliance​

  • Automatische Retention-Berechnung beim Upload — Ablaufdatum wird direkt gesetzt
  • Kategorisierung kritisch/unkritisch — bestimmt das Löschverhalten
  • Soft-Delete mit Audit-Trail — Dateien werden nicht sofort physisch entfernt; FileAccessLog protokolliert jeden Vorgang
  • Cron-Jobs (täglich, via vercel.json):
    • check-expired-files (02:00): Markiert Dateien, deren Retention-Frist abgelaufen ist
    • delete-non-critical-files (03:00): Löscht physisch unkritische abgelaufene Dateien (Batch: max. 100)
  • Anonymisierung: Access-Logs bei anonymen Zugriffen ohne User-Referenz
  • Multi-Tenant-Isolation: Jede Datei ist einem Tenant zugeordnet; kein mandantenübergreifender Zugriff

Frontend-Komponenten​

FileUploadDialog (components/file/FileUploadDialog.tsx)​

Dialog für Datei-Uploads mit:

  • Single- und Multi-File-Upload, Drag & Drop
  • Auswahl von Kategorie und Sichtbarkeit (Visibility)
  • MIME-Type- und Größenvalidierung (4 MB, mit Hinweis auf Hobby-Plan-Limit)
  • Progress-Anzeige und Fehlerbehandlung

FileList (components/file/FileList.tsx)​

Tabellenansicht vorhandener Dateien mit:

  • Lade- und Leer-Zustand
  • Download-Button (löst Permission-Check aus)
  • Filterunterstützung (category, studentId, classId)
  • Formatierte Dateigröße

Beide Komponenten sind fertig und können direkt eingebunden werden (z. B. in Schüler- oder Lehrerdetailseiten).

Implementierungsstatus​

Abgeschlossen ✅​

PhaseInhalt
Phase 1Prisma-Schema (Modelle, Enums, Relationen, Indexierung), Environment-Funktionen
Phase 2Storage-Abstraction-Layer, Vercel Blob Provider, Factory Pattern, File-Validation
Phase 3Upload-Service (inkl. Multi-File), Download-Service, Permission-Service
Phase 4Alle API-Routes (Upload, Download, Public, List, Admin-Endpoints)
Phase 5Cron-Jobs (Expired-Check, Delete-Non-Critical), Admin-API, vercel.json
Phase 6FileUploadDialog, FileList (beide produktionsbereit)
Phase 7Technische Dokumentation

Bereits erledigt (laufzeitbezogen)​

  • @vercel/blob (^2.3.1) ist in package.json installiert.
  • Die Migration 20251128215910_add_file_storage ist eingespielt (Tabellen files, file_access_logs).
  • Die Cron-Jobs sind in vercel.json registriert und im Vercel-Deployment aktiv.

Für den produktiven Betrieb müssen lediglich die Secrets gesetzt sein:

  1. Öffentlicher Vercel Blob Store + Token → BLOB_READ_WRITE_TOKEN
  2. Privater Vercel Blob Store + Token → BLOB_PRIVATE_READ_WRITE_TOKEN
  3. Hauptschlüssel → FILE_ENCRYPTION_KEY (Preview: DEVELOP_FILE_ENCRYPTION_KEY)
  4. CRON_SECRET für die Cron-Endpunkte

Optional / noch offen ⚙️​

  • app/admin/files/page.tsx — zentrale Admin-UI für Dateiverwaltung (noch nicht angelegt)
  • Unit- und E2E-Tests für Services und Permission-Checks
  • Tiefere Frontend-Integration in Schüler-, Lehrer- und Klassenseiten

Vercel Hobby-Plan Limits​

Das System ist auf den Hobby-Plan ausgelegt:

  • Maximale Dateigröße: 4 MB (Body-Limit: 4,5 MB)
  • Geeignet für: Zeugnisse, Bescheinigungen, Profilbilder, Standard-PDFs
  • Nicht geeignet für: Dateien > 4 MB (große PDFs, hochauflösende Scans)
  • Upgrade auf Vercel Pro erhöht das Limit auf 50 MB