Webdesign-Bereich: Fundament, oeffentliche Seiten, Zugangsschutz, PayPal

Umsetzung des "Website Masterplan" (16 Seiten) unter /webdesign.

Zugangsschutz mit EIGENER Schranke (server/webdesign-gate.js) statt gate.js:
gate.js laesst seit dem oeffentlichen Start am 21.08.2026 jeden durch, weil
die Pruefung auf SITE_PUBLIC_LAUNCH_AT als allererste Zeile steht. Haette man
/webdesign dahintergehaengt, waere der ausdruecklich nicht-oeffentliche Bereich
inklusive Preisen und spaeteren Kundendaten ab der ersten Sekunde fuer jeden
lesbar gewesen. Eigenes Sitzungs-Cookie, bereich="webdesign" im Token, damit
ein gueltiges Universe-Cookie hier NICHT gilt. 37/37 Tests.

Sieben oeffentliche Seiten in fuenf Sprachen (de, de-CH mit echtem Dialekt, en,
fr, pt). Preise, Zeitrahmen, Paketnamen und die 30-%-Regel stehen an genau
EINER Stelle in wd-core.js -- der Masterplan verlangt "ueberall
widerspruchsfrei", und vier Kopien laufen bei der ersten Preisaenderung
auseinander.

Als App installierbar auf Handy und PC. Der Service Worker speichert bewusst
KEINE HTML-Seite zwischen: nach dem Abmelden wuerden sonst geschuetzte Seiten
weiter ausgeliefert, ohne dass der Server je gefragt wird. 18/18 Tests.

Handy-Abnahme ueber alle Seiten in fuenf Breiten (320-1440) und fuenf Sprachen:
40/40. Der Test fand 35 echte Fehler (Touch-Ziele unter 44px), behoben im
Designsystem statt einzeln pro Seite.

PayPal (Wunsch 22.08.2026 "sofort auf meinem paypal"): Orders API mit
intent=CAPTURE, also sofortiger Einzug statt blosser Reservierung. Gebuehr und
Nettobetrag getrennt gespeichert. Betraege durchgehend als Ganzzahl in Cent.
Gefaelschte Webhooks werden abgewiesen. Fail closed solange Zugangsdaten
fehlen. 28/28 Tests gegen einen nachgebauten PayPal-Server.

