Files
ToolCTRL-website/server.js
T

791 lines
25 KiB
JavaScript

/* ============================================================
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;
/* 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";
const PLATFORM_LEADS_PATH = "/v1/platform/leads";
const PLATFORM_LEAD_RETRYABLE_STATUSES = new Set([502, 503, 504]);
const PLATFORM_LEAD_CREDENTIAL_PATTERN = /^[A-Za-z0-9._~-]{32,512}$/;
const SUBMISSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{7,199}$/;
const TOOLCTRL_SOURCE_DETAIL = "Messe — Landing Page";
const TOOLCTRL_UTM_CAMPAIGN = "messe-landing-page";
class PlatformLeadConfigurationError extends Error {
constructor() {
super("Platform lead producer is not configured");
this.name = "PlatformLeadConfigurationError";
}
}
class PlatformLeadSubmissionError extends Error {
constructor(code, upstreamStatus) {
super("Platform lead submission failed: " + code);
this.name = "PlatformLeadSubmissionError";
this.code = code;
this.upstreamStatus = upstreamStatus;
}
}
function platformLeadConfig(env) {
const rawUrl = String(env.PLATFORM_LEADS_API_URL || "").trim();
const key = String(env.PLATFORM_LEADS_API_KEY || "").trim();
if (!rawUrl || !PLATFORM_LEAD_CREDENTIAL_PATTERN.test(key)) {
throw new PlatformLeadConfigurationError();
}
let baseUrl;
try {
baseUrl = new URL(rawUrl);
} catch (_error) {
throw new PlatformLeadConfigurationError();
}
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(baseUrl.hostname);
const validProtocol = baseUrl.protocol === "https:" ||
(baseUrl.protocol === "http:" && loopback);
if (!validProtocol || (baseUrl.pathname !== "/" && baseUrl.pathname !== "") ||
baseUrl.username || baseUrl.password || baseUrl.search || baseUrl.hash) {
throw new PlatformLeadConfigurationError();
}
return {
endpoint: new URL(PLATFORM_LEADS_PATH, baseUrl).toString(),
key: key
};
}
function compactObject(value) {
return Object.fromEntries(
Object.entries(value).filter((entry) => entry[1] !== undefined)
);
}
async function forwardLeadToPlatform(lead, options) {
const opts = options || {};
const config = platformLeadConfig(opts.env || process.env);
const fetchImpl = opts.fetchImpl || fetch;
const maxAttempts = opts.maxAttempts || 2;
const timeoutMs = opts.timeoutMs || 5000;
const payload = compactObject({
externalId: lead.submissionId,
sourceDetail: TOOLCTRL_SOURCE_DETAIL,
firstName: lead.firstName,
lastName: lead.lastName,
email: lead.email,
phone: lead.phone,
company: lead.company,
message: lead.message,
utmCampaign: TOOLCTRL_UTM_CAMPAIGN,
metadata: {
consent: true,
formId: "contact-form",
formName: "ToolCTRL Kontaktformular",
locale: "de-DE",
submissionId: lead.submissionId
}
});
const serializedPayload = JSON.stringify(payload);
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
let response;
try {
response = await fetchImpl(config.endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + config.key,
"Idempotency-Key": lead.submissionId
},
body: serializedPayload,
signal: AbortSignal.timeout(timeoutMs)
});
} catch (_error) {
if (attempt < maxAttempts) continue;
throw new PlatformLeadSubmissionError("network");
}
if (response.status === 201) {
const result = await response.json().catch(() => null);
if (result && typeof result.id === "string" &&
typeof result.status === "string" &&
typeof result.createdAt === "string") {
return result;
}
throw new PlatformLeadSubmissionError("invalid_response", response.status);
}
if (PLATFORM_LEAD_RETRYABLE_STATUSES.has(response.status) &&
attempt < maxAttempts) {
continue;
}
throw new PlatformLeadSubmissionError("upstream", response.status);
}
throw new PlatformLeadSubmissionError("network");
}
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"
};
/* ---------- 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);
}
/* ---------- 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("<h1>404</h1><p>Seite nicht gefunden.</p>");
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, submitLead) {
const contentLength = Number.parseInt(req.headers["content-length"] || "0", 10);
if (Number.isFinite(contentLength) && contentLength > 64 * 1024) {
req.resume();
return sendJSON(res, 413, { error: "Anfrage zu groß." });
}
let body = "";
let tooLarge = false;
req.on("data", (chunk) => {
if (tooLarge) return;
body += chunk;
if (Buffer.byteLength(body, "utf8") > 64 * 1024) {
tooLarge = true;
body = "";
}
});
req.on("end", async () => {
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." });
}
if (!data || typeof data !== "object" || Array.isArray(data)) {
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 stringFields = ["name", "email", "nachricht", "firma", "telefon", "submissionId"];
if (stringFields.some((field) =>
data[field] != null && typeof data[field] !== "string")) {
return sendJSON(res, 400, { error: "Ungültiges Format." });
}
const name = String(data.name || "").trim();
const email = String(data.email || "").trim().toLowerCase();
const message = String(data.nachricht || "").trim();
const company = String(data.firma || "").trim();
const phone = String(data.telefon || "").trim();
const submissionId = String(data.submissionId || "").trim();
if (!name || !email || !message) {
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." });
}
if (!SUBMISSION_ID_PATTERN.test(submissionId)) {
return sendJSON(res, 400, { error: "Gültige Submission-ID erforderlich." });
}
if (name.length > 241 || email.length > 320 || company.length > 200 ||
phone.length > 80 || message.length > 4000) {
return sendJSON(res, 400, { error: "Eingabe ist zu lang." });
}
const nameParts = name.split(/\s+/);
const firstName = nameParts.shift();
const lastName = nameParts.join(" ") || undefined;
if (firstName.length > 120 || (lastName && lastName.length > 120)) {
return sendJSON(res, 400, { error: "Name ist zu lang." });
}
const lead = {
submissionId: submissionId,
firstName: firstName,
lastName: lastName,
email: email,
phone: phone || undefined,
company: company || undefined,
message: message
};
try {
const result = await submitLead(lead);
console.log("[lead] →platform submission=" + submissionId + " lead=" + result.id);
return sendJSON(res, 200, { ok: true, id: result.id });
} catch (error) {
if (error instanceof PlatformLeadConfigurationError) {
console.error("[lead] platform producer is not configured");
return sendJSON(res, 503, {
error: "Anfragen können derzeit nicht übermittelt werden. Bitte später erneut versuchen."
});
}
if (error instanceof PlatformLeadSubmissionError) {
console.error("[lead] platform submission failed", {
code: error.code,
upstreamStatus: error.upstreamStatus
});
return sendJSON(res, 502, {
error: "Anfrage konnte nicht übermittelt werden. Bitte erneut versuchen."
});
}
console.error("[lead] unexpected platform producer failure");
return sendJSON(res, 500, {
error: "Interner Fehler. Bitte später erneut versuchen."
});
}
});
}
/* ---------- 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 = {
platformLeadsConfigured: false,
gamejamWritable: false,
gamesWritable: false,
clamav: !CLAMAV.enabled
};
try {
platformLeadConfig(process.env);
checks.platformLeadsConfigured = true;
} catch (_error) {
checks.platformLeadsConfigured = false;
}
try {
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 ---------- */
function createServer(options) {
const submitLead = (options && options.submitLead) || forwardLeadToPlatform;
return 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, submitLead);
}
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(server, 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(server, candidates.slice(1));
} else {
console.error("Serverfehler:", err.message);
process.exit(1);
}
});
server.listen(port, HOST, () => {
const actual = server.address().port;
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)");
}
try {
platformLeadConfig(process.env);
console.log(" Plattform-Leads: konfiguriert");
} catch (_error) {
console.log(" Plattform-Leads: NICHT konfiguriert — Formulare liefern 503");
}
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("");
});
}
if (require.main === module) {
listen(createServer(), PORT_CANDIDATES);
}
module.exports = {
PlatformLeadConfigurationError,
PlatformLeadSubmissionError,
createServer,
forwardLeadToPlatform,
platformLeadConfig
};