diff --git a/README.md b/README.md index be6a704..cc0a9f4 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,11 @@ Prüft die Konfiguration gegen alles, was einen öffentlichen Shop still unbrauc - `NEXT_PUBLIC_SITE_URL` auf HTTPS und darauf, dass dort nicht `localhost` steht — genau dieser Wert landet in Bestellbestätigungen, in `sitemap.xml` und in allen Canonical-Tags. - Stripe: Schlüssel und Webhook-Secret vorhanden, und ob versehentlich noch ein `sk_test`-Schlüssel gesetzt ist. Ohne Webhook-Secret wird keine Zahlung bestätigt: Bestellungen bleiben auf „NEU" stehen, ohne Rechnung und ohne Bestätigungsmail. - SMTP vollständig gesetzt — fehlt es, werden E-Mails nur ins Server-Log geschrieben statt versendet (siehe `lib/mail.ts`), also keine Bestellbestätigung und keine Passwort-Zurücksetzung. -- Impressum: ob in `src/i18n/dictionaries/de.ts` bzw. `en.ts` noch `[PLATZHALTER]`/`[PLACEHOLDER]`-Markierungen stehen. Ein unvollständiges Impressum nach § 5 TMG ist in Deutschland abmahnfähig. +- Rechtstexte (Impressum, Datenschutz, AGB, Widerrufsbelehrung) und Telefonnummer auf verbliebene `[PLATZHALTER]`/`[PLACEHOLDER]`-Markierungen — je Sprachfassung einzeln benannt. Ein unvollständiges Impressum nach § 5 TMG ist in Deutschland abmahnfähig. + + Diese Prüfung fragt die **Datenbank**, nicht den Quelltext: all diese Texte sind im Admin-Bereich unter „Inhalte" pflegbar (siehe `lib/content-registry.ts`), die Fassungen in `src/i18n/dictionaries/` sind nur der Rückfallwert, solange dort nichts gepflegt ist. Zum Ausfüllen des Impressums ist also **kein Deploy nötig** — und eine Prüfung, die nur in den Quelltext schaut, würde einen im Admin längst geschlossenen Blocker weiter melden (und umgekehrt schweigen, wenn jemand einen Platzhaltertext erst im Admin einträgt). + + Ist die Datenbank vom ausführenden Rechner aus nicht erreichbar, meldet das Skript diese Punkte ausdrücklich als **ungeprüft** — es rät weder „in Ordnung" noch „blockiert". Unabhängig davon bleiben die Rechtstexte (AGB, Datenschutz, Widerrufsbelehrung) Platzhalter und **müssen** vor dem Live-Gang anwaltlich geprüft werden — das kann kein Skript feststellen. diff --git a/package.json b/package.json index 6012999..7288ea1 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test", - "preflight": "node scripts/preflight.mjs" + "preflight": "tsx scripts/preflight.ts" }, "dependencies": { "@prisma/adapter-mariadb": "^7.10.0", diff --git a/scripts/preflight.mjs b/scripts/preflight.mjs deleted file mode 100644 index 77168ac..0000000 --- a/scripts/preflight.mjs +++ /dev/null @@ -1,172 +0,0 @@ -#!/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" -); diff --git a/scripts/preflight.ts b/scripts/preflight.ts new file mode 100644 index 0000000..fc8896d --- /dev/null +++ b/scripts/preflight.ts @@ -0,0 +1,220 @@ +/** + * 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. + * Endet mit Exit-Code 1, sobald ein Blocker offen ist. + * + * Die Inhaltspruefungen fragen bewusst die DATENBANK und nicht den + * Quelltext: Impressum, Datenschutz, AGB, Widerrufsbelehrung und die + * Telefonnummer sind im Admin-Bereich unter "Inhalte" pflegbar (siehe + * lib/content-registry.ts). Die Texte in i18n/dictionaries/ sind nur der + * Rueckfallwert, solange dort nichts gepflegt ist. Eine Pruefung, die nur + * in den Quelltext schaut, meldet deshalb einen Blocker, den die + * Betreiberin im Admin laengst geschlossen hat — und schweigt umgekehrt, + * wenn jemand einen Platzhaltertext erst im Admin eintraegt. + * + * Ist die Datenbank nicht erreichbar (Aufruf von einer Maschine ohne + * Zugang zur Produktionsdatenbank), werden diese Pruefungen ausdruecklich + * als ungeprueft gemeldet statt geraten. + */ +import "dotenv/config"; +import { prisma } from "../src/lib/prisma"; +import { getContentValue } from "../src/lib/content"; +import { locales } from "../src/i18n/config"; + +const blockers: string[] = []; +const warnings: string[] = []; +const passed: string[] = []; + +function must(label: string, ok: boolean, hint: string): void { + if (ok) passed.push(label); + else blockers.push(`${label}\n ${hint}`); +} + +function should(label: string, ok: boolean, hint: string): void { + if (ok) passed.push(label); + else warnings.push(`${label}\n ${hint}`); +} + +const value = (name: string): string => (process.env[name] ?? "").trim(); +const isSet = (name: string): boolean => 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@...".' +); +must( + "ADMIN_PASSWORD gesetzt, kein Beispielwert, mindestens 12 Zeichen", + value("ADMIN_PASSWORD").length >= 12 && !value("ADMIN_PASSWORD").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 ----------------------------- +// Beide Markierungen pruefen: die deutsche Fassung nutzt "[PLATZHALTER", +// die englische "[PLACEHOLDER". Eine Pruefung auf nur eine von beiden liesse +// die jeweils andere Sprachfassung mit Luecken online gehen. +/** + * Erste aussagekraeftige Zeile einer Fehlermeldung. Prisma-Verbindungsfehler + * beginnen mit Leerzeilen — ein blosses split("\n")[0] ergaebe eine leere + * Klammer statt der Ursache. + */ +function firstMeaningfulLine(error: unknown): string { + if (!(error instanceof Error)) return "unbekannter Fehler"; + const line = error.message.split("\n").map((l) => l.trim()).find((l) => l.length > 0); + return line ?? error.name; +} + +const PLACEHOLDER_MARKERS = ["[PLATZHALTER", "[PLACEHOLDER"]; +const hasPlaceholder = (text: string): boolean => + PLACEHOLDER_MARKERS.some((marker) => text.includes(marker)); + +const LEGAL_KEYS = [ + { key: "legal.impressum", label: "Impressum" }, + { key: "legal.datenschutz", label: "Datenschutz" }, + { key: "legal.agb", label: "AGB" }, + { key: "legal.widerruf", label: "Widerrufsbelehrung" }, +] as const; + +// Kein Top-Level-await: das Projekt hat kein "type": "module" in der +// package.json, tsx uebersetzt dieses Skript daher nach CommonJS. +async function checkDatabaseContent(): Promise { +try { + // Verbindung zuerst pruefen, damit ein DB-Ausfall unten nicht faelschlich + // als "Inhalt in Ordnung" durchgeht. + await prisma.$queryRaw`SELECT 1`; + + const withPlaceholder: string[] = []; + for (const { key, label } of LEGAL_KEYS) { + for (const locale of locales) { + if (hasPlaceholder(await getContentValue(key, locale))) { + withPlaceholder.push(`${label} (${locale})`); + } + } + } + must( + "Rechtstexte enthalten keine Platzhalter-Markierungen mehr", + withPlaceholder.length === 0, + `Noch offen: ${withPlaceholder.join(", ")}. Im Admin-Bereich unter "Inhalte" pflegbar — kein Deploy noetig. Ein unvollstaendiges Impressum nach § 5 TMG ist in Deutschland abmahnfaehig: Name, Rechtsform und Umsatzsteuer-ID bzw. der Hinweis auf § 19 UStG muessen eingetragen sein.` + ); + + should( + "Telefonnummer ist keine Platzhalternummer", + !(await getContentValue("company.phone", "de")).includes("000 000000"), + 'Sichtbar im Footer, auf der Kontaktseite und im Impressum. Im Admin-Bereich unter "Inhalte" pflegbar.' + ); +} catch (error: unknown) { + // Bewusst KEIN Blocker und KEIN stilles Bestehen: ohne Datenbank laesst + // sich der tatsaechlich ausgelieferte Text nicht feststellen. + warnings.push( + `Rechtstexte und Telefonnummer konnten NICHT geprueft werden\n Die Datenbank ist von hier aus nicht erreichbar (${firstMeaningfulLine( + error + )}). Diese Inhalte liegen in der Datenbank, nicht im Quelltext — die Pruefung mit Zugriff auf die Produktionsdatenbank wiederholen oder /impressum im Browser aufrufen.` + ); +} finally { + await prisma.$disconnect(); +} +} + +function report(): void { +// --- 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" +); +} + +void checkDatabaseContent().then(report);