Datenbank: 15 Tabellen mit Praefix wd_, fachlich vollstaendig vom Universe
getrennt.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-22 18:06:52 +02:00
co-authored by Claude Opus 5
parent 2010b5ec33
commit 4b3ec450d5
50 changed files with 8264 additions and 0 deletions
+23
View File
@@ -15,3 +15,26 @@ SITE_ACCESS_CODE_DIENE=
# automatischer Start, die Schranke bleibt dauerhaft aktiv.
# Beispiel (21 Uhr deutscher Sommerzeit): 2026-08-21T21:00:00+02:00
SITE_PUBLIC_LAUNCH_AT=
# ---------------------------------------------------------------------------
# Webdesign-Bereich (/webdesign), 22.08.2026 — eigener Zugangsschutz.
# Bewusst UNABHAENGIG von SITE_PUBLIC_LAUNCH_AT oben: der oeffentliche Start
# des Universe darf diesen Bereich nicht mit aufsperren.
# ---------------------------------------------------------------------------
# Status: entwurf | vorabpruefung | freigegeben
# entwurf = komplett dicht, auch mit richtigem Code kein Zugang
# vorabpruefung = Zugang nur ueber die persoenlichen Codes (Standard)
# freigegeben = oeffentliche Seiten frei; die Verwaltung bleibt geschuetzt
WEBDESIGN_STATUS=vorabpruefung
# Leer lassen = die SITE_ACCESS_CODE_*-Codes von oben gelten auch hier
# (so eingerichtet auf Wunsch vom 22.08.2026). Wird hier spaeter ein eigener
# Wert eingetragen, gilt NUR noch dieser -- damit laesst sich der
# Webdesign-Zugang getrennt aendern oder entziehen, ohne den Universe-Zugang
# anzufassen.
WEBDESIGN_CODE_DOGI=
WEBDESIGN_CODE_VANVAN=
# Eigenes Signatur-Geheimnis fuer die Webdesign-Sitzung. Leer lassen = es
# wird SITE_ACCESS_SECRET benutzt. Ein Universe-Cookie funktioniert hier
# trotzdem nicht (die Sitzung traegt zusaetzlich bereich="webdesign").
WEBDESIGN_ACCESS_SECRET=
+9
View File
@@ -11,6 +11,7 @@ import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { gateMiddleware } from "./gate.js";
import { webdesignGate } from "./webdesign-gate.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const SITE_DIR = join(__dirname, "..");
@@ -37,6 +38,14 @@ app.use(cookieParser());
app.use(express.json({ limit: "1mb" }));
app.use(gateMiddleware);
/* Zugangsschutz für /webdesign (22.08.2026). Muss NACH gateMiddleware und
VOR express.static stehen. Eine eigene Schranke ist zwingend: gate.js
lässt seit dem öffentlichen Start am 21.08.2026 jeden durch
(SITE_PUBLIC_LAUNCH_AT liegt in der Vergangenheit) — der ausdrücklich
nicht-öffentliche Webdesign-Bereich wäre darüber sofort für alle
lesbar. Ausführliche Begründung im Kopf von webdesign-gate.js. */
app.use(webdesignGate);
/* Sicherheits-Fix 20.08.2026 (Audit vor dem geplanten öffentlichen Start morgen).
SITE_DIR ist der GESAMTE Repo-Ordner (join(__dirname, "..")) — express.static liefert
ohne diese Sperre daher nicht nur die eigentliche Website aus, sondern auch den Server-
+62
View File
@@ -0,0 +1,62 @@
/* Temporäres Prüfskript (kein Deploy-Bestandteil): macht Screenshots des
Webdesign-Bereichs im Handy- und Desktopformat und meldet Konsolenfehler.
Aufruf: node tmp-screenshot.mjs <pfad> <name> [--code]
--code = vorher an der Zugangswand anmelden. */
import { chromium } from "playwright";
const pfad = process.argv[2] || "/webdesign/zugang.html";
const name = process.argv[3] || "seite";
const mitCode = process.argv.includes("--code");
const BASIS = "http://127.0.0.1:4199";
const browser = await chromium.launch();
const kontext = await browser.newContext();
const seite = await kontext.newPage();
const fehler = [];
seite.on("console", (m) => { if (m.type() === "error") fehler.push(m.text()); });
seite.on("pageerror", (e) => fehler.push("pageerror: " + e.message));
if (mitCode) {
await seite.goto(BASIS + "/webdesign/zugang.html", { waitUntil: "networkidle" });
await seite.fill("#zg-code-dogfather", "DOGI-TEST-1");
/* Auf die durch location.href ausgeloeste Navigation warten, statt nur
auf networkidle -- sonst startet der naechste goto() mitten in einer
laufenden Navigation und Chrome bricht beide mit ERR_ABORTED ab. */
await Promise.all([
seite.waitForURL((u) => !u.pathname.endsWith("zugang.html"), { timeout: 15000 }),
seite.click("#zg-form-dogfather button[type=submit]"),
]);
await seite.waitForLoadState("networkidle");
}
for (const [breite, hoehe, kuerzel] of [[390, 844, "handy"], [1440, 900, "desktop"]]) {
await seite.setViewportSize({ width: breite, height: hoehe });
await seite.goto(BASIS + pfad, { waitUntil: "networkidle" });
/* Marken-Intro wegklicken, falls es laeuft -- sonst liegt es auf dem Bild. */
const skip = seite.locator("#wd-intro-skip");
if (await skip.isVisible().catch(() => false)) await skip.click();
/* WICHTIG: Ein fullPage-Screenshot loest KEINEN IntersectionObserver aus.
Ohne dieses Durchscrollen bleiben alle .wd-auf-Elemente unsichtbar und
das Bild zeigt eine praktisch leere Seite -- was wie ein schwerer
Fehler aussieht, aber nur ein Messfehler waere. Deshalb einmal in
Schritten nach unten scrollen, damit jeder Abschnitt eingeblendet
wird, danach zurueck nach oben. */
await seite.evaluate(async () => {
const schritt = window.innerHeight * 0.8;
for (let y = 0; y < document.body.scrollHeight; y += schritt) {
window.scrollTo(0, y);
await new Promise((r) => setTimeout(r, 120));
}
window.scrollTo(0, 0);
await new Promise((r) => setTimeout(r, 300));
});
await seite.waitForTimeout(900); // Einblend-Animationen zu Ende laufen lassen
await seite.screenshot({ path: `tmp-${name}-${kuerzel}.png`, fullPage: kuerzel === "desktop" });
console.log(`tmp-${name}-${kuerzel}.png`);
}
console.log(fehler.length ? "KONSOLENFEHLER:\n" + fehler.join("\n") : "Keine Konsolenfehler.");
await browser.close();
+154
View File
@@ -0,0 +1,154 @@
/* =====================================================================
pruefe-webdesign-handy.mjs — Handy-Abnahme für alle Webdesign-Seiten.
Prüft je Seite und je Bildschirmbreite (320 / 390 / 430 / 768 / 1440):
- kein waagerechter Überlauf (der häufigste Handy-Fehler überhaupt)
- kein Element ragt über den rechten Rand hinaus
- alle Knöpfe und Links sind mindestens 44px hoch (Touch-Ziel)
- keine Konsolenfehler
- in allen fünf Sprachen: keine unübersetzten Schlüssel sichtbar
320px ist bewusst dabei: das ist ein iPhone SE der ersten Generationen
und immer noch die Breite, bei der Layouts zuerst brechen.
Aufruf (lokaler Testserver muss laufen):
node pruefe-webdesign-handy.mjs
===================================================================== */
import { chromium } from "playwright";
const BASIS = process.env.WD_BASIS || "http://127.0.0.1:4199";
const CODE = process.env.WD_CODE || "DOGI-TEST-1";
const SEITEN = [
"/webdesign/",
"/webdesign/leistungen.html",
"/webdesign/preise.html",
"/webdesign/portfolio.html",
"/webdesign/ablauf.html",
"/webdesign/ueber.html",
"/webdesign/faq.html",
];
const BREITEN = [320, 390, 430, 768, 1440];
const SPRACHEN = ["de", "de-CH", "en", "fr", "pt"];
let ok = 0;
const probleme = [];
const browser = await chromium.launch();
const kontext = await browser.newContext();
const seite = await kontext.newPage();
const konsole = [];
seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); });
seite.on("pageerror", (e) => konsole.push("pageerror: " + e.message));
// --- Anmelden ---
await seite.goto(BASIS + "/webdesign/zugang.html", { waitUntil: "networkidle" });
await seite.fill("#zg-code-dogfather", CODE);
await Promise.all([
seite.waitForURL((u) => !u.pathname.endsWith("zugang.html"), { timeout: 15000 }),
seite.click("#zg-form-dogfather button[type=submit]"),
]);
console.log("\n=== Handy-Abnahme ===\n");
for (const pfad of SEITEN) {
console.log(pfad);
for (const breite of BREITEN) {
await seite.setViewportSize({ width: breite, height: 900 });
await seite.goto(BASIS + pfad, { waitUntil: "networkidle" });
// Marken-Intro wegklicken, es liegt sonst über allem.
const skip = seite.locator("#wd-intro-skip");
if (await skip.isVisible().catch(() => false)) await skip.click();
await seite.waitForTimeout(250);
const befund = await seite.evaluate((vp) => {
const raus = [];
// Waagerechter Überlauf des ganzen Dokuments
const ueberlauf = document.documentElement.scrollWidth > vp + 1;
// Einzelne Elemente, die über den rechten Rand ragen
for (const el of document.querySelectorAll("body *")) {
const s = getComputedStyle(el);
if (s.display === "none" || s.visibility === "hidden") continue;
// Absichtlich waagerecht scrollbare Leisten ausnehmen
if (el.closest(".ls-sprung, .fq-sprung, .wd-tabelle-rahmen")) continue;
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) continue;
if (r.right > vp + 1.5) {
raus.push((el.tagName.toLowerCase() + "." + (el.className || "").toString().split(" ")[0])
.slice(0, 50) + " bis " + Math.round(r.right) + "px");
}
}
// Zu kleine Touch-Ziele
const klein = [];
for (const el of document.querySelectorAll("a, button, summary, input, select")) {
const s = getComputedStyle(el);
if (s.display === "none" || s.visibility === "hidden") continue;
const r = el.getBoundingClientRect();
if (r.height === 0 || r.width === 0) continue;
if (r.height < 44) {
klein.push((el.tagName.toLowerCase() + " „" + (el.textContent || "").trim().slice(0, 24) + "“ ")
+ Math.round(r.height) + "px");
}
}
// Unübersetzte Schlüssel: erscheinen als roher Schlüsselname im Text
const roh = [];
for (const el of document.querySelectorAll("[data-i18n]")) {
const txt = (el.textContent || "").trim();
if (txt === el.getAttribute("data-i18n")) roh.push(txt);
}
return { ueberlauf, raus: raus.slice(0, 5), klein: klein.slice(0, 5), roh: roh.slice(0, 5) };
}, breite);
const zeile = ` ${String(breite).padStart(4)}px`;
const fehler = [];
if (befund.ueberlauf) fehler.push("waagerechter Ueberlauf");
if (befund.raus.length) fehler.push("ragt heraus: " + befund.raus.join(", "));
if (befund.klein.length) fehler.push("Touch-Ziel < 44px: " + befund.klein.join(", "));
if (befund.roh.length) fehler.push("unuebersetzt: " + befund.roh.join(", "));
if (fehler.length) {
probleme.push(`${pfad} @ ${breite}px -> ${fehler.join(" | ")}`);
console.log(`${zeile} FEHLER: ${fehler.join(" | ")}`);
} else {
ok++;
console.log(`${zeile} ok`);
}
}
}
// --- Sprachen: einmal je Sprache die Startseite prüfen ---
console.log("\nSprachen (Startseite, 390px)");
await seite.setViewportSize({ width: 390, height: 900 });
for (const sprache of SPRACHEN) {
await seite.goto(BASIS + "/webdesign/", { waitUntil: "networkidle" });
const skip = seite.locator("#wd-intro-skip");
if (await skip.isVisible().catch(() => false)) await skip.click();
await seite.evaluate((s) => window.WD.setSprache(s), sprache);
await seite.waitForTimeout(350);
const befund = await seite.evaluate((vp) => {
const roh = [];
for (const el of document.querySelectorAll("[data-i18n]")) {
const txt = (el.textContent || "").trim();
if (txt === el.getAttribute("data-i18n")) roh.push(txt);
}
return { roh: roh.slice(0, 5), ueberlauf: document.documentElement.scrollWidth > vp + 1, lang: document.documentElement.lang };
}, 390);
const fehler = [];
if (befund.lang !== sprache) fehler.push("lang-Attribut ist " + befund.lang);
if (befund.roh.length) fehler.push("unuebersetzt: " + befund.roh.join(", "));
if (befund.ueberlauf) fehler.push("waagerechter Ueberlauf");
if (fehler.length) { probleme.push(`Sprache ${sprache} -> ${fehler.join(" | ")}`); console.log(` ${sprache.padEnd(6)} FEHLER: ${fehler.join(" | ")}`); }
else { ok++; console.log(` ${sprache.padEnd(6)} ok`); }
}
await browser.close();
console.log(konsole.length ? "\nKONSOLENFEHLER:\n" + [...new Set(konsole)].join("\n") : "\nKeine Konsolenfehler.");
console.log(`\n=== ${ok} ok, ${probleme.length} Probleme ===`);
if (probleme.length) probleme.forEach((p) => console.log(" " + p));
process.exit(probleme.length === 0 && konsole.length === 0 ? 0 : 1);
+104
View File
@@ -0,0 +1,104 @@
/* Prüft für jede Webdesign-Seite:
1. jeder data-i18n-Schlüssel existiert im Wörterbuch
2. jeder Schlüssel hat ALLE fünf Sprachen
3. keine Überschrift h1-h4 ohne data-i18n (der dokumentierte
Bug-Klassiker aus DogiHompage/CLAUDE.md)
4. keine unbenutzten Schlüssel (Hinweis, kein Fehler)
Aufruf: node tmp-i18n-check.mjs */
import { readFileSync, existsSync, readdirSync } from "node:fs";
import { chdir } from "node:process";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
/* Immer vom Projektordner aus arbeiten, egal von wo aufgerufen. */
chdir(join(dirname(fileURLToPath(import.meta.url)), ".."));
const SPRACHEN = ["de", "de-CH", "en", "fr", "pt"];
const KERN = "assets/js/wd-core.js";
/* Schlüssel, die wd-core.js selbst mitbringt (Navigation, Fußzeile,
Fehlermeldungen) — die dürfen in jeder Seite benutzt werden. */
const kernQuelle = readFileSync(KERN, "utf8");
const kernSchluessel = new Set(
[...kernQuelle.matchAll(/^\s{4}([a-z0-9_]+):\s*\{/gm)].map((m) => m[1])
);
let fehler = 0;
const seiten = existsSync("webdesign")
? readdirSync("webdesign").filter((f) => f.endsWith(".html"))
: [];
for (const datei of seiten) {
const name = datei.replace(/\.html$/, "");
const html = readFileSync(`webdesign/${datei}`, "utf8");
const i18nDatei = `assets/js/i18n-wd-${name}.js`;
console.log(`\n--- ${datei} ---`);
if (!existsSync(i18nDatei)) {
console.log(` FEHLER: ${i18nDatei} fehlt`);
fehler++;
continue;
}
// Wörterbuch laden (setzt window.I18N_WD)
global.window = {};
// eslint-disable-next-line no-eval
eval(readFileSync(i18nDatei, "utf8"));
const woerterbuch = global.window.I18N_WD || {};
// --- 1+2: benutzte Schlüssel prüfen ---
const benutzt = new Set();
for (const m of html.matchAll(/data-i18n="([^"]+)"/g)) benutzt.add(m[1]);
for (const m of html.matchAll(/data-i18n-attr="([^"]+)"/g)) {
m[1].split(";").forEach((teil) => {
const pos = teil.indexOf(":");
if (pos > 0) benutzt.add(teil.slice(pos + 1).trim());
});
}
const titelKey = (html.match(/data-i18n-titel="([^"]+)"/) || [])[1];
if (titelKey) benutzt.add(titelKey);
// Schlüssel, die nur im Seitenskript per WD.t(...) geholt werden
for (const m of html.matchAll(/WD\.t\("([a-z0-9_]+)"\)/g)) benutzt.add(m[1]);
const fehlend = [];
const unvollstaendig = [];
for (const k of benutzt) {
const eintrag = woerterbuch[k];
if (!eintrag) {
if (!kernSchluessel.has(k)) fehlend.push(k);
continue;
}
for (const s of SPRACHEN) if (!eintrag[s]) unvollstaendig.push(`${k}:${s}`);
}
// --- 3: Überschriften ohne data-i18n ---
const ohneI18n = [];
for (const m of html.matchAll(/<(h[1-4])\b([^>]*)>([\s\S]*?)<\/\1>/g)) {
if (m[2].includes("data-i18n")) continue;
const text = m[3].replace(/<[^>]*>/g, "").trim();
if (!text) continue; // rein dekorativ
if (/^\$\{/.test(text)) continue; // dynamisch aus JS
ohneI18n.push(`${m[1]}: ${text.slice(0, 60)}`);
}
// --- 4: unbenutzte Schlüssel (nur Hinweis) ---
const unbenutzt = Object.keys(woerterbuch).filter((k) => !benutzt.has(k));
console.log(` Schluessel im HTML: ${benutzt.size}, im Woerterbuch: ${Object.keys(woerterbuch).length}`);
if (fehlend.length) { console.log(` FEHLENDE Schluessel: ${fehlend.join(", ")}`); fehler += fehlend.length; }
if (unvollstaendig.length) { console.log(` FEHLENDE Sprachen: ${unvollstaendig.join(", ")}`); fehler += unvollstaendig.length; }
if (ohneI18n.length) { console.log(` Ueberschrift ohne data-i18n:\n ${ohneI18n.join("\n ")}`); fehler += ohneI18n.length; }
if (unbenutzt.length) console.log(` Hinweis, unbenutzt: ${unbenutzt.join(", ")}`);
if (!fehlend.length && !unvollstaendig.length && !ohneI18n.length) console.log(" alles vollstaendig");
// --- Tag-Balance grob prüfen ---
for (const tag of ["div", "section", "main", "article", "details"]) {
const auf = (html.match(new RegExp(`<${tag}\\b`, "g")) || []).length;
const zu = (html.match(new RegExp(`</${tag}>`, "g")) || []).length;
if (auf !== zu) { console.log(` TAG-UNGLEICHGEWICHT <${tag}>: ${auf} offen, ${zu} geschlossen`); fehler++; }
}
}
console.log(`\n=== ${fehler === 0 ? "Keine Fehler" : fehler + " Fehler"} ===`);
process.exit(fehler === 0 ? 0 : 1);
+88
View File
@@ -0,0 +1,88 @@
/* Prüft die App-Installierbarkeit des Webdesign-Bereichs:
- Manifest erreichbar, gültig, Icons laden
- Service Worker registriert sich und wird aktiv
- HTML wird NICHT zwischengespeichert (Sicherheitsvorgabe)
- Bausteine unter /assets/ werden zwischengespeichert */
import { chromium } from "playwright";
const BASIS = "http://127.0.0.1:4199";
const browser = await chromium.launch();
const kontext = await browser.newContext();
const seite = await kontext.newPage();
const fehler = [];
seite.on("console", (m) => { if (m.type() === "error") fehler.push(m.text()); });
seite.on("pageerror", (e) => fehler.push("pageerror: " + e.message));
let ok = 0, schlecht = 0;
const pruefe = (name, b, zusatz = "") => {
if (b) { ok++; console.log(" ok " + name); }
else { schlecht++; console.log(" FEHLT " + name + " " + zusatz); }
};
console.log("\n=== App-Installierbarkeit ===\n");
// --- Manifest ohne Anmeldung erreichbar (Installation von der Zugangswand) ---
console.log("1. Manifest");
const mRes = await seite.request.get(BASIS + "/webdesign/app.webmanifest");
pruefe("ohne Anmeldung erreichbar", mRes.status() === 200, "(war " + mRes.status() + ")");
const manifest = await mRes.json().catch(() => null);
pruefe("gueltiges JSON", !!manifest);
if (manifest) {
pruefe("start_url im Webdesign-Bereich", manifest.start_url === "/webdesign/");
pruefe("scope begrenzt auf /webdesign/", manifest.scope === "/webdesign/");
pruefe("display standalone", manifest.display === "standalone");
pruefe("192er-Symbol vorhanden", manifest.icons.some((i) => i.sizes === "192x192"));
pruefe("512er-Symbol vorhanden", manifest.icons.some((i) => i.sizes === "512x512"));
pruefe("maskierbares Symbol vorhanden", manifest.icons.some((i) => (i.purpose || "").includes("maskable")));
for (const symbol of manifest.icons) {
const r = await seite.request.get(BASIS + symbol.src);
pruefe("Symbol laedt: " + symbol.src, r.status() === 200);
}
}
const swRes = await seite.request.get(BASIS + "/webdesign/sw.js");
pruefe("Service Worker ohne Anmeldung erreichbar", swRes.status() === 200, "(war " + swRes.status() + ")");
pruefe("wird als JavaScript ausgeliefert",
(swRes.headers()["content-type"] || "").includes("javascript"),
"(war " + swRes.headers()["content-type"] + ")");
// --- Registrierung im Browser ---
console.log("\n2. Service Worker im Browser");
await seite.goto(BASIS + "/webdesign/zugang.html", { waitUntil: "networkidle" });
await seite.fill("#zg-code-dogfather", "DOGI-TEST-1");
await Promise.all([
seite.waitForURL((u) => !u.pathname.endsWith("zugang.html"), { timeout: 15000 }),
seite.click("#zg-form-dogfather button[type=submit]"),
]);
await seite.waitForLoadState("networkidle");
const aktiv = await seite.evaluate(async () => {
const reg = await navigator.serviceWorker.ready.catch(() => null);
return reg ? { scope: reg.scope, aktiv: !!reg.active } : null;
});
pruefe("registriert und aktiv", !!(aktiv && aktiv.aktiv));
pruefe("Geltungsbereich ist /webdesign/", !!(aktiv && aktiv.scope.endsWith("/webdesign/")), aktiv ? aktiv.scope : "");
// --- Sicherheitsvorgabe: keine HTML-Seiten im Zwischenspeicher ---
console.log("\n3. Sicherheit des Zwischenspeichers");
await seite.reload({ waitUntil: "networkidle" });
await seite.waitForTimeout(1200);
const inhalt = await seite.evaluate(async () => {
const namen = await caches.keys();
const alles = [];
for (const n of namen) {
const c = await caches.open(n);
for (const anfrage of await c.keys()) alles.push(anfrage.url);
}
return alles;
});
console.log(" Zwischenspeicher enthaelt " + inhalt.length + " Eintraege");
const htmlDrin = inhalt.filter((u) => u.endsWith(".html") || new URL(u).pathname === "/webdesign/");
pruefe("KEINE geschuetzte HTML-Seite zwischengespeichert", htmlDrin.length === 0, htmlDrin.join(", "));
pruefe("Stilvorlage ist zwischengespeichert", inhalt.some((u) => u.includes("webdesign.css")));
pruefe("Kern-Skript ist zwischengespeichert", inhalt.some((u) => u.includes("wd-core.js")));
console.log(fehler.length ? "\nKONSOLENFEHLER:\n" + fehler.join("\n") : "\nKeine Konsolenfehler.");
await browser.close();
console.log(`\n=== ${ok} bestanden, ${schlecht} gescheitert ===\n`);
process.exit(schlecht === 0 && fehler.length === 0 ? 0 : 1);
+238
View File
@@ -0,0 +1,238 @@
/* =====================================================================
test-webdesign-gate.mjs — Selbsttest der Webdesign-Zugangsschranke.
Ausführen (aus dem Ordner server/):
node test-webdesign-gate.mjs
Startet einen eigenen Server auf einem freien Port mit Test-Codes und
spielt die sicherheitskritischen Fälle durch. Rührt weder die echte
.env noch den laufenden Dienst an.
Warum ein eigener Test und nicht "einmal im Browser klicken":
Die gefährlichen Fälle sind genau die, die man beim Klicken NICHT sieht —
ein Universe-Cookie, das hier akzeptiert wird; ein "next", das auf eine
fremde Domain zeigt; eine Sperre, die nicht greift.
===================================================================== */
process.env.SITE_ACCESS_SECRET = "test-geheimnis-nur-fuer-diesen-lauf";
process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
process.env.SITE_ACCESS_CODE_VANVAN = "VANVAN-TEST-1";
process.env.WEBDESIGN_STATUS = "vorabpruefung";
// Wichtig für den Kernfall: das Universe ist öffentlich, /webdesign nicht.
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const { default: express } = await import("express");
const { default: cookieParser } = await import("cookie-parser");
const { webdesignGate, wdSigniere, wdPruefe } = await import("./webdesign-gate.js");
const { signSession } = await import("./gate.js");
const app = express();
app.set("trust proxy", 1);
app.use(cookieParser());
app.use(express.json());
app.use(webdesignGate);
// Ersatz für express.static — antwortet nur, wenn die Schranke durchlässt.
app.use((req, res) => res.status(200).send("GEHEIMER INHALT"));
const server = app.listen(0, "127.0.0.1");
await new Promise((r) => server.once("listening", r));
const basis = `http://127.0.0.1:${server.address().port}`;
let bestanden = 0;
let gescheitert = 0;
function pruefe(name, bedingung, zusatz = "") {
if (bedingung) {
bestanden++;
console.log(` ok ${name}`);
} else {
gescheitert++;
console.log(` FEHLT ${name} ${zusatz}`);
}
}
async function hole(pfad, optionen = {}) {
const res = await fetch(basis + pfad, { redirect: "manual", ...optionen });
const text = await res.text();
let json = null;
try { json = JSON.parse(text); } catch { /* kein JSON, egal */ }
return { res, text, json, cookies: res.headers.getSetCookie?.() || [] };
}
function cookieAus(cookies, name) {
const treffer = cookies.find((c) => c.startsWith(name + "="));
return treffer ? treffer.split(";")[0].split("=").slice(1).join("=") : null;
}
console.log("\n=== Webdesign-Zugangsschranke ===\n");
/* --- 1. Der Kernfall: Universe ist öffentlich, /webdesign trotzdem dicht --- */
console.log("1. Grundschutz");
{
const a = await hole("/webdesign/");
pruefe("ohne Code -> Umleitung zur Zugangswand", a.res.status === 302, `(war ${a.res.status})`);
pruefe("Umleitungsziel ist die Zugangswand",
(a.res.headers.get("location") || "").startsWith("/webdesign/zugang.html"));
pruefe("Inhalt wird NICHT mitgeschickt", !a.text.includes("GEHEIMER INHALT"));
const b = await hole("/webdesign/preise.html");
pruefe("auch Unterseiten sind dicht", b.res.status === 302);
const c = await hole("/webdesign/zugang.html");
pruefe("Zugangswand selbst ist erreichbar", c.res.status === 200);
const d = await hole("/index.html");
pruefe("Universe bleibt unberührt öffentlich", d.res.status === 200);
const e = await hole("/webdesign/");
pruefe("noindex-Header ist gesetzt",
(e.res.headers.get("x-robots-tag") || "").includes("noindex"));
}
/* --- 2. Anmeldung --- */
console.log("\n2. Anmeldung");
let sitzungDogi = null;
{
const falsch = await hole("/webdesign/zugang-pruefen", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: "irgendwas" }),
});
pruefe("falscher Code -> 401", falsch.res.status === 401);
const richtig = await hole("/webdesign/zugang-pruefen", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: "DOGI-TEST-1" }),
});
pruefe("richtiger Code -> ok", richtig.res.status === 200 && richtig.json?.ok === true);
pruefe("Rolle wird gemeldet", richtig.json?.rolle === "dogfather");
sitzungDogi = cookieAus(richtig.cookies, "dogiwd_session");
pruefe("Sitzungs-Cookie wird gesetzt", !!sitzungDogi);
pruefe("Cookie ist httpOnly", richtig.cookies.some((c) => c.startsWith("dogiwd_session=") && /httponly/i.test(c)));
pruefe("Cookie ist secure", richtig.cookies.some((c) => c.startsWith("dogiwd_session=") && /secure/i.test(c)));
pruefe("Cookie hat KEIN Ablaufdatum (nur Browser-Sitzung)",
richtig.cookies.some((c) => c.startsWith("dogiwd_session=") && !/max-age|expires/i.test(c)));
const vanvan = await hole("/webdesign/zugang-pruefen", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: "VANVAN-TEST-1" }),
});
pruefe("VanVans Code funktioniert gleichwertig", vanvan.json?.rolle === "vanvan");
const mit = await hole("/webdesign/", { headers: { Cookie: `dogiwd_session=${sitzungDogi}` } });
pruefe("mit gültiger Sitzung -> Inhalt kommt", mit.res.status === 200 && mit.text.includes("GEHEIMER INHALT"));
}
/* --- 3. Weiterleitungsziel kann nicht auf eine fremde Seite zeigen --- */
console.log("\n3. Weiterleitungsziel");
{
const faelle = [
["//boese-seite.de", "protokoll-relative fremde Domain"],
["https://boese-seite.de", "vollstaendige fremde URL"],
["/webdesign/../../etc/passwd", "Pfadwechsel nach oben"],
["/index.html", "ausserhalb des Bereichs"],
["/webdesign\\boese", "Rueckwaertsschraegstrich"],
];
for (const [wert, was] of faelle) {
const a = await hole("/webdesign/zugang-pruefen", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: "DOGI-TEST-1", next: wert }),
});
pruefe(`abgewehrt: ${was}`, a.json?.next === "/webdesign/", `(wurde ${a.json?.next})`);
}
const gut = await hole("/webdesign/zugang-pruefen", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: "DOGI-TEST-1", next: "/webdesign/preise.html" }),
});
pruefe("eigener Pfad wird uebernommen", gut.json?.next === "/webdesign/preise.html");
}
/* --- 4. Ein Universe-Cookie darf hier NICHT gelten --- */
console.log("\n4. Trennung vom Universe-Zugang");
{
const universeToken = await signSession("dogi", process.env.SITE_ACCESS_SECRET);
const a = await hole("/webdesign/", { headers: { Cookie: `dogiwd_session=${universeToken}` } });
pruefe("Universe-Sitzung oeffnet den Webdesign-Bereich NICHT", a.res.status === 302);
pruefe("wdPruefe lehnt Universe-Token ab", wdPruefe(universeToken, process.env.SITE_ACCESS_SECRET) === null);
const fremd = wdSigniere("dogfather", "ein-ganz-anderes-geheimnis");
const b = await hole("/webdesign/", { headers: { Cookie: `dogiwd_session=${fremd}` } });
pruefe("mit falschem Geheimnis signiert -> abgelehnt", b.res.status === 302);
const verdreht = sitzungDogi.split(".")[0] + "." + "AAAA";
const c = await hole("/webdesign/", { headers: { Cookie: `dogiwd_session=${verdreht}` } });
pruefe("verfaelschte Signatur -> abgelehnt", c.res.status === 302);
const abgelaufen = (() => {
const inhalt = Buffer.from(JSON.stringify({ bereich: "webdesign", rolle: "dogfather", exp: Date.now() - 1000 }))
.toString("base64url");
// absichtlich ohne gueltige Signatur -- muss doppelt scheitern
return inhalt + ".xxx";
})();
const d = await hole("/webdesign/", { headers: { Cookie: `dogiwd_session=${abgelaufen}` } });
pruefe("abgelaufene/ungueltige Sitzung -> abgelehnt", d.res.status === 302);
}
/* --- 5. Fehlversuchs-Sperre --- */
console.log("\n5. Fehlversuchs-Sperre");
{
let letzte = null;
for (let i = 0; i < 6; i++) {
letzte = await hole("/webdesign/zugang-pruefen", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: "falsch-" + i }),
});
}
pruefe("nach fuenf Fehlversuchen greift die Sperre (429)", letzte.res.status === 429, `(war ${letzte.res.status})`);
const trotzRichtig = await hole("/webdesign/zugang-pruefen", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: "DOGI-TEST-1" }),
});
pruefe("Sperre gilt auch fuer den richtigen Code", trotzRichtig.res.status === 429);
pruefe("Meldung nennt die Wartezeit", /Minute/.test(trotzRichtig.json?.error || ""));
const andere = await hole("/webdesign/zugang-pruefen", {
method: "POST",
headers: { "Content-Type": "application/json", "CF-Connecting-IP": "203.0.113.77" },
body: JSON.stringify({ code: "DOGI-TEST-1" }),
});
pruefe("andere Besucher sind NICHT mitgesperrt", andere.res.status === 200,
"(genau dieser Fehler trat am 19.08.2026 im Universe auf)");
}
/* --- 6. Statusmodi --- */
console.log("\n6. Zugangsstatus");
{
process.env.WEBDESIGN_STATUS = "entwurf";
const a = await hole("/webdesign/zugang-pruefen", {
method: "POST", headers: { "Content-Type": "application/json", "CF-Connecting-IP": "203.0.113.90" },
body: JSON.stringify({ code: "DOGI-TEST-1" }),
});
pruefe("ENTWURF: auch der richtige Code oeffnet nicht", a.res.status === 403);
process.env.WEBDESIGN_STATUS = "freigegeben";
const b = await hole("/webdesign/preise.html");
pruefe("FREIGEGEBEN: oeffentliche Seite ohne Code erreichbar", b.res.status === 200);
const c = await hole("/webdesign/verwaltung.html");
pruefe("FREIGEGEBEN: Verwaltung bleibt trotzdem geschuetzt", c.res.status === 302);
const d = await hole("/webdesign/preise.html");
pruefe("FREIGEGEBEN: kein noindex mehr", !(d.res.headers.get("x-robots-tag") || "").includes("noindex"));
process.env.WEBDESIGN_STATUS = "vorabpruefung";
}
/* --- 7. Ohne Geheimnis muss zugesperrt werden, nicht aufgesperrt --- */
console.log("\n7. Fehlende Einstellung");
{
const gemerkt = process.env.SITE_ACCESS_SECRET;
delete process.env.SITE_ACCESS_SECRET;
const a = await hole("/webdesign/");
pruefe("ohne Geheimnis -> 503 statt offener Tuer", a.res.status === 503, `(war ${a.res.status})`);
pruefe("Inhalt bleibt geheim", !a.text.includes("GEHEIMER INHALT"));
process.env.SITE_ACCESS_SECRET = gemerkt;
}
server.close();
console.log(`\n=== ${bestanden} bestanden, ${gescheitert} gescheitert ===\n`);
process.exit(gescheitert === 0 ? 0 : 1);
+43
View File
@@ -0,0 +1,43 @@
/* Prüft den Steuerzeichen-Filter aus webdesign-helfer.js isoliert.
Bewusst mit String.fromCharCode statt echten Steuerzeichen im Quelltext. */
const f = (w, max = 2000) =>
String(w).replace(/[^\P{Cc}\n\t]/gu, "").trim().slice(0, max);
const NUL = String.fromCharCode(0);
const BELL = String.fromCharCode(7);
const CR = String.fromCharCode(13);
const ESC = String.fromCharCode(27);
const DEL = String.fromCharCode(127);
const NL = "\n";
const TAB = "\t";
const proben = [
["einfacher Text", "Hallo Welt", "Hallo Welt"],
["Zeilenumbruch bleibt", "Zeile1" + NL + "Zeile2", "Zeile1" + NL + "Zeile2"],
["Tabulator bleibt", "Tab" + TAB + "hier", "Tab" + TAB + "hier"],
["Nullbyte raus", "Text" + NUL + "mehr", "Textmehr"],
["Bell raus", "Bell" + BELL + "X", "BellX"],
["Escape raus (Terminal-Trick)", ESC + "[31mrot", "[31mrot"],
["DEL raus", "A" + DEL + "B", "AB"],
/* Erwartung bewusst ohne führenden Umbruch: der Filter entfernt zwar nur
den Wagenrücklauf, das anschließende trim() räumt den dann alleine
stehenden Zeilenumbruch am Rand aber mit weg. Genau so ist es gewollt. */
["Wagenruecklauf raus", CR + NL + "Text" + CR, "Text"],
["Randleerzeichen weg", " Rand ", "Rand"],
["Umlaute bleiben", "Umlaute äöüß bleiben", "Umlaute äöüß bleiben"],
["Emoji bleibt", "Emoji \u{1F43A} bleibt", "Emoji \u{1F43A} bleibt"],
];
let ok = 0, schlecht = 0;
for (const [name, ein, erwartet] of proben) {
const raus = f(ein);
if (raus === erwartet) { ok++; console.log(" ok " + name); }
else { schlecht++; console.log(" FEHLT " + name + " -> " + JSON.stringify(raus) + " statt " + JSON.stringify(erwartet)); }
}
const lang = f("x".repeat(5000), 10);
if (lang.length === 10) { ok++; console.log(" ok Laengenbegrenzung greift"); }
else { schlecht++; console.log(" FEHLT Laengenbegrenzung: " + lang.length); }
console.log(`\n=== ${ok} ok, ${schlecht} falsch ===`);
process.exit(schlecht === 0 ? 0 : 1);
+377
View File
@@ -0,0 +1,377 @@
/* =====================================================================
webdesign-gate.js — Zugangsschutz für den Bereich /webdesign
(Masterplan S.4 "ZUGANGSSCHUTZ" und S.14 "VOR DER ÖFFENTLICHEN FREIGABE").
⚠️ WARUM DAS EINE EIGENE SCHRANKE IST UND NICHT gate.js MITBENUTZT
gate.js schützt die Universe-Seite. Seit dem öffentlichen Start am
21.08.2026 ist es dort aber wirkungslos: die allererste Prüfung in
gateMiddleware lautet
if (ermittleLiveStatus().isLive) return next();
und SITE_PUBLIC_LAUNCH_AT liegt in der Vergangenheit. Jede Anfrage geht
also ohne Code durch. Hätte man /webdesign einfach hinter gate.js
gehängt, wäre der komplette, ausdrücklich nicht-öffentliche
Webdesign-Bereich ab der ersten Sekunde für jeden im Netz lesbar
gewesen — inklusive Preisen, Kundendaten-Ansichten und Verwaltung.
Deshalb: eigene Middleware, eigenes Sitzungs-Cookie, eigener Status.
Der öffentliche Start des Universe hat hier bewusst KEINE Wirkung.
Die Zugangscodes selbst sind auf Wunsch (22.08.2026) dieselben wie im
Universe — aber über eigene Variablen überschreibbar, damit sie später
im Verwaltungsbereich getrennt geändert werden können, ohne den
Universe-Zugang anzufassen (Masterplan S.13: "Dogfather- und
VanVan-Code separat ändern").
===================================================================== */
import { createHmac, timingSafeEqual as cryptoTimingSafeEqual, randomUUID } from "node:crypto";
export const WD_COOKIE = "dogiwd_session";
export const WD_ROLLE_COOKIE = "dogiwd_rolle";
/* Sitzungsdauer. Bewusst länger als die 12 Stunden im Universe: hier wird
inhaltlich gearbeitet (Anfragen prüfen, Texte pflegen), und ein
Rauswurf mitten in einer halb ausgefüllten Verwaltungsmaske wäre
teurer als das kleine zusätzliche Risiko. Das Cookie selbst bleibt ein
reines Sitzungs-Cookie ohne maxAge — beim Schließen des Browsers ist
der Zugang trotzdem weg. */
export const WD_SESSION_STUNDEN = 24;
const WD_PRAEFIX = "/webdesign";
/* ---------------------------------------------------------------------
Signierte Sitzung
Das Feld "bereich" ist der Grund, warum ein Universe-Token hier NICHT
funktioniert, obwohl beide dasselbe Geheimnis benutzen: die Prüfung
unten verlangt bereich === "webdesign". Ohne dieses Feld könnte man
sich mit einem gültigen Universe-Cookie in den geschützten Bereich
hineinlegen.
--------------------------------------------------------------------- */
function b64url(buf) {
return Buffer.from(buf).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function b64urlZurueck(str) {
return Buffer.from(String(str).replace(/-/g, "+").replace(/_/g, "/"), "base64");
}
function geheimnis() {
/* Eigenes Geheimnis bevorzugt; sonst das des Universe. Fehlt beides,
wird der Bereich komplett gesperrt (siehe unten) — NIE offen
durchgelassen. Das ist der bewusste Unterschied zu gate.js, das bei
fehlenden Variablen "fail-open" arbeitet: dort war das richtig (eine
öffentliche Seite darf nicht wegen einer fehlenden Variable
verschwinden), hier wäre es fatal. */
return process.env.WEBDESIGN_ACCESS_SECRET || process.env.SITE_ACCESS_SECRET || null;
}
export function wdSigniere(rolle, secret) {
const inhalt = JSON.stringify({
bereich: "webdesign",
rolle,
sid: randomUUID(), // eindeutige Sitzungskennung fürs Protokoll
exp: Date.now() + WD_SESSION_STUNDEN * 60 * 60 * 1000,
});
const nutzlast = b64url(Buffer.from(inhalt, "utf8"));
const sig = createHmac("sha256", secret).update(nutzlast).digest();
return `${nutzlast}.${b64url(sig)}`;
}
export function wdPruefe(token, secret) {
if (!token || typeof token !== "string" || !token.includes(".")) return null;
const [nutzlast, sigB64] = token.split(".");
try {
const erwartet = createHmac("sha256", secret).update(nutzlast).digest();
const geliefert = b64urlZurueck(sigB64);
/* Längen zuerst vergleichen: crypto.timingSafeEqual WIRFT bei
unterschiedlich langen Puffern, statt false zu liefern. */
if (geliefert.length !== erwartet.length) return null;
if (!cryptoTimingSafeEqual(geliefert, erwartet)) return null;
const daten = JSON.parse(b64urlZurueck(nutzlast).toString("utf8"));
if (daten.bereich !== "webdesign") return null; // kein Universe-Token akzeptieren
if (!daten.exp || daten.exp < Date.now()) return null;
return daten;
} catch {
return null;
}
}
/* ---------------------------------------------------------------------
Codes
Reihenfolge: eigener Webdesign-Code, sonst der Universe-Code.
--------------------------------------------------------------------- */
function codeFuer(person) {
const eigen = process.env[`WEBDESIGN_CODE_${person}`];
const universe = process.env[`SITE_ACCESS_CODE_${person}`];
return eigen || universe || null;
}
/* Zeitsicherer Vergleich für Zeichenketten unterschiedlicher Länge —
dieselbe Umsetzung wie in gate.js und server-internal/lib/auth.js.
crypto.timingSafeEqual ist hier nicht direkt brauchbar, weil es bei
ungleicher Länge wirft und damit die Länge des Codes verraten würde. */
function gleichZeitsicher(a, b) {
a = String(a == null ? "" : a);
b = String(b == null ? "" : b);
const laenge = Math.max(a.length, b.length);
let abweichung = a.length === b.length ? 0 : 1;
for (let i = 0; i < laenge; i++) {
abweichung |= (i < a.length ? a.charCodeAt(i) : 0) ^ (i < b.length ? b.charCodeAt(i) : 0);
}
return abweichung === 0;
}
/* ---------------------------------------------------------------------
Fehlversuchs-Sperre (Masterplan S.4, Punkt 5)
Im Arbeitsspeicher, pro echter Besucher-IP. Gleiche Begründung wie in
gate.js: dieser Server hat keine eigene Datenbank, und ein Neustart
setzt die Zählung zurück, was hier unkritisch ist.
--------------------------------------------------------------------- */
const WD_MAX_VERSUCHE = 5;
const WD_SPERRE_MS = 15 * 60 * 1000;
const versuche = new Map(); // schluessel -> { anzahl, gesperrtBis, zuletzt }
function istIpAdresse(wert) {
if (typeof wert !== "string") return false;
const s = wert.trim();
if (!s || s.length > 45) return false;
return /^(\d{1,3}\.){3}\d{1,3}$/.test(s) || (s.includes(":") && /^[0-9a-fA-F:]+$/.test(s));
}
/* CF-Connecting-IP ist hier verlässlich, weil die Firewall Port 80/443 nur
noch aus den Cloudflare-Netzen zulässt (nachgeprüft am 19.08.2026, siehe
ausführlichen Kommentar in gate.js).
⚠️ Sollte der Direktzugriff auf den Server jemals wieder geöffnet werden,
ist dieser Header wieder fälschbar und diese Stelle muss zurückgebaut
werden — genau wie die entsprechende Stelle in gate.js. */
function besucherSchluessel(req) {
const cf = req.headers["cf-connecting-ip"];
if (istIpAdresse(cf)) return cf.trim();
return req.ip || req.socket?.remoteAddress || "unbekannt";
}
function aufraeumen() {
const jetzt = Date.now();
for (const [k, e] of versuche) {
const weg = (!e.gesperrtBis || e.gesperrtBis < jetzt) && (e.zuletzt || 0) < jetzt - WD_SPERRE_MS;
if (weg) versuche.delete(k);
}
}
function istGesperrt(k) {
const e = versuche.get(k);
return !!(e && e.gesperrtBis && e.gesperrtBis > Date.now());
}
function restMinuten(k) {
const e = versuche.get(k);
if (!e || !e.gesperrtBis) return 0;
return Math.max(1, Math.ceil((e.gesperrtBis - Date.now()) / 60000));
}
function fehlversuch(k) {
const e = versuche.get(k) || { anzahl: 0, gesperrtBis: null };
e.anzahl += 1;
e.zuletzt = Date.now();
if (e.anzahl >= WD_MAX_VERSUCHE) e.gesperrtBis = Date.now() + WD_SPERRE_MS;
versuche.set(k, e);
}
function zuruecksetzen(k) { versuche.delete(k); }
/* Protokollierung (Masterplan S.4: "Fehlversuche werden begrenzt,
protokolliert"). Landet in journalctl -u dogiweb.service. Der Code
selbst wird NIE mitgeschrieben — auch nicht ein falsch eingegebener,
weil das erfahrungsgemäß oft ein richtiger Code mit Tippfehler ist. */
function protokoll(ereignis, felder) {
const zeit = new Date().toISOString();
const rest = Object.entries(felder || {}).map(([k, v]) => `${k}=${v}`).join(" ");
console.log(`[webdesign-zugang] ${zeit} ${ereignis} ${rest}`);
}
/* ---------------------------------------------------------------------
Zugangsstatus (Masterplan S.4 "ZUGANGSSTATUS")
entwurf — komplett dicht, auch mit Code kein Zugang
vorabpruefung — Zugang nur über die persönlichen Codes (Standard)
freigegeben — öffentliche Inhalte frei; Portal und Verwaltung
bleiben durch ihre eigenen Anmeldungen geschützt
--------------------------------------------------------------------- */
function status() {
const wert = String(process.env.WEBDESIGN_STATUS || "vorabpruefung").toLowerCase().trim();
return ["entwurf", "vorabpruefung", "freigegeben"].includes(wert) ? wert : "vorabpruefung";
}
/* Diese Seiten bleiben auch nach der Freigabe geschützt: sie haben eine
eigene Anmeldung, sollen aber zusätzlich gar nicht erst ohne Zugangscode
erreichbar sein, solange der Bereich in der Vorabprüfung ist. */
const IMMER_GESCHUETZT = ["/webdesign/verwaltung.html"];
/* ---------------------------------------------------------------------
Anmelde-Endpunkt
--------------------------------------------------------------------- */
function sicherePfadAngabe(wert) {
/* Nur eigene Pfade INNERHALB des Webdesign-Bereichs. "//fremde-seite.de"
ist eine protokoll-relative URL, die der Browser als fremde Domain
auflöst — genau die Lücke, die am 07.08.2026 in gate.js gefunden
wurde. Hier von vornherein ausgeschlossen, zusätzlich verschärft auf
das /webdesign-Präfix. */
return typeof wert === "string" &&
wert.startsWith(WD_PRAEFIX + "/") &&
!wert.startsWith("//") &&
!wert.includes("\\") &&
!wert.includes("..");
}
export async function wdAnmeldung(req, res) {
const schluessel = besucherSchluessel(req);
aufraeumen();
if (istGesperrt(schluessel)) {
const min = restMinuten(schluessel);
protokoll("gesperrt", { ip: schluessel, restMin: min });
return res.status(429).json({
ok: false,
error: `Zu viele Fehlversuche. Bitte ${min} Minute${min === 1 ? "" : "n"} warten – dein Code ist danach wieder gültig.`,
});
}
const secret = geheimnis();
if (!secret) {
protokoll("kein-geheimnis", { ip: schluessel });
return res.status(503).json({ ok: false, error: "Zugang ist gerade nicht möglich. Bitte Dogfather Bescheid geben." });
}
if (status() === "entwurf") {
protokoll("entwurf-abgewiesen", { ip: schluessel });
return res.status(403).json({ ok: false, error: "Der Bereich ist gerade vollständig gesperrt." });
}
const eingabe = String((req.body && req.body.code) || "").trim();
const ziel = sicherePfadAngabe(req.body && req.body.next) ? req.body.next : "/webdesign/";
let rolle = null;
const codeDogi = codeFuer("DOGI");
const codeVanvan = codeFuer("VANVAN");
if (codeDogi && gleichZeitsicher(eingabe, codeDogi)) rolle = "dogfather";
else if (codeVanvan && gleichZeitsicher(eingabe, codeVanvan)) rolle = "vanvan";
if (!rolle) {
fehlversuch(schluessel);
const e = versuche.get(schluessel);
protokoll("falscher-code", { ip: schluessel, versuch: e ? e.anzahl : 1 });
return res.status(401).json({ ok: false, error: "Falscher Zugangscode." });
}
zuruecksetzen(schluessel);
protokoll("angemeldet", { ip: schluessel, rolle });
const token = wdSigniere(rolle, secret);
/* httpOnly: das Sitzungs-Cookie darf für JavaScript unsichtbar sein —
der Server prüft es, die Seite braucht es nie zu lesen.
Kein maxAge → gilt nur für die laufende Browser-Sitzung. */
res.cookie(WD_COOKIE, token, { path: "/", httpOnly: true, secure: true, sameSite: "lax" });
/* Das Rollen-Cookie ist bewusst LESBAR: die Seite begrüßt damit
persönlich (Masterplan S.4). Es trägt keinerlei Berechtigung — wer es
fälscht, ändert nur die Anrede, nicht den Zugang. */
res.cookie(WD_ROLLE_COOKIE, rolle, { path: "/", secure: true, sameSite: "lax" });
return res.json({ ok: true, rolle, next: ziel });
}
export function wdAbmeldung(req, res) {
res.clearCookie(WD_COOKIE, { path: "/" });
res.clearCookie(WD_ROLLE_COOKIE, { path: "/" });
protokoll("abgemeldet", { ip: besucherSchluessel(req) });
return res.json({ ok: true });
}
/* ---------------------------------------------------------------------
Die eigentliche Middleware
--------------------------------------------------------------------- */
export function webdesignGate(req, res, next) {
const pfad = req.path;
// Alles außerhalb von /webdesign geht diese Schranke nichts an.
if (pfad !== WD_PRAEFIX && !pfad.startsWith(WD_PRAEFIX + "/")) return next();
/* Noindex für den GESAMTEN Bereich, unabhängig vom Anmeldezustand
(Masterplan S.14: "Noindex, Zugriffsschutz und interne Linksperre
bleiben aktiv, bis Dogfather den Launch ausdrücklich bestätigt").
Als HTTP-Header statt nur als <meta>, weil ein Header auch für
Nicht-HTML-Antworten (PDF, JSON) gilt und von Suchmaschinen auch dann
gelesen wird, wenn die Seite gar nicht erst ausgeliefert wird. */
if (status() !== "freigegeben") {
res.setHeader("X-Robots-Tag", "noindex, nofollow, noarchive");
}
// Anmelden / Abmelden
if (pfad === "/webdesign/zugang-pruefen" && req.method === "POST") {
return Promise.resolve(wdAnmeldung(req, res)).catch(next);
}
if (pfad === "/webdesign/zugang-beenden" && req.method === "POST") {
return wdAbmeldung(req, res);
}
// Wer bin ich? (für die persönliche Begrüßung)
if (pfad === "/webdesign/zugang-status" && req.method === "GET") {
const secret = geheimnis();
const daten = secret ? wdPruefe(req.cookies?.[WD_COOKIE], secret) : null;
return res.json({
ok: true,
angemeldet: !!daten,
rolle: daten ? daten.rolle : null,
status: status(),
});
}
const secret = geheimnis();
if (!secret) {
/* Bewusst DICHT statt offen (Gegenteil von gate.js, Begründung oben).
Ein nicht-öffentlicher Bereich darf niemals wegen einer fehlenden
Einstellung aufgehen. */
protokoll("gesperrt-kein-geheimnis", { pfad });
return res
.status(503)
.type("text/plain; charset=utf-8")
.send("Der Webdesign-Bereich ist noch nicht eingerichtet.");
}
const sitzung = wdPruefe(req.cookies?.[WD_COOKIE], secret);
// Freigegeben: öffentliche Seiten frei, die Verwaltung bleibt geschützt.
if (status() === "freigegeben" && !IMMER_GESCHUETZT.includes(pfad)) return next();
if (sitzung) {
// Für nachgelagerte Handler verfügbar machen (z.B. Rollenprüfung).
req.wdRolle = sitzung.rolle;
return next();
}
/* Die Zugangsseite selbst und alles, was sie zum Anzeigen braucht, muss
ohne Sitzung erreichbar sein — sonst sieht man eine nackte Seite ohne
Stil und ohne Logo. Die geteilten /assets liegen außerhalb von
/webdesign und werden von dieser Schranke ohnehin nicht angefasst. */
/* App-Bausteine müssen ebenfalls ohne Sitzung erreichbar sein.
Begründung (Lektion aus dem Universe, gate.html am 19.08.2026):
Die Zugangswand ist der ERSTE Bildschirm, den man überhaupt sieht —
also auch der, von dem aus installiert wird. Ohne erreichbares
Manifest und ohne registrierbaren Service Worker bietet Chrome dort
gar kein "App installieren" an, und wer vorher installiert, bekommt
nur eine nackte Verknüpfung ohne Symbol.
Unbedenklich: beide Dateien enthalten keinerlei Kunden-, Projekt-
oder Preisdaten, und sw.js speichert bewusst keine HTML-Seiten
zwischen (siehe Kopf von webdesign/sw.js).
Würde man sie sperren, lieferte die Schranke stattdessen eine
302-Umleitung auf zugang.html — der Browser bekäme HTML statt
JavaScript und bräche die Registrierung mit einem MIME-Fehler ab. */
if (
pfad === "/webdesign/zugang.html" ||
pfad === "/webdesign/sw.js" ||
pfad === "/webdesign/app.webmanifest"
) {
return next();
}
/* Alles andere zur Zugangswand. Der ursprünglich gewünschte Pfad wird
mitgegeben, damit man nach der Eingabe genau dort landet, wo man
hinwollte (und nicht immer auf der Startseite). */
const gewuenscht = pfad === WD_PRAEFIX || pfad === WD_PRAEFIX + "/" ? "/webdesign/" : pfad;
return res.redirect(302, `/webdesign/zugang.html?next=${encodeURIComponent(gewuenscht)}`);
}