Files
ToolCTRL-website/server.js
T
ToolCTRLandClaude Opus 4.7 b629bbc90a GameJam-Seite integriert
- public/gamejam.html: teilnehmerseitige AI-GameJam-Seite (TechDay
  Bruchsal, 27.05.) mit eigenem Schwarz-Weiß-Styling, Zurück-Link zur
  Landingpage
- server.js: abhängigkeitsfreier Multipart-Parser plus Endpunkte
  POST /api/gamejam/upload und GET /api/gamejam/games
- index.html: Navigations-Verweis "GameJam" in Header und Mobilmenü
- .gitignore: hochgeladene Spiele und Einreichungen ausgeschlossen

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 17:16:58 +02:00

399 lines
12 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)
- 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 ROOT = __dirname;
const PUBLIC_DIR = path.join(ROOT, "public");
const LEADS_DIR = path.join(ROOT, "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(ROOT, "gamejam");
const SUBMISSIONS_JSON = path.join(GAMEJAM_DIR, "submissions.ndjson");
const GAMES_DIR = path.join(PUBLIC_DIR, "gamejam-games");
const MAX_UPLOAD = 5 * 1024 * 1024; /* 5 MB pro eingereichtem Spiel */
/* Bevorzugte Ports — der erste freie wird genutzt. */
const PORT_CANDIDATES = [4317, 4318, 4417, 5317, 8317, 0];
const HOST = "127.0.0.1";
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 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 */
const filePath = path.normalize(path.join(PUBLIC_DIR, urlPath));
if (!filePath.startsWith(PUBLIC_DIR)) {
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;
}
/* ---------- GameJam: Spiel einreichen ---------- */
function handleGamejamUpload(req, res) {
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", () => {
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." });
}
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));
}
/* ---------- Router ---------- */
const server = http.createServer((req, res) => {
const route = req.url.split("?")[0];
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();
console.log("");
console.log(" ToolCTRL — Landing Page läuft");
console.log(" ----------------------------------------");
console.log(" URL: http://" + HOST + ":" + actual);
console.log(" GameJam: http://" + HOST + ":" + actual + "/gamejam.html");
console.log(" Leads: " + LEADS_CSV);
console.log(" GameJam-Einreichungen: " + SUBMISSIONS_JSON);
console.log(" Stop: Strg + C");
console.log("");
});
}
listen(PORT_CANDIDATES);