Files
vans-diy-bastelbedarf/functions/_middleware.js
T
qcigano c6f8465e64 Zugangsseite: zwei eigene Fenster für qciga und VanVan statt einer gemeinsamen Box
Auf ausdrücklichen Wunsch im Stil einer bereits bestehenden Zwei-Personen-Zugangsseite umgebaut,
aber komplett an Van's eigene Marke angepasst (Farben/Schrift/Wasserzeichen unverändert: Lila/
Anthrazit/Hellblau/Gold statt fremder Projektfarben). Jede Person bekommt ihr eigenes Fenster mit
Avatar (VanVan: ihr echtes Porträtfoto; qciga: passendes Icon, da noch kein Foto in diesem Projekt
hinterlegt ist), eigenem Codefeld und eigenem "Öffnen"-Button in unterschiedlicher Akzentfarbe
(Cyan/Violett für qciga, Gold/Violett für VanVan). Technisch unverändert: /gate-auth erkennt die
Rolle weiterhin selbst anhand des eingegebenen Codes, unabhängig vom benutzten Fenster.

Dabei gefundener und behobener Bug: /img/vanvan-portrait.png wurde von der Zugangs-Schranke selbst
blockiert (302) — das für den Avatar gebrauchte Foto zeigte deshalb nur einen leeren Kreis. Gezielt
nur diese eine Datei freigegeben (kein Shop-/Kundendatum, ohnehin VanVans öffentliches Porträtfoto).

Per echtem Browsertest verifiziert: beide Fenster funktional getrennt (falscher Code im einen
Fenster zeigt den Fehler nur dort, richtiger Code im anderen Fenster loggt korrekt ein), Layout auf
Desktop nebeneinander und auf schmalen Bildschirmen sauber gestapelt.
2026-08-04 16:28:05 +02:00

315 lines
17 KiB
JavaScript

