Files
Wabenhain/scripts/preflight.mjs
T
Nick SzaunigandClaude Opus 5 a78c1cd235 fix: hochgeladene Bilder ueberleben einen Redeploy
Das uploads-Volume im App-Deployment war ein emptyDir-Platzhalter. Der ist
an die Lebensdauer des Pods gebunden: bei jedem Neustart, jedem Redeploy
und jeder Node-Verlagerung waren saemtliche hochgeladenen Produkt-,
Ernten- und Inhaltsfotos weg. Die Datenbank behaelt dabei ihre Bildpfade —
die Seite haette danach ueberall kaputte Bilder gezeigt, ohne dass
irgendwo ein Fehler aufgetaucht waere.

Neu: k8s/04-uploads-pvc.yaml (die Nummer war in der Reihenfolge noch frei)
mit einem PersistentVolumeClaim, den das Deployment jetzt bindet.
storageClassName ist bewusst auskommentiert, damit das Manifest ohne
Aenderung gegen die Default-StorageClass des Clusters anwendbar bleibt.

Damit einher geht zwingend "strategy: Recreate": Kubernetes' Standard
"RollingUpdate" startet den neuen Pod, BEVOR der alte beendet ist. Ein
ReadWriteOnce-Claim kann aber nur an einen Pod gebunden sein — der neue
Pod bliebe mit "Multi-Attach error for volume" in ContainerCreating
haengen, und das im k8s/README.md dokumentierte
"kubectl rollout restart" liefe schlicht in den Timeout. Recreate kostet
wenige Sekunden Ausfall pro Redeploy und sorgt nebenbei dafuer, dass nie
zwei Pods gleichzeitig laufen, die sich sonst die im Prozessspeicher
gehaltenen Rate-Limits teilen wuerden.

Alle Manifeste gegen einen YAML-Parser geprueft, zusaetzlich strukturell:
claimName trifft den PVC-Namen, PVC liegt im selben Namespace, und der
mountPath stimmt mit UPLOAD_DIR aus 01-configmap.yaml ueberein.

preflight: NEXT_PUBLIC_SITE_URL wird jetzt auch gegen "example.com"
geprueft. Die ausgelieferte Vorgabe ist "shop.example.com" — blieb sie
stehen, zeigten Sitemap, Canonicals und jeder Link in jeder
Bestellbestaetigung auf eine fremde Domain, ohne dass die bisherigen
HTTPS-/localhost-Pruefungen angeschlagen haetten.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-15 10:13:06 +02:00

173 lines
7.3 KiB
JavaScript

