import 'server-only' import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client, type ServerSideEncryption, } from '@aws-sdk/client-s3' import { env } from '@/lib/env' import { dateiRoutePfad, schluesselErfordern, zugriffTokenErzeugen, TOKEN_STANDARD_SEKUNDEN, } from '@/lib/speicher/schluessel' import { entschluesselnBinaer, verschluesselnBinaer } from '@/lib/speicher/umschlag' /** * Treiber "s3": S3-kompatibler Objektspeicher, betrieben in der EU. * * Voraussetzung fuer den Einsatz ist ein Auftragsverarbeitungsvertrag nach * Art. 28 DSGVO mit dem Anbieter - hier liegen Gesundheits- und * Finanzdaten. Ohne diesen Vertrag bleibt es beim Dateitreiber. * * ## Anwendungsseitige Verschluesselung * * Frueher stand hier nur ServerSideEncryption: 'AES256'. Das ist * Verschluesselung mit dem Schluessel des Anbieters: Er entschluesselt beim * Ausliefern selbst. Gegen eine verlorene Festplatte im Rechenzentrum hilft * das, gegen den Anbieter und gegen gestohlene Zugangsdaten zum Eimer * nicht - und in diesem Eimer liegen Bescheide, Arztbriefe und * Kontoauszuege. * * Deshalb liegt jetzt derselbe Umschlag darum wie beim Dateitreiber, mit * einem Schluessel aus der Umgebung. * * Die serverseitige Verschluesselung ist damit ueberfluessig und wird nur * noch gesendet, wenn S3_SSE gesetzt ist. Im Kommentar stand hier einmal, * sie "kostet nichts" - das war falsch: Das mitgelieferte MinIO laeuft * ohne KMS und lehnte jedes PutObject mit "NotImplemented" ab. Sie kostete * die gesamte Funktion. * * ## Und deshalb keine presigned URLs mehr * * Eine presigned URL laesst den Browser direkt beim Objektspeicher laden. * Was dort liegt, ist jetzt aber Geheimtext - der Browser bekaeme * unlesbare Bytes. Die Auslieferung geht darum ueber dieselbe eigene Route * wie beim Dateitreiber: Sie prueft zusaetzlich die Berechtigung des * angemeldeten Benutzers und schreibt einen Audit-Eintrag. Dass jeder * Abruf durch die Anwendung laeuft, ist bei einer Handvoll Dokumenten am * Tag kein Thema. */ let klient: S3Client | null = null function s3(): S3Client { if (klient) return klient const k = env.s3 klient = new S3Client({ region: k.region, endpoint: k.endpoint, // Viele S3-kompatible Anbieter (MinIO, Hetzner) brauchen Pfad-Stil. forcePathStyle: true, credentials: { accessKeyId: k.accessKeyId, secretAccessKey: k.secretAccessKey }, }) return klient } function eimer(): string { return env.s3.bucket } /** * Legt ein Objekt ab - verschluesselt. * * Der gemeldete MIME-Typ wird nicht mehr gebraucht: Was hier landet, ist * Geheimtext und wird als application/octet-stream ausgezeichnet. Der * Parameter bleibt in der Signatur, damit die Fassade beide Treiber gleich * aufrufen kann. */ export async function schreiben( schluessel: string, inhalt: Buffer, _mimeTyp: string, ): Promise { const geprueft = schluesselErfordern(schluessel) const sse = env.s3.serverseitigeVerschluesselung await s3().send( new PutObjectCommand({ Bucket: eimer(), Key: geprueft, Body: verschluesselnBinaer(inhalt, geprueft), // Bewusst nicht der echte Typ: Was hier liegt, ist Geheimtext. Ein // "application/pdf" darauf waere eine Falschauskunft, und der einzige // Weg zum Inhalt fuehrt ohnehin durch die Anwendung. ContentType: 'application/octet-stream', // Auch beim direkten Abruf niemals im Browser darstellen. ContentDisposition: 'attachment', // Nur mitschicken, wenn konfiguriert - siehe env.s3. ...(sse ? { ServerSideEncryption: sse as ServerSideEncryption } : {}), }), ) } export async function lesen(schluessel: string): Promise { const geprueft = schluesselErfordern(schluessel) const antwort = await s3().send(new GetObjectCommand({ Bucket: eimer(), Key: geprueft })) if (!antwort.Body) throw new Error(`Objekt ${geprueft} ist leer.`) const bytes = await antwort.Body.transformToByteArray() // Objekte, die vor dieser Aenderung abgelegt wurden, tragen keinen // Umschlag und kommen unveraendert zurueck. Der Migrationslauf // (npm run speicher:verschluesseln) schreibt sie nach und nach um. return entschluesselnBinaer(Buffer.from(bytes), geprueft) } export async function entfernen(schluessel: string): Promise { const geprueft = schluesselErfordern(schluessel) await s3().send(new DeleteObjectCommand({ Bucket: eimer(), Key: geprueft })) } export async function existiert(schluessel: string): Promise { const geprueft = schluesselErfordern(schluessel) try { await s3().send(new HeadObjectCommand({ Bucket: eimer(), Key: geprueft })) return true } catch { return false } } /** * Zeigt auf die eigene, signierte Route - nicht mehr auf den * Objektspeicher. * * Der direkte Weg zum Anbieter lieferte den Rohinhalt aus. Solange der * Klartext war, ging das; jetzt ist es Geheimtext, und entschluesseln kann * nur die Anwendung. Der Nebeneffekt ist erwuenscht: Die Route prueft die * Berechtigung des angemeldeten Benutzers und schreibt einen Audit-Eintrag. * Ein weitergeleiteter Link allein oeffnet also nichts mehr. */ export async function signierteUrl( schluessel: string, sekunden: number = TOKEN_STANDARD_SEKUNDEN, ): Promise { const geprueft = schluesselErfordern(schluessel) return dateiRoutePfad(zugriffTokenErzeugen(geprueft, sekunden)) } // ----------------------------------------------------------------------------- // Wartung // ----------------------------------------------------------------------------- // // Nur fuer den Migrationslauf, der bestehende Objekte nachtraeglich in den // Umschlag steckt. Bewusst hier und nicht im Skript: Der Zugang zum Eimer // wird an genau einer Stelle aufgebaut. /** Alle Ablageschluessel im Eimer, seitenweise geholt. */ export async function objekteAuflisten(): Promise { const gefunden: string[] = [] let weiter: string | undefined do { const antwort = await s3().send( new ListObjectsV2Command({ Bucket: eimer(), ContinuationToken: weiter }), ) for (const eintrag of antwort.Contents ?? []) { if (eintrag.Key) gefunden.push(eintrag.Key) } weiter = antwort.IsTruncated ? antwort.NextContinuationToken : undefined } while (weiter) return gefunden } /** * Legt Bytes ohne Umschlag ab. Nur fuer die Bereitschaftspruefung. * * Bewusst ohne schluesselErfordern(): Der Probeschluessel gehoert zu * keiner betreuten Person und faellt durch das Muster. */ export async function rohSchreiben(schluessel: string, inhalt: Buffer): Promise { await s3().send( new PutObjectCommand({ Bucket: eimer(), Key: schluessel, Body: inhalt, ContentType: 'application/octet-stream', ContentDisposition: 'attachment', }), ) } /** Raeumt die Probedatei wieder weg. */ export async function rohEntfernen(schluessel: string): Promise { await s3().send(new DeleteObjectCommand({ Bucket: eimer(), Key: schluessel })) } /** Der Inhalt, wie er im Eimer liegt - ohne den Umschlag abzunehmen. */ export async function rohLesen(schluessel: string): Promise { const antwort = await s3().send(new GetObjectCommand({ Bucket: eimer(), Key: schluessel })) if (!antwort.Body) throw new Error(`Objekt ${schluessel} ist leer.`) return Buffer.from(await antwort.Body.transformToByteArray()) }