208 lines
8.5 KiB
JavaScript
208 lines
8.5 KiB
JavaScript
/* =====================================================================
|
|
gate-worker.js — Zugangs-Schranke vor der ganzen statischen Website.
|
|
|
|
Warum ein eigener Worker-Einstiegspunkt statt reiner "[assets]"-Auslieferung:
|
|
Solange die Seite noch nicht öffentlich sein soll (Dogi will weiter daran
|
|
arbeiten), aber Dogi + VanVan trotzdem jederzeit von überall draufkommen
|
|
müssen, reicht eine reine statische Auslieferung nicht — es braucht eine
|
|
Prüfung VOR jeder Anfrage. Cloudflare Access (Zero Trust) wäre die
|
|
"Enterprise"-Lösung, kostet aber ab einer gewissen Nutzerzahl und bräuchte
|
|
einen separaten Login-Flow; diese Lösung hier ist funktional gleichwertig
|
|
für 2 Personen, komplett kostenlos und lebt im selben Repo.
|
|
|
|
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
|
|
"dogi_session" (90 Tage) wird gesetzt + ein zweites, NICHT-HttpOnly
|
|
Cookie "dogi_role" (dogi/vanvan) — rein zur Anzeige des
|
|
Vorschau-Umschalters im Frontend (main.js), kein Sicherheits-Token.
|
|
- Gültiges Session-Cookie -> normale Auslieferung über env.ASSETS.
|
|
|
|
Die Signatur (HMAC-SHA256 über Web Crypto, in jeder Workers-Runtime
|
|
eingebaut) verhindert, dass jemand sich einfach selbst ein
|
|
"dogi_session=irgendwas"-Cookie setzt.
|
|
===================================================================== */
|
|
|
|
const COOKIE_NAME = "dogi_session";
|
|
const ROLE_COOKIE_NAME = "dogi_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;
|
|
}
|
|
|
|
/* html_handling = "none" (wrangler.toml) liefert NUR exakt passende
|
|
Dateipfade aus — verhindert zwar die Redirect-Schleife zwischen
|
|
/gate.html und /gate (siehe Kommentar in wrangler.toml), heißt aber
|
|
auch: der bloße Wurzelpfad "/" wird NICHT mehr automatisch auf
|
|
"/index.html" abgebildet (es gibt ja keine Datei namens "/"). Ohne
|
|
diesen Ausgleich hier landete man nach erfolgreichem Login auf einer
|
|
404-Seite, weil sowohl der Standard-"next"-Wert als auch direkte
|
|
Aufrufe der nackten Domain auf "/" zeigen. Deshalb: "/" bei der
|
|
Auslieferung IMMER auf "/index.html" umbiegen. */
|
|
function assetsFetch(request, env) {
|
|
const url = new URL(request.url);
|
|
if (url.pathname === "/") {
|
|
url.pathname = "/index.html";
|
|
request = new Request(url, request);
|
|
}
|
|
return env.ASSETS.fetch(request);
|
|
}
|
|
|
|
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();
|
|
// "/" wird HIER schon aussortiert (nicht erst in assetsFetch weiter unten
|
|
// abgefangen) — Grund: ein bereits offener, veralteter gate.html-Tab
|
|
// (z.B. mit "?next=%2F" von vor diesem Fix in der Adresszeile) würde
|
|
// sonst weiterhin exakt "/" an den Server schicken und dieser hätte es
|
|
// unverändert durchgereicht ("/" beginnt ja mit "/", bestand also den
|
|
// alten Check). Das war der eigentliche Rest-Bug, wieso die 404-Seite
|
|
// nach dem Einloggen trotz der vorherigen Korrektur weiter auftauchte:
|
|
// der Server war nicht wirklich die "Quelle der Wahrheit", sondern hat
|
|
// dem Client teilweise vertraut. Jetzt: "/" gilt serverseitig IMMER als
|
|
// ungültiges Ziel, unabhängig davon, was der (evtl. veraltete) Client
|
|
// schickt.
|
|
const next =
|
|
typeof body.next === "string" && body.next.startsWith("/") && body.next !== "/"
|
|
? body.next
|
|
: "/index.html";
|
|
|
|
let role = null;
|
|
if (env.SITE_ACCESS_CODE_DOGI && code === env.SITE_ACCESS_CODE_DOGI) role = "dogi";
|
|
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 default {
|
|
async fetch(request, env) {
|
|
const url = new URL(request.url);
|
|
|
|
// Fail-open-Absicherung umgekehrt: fehlen die Zugangs-Secrets (z.B. noch
|
|
// nicht gesetzt), lieber die Schranke NICHT scharf schalten, als Dogi
|
|
// selbst auszusperren, während er live daran arbeitet.
|
|
if (!env.SITE_ACCESS_SECRET || (!env.SITE_ACCESS_CODE_DOGI && !env.SITE_ACCESS_CODE_VANVAN)) {
|
|
return assetsFetch(request, env);
|
|
}
|
|
|
|
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 assetsFetch(request, env);
|
|
}
|
|
|
|
// gate.html selbst + seine Assets (CSS/Bilder) müssen ohne Session
|
|
// ladbar sein, sonst kommt niemand überhaupt bis zur Eingabemaske.
|
|
// /assets/ generell mit freizugeben ist bewusst in Kauf genommen (reines
|
|
// Design-CSS/JS, kein Inhalt) — hält gate.html im selben Look wie die
|
|
// restliche Seite, ohne alles inline duplizieren zu müssen.
|
|
// manifest.json/sw.js/Icons ebenfalls IMMER freigeben (04.08.2026-Fix):
|
|
// der Browser fragt diese rein technischen PWA-Dateien manchmal in
|
|
// einem Kontext ab, der kein Session-Cookie mitschickt — landete dann
|
|
// in der Login-Weiterleitung statt echtem JSON ("Manifest: Syntax
|
|
// error" in der Konsole, Icons/PWA-Installierbarkeit kaputt). Kein
|
|
// schützenswerter Inhalt, nur Technik.
|
|
if (
|
|
url.pathname === "/gate.html" ||
|
|
url.pathname.startsWith("/assets/") ||
|
|
url.pathname === "/manifest.json" ||
|
|
url.pathname === "/sw.js" ||
|
|
url.pathname === "/favicon.ico"
|
|
) {
|
|
return assetsFetch(request, env);
|
|
}
|
|
|
|
// "/" selbst soll ebenfalls zu gate.html führen, aber als spätere
|
|
// Zielseite "/index.html" mitgeben (siehe next-Kommentar oben) statt
|
|
// des nicht auflösbaren "/".
|
|
const next = url.pathname === "/" ? "/index.html" : url.pathname + url.search;
|
|
return Response.redirect(`${url.origin}/gate.html?next=${encodeURIComponent(next)}`, 302);
|
|
},
|
|
};
|