/* ===================================================================== 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. 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. ===================================================================== */ const COOKIE_NAME = "vandiy_gate_session"; const ROLE_COOKIE_NAME = "vandiy_gate_role"; const SESSION_TAGE = 90; 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"] ); } async function signSession(role, secret) { const payload = JSON.stringify({ role, exp: Date.now() + SESSION_TAGE * 24 * 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)); const sigB64 = b64urlEncode(new Uint8Array(sig)); return `${payloadB64}.${sigB64}`; } 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; // { role, exp } } catch { return null; } } function getCookie(request, name) { const header = request.headers.get("Cookie") || ""; const match = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`)); return match ? decodeURIComponent(match[1]) : null; } 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(); } // Fail-open-Absicherung: fehlen die Zugangs-Secrets (z.B. noch nicht gesetzt), lieber die // Schranke NICHT scharf schalten, als sich selbst aus der eigenen Seite auszusperren. if (!env.SITE_ACCESS_SECRET || (!env.SITE_ACCESS_CODE_QCIGA && !env.SITE_ACCESS_CODE_VANVAN)) { 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(); } const nextParam = url.pathname + url.search; return Response.redirect(`${url.origin}/gate?next=${encodeURIComponent(nextParam)}`, 302); }