diff --git a/README.md b/README.md index e0b6508..be6a704 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,7 @@ Regionale Imkerei-Website und Shop für Wabenhain (Altenmünster) — MVP-Stufe. | Sprache | TypeScript | | Framework | Next.js 16 (App Router) + React 19 | | Styling | Tailwind CSS v4 | -| Scroll-/Hero-Animationen | GSAP + ScrollTrigger | -| UI-Animationen | Motion for React (Menü, Warenkorb, Karten) | +| Animationen | Motion for React (Scroll-Reveal, Hero, Menü, Warenkorb, Karten) | | Datenbank | PostgreSQL (Standard) oder MySQL/MariaDB, per `DATABASE_PROVIDER` wählbar — siehe „Datenbank-Provider wechseln" | | ORM | Prisma 7 (`@prisma/adapter-pg` bzw. `@prisma/adapter-mariadb`) | | Payment | Stripe Checkout | @@ -204,6 +203,33 @@ docker compose exec app pnpm db:seed:demo # optional: Beispieldaten zum Auspro Die App läuft danach hinter [Caddy](https://caddyserver.com/) unter `http://localhost`, PostgreSQL unter Port `5432`. `app` selbst ist **nicht** direkt vom Host aus erreichbar (kein `ports:`-Mapping mehr, nur `expose`) — Caddy ist bewusst der einzige nach außen offene Dienst, weil er die tatsächliche Client-Verbindung terminiert und `X-Forwarded-For` dabei neu setzt statt einen vom Client selbst mitgeschickten Wert zu übernehmen. Ohne diesen Proxy davor ließe sich jedes IP-basierte Rate-Limit (Login, 2FA-Code, Checkout, Registrierung, siehe `lib/rate-limit.ts`) durch einen gefälschten Header umgehen. Für eine echte Domain mit automatischem HTTPS `SITE_ADDRESS` in `.env` setzen und Port `443` in `docker-compose.yml` freigeben (siehe Kommentar dort und in der `Caddyfile`). +## Vor dem Live-Gang + +```bash +pnpm preflight +``` + +Prüft die Konfiguration gegen alles, was einen öffentlichen Shop still unbrauchbar machen kann, und beendet sich mit Exit-Code 1, solange ein Blocker offen ist — dadurch lässt sich der Aufruf unverändert als Schritt in die Deploy-Pipeline hängen. Geprüft werden unter anderem: + +- `SESSION_SECRET`, `ADMIN_EMAIL` und `ADMIN_PASSWORD` auf Länge und darauf, dass noch der Beispielwert aus `.env.example` darin steht (ein bekanntes Signiergeheimnis macht jede Sitzung fälschbar). +- `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. + +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. + +## Health-Endpunkte + +Zwei getrennte Endpunkte, bewusst mit unterschiedlicher Aufgabe: + +| Endpunkt | Prüft | Verwendung | +|---|---|---| +| `/api/health` | nur, ob der Node-Prozess antwortet — **kein** Datenbankzugriff | Liveness-Probe | +| `/api/health/ready` | zusätzlich `SELECT 1` gegen die Datenbank, antwortet sonst mit 503 | Readiness-Probe, Docker-Healthcheck | + +Die Trennung ist der Grund für die Endpunkte: eine Liveness-Probe, die an der Datenbank hängt, macht aus einer vorübergehenden DB-Störung eine Neustart-Schleife, weil der neu startende Container laut `Dockerfile`-CMD zuerst `prisma migrate deploy` gegen genau diese gestörte Datenbank fährt. Die Readiness-Probe darf dagegen ruhig fehlschlagen — Kubernetes nimmt den Pod dann nur aus dem Load-Balancing, statt ihn zu töten. Vorher zeigten beide Probes auf `/`, also alle 10 bzw. 30 Sekunden auf eine `force-dynamic`-Seite samt DB-Abfragen und vollem React-Rendering. + ## Kubernetes / Gitea CI `.gitea/workflows/docker-build.yml` baut bei jedem Push auf `main` automatisch ein Docker-Image und veröffentlicht es in der Gitea-eigenen Container-Registry. Der Container migriert/seedet die Datenbank beim Start selbst (idempotent), kein separates Migrations-Image oder -Job nötig. `k8s/` enthält vollständige, rohe Kubernetes-Manifeste (Namespace, ConfigMap, Secret-Vorlage, Postgres/MySQL-StatefulSet, App-Deployment, Ingress) für den Betrieb dieses Images im Cluster — Einrichtung und Deployment-Ablauf stehen in [`k8s/README.md`](k8s/README.md). @@ -259,6 +285,7 @@ Beim Ausbau der Warenkorb-/Checkout-Tests kam ein echter, vorher unentdeckter Bu pnpm dev # Entwicklungsserver pnpm build # Produktions-Build pnpm lint # ESLint +pnpm preflight # Go-Live-Pruefung (siehe "Vor dem Live-Gang") pnpm db:studio # Prisma Studio (DB-GUI) pnpm db:migrate # neue Migration erstellen/anwenden (Dev) pnpm audit # Abhängigkeiten auf bekannte Schwachstellen prüfen diff --git a/scripts/preflight.mjs b/scripts/preflight.mjs new file mode 100644 index 0000000..85e4478 --- /dev/null +++ b/scripts/preflight.mjs @@ -0,0 +1,167 @@ +#!/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." +); + +// --- 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" +);