ae9536db3a
- 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>
161 lines
5.3 KiB
TypeScript
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 "";
|
|
}
|
|
}
|