#!/usr/bin/env node
/**
* Go-Live-Pruefung: faellt laut durch, statt den Shop still mit
* Platzhaltern oder Test-Zugangsdaten oeffentlich zu stellen.
*
* Aufruf: `pnpm preflight` (liest .env) — gedacht als letzter Schritt vor
* dem ersten oeffentlichen Deploy und als Schritt in der Deploy-Pipeline.
*
* Bewusst OHNE Datenbankzugriff: die Pruefung soll auch dann laufen, wenn
* die Produktionsdatenbank von der ausfuehrenden Maschine aus nicht
* erreichbar ist. Felder, die zusaetzlich im Admin-Bereich unter "Inhalte"
* gepflegt werden koennen (Telefonnummer, Anschrift), sind deshalb als
* WARNUNG eingestuft, nicht als BLOCKER — der hier gepruefte Wert ist nur
* der Rueckfallwert aus src/lib/site-config.ts.
*/
import { readFileSync } from "node:fs";
import { config as loadEnv } from "dotenv";
loadEnv();
const blockers = [];
const warnings = [];
const passed = [];
/** @param {string} label @param {boolean} ok @param {string} hint */
function must(label, ok, hint) {
if (ok) passed.push(label);
else blockers.push(`${label}\n ${hint}`);
}
/** @param {string} label @param {boolean} ok @param {string} hint */
function should(label, ok, hint) {
if (ok) passed.push(label);
else warnings.push(`${label}\n ${hint}`);
}
const env = process.env;
/** @param {string} name */
const value = (name) => (env[name] ?? "").trim();
/** @param {string} name */
const isSet = (name) => value(name).length > 0;
// --- Sitzungs-/Zugangsgeheimnisse -----------------------------------------
const sessionSecret = value("SESSION_SECRET");
must(
"SESSION_SECRET gesetzt und mindestens 32 Zeichen lang",
sessionSecret.length >= 32,
"Signiert die Login-Cookies. Erzeugen mit: openssl rand -base64 32"
);
must(
"SESSION_SECRET ist nicht mehr der Beispielwert",
!sessionSecret.includes("change-me"),
"Der Wert aus .env.example ist oeffentlich bekannt — jede Sitzung waere faelschbar."
);
const adminEmail = value("ADMIN_EMAIL");
must(
"ADMIN_EMAIL gesetzt und kein Beispielwert",
adminEmail.length > 0 && !adminEmail.includes("change-me") && !adminEmail.endsWith("@example.com"),
"Legt das erste Admin-Konto an (prisma db seed). Eine unvorhersehbare Adresse waehlen, nicht \"admin@...\"."
);
const adminPassword = value("ADMIN_PASSWORD");
must(
"ADMIN_PASSWORD gesetzt, kein Beispielwert, mindestens 12 Zeichen",
adminPassword.length >= 12 && !adminPassword.includes("change-me"),
"Dieses Konto darf Rueckerstattungen ausloesen und alle Kundendaten einsehen."
);
// --- Oeffentliche Adresse der Seite ---------------------------------------
const siteUrl = value("NEXT_PUBLIC_SITE_URL");
must(
"NEXT_PUBLIC_SITE_URL gesetzt",
siteUrl.length > 0,
"Einzige Quelle fuer Sitemap, Canonical-Tags, JSON-LD und alle Links in E-Mails (src/lib/url.ts)."
);
must(
"NEXT_PUBLIC_SITE_URL nutzt HTTPS und nicht localhost",
siteUrl.startsWith("https://") && !siteUrl.includes("localhost"),
"Bei HTTP oder localhost landen genau diese Adressen in Bestellbestaetigungen und in der Sitemap."
);
must(
"NEXT_PUBLIC_SITE_URL ist die echte Domain, kein Platzhalter",
!siteUrl.includes("example.com") && !siteUrl.includes("example.org"),
"k8s/01-configmap.yaml und k8s/06-ingress.yaml liefern \"shop.example.com\" als Vorgabe aus. Bleibt der Wert stehen, zeigen Sitemap, Canonical-Tags und jeder Link in jeder Bestellbestaetigung auf eine fremde Domain."
);
// --- Zahlung ---------------------------------------------------------------
must(
"STRIPE_SECRET_KEY gesetzt",
isSet("STRIPE_SECRET_KEY"),
"Ohne Stripe-Schluessel schlaegt jeder Checkout fehl — der Shop nimmt keine Bestellung an."
);
must(
"STRIPE_WEBHOOK_SECRET gesetzt",
isSet("STRIPE_WEBHOOK_SECRET"),
"Ohne Webhook-Secret wird keine Zahlung bestaetigt: Bestellungen bleiben auf \"NEU\" stehen, es gibt keine Rechnung und keine Bestaetigungsmail."
);
must(
"Stripe laeuft im Live-Modus (kein sk_test-Schluessel)",
!value("STRIPE_SECRET_KEY").startsWith("sk_test"),
"Mit einem Test-Schluessel zahlt niemand echtes Geld — Bestellungen sehen erfolgreich aus, ohne dass Umsatz entsteht."
);
// --- E-Mail ----------------------------------------------------------------
must(
"SMTP_HOST, SMTP_USER und SMTP_PASSWORD gesetzt",
isSet("SMTP_HOST") && isSet("SMTP_USER") && isSet("SMTP_PASSWORD"),
"Ohne SMTP werden E-Mails nur ins Server-Log geschrieben (siehe src/lib/mail.ts): keine Bestellbestaetigung, keine Versandmeldung, keine Passwort-Zuruecksetzung."
);
should(
"SMTP_FROM zeigt auf eine eigene Domain",
!value("SMTP_FROM").includes("example.com"),
"Absenderadressen fremder Domains landen regelmaessig im Spam-Ordner."
);
// --- Datenbank -------------------------------------------------------------
must("DATABASE_URL gesetzt", isSet("DATABASE_URL"), "Ohne Datenbank startet die Anwendung nicht.");
// --- Missbrauchsschutz -----------------------------------------------------
should(
"Cloudflare Turnstile konfiguriert",
isSet("TURNSTILE_SECRET_KEY") && isSet("NEXT_PUBLIC_TURNSTILE_SITE_KEY"),
"Ohne Turnstile laufen Registrierung, Newsletter und Firmenanfrage ohne CAPTCHA — bei einem oeffentlichen Shop sammeln sich darueber schnell Bot-Eintraege."
);
// --- Inhalte, die rechtlich verpflichtend sind -----------------------------
const dictionaries = ["src/i18n/dictionaries/de.ts", "src/i18n/dictionaries/en.ts"];
// Beide Markierungen pruefen: die deutsche Fassung nutzt "[PLATZHALTER",
// die englische "[PLACEHOLDER" — eine Pruefung auf nur eine von beiden liesse
// die jeweils andere Sprachfassung unbemerkt mit Luecken online gehen.
const dictionariesWithPlaceholder = dictionaries.filter((file) => {
const source = readFileSync(file, "utf8");
return source.includes("[PLATZHALTER") || source.includes("[PLACEHOLDER");
});
must(
"Impressum enthaelt keine [PLATZHALTER]-Markierungen mehr",
dictionariesWithPlaceholder.length === 0,
`Noch offen in: ${dictionariesWithPlaceholder.join(", ") || "-"}. Ein unvollstaendiges Impressum nach § 5 TMG ist in Deutschland abmahnfaehig — Name, Rechtsform und Umsatzsteuer-ID bzw. der Hinweis auf § 19 UStG muessen eingetragen sein.`
);
const siteConfigSource = readFileSync("src/lib/site-config.ts", "utf8");
should(
"Telefonnummer in site-config.ts ist keine Platzhalternummer",
!siteConfigSource.includes("000 000000"),
"Sichtbar im Footer und auf der Kontaktseite. Alternativ im Admin-Bereich unter \"Inhalte\" pflegbar — dann ist diese Warnung gegenstandslos."
);
// --- Ausgabe ---------------------------------------------------------------
const line = "─".repeat(72);
console.log(`\n${line}\nGo-Live-Pruefung Wabenhain\n${line}`);
console.log(`\n✔ Bestanden: ${passed.length}`);
for (const item of passed) console.log(` ✔ ${item}`);
if (warnings.length > 0) {
console.log(`\n⚠ Warnungen: ${warnings.length}`);
for (const item of warnings) console.log(` ⚠ ${item}`);
}
if (blockers.length > 0) {
console.log(`\n✖ Blocker: ${blockers.length}`);
for (const item of blockers) console.log(` ✖ ${item}`);
console.log(`\n${line}`);
console.log("Nicht bereit fuer den Live-Betrieb. Die Blocker oben zuerst beheben.\n");
process.exit(1);
}
console.log(`\n${line}`);
console.log(
warnings.length > 0
? "Keine Blocker. Warnungen oben pruefen, dann ist der Live-Betrieb moeglich.\n"
: "Alle Pruefungen bestanden — bereit fuer den Live-Betrieb.\n"
);