/* =====================================================================
functions/_middleware.js — Zugangs-Schranke vor der ganzen Website.
Van's DIY & Bastelbedarf ist technisch schon live (Cloudflare Pages),
soll aber noch nicht öffentlich einsehbar sein — nur qcigano und VanVan
sollen jederzeit von überall reinkommen können, ohne dass es eine
Enterprise-Lösung wie Cloudflare Access/Zero Trust braucht (die kostet ab
einer gewissen Nutzerzahl und bräuchte einen separaten Login-Flow, der
VanVan genau die Probleme gemacht hat, die zu diesem Umbau geführt haben).
Gleiches Prinzip wie beim Dogfather-Universe-Projekt, nur als Cloudflare
PAGES FUNCTIONS Middleware statt Workers-Static-Assets (Pages hat kein
env.ASSETS.fetch() — hier übernimmt context.next() dieselbe Rolle: die
eigentliche Seite normal weiter ausliefern).
Ablauf:
- Kein/ungültiges Session-Cookie -> Zugangscode-Seite (/gate.html) statt
der eigentlichen Seite, Zielseite wird als ?next= mitgegeben.
- POST /gate-auth mit korrektem Code -> signiertes, HttpOnly-Cookie
"vandiy_gate_session" (90 Tage) wird gesetzt + ein zweites, NICHT-
HttpOnly Cookie "vandiy_gate_role" (qciga/vanvan) — rein zur Anzeige des
Vorschau-Umschalters im Frontend (siehe Layout.astro), kein
Sicherheits-Token.
- Gültiges Session-Cookie -> normale Auslieferung über context.next().
- /admin/* läuft IMMER unangetastet durch diese Schranke durch — der
Adminbereich hat sein eigenes, echtes Login (GitHub-OAuth über den
cms-auth-Worker) und funktioniert für VanVan bereits einwandfrei; eine
zusätzliche Schranke davor würde nur doppelt verwirren.
- /verwaltung/* (interne Bestellübersicht) und /api/verwaltung/* (die dazugehörigen
Bestell-Daten, siehe functions/api/verwaltung/) bleiben IMMER geschützt — auch wenn der
Rest der Seite später per SITE_PUBLIC=true freigeschaltet wird (siehe unten). Diese Seite
ist nie für Kund:innen gedacht. /api/orders (Bestellung ANLEGEN beim Checkout) ist bewusst
NICHT dauerhaft geschützt — das muss auch für echte Kund:innen erreichbar sein, sobald der
Shop öffentlich ist, genau wie der Checkout selbst.
- Die *.pages.dev-Adresse (interne "Werkstatt"/Vorschau, siehe onRequest() unten) bleibt
UNABHÄNGIG vom SITE_PUBLIC-Schalter für immer hinter dem Zugangscode — nur die spätere eigene
Domain soll durch SITE_PUBLIC=true öffentlich werden.
- /verwaltung/* + /api/verwaltung/* verlangen ZUSÄTZLICH zum normalen 90-Tage-Seitenzugang noch
eine zweite, eigene Sitzung, die nur 24 Stunden gültig ist (siehe
functions/_shared/verwaltung-auth.js, /verwaltung-code.html) — auf ausdrücklichen Wunsch.
Geprüft wird dabei GENAU DERSELBE persönliche Code wie beim normalen Seitenzugang (kein
separater dritter Code mehr — das hatte anfangs zu Verwirrung geführt, siehe Tagesprotokoll
04.08.2026). Wer /verwaltung/ öffnen will, tippt also überall denselben Code, muss ihn aber
hier einmal täglich neu eingeben, statt 90 Tage lang eingeloggt zu bleiben.
- API-Routen (/api/*) bekommen bei fehlender Sitzung eine 401-JSON-Antwort statt der
302-Weiterleitung zur Zugangscode-Seite — ein fetch() im Checkout/Verwaltung-Skript soll
einen klaren Fehler sehen, nicht versehentlich HTML als Antwort bekommen.
- POST /gate-logout (04.08.2026, Abmelde-Button auf /verwaltung/) löscht alle drei
Zugangs-Cookies auf einmal (normale Sitzung + Rollen-Anzeige + tägliche Verwaltungs-Sitzung).
Die Signatur (HMAC-SHA256 über Web Crypto, in jeder Workers/Pages-Runtime
eingebaut) verhindert, dass jemand sich einfach selbst ein
"vandiy_gate_session=irgendwas"-Cookie setzt.
===================================================================== */
import { COOKIE_NAME, ROLE_COOKIE_NAME, SESSION_TAGE, signSession, verifySession, getCookie } from "./_shared/auth.js";
import { VERWALTUNG_COOKIE_NAME, VERWALTUNG_SESSION_STUNDEN, signVerwaltungSession, verifyVerwaltungSession } from "./_shared/verwaltung-auth.js";
/* Seiten, die auch ohne Zugangscode ausgeliefert werden (siehe Begründung unten in onRequest).
Bewusst als feste, vollständige Liste statt "alles was mit X anfängt" — so kann hier nicht
versehentlich mehr freigegeben werden als gewollt. */
const OEFFENTLICHE_SEITEN = new Set([
"/impressum",
"/datenschutz",
"/agb",
"/widerruf",
"/muster-widerrufsformular",
]);
/* Damit diese Seiten nicht nackt/ohne Gestaltung beim Prüfteam ankommen, sind zusätzlich die
rein gestalterischen Dateien freigegeben: Stylesheets sowie Logo/Favicons. Bewusst NUR
.css aus /_astro/ — die JavaScript-Bündel dort enthalten u.a. Produktdaten und bleiben
deshalb weiterhin hinter der Schranke.
/sw.js + /manifest.webmanifest (04.08.2026 ergänzt): enthalten keinerlei Shop-/Kundendaten
(Service Worker ist reines Caching-Skript, Manifest nur Name/Icons/Farben), wurden bisher aber
von der Schranke blockiert (302 statt der Datei) — dadurch schlug u.a. die Service-Worker-
Registrierung auf /admin/ mit einem echten Browserfehler fehl, sobald jemand /admin/ ohne
gültige Haupt-Gate-Sitzung aufruft (z.B. direkt per Lesezeichen, ohne vorher die Haupt-Seite
besucht/eingeloggt zu haben).
/img/vanvan-portrait.png (04.08.2026 ergänzt): wird als Avatar-Foto direkt AUF der
Zugangscode-Seite selbst gezeigt (siehe gate.html) — ohne Freigabe zeigt das Foto dort nur einen
leeren Kreis, weil selbst dieses eine Bild noch hinter der Schranke lag, die es doch gerade
anzeigen soll. Kein Shop-/Kundendatum, sondern VanVans eigenes öffentliches Porträtfoto (wird
nach dem Öffentlich-Schalten ohnehin überall sichtbar sein, z.B. Startseite). */
function istOeffentlichErreichbar(pfad) {
const ohneSlash = pfad.endsWith("/") && pfad.length > 1 ? pfad.slice(0, -1) : pfad;
if (OEFFENTLICHE_SEITEN.has(ohneSlash)) return true;
if (pfad.startsWith("/_astro/") && pfad.endsWith(".css")) return true;
if (pfad === "/img/vanvan-portrait.png") return true;
if (/^\/(logo\.png|favicon\.ico|favicon-\d+x\d+\.png|apple-touch-icon\.png|icon-\d+(-maskable)?\.png|sw\.js|manifest\.webmanifest)$/.test(pfad)) return true;
return false;
}
async function handleAuth(request, env) {
let body;
try {
body = await request.json();
} catch {
return new Response(JSON.stringify({ ok: false, error: "Ungültige Anfrage." }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const code = (body.code || "").trim();
const next = typeof body.next === "string" && body.next.startsWith("/") ? body.next : "/";
let role = null;
if (env.SITE_ACCESS_CODE_QCIGA && code === env.SITE_ACCESS_CODE_QCIGA) role = "qciga";
else if (env.SITE_ACCESS_CODE_VANVAN && code === env.SITE_ACCESS_CODE_VANVAN) role = "vanvan";
if (!role) {
return new Response(JSON.stringify({ ok: false, error: "Falscher Zugangscode." }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const session = await signSession(role, env.SITE_ACCESS_SECRET);
const maxAge = SESSION_TAGE * 24 * 60 * 60;
const headers = new Headers({ "Content-Type": "application/json" });
headers.append(
"Set-Cookie",
`${COOKIE_NAME}=${encodeURIComponent(session)}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`
);
headers.append(
"Set-Cookie",
`${ROLE_COOKIE_NAME}=${role}; Path=/; Max-Age=${maxAge}; Secure; SameSite=Lax`
);
return new Response(JSON.stringify({ ok: true, next }), { status: 200, headers });
}
/* Gegenstück zu handleAuth() oben, aber für die zweite, tägliche Sitzung auf /verwaltung/ — auf
ausdrücklichen Wunsch NACH einer Verwirrung mit einem anfangs dritten, separaten Code umgestellt
(04.08.2026): Es gibt KEINEN eigenen Verwaltungs-Code mehr. Stattdessen gilt hier genau derselbe
persönliche Code wie beim normalen Seitenzugang (SITE_ACCESS_CODE_QCIGA / SITE_ACCESS_CODE_VANVAN,
siehe handleAuth() oben) — wer sich einmal am Tag einloggt, tippt also überall denselben, ihr
bereits bekannten Code, statt sich einen dritten, separaten Code merken zu müssen. Einzige
Besonderheit gegenüber dem normalen Zugang: die dadurch erzeugte SITZUNG läuft schon nach 24h ab
(statt 90 Tage), wodurch derselbe Code hier täglich neu eingegeben werden muss. */
async function handleVerwaltungAuth(request, env) {
let body;
try {
body = await request.json();
} catch {
return new Response(JSON.stringify({ ok: false, error: "Ungültige Anfrage." }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const code = (body.code || "").trim();
const next = typeof body.next === "string" && body.next.startsWith("/") ? body.next : "/verwaltung/";
const codeGueltig =
(env.SITE_ACCESS_CODE_QCIGA && code === env.SITE_ACCESS_CODE_QCIGA) ||
(env.SITE_ACCESS_CODE_VANVAN && code === env.SITE_ACCESS_CODE_VANVAN);
if (!codeGueltig) {
return new Response(JSON.stringify({ ok: false, error: "Falscher Code." }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const session = await signVerwaltungSession(env.VERWALTUNG_SESSION_SECRET);
const maxAge = VERWALTUNG_SESSION_STUNDEN * 60 * 60;
const headers = new Headers({ "Content-Type": "application/json" });
headers.append(
"Set-Cookie",
`${VERWALTUNG_COOKIE_NAME}=${encodeURIComponent(session)}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`
);
return new Response(JSON.stringify({ ok: true, next }), { status: 200, headers });
}
/* Gegenstück zu handleAuth()/handleVerwaltungAuth() oben: löscht ALLE drei Zugangs-Cookies auf
einmal (normale 90-Tage-Sitzung, die Rollen-Anzeige dafür, UND die tägliche Verwaltungs-Sitzung)
— auf ausdrücklichen Wunsch (04.08.2026) als eigener Abmelde-Button auf /verwaltung/. Löscht
bewusst auch die Verwaltungs-Sitzung mit, nicht nur die normale, damit "abmelden" wirklich
"abmelden von allem" bedeutet und nicht nur einen Teil der Schranke offen lässt. */
function handleLogout() {
const headers = new Headers({ "Content-Type": "application/json" });
const loeschen = (name, httpOnly) =>
`${name}=; Path=/; Max-Age=0; ${httpOnly ? "HttpOnly; " : ""}Secure; SameSite=Lax`;
headers.append("Set-Cookie", loeschen(COOKIE_NAME, true));
headers.append("Set-Cookie", loeschen(ROLE_COOKIE_NAME, false));
headers.append("Set-Cookie", loeschen(VERWALTUNG_COOKIE_NAME, true));
return new Response(JSON.stringify({ ok: true }), { status: 200, headers });
}
export async function onRequest(context) {
const { request, next, env } = context;
const url = new URL(request.url);
// /admin/* NIE anfassen — eigenes, echtes GitHub-OAuth-Login, funktioniert für VanVan bereits.
if (url.pathname.startsWith("/admin")) {
return next();
}
// Abmelden muss IMMER funktionieren — unabhängig von SITE_PUBLIC, Hostname oder ob die
// Sitzung technisch schon abgelaufen/ungültig ist. Deshalb ganz oben geprüft, vor jeder
// sonstigen Weiche unten.
if (url.pathname === "/gate-logout" && request.method === "POST") {
return handleLogout();
}
// Rechtstexte (Impressum, Datenschutz, AGB, Widerruf) bleiben IMMER ohne Zugangscode
// erreichbar. Grund: PayPal prüft vor der Freischaltung von "Log in with PayPal" die
// angegebene Datenschutz- und AGB-Adresse tatsächlich nach — hinter der Zugangsschranke
// sähe deren Prüfteam nur die Code-Eingabe, und die Freischaltung würde scheitern. Gleiches
// gilt für Google (verlangt beim Veröffentlichen eine erreichbare Datenschutzerklärung).
// Rechtlich ist das ohnehin die saubere Seite: Impressum und Datenschutzerklärung sollen
// "ständig verfügbar" sein. Diese Seiten enthalten keine Shop-/Kundendaten.
if (istOeffentlichErreichbar(url.pathname)) {
return next();
}
// Fail-open-Absicherung: fehlen die Zugangs-Secrets komplett (z.B. noch nicht gesetzt), lieber
// GAR KEINE Schranke scharf schalten, als sich selbst aus der eigenen Seite auszusperren.
const codesKonfiguriert = !!(env.SITE_ACCESS_SECRET && (env.SITE_ACCESS_CODE_QCIGA || env.SITE_ACCESS_CODE_VANVAN));
if (!codesKonfiguriert) {
return next();
}
// /verwaltung/* (Bestellübersicht — nur für qciga/vanvan) bleibt IMMER geschützt, unabhängig
// vom SITE_PUBLIC-Schalter unten. Sobald der Shop live geht, setzt man einfach das Secret
// SITE_PUBLIC=true (wrangler pages secret put) — dann öffnet sich der Rest der Seite für alle,
// aber /verwaltung/ bleibt weiterhin komplett hinter dem Zugangscode.
const immerGeschuetzterPfad = url.pathname.startsWith("/verwaltung") || url.pathname.startsWith("/api/verwaltung");
// WICHTIG (04.08.2026, auf ausdrücklichen Wunsch klargestellt): Die *.pages.dev-Adresse ist die
// interne "Werkstatt"/Vorschau-Adresse, unter der qciga und VanVan gemeinsam an der Seite
// arbeiten und Änderungen zuerst begutachten, BEVOR sie über die eigene Domain live gehen —
// diese Adresse soll NIEMALS öffentlich werden, auch nicht aus Versehen. Nur die spätere eigene
// Domain (vans-diy-bastelbedarf.com) soll durch SITE_PUBLIC=true öffentlich geschaltet werden
// können. Ohne diese Unterscheidung würde SITE_PUBLIC=true (gedacht für den Domain-Go-Live)
// versehentlich auch die *.pages.dev-Vorschau-Adresse für jede/n im Internet öffnen — genau das
// darf nie passieren, deshalb hier hart anhand des aufgerufenen Hostnamens geprüft, unabhängig
// vom SITE_PUBLIC-Wert.
const istWorkVorschauHost = url.hostname.endsWith(".pages.dev");
const sitePublic = env.SITE_PUBLIC === "true" && !istWorkVorschauHost;
if (sitePublic && !immerGeschuetzterPfad) {
return next();
}
if (url.pathname === "/gate-auth" && request.method === "POST") {
return handleAuth(request, env);
}
const sessionToken = getCookie(request, COOKIE_NAME);
const payload = sessionToken ? await verifySession(sessionToken, env.SITE_ACCESS_SECRET) : null;
if (payload) {
// Normaler Seitenzugang ist gültig — für alles außerhalb von /verwaltung/ reicht das.
if (!immerGeschuetzterPfad) {
return next();
}
// Ab hier: /verwaltung/* oder /api/verwaltung/* MIT gültigem normalem Seitenzugang.
// Die Eingabemaske für den zweiten Code + ihr eigener Auswertungs-Endpunkt müssen erreichbar
// sein, BEVOR der zweite Code selbst geprüft wird — sonst käme hier niemand überhaupt zur
// Eingabe. Gleiches "/verwaltung-code.html" <-> "/verwaltung-code"-Doppel wie bei gate.html
// oben, aus demselben Grund (Cloudflare Pages html_handling).
if (url.pathname === "/verwaltung-code.html" || url.pathname === "/verwaltung-code") {
return next();
}
if (url.pathname === "/verwaltung-auth" && request.method === "POST") {
return handleVerwaltungAuth(request, env);
}
// Fail-open auch hier: fehlt das Sitzungs-Secret komplett, lieber die zweite Schranke nicht
// scharf schalten, als sich selbst auszusperren — die erste Schranke (normaler Seitenzugang)
// schützt /verwaltung/ in diesem Fall ja immer noch. Die Zugangscodes selbst
// (SITE_ACCESS_CODE_QCIGA/_VANVAN) sind an dieser Stelle bereits garantiert vorhanden, siehe
// codesKonfiguriert-Prüfung weiter oben in onRequest().
if (!env.VERWALTUNG_SESSION_SECRET) {
return next();
}
const vwToken = getCookie(request, VERWALTUNG_COOKIE_NAME);
const vwPayload = vwToken ? await verifyVerwaltungSession(vwToken, env.VERWALTUNG_SESSION_SECRET) : null;
if (vwPayload) {
return next();
}
if (url.pathname.startsWith("/api/")) {
return new Response(JSON.stringify({ ok: false, error: "Zusätzlicher Verwaltungs-Code erforderlich." }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const vwNext = url.pathname + url.search;
return Response.redirect(`${url.origin}/verwaltung-code.html?next=${encodeURIComponent(vwNext)}`, 302);
}
// gate.html selbst muss ohne Session ladbar sein, sonst kommt niemand überhaupt bis zur
// Eingabemaske. Ist komplett selbst-gestylt (inline CSS, keine externen Assets) — deshalb
// reicht diese eine Ausnahme, es müssen keine CSS-/Bild-Pfade extra freigegeben werden.
// WICHTIG: Cloudflare Pages leitet "/gate.html" standardmäßig automatisch auf die
// "saubere" URL "/gate" um (html_handling) — beide Schreibweisen müssen hier durchgelassen
// werden, sonst entsteht eine Endlosschleife (gate.html -> Redirect auf /gate -> Middleware
// kennt /gate nicht -> Redirect zurück auf gate.html -> ...).
if (url.pathname === "/gate.html" || url.pathname === "/gate") {
return next();
}
// API-Aufrufe (fetch() aus Checkout/Verwaltung) sollen bei fehlender Sitzung eine saubere
// 401-JSON-Antwort bekommen statt eines 302 auf die HTML-Zugangscode-Seite (siehe Kopfkommentar).
if (url.pathname.startsWith("/api/")) {
return new Response(JSON.stringify({ ok: false, error: "Nicht angemeldet." }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const nextParam = url.pathname + url.search;
return Response.redirect(`${url.origin}/gate?next=${encodeURIComponent(nextParam)}`, 302);
}