Setzt Teilprojekte 1-5 der Spec 2026-10-16 um: - Urkunden-Engine (lib/certificate): ein Layout fuer PDF, Vorschau und 3D-Textur, Vorlage classic@1, eingebettete OFL-Schriften, Testmatrix - Einmal-Patenschaft ueber Traegerprodukte (Kategorie PATENSCHAFT), Paket wird im Checkout in Patenschaft (Regelsatz) und Honig aufgeteilt, Kontopflicht, rein digitale Bestellungen (FulfillmentMethod DIGITAL) - Ausstellung nach Zahlung idempotent, Wiederholung im App-Prozess (instrumentation + background-jobs), Ablage in CERTIFICATE_DIR - Verkaufsseite /patenschaft mit Konfigurator und Live-Vorschau, Warenkorb mit Schluessel je Position - Kundenkonto: 3D-Ansicht von Urkunde und Medaille, Downloads, ZIP, Uebertragung per befristetem Einmal-Link, Treuestufen bis VIP - Admin: Angebote, Patenschaften, Druck, Korrektur mit Revisionen, Abos, Erntefenster, Deckung, SEPA-Frist, Treuestufen - Abo ueber Stripe Billing: Lieferung erst nach bezahlten Monaten, SEPA-Neukunden-Frist und Rueckbuchungsschutz, Honigreservierung, Kuendigungsbutton, Monatsrechnungen, Stripe-Einrichtungsskript Startangebote werden inaktiv angelegt; Livebetrieb erst nach Freigabe. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
99 lines
4.2 KiB
TypeScript
99 lines
4.2 KiB
TypeScript
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<Buffer | null> {
|
|
try {
|
|
return await readFile(resolveCertificatePath(relativePath));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function sha256Hex(data: Buffer): string {
|
|
return createHash("sha256").update(data).digest("hex");
|
|
}
|