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:
@@ -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=
|
||||
|
||||
@@ -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-
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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)}`);
|
||||
}
|
||||
Reference in New Issue
Block a user