Files
windwiderstand/src/routes/api/mitmachen/+server.ts
T
Peter Meier ae9536db3a
Deploy / verify (push) Successful in 1m2s
Deploy / deploy (push) Successful in 1m19s
fix(forms): unsichtbare Unicode-Zeichen in Eingaben strippen + FormCard-Rahmen
- cleanText(): Zero-Width-/Bidi-Marks/BOM (Copy-Paste aus iOS/WhatsApp)
  vor Validierung und im Payload entfernen — Telefonnummern mit
  U+202D/U+202C fielen sonst durch die Validierung
- FormCard.svelte: gerahmter Container, umschließt Kontakt- und
  Mitmachen-Formular

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:36:47 +02:00

161 lines
5.3 KiB
TypeScript

import { json } from "@sveltejs/kit";
import { env as publicEnv } from "$env/dynamic/public";
import { env as privateEnv } from "$env/dynamic/private";
import type { RequestHandler } from "./$types";
import {
cleanText,
HONEYPOT_FIELDS,
LIMITS,
validateFiles,
validateMitmachen,
} from "$lib/components/internal/mitmachen-form-validation";
const TURNSTILE_SECRET = privateEnv.TURNSTILE_SECRET ?? "";
const TURNSTILE_ENABLED = TURNSTILE_SECRET !== "";
async function verifyTurnstile(token: string, ip: string): Promise<boolean> {
if (!TURNSTILE_ENABLED) return true;
if (!token) return false;
try {
const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ secret: TURNSTILE_SECRET, response: token, remoteip: ip }),
});
if (!res.ok) return false;
const data = (await res.json()) as { success?: boolean };
return data.success === true;
} catch (e) {
console.error("[mitmachen] turnstile verify failed:", e);
return false;
}
}
const ALLOWED_ORIGINS = new Set([
"https://windwiderstand.de",
"https://www.windwiderstand.de",
"http://localhost:5173",
"http://localhost:4173",
]);
export const POST: RequestHandler = async ({ request, getClientAddress }) => {
// Origin/Referer must match — blocks naive cross-site POSTs
const origin = request.headers.get("origin") ?? "";
const referer = request.headers.get("referer") ?? "";
const refererOrigin = referer ? safeOrigin(referer) : "";
if (!ALLOWED_ORIGINS.has(origin) && !ALLOWED_ORIGINS.has(refererOrigin)) {
return json({ error: "Ungültige Anfrage." }, { status: 403 });
}
let form: FormData;
try {
form = await request.formData();
} catch {
return json({ error: "Ungültige Anfrage." }, { status: 400 });
}
const text = (key: string): string => {
const v = form.get(key);
return typeof v === "string" ? v : "";
};
// Honeypots — must all be empty
for (const hp of HONEYPOT_FIELDS) {
if (text(hp).trim() !== "") {
// Pretend success so bots don't learn the trap exists
return json({ ok: true }, { status: 201 });
}
}
// Client age — bots usually post immediately
const clientAge = Number(text("_client_age_ms"));
if (!Number.isFinite(clientAge) || clientAge < LIMITS.MIN_CLIENT_AGE_MS) {
return json({ error: "Bitte etwas mehr Zeit lassen." }, { status: 429 });
}
// Cloudflare Turnstile (skipped when no secret configured)
if (TURNSTILE_ENABLED) {
const ok = await verifyTurnstile(text("cf_turnstile_token"), getClientAddress());
if (!ok) {
return json({ error: "captcha" }, { status: 403 });
}
}
// Full validation
const fields = {
title: text("title"),
description: text("description"),
contactName: text("contactName"),
phone: text("phone"),
email: text("email"),
consent: text("consent") === "true",
};
const errors = validateMitmachen(fields);
if (Object.keys(errors).length > 0) {
return json({ errors }, { status: 422 });
}
const files = form
.getAll("file")
.filter((v): v is File => v instanceof File && v.size > 0);
const fileError = validateFiles(files);
if (fileError) {
return json({ errors: { files: fileError } }, { status: 422 });
}
// Forward to CMS forms plugin (multipart: data-JSON-Part + file-Parts)
const cmsBase = (publicEnv.PUBLIC_CMS_URL || "http://localhost:3099").replace(/\/$/, "");
const cmsEnv = publicEnv.PUBLIC_CMS_ENV || "";
const cmsUrl = cmsEnv
? `${cmsBase}/api/forms/mitmachen/submit-multipart?_environment=${encodeURIComponent(cmsEnv)}`
: `${cmsBase}/api/forms/mitmachen/submit-multipart`;
// cleanText: unsichtbare Format-Zeichen (Bidi-Marks aus Copy-Paste) raus —
// gleiche Normalisierung wie in der Validierung.
const payload = {
title: cleanText(fields.title),
description: cleanText(fields.description),
contact_name: cleanText(fields.contactName),
phone: cleanText(fields.phone),
email: cleanText(fields.email),
consent_at: new Date().toISOString(),
submitted_via: "windwiderstand.de/api/mitmachen",
user_agent: (request.headers.get("user-agent") ?? "").slice(0, 200),
};
const outgoing = new FormData();
outgoing.set("data", JSON.stringify(payload));
for (const f of files) outgoing.append("file", f, f.name);
let res: Response;
try {
// No Origin header — we already validated it at this server.
// CMS forms plugin (REQUIRE_ORIGIN=false) accepts requests without one.
res = await fetch(cmsUrl, {
method: "POST",
headers: { "X-Forwarded-For": getClientAddress() },
body: outgoing,
});
} catch (e) {
console.error("[mitmachen] CMS fetch failed:", e);
return json({ error: "Server nicht erreichbar." }, { status: 502 });
}
if (res.ok) {
return json({ ok: true }, { status: 201 });
}
if (res.status === 429) {
return json({ error: "Zu viele Anfragen. Bitte später erneut versuchen." }, { status: 429 });
}
const text2 = await res.text().catch(() => "");
console.error("[mitmachen] CMS error", res.status, text2);
return json({ error: "Fehler beim Senden." }, { status: 502 });
};
function safeOrigin(u: string): string {
try {
return new URL(u).origin;
} catch {
return "";
}
}