Files
dogfather-universe/server/gate.js
T
DogFatherGitandClaude Sonnet 5 57966e24d0 Automatischer öffentlicher Start um 21 Uhr + Countdown auf der Zugangsseite
Nutzer-Wunsch 21.08.2026: "ich will dass du auch ein countdown zu der
seite hinzufügst wo wir den zugangscode eingeben müssen. den um 21h heute
abend geht die seite live. kannst du das sogar so anpassen dass es
automatisch läuft?"

server/gate.js:
- Neue Einstellung SITE_PUBLIC_LAUNCH_AT (ISO-Zeitstempel mit Zeitzone).
  Ab diesem Moment lässt gateMiddleware ausnahmslos jeden durch -- ganz
  ohne Neustart oder manuellen Eingriff, weil jede Anfrage die aktuelle
  Serverzeit live neu prüft. Die Freischaltung "passiert" also von selbst
  in der Sekunde, in der die Uhrzeit erreicht wird. Vorher bleiben die
  Zugangscodes unverändert nötig, damit das Team schon vorher rein kann.
  Fail-safe statt fail-open geprüft: ein kaputter/unparsbarer Zeitwert
  (z.B. Tippfehler in der .env) lässt die Schranke aktiv, statt die Seite
  versehentlich für alle zu öffnen.
- Neuer öffentlicher Endpunkt GET /gate-launch-info (immer erreichbar,
  auch ohne gültige Sitzung) liefert launchAt/isLive/serverTime für die
  Countdown-Anzeige im Frontend.

gate.html:
- Neue Countdown-Box zwischen Titel-Karte und den Zugangscode-Kacheln
  (bleibt unsichtbar, solange kein Starttermin konfiguriert ist). Rechnet
  auf der SERVERZEIT statt der eigenen Uhr (einmaliger Zeit-Abgleich beim
  Laden), damit eine falsch gehende Besucher-Uhr weder zu früh noch zu
  spät zählt. Bei Erreichen von Null folgt ein letzter Abgleich mit dem
  Server, bevor automatisch zur Zielseite weitergeleitet wird -- kein
  Klick, kein Neuladen nötig.
- Zugangscode-Kacheln (Dogi/VanVan/Diene) bleiben während des Countdowns
  unverändert nutzbar.

Getestet: 18 Middleware-Tests (inkl. Fail-safe bei kaputtem Zeitwert,
weiterhin funktionierender Zugangscode vor dem Start) + 10 Playwright-
Tests der Countdown-Oberfläche (Anzeige, Format, automatischer Sprung bei
Ablauf, sofortige Weiterleitung falls schon live, stiller Fallback bei
Netzwerkfehler). Zusätzlich alle 32 echten Seiten auf PC-Installierbarkeit
geprüft (Manifest, Icons, Service Worker, Install-Knopf, echter
Install-Klick-Ablauf simuliert) -- keine Probleme gefunden.

Cache-Busting-Version auf 20260821s erhöht.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-21 19:25:12 +02:00

