feat(leads): send contact requests to native platform intake #1

Closed
raffael wants to merge 2 commits from feat/native-platform-leads-producer into main
pull from: feat/native-platform-leads-producer
13 changed files with 786 additions and 154 deletions

No files matched your search

+1
View File
@@ -3,6 +3,7 @@
.DS_Store
node_modules
*.log
.env*
leads
gamejam
public/gamejam-games
+3
View File
@@ -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
+26
View File
@@ -0,0 +1,26 @@
name: CI
on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
jobs:
quality:
name: Syntax and tests
runs-on: toolctrl-ci
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Verify
run: |
npm run check
npm test
git diff --check
+39 -5
View File
@@ -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
+4
View File
@@ -11,3 +11,7 @@ public/gamejam-games/
# Node
node_modules/
*.log
# Runtime-Secrets (nur das Muster wird versioniert)
.env*
!.env.example
+1 -1
View File
@@ -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"]
+50 -9
View File
@@ -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=<source-spezifischer Key für toolctrl-website, 32–512 Zeichen>
```
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=<git-sha>
PLATFORM_LEADS_API_URL=https://api.hivegrid.de
PLATFORM_LEADS_API_KEY=<Runtime-Secret>
```
`/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`.
+9
View File
@@ -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"
}
}
+7 -6
View File
@@ -425,11 +425,11 @@
<div class="form-grid">
<div class="form-row">
<label for="f-vorname">Vorname <span>*</span></label>
<input id="f-vorname" name="vorname" type="text" autocomplete="given-name" required />
<input id="f-vorname" name="vorname" type="text" autocomplete="given-name" maxlength="120" required />
</div>
<div class="form-row">
<label for="f-nachname">Nachname <span>*</span></label>
<input id="f-nachname" name="nachname" type="text" autocomplete="family-name" required />
<input id="f-nachname" name="nachname" type="text" autocomplete="family-name" maxlength="120" required />
</div>
</div>
<div class="form-grid">
@@ -445,16 +445,16 @@
<div class="form-grid">
<div class="form-row">
<label for="f-email">E-Mail <span>*</span></label>
<input id="f-email" name="email" type="email" autocomplete="email" required />
<input id="f-email" name="email" type="email" autocomplete="email" maxlength="320" required />
</div>
<div class="form-row">
<label for="f-telefon">Telefonnummer</label>
<input id="f-telefon" name="telefon" type="tel" autocomplete="tel" />
<input id="f-telefon" name="telefon" type="tel" autocomplete="tel" maxlength="80" />
</div>
</div>
<div class="form-row">
<label for="f-nachricht">Worum geht es Ihnen? <span>*</span></label>
<textarea id="f-nachricht" name="nachricht" rows="4" required
<textarea id="f-nachricht" name="nachricht" rows="4" maxlength="4000" required
placeholder="Kurz Ihr Anliegen, wir kümmern uns um die Details."></textarea>
</div>
<!-- Honeypot gegen Spam -->
@@ -507,6 +507,7 @@
</div>
</footer>
<script src="/script.js?v=20260610"></script>
<script src="/submission-id.js?v=20260716"></script>
<script src="/script.js?v=20260716"></script>
</body>
</html>
+27 -7
View File
@@ -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.",
+49
View File
@@ -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
};
});
+244 -126
View File
@@ -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
};
+326
View File
@@ -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('<script src="/submission-id.js?v=');
const clientPosition = index.indexOf('<script src="/script.js?v=');
assert.notEqual(helperPosition, -1);
assert.notEqual(clientPosition, -1);
assert.equal(helperPosition < clientPosition, true);
});
});
describe("runtime readiness", () => {
it("fails without platform credentials and succeeds with valid runtime configuration", async () => {
const previousUrl = process.env.PLATFORM_LEADS_API_URL;
const previousKey = process.env.PLATFORM_LEADS_API_KEY;
try {
delete process.env.PLATFORM_LEADS_API_URL;
delete process.env.PLATFORM_LEADS_API_KEY;
await withServer(async () => ({ id: "unused" }), async (baseUrl) => {
const response = await fetch(baseUrl + "/readyz");
assert.equal(response.status, 503);
assert.equal((await response.json()).checks.platformLeadsConfigured, false);
});
process.env.PLATFORM_LEADS_API_URL = ENV.PLATFORM_LEADS_API_URL;
process.env.PLATFORM_LEADS_API_KEY = ENV.PLATFORM_LEADS_API_KEY;
await withServer(async () => ({ id: "unused" }), async (baseUrl) => {
const response = await fetch(baseUrl + "/readyz");
assert.equal(response.status, 200);
assert.equal((await response.json()).checks.platformLeadsConfigured, true);
});
} finally {
if (previousUrl == null) delete process.env.PLATFORM_LEADS_API_URL;
else process.env.PLATFORM_LEADS_API_URL = previousUrl;
if (previousKey == null) delete process.env.PLATFORM_LEADS_API_KEY;
else process.env.PLATFORM_LEADS_API_KEY = previousKey;
}
});
it("uses readiness rather than liveness for the container healthcheck", () => {
const dockerfile = fs.readFileSync(path.join(__dirname, "Dockerfile"), "utf8");
assert.match(dockerfile, /HEALTHCHECK[^]*\/readyz/);
});
});
describe("deployment contract", () => {
it("uses Coolify pull-deploy and verifies the ready revision", () => {
const workflow = fs.readFileSync(
path.join(__dirname, ".gitea/workflows/deploy.yml"),
"utf8"
);
assert.equal(
workflow.includes(
"/api/v1/deploy?uuid=${COOLIFY_RESOURCE_UUID}&force=true"
),
true
);
assert.doesNotMatch(
workflow,
/\/api\/v1\/applications\/[^\s"']+\/(?:start|restart)/
);
assert.equal(workflow.includes('-t "$IMAGE_LC:$SHA"'), true);
assert.equal(workflow.includes("EXPECTED_REVISION: ${{ github.sha }}"), true);
assert.equal(workflow.includes("READY_URL: https://tool-ctrl.de/readyz"), true);
assert.equal(
workflow.includes(
'if [ "$DEPLOYED_REVISION" = "$EXPECTED_REVISION" ]; then'
),
true
);
});
});
describe("POST /api/lead", () => {
const formBody = {
name: " Erika Musterfrau ",
firma: " Beispiel GmbH ",
email: " ERIKA@example.com ",
telefon: " +49 123 456 ",
nachricht: " Bitte melden. ",
website: "",
submissionId: LEAD.submissionId
};
it("returns success only after the platform accepted the normalized lead", async () => {
const captured = [];
await withServer(async (lead) => {
captured.push(lead);
return { id: "lead_route", status: "new", createdAt: "now" };
}, async (baseUrl) => {
const response = await fetch(baseUrl + "/api/lead", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formBody)
});
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { ok: true, id: "lead_route" });
});
assert.deepEqual(captured, [LEAD]);
assert.equal(fs.existsSync(path.join(testDataDir, "leads")), false);
});
it("returns an error and does not write NDJSON/CSV when the platform fails", async () => {
await withServer(async () => {
throw new PlatformLeadSubmissionError("network");
}, async (baseUrl) => {
const response = await fetch(baseUrl + "/api/lead", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formBody)
});
assert.equal(response.status, 502);
assert.equal((await response.json()).error.includes("erneut"), true);
});
assert.equal(fs.existsSync(path.join(testDataDir, "leads")), false);
});
it("requires a stable submission id before calling the platform", async () => {
let calls = 0;
await withServer(async () => {
calls += 1;
return { id: "unexpected" };
}, async (baseUrl) => {
const response = await fetch(baseUrl + "/api/lead", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...formBody, submissionId: undefined })
});
assert.equal(response.status, 400);
});
assert.equal(calls, 0);
});
it("keeps the honeypot response silent without creating a lead", async () => {
let calls = 0;
await withServer(async () => {
calls += 1;
return { id: "unexpected" };
}, async (baseUrl) => {
const response = await fetch(baseUrl + "/api/lead", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ website: "bot.example" })
});
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { ok: true });
});
assert.equal(calls, 0);
});
});