import { createHash } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; /** * Ablage der Urkunden-PDFs. * * Aus demselben Grund wie die Rechnungen (lib/invoice-storage.ts) BEWUSST * NICHT unter UPLOAD_DIR: app/uploads/[...path] liefert jede Datei dort ohne * Anmeldung aus. Eine Urkunde traegt Namen von Kaeufer, Beschenkten und * Schenkenden und eine persoenliche Widmung — sie darf nur ueber die Routen * ausgeliefert werden, die Anmeldung und Besitz pruefen. * * Abgelegt wird jede Revision als eigene Datei und nie ueberschrieben: was * die Kundschaft einmal heruntergeladen hat, bleibt genau so erhalten, auch * wenn spaeter korrigiert wird oder sich die Gestaltung aendert. * * In Kubernetes zeigt CERTIFICATE_DIR auf ein Verzeichnis im gesicherten * Volume (siehe k8s/05-app-deployment.yaml und den Sicherungs-CronJob). */ export const CERTIFICATE_DIR = path.resolve( /*turbopackIgnore: true*/ process.env.CERTIFICATE_DIR || "./certificates" ); /** * Harte Pruefung beim Laden des Moduls wie bei INVOICE_DIR: lieber startet * die Anwendung nicht, als dass Urkunden still oeffentlich abrufbar werden, * weil jemand CERTIFICATE_DIR unter das Upload-Verzeichnis gelegt hat. */ function assertCertificatesAreNotPubliclyServed(): void { const uploads = path.resolve(/*turbopackIgnore: true*/ process.env.UPLOAD_DIR || "./uploads"); const withSep = uploads.endsWith(path.sep) ? uploads : uploads + path.sep; if (CERTIFICATE_DIR === uploads || CERTIFICATE_DIR.startsWith(withSep)) { throw new Error( `CERTIFICATE_DIR ("${CERTIFICATE_DIR}") liegt innerhalb von UPLOAD_DIR ("${uploads}"). ` + "Das Upload-Verzeichnis wird oeffentlich ausgeliefert — Urkunden mit Namen und Widmungen waeren fuer " + "jeden abrufbar. Bitte ein eigenes Verzeichnis ausserhalb von UPLOAD_DIR waehlen." ); } } assertCertificatesAreNotPubliclyServed(); /** * Relativer Pfad einer Revision, z.B. "2026/WH-P-2026-00042/r1.pdf". Nur aus * Nummer und Revisionszahl gebildet — keine Namen im Dateisystem, damit eine * Korrektur des Namens keinen veralteten Namen im Pfad stehen laesst. */ export function certificateRelativePath(input: { number: string; revision: number; year: number }): string { if (!/^WH-P-\d{4}-\d{5}$/.test(input.number)) { throw new Error(`Ungueltige Patenschaftsnummer fuer den Ablagepfad: ${input.number}`); } return path.posix.join(String(input.year), input.number, `r${input.revision}.pdf`); } export function resolveCertificatePath(relativePath: string): string { const resolved = path.resolve(/*turbopackIgnore: true*/ CERTIFICATE_DIR, relativePath); const withSep = CERTIFICATE_DIR.endsWith(path.sep) ? CERTIFICATE_DIR : CERTIFICATE_DIR + path.sep; if (!resolved.startsWith(withSep)) { throw new Error(`Urkundenpfad liegt ausserhalb von CERTIFICATE_DIR: ${relativePath}`); } return resolved; } /** * Schreibt die PDF exklusiv ("wx"): existiert die Datei schon, ist das ein * Programmfehler (Revisionen werden nie ueberschrieben) und kein Fall fuer * stilles Ersetzen. Ausnahme: dieselbe Datei mit identischem Inhalt — das * passiert, wenn ein Lauf nach dem Schreiben, aber vor dem Speichern der * Revision abbrach und wiederholt wird. */ export async function storeCertificatePdf(relativePath: string, pdf: Buffer): Promise<{ sha256: string }> { const target = resolveCertificatePath(relativePath); await mkdir(path.dirname(target), { recursive: true }); const sha256 = sha256Hex(pdf); try { await writeFile(target, pdf, { flag: "wx" }); } catch (error: unknown) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; const existing = await readFile(target); if (sha256Hex(existing) !== sha256) { throw new Error(`Urkunde ${relativePath} existiert bereits mit anderem Inhalt — wird nicht ueberschrieben.`); } } return { sha256 }; } export async function readCertificatePdf(relativePath: string): Promise { try { return await readFile(resolveCertificatePath(relativePath)); } catch { return null; } } export function sha256Hex(data: Buffer): string { return createHash("sha256").update(data).digest("hex"); }