Website und App auf getrennten Domains (craftvia.de / app.craftvia.de)
Der Proxy trennt nach Host, sobald MARKETING_HOST gesetzt ist: craftvia.de zeigt nur die Produktseiten und leitet Login, Testphase, App und API auf die App-Domain aus AUTH_URL um; www leitet auf die Hauptdomain; auf der App-Domain landet / ohne Sitzung beim Login und Produktseiten gehen zurück zur Website. Interne Aufrufe (Healthcheck) bleiben unverändert. Links bleiben relativ, damit keine Domain im Image steckt. Dazu Test scripts/test-host-routing.ts, Env-Vorlagen mit MARKETING_HOST und Registry, scripts/generate-coolify-env.sh für lokal erzeugte Geheimnisse und DEPLOY.md §2.1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,10 @@ RLS_ENFORCED=false
|
|||||||
# und ist deshalb NICHT separat zu setzen (in Coolify sonst als „managed" gesperrt).
|
# und ist deshalb NICHT separat zu setzen (in Coolify sonst als „managed" gesperrt).
|
||||||
REDIS_PASSWORD=CHANGE_ME_redis_password
|
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) ---
|
# --- Objektspeicher (Service "garage", S3-kompatibel) ---
|
||||||
# Garage ersetzt MinIO (Community EOL). Buckets/Keys legt NICHT die S3-API an, sondern
|
# 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
|
# 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 (Härtung §1): openssl rand -hex 32 — frisch je Umgebung, NICHT rotierbar, nie ins Artefakt.
|
||||||
PASSWORD_PEPPER=CHANGE_ME_openssl_rand_hex_32
|
PASSWORD_PEPPER=CHANGE_ME_openssl_rand_hex_32
|
||||||
# MFA_ENC_KEY= (leer = aus AUTH_SECRET abgeleitet; nach dem Setzen nicht mehr ändern)
|
# 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:
|
# Hinter Reverse-Proxy (Coolify/Traefik) für Auth.js v5 zwingend, sonst UntrustedHost:
|
||||||
AUTH_TRUST_HOST=true
|
AUTH_TRUST_HOST=true
|
||||||
|
|
||||||
|
|||||||
@@ -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: TOTP-Secrets at-rest (leer = aus AUTH_SECRET). ⚠ Nach dem Setzen nicht mehr ändern.
|
||||||
MFA_ENC_KEY=CHANGE_ME_openssl_rand_hex_32
|
MFA_ENC_KEY=CHANGE_ME_openssl_rand_hex_32
|
||||||
AUTH_URL=https://app.craftvia.example
|
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.
|
# AUTH_TRUST_HOST ist im Compose fest auf true (hinter dem Coolify-Proxy) – nicht nötig.
|
||||||
# Passkeys/WebAuthn: leer = aus AUTH_URL abgeleitet.
|
# Passkeys/WebAuthn: leer = aus AUTH_URL abgeleitet.
|
||||||
# WEBAUTHN_ORIGIN=https://app.craftvia.example
|
# WEBAUTHN_ORIGIN=https://app.craftvia.example
|
||||||
|
|||||||
@@ -59,6 +59,25 @@ Entrypoints von postgres/redis), CPU-/RAM-Limits, gepinnte Image-Tags, non-root-
|
|||||||
Proxies dürfen kein niedrigeres Body-Limit haben.
|
Proxies dürfen kein niedrigeres Body-Limit haben.
|
||||||
- DNS: A/AAAA-Record `app.craftvia.example` → Server-IP; Firewall nur 80/443 + SSH.
|
- 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)
|
## 3. Ersteinrichtung (Coolify)
|
||||||
|
|
||||||
1. **Ressource:** Git-Repo (Deploy-Key, nur lesend), Build Pack **Docker Compose**, Compose Location
|
1. **Ressource:** Git-Repo (Deploy-Key, nur lesend), Build Pack **Docker Compose**, Compose Location
|
||||||
|
|||||||
Executable
+45
@@ -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."
|
||||||
@@ -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);
|
||||||
@@ -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<string, string | undefined> = 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" };
|
||||||
|
}
|
||||||
+20
-4
@@ -1,4 +1,5 @@
|
|||||||
import { NextResponse, type NextRequest } from "next/server";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import { hostSplit, routeByHost } from "@/lib/marketing/hosts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route gate (Next.js 16 proxy, formerly middleware): redirects anonymous
|
* 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) {
|
export function proxy(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl;
|
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)) {
|
if (matchesAny(pathname, PUBLIC_PATHS)) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
@@ -41,10 +61,6 @@ export function proxy(request: NextRequest) {
|
|||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasSessionCookie =
|
|
||||||
request.cookies.has("authjs.session-token") ||
|
|
||||||
request.cookies.has("__Secure-authjs.session-token");
|
|
||||||
|
|
||||||
if (!hasSessionCookie) {
|
if (!hasSessionCookie) {
|
||||||
// Versioned API: machine clients (PWA sync, integrations) need a status code, not a login page.
|
// Versioned API: machine clients (PWA sync, integrations) need a status code, not a login page.
|
||||||
if (pathname === "/api/v1" || pathname.startsWith("/api/v1/")) {
|
if (pathname === "/api/v1" || pathname.startsWith("/api/v1/")) {
|
||||||
|
|||||||
Reference in New Issue
Block a user