import "server-only"; import { createHash, randomUUID } from "node:crypto"; import { Readable } from "node:stream"; import { Client as MinioClient } from "minio"; import { env } from "@/lib/env"; /** * Der einzige Weg zu Dateien. * * Regel aus CLAUDE.md: Dateizugriff ausschließlich über lib/storage.ts. Kein * fs.readFile, kein Pfad im Dateisystem, keine Blobs in der Datenbank. Alles * liegt in MinIO — dadurch überlebt eine Datei den Neuaufbau des Containers, * lässt sich versionieren und getrennt sichern. * * Objektschlüssel folgen immer demselben Muster: * ///- * Der Slug vorn macht Sicherungen und Löschungen je Wehr einfach. */ let clientCache: MinioClient | null = null; function client(): MinioClient { if (!clientCache) { clientCache = new MinioClient({ endPoint: env.S3_ENDPOINT, port: env.S3_PORT, useSSL: env.S3_USE_SSL, accessKey: env.S3_ACCESS_KEY, secretKey: env.S3_SECRET_KEY, region: env.S3_REGION, }); } return clientCache; } export const BUCKET = env.S3_BUCKET; /** Zeichen, die in Objektschlüsseln Ärger machen, werden ersetzt. */ function dateinameBereinigen(name: string): string { return name .normalize("NFKD") .replace(/[̀-ͯ]/g, "") .replace(/ä/gi, "ae") .replace(/ö/gi, "oe") .replace(/ü/gi, "ue") .replace(/ß/g, "ss") .replace(/[^a-zA-Z0-9._-]/g, "_") .replace(/_{2,}/g, "_") .slice(0, 180); } export interface SchluesselTeile { organisationSlug: string; /** Modellname, z. B. "Geraet". Wird kleingeschrieben. */ entitaet: string; entitaetId?: string; dateiname: string; } /** Baut einen eindeutigen Objektschlüssel nach dem oben beschriebenen Muster. */ export function objektSchluessel({ organisationSlug, entitaet, entitaetId, dateiname, }: SchluesselTeile): string { const teile = [ organisationSlug, entitaet.toLowerCase(), entitaetId ?? "allgemein", `${randomUUID()}-${dateinameBereinigen(dateiname)}`, ]; return teile.join("/"); } export interface HochladenErgebnis { bucket: string; objektSchluessel: string; groesseBytes: number; /** SHA-256 des Inhalts — erkennt Dubletten, belegt Unverändertheit. */ pruefsumme: string; etag: string; } /** * Legt einen Puffer in MinIO ab und gibt zurück, was in die Datei-Tabelle * gehört. Der Aufrufer schreibt den Datensatz und den Audit-Eintrag. */ export async function hochladen( schluessel: string, inhalt: Buffer, mimeTyp: string, metadaten: Record = {}, ): Promise { await bucketSicherstellen(); const pruefsumme = createHash("sha256").update(inhalt).digest("hex"); const ergebnis = await client().putObject(BUCKET, schluessel, inhalt, inhalt.length, { "Content-Type": mimeTyp, // MinIO nimmt eigene Angaben nur mit diesem Präfix an. "x-amz-meta-pruefsumme": pruefsumme, ...Object.fromEntries( Object.entries(metadaten).map(([schlssel, wert]) => [`x-amz-meta-${schlssel}`, wert]), ), }); return { bucket: BUCKET, objektSchluessel: schluessel, groesseBytes: inhalt.length, pruefsumme, etag: ergebnis.etag, }; } /** Lädt ein Objekt vollständig in den Speicher. Für große Dateien streamen. */ export async function herunterladen(schluessel: string): Promise { const strom = await client().getObject(BUCKET, schluessel); const stuecke: Buffer[] = []; for await (const stueck of strom) { stuecke.push(Buffer.isBuffer(stueck) ? stueck : Buffer.from(stueck)); } return Buffer.concat(stuecke); } /** Objekt als Strom — für Downloads, die durch die Anwendung durchgereicht werden. */ export async function alsStrom(schluessel: string): Promise { return client().getObject(BUCKET, schluessel); } /** * Zeitlich begrenzter Download-Link. Damit muss die Datei nicht durch die * Anwendung laufen — der Browser holt sie direkt bei MinIO ab. * Der Link gilt S3_URL_TTL Sekunden und ist danach wertlos. */ export async function downloadLink( schluessel: string, dateiname?: string, gueltigkeitSekunden = env.S3_URL_TTL, ): Promise { const kopfzeilen: Record = {}; if (dateiname) { kopfzeilen["response-content-disposition"] = `attachment; filename="${dateinameBereinigen(dateiname)}"`; } return client().presignedGetObject(BUCKET, schluessel, gueltigkeitSekunden, kopfzeilen); } /** * Zeitlich begrenzter Upload-Link. Der Browser lädt direkt zu MinIO hoch, * ohne dass große Dateien durch die Anwendung müssen. */ export async function uploadLink( schluessel: string, gueltigkeitSekunden = env.S3_URL_TTL, ): Promise { await bucketSicherstellen(); return client().presignedPutObject(BUCKET, schluessel, gueltigkeitSekunden); } /** Löscht ein Objekt endgültig. Nur für den Aufbewahrungs-Job des Workers. */ export async function endgueltigLoeschen(schluessel: string): Promise { await client().removeObject(BUCKET, schluessel); } /** Gibt es das Objekt und wie groß ist es? */ export async function objektInfo(schluessel: string) { try { const status = await client().statObject(BUCKET, schluessel); return { vorhanden: true as const, groesseBytes: status.size, geaendertAm: status.lastModified, mimeTyp: status.metaData?.["content-type"] ?? "application/octet-stream", etag: status.etag, }; } catch { return { vorhanden: false as const }; } } /** Kopiert ein Objekt, z. B. beim Duplizieren einer Prüfvorlage. */ export async function kopieren(vonSchluessel: string, nachSchluessel: string): Promise { await client().copyObject(BUCKET, nachSchluessel, `/${BUCKET}/${vonSchluessel}`); } /** Legt den Bucket an, falls er fehlt. Idempotent, deshalb überall aufrufbar. */ export async function bucketSicherstellen(): Promise { const vorhanden = await client().bucketExists(BUCKET).catch(() => false); if (!vorhanden) { await client().makeBucket(BUCKET, env.S3_REGION); } } /** Erreichbarkeitsprüfung für /api/health. */ export async function speicherErreichbar(): Promise { try { await client().bucketExists(BUCKET); return true; } catch { return false; } } /** Nur für Tests: setzt den zwischengespeicherten Client zurück. */ export function _clientZuruecksetzen(): void { clientCache = null; }