/* ===================================================================== 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. - 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. 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"; /* 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. */ 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 (/^\/(logo\.png|favicon\.ico|favicon-\d+x\d+\.png|apple-touch-icon\.png|icon-\d+(-maskable)?\.png)$/.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 }); } 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(); } // 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"); const sitePublic = env.SITE_PUBLIC === "true"; 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) { return next(); } // 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); }