diff --git a/.env.coolify.example b/.env.coolify.example index 3709b5d..2fb6020 100644 --- a/.env.coolify.example +++ b/.env.coolify.example @@ -22,6 +22,10 @@ RLS_ENFORCED=false # und ist deshalb NICHT separat zu setzen (in Coolify sonst als „managed" gesperrt). REDIS_PASSWORD=CHANGE_ME_redis_password +# --- Fertige Images (docker-compose.coolify.prebuilt.yml, DEPLOY.md §9) --- +REGISTRY=git.certvia.de/msolarczek +IMAGE_TAG=main + # --- Objektspeicher (Service "garage", S3-kompatibel) --- # Garage ersetzt MinIO (Community EOL). Buckets/Keys legt NICHT die S3-API an, sondern # der Init-Job "garage-provision" (Admin-API). S3_ACCESS_KEY/S3_SECRET_KEY sind der @@ -60,7 +64,11 @@ AUTH_SECRET=CHANGE_ME_openssl_rand_base64_32 # PASSWORD_PEPPER (Härtung §1): openssl rand -hex 32 — frisch je Umgebung, NICHT rotierbar, nie ins Artefakt. PASSWORD_PEPPER=CHANGE_ME_openssl_rand_hex_32 # MFA_ENC_KEY= (leer = aus AUTH_SECRET abgeleitet; nach dem Setzen nicht mehr ändern) -AUTH_URL=http://REPLACE-WITH-COOLIFY-SSLIP-DOMAIN +AUTH_URL=https://app.craftvia.de +# Website und App auf getrennten Domains (src/lib/marketing/hosts.ts): craftvia.de zeigt nur die +# Produktseiten, AUTH_URL-Domain die App. Beide Domains in Coolify beim Service "app" eintragen. +# Leer = alles auf einer Domain. +MARKETING_HOST=craftvia.de # Hinter Reverse-Proxy (Coolify/Traefik) für Auth.js v5 zwingend, sonst UntrustedHost: AUTH_TRUST_HOST=true diff --git a/.env.prod.example b/.env.prod.example index f88524b..cf4e190 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -63,6 +63,10 @@ PASSWORD_PEPPER=CHANGE_ME_openssl_rand_hex_32 # MFA_ENC_KEY: TOTP-Secrets at-rest (leer = aus AUTH_SECRET). ⚠ Nach dem Setzen nicht mehr ändern. MFA_ENC_KEY=CHANGE_ME_openssl_rand_hex_32 AUTH_URL=https://app.craftvia.example +# Website und App auf getrennten Domains (src/lib/marketing/hosts.ts): craftvia.de zeigt nur die +# Produktseiten, AUTH_URL-Domain die App. Beide Domains in Coolify beim Service "app" eintragen. +# Leer = alles auf einer Domain. +MARKETING_HOST=craftvia.example # AUTH_TRUST_HOST ist im Compose fest auf true (hinter dem Coolify-Proxy) – nicht nötig. # Passkeys/WebAuthn: leer = aus AUTH_URL abgeleitet. # WEBAUTHN_ORIGIN=https://app.craftvia.example diff --git a/docs/craftvia/DEPLOY.md b/docs/craftvia/DEPLOY.md index 2dbf6fe..86cb870 100644 --- a/docs/craftvia/DEPLOY.md +++ b/docs/craftvia/DEPLOY.md @@ -59,6 +59,25 @@ Entrypoints von postgres/redis), CPU-/RAM-Limits, gepinnte Image-Tags, non-root- Proxies dürfen kein niedrigeres Body-Limit haben. - DNS: A/AAAA-Record `app.craftvia.example` → Server-IP; Firewall nur 80/443 + SSH. +### 2.1 Website und App auf getrennten Domains + +Ein `app`-Container bedient beide Domains; der Proxy trennt sie (`src/lib/marketing/hosts.ts`, +Test `scripts/test-host-routing.ts`): + +| Domain | zeigt | leitet um | +|---|---|---| +| `craftvia.de` | `/`, `/funktionen/**`, `/sicherheit`, `/preise`, `/impressum` | alles andere (Login, `/testen`, App, API) → `app.craftvia.de` (308) | +| `www.craftvia.de` | – | → `craftvia.de` (308) | +| `app.craftvia.de` | die Anwendung; `/` ohne Sitzung → `/login` | Produktseiten → `craftvia.de` (308) | + +- Env: `AUTH_URL=https://app.craftvia.de`, `MARKETING_HOST=craftvia.de` (leer = eine Domain für alles). +- Coolify, Service `app`, Feld *Domains*: `https://app.craftvia.de:3000,https://craftvia.de:3000,https://www.craftvia.de:3000`. +- DNS: `craftvia.de` und `app.craftvia.de` als A/AAAA auf den Server, `www.craftvia.de` als CNAME auf `craftvia.de`. +- Die Links der Produktseiten bleiben relativ (`/login`, `/testen`); die Umleitung übernimmt der Proxy. + So steckt keine Domain im Image, und dieselben Images laufen auf Test und Produktion. +- Sitzungs-Cookies gelten nur für `app.craftvia.de`. Interne Aufrufe (Healthcheck über `127.0.0.1`) + werden nicht umgeleitet. + ## 3. Ersteinrichtung (Coolify) 1. **Ressource:** Git-Repo (Deploy-Key, nur lesend), Build Pack **Docker Compose**, Compose Location diff --git a/scripts/generate-coolify-env.sh b/scripts/generate-coolify-env.sh new file mode 100755 index 0000000..98175ef --- /dev/null +++ b/scripts/generate-coolify-env.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Erzeugt die Env-Datei für den Coolify-Testserver aus .env.coolify.example — mit frischen +# Geheimnissen, die nur auf diesem Rechner entstehen. Die Datei ist per .gitignore (.env*) +# vom Repo ausgeschlossen; Inhalt in Coolify unter "Environment Variables" einfügen. +# +# ./scripts/generate-coolify-env.sh # schreibt .env.coolify.local +# APP_DOMAIN=app.craftvia.de MARKETING_HOST=craftvia.de ./scripts/generate-coolify-env.sh +set -euo pipefail + +OUT="${1:-.env.coolify.local}" +APP_DOMAIN="${APP_DOMAIN:-app.craftvia.de}" +MARKETING_HOST="${MARKETING_HOST:-craftvia.de}" + +if [ -e "$OUT" ]; then + echo "$OUT existiert bereits und wird nicht überschrieben (Geheimnisse wären sonst verloren)." >&2 + exit 1 +fi + +hex() { openssl rand -hex "$1"; } +DB_PASSWORD="$(hex 24)" + +sed \ + -e "s|CHANGE_ME_db_password|${DB_PASSWORD}|g" \ + -e "s|^REDIS_PASSWORD=.*|REDIS_PASSWORD=$(hex 24)|" \ + -e "s|^S3_ACCESS_KEY=.*|S3_ACCESS_KEY=GK$(hex 12)|" \ + -e "s|^S3_SECRET_KEY=.*|S3_SECRET_KEY=$(hex 32)|" \ + -e "s|^GARAGE_RPC_SECRET=.*|GARAGE_RPC_SECRET=$(hex 32)|" \ + -e "s|^GARAGE_ADMIN_TOKEN=.*|GARAGE_ADMIN_TOKEN=$(hex 32)|" \ + -e "s|^AUTH_SECRET=.*|AUTH_SECRET=$(openssl rand -base64 32 | tr -d '\n')|" \ + -e "s|^PASSWORD_PEPPER=.*|PASSWORD_PEPPER=$(hex 32)|" \ + -e "s|^AUTH_URL=.*|AUTH_URL=https://${APP_DOMAIN}|" \ + -e "s|^MARKETING_HOST=.*|MARKETING_HOST=${MARKETING_HOST}|" \ + .env.coolify.example > "$OUT" + +{ + echo "" + echo "# --- erzeugt von scripts/generate-coolify-env.sh ($(date +%F)) ---" + echo "MFA_ENC_KEY=$(hex 32)" + echo "BACKUP_ENC_KEY=$(hex 32)" +} >> "$OUT" +chmod 600 "$OUT" + +echo "Geschrieben: $OUT (nur lokal, Rechte 600)." +echo "Noch von Hand ergänzen: SMTP_HOST/PORT/SECURE/USER/PASSWORD/FROM, ANTHROPIC_API_KEY, TRIAL_CONTACT_EMAIL." +echo "Danach sicher ablegen (Passwortmanager) — PASSWORD_PEPPER und MFA_ENC_KEY sind nicht rotierbar." diff --git a/scripts/test-host-routing.ts b/scripts/test-host-routing.ts new file mode 100644 index 0000000..da61ec3 --- /dev/null +++ b/scripts/test-host-routing.ts @@ -0,0 +1,51 @@ +// Domain-Trennung craftvia.de ↔ app.craftvia.de (src/lib/marketing/hosts.ts) +// +// Lauf: npx tsx scripts/test-host-routing.ts + +import { hostSplit, routeByHost, type HostDecision } from "../src/lib/marketing/hosts"; + +let failures = 0; +const ok = (cond: boolean, msg: string) => { + console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); + if (!cond) failures++; +}; + +const env = { MARKETING_HOST: "craftvia.de", AUTH_URL: "https://app.craftvia.de" }; +const split = hostSplit(env)!; +const route = (host: string, path: string, hasSession = false): HostDecision => { + const [pathname, query] = path.split("?"); + return routeByHost({ host, pathname, search: query ? `?${query}` : "", hasSession }, split); +}; +const to = (d: HostDecision) => (d.kind === "redirect" ? `${d.status} ${d.location}` : "next"); + +console.log("\n— Konfiguration —"); +ok(hostSplit({ AUTH_URL: "https://app.craftvia.de" }) === null, "ohne MARKETING_HOST keine Trennung (lokal)"); +ok(hostSplit({ MARKETING_HOST: "craftvia.de" }) === null, "ohne AUTH_URL keine Trennung"); +ok(hostSplit({ MARKETING_HOST: "app.craftvia.de", AUTH_URL: "https://app.craftvia.de" }) === null, "gleiche Domain → keine Trennung"); +ok(hostSplit({ MARKETING_HOST: "https://Craftvia.de/", AUTH_URL: "https://app.craftvia.de" })?.marketingHost === "craftvia.de", "MARKETING_HOST mit Schema/Slash/Großschreibung wird normalisiert"); + +console.log("\n— Website-Domain —"); +ok(to(route("craftvia.de", "/")) === "next", "/ zeigt die Startseite"); +ok(to(route("craftvia.de", "/funktionen/planung")) === "next", "Funktionsseiten bleiben auf der Website"); +ok(to(route("craftvia.de", "/impressum")) === "next", "Impressum bleibt auf der Website"); +ok(to(route("craftvia.de", "/login")) === "308 https://app.craftvia.de/login", "/login → App-Domain"); +ok(to(route("craftvia.de", "/testen?ref=preise")) === "308 https://app.craftvia.de/testen?ref=preise", "/testen → App-Domain, Query bleibt erhalten"); +ok(to(route("craftvia.de", "/dashboard")) === "308 https://app.craftvia.de/dashboard", "App-Seiten → App-Domain"); +ok(to(route("craftvia.de", "/api/v1/sync")) === "308 https://app.craftvia.de/api/v1/sync", "API → App-Domain (308 behält die Methode)"); +ok(to(route("craftvia.de:443", "/preise")) === "next", "Port im Host wird ignoriert"); +ok(to(route("www.craftvia.de", "/preise")) === "308 https://craftvia.de/preise", "www → Hauptdomain"); + +console.log("\n— App-Domain —"); +ok(to(route("app.craftvia.de", "/")) === "307 https://app.craftvia.de/login", "/ ohne Sitzung → Login"); +ok(to(route("app.craftvia.de", "/", true)) === "next", "/ mit Sitzung → weiter (Seite leitet auf Dashboard bzw. /m)"); +ok(to(route("app.craftvia.de", "/funktionen")) === "308 https://craftvia.de/funktionen", "Produktseiten → Website"); +ok(to(route("app.craftvia.de", "/impressum")) === "308 https://craftvia.de/impressum", "Impressum → Website"); +ok(to(route("app.craftvia.de", "/testen")) === "next", "Testphase bleibt in der App"); +ok(to(route("app.craftvia.de", "/planning", true)) === "next", "App-Seiten bleiben in der App"); + +console.log("\n— Interne Aufrufe —"); +ok(to(route("127.0.0.1:3000", "/")) === "next", "Healthcheck über 127.0.0.1 unverändert"); +ok(to(route("craftvia-app:3000", "/login")) === "next", "Container-interne Aufrufe unverändert"); + +console.log(failures ? `\n✗ ${failures} Fehler` : "\n✓ alle Prüfungen grün"); +process.exit(failures ? 1 : 0); diff --git a/src/lib/marketing/hosts.ts b/src/lib/marketing/hosts.ts new file mode 100644 index 0000000..7da65c0 --- /dev/null +++ b/src/lib/marketing/hosts.ts @@ -0,0 +1,58 @@ +/** + * Domain-Trennung Website ↔ App: `craftvia.de` zeigt nur die Produktseiten, `app.craftvia.de` die + * Anwendung. Aktiv nur, wenn `MARKETING_HOST` gesetzt ist; die App-Domain kommt aus `AUTH_URL`. + * Ohne `MARKETING_HOST` (lokal, Tests) läuft alles wie bisher auf einem Host. + * + * Links der Produktseiten bleiben relativ (/login, /testen): der Proxy leitet sie von der + * Website-Domain auf die App-Domain um. So wird keine Domain beim Image-Bau festgeschrieben. + */ + +export const MARKETING_PATHS = ["/", "/funktionen", "/sicherheit", "/preise", "/impressum"] as const; + +export type HostSplit = { marketingHost: string; appHost: string; appOrigin: string }; + +export type HostDecision = { kind: "next" } | { kind: "redirect"; location: string; status: 307 | 308 }; + +export function hostSplit(env: Record = process.env): HostSplit | null { + const marketingHost = env.MARKETING_HOST?.trim() + .toLowerCase() + .replace(/^https?:\/\//, "") + .replace(/[/:].*$/, ""); + const authUrl = env.AUTH_URL?.trim(); + if (!marketingHost || !authUrl) return null; + let app: URL; + try { + app = new URL(authUrl); + } catch { + return null; + } + const appHost = app.hostname.toLowerCase(); + if (appHost === marketingHost) return null; + return { marketingHost, appHost, appOrigin: app.origin }; +} + +export function isMarketingPath(pathname: string): boolean { + return MARKETING_PATHS.some((p) => pathname === p || (p !== "/" && pathname.startsWith(p + "/"))); +} + +export function routeByHost(input: { host: string; pathname: string; search: string; hasSession: boolean }, split: HostSplit): HostDecision { + const host = input.host.toLowerCase().split(",")[0].trim().replace(/:\d+$/, ""); + const target = input.pathname + input.search; + const marketingOrigin = `https://${split.marketingHost}`; + + // www → Hauptdomain + if (host === `www.${split.marketingHost}`) return { kind: "redirect", location: marketingOrigin + target, status: 308 }; + + // Website-Domain: nur Produktseiten, alles andere (Login, Testphase, App, API) gehört zur App + if (host === split.marketingHost) { + return isMarketingPath(input.pathname) ? { kind: "next" } : { kind: "redirect", location: split.appOrigin + target, status: 308 }; + } + + // Interne Aufrufe (Healthcheck über 127.0.0.1, Worker) bleiben unverändert + if (host !== split.appHost) return { kind: "next" }; + + // App-Domain: Produktseiten wohnen auf der Website, die Startseite ist der Login + if (input.pathname !== "/" && isMarketingPath(input.pathname)) return { kind: "redirect", location: marketingOrigin + target, status: 308 }; + if (input.pathname === "/" && !input.hasSession) return { kind: "redirect", location: `${split.appOrigin}/login`, status: 307 }; + return { kind: "next" }; +} diff --git a/src/proxy.ts b/src/proxy.ts index 7d60aac..3c63a03 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,4 +1,5 @@ import { NextResponse, type NextRequest } from "next/server"; +import { hostSplit, routeByHost } from "@/lib/marketing/hosts"; /** * Route gate (Next.js 16 proxy, formerly middleware): redirects anonymous @@ -25,6 +26,25 @@ const matchesAny = (pathname: string, paths: string[]) => export function proxy(request: NextRequest) { const { pathname } = request.nextUrl; + const hasSessionCookie = + request.cookies.has("authjs.session-token") || + request.cookies.has("__Secure-authjs.session-token"); + + // craftvia.de (Website) ↔ app.craftvia.de (App) — nur mit MARKETING_HOST, siehe src/lib/marketing/hosts.ts + const split = hostSplit(); + if (split) { + const decision = routeByHost( + { + host: request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? "", + pathname, + search: request.nextUrl.search, + hasSession: hasSessionCookie, + }, + split, + ); + if (decision.kind === "redirect") return NextResponse.redirect(decision.location, decision.status); + } + if (matchesAny(pathname, PUBLIC_PATHS)) { return NextResponse.next(); } @@ -41,10 +61,6 @@ export function proxy(request: NextRequest) { return NextResponse.next(); } - const hasSessionCookie = - request.cookies.has("authjs.session-token") || - request.cookies.has("__Secure-authjs.session-token"); - if (!hasSessionCookie) { // Versioned API: machine clients (PWA sync, integrations) need a status code, not a login page. if (pathname === "/api/v1" || pathname.startsWith("/api/v1/")) {