import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; /** * Ablage der erzeugten Rechnungs-PDFs. * * BEWUSST NICHT unter UPLOAD_DIR: app/uploads/[...path]/route.ts liefert * JEDE Datei unterhalb von UPLOAD_DIR ohne Anmeldung aus. Eine Rechnung dort * abzulegen hiesse, dass jede Person, die den Pfad erraet, fremde Rechnungen * samt Anschrift und Bestellinhalt herunterladen kann. Rechnungen bekommen * deshalb ein eigenes Verzeichnis, auf das keine oeffentliche Route zeigt. * * Warum ueberhaupt als Datei und nicht bei jedem Abruf neu erzeugt: eine * Rechnung ist ein Beleg. Wuerde sie jedes Mal neu gerendert, saehe dieselbe * Rechnungsnummer nach einer Aenderung an Firmendaten oder am Layout anders * aus als das, was die Kundschaft per E-Mail bekommen hat. Abgelegt wird * deshalb genau das Dokument, das auch versendet wurde. * * Verzeichnis ueber INVOICE_DIR einstellbar; in Kubernetes zeigt das auf ein * eigenes, gesichertes Volume (siehe k8s/04-uploads-pvc.yaml und den * Sicherungs-CronJob 07-backup-cronjob.yaml). */ export const INVOICE_DIR = path.resolve( /*turbopackIgnore: true*/ process.env.INVOICE_DIR || "./invoices" ); /** * Stellt sicher, dass die Rechnungsablage NICHT innerhalb des oeffentlich * ausgelieferten Upload-Verzeichnisses liegt. * * Die Trennung der beiden Verzeichnisse ist die einzige Sicherung dagegen, * dass Rechnungen ueber app/uploads/[...path] abrufbar werden — jene Route * liefert JEDE Datei unterhalb von UPLOAD_DIR ohne Anmeldung aus. Bisher hing * diese Trennung allein daran, dass jemand INVOICE_DIR richtig setzt. Ein * INVOICE_DIR="/app/uploads/rechnungen" haette genuegt, damit fremde * Rechnungen samt Anschrift und Bestellinhalt oeffentlich abrufbar sind — und * es waere nicht aufgefallen, denn funktioniert haette weiterhin alles. * * Deshalb eine harte Pruefung beim Laden des Moduls statt eines blossen * Hinweises in der Dokumentation: lieber startet die Anwendung nicht, als * dass sie Belege oeffentlich ausliefert. */ function assertInvoicesAreNotPubliclyServed(): void { const uploads = path.resolve(/*turbopackIgnore: true*/ process.env.UPLOAD_DIR || "./uploads"); const withSep = uploads.endsWith(path.sep) ? uploads : uploads + path.sep; if (INVOICE_DIR === uploads || INVOICE_DIR.startsWith(withSep)) { throw new Error( `INVOICE_DIR ("${INVOICE_DIR}") liegt innerhalb von UPLOAD_DIR ("${uploads}"). ` + "Das Upload-Verzeichnis wird oeffentlich und ohne Anmeldung ausgeliefert — Rechnungen waeren damit " + "fuer jeden abrufbar, der den Pfad erraet. Bitte ein eigenes Verzeichnis ausserhalb von UPLOAD_DIR waehlen." ); } } assertInvoicesAreNotPubliclyServed(); /** * Macht aus einem Namen einen Verzeichnisnamen: Kleinbuchstaben, Umlaute * ausgeschrieben, alles Uebrige zu Bindestrichen. Ohne das Ausschreiben * wuerde aus "Mueller" ein "m-ller". */ function slugifyNamePart(value: string): string { return value .toLowerCase() .replace(/ä/g, "ae") .replace(/ö/g, "oe") .replace(/ü/g, "ue") .replace(/ß/g, "ss") .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); } export interface InvoicePathInput { /** Kalenderjahr der Rechnung — die oberste Ebene der Ablage. */ year: number; lastName: string; firstName: string; invoiceNumber: string; } /** * Pfad einer Rechnung RELATIV zu INVOICE_DIR, nach Jahr und Kunde sortiert: * * 2026/mustermann-erika/RE-2026-0001.pdf * * Das Jahr steht zuerst, weil sich die Aufbewahrungsfrist danach richtet: ein * abgelaufener Jahrgang laesst sich als ganzes Verzeichnis archivieren. * * Bekannte Grenze: zwei verschiedene Personen mit identischem Namen teilen * sich einen Ordner. Das ist Absicht — ein angehaengtes technisches Kuerzel * machte die Ablage fuer Menschen schlechter durchsuchbar, und die * Rechnungsnummer im Dateinamen bleibt ohnehin eindeutig. * * Faellt ein Name vollstaendig weg (nur Sonderzeichen), tritt "unbekannt" an * seine Stelle, damit nie ein leeres Pfadsegment entsteht. */ export function invoiceRelativePath(input: InvoicePathInput): string { const customer = [slugifyNamePart(input.lastName), slugifyNamePart(input.firstName)].filter(Boolean).join("-") || "unbekannt"; return path.posix.join(String(input.year), customer, `${input.invoiceNumber}.pdf`); } /** * Loest einen relativen Rechnungspfad gegen INVOICE_DIR auf und stellt * sicher, dass er das Verzeichnis nicht verlaesst. * * Dieselbe Absicherung wie bei den Uploads (lib/uploads.ts): der relative * Pfad stammt zwar aus der eigenen Datenbank, aber ein Pfad aus der Datenbank * ist kein Beweis fuer einen harmlosen Pfad — er ist irgendwann einmal aus * Eingaben entstanden. "../" darf hier nie hinausfuehren. */ export function resolveInvoicePath(relativePath: string): string { const resolved = path.resolve(/*turbopackIgnore: true*/ INVOICE_DIR, relativePath); const withSep = INVOICE_DIR.endsWith(path.sep) ? INVOICE_DIR : INVOICE_DIR + path.sep; if (!resolved.startsWith(withSep)) { throw new Error(`Rechnungspfad liegt ausserhalb von INVOICE_DIR: ${relativePath}`); } return resolved; } /** Legt das PDF ab und gibt den relativen Pfad zurueck, unter dem es liegt. */ export async function storeInvoicePdf(relativePath: string, pdf: Buffer): Promise { const target = resolveInvoicePath(relativePath); await mkdir(path.dirname(target), { recursive: true }); await writeFile(target, pdf); return relativePath; } /** * Liest ein abgelegtes PDF, oder null, wenn es (noch) keines gibt. * * Null ist ein regulaerer Fall, kein Fehler: Rechnungen aus der Zeit vor * dieser Ablage haben keine Datei, und der Abruf erzeugt sie dann eben neu. */ export async function readStoredInvoicePdf(relativePath: string): Promise { try { return await readFile(resolveInvoicePath(relativePath)); } catch { return null; } }