288 lines
15 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.
/* =====================================================================
gate.js — Zugangs-Schranke vor der ganzen statischen Website. 1:1 portiert aus
gate-worker.js (Cloudflare Worker), auf Express-Middleware umgestellt. Gleiche Logik:
HMAC-signierte Session-Cookies, Fail-open falls Secrets fehlen, gate.html + Assets immer
erreichbar, "/" wird intern auf "/index.html" abgebildet.
===================================================================== */
export const COOKIE_NAME = "dogi_session";
export const ROLE_COOKIE_NAME = "dogi_role";
/* Zugang gilt nur für die laufende Browser-Sitzung (Wunsch 05.08.2026: "jedes Mal wenn wir den
Browser zu machen, soll der Code wieder gefragt werden"). Umgesetzt über ein reines
Sitzungs-Cookie OHNE maxAge/expires — das löscht der Browser beim Schließen selbst.
SESSION_STUNDEN ist nur die zusätzliche Notbremse im signierten Token für den Fall, dass ein
Browser tage-/wochenlang offen bleibt (dann läuft der Zugang trotzdem ab). */
export const SESSION_STUNDEN = 12;
function b64urlEncode(bytes) {
let bin = "";
bytes.forEach((b) => (bin += String.fromCharCode(b)));
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function b64urlDecodeToBytes(str) {
str = str.replace(/-/g, "+").replace(/_/g, "/");
while (str.length % 4) str += "=";
const bin = atob(str);
return Uint8Array.from(bin, (c) => c.charCodeAt(0));
}
async function hmacKey(secret) {
return crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
}
export async function signSession(role, secret) {
const payload = JSON.stringify({ role, exp: Date.now() + SESSION_STUNDEN * 60 * 60 * 1000 });
const payloadB64 = b64urlEncode(new TextEncoder().encode(payload));
const key = await hmacKey(secret);
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payloadB64));
return `${payloadB64}.${b64urlEncode(new Uint8Array(sig))}`;
}
export async function verifySession(token, secret) {
if (!token || !token.includes(".")) return null;
const [payloadB64, sigB64] = token.split(".");
try {
const key = await hmacKey(secret);
const valid = await crypto.subtle.verify("HMAC", key, b64urlDecodeToBytes(sigB64), new TextEncoder().encode(payloadB64));
if (!valid) return null;
const payload = JSON.parse(new TextDecoder().decode(b64urlDecodeToBytes(payloadB64)));
if (!payload.exp || payload.exp < Date.now()) return null;
return payload;
} catch {
return null;
}
}
/* SICHERHEITSKORREKTUR (07.08.2026, Audit-Fund): "next" kam bisher direkt vom Besucher (Query-
Parameter in gate.html -> POST-Body hier -> unveraendert in der Antwort zurueck -> Browser
macht `location.href = data.next`). Die Pruefung "faengt mit / an und ist nicht genau /" laesst
AUCH "//boese-seite.de" durch -- das ist eine protokoll-relative URL, der Browser haengt das
aktuelle https:// davor und navigiert tatsaechlich auf die fremde Seite. Ein praeparierter Link
wie "https://dogfather-universe.com/gate.html?next=%2F%2Fboese-seite.de" saehe aus wie die
echte Seite (echte Domain!), wuerde nach der Code-Eingabe aber still auf eine Phishing-Seite
umleiten. Fix: zusaetzlich "//" und "/\" (das interpretieren manche Browser genauso) explizit
ausschliessen -- "next" darf NUR noch ein echter, eigener Pfad sein. */
function isSafeNextPath(value) {
return typeof value === "string" && value.startsWith("/") && !value.startsWith("//") && !value.startsWith("/\\") && value !== "/";
}
/* Zeitsicherer Vergleich (07.08.2026, Audit-Fund) -- genau dasselbe Prinzip wie schon in
server-internal/lib/auth.js: normales "===" bricht bei der ersten abweichenden Stelle ab, was
theoretisch ueber viele Anfragen einen winzigen Zeitunterschied messbar macht. Praktisch schwer
auszunutzen, aber kostenlos zu vermeiden -- die Schleife laeuft immer komplett durch. */
function timingSafeEqual(a, b) {
a = String(a == null ? "" : a);
b = String(b == null ? "" : b);
const len = Math.max(a.length, b.length);
let mismatch = a.length === b.length ? 0 : 1;
for (let i = 0; i < len; i++) {
mismatch |= (i < a.length ? a.charCodeAt(i) : 0) ^ (i < b.length ? b.charCodeAt(i) : 0);
}
return mismatch === 0;
}
/* SICHERHEITSKORREKTUR (07.08.2026, Audit-Fund): bisher gab es HIER keine Bremse gegen
automatisiertes Durchprobieren des Zugangscodes -- im Gegensatz zum internen Team-Bereich
(server-internal/lib/auth.js), der schon eine 5-Versuche/15-Minuten-Sperre hat. Absichtlich
EINFACHER gehalten als dort: dieser Server hat (noch) keine Datenbank, eine Sperre im
Arbeitsspeicher (Map, IP -> Zaehler) reicht fuer diesen Zweck voellig aus -- der einzige
Nachteil ist, dass die Zaehlung bei einem Server-Neustart zuruecksetzt, was hier unkritisch ist
(kein Angriff ueberlebt einen Neustart laenger als der ohnehin sehr seltene Fall, dass genau
dann neu gestartet wird). req.ip ist dank `app.set("trust proxy", 1)` in index.js + genau einer
vertrauten Proxy-Stufe (Caddy) nicht vom Client faelschbar -- exakt dieselbe Absicherung wie
beim internen Bereich (siehe dortiger Kommentar vom 05.08.2026 zur CF-Connecting-IP-Luecke). */
const GATE_MAX_ATTEMPTS = 5;
const GATE_LOCKOUT_MS = 15 * 60 * 1000;
const gateAttempts = new Map(); // ip -> { count, lockedUntil }
/* KORREKTUR (19.08.2026) — die Sperre traf bisher ALLE Besucher gleichzeitig.
Nutzer-Report: "ich komme auf dem Handy nicht rein", richtiger Code wurde als falsch
abgewiesen. Ursache: req.ip liefert hier NICHT die IP des Besuchers, sondern die des
Cloudflare-Servers, ueber den alle laufen (Kette: Besucher -> Cloudflare -> Caddy ->
Express; bei `trust proxy: 1` bleibt genau der Cloudflare-Knoten uebrig). Alle Besucher
teilten sich dadurch EINEN Fehlversuchs-Zaehler: fuenf Vertipper von irgendwem sperrten
die Seite fuer jeden. Live nachgewiesen -- derselbe richtige Code wurde ueber
dogfather-universe.com abgelehnt und ueber www.dogfather-universe.com akzeptiert, weil
die beiden Namen ueber unterschiedliche Cloudflare-Knoten liefen (= zwei Zaehler).
Loesung: die echte Besucher-IP aus `CF-Connecting-IP` nehmen. Am 05.08.2026 war genau
dieser Header bewusst VERWORFEN worden, weil er faelschbar war, solange der Server auch
direkt unter seiner IP erreichbar war. Diese Voraussetzung hat sich seither geaendert:
die Firewall laesst Port 80/443 nur noch aus den Cloudflare-Netzen zu (am 19.08.2026 von
aussen gegengeprueft: Direktzugriff auf beide Ports kommt gar nicht mehr zustande).
Damit kann der Header nur noch von Cloudflare selbst gesetzt werden, und Cloudflare
ueberschreibt ihn immer.
⚠️ ABHAENGIGKEIT: Sollte der Direktzugriff auf den Server jemals wieder geoeffnet werden,
wird dieser Header wieder faelschbar -- dann muss diese Stelle zurueckgebaut werden. */
function istIpAdresse(wert) {
if (typeof wert !== "string") return false;
const s = wert.trim();
if (!s || s.length > 45) return false;
const ipv4 = /^(\d{1,3}\.){3}\d{1,3}$/;
const ipv6 = /^[0-9a-fA-F:]+$/;
return ipv4.test(s) || (s.includes(":") && ipv6.test(s));
}
function gateClientKey(req) {
const cf = req.headers["cf-connecting-ip"];
if (istIpAdresse(cf)) return cf.trim();
// Kein Cloudflare davor (z.B. lokaler Test) -> alter Weg, weiterhin nicht faelschbar.
return req.ip || req.socket?.remoteAddress || "unknown";
}
function isGateLockedOut(key) {
const entry = gateAttempts.get(key);
return !!(entry && entry.lockedUntil && entry.lockedUntil > Date.now());
}
/* Wie lange die Sperre noch laeuft (aufgerundete Minuten, mindestens 1). */
function gateSperreRestMinuten(key) {
const entry = gateAttempts.get(key);
if (!entry || !entry.lockedUntil) return 0;
return Math.max(1, Math.ceil((entry.lockedUntil - Date.now()) / 60000));
}
/* Seit die Zaehlung pro echter Besucher-IP laeuft, kann die Map theoretisch unbegrenzt
wachsen (vorher gab es nur eine Handvoll Cloudflare-Knoten). Deshalb bei jeder Anmeldung
abgelaufene Eintraege wegraeumen -- kein Timer noetig, kostet praktisch nichts. */
function gateAufraeumen() {
const jetzt = Date.now();
for (const [k, e] of gateAttempts) {
const abgelaufen = (!e.lockedUntil || e.lockedUntil < jetzt) && (e.zuletzt || 0) < jetzt - GATE_LOCKOUT_MS;
if (abgelaufen) gateAttempts.delete(k);
}
}
function recordGateFailedAttempt(key) {
const entry = gateAttempts.get(key) || { count: 0, lockedUntil: null };
entry.count += 1;
entry.zuletzt = Date.now();
if (entry.count >= GATE_MAX_ATTEMPTS) entry.lockedUntil = Date.now() + GATE_LOCKOUT_MS;
gateAttempts.set(key, entry);
}
function clearGateAttempts(key) {
gateAttempts.delete(key);
}
export async function gateAuthHandler(req, res) {
const clientKey = gateClientKey(req);
gateAufraeumen();
/* GEAENDERT 19.08.2026 (Nutzer-Entscheidung): Bisher kam bei einer Sperre absichtlich exakt
dieselbe Meldung wie bei einem falschen Code, damit ein Angreifer nicht erkennt, wie nah er
an der Sperrschwelle ist. In der Praxis hat genau das den Besitzer der Seite selbst ratlos
gemacht -- er tippte den RICHTIGEN Code ein und las "Falscher Zugangscode", ohne jede
Chance zu erkennen, dass nur eine Wartezeit lief. Jetzt eine ehrliche Meldung mit Restzeit.
Der Sicherheitsverlust ist gering: der Code selbst wird dadurch nicht leichter erratbar, die
Sperre bleibt in voller Laenge bestehen, und wer sie ausloest, erfaehrt lediglich, dass er
warten muss (was er ohnehin merkt). Der Gewinn -- niemand haelt eine Wartezeit faelschlich
fuer einen falschen Code -- wiegt hier deutlich schwerer. */
if (isGateLockedOut(clientKey)) {
const min = gateSperreRestMinuten(clientKey);
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 body = req.body || {};
const code = (body.code || "").trim();
const next = isSafeNextPath(body.next) ? body.next : "/index.html";
let role = null;
if (process.env.SITE_ACCESS_CODE_DOGI && timingSafeEqual(code, process.env.SITE_ACCESS_CODE_DOGI)) role = "dogi";
else if (process.env.SITE_ACCESS_CODE_VANVAN && timingSafeEqual(code, process.env.SITE_ACCESS_CODE_VANVAN)) role = "vanvan";
// Dritter Zugang, 21.08.2026 (Nutzer-Wunsch: "ich will nämlich dass Diene
// schon jetzt auch die Seite sieht, damit wir mit mehreren Augen die
// letzten kleinen Details verbessern"). Eigener Code + eigene Rolle statt
// Weitergabe eines bestehenden Codes -- so lässt sich der Zugang später
// einzeln wieder entziehen, ohne Dogi oder VanVan auszusperren.
else if (process.env.SITE_ACCESS_CODE_DIENE && timingSafeEqual(code, process.env.SITE_ACCESS_CODE_DIENE)) role = "diene";
if (!role) {
recordGateFailedAttempt(clientKey);
return res.status(401).json({ ok: false, error: "Falscher Zugangscode." });
}
clearGateAttempts(clientKey);
const session = await signSession(role, process.env.SITE_ACCESS_SECRET);
// Bewusst OHNE maxAge/expires → reines Sitzungs-Cookie, verfällt beim Schließen des Browsers.
res.cookie(COOKIE_NAME, session, { path: "/", httpOnly: true, secure: true, sameSite: "lax" });
res.cookie(ROLE_COOKIE_NAME, role, { path: "/", secure: true, sameSite: "lax" });
return res.json({ ok: true, next });
}
/* Automatischer öffentlicher Start (21.08.2026, Nutzer-Wunsch: "um 21h heute
abend geht die seite live. kannst du das sogar so anpassen dass es
automatisch läuft?"). SITE_PUBLIC_LAUNCH_AT ist ein fester Zeitpunkt
(ISO-String MIT Zeitzone in der .env, z.B. "2026-08-21T21:00:00+02:00").
Ab diesem Moment lässt diese Prüfung ausnahmslos jeden durch — ganz ohne
Neustart und ohne jeden manuellen Eingriff: jede einzelne Anfrage prüft
die aktuelle Serverzeit neu, die Freischaltung "passiert" also von selbst
in der Sekunde, in der die Uhrzeit erreicht wird. Vorher (isLive: false)
ändert sich am bisherigen Verhalten nichts — die Zugangscodes bleiben bis
dahin unverändert nötig, damit das Team schon vorher rein kann.
Fehlt die Variable (z.B. lokal beim Testen), bleibt isLive dauerhaft
false — reines Opt-in, kein Verhalten ändert sich ohne diese Einstellung. */
function ermittleLiveStatus() {
const wert = process.env.SITE_PUBLIC_LAUNCH_AT;
if (!wert) return { launchAt: null, isLive: false };
const launchMs = new Date(wert).getTime();
if (Number.isNaN(launchMs)) return { launchAt: null, isLive: false };
return { launchAt: launchMs, isLive: Date.now() >= launchMs };
}
export function gateMiddleware(req, res, next) {
const pfad = req.path;
/* Öffentlicher, IMMER erreichbarer Status-Endpunkt für den Countdown auf
gate.html — unabhängig davon, ob gerade Codes verlangt werden, damit
das Frontend jederzeit den echten (serverseitigen) Stand erfragen kann,
statt sich auf die möglicherweise falsch gestellte Uhr des Besuchers zu
verlassen. Bewusst vor jeder anderen Prüfung, damit er auch dann noch
antwortet, wenn z.B. SITE_ACCESS_SECRET fehlt oder die Sperre aktiv ist. */
if (pfad === "/gate-launch-info" && req.method === "GET") {
const { launchAt, isLive } = ermittleLiveStatus();
return res.json({
ok: true,
launchAt: launchAt ? new Date(launchAt).toISOString() : null,
isLive,
serverTime: new Date().toISOString(),
});
}
if (ermittleLiveStatus().isLive) return next(); // Uhrzeit erreicht -> Seite ist automatisch öffentlich
const codesKonfiguriert = !!(process.env.SITE_ACCESS_SECRET && (process.env.SITE_ACCESS_CODE_DOGI || process.env.SITE_ACCESS_CODE_VANVAN || process.env.SITE_ACCESS_CODE_DIENE));
if (!codesKonfiguriert) return next();
if (pfad === "/gate-auth" && req.method === "POST") {
// .catch(next) ergänzt 05.08.2026: ohne das würde ein Fehler beim Signieren zu einer
// unbehandelten Promise-Ablehnung und damit zum Absturz des ganzen Servers führen.
return Promise.resolve(gateAuthHandler(req, res)).catch(next);
}
const sessionToken = req.cookies?.[COOKIE_NAME];
(async () => {
const payload = sessionToken ? await verifySession(sessionToken, process.env.SITE_ACCESS_SECRET) : null;
if (payload) return next();
if (
pfad === "/gate.html" ||
pfad.startsWith("/assets/") ||
pfad === "/manifest.json" ||
pfad === "/manifest-verwaltung.json" ||
pfad === "/sw.js" ||
pfad === "/favicon.ico"
) {
return next();
}
const nextParam = pfad === "/" ? "/index.html" : pfad + (req.originalUrl.includes("?") ? "?" + req.originalUrl.split("?")[1] : "");
return res.redirect(302, `/gate.html?next=${encodeURIComponent(nextParam)}`);
})().catch(next); // siehe oben: verhindert Serverabsturz bei einem Fehler in der Prüfung
}