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 { 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 ""; } }