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.
315 lines
17 KiB
JavaScript
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);
|
|
}
|