Alle ausstehenden Aenderungen (Bilder, Texte) fuer den Server-Umzug uebernommen
This commit is contained in:
+207
@@ -0,0 +1,207 @@
|
||||
/* =====================================================================
|
||||
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);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user