import { createHash } from "node:crypto"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { prisma } from "@/lib/db"; import { ENDUNG_JE_TYP, anhaengePfad, dateinameSaeubern, groesseText, istErlaubterMedientyp, maxGroesseBytes, maxJeLauf, type Medientyp, } from "@/lib/anhaenge"; import { laufZumSchreibenHolen } from "@/server/services/laeufe"; import { kundeZugriffPruefen, type HandelnderBenutzer } from "@/server/mandant"; /** * Anhaenge und Fotos an Schritten (#032). * * Die Datei liegt im Dateisystem, die Metadaten in der Datenbank. Der Pfad * entsteht **aus der Kennung**, nie aus dem Namen vom Browser — ein Dateiname * von aussen darf keinen Pfad bestimmen. * * Der Archiv-Schreibschutz gilt auch hier: `laufZumSchreibenHolen` sperrt die * Laufzeile, prueft die Mandantengrenze und ruft `laufMussVeraenderbarSein` — * dieselbe eine Stelle wie bei Schrittergebnissen und Kommentaren. */ export class AnhangFehler extends Error { constructor(meldung: string) { super(meldung); this.name = "AnhangFehler"; } } /** Der Pfad einer Datei. Nach Kunde und Lauf gegliedert, damit ein Verzeichnis überschaubar bleibt. */ function dateiPfad(anhang: { readonly id: string; readonly kundeId: string; readonly laufId: string; readonly medientyp: string; }): string { const endung = istErlaubterMedientyp(anhang.medientyp) ? ENDUNG_JE_TYP[anhang.medientyp] : "bin"; return join(anhaengePfad(), anhang.kundeId, anhang.laufId, `${anhang.id}.${endung}`); } export type AnhangEingabe = { readonly laufId: string; readonly vorlagenSchrittId: string; readonly dateiname: string; readonly medientyp: string; readonly inhalt: Uint8Array; }; /** * Legt einen Anhang an einem Schritt ab. * * Reihenfolge mit Absicht: erst alles pruefen, dann die Datei schreiben, dann * die Zeile anlegen. Bricht das Anlegen der Zeile ab, wird die Datei wieder * entfernt — eine Datei ohne Zeile waere Muell, den niemand findet. Umgekehrt * waere es schlimmer: eine Zeile ohne Datei ist ein Anhang, der beim Oeffnen * fehlschlaegt. */ export async function anhangSpeichern( eingabe: AnhangEingabe, benutzer: HandelnderBenutzer, ): Promise<{ readonly id: string }> { if (!istErlaubterMedientyp(eingabe.medientyp)) { throw new AnhangFehler( `Dateien vom Typ „${eingabe.medientyp || "unbekannt"}“ lassen sich nicht anhängen. ` + "Erlaubt sind Bilder (JPEG, PNG, WebP, HEIC) und PDF.", ); } if (eingabe.inhalt.byteLength === 0) { throw new AnhangFehler("Die Datei ist leer."); } const grenze = maxGroesseBytes(); if (eingabe.inhalt.byteLength > grenze) { throw new AnhangFehler( `Die Datei ist ${groesseText(eingabe.inhalt.byteLength)} groß. Erlaubt sind höchstens ` + `${groesseText(grenze)} je Datei.`, ); } const vorbereitet = await prisma.$transaction(async (tx) => { // Sperrt den Lauf, prueft Mandant **und** Schreibschutz – die eine Stelle. const lauf = await laufZumSchreibenHolen(tx, eingabe.laufId, benutzer); const ergebnis = await tx.schrittergebnis.findUnique({ where: { laufId_vorlagenSchrittId: { laufId: lauf.id, vorlagenSchrittId: eingabe.vorlagenSchrittId, }, }, select: { id: true }, }); if (!ergebnis) { throw new AnhangFehler("Diesen Schritt gibt es in diesem Lauf nicht."); } const vorhanden = await tx.anhang.count({ where: { laufId: lauf.id } }); const hoechstens = maxJeLauf(); if (vorhanden >= hoechstens) { throw new AnhangFehler( `An diesem Lauf hängen schon ${vorhanden} Dateien. Mehr als ${hoechstens} je Lauf ` + "nimmt Laufzettel nicht — erst eine entfernen.", ); } return { laufId: lauf.id, kundeId: lauf.kundeId, schrittergebnisId: ergebnis.id }; }); const pruefsumme = createHash("sha256").update(eingabe.inhalt).digest("hex"); const id = `anh_${createHash("sha256") .update(`${vorbereitet.schrittergebnisId}:${pruefsumme}:${Date.now()}:${Math.random()}`) .digest("hex") .slice(0, 24)}`; const pfad = dateiPfad({ id, kundeId: vorbereitet.kundeId, laufId: vorbereitet.laufId, medientyp: eingabe.medientyp, }); await mkdir(join(pfad, ".."), { recursive: true }); await writeFile(pfad, eingabe.inhalt, { flag: "wx" }); try { await prisma.anhang.create({ data: { id, schrittergebnisId: vorbereitet.schrittergebnisId, laufId: vorbereitet.laufId, kundeId: vorbereitet.kundeId, dateiname: dateinameSaeubern(eingabe.dateiname), medientyp: eingabe.medientyp, groesse: eingabe.inhalt.byteLength, pruefsumme, erstelltVonId: benutzer.id, }, }); } catch (fehler) { // Die Datei wieder weg: eine Datei ohne Zeile findet niemand mehr. await rm(pfad, { force: true }); throw fehler; } return { id }; } /** * Den Inhalt eines Anhangs holen – mit Recht und Mandantengrenze. * * **Jeder** Abruf prueft beides, auch der des Bildes selbst. Eine Adresse, die * ohne Pruefung ausliefert, waere die Luecke, die alles andere umgeht. */ export async function anhangLesen( anhangId: string, benutzer: HandelnderBenutzer, ): Promise<{ readonly inhalt: Buffer; readonly medientyp: string; readonly dateiname: string; readonly groesse: number; }> { const anhang = await prisma.anhang.findUnique({ where: { id: anhangId } }); if (!anhang) throw new AnhangFehler("Diesen Anhang gibt es nicht (mehr)."); await kundeZugriffPruefen(anhang.kundeId, benutzer); const pfad = dateiPfad(anhang); let inhalt: Buffer; try { inhalt = await readFile(pfad); } catch { /* * Die Zeile steht, die Datei fehlt. Das ist der Fall nach einer Sicherung, * die nur die Datenbank mitgenommen hat — und deshalb sagt die Meldung das * auch, statt „nicht gefunden“ zu behaupten. */ throw new AnhangFehler( `Die Datei zu „${anhang.dateiname}“ fehlt im Dateisystem. Wurde eine Sicherung ` + "eingespielt, die nur die Datenbank enthielt? Siehe docs/betrieb/sicherung.md.", ); } return { inhalt, medientyp: anhang.medientyp, dateiname: anhang.dateiname, groesse: anhang.groesse, }; } /** * Entfernt einen Anhang. * * Erst die Zeile, dann die Datei: bleibt die Datei liegen, ist sie unerreichbar * und beim naechsten Aufraeumen zu finden. Bliebe umgekehrt die Zeile stehen, * zeigte die Oberflaeche einen Anhang, der nicht mehr zu oeffnen ist. */ export async function anhangEntfernen(anhangId: string, benutzer: HandelnderBenutzer): Promise { const anhang = await prisma.anhang.findUnique({ where: { id: anhangId } }); if (!anhang) throw new AnhangFehler("Diesen Anhang gibt es nicht (mehr)."); await prisma.$transaction(async (tx) => { // Sperrt, prueft Mandant und Schreibschutz – an einem archivierten Lauf // kommt kein Anhang weg. await laufZumSchreibenHolen(tx, anhang.laufId, benutzer); await tx.anhang.delete({ where: { id: anhangId } }); }); await rm(dateiPfad(anhang), { force: true }); } /** Die Anhaenge eines Laufs, je Schrittergebnis. Nur Metadaten. */ export async function anhaengeDesLaufs(laufId: string): Promise< readonly { readonly id: string; readonly schrittergebnisId: string; readonly dateiname: string; readonly medientyp: string; readonly groesse: number; readonly erstelltAm: Date; readonly erstelltVon: string | null; }[] > { const zeilen = await prisma.anhang.findMany({ where: { laufId }, orderBy: { erstelltAm: "asc" }, select: { id: true, schrittergebnisId: true, dateiname: true, medientyp: true, groesse: true, erstelltAm: true, erstelltVon: { select: { name: true, email: true } }, }, }); return zeilen.map((zeile) => ({ id: zeile.id, schrittergebnisId: zeile.schrittergebnisId, dateiname: zeile.dateiname, medientyp: zeile.medientyp, groesse: zeile.groesse, erstelltAm: zeile.erstelltAm, erstelltVon: zeile.erstelltVon?.name ?? zeile.erstelltVon?.email ?? null, })); } /** Nur fuer Tests und Werkzeuge: der Pfad einer Zeile. */ export function anhangPfad(anhang: { readonly id: string; readonly kundeId: string; readonly laufId: string; readonly medientyp: string; }): string { return dateiPfad(anhang); } export type { Medientyp };