diff --git a/.gitignore b/.gitignore index 2468d66..042ba05 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ node_modules/ cloudflare-worker/.wrangler/ cloudflare-worker/node_modules/ .wrangler/ +.env +.env.* +!.env.example +server/node_modules/ diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..1f18460 --- /dev/null +++ b/server/.env.example @@ -0,0 +1,8 @@ +# Vorlage — echte Werte kommen in eine eigene ".env"-Datei (nie committen). + +PORT=4100 + +# Zugangscode-Schranke — gleiche Codes wie beim bisherigen Cloudflare-Worker möglich, oder neu. +SITE_ACCESS_SECRET= +SITE_ACCESS_CODE_DOGI= +SITE_ACCESS_CODE_VANVAN= diff --git a/server/gate.js b/server/gate.js new file mode 100644 index 0000000..ea1e9b8 --- /dev/null +++ b/server/gate.js @@ -0,0 +1,99 @@ +/* ===================================================================== + gate.js — Zugangs-Schranke vor der ganzen statischen Website. 1:1 portiert aus + gate-worker.js (Cloudflare Worker), auf Express-Middleware umgestellt. Gleiche Logik: + HMAC-signierte Session-Cookies, Fail-open falls Secrets fehlen, gate.html + Assets immer + erreichbar, "/" wird intern auf "/index.html" abgebildet. + ===================================================================== */ + +export const COOKIE_NAME = "dogi_session"; +export const ROLE_COOKIE_NAME = "dogi_role"; +export 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"]); +} + +export 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)); + return `${payloadB64}.${b64urlEncode(new Uint8Array(sig))}`; +} + +export 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; + } catch { + return null; + } +} + +export async function gateAuthHandler(req, res) { + const body = req.body || {}; + const code = (body.code || "").trim(); + const next = typeof body.next === "string" && body.next.startsWith("/") && body.next !== "/" ? body.next : "/index.html"; + + let role = null; + if (process.env.SITE_ACCESS_CODE_DOGI && code === process.env.SITE_ACCESS_CODE_DOGI) role = "dogi"; + else if (process.env.SITE_ACCESS_CODE_VANVAN && code === process.env.SITE_ACCESS_CODE_VANVAN) role = "vanvan"; + + if (!role) { + return res.status(401).json({ ok: false, error: "Falscher Zugangscode." }); + } + + const session = await signSession(role, process.env.SITE_ACCESS_SECRET); + const maxAge = SESSION_TAGE * 24 * 60 * 60 * 1000; + res.cookie(COOKIE_NAME, session, { path: "/", maxAge, httpOnly: true, secure: true, sameSite: "lax" }); + res.cookie(ROLE_COOKIE_NAME, role, { path: "/", maxAge, secure: true, sameSite: "lax" }); + return res.json({ ok: true, next }); +} + +export function gateMiddleware(req, res, next) { + const pfad = req.path; + + const codesKonfiguriert = !!(process.env.SITE_ACCESS_SECRET && (process.env.SITE_ACCESS_CODE_DOGI || process.env.SITE_ACCESS_CODE_VANVAN)); + if (!codesKonfiguriert) return next(); + + if (pfad === "/gate-auth" && req.method === "POST") { + return gateAuthHandler(req, res); + } + + const sessionToken = req.cookies?.[COOKIE_NAME]; + + (async () => { + const payload = sessionToken ? await verifySession(sessionToken, process.env.SITE_ACCESS_SECRET) : null; + if (payload) return next(); + + if ( + pfad === "/gate.html" || + pfad.startsWith("/assets/") || + pfad === "/manifest.json" || + pfad === "/sw.js" || + pfad === "/favicon.ico" + ) { + return next(); + } + + const nextParam = pfad === "/" ? "/index.html" : pfad + (req.originalUrl.includes("?") ? "?" + req.originalUrl.split("?")[1] : ""); + return res.redirect(302, `/gate.html?next=${encodeURIComponent(nextParam)}`); + })(); +} diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..e3ac177 --- /dev/null +++ b/server/index.js @@ -0,0 +1,36 @@ +/* ===================================================================== + index.js — Haupteinstiegspunkt für DogFather Universe. Liefert die statische Website aus + (../ ist der Ordner mit allen .html-Dateien) plus die Zugangsschranke (gate.js), 1:1 wie + vorher der Cloudflare Worker (gate-worker.js). + ===================================================================== */ + +import "dotenv/config"; +import express from "express"; +import cookieParser from "cookie-parser"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { gateMiddleware } from "./gate.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SITE_DIR = join(__dirname, ".."); +const PORT = Number(process.env.PORT || 4100); + +const app = express(); +app.set("trust proxy", 1); + +app.use(cookieParser()); +app.use(express.json({ limit: "1mb" })); +app.use(gateMiddleware); + +app.use(express.static(SITE_DIR, { index: "index.html" })); + +app.use((req, res) => { + res.status(404).sendFile(join(SITE_DIR, "404.html"), (err) => { + if (err) res.status(404).send("Nicht gefunden."); + }); +}); + +app.listen(PORT, "127.0.0.1", () => { + console.log(`DogFather Universe – Server läuft auf http://127.0.0.1:${PORT}`); +}); diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..9bff2e7 --- /dev/null +++ b/server/package.json @@ -0,0 +1,17 @@ +{ + "name": "dogfather-universe-server", + "type": "module", + "version": "1.0.0", + "description": "Eigener Node.js/Express-Server für DogFather Universe – Ersatz für den Cloudflare-Worker (gate-worker.js), liefert die statische Seite + Zugangsschranke.", + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "start": "node index.js" + }, + "dependencies": { + "cookie-parser": "^1.4.7", + "dotenv": "^16.4.7", + "express": "^4.21.2" + } +}