From 079764d93d2edb199a54fcbe0b316e143e028cfa Mon Sep 17 00:00:00 2001 From: Raffael Sheikh Date: Thu, 16 Jul 2026 09:07:42 +0200 Subject: [PATCH 1/2] feat(leads): submit contact requests to native platform intake --- .dockerignore | 1 + .env.example | 3 + .gitea/workflows/deploy.yml | 44 ++++- .gitignore | 4 + Dockerfile | 2 +- README.md | 59 +++++- package.json | 9 + public/index.html | 13 +- public/script.js | 34 +++- public/submission-id.js | 49 +++++ server.js | 370 ++++++++++++++++++++++++------------ server.test.js | 326 +++++++++++++++++++++++++++++++ 12 files changed, 760 insertions(+), 154 deletions(-) create mode 100644 .env.example create mode 100644 package.json create mode 100644 public/submission-id.js create mode 100644 server.test.js diff --git a/.dockerignore b/.dockerignore index f40a043..7bb42bc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,7 @@ .DS_Store node_modules *.log +.env* leads gamejam public/gamejam-games diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b0af968 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Server runtime only. Never expose the key in public/ or client-side JavaScript. +PLATFORM_LEADS_API_URL=https://api.hivegrid.de +PLATFORM_LEADS_API_KEY=replace-with-source-key-at-least-32-characters-long diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 687bdfb..7faea30 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -33,7 +33,8 @@ jobs: - name: Static checks run: | cd /workspace/repo - node --check server.js + npm run check + npm test git diff --check - name: Login to Gitea Registry @@ -45,18 +46,51 @@ jobs: SHA: ${{ github.sha }} run: | cd /workspace/repo - SHORT_SHA=${SHA:0:7} IMAGE_LC=$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]') docker build \ --build-arg APP_REVISION="$SHA" \ - -t "$IMAGE_LC:$SHORT_SHA" \ + -t "$IMAGE_LC:$SHA" \ -t "$IMAGE_LC:main" \ . - docker push "$IMAGE_LC:$SHORT_SHA" + docker push "$IMAGE_LC:$SHA" docker push "$IMAGE_LC:main" - name: Trigger Coolify deploy + env: + COOLIFY_RESOURCE_UUID: ocwtt7rd2zijtlszgua005k9 run: | curl -fsS -X POST \ -H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}" \ - "https://saas-ctrl.com/api/v1/applications/ocwtt7rd2zijtlszgua005k9/start" + "https://saas-ctrl.com/api/v1/deploy?uuid=${COOLIFY_RESOURCE_UUID}&force=true" + + - name: Verify deployed revision and readiness + env: + EXPECTED_REVISION: ${{ github.sha }} + READY_URL: https://tool-ctrl.de/readyz + run: | + for attempt in $(seq 1 36); do + READY_RESPONSE=$(curl -fsS --max-time 10 "$READY_URL" 2>/dev/null || true) + DEPLOYED_REVISION=$(printf '%s' "$READY_RESPONSE" | node -e ' + let body = ""; + process.stdin.on("data", (chunk) => { body += chunk; }); + process.stdin.on("end", () => { + try { + const parsed = JSON.parse(body); + if (parsed.ok === true && typeof parsed.revision === "string") { + process.stdout.write(parsed.revision); + } + } catch (_error) {} + }); + ' 2>/dev/null || true) + + if [ "$DEPLOYED_REVISION" = "$EXPECTED_REVISION" ]; then + echo "toolctrl-website is ready on revision $EXPECTED_REVISION" + exit 0 + fi + + echo "Waiting for revision $EXPECTED_REVISION (attempt $attempt/36)" + sleep 10 + done + + echo "Timed out waiting for ready revision $EXPECTED_REVISION" >&2 + exit 1 diff --git a/.gitignore b/.gitignore index bb42f04..733110e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,7 @@ public/gamejam-games/ # Node node_modules/ *.log + +# Runtime-Secrets (nur das Muster wird versioniert) +.env* +!.env.example diff --git a/Dockerfile b/Dockerfile index b033789..4359a02 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,6 @@ USER nodejs EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ - CMD curl -fsS http://127.0.0.1:3000/healthz >/dev/null || exit 1 + CMD curl -fsS http://127.0.0.1:3000/readyz >/dev/null || exit 1 CMD ["node", "server.js"] diff --git a/README.md b/README.md index 8543a7b..28b132e 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,13 @@ und Netzwerk-URL aus. Stoppen mit `Strg + C`. Voraussetzung: Node.js (getestet mit v26). +Qualitätschecks: + +```bash +npm run check +npm test +``` + ## Aufbau ``` @@ -29,18 +36,46 @@ public/ statische Website script.js Formular-Logik & Mobile-Navigation assets/ Logos, Emblem, Favicon, Icons, Schriften, Mockups server.js abhängigkeitsfreier Node-HTTP-Server + API -leads/ gesammelte Kontaktanfragen (wird automatisch angelegt) +leads/ historische NDJSON/CSV-Leads (nur noch Backfill-Quelle) gamejam/ GameJam-Einreichungen, Metadaten (wird automatisch angelegt) ``` `leads/`, `gamejam/` und hochgeladene Spiele sind per `.gitignore` vom Repo ausgeschlossen. -## Leads +## Plattform-Leads -`POST /api/lead` (JSON, Pflichtfelder `name`, `email`, `nachricht`). Jede -Anfrage landet sofort lokal in `leads/leads.csv` **und** `leads.ndjson` — so -geht auf der Messe nichts verloren. Ein Honeypot-Feld fängt einfache Bots ab. +`POST /api/lead` validiert das Kontaktformular serverseitig und sendet die +Anfrage an HiveGrids nativen Endpoint `POST /v1/platform/leads`. Pflichtfelder +sind `name`, `email`, `nachricht` und die vom Browser erzeugte `submissionId`. +Ein Honeypot-Feld fängt einfache Bots ab. + +Der Browser behält die Submission-ID nach einem fehlgeschlagenen Versuch mit +semantisch gleichem Payload bei. Wird das Formular inhaltlich geändert, bindet er +vor dem nächsten Senden eine neue ID. Der Server verwendet dieselbe ID als +`externalId`, `metadata.submissionId` und `Idempotency-Key`; auch der einmalige +Netzwerk-/5xx-Retry verwendet exakt denselben Key und Payload. Dem Formular wird +nur dann Erfolg gemeldet, wenn die Plattform den Lead mit `201` angenommen hat. +API-, Netzwerk- und Konfigurationsfehler bleiben Fehler. + +Die bisherige Zuordnung `Messe — Landing Page` bleibt im nativen Vertrag als +`sourceDetail` und `utmCampaign=messe-landing-page` erhalten. + +Erforderliche Runtime-Variablen: + +```bash +PLATFORM_LEADS_API_URL=https://api.hivegrid.de +PLATFORM_LEADS_API_KEY= +``` + +Der Bearer-Key gehört ausschließlich in die Server-Runtime (Coolify), nie ins +Repository oder nach `public/`. Ohne gültige Konfiguration liefert die Route +`503` und `/readyz` bleibt nicht bereit. + +Die bestehenden Dateien `leads/leads.ndjson` und `leads/leads.csv` sind +Legacy-Bestände für den kontrollierten R-LEADS-Backfill. Der laufende Producer +erstellt oder verändert sie nicht mehr und meldet bei Plattformfehlern keinen +lokalen Ersatz-Erfolg. ## GameJam-Upload & Virenscan (ClamAV) @@ -93,11 +128,15 @@ CLAMAV_HOST=127.0.0.1 CLAMAV_PORT=3310 CLAMAV_ENABLED=true APP_REVISION= +PLATFORM_LEADS_API_URL=https://api.hivegrid.de +PLATFORM_LEADS_API_KEY= ``` `/healthz` ist die leichte Liveness-Probe für Container und Coolify. -`/readyz` prüft zusätzlich Schreibzugriff auf die Datenverzeichnisse und, -falls aktiv, die Erreichbarkeit von ClamAV. +`/readyz` prüft zusätzlich die Plattform-Lead-Konfiguration, Schreibzugriff auf +die GameJam-Datenverzeichnisse und, falls aktiv, die Erreichbarkeit von ClamAV. +Der Container-Healthcheck verwendet `/readyz`, damit eine fehlende +Plattform-Lead-Konfiguration einen Deploy nicht gesund erscheinen lässt. ### Funktion testen @@ -117,9 +156,11 @@ Deployment folgt dem Gitea-Infra-Standard aus `toolctrl-labs/infra`: 1. Push auf `main` triggert `.gitea/workflows/deploy.yml`. 2. Gitea Actions baut das Docker-Image und pusht es nach `git.tool-ctrl.de/toolctrl-labs/toolctrl-website`. -3. Tags: kurzer Commit-SHA und `main`. +3. Tags: vollständiger, unveränderlicher Commit-SHA und `main`. 4. Coolify-App `toolctrl-website` (`ocwtt7rd2zijtlszgua005k9`) zieht - `:main` auf der Marketing-VM und startet neu. + `:main` per erzwungenem Pull-Deploy auf der Marketing-VM. +5. Der Workflow wartet anschließend, bis `/readyz` erfolgreich ist und exakt + den ausgelieferten Commit-SHA als `revision` meldet. Die Coolify-App läuft im Projekt `Marketing`, Environment `production`, Server `marketing-vm`, Port `3000`, mit persistentem Storage auf `/app/data`. diff --git a/package.json b/package.json new file mode 100644 index 0000000..8261c7b --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "toolctrl-website", + "version": "1.0.0", + "private": true, + "scripts": { + "check": "node --check server.js && node --check public/submission-id.js && node --check public/script.js", + "test": "node --test server.test.js" + } +} diff --git a/public/index.html b/public/index.html index d08060d..992a768 100644 --- a/public/index.html +++ b/public/index.html @@ -425,11 +425,11 @@
- +
- +
@@ -445,16 +445,16 @@
- +
- +
-
@@ -507,6 +507,7 @@
- + + diff --git a/public/script.js b/public/script.js index cff7b0d..29459ad 100644 --- a/public/script.js +++ b/public/script.js @@ -31,6 +31,8 @@ /* ---- Lead-Formular ---- */ var form = document.getElementById("lead-form"); var status = document.getElementById("form-status"); + var pendingSubmission = null; + var submissionIds = window.ToolCtrlSubmission; if (!form) return; function setStatus(msg, kind) { @@ -69,22 +71,39 @@ } var btn = form.querySelector("button[type=submit]"); + if (!submissionIds) { + setStatus("Formular konnte nicht initialisiert werden. Bitte laden Sie die Seite neu.", "err"); + return; + } var vorname = form.vorname.value.trim(); var nachname = form.nachname.value.trim(); var firmenname = form.firmenname.value.trim(); var branche = form.branche.value.trim(); - var payload = { + var leadInput = { name: (vorname + " " + nachname).trim(), - vorname: vorname, - nachname: nachname, firma: firmenname + (branche ? " (" + branche + ")" : ""), - firmenname: firmenname, - branche: branche, email: form.email.value.trim(), telefon: form.telefon.value.trim(), - nachricht: form.nachricht.value.trim(), + nachricht: form.nachricht.value.trim() + }; + pendingSubmission = submissionIds.bindSubmissionId( + pendingSubmission, + submissionIds.createLeadFingerprint(leadInput), + function () { return submissionIds.createSubmissionId(window.crypto); } + ); + var payload = { + name: leadInput.name, + vorname: vorname, + nachname: nachname, + firma: leadInput.firma, + firmenname: firmenname, + branche: branche, + email: leadInput.email, + telefon: leadInput.telefon, + nachricht: leadInput.nachricht, website: form.website.value.trim(), // Honeypot - quelle: "Landing Page" + quelle: "Landing Page", + submissionId: pendingSubmission.id }; if (btn) { btn.disabled = true; btn.textContent = "Wird gesendet …"; } @@ -102,6 +121,7 @@ }) .then(function (r) { if (r.ok) { + pendingSubmission = null; form.reset(); setStatus( "Vielen Dank — Ihre Anfrage ist bei uns eingegangen. Wir melden uns zeitnah.", diff --git a/public/submission-id.js b/public/submission-id.js new file mode 100644 index 0000000..f634571 --- /dev/null +++ b/public/submission-id.js @@ -0,0 +1,49 @@ +(function (root, factory) { + "use strict"; + var api = factory(); + if (typeof module === "object" && module.exports) module.exports = api; + if (root) root.ToolCtrlSubmission = api; +})(typeof globalThis === "object" ? globalThis : this, function () { + "use strict"; + + function createSubmissionId(cryptoApi) { + if (!cryptoApi || typeof cryptoApi.getRandomValues !== "function") { + throw new Error("Secure browser randomness is unavailable"); + } + if (typeof cryptoApi.randomUUID === "function") { + return cryptoApi.randomUUID(); + } + var bytes = new Uint8Array(16); + cryptoApi.getRandomValues(bytes); + return "web-" + Array.from(bytes, function (value) { + return value.toString(16).padStart(2, "0"); + }).join(""); + } + + function normalizedName(value) { + return String(value || "").trim().replace(/\s+/g, " "); + } + + function createLeadFingerprint(input) { + return JSON.stringify({ + name: normalizedName(input.name), + company: String(input.firma || "").trim(), + email: String(input.email || "").trim().toLowerCase(), + phone: String(input.telefon || "").trim(), + message: String(input.nachricht || "").trim(), + formId: "contact-form", + consent: true + }); + } + + function bindSubmissionId(pending, fingerprint, createId) { + if (pending && pending.fingerprint === fingerprint) return pending; + return { id: createId(), fingerprint: fingerprint }; + } + + return { + bindSubmissionId: bindSubmissionId, + createLeadFingerprint: createLeadFingerprint, + createSubmissionId: createSubmissionId + }; +}); diff --git a/server.js b/server.js index 6309b7d..da4f093 100644 --- a/server.js +++ b/server.js @@ -22,9 +22,6 @@ 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"); @@ -38,31 +35,127 @@ const MAX_UPLOAD = 5 * 1024 * 1024; /* 5 MB pro eingereichtem Spiel */ 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 || ""; +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"; -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; +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; @@ -120,18 +213,6 @@ const MIME = { ".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 }); @@ -150,28 +231,6 @@ function writeProbe(dir) { 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]); @@ -228,19 +287,26 @@ function serveStatic(req, res) { } /* ---------- Lead-Endpunkt ---------- */ -function handleLead(req, res) { +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 (body.length > 64 * 1024) { + if (Buffer.byteLength(body, "utf8") > 64 * 1024) { tooLarge = true; - req.destroy(); + body = ""; } }); - req.on("end", () => { + req.on("end", async () => { if (tooLarge) return sendJSON(res, 413, { error: "Anfrage zu groß." }); let data; @@ -250,16 +316,29 @@ function handleLead(req, res) { 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 name = String(data.name || "").trim(); - const email = String(data.email || "").trim(); - const nachricht = String(data.nachricht || "").trim(); + 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." }); + } - if (!name || !email || !nachricht) { + 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." }); @@ -267,38 +346,55 @@ function handleLead(req, res) { 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 = { - 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) + submissionId: submissionId, + firstName: firstName, + lastName: lastName, + email: email, + phone: phone || undefined, + company: company || undefined, + message: message }; - /* 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." - }); - } + 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." + }); } - - console.log( - "[lead] " + (forwarded ? "→inbox " : "→local ") + - lead.zeitpunkt + " — " + lead.name + - (lead.firma ? " (" + lead.firma + ")" : "") + " <" + lead.email + ">" - ); - sendJSON(res, 200, { ok: true }); - }); + 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." + }); + } }); } @@ -560,15 +656,20 @@ async function handleReadyz(req, res) { } const checks = { - leadsWritable: false, + platformLeadsConfigured: false, gamejamWritable: false, gamesWritable: false, clamav: !CLAMAV.enabled }; try { - writeProbe(LEADS_DIR); - checks.leadsWritable = true; + platformLeadConfig(process.env); + checks.platformLeadsConfigured = true; + } catch (_error) { + checks.platformLeadsConfigured = false; + } + + try { writeProbe(GAMEJAM_DIR); checks.gamejamWritable = true; writeProbe(GAMES_DIR); @@ -588,42 +689,45 @@ async function handleReadyz(req, res) { } /* ---------- Router ---------- */ -const server = http.createServer((req, res) => { - const route = req.url.split("?")[0]; +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 === "/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." }); + if (route === "/api/lead") { + if (req.method !== "POST") { + return sendJSON(res, 405, { error: "Methode nicht erlaubt." }); + } + return handleLead(req, res, submitLead); } - return handleLead(req, res); - } - if (route === "/api/gamejam/upload") { - if (req.method !== "POST") { - return sendJSON(res, 405, { error: "Methode nicht erlaubt." }); + if (route === "/api/gamejam/upload") { + if (req.method !== "POST") { + return sendJSON(res, 405, { error: "Methode nicht erlaubt." }); + } + return handleGamejamUpload(req, res); } - return handleGamejamUpload(req, res); - } - if (route === "/api/gamejam/games") { - if (req.method !== "GET") { - return sendJSON(res, 405, { error: "Methode nicht erlaubt." }); + if (route === "/api/gamejam/games") { + if (req.method !== "GET") { + return sendJSON(res, 405, { error: "Methode nicht erlaubt." }); + } + return handleGamejamGames(req, res); } - return handleGamejamGames(req, res); - } - if (req.method !== "GET" && req.method !== "HEAD") { - res.writeHead(405).end("Method Not Allowed"); - return; - } - serveStatic(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) { +function listen(server, candidates) { if (candidates.length === 0) { console.error("Kein freier Port gefunden."); process.exit(1); @@ -632,7 +736,7 @@ function listen(candidates) { server.once("error", (err) => { if (err.code === "EADDRINUSE") { console.log("Port " + port + " belegt — versuche nächsten …"); - listen(candidates.slice(1)); + listen(server, candidates.slice(1)); } else { console.error("Serverfehler:", err.message); process.exit(1); @@ -640,7 +744,6 @@ function listen(candidates) { }); server.listen(port, HOST, () => { const actual = server.address().port; - ensureLeadStore(); ensureGamejamStore(); const lan = getLanIP(); console.log(""); @@ -651,7 +754,12 @@ function listen(candidates) { console.log(" Netzwerk: http://" + lan + ":" + actual + " (andere Geräte im selben WLAN / QR-Code)"); } - console.log(" Leads: " + LEADS_CSV); + 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); @@ -669,4 +777,14 @@ function listen(candidates) { }); } -listen(PORT_CANDIDATES); +if (require.main === module) { + listen(createServer(), PORT_CANDIDATES); +} + +module.exports = { + PlatformLeadConfigurationError, + PlatformLeadSubmissionError, + createServer, + forwardLeadToPlatform, + platformLeadConfig +}; diff --git a/server.test.js b/server.test.js new file mode 100644 index 0000000..2363a14 --- /dev/null +++ b/server.test.js @@ -0,0 +1,326 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { after, describe, it } = require("node:test"); + +const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "toolctrl-leads-test-")); +process.env.DATA_DIR = testDataDir; +process.env.CLAMAV_ENABLED = "false"; + +const { + bindSubmissionId, + createLeadFingerprint +} = require("./public/submission-id"); + +const { + PlatformLeadConfigurationError, + PlatformLeadSubmissionError, + createServer, + forwardLeadToPlatform +} = require("./server"); + +const ENV = { + PLATFORM_LEADS_API_URL: "https://api.hivegrid.de", + PLATFORM_LEADS_API_KEY: "t".repeat(32) +}; + +const LEAD = { + submissionId: "334955b4-269d-4f18-b51b-7b1d93ed2103", + firstName: "Erika", + lastName: "Musterfrau", + email: "erika@example.com", + phone: "+49 123 456", + company: "Beispiel GmbH", + message: "Bitte melden." +}; + +function successResponse() { + return new Response(JSON.stringify({ + id: "lead_789", + status: "new", + createdAt: "2026-07-16T12:00:00.000Z" + }), { + status: 201, + headers: { "Content-Type": "application/json" } + }); +} + +async function withServer(submitLead, callback) { + const server = createServer({ submitLead: submitLead }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + try { + return await callback("http://127.0.0.1:" + address.port); + } finally { + await new Promise((resolve) => server.close(resolve)); + } +} + +after(() => { + fs.rmSync(testDataDir, { recursive: true, force: true }); +}); + +describe("forwardLeadToPlatform", () => { + it("sends the strict native contract without a source field", async () => { + const calls = []; + const fetchImpl = async (input, init) => { + calls.push({ input: String(input), init: init }); + return successResponse(); + }; + + const result = await forwardLeadToPlatform(LEAD, { + env: ENV, + fetchImpl: fetchImpl + }); + + assert.equal(result.id, "lead_789"); + assert.equal(calls.length, 1); + assert.equal(calls[0].input, "https://api.hivegrid.de/v1/platform/leads"); + assert.deepEqual(calls[0].init.headers, { + "Content-Type": "application/json", + Authorization: "Bearer " + "t".repeat(32), + "Idempotency-Key": LEAD.submissionId + }); + const body = JSON.parse(calls[0].init.body); + assert.deepEqual(body, { + externalId: LEAD.submissionId, + sourceDetail: "Messe — Landing Page", + firstName: "Erika", + lastName: "Musterfrau", + email: "erika@example.com", + phone: "+49 123 456", + company: "Beispiel GmbH", + message: "Bitte melden.", + utmCampaign: "messe-landing-page", + metadata: { + consent: true, + formId: "contact-form", + formName: "ToolCTRL Kontaktformular", + locale: "de-DE", + submissionId: LEAD.submissionId + } + }); + assert.equal(Object.hasOwn(body, "source"), false); + }); + + it("reuses the exact key and body across a retry", async () => { + const calls = []; + const fetchImpl = async (_input, init) => { + calls.push(init); + return calls.length === 1 + ? new Response(null, { status: 503 }) + : successResponse(); + }; + + await forwardLeadToPlatform(LEAD, { env: ENV, fetchImpl: fetchImpl }); + + assert.equal(calls.length, 2); + assert.equal(calls[0].body, calls[1].body); + assert.deepEqual(calls[0].headers, calls[1].headers); + }); + + it("rejects an API error instead of reporting success", async () => { + await assert.rejects( + forwardLeadToPlatform(LEAD, { + env: ENV, + fetchImpl: async () => new Response(null, { status: 401 }) + }), + (error) => error instanceof PlatformLeadSubmissionError && + error.code === "upstream" && error.upstreamStatus === 401 + ); + }); + + it("fails closed without runtime credentials", async () => { + await assert.rejects( + forwardLeadToPlatform(LEAD, { env: {} }), + PlatformLeadConfigurationError + ); + }); +}); + +describe("browser submission id binding", () => { + it("keeps the id for an equivalent retry and renews it after a semantic edit", () => { + let sequence = 0; + const createId = () => "submission-" + (++sequence); + const original = { + name: " Erika Musterfrau ", + firma: " Beispiel GmbH ", + email: " ERIKA@example.com ", + telefon: " +49 123 456 ", + nachricht: " Bitte melden. " + }; + const first = bindSubmissionId(null, createLeadFingerprint(original), createId); + const retry = bindSubmissionId(first, createLeadFingerprint({ + ...original, + name: "Erika Musterfrau", + email: "erika@EXAMPLE.com" + }), createId); + const edited = bindSubmissionId(retry, createLeadFingerprint({ + ...original, + nachricht: "Bitte morgen melden." + }), createId); + + assert.equal(retry, first); + assert.equal(retry.id, "submission-1"); + assert.equal(edited.id, "submission-2"); + }); + + it("loads the binding helper before the form client", () => { + const index = fs.readFileSync(path.join(__dirname, "public/index.html"), "utf8"); + const helperPosition = index.indexOf('