Files
dogfather-universe/server/webdesign-gate.js
T
DogFatherGitandClaude Opus 5 356bb974b8 Knopf war unlesbar, und der Code wird jetzt bei jedem Schliessen neu verlangt
Zwei Rueckmeldungen vom 22.08.2026, beide behoben und mit Tests abgesichert.

1) "das sieht nicht gut aus" (Bildschirmfoto des Hauptknopfs)
   Ursache: ".wd a" ist Klasse+Element und damit spezifischer als die reine
   Knopfklasse ".wd-btn--haupt". Die Textfarbe des Knopfs wurde dadurch
   ueberstimmt -- hellblaue Schrift auf hellblauem Grund, praktisch
   unlesbar. Derselbe Spezifitaetsfehler wie zuvor bei den Namen auf der
   Zugangswand. Fix: ".wd a:not(.wd-btn)" plus zweistufig geschriebene
   Knopfregeln, damit das nicht wieder passieren kann.

2) "das sieht lang gezogen aus"
   Die Pillenform (border-radius 999px) laesst breite Knoepfe
   auseinandergezogen wirken, weil der Radius optisch mit der Breite
   mitwaechst. Jetzt fester Radius von 14px -- gleiche Form bei jeder
   Breite, ruhiger und hochwertiger.

3) "ich will das ich jedes mal den code gefragt werde wenn man die seite
   zu macht"
   Das Sitzungs-Cookie allein reicht dafuer nicht: Browser stellen genau
   solche Cookies beim Wiederherstellen von Tabs zurueck ("Dort
   fortfahren, wo du aufgehoert hast"), man landet dann ohne Codeabfrage
   wieder mitten in der Seite. Zusaetzlich jetzt eine Sitzungsmarke im
   sessionStorage, die beim echten Schliessen verschwindet. Fehlt sie bei
   vorhandenem Cookie, wird die Sitzung serverseitig beendet und zur
   Zugangswand geleitet. Token-Notbremse von 24 auf 8 Stunden gesenkt.
   13/13 Tests im echten Browser, inklusive Schutz vor Endlosschleife.

DEPLOY.md: zwei Checkouts auf dem Server dokumentiert (/home/dogiweb und
/home/dogiintern) und der Vorfall, dass /webdesign nach dem Pull kurz ohne
Zugangsschutz erreichbar war, weil der Dienstneustart fehlte.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-22 18:20:34 +02:00

387 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
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 als NOTBREMSE, nicht als eigentlicher Ablauf.
Der Zugang soll bei jedem Schließen der Seite neu abgefragt werden
(Wunsch 22.08.2026: "ich will das ich jedes mal den code gefragt werde
wenn man die seite zu macht"). Dafür sorgen zwei Dinge zusammen:
1. Das Cookie ist ein reines Sitzungs-Cookie (kein maxAge/expires) —
der Browser wirft es beim Schließen selbst weg.
2. Eine Sitzungskennung im Browser-Sitzungsspeicher, die beim
Schließen ebenfalls verschwindet (siehe wd-core.js). Nötig, weil
viele Browser mit "Tabs wiederherstellen" auch Sitzungs-Cookies
zurückholen und Punkt 1 allein dann NICHT greift.
Diese Stundenzahl ist nur der Fall, dass ein Browser tagelang offen
bleibt. Von 24 auf 8 Stunden gesenkt: bleibt ein Gerät unbeaufsichtigt
offen liegen, soll der Zugang spätestens nach einem Arbeitstag zu sein. */
export const WD_SESSION_STUNDEN = 8;
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)}`);
}