From ad075849068532e3d7c05660679e985ced2e650b Mon Sep 17 00:00:00 2001 From: qcigano Date: Mon, 3 Aug 2026 18:55:44 +0200 Subject: [PATCH] =?UTF-8?q?Eigene=20Zugangscode-Schranke=20statt=20Cloudfl?= =?UTF-8?q?are=20Access=20=E2=80=94=20l=C3=B6st=20VanVans=20Login-Problem?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ersetzt die (fehlerhaft auf die GANZE Domain wirkende) Cloudflare-Zero- Trust-Access-Sperre durch eine eigene, schlanke Lösung nach dem bewährten Vorbild von "Dogfather Universe": eine Pages-Functions-Middleware (functions/_middleware.js) prüft bei jeder Anfrage ein HMAC-signiertes Session-Cookie; ohne gültiges Cookie geht's zu public/gate.html (im Van's- Design), wo ein Zugangscode eingegeben wird. Zwei getrennte Codes für qciga und VanVan, 90 Tage gültig, danach neu eingeben. /admin/* ist komplett ausgenommen — läuft weiter über das echte GitHub-OAuth-Login, das für VanVan schon funktioniert. Bonus: die drei separaten Vorschau-Seiten (demo-normal/demo-konto/demo-abo) sind jetzt überflüssig und entfernt. Stattdessen gibt's ein schwebendes Umschalter-Widget (Layout.astro), das NUR sichtbar ist, wenn das Gate-Cookie vorhanden ist (also nur für uns beide) — auf jeder beliebigen Seite kann per Knopfdruck zwischen "Normaler Besucher", "Angemeldete Kundin" und "Abonnentin" gewechselt werden, ohne die Seite verlassen zu müssen. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 2 + astro.config.mjs | 5 +- functions/_middleware.js | 172 +++++++++++++++++++++++++++++ public/gate.html | 173 ++++++++++++++++++++++++++++++ src/layouts/Layout.astro | 106 ++++++++++++++++++ src/pages/demo-abo/index.astro | 69 ------------ src/pages/demo-konto/index.astro | 59 ---------- src/pages/demo-normal/index.astro | 29 ----- src/styles/global.css | 48 +++++++++ 9 files changed, 502 insertions(+), 161 deletions(-) create mode 100644 functions/_middleware.js create mode 100644 public/gate.html delete mode 100644 src/pages/demo-abo/index.astro delete mode 100644 src/pages/demo-konto/index.astro delete mode 100644 src/pages/demo-normal/index.astro 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 @@ + + + + + +Zugang — Van's DIY & Bastelbedarf + + + + + +
+ 🔒 Noch nicht öffentlich +

Van's DIY & Bastelbedarf

+

Diese Seite ist gerade im Aufbau und noch nicht für die Öffentlichkeit freigegeben.
Zugangscode eingeben, um weiterzukommen.

+
+ + +
+

+ +
+ + + diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index 1b90654..d453651 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -179,10 +179,116 @@ const p = (href: string) => `${localePrefix(lang)}${href}`; + {/* Vorschau-Umschalter: nur sichtbar, wenn im Browser ein gültiges Gate-Zugangscookie liegt + (siehe functions/_middleware.js + public/gate.html) — also nur für uns beide (qciga/vanvan), + niemals für echte Besucher:innen. Ersetzt die früheren separaten demo-Vorschauseiten: EINE + schwebende Box, auf JEDER Seite nutzbar, simuliert per Knopfdruck sofort den gewählten + Konto-Zustand (localStorage) und lädt die aktuelle Seite neu. */} + + diff --git a/src/pages/demo-konto/index.astro b/src/pages/demo-konto/index.astro deleted file mode 100644 index c9fbef3..0000000 --- a/src/pages/demo-konto/index.astro +++ /dev/null @@ -1,59 +0,0 @@ ---- -import Layout from "../../layouts/Layout.astro"; -import type { Locale } from "../../i18n/config"; - -const lang: Locale = "de"; - -// Rein internes Vorschau-Konto — zeigt das Kundenkonto einer ganz normalen, angemeldeten Person -// OHNE Abo (Treuebonus dadurch bewusst im gesperrten Zustand, siehe scripts/account.ts). Wird -// per JS ins localStorage DIESES Browsers geschrieben, ändert nichts an echten Kundendaten. -const demoAccount = { - loggedIn: true, - name: "Vorschau Kundin", - username: "Vorschau", - email: "vorschau@vans-diy-bastelbedarf.de", - photo: "", - bewerteteBestellungen: [], - treueBestellungen: 4, - treuebonusAktiv: false, - treuebonusArtikelSlug: null, - abo: { - stufe: null, - startDatum: null, - bezahltBisDatum: null, - bezahlteMonate: 0, - gekuendigt: false, - gekuendigtAm: null, - kleinTeddyFreigeschaltetGesamt: 0, - grossTeddyFreigeschaltetGesamt: 0, - kleinTeddyEingeloest: 0, - grossTeddyEingeloest: 0, - historie: [], - }, - benachrichtigungen: [], -}; ---- - -
-
-

