import 'server-only' import { createHash } from 'node:crypto' import { env } from '@/lib/env' import { ERLAUBTE_TYPEN, formatBytes, inhaltBeschreiben, typErkennen, type Dateityp, } from '@/lib/speicher/dateityp' import { betreuteIdAusSchluessel, schluesselErfordern, schluesselErzeugen, schluesselGueltig, TOKEN_STANDARD_SEKUNDEN, type Bereich, } from '@/lib/speicher/schluessel' import * as dateiTreiber from '@/lib/speicher/datei' /** * Dateiablage - die Fassade, hinter der die beiden Treiber stecken. * * Vertrag: docs/SCHNITTSTELLEN.md, Abschnitt 1. Andere Module (Berichte, * Exporte, Belegmappen) rufen ausschliesslich diese Funktionen auf. * * Die drei Zusagen, auf die sich alles verlaesst: * - Der Schluessel wird erzeugt, nie vom Nutzer bestimmt. * - Der Typ wird am Inhalt geprueft, nicht am gemeldeten MIME-Typ. * - Jede Auslieferung ist ein Anhang, niemals HTML. */ export type Ablageziel = { /** Schluessel im Speicher, z.B. "betreute//dokumente/.pdf" */ schluessel: string groesseBytes: number /** SHA-256 hex - Nachweis der Unveraendertheit gegenueber Gericht. */ pruefsumme: string } /** Zusatzangaben, die der Aufrufer nach dem Ablegen meist braucht. */ export type AblageErgebnis = Ablageziel & { /** Der tatsaechlich erkannte Typ - nicht der gemeldete. */ typ: Dateityp /** Bereinigter Originalname, nur zur Anzeige. */ dateiname: string } export class DateiAbgelehnt extends Error { readonly grund: 'zu_gross' | 'leer' | 'typ' constructor(grund: 'zu_gross' | 'leer' | 'typ', nachricht: string) { super(nachricht) this.name = 'DateiAbgelehnt' this.grund = grund } } function treiber() { return env.speicherTreiber } /** * Der Anzeigename. Er landet nur in der Datenbank, nie im Pfad - trotzdem * werden Pfadanteile und Steuerzeichen entfernt, damit er sich gefahrlos * in einem Content-Disposition-Kopf und in der Oberflaeche darstellen laesst. */ export function dateinamenBereinigen(roh: string): string { const ohnePfad = roh.split(/[\\/]/).pop() ?? roh const sauber = ohnePfad // eslint-disable-next-line no-control-regex .replace(/[\u0000-\u001f\u007f"\\]/g, '') .replace(/\s+/g, ' ') .trim() return sauber.slice(0, 180) || 'dokument' } /** SHA-256 hex ueber den Klartextinhalt - der Nachweis fuer das Gericht. */ export function pruefsummeBilden(inhalt: Buffer): string { return createHash('sha256').update(inhalt).digest('hex') } /** * Prueft Groesse und Typ, ohne etwas abzulegen. Wird auch von der * Sammel-Upload-Vorschau benutzt, damit der Nutzer die Ablehnung sieht, * bevor zehn Fotos hochgeladen sind. */ export function dateiPruefen(inhalt: Buffer, dateiname: string): Dateityp { if (inhalt.length === 0) { throw new DateiAbgelehnt('leer', `„${dateiname}“ ist leer und wurde nicht übernommen.`) } if (inhalt.length > env.uploadMaxBytes) { throw new DateiAbgelehnt( 'zu_gross', `„${dateiname}“ ist ${formatBytes(inhalt.length)} groß. Erlaubt sind höchstens ` + `${formatBytes(env.uploadMaxBytes)}. Fotografiere das Blatt mit weniger Auflösung ` + `oder lade das PDF seitenweise hoch.`, ) } const erkannt = typErkennen(inhalt) if (!erkannt) { throw new DateiAbgelehnt( 'typ', `„${dateiname}“ ist ${inhaltBeschreiben(inhalt)}, kein Dokument. Erlaubt sind ` + `${ERLAUBTE_TYPEN.map((t) => t.bezeichnung).join(', ')}.`, ) } return erkannt } /** * Legt eine Datei ab. Der Schluessel wird erzeugt, nie vom Nutzer bestimmt. */ export async function ablegen( betreuteId: string, bereich: Bereich, dateiname: string, inhalt: Buffer, mimeTyp: string, ): Promise { return ablegenMitTyp(betreuteId, bereich, dateiname, inhalt, mimeTyp) } /** Wie ablegen(), gibt aber zusaetzlich den erkannten Typ zurueck. */ export async function ablegenMitTyp( betreuteId: string, bereich: Bereich, dateiname: string, inhalt: Buffer, _gemeldeterMimeTyp: string, ): Promise { // Der gemeldete MIME-Typ wird bewusst ignoriert: er kommt vom Browser. const typ = dateiPruefen(inhalt, dateiname) const schluessel = schluesselErzeugen(betreuteId, bereich, typ.endung) if (treiber() === 's3') { const s3 = await import('@/lib/speicher/s3') await s3.schreiben(schluessel, inhalt, typ.mimeTyp) } else { await dateiTreiber.schreiben(schluessel, inhalt) } return { schluessel, groesseBytes: inhalt.length, pruefsumme: pruefsummeBilden(inhalt), typ, dateiname: dateinamenBereinigen(dateiname), } } /** Holt eine Datei zurueck in den Speicher des Servers. */ export async function lesen(schluessel: string): Promise { schluesselErfordern(schluessel) if (treiber() === 's3') { const s3 = await import('@/lib/speicher/s3') return s3.lesen(schluessel) } return dateiTreiber.lesen(schluessel) } /** * Kurzlebige, signierte URL. Gueltigkeit in Sekunden, Standard 300. * Beim Dateitreiber zeigt sie auf eine eigene, ebenfalls signierte Route. */ export async function signierteUrl( schluessel: string, sekunden: number = TOKEN_STANDARD_SEKUNDEN, ): Promise { schluesselErfordern(schluessel) if (treiber() === 's3') { const s3 = await import('@/lib/speicher/s3') return s3.signierteUrl(schluessel, sekunden) } return dateiTreiber.signierteUrl(schluessel, sekunden) } export async function entfernen(schluessel: string): Promise { schluesselErfordern(schluessel) if (treiber() === 's3') { const s3 = await import('@/lib/speicher/s3') return s3.entfernen(schluessel) } return dateiTreiber.entfernen(schluessel) } export async function existiert(schluessel: string): Promise { if (!schluesselGueltig(schluessel)) return false if (treiber() === 's3') { const s3 = await import('@/lib/speicher/s3') return s3.existiert(schluessel) } return dateiTreiber.existiert(schluessel) } export { betreuteIdAusSchluessel, schluesselGueltig, schluesselErzeugen, schluesselErfordern, type Bereich, } export { ERLAUBTE_TYPEN, typErkennen, formatBytes, type Dateityp } from '@/lib/speicher/dateityp'