Files
Wabenhain/src/lib/sponsorship/storage.ts
T
Nick SzaunigandClaude Opus 5.5 f227aeb8f9 feat: Bienenpatenschaft mit Urkunde, Abo, Medaillen und 3D-Konto
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]>
2026-10-16 14:07:30 +02:00

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");
}