app/uploads/[...path] liefert JEDE Datei unterhalb von UPLOAD_DIR ohne Anmeldung aus — das ist fuer Produktfotos gewollt. Dass Rechnungen nicht darunter liegen, war bisher aber allein eine Frage der richtigen Konfiguration: ein INVOICE_DIR="/app/uploads/rechnungen" haette genuegt, damit fremde Rechnungen samt Anschrift und Bestellinhalt fuer jeden abrufbar sind, der den Pfad erraet. Das waere im Betrieb nicht aufgefallen, denn funktioniert haette weiterhin alles: Ablegen, Versenden und Abrufen ueber /rechnung/<nummer>/pdf haetten sich genauso verhalten wie vorher. Nur haette daneben eine zweite, ungeschuetzte Tuer zu denselben Dateien gestanden. Drei Sicherungen statt einer Anmerkung in der Dokumentation: - lib/invoice-storage.ts wirft beim Laden des Moduls, wenn INVOICE_DIR innerhalb von UPLOAD_DIR liegt. Lieber startet die Anwendung nicht, als dass sie Belege oeffentlich ausliefert. - "pnpm preflight" meldet denselben Fall als Blocker, also bereits vor dem Deploy statt erst beim Start. - Ein Test haelt die Trennung fest, damit sie nicht beim naechsten Umbau unbemerkt verloren geht. Der uebrige Sicherheitsdurchgang ueber die Aenderungen dieser Sitzung ergab keine ausnutzbaren Befunde: der Admin-Bereich ist dreifach abgesichert (Proxy, Layout, jede Server Action einzeln), jeweils autoritativ gegen die Datenbank und mit erzwungener Zwei-Faktor-Authentifizierung; das returnTo des Sync-Knopfes wird gegen eine feste Liste geprueft; Pfade aus der Datenbank werden weiterhin gegen ihr Wurzelverzeichnis validiert. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
253 lines
11 KiB
TypeScript
253 lines
11 KiB
TypeScript
/**
|
|
* 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 path from "node:path";
|
|
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"),
|
|
'Die echte Domain steht in k8s/01-configmap.yaml (NEXT_PUBLIC_SITE_URL) und k8s/06-ingress.yaml (host/tls.hosts) und muss an beiden Stellen dieselbe sein. Steht hier eine Beispiel-Domain, 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."
|
|
);
|
|
|
|
// --- Umsatzsteuer ----------------------------------------------------------
|
|
// Ein falscher Steuersatz faellt im Betrieb kaum auf: die Rechnung sieht
|
|
// plausibel aus, nur die ausgewiesene Steuer stimmt nicht. Deshalb hier
|
|
// ausdruecklich pruefen, statt sich auf den Rueckfallwert zu verlassen.
|
|
const vatRate = value("VAT_RATE_PERCENT");
|
|
const vatRateNumber = Number(vatRate);
|
|
must(
|
|
"VAT_RATE_PERCENT gesetzt und ein ganzzahliger Prozentwert",
|
|
vatRate.length > 0 && Number.isInteger(vatRateNumber) && vatRateNumber >= 0 && vatRateNumber <= 100,
|
|
"Wird auf jeder Rechnung ausgewiesen. 7 = ermaessigt (Honig), 19 = voll, 0 = Kleinunternehmerregelung nach § 19 UStG. Ohne gueltigen Wert rechnet die Anwendung mit 7% weiter — fuer Kerzen zu niedrig, fuer Kleinunternehmer schlicht falsch."
|
|
);
|
|
should(
|
|
"Umsatzsteuersatz ist ein in Deutschland ueblicher Wert",
|
|
["0", "7", "19"].includes(vatRate),
|
|
`Eingetragen ist "${vatRate}". Ueblich sind 0 (§ 19 UStG), 7 (ermaessigt) oder 19 (voll) — ein anderer Wert ist moeglich, aber pruefenswert.`
|
|
);
|
|
|
|
// --- Rechnungsablage -------------------------------------------------------
|
|
// Das Upload-Verzeichnis wird oeffentlich und ohne Anmeldung ausgeliefert
|
|
// (app/uploads/[...path]). Liegt die Rechnungsablage darin, sind fremde
|
|
// Rechnungen samt Anschrift und Bestellinhalt fuer jeden abrufbar, der den
|
|
// Pfad erraet — und im Betrieb faellt das nicht auf, weil weiterhin alles
|
|
// funktioniert.
|
|
const invoiceDir = path.resolve(value("INVOICE_DIR") || "./invoices");
|
|
const uploadDir = path.resolve(value("UPLOAD_DIR") || "./uploads");
|
|
must(
|
|
"INVOICE_DIR liegt ausserhalb von UPLOAD_DIR",
|
|
invoiceDir !== uploadDir && !invoiceDir.startsWith(uploadDir + path.sep),
|
|
`Rechnungen (${invoiceDir}) duerfen nicht unterhalb des oeffentlich ausgelieferten Upload-Verzeichnisses (${uploadDir}) liegen. Eigenes Verzeichnis waehlen, siehe .env.example.`
|
|
);
|
|
|
|
// --- 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<void> {
|
|
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);
|