Widerrufsbelehrung und Datenschutz
RECHERCHIERT, NICHT AUS DEM GEDAECHTNIS
Der Webdesign-Bereich hatte KEINE eigene Rechtsseite -- obwohl dort
Vertraege geschlossen und Zahlungen ausgeloest werden. Die Fusszeile
verwies auf die Kontaktseite des Universe, die ein Content-Projekt
beschreibt, kein Dienstleistungsgeschaeft.
Die Web-Recherche hat eine Pflicht zutage gefoerdert, die seit zwei
Monaten gilt und die die Seite nicht erfuellt hat:
§ 356a BGB -- elektronische Widerrufsfunktion, in Kraft seit
19.06.2026. Sie gilt fuer ALLE Fernabsatzvertraege, die ueber eine
Online-Benutzeroberflaeche geschlossen werden, nicht nur fuer
Finanzdienstleistungen.
WARUM DAS HIER GILT: Im Kundenportal nimmt der Kunde Zusatzangebote per
Klick VERBINDLICH an ("Damit nimmst du das Zusatzangebot verbindlich
an"). Das ist ein Vertragsschluss ueber eine Online-Benutzeroberflaeche.
Fuer die geplanten PayPal-Zahlungen gilt dasselbe.
Der Anbieter sitzt in Luxemburg. Fuer Verbraucher mit gewoehnlichem
Aufenthalt in Deutschland gilt nach Art. 6 Rom-I-VO trotzdem das
deutsche zwingende Verbraucherrecht, weil die Seite sich erkennbar an den
deutschsprachigen Markt richtet. Deshalb wurde der STRENGERE Standard
umgesetzt, nicht der bequemere.
WAS GEBAUT WURDE
webdesign/widerruf.html -- die Widerrufsfunktion, genau nach dem
Wortlaut der Vorschrift:
* Beschriftung "Vertrag widerrufen" (gesetzlich vorgegeben)
* ZWEITE Schaltflaeche "Widerruf bestaetigen" (ebenfalls vorgegeben --
ein einstufiges Formular wuerde die Vorschrift nicht erfuellen)
* unverzuegliche Eingangsbestaetigung mit Nummer, ZEITPUNKT (nicht nur
Datum), Empfaenger und dem Wortlaut der Erklaerung
* Link in der Fusszeile JEDER Seite -- "staendig verfuegbar,
hervorgehoben platziert, leicht zugaenglich" heisst nicht "in den AGB
versteckt"
Drei Entscheidungen, die unmittelbar aus dem Sinn der Vorschrift folgen:
KEINE ANMELDUNG. Die Seite ist von der Zugangswand ausgenommen. Wer
seinen Code verlegt hat, wuerde sonst seine Frist verlieren --
Fristverlust durch eine selbstgebaute Huerde ist der schlimmste
denkbare Fall.
KEINE MENGENBEGRENZUNG. Ueberall sonst richtig, hier ein
Rechtsverlust: Wer in der letzten Stunde seiner Frist wegen eines
hakenden Netzes dreimal klickt, darf nicht abgewiesen werden.
KEIN ZWISCHENSPEICHER. Beim Anfrageformular ein Segen, hier eine
Falle: Auf einem geteilten Rechner laege der halb ausgefuellte Widerruf
des einen im Browser des naechsten.
webdesign/rechtliches.html -- Impressum, AGB, Widerrufsbelehrung samt
Muster-Widerrufsformular, Datenschutzerklaerung. Alle Betreiberangaben
sind aus den bestehenden Seiten UEBERNOMMEN, nichts erfunden: Anschrift
in Mondercange, Kleinunternehmerregelung nach Art. 57 TVA-Gesetz LU und
Richtlinie (EU) 2020/285, netcup/Cloudflare, PayPal Luxemburg.
Migration 0014: wd_widerrufe (der dauerhafte Datentraeger und der
Nachweis, wird nie geleert) und wd_zustimmungen. Letztere speichert nicht
nur den Haken, sondern den WORTLAUT, den der Kunde gesehen hat -- die
Beweislast fuer die Zustimmung zum vorzeitigen Leistungsbeginn liegt beim
Unternehmer (§ 356 Abs. 4 BGB), und im Streit zaehlt, WAS bestaetigt
wurde. Bewusst OHNE Fremdschluessel: CASCADE wuerde den Nachweis mit dem
Kunden mitloeschen, RESTRICT wuerde eine DSGVO-Loeschung blockieren.
Druckansicht: Die Eingangsbestaetigung ist ein Rechtsnachweis. Auf Papier
schwarz auf weiss, ohne Navigation und Knoepfe -- ein Nachweis, den
niemand ausdruckt, weil er eine halbe Patrone kostet, erfuellt seinen
Zweck nicht.
ZWEI ECHTE FEHLER GEFUNDEN
1. Die Pflicht-Sternchen verschwanden nach der Uebersetzung. Ursache:
data-i18n stand auf dem <label> selbst und ersetzte dessen ganzen
Inhalt -- samt <span class="wd-pflicht">. Das Anfrageformular macht es
laengst richtig (data-i18n auf einem INNEREN span); die neue Seite
hatte das Muster nicht uebernommen. Aufgefallen ist es nur, weil der
Test die Pflichtfelder GEZAEHLT hat statt sie vorauszusetzen.
2. window.WD.sprache() gibt es nicht, die Funktion heisst getSprache().
Dadurch brach der Uebergang zur zweiten Stufe stumm ab.
GEPRUEFT: 58 Pruefungen, alle bestanden. Darunter die gesetzlich
vorgegebenen Beschriftungen in allen FUENF Sprachen, "kein Grund noetig",
genau drei Pflichtfelder, Datum UND Uhrzeit in der Bestaetigung, und die
Druckansicht.
WAS FILIPE NOCH PRUEFEN LASSEN MUSS: Diese Texte sind sorgfaeltig
recherchiert, aber ich bin keine Rechtsanwaeltin. Vor dem oeffentlichen
Start gehoert das Ganze einmal zu einer im luxemburgischen Recht
qualifizierten Fachperson -- besonders die grenzueberschreitende Lage
(Sitz LU, Kunden in DE/CH/FR/PT) und die Frage, ob die Seite in
Franzoesisch und Portugiesisch aktiv verkaufen soll. Dann muessen die
Rechtstexte auch dorthin uebersetzt werden, und zwar fachlich.
Versionsstempel und Cache-Name auf v7.
Co-Authored-By: Claude Opus 5 <[email protected]>
460 lines
20 KiB
JavaScript
460 lines
20 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 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);
|
||
}
|
||
|
||
/* ---------------------------------------------------------------------
|
||
AUSWEIS FÜR DIE INTERNE API
|
||
|
||
Rückmeldung 22.08.2026: "ich hab auf der verwaltungs seite 2 mal dass
|
||
ich den zugangscode eingeben muss und vanvan auch, ich will es nur
|
||
einmal eingeben müssen."
|
||
|
||
Berechtigt. Die Zugangswand hat serverseitig bereits geprüft, WER da
|
||
ist — Dogfather oder VanVan, beide laut Masterplan S.4
|
||
gleichberechtigte Volladministratoren. Ein zweites Mal danach zu
|
||
fragen bringt keinen zusätzlichen Schutz, es nervt nur.
|
||
|
||
Warum es trotzdem nicht einfach "das Cookie mitschicken" ist:
|
||
Die Verwaltungsdaten liegen hinter postfach.dogfather-universe.com,
|
||
einem ANDEREN Rechnernamen. Das Sitzungs-Cookie dieser Seite gilt
|
||
dort nicht — so sind Cookies gebaut, und das ist auch gut so.
|
||
|
||
Deshalb stellt diese Stelle einen kurzlebigen, signierten Ausweis
|
||
aus, den die interne API prüfen kann:
|
||
- nur 15 Minuten gültig (die Seite holt sich bei Bedarf einen neuen)
|
||
- trägt bereich="wd-admin", damit er NICHT als Sitzung dieser Seite
|
||
durchgeht und umgekehrt
|
||
- nur mit gültiger Zugangssitzung zu bekommen
|
||
|
||
Fehlt WEBDESIGN_API_SECRET, wird kein Ausweis ausgestellt und die
|
||
Verwaltung fragt wie bisher nach dem Team-Code. Kein Ausfall, nur
|
||
kein Komfort — ein fehlender Wert darf nie eine Tür öffnen.
|
||
--------------------------------------------------------------------- */
|
||
if (pfad === "/webdesign/api-ausweis" && req.method === "GET") {
|
||
const secret = geheimnis();
|
||
const apiSecret = process.env.WEBDESIGN_API_SECRET;
|
||
const sitzung = secret ? wdPruefe(req.cookies?.[WD_COOKIE], secret) : null;
|
||
|
||
if (!sitzung) {
|
||
return res.status(401).json({ ok: false, error: "Keine gültige Zugangssitzung." });
|
||
}
|
||
if (!apiSecret) {
|
||
/* Bewusst 200 mit ausweis:null statt eines Fehlers: die Verwaltung
|
||
soll daraufhin ruhig ihre eigene Anmeldung zeigen, nicht eine
|
||
Störungsmeldung. */
|
||
return res.json({ ok: true, ausweis: null, grund: "nicht eingerichtet" });
|
||
}
|
||
|
||
const inhalt = JSON.stringify({
|
||
bereich: "wd-admin",
|
||
rolle: sitzung.rolle,
|
||
exp: Date.now() + 15 * 60 * 1000,
|
||
});
|
||
const nutzlast = b64url(Buffer.from(inhalt, "utf8"));
|
||
const sig = createHmac("sha256", apiSecret).update(nutzlast).digest();
|
||
return res.json({ ok: true, ausweis: `${nutzlast}.${b64url(sig)}`, rolle: sitzung.rolle });
|
||
}
|
||
|
||
// 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. */
|
||
/* WIDERRUF UND RECHTSTEXTE BLEIBEN IMMER OFFEN — auch vor der Freigabe
|
||
und auch ohne Zugangscode.
|
||
|
||
Das ist keine Bequemlichkeit, sondern Pflicht. § 356a BGB verlangt,
|
||
dass die Widerrufsfunktion während der gesamten Widerrufsfrist
|
||
"ständig verfügbar, hervorgehoben platziert und leicht zugänglich"
|
||
ist. Eine Zugangswand davor wäre das genaue Gegenteil: Wer seinen
|
||
Code verlegt hat, könnte seine Frist verlieren — und Fristverlust
|
||
durch eine selbstgebaute Hürde ist der schlimmste denkbare Fall.
|
||
|
||
Dasselbe gilt für die Rechtstexte: Die Widerrufsbelehrung muss
|
||
lesbar sein, BEVOR man widerruft, nicht danach.
|
||
|
||
Unbedenklich, weil beide Seiten ausschliesslich Texte enthalten, die
|
||
ohnehin für jeden bestimmt sind — keine Kunden-, Projekt- oder
|
||
Preisdaten. Der Widerrufs-Endpunkt nimmt nur entgegen und gibt
|
||
nichts preis: Er beantwortet keine Frage, sondern speichert eine
|
||
Erklärung. */
|
||
if (
|
||
pfad === "/webdesign/zugang.html" ||
|
||
pfad === "/webdesign/widerruf.html" ||
|
||
pfad === "/webdesign/rechtliches.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)}`);
|
||
}
|