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]>
378 lines
16 KiB
JavaScript
378 lines
16 KiB
JavaScript
/* =====================================================================
|
||
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)}`);
|
||
}
|