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-OperationenvercelBlobProvider.ts— vollständige Implementierung:upload(),download(),getSignedUrl(),delete(),exists(),getMetadata()storageFactory.ts—createStorageProvider()/getStorageProvider()(Singleton) /resetStorageProvider()für TestsfileValidation.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:
| Dateien | Store | Token | Inhalt |
|---|---|---|---|
isPublic: true | öffentlich | BLOB_READ_WRITE_TOKEN | unverschlüsselt |
| alle anderen | privat | BLOB_PRIVATE_READ_WRITE_TOKEN | verschlü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
| Umgebung | Variable |
|---|---|
| Production, lokal | FILE_ENCRYPTION_KEY |
| Preview | DEVELOP_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:
- Bisherigen Schlüssel zusätzlich als
FILE_ENCRYPTION_KEY_V1hinterlegen (Preview:DEVELOP_FILE_ENCRYPTION_KEY_V1). - Neuen Schlüssel als
FILE_ENCRYPTION_KEYhinterlegen undFILE_ENCRYPTION_KEY_VERSION=2setzen. - Neu deployen. Neue Dateien nutzen den neuen Schlüssel, vorhandene bleiben über
File.keyVersionlesbar.
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.
- 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.keyVersionvorkommende Version wird gebraucht. - Neu deployen, Variablen wirken erst danach.
- 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
| Enum | Werte |
|---|---|
FileCategory | STUDENT_DOCUMENT, TEACHER_DOCUMENT, CLASS_DOCUMENT, GUARDIAN_DOCUMENT, PROFILE_IMAGE, TEMP_UPLOAD, GENERAL |
FileVisibility | PRIVATE, TEACHERS_ONLY, CLASS, SCHOOL, PUBLIC |
FileAccessAction | UPLOAD, 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
| Kategorie | Kritisch | Aufbewahrung | Auto-Delete |
|---|---|---|---|
STUDENT_DOCUMENT | ✅ Ja | 10 Jahre | Nein |
TEACHER_DOCUMENT | ✅ Ja | 5 Jahre | Nein |
CLASS_DOCUMENT | ⚙️ Semi | 3 Jahre | Nein |
GUARDIAN_DOCUMENT | ✅ Ja | 10 Jahre | Nein |
PROFILE_IMAGE | Nein | 2 Jahre | ✅ Ja |
TEMP_UPLOAD | Nein | 1 Tag | ✅ Ja |
GENERAL | ⚙️ Konfigurierbar | 2 Jahre | ⚙️ Konfigurierbar |
Kritische Dateien werden nur per Soft-Delete markiert, niemals automatisch physisch gelöscht.
API-Endpoints
| Methode | Pfad | Beschreibung |
|---|---|---|
POST | /api/files/upload | Datei hochladen (FormData, max. 4,5 MB Body) |
GET | /api/files | Dateiliste mit Filtering (category, studentId, classId) und Pagination |
GET | /api/files/:id/download | Datei herunterladen (mit Permission-Check) |
GET | /api/files/public/:id | Öffentliche Datei abrufen (anonym, Redirect zur Blob-URL) |
GET | /api/admin/files | Admin: Alle Dateien auflisten (Filter: expired, critical, category) |
DELETE | /api/admin/files/:id | Admin: Datei löschen |
POST | /api/admin/files/:id/archive | Admin: Datei archivieren |
GET | /api/cron/check-expired-files | Cron: Abgelaufene Dateien markieren (täglich 02:00) |
GET | /api/cron/delete-non-critical-files | Cron: 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;
FileAccessLogprotokolliert jeden Vorgang - Cron-Jobs (täglich, via
vercel.json):check-expired-files(02:00): Markiert Dateien, deren Retention-Frist abgelaufen istdelete-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 ✅
| Phase | Inhalt |
|---|---|
| Phase 1 | Prisma-Schema (Modelle, Enums, Relationen, Indexierung), Environment-Funktionen |
| Phase 2 | Storage-Abstraction-Layer, Vercel Blob Provider, Factory Pattern, File-Validation |
| Phase 3 | Upload-Service (inkl. Multi-File), Download-Service, Permission-Service |
| Phase 4 | Alle API-Routes (Upload, Download, Public, List, Admin-Endpoints) |
| Phase 5 | Cron-Jobs (Expired-Check, Delete-Non-Critical), Admin-API, vercel.json |
| Phase 6 | FileUploadDialog, FileList (beide produktionsbereit) |
| Phase 7 | Technische Dokumentation |
Bereits erledigt (laufzeitbezogen)
@vercel/blob(^2.3.1) ist inpackage.jsoninstalliert.- Die Migration
20251128215910_add_file_storageist eingespielt (Tabellenfiles,file_access_logs). - Die Cron-Jobs sind in
vercel.jsonregistriert und im Vercel-Deployment aktiv.
Für den produktiven Betrieb müssen lediglich die Secrets gesetzt sein:
- Öffentlicher Vercel Blob Store + Token →
BLOB_READ_WRITE_TOKEN - Privater Vercel Blob Store + Token →
BLOB_PRIVATE_READ_WRITE_TOKEN - Hauptschlüssel →
FILE_ENCRYPTION_KEY(Preview:DEVELOP_FILE_ENCRYPTION_KEY) CRON_SECRETfü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