import { json } from "@sveltejs/kit"; import { env as publicEnv } from "$env/dynamic/public"; import type { RequestHandler } from "./$types"; // Honeypot field — must be empty (bot trap) const HONEYPOT = "website"; // Bots usually post immediately; require a minimum dwell time const MIN_CLIENT_AGE_MS = 3_000; // Prompt can be large (embeds the full draft). CMS body cap is the hard backstop. const PROMPT_MAX = 60_000; const ALLOWED_ORIGINS = new Set([ "https://windwiderstand.de", "https://www.windwiderstand.de", "http://localhost:5173", "http://localhost:4173", ]); function isValidEmail(s: string): boolean { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s.trim()); } 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 }); } const raw = await request.json().catch(() => null); if (!raw || typeof raw !== "object") { return json({ error: "Ungültige Anfrage." }, { status: 400 }); } const body = raw as Record; const str = (k: string): string => (typeof body[k] === "string" ? (body[k] as string) : ""); // Honeypot — pretend success so bots don't learn the trap exists if (str(HONEYPOT).trim() !== "") { return json({ ok: true }, { status: 201 }); } // Client age const clientAge = Number(body._client_age_ms); if (!Number.isFinite(clientAge) || clientAge < MIN_CLIENT_AGE_MS) { return json({ error: "Bitte etwas mehr Zeit lassen." }, { status: 429 }); } // Validation const fields = { name: str("name").trim(), email: str("email").trim(), vorranggebiet: str("vorranggebiet").trim(), prompt: str("prompt"), }; const errors: Record = {}; if (!fields.name) errors.name = "Name fehlt."; if (!isValidEmail(fields.email)) errors.email = "Ungültige E-Mail."; if (fields.prompt.trim().length < 50) errors.prompt = "Kein Prompt vorhanden."; if (fields.prompt.length > PROMPT_MAX) errors.prompt = "Prompt zu lang."; if (Object.keys(errors).length > 0) { return json({ errors }, { status: 422 }); } // Forward to CMS forms plugin const cmsBase = (publicEnv.PUBLIC_CMS_URL || "http://localhost:3099").replace(/\/$/, ""); const cmsEnv = publicEnv.PUBLIC_CMS_ENV || ""; const cmsUrl = cmsEnv ? `${cmsBase}/api/forms/stellungnahme/submit?_environment=${encodeURIComponent(cmsEnv)}` : `${cmsBase}/api/forms/stellungnahme/submit`; const payload = { name: fields.name, email: fields.email, vorranggebiet: fields.vorranggebiet, prompt: fields.prompt, consent_at: new Date().toISOString(), submitted_via: "windwiderstand.de/api/stellungnahme", user_agent: (request.headers.get("user-agent") ?? "").slice(0, 200), }; let res: Response; try { // No Origin header — already validated here. CMS forms plugin (REQUIRE_ORIGIN=false) accepts it. res = await fetch(cmsUrl, { method: "POST", headers: { "Content-Type": "application/json", "X-Forwarded-For": getClientAddress(), }, body: JSON.stringify(payload), }); } catch (e) { console.error("[stellungnahme] 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 text = await res.text().catch(() => ""); console.error("[stellungnahme] CMS error", res.status, text); return json({ error: "Fehler beim Senden." }, { status: 502 }); }; function safeOrigin(u: string): string { try { return new URL(u).origin; } catch { return ""; } }