Deploy / build-and-deploy (push) Successful in 21s
server.js: handleGamejamUpload lehnt nach UPLOAD_DEADLINE mit HTTP 410 und Klartext-Hinweis ab. gamejam.html: Formular wird nach Ablauf clientseitig gesperrt und ein roter Status zeigt den abgelaufenen Termin. Doppelschicht (Server + UI), Server ist verbindlich. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
615 lines
19 KiB
JavaScript
615 lines
19 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;
|
|
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";
|
|
|
|
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",
|
|
".ico": "image/x-icon",
|
|
".json": "application/json; charset=utf-8",
|
|
".woff2": "font/woff2"
|
|
};
|
|
|
|
/* ---------- 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]);
|
|
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();
|
|
res.writeHead(200, {
|
|
"Content-Type": MIME[ext] || "application/octet-stream",
|
|
"Cache-Control": "no-cache"
|
|
});
|
|
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)
|
|
};
|
|
|
|
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] " + 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);
|