- 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 … -

-

Falls die Weiterleitung nicht klappt: hier klicken →

-
-
-
- - diff --git a/src/pages/demo-normal/index.astro b/src/pages/demo-normal/index.astro deleted file mode 100644 index 13fca7f..0000000 --- a/src/pages/demo-normal/index.astro +++ /dev/null @@ -1,29 +0,0 @@ ---- -import Layout from "../../layouts/Layout.astro"; -import type { Locale } from "../../i18n/config"; - -const lang: Locale = "de"; ---- - -
-
-

- 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 … -

-

Falls die Weiterleitung nicht klappt: hier klicken →

-
-
-
- - diff --git a/src/styles/global.css b/src/styles/global.css index dc07bba..beeee04 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -2632,3 +2632,51 @@ a:focus-visible, button:focus-visible { .abo-notif-body { display: flex; flex-direction: column; gap: 0.15rem; } .abo-notif-date { color: var(--c-text-muted); } @media (prefers-reduced-motion: reduce) { .abo-plan-card, .abo-plan-card.is-current-plan, .abo-reward-ready { animation: none; transition: none; } } + +/* Vorschau-Umschalter (nur für qciga/vanvan sichtbar, siehe Layout.astro) — schwebt rechts unten + über jeder Seite, damit man ohne Seitenwechsel zwischen normalem Besucher/angemeldeter Kundin/ + Abonnentin hin- und herschalten kann. */ +#vandiy-preview-switcher { position: fixed; right: 1.2rem; bottom: 1.2rem; z-index: 200; } +#vandiy-preview-toggle { + width: 52px; + height: 52px; + border-radius: 50%; + border: 1.5px solid transparent; + background: + linear-gradient(155deg, var(--c-anthracite-light), var(--c-anthracite)) padding-box, + linear-gradient(120deg, #9b7fd6, var(--c-accent), #e3b23c) border-box; + font-size: 1.4rem; + cursor: pointer; + box-shadow: 0 10px 26px -10px rgba(0, 0, 0, 0.7); + transition: transform 0.2s var(--ease); +} +#vandiy-preview-toggle:hover { transform: scale(1.08); } +#vandiy-preview-panel { + display: none; + position: absolute; + right: 0; + bottom: calc(100% + 0.7rem); + min-width: 240px; + padding: 0.8rem; + border-radius: var(--radius-m); + background: var(--c-anthracite-light); + border: 1px solid rgba(244, 241, 247, 0.14); + box-shadow: var(--shadow-card); + flex-direction: column; + gap: 0.4rem; +} +#vandiy-preview-panel.open { display: flex; } +#vandiy-preview-current { margin: 0 0 0.3rem; color: var(--c-text-muted); } +#vandiy-preview-panel button[data-preview] { + text-align: left; + padding: 0.55em 0.7em; + border-radius: var(--radius-s); + border: 1px solid rgba(244, 241, 247, 0.12); + background: rgba(244, 241, 247, 0.04); + color: var(--c-text); + font-size: 0.88rem; + cursor: pointer; + transition: background 0.15s var(--ease); +} +#vandiy-preview-panel button[data-preview]:hover { background: color-mix(in srgb, var(--c-accent) 14%, transparent); } +@media (max-width: 500px) { #vandiy-preview-switcher { right: 0.7rem; bottom: 0.7rem; } }