diff --git a/.gitignore b/.gitignore index f7f1a6b..7e9239b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ dist/ !.env.example .DS_Store *.log +.dev.vars +.wrangler/ diff --git a/astro.config.mjs b/astro.config.mjs index 289ce85..bac8f1c 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -8,10 +8,7 @@ const SITE_URL = 'https://www.vans-diy-bastelbedarf.de'; export default defineConfig({ site: SITE_URL, - // /demo-*/ sind interne Vorschau-Seiten (simulierte Konto-Zustände für VanVan und mich zum - // Anschauen) — sollen nie in der Sitemap/Suchmaschinen auftauchen (siehe zusätzlich noindex-Meta - // in den Seiten selbst). - integrations: [sitemap({ filter: (page) => !page.includes('/demo-abo/') && !page.includes('/demo-konto/') && !page.includes('/demo-normal/') })], + integrations: [sitemap()], vite: { server: { // Erlaubt Zugriff über den temporären cloudflared-Quick-Tunnel (nur für lokale diff --git a/functions/_middleware.js b/functions/_middleware.js new file mode 100644 index 0000000..ad5d958 --- /dev/null +++ b/functions/_middleware.js @@ -0,0 +1,172 @@ +/* ===================================================================== + 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); +} diff --git a/public/gate.html b/public/gate.html new file mode 100644 index 0000000..6adc59f --- /dev/null +++ b/public/gate.html @@ -0,0 +1,173 @@ + + +
+ + +Diese Seite ist gerade im Aufbau und noch nicht für die Öffentlichkeit freigegeben.
Zugangscode eingeben, um weiterzukommen.
🧶 Handgemacht mit Liebe
+- Interne Vorschau für VanVan — zeigt das Kundenkonto einer ganz normalen angemeldeten - Person OHNE Abo (Treuebonus ist dadurch bewusst gesperrt zu sehen — genau das Verhalten, - das Kund:innen ohne Abo anspornen soll). Wird nur in DEINEM Browser simuliert, ändert - nichts an echten Kundendaten. Du wirst gleich automatisch weitergeleitet … -
- -- Interne Vorschau für VanVan — zeigt den Shop so, wie ihn ein ganz normaler, NICHT - angemeldeter Besucher sieht (räumt dafür ein eventuell simuliertes Konto in diesem Browser - wieder auf). Du wirst gleich automatisch weitergeleitet … -
- -