/* ============================================================ ToolCTRL — Landing Page Server Abhängigkeitsfrei (nur Node-Standardbibliothek). - Liefert /public - POST /api/lead — Kontakt-Leads - POST /api/gamejam/upload — GameJam: Spiel einreichen (Datei-Upload, wird per ClamAV auf Viren geprüft) - GET /api/gamejam/games — GameJam: eingereichte Spiele ============================================================ */ "use strict"; const http = require("http"); const fs = require("fs"); const path = require("path"); const crypto = require("crypto"); const os = require("os"); const net = require("net"); const ROOT = __dirname; const PUBLIC_DIR = path.join(ROOT, "public"); const DATA_DIR = process.env.DATA_DIR ? path.resolve(process.env.DATA_DIR) : ROOT; const LEADS_DIR = path.join(DATA_DIR, "leads"); const LEADS_JSON = path.join(LEADS_DIR, "leads.ndjson"); const LEADS_CSV = path.join(LEADS_DIR, "leads.csv"); /* GameJam-Speicher */ const GAMEJAM_DIR = path.join(DATA_DIR, "gamejam"); const SUBMISSIONS_JSON = path.join(GAMEJAM_DIR, "submissions.ndjson"); const GAMES_DIR = process.env.DATA_DIR ? path.join(DATA_DIR, "gamejam-games") : path.join(PUBLIC_DIR, "gamejam-games"); const MAX_UPLOAD = 5 * 1024 * 1024; /* 5 MB pro eingereichtem Spiel */ /* Hart-Deadline für GameJam-Uploads: 27.05.2026, 22:00 Europe/Berlin (CEST = UTC+2). */ const UPLOAD_DEADLINE = new Date("2026-05-27T22:00:00+02:00"); const APP_REVISION = process.env.APP_REVISION || "local"; /* Central lead-inbox (infra ADR 012). Sites forward leads here; local files stay as a fallback. Unset env => old local-only behavior. */ const LEAD_INBOX_URL = process.env.LEAD_INBOX_URL || ""; const LEAD_INBOX_KEY = process.env.LEAD_INBOX_KEY || ""; async function forwardLeadToInbox(lead) { if (!LEAD_INBOX_URL || !LEAD_INBOX_KEY) return false; try { const res = await fetch(LEAD_INBOX_URL.replace(/\/+$/, "") + "/v1/leads", { method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer " + LEAD_INBOX_KEY }, /* inbox accepts DE aliases: firma, telefon, nachricht, quelle */ body: JSON.stringify(lead), signal: AbortSignal.timeout(5000) }); return res.ok; } catch (e) { console.error("[lead] inbox forward failed:", e.message); return false; } } function envFlag(name, defaultValue) { const raw = process.env[name]; if (raw == null || raw === "") return defaultValue; return !/^(0|false|no|off)$/i.test(raw); } function envInt(name, defaultValue) { const raw = Number.parseInt(process.env[name] || "", 10); return Number.isFinite(raw) ? raw : defaultValue; } /* Virenscan über ClamAV. Der Server spricht das clamd-Socket-Protokoll direkt — keine zusätzliche Abhängigkeit. Voraussetzung: ClamAV ist installiert und der Daemon "clamd" läuft (Einrichtung siehe README). enabled:false schaltet den Scan ab (z. B. lokal ohne ClamAV). */ const CLAMAV = { enabled: envFlag("CLAMAV_ENABLED", true), host: process.env.CLAMAV_HOST || "127.0.0.1", port: envInt("CLAMAV_PORT", 3310), /* clamd TCPSocket-Standardport */ timeoutMs: envInt("CLAMAV_TIMEOUT_MS", 15000) }; /* Bevorzugte Ports — der erste freie wird genutzt. */ const PORT_CANDIDATES = process.env.PORT ? [envInt("PORT", 3000)] : [4317, 4318, 4417, 5317, 8317, 0]; /* 0.0.0.0 = auf allen Schnittstellen erreichbar, auch für andere Geräte im selben WLAN (z. B. Handys, die den QR-Code scannen). */ const HOST = process.env.HOST || process.env.HOSTNAME || "0.0.0.0"; /* Erste IPv4-LAN-Adresse dieses Rechners ermitteln. */ function getLanIP() { const ifaces = os.networkInterfaces(); for (const name of Object.keys(ifaces)) { for (const net of ifaces[name] || []) { if (net.family === "IPv4" && !net.internal) return net.address; } } return null; } const MIME = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".ico": "image/x-icon", ".json": "application/json; charset=utf-8", ".woff2": "font/woff2", ".txt": "text/plain; charset=utf-8", ".xml": "application/xml; charset=utf-8" }; /* ---------- Lead-Speicher vorbereiten ---------- */ function ensureLeadStore() { if (!fs.existsSync(LEADS_DIR)) fs.mkdirSync(LEADS_DIR, { recursive: true }); if (!fs.existsSync(LEADS_CSV)) { fs.writeFileSync( LEADS_CSV, "zeitpunkt,name,firma,email,telefon,nachricht,quelle\n", "utf8" ); } } /* ---------- GameJam-Speicher vorbereiten ---------- */ function ensureGamejamStore() { if (!fs.existsSync(GAMEJAM_DIR)) fs.mkdirSync(GAMEJAM_DIR, { recursive: true }); if (!fs.existsSync(GAMES_DIR)) fs.mkdirSync(GAMES_DIR, { recursive: true }); } function assertInside(baseDir, candidate) { const relative = path.relative(baseDir, candidate); return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); } function writeProbe(dir) { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); const file = path.join(dir, ".write-probe-" + process.pid + "-" + Date.now()); fs.writeFileSync(file, "ok", "utf8"); fs.unlinkSync(file); } function csvCell(value) { const s = String(value == null ? "" : value); return '"' + s.replace(/"/g, '""').replace(/\r?\n/g, " ") + '"'; } function saveLead(lead) { ensureLeadStore(); fs.appendFileSync(LEADS_JSON, JSON.stringify(lead) + "\n", "utf8"); const row = [ lead.zeitpunkt, lead.name, lead.firma, lead.email, lead.telefon, lead.nachricht, lead.quelle ] .map(csvCell) .join(","); fs.appendFileSync(LEADS_CSV, row + "\n", "utf8"); } /* ---------- Statische Dateien ---------- */ function serveStatic(req, res) { let urlPath = decodeURIComponent(req.url.split("?")[0]); /* SEO: kanonischer Host (www -> non-www) und /index.html -> / */ const host = (req.headers.host || "").toLowerCase(); if (host === "www.tool-ctrl.de") { res.writeHead(301, { Location: "https://tool-ctrl.de" + req.url }); res.end(); return; } if (urlPath === "/index.html") { res.writeHead(301, { Location: "/" }); res.end(); return; } if (urlPath === "/") urlPath = "/index.html"; /* Path-Traversal verhindern */ let baseDir = PUBLIC_DIR; let filePath = path.normalize(path.join(PUBLIC_DIR, urlPath)); if (urlPath.startsWith("/gamejam-games/")) { baseDir = GAMES_DIR; filePath = path.normalize(path.join(GAMES_DIR, urlPath.replace(/^\/gamejam-games\//, ""))); } if (!assertInside(baseDir, filePath)) { res.writeHead(403).end("Forbidden"); return; } fs.stat(filePath, (err, stat) => { if (err || !stat.isFile()) { res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" }); res.end("
Seite nicht gefunden.
"); return; } const ext = path.extname(filePath).toLowerCase(); /* HTML immer frisch, statische Assets lange cachen (Versionierung via ?v=) */ const cacheControl = ext === ".html" ? "no-cache" : "public, max-age=31536000, immutable"; const headers = { "Content-Type": MIME[ext] || "application/octet-stream", "Cache-Control": cacheControl }; /* Hochgeladene GameJam-Spiele nicht indexieren (Fremdcontent) */ if (urlPath.startsWith("/gamejam-games/")) { headers["X-Robots-Tag"] = "noindex"; } res.writeHead(200, headers); fs.createReadStream(filePath).pipe(res); }); } /* ---------- Lead-Endpunkt ---------- */ function handleLead(req, res) { let body = ""; let tooLarge = false; req.on("data", (chunk) => { body += chunk; if (body.length > 64 * 1024) { tooLarge = true; req.destroy(); } }); req.on("end", () => { if (tooLarge) return sendJSON(res, 413, { error: "Anfrage zu groß." }); let data; try { data = JSON.parse(body || "{}"); } catch (e) { return sendJSON(res, 400, { error: "Ungültiges Format." }); } /* Honeypot — befülltes Feld = Bot. Still mit OK quittieren. */ if (data.website && String(data.website).trim() !== "") { return sendJSON(res, 200, { ok: true }); } const name = String(data.name || "").trim(); const email = String(data.email || "").trim(); const nachricht = String(data.nachricht || "").trim(); if (!name || !email || !nachricht) { return sendJSON(res, 400, { error: "Bitte Name, E-Mail und Nachricht angeben." }); } if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { return sendJSON(res, 400, { error: "Bitte eine gültige E-Mail angeben." }); } const lead = { zeitpunkt: new Date().toISOString(), name: name.slice(0, 200), firma: String(data.firma || "").trim().slice(0, 200), email: email.slice(0, 200), telefon: String(data.telefon || "").trim().slice(0, 80), nachricht: nachricht.slice(0, 4000), quelle: String(data.quelle || "Landing Page").slice(0, 120) }; /* Central inbox is source of truth; local files are the fallback so a lead is never lost if the inbox is unreachable (infra ADR 012). */ forwardLeadToInbox(lead).then((forwarded) => { if (!forwarded) { try { saveLead(lead); } catch (e) { console.error("[lead] Speichern fehlgeschlagen:", e.message); return sendJSON(res, 500, { error: "Speichern fehlgeschlagen. Bitte später erneut versuchen." }); } } console.log( "[lead] " + (forwarded ? "→inbox " : "→local ") + lead.zeitpunkt + " — " + lead.name + (lead.firma ? " (" + lead.firma + ")" : "") + " <" + lead.email + ">" ); sendJSON(res, 200, { ok: true }); }); }); } /* ---------- Multipart-Parser (abhängigkeitsfrei) ---------- */ function parseMultipart(buffer, boundary) { const result = { fields: {}, files: [] }; const delim = Buffer.from("--" + boundary); let start = buffer.indexOf(delim); if (start < 0) return result; start += delim.length; while (start < buffer.length) { /* "--" direkt nach dem Delimiter => Ende */ if (buffer[start] === 0x2d && buffer[start + 1] === 0x2d) break; if (buffer[start] === 0x0d && buffer[start + 1] === 0x0a) start += 2; const next = buffer.indexOf(delim, start); if (next < 0) break; let partEnd = next; if (buffer[partEnd - 2] === 0x0d && buffer[partEnd - 1] === 0x0a) partEnd -= 2; const part = buffer.slice(start, partEnd); start = next + delim.length; const headerEnd = part.indexOf("\r\n\r\n"); if (headerEnd < 0) continue; const headerStr = part.slice(0, headerEnd).toString("utf8"); const content = part.slice(headerEnd + 4); const nameM = headerStr.match(/name="([^"]*)"/i); if (!nameM) continue; const fileM = headerStr.match(/filename="([^"]*)"/i); if (fileM) { result.files.push({ field: nameM[1], filename: fileM[1], data: content }); } else { result.fields[nameM[1]] = content.toString("utf8"); } } return result; } /* ---------- Virenscan via ClamAV (clamd INSTREAM) ---------- */ function scanWithClamAV(buffer) { return new Promise((resolve, reject) => { const socket = net.createConnection({ host: CLAMAV.host, port: CLAMAV.port }); let reply = ""; let settled = false; const done = (fn, arg) => { if (settled) return; settled = true; socket.destroy(); fn(arg); }; socket.setTimeout(CLAMAV.timeoutMs); socket.on("timeout", () => done(reject, new Error("clamd-Zeitüberschreitung"))); socket.on("error", (e) => done(reject, e)); socket.on("data", (d) => { reply += d.toString("utf8"); }); socket.on("end", () => { const r = reply.replace(/\0/g, "").trim(); if (/\bOK$/.test(r)) return done(resolve, { clean: true, signature: null }); const m = r.match(/:\s*(.+)\s+FOUND$/); if (m) return done(resolve, { clean: false, signature: m[1] }); done(reject, new Error("Unerwartete clamd-Antwort: " + r)); }); socket.on("connect", () => { socket.write("zINSTREAM\0"); const CHUNK = 64 * 1024; for (let i = 0; i < buffer.length; i += CHUNK) { const part = buffer.subarray(i, i + CHUNK); const len = Buffer.alloc(4); len.writeUInt32BE(part.length, 0); socket.write(len); socket.write(part); } socket.write(Buffer.alloc(4)); /* 0-Länge = Stream-Ende */ }); }); } /* Verfügbarkeits-Check: clamd PING -> PONG */ function clamavPing() { return new Promise((resolve) => { const socket = net.createConnection({ host: CLAMAV.host, port: CLAMAV.port }); let reply = ""; let settled = false; const done = (v) => { if (settled) return; settled = true; socket.destroy(); resolve(v); }; socket.setTimeout(3000); socket.on("timeout", () => done(false)); socket.on("error", () => done(false)); socket.on("data", (d) => { reply += d.toString("utf8"); }); socket.on("end", () => done(/PONG/.test(reply))); socket.on("connect", () => socket.write("zPING\0")); }); } /* ---------- GameJam: Spiel einreichen ---------- */ function handleGamejamUpload(req, res) { /* Hart-Deadline durchsetzen — danach werden keine Einreichungen mehr angenommen. */ if (Date.now() > UPLOAD_DEADLINE.getTime()) { return sendJSON(res, 410, { error: "Einreichungen sind seit dem 27.05.2026, 22:00 Uhr geschlossen." }); } const ctype = req.headers["content-type"] || ""; const bM = ctype.match(/boundary=(?:"([^"]+)"|([^;]+))/i); if (!/multipart\/form-data/i.test(ctype) || !bM) { return sendJSON(res, 400, { error: "Ungültiges Upload-Format." }); } const boundary = (bM[1] || bM[2]).trim(); const chunks = []; let size = 0; let tooLarge = false; req.on("data", (chunk) => { size += chunk.length; if (size > MAX_UPLOAD) { tooLarge = true; req.destroy(); return; } chunks.push(chunk); }); req.on("end", async () => { if (tooLarge) { return sendJSON(res, 413, { error: "Datei zu groß (max. 5 MB)." }); } let parsed; try { parsed = parseMultipart(Buffer.concat(chunks), boundary); } catch (e) { return sendJSON(res, 400, { error: "Upload konnte nicht gelesen werden." }); } const teilnehmer = String(parsed.fields.teilnehmer || "").trim(); const game = String(parsed.fields.game || "").trim(); const file = parsed.files.find((f) => f.field === "file"); if (!teilnehmer || !game) { return sendJSON(res, 400, { error: "Teilnehmer und Game-Name angeben." }); } if (!file || !file.data || file.data.length === 0) { return sendJSON(res, 400, { error: "Keine Datei ausgewählt." }); } if (!/\.html?$/i.test(file.filename || "")) { return sendJSON(res, 400, { error: "Nur einzelne .html-Dateien." }); } /* Virenscan vor dem Speichern */ if (CLAMAV.enabled) { let scan; try { scan = await scanWithClamAV(file.data); } catch (e) { console.error("[gamejam] Virenscan nicht möglich:", e.message); return sendJSON(res, 503, { error: "Sicherheitsprüfung derzeit nicht verfügbar. Bitte später erneut versuchen." }); } if (!scan.clean) { console.warn( "[gamejam] Upload ABGELEHNT — Fund: " + scan.signature + " (Datei: " + (file.filename || "?") + ")" ); return sendJSON(res, 422, { error: "Die Datei wurde von der Sicherheitsprüfung abgelehnt." }); } } const id = Date.now().toString(36) + "-" + crypto.randomBytes(4).toString("hex"); const entry = { id: id, zeitpunkt: new Date().toISOString(), teilnehmer: teilnehmer.slice(0, 200), game: game.slice(0, 200), url: "/gamejam-games/" + id + ".html" }; try { ensureGamejamStore(); fs.writeFileSync(path.join(GAMES_DIR, id + ".html"), file.data); fs.appendFileSync(SUBMISSIONS_JSON, JSON.stringify(entry) + "\n", "utf8"); } catch (e) { console.error("[gamejam] Speichern fehlgeschlagen:", e.message); return sendJSON(res, 500, { error: "Speichern fehlgeschlagen." }); } console.log( "[gamejam] " + entry.zeitpunkt + " — \"" + entry.game + "\" von " + entry.teilnehmer ); sendJSON(res, 200, { ok: true, id: id }); }); } /* ---------- GameJam: eingereichte Spiele ---------- */ function handleGamejamGames(req, res) { let games = []; try { if (fs.existsSync(SUBMISSIONS_JSON)) { games = fs .readFileSync(SUBMISSIONS_JSON, "utf8") .split("\n") .filter(Boolean) .map((line) => { try { return JSON.parse(line); } catch (e) { return null; } }) .filter(Boolean) .map((g) => ({ id: g.id, teilnehmer: g.teilnehmer, game: g.game, url: g.url })); } } catch (e) { console.error("[gamejam] Galerie konnte nicht gelesen werden:", e.message); } sendJSON(res, 200, games); } function sendJSON(res, code, obj) { res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" }); res.end(JSON.stringify(obj)); } function handleHealthz(req, res) { if (req.method !== "GET" && req.method !== "HEAD") { return sendJSON(res, 405, { error: "Methode nicht erlaubt." }); } sendJSON(res, 200, { ok: true, service: "toolctrl-website", revision: APP_REVISION }); } async function handleReadyz(req, res) { if (req.method !== "GET" && req.method !== "HEAD") { return sendJSON(res, 405, { error: "Methode nicht erlaubt." }); } const checks = { leadsWritable: false, gamejamWritable: false, gamesWritable: false, clamav: !CLAMAV.enabled }; try { writeProbe(LEADS_DIR); checks.leadsWritable = true; writeProbe(GAMEJAM_DIR); checks.gamejamWritable = true; writeProbe(GAMES_DIR); checks.gamesWritable = true; if (CLAMAV.enabled) checks.clamav = await clamavPing(); } catch (e) { console.error("[readyz] Prüfung fehlgeschlagen:", e.message); } const ok = Object.keys(checks).every((key) => checks[key] === true); sendJSON(res, ok ? 200 : 503, { ok: ok, service: "toolctrl-website", revision: APP_REVISION, checks: checks }); } /* ---------- Router ---------- */ const server = http.createServer((req, res) => { const route = req.url.split("?")[0]; if (route === "/healthz") return handleHealthz(req, res); if (route === "/readyz") return handleReadyz(req, res); if (route === "/api/lead") { if (req.method !== "POST") { return sendJSON(res, 405, { error: "Methode nicht erlaubt." }); } return handleLead(req, res); } if (route === "/api/gamejam/upload") { if (req.method !== "POST") { return sendJSON(res, 405, { error: "Methode nicht erlaubt." }); } return handleGamejamUpload(req, res); } if (route === "/api/gamejam/games") { if (req.method !== "GET") { return sendJSON(res, 405, { error: "Methode nicht erlaubt." }); } return handleGamejamGames(req, res); } if (req.method !== "GET" && req.method !== "HEAD") { res.writeHead(405).end("Method Not Allowed"); return; } serveStatic(req, res); }); /* ---------- Freien Port suchen und starten ---------- */ function listen(candidates) { if (candidates.length === 0) { console.error("Kein freier Port gefunden."); process.exit(1); } const port = candidates[0]; server.once("error", (err) => { if (err.code === "EADDRINUSE") { console.log("Port " + port + " belegt — versuche nächsten …"); listen(candidates.slice(1)); } else { console.error("Serverfehler:", err.message); process.exit(1); } }); server.listen(port, HOST, () => { const actual = server.address().port; ensureLeadStore(); ensureGamejamStore(); const lan = getLanIP(); console.log(""); console.log(" ToolCTRL — Landing Page läuft"); console.log(" ----------------------------------------"); console.log(" Lokal: http://127.0.0.1:" + actual); if (lan) { console.log(" Netzwerk: http://" + lan + ":" + actual + " (andere Geräte im selben WLAN / QR-Code)"); } console.log(" Leads: " + LEADS_CSV); console.log(" GameJam-Einreichungen: " + SUBMISSIONS_JSON); console.log(" Daten: " + DATA_DIR); console.log(" Revision: " + APP_REVISION); if (CLAMAV.enabled) { clamavPing().then((ok) => { console.log(" Virenscan: " + (ok ? "aktiv (clamd " + CLAMAV.host + ":" + CLAMAV.port + ")" : "clamd NICHT erreichbar — GameJam-Uploads werden abgelehnt!")); }); } else { console.log(" Virenscan: deaktiviert (CLAMAV.enabled = false)"); } console.log(" Stop: Strg + C"); console.log(""); }); } listen(PORT_CANDIDATES);