diff --git a/src/lib/block-types.ts b/src/lib/block-types.ts
index c0ace94..0dbc5e9 100644
--- a/src/lib/block-types.ts
+++ b/src/lib/block-types.ts
@@ -469,7 +469,7 @@ export type CalendarBlockData = Omit<
};
/** Internal component block (_type: "internal_component"). */
-export type InternalComponentId = "form-contact";
+export type InternalComponentId = "form-contact" | "form-mitmachen";
export interface InternalComponentBlockData {
_type?: "internal_component";
diff --git a/src/lib/components/blocks/InternalComponentBlock.svelte b/src/lib/components/blocks/InternalComponentBlock.svelte
index c9fe302..8bf76c5 100644
--- a/src/lib/components/blocks/InternalComponentBlock.svelte
+++ b/src/lib/components/blocks/InternalComponentBlock.svelte
@@ -2,6 +2,7 @@
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { InternalComponentBlockData } from "$lib/block-types";
import ContactFormComponent from "$lib/components/internal/ContactFormComponent.svelte";
+ import MitmachenFormComponent from "$lib/components/internal/MitmachenFormComponent.svelte";
let { block }: { block: InternalComponentBlockData } = $props();
@@ -12,6 +13,8 @@
{#if component === "form-contact"}
+ {:else if component === "form-mitmachen"}
+
{:else}
Unbekannte interne Komponente: {component}
{/if}
diff --git a/src/lib/components/internal/MitmachenFormComponent.svelte b/src/lib/components/internal/MitmachenFormComponent.svelte
new file mode 100644
index 0000000..54110dd
--- /dev/null
+++ b/src/lib/components/internal/MitmachenFormComponent.svelte
@@ -0,0 +1,389 @@
+
+
+{#if formState === "success"}
+
+
{t(T.mitmachen_success_title)}
+
{t(T.mitmachen_success_body)}
+
+{:else}
+
+{/if}
diff --git a/src/lib/components/internal/mitmachen-form-validation.ts b/src/lib/components/internal/mitmachen-form-validation.ts
new file mode 100644
index 0000000..5152ea5
--- /dev/null
+++ b/src/lib/components/internal/mitmachen-form-validation.ts
@@ -0,0 +1,141 @@
+/**
+ * Shared validation for MitmachenFormComponent + /api/mitmachen server route.
+ * Single source of truth for limits and rules.
+ */
+
+export const LIMITS = {
+ TITLE_MIN: 2,
+ TITLE_MAX: 120,
+ DESCRIPTION_MIN: 10,
+ DESCRIPTION_MAX: 4000,
+ CONTACT_NAME_MAX: 80,
+ PHONE_MAX: 40,
+ EMAIL_MAX: 200,
+ MAX_URLS_IN_DESCRIPTION: 2,
+ MIN_CLIENT_AGE_MS: 3000,
+ MAX_FILES: 5,
+ MAX_FILE_MB: 5,
+} as const;
+
+export const HONEYPOT_FIELDS = ["_honeypot", "website", "company"] as const;
+
+/** Muss mit ALLOWED_UPLOAD_MIMES im CMS-Forms-Plugin übereinstimmen. */
+export const ALLOWED_FILE_TYPES = [
+ "image/jpeg",
+ "image/png",
+ "image/webp",
+ "image/gif",
+ "application/pdf",
+] as const;
+
+export const FILE_ACCEPT = ALLOWED_FILE_TYPES.join(",");
+
+export type MitmachenFields = {
+ title: string;
+ description: string;
+ contactName: string;
+ phone: string;
+ email: string;
+ consent: boolean;
+};
+
+/** Error as translation-key + optional params (resolved in the UI). */
+export type MitmachenError = {
+ key: string;
+ params?: Record
;
+};
+
+export type MitmachenErrors = Partial>;
+
+const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
+const URL_RE = /\b(?:https?:\/\/|www\.)\S+/gi;
+const CONTROL_CHAR_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F]/;
+const PHONE_RE = /^[0-9+()/\-.\s]*$/;
+
+function trim(s: unknown): string {
+ return typeof s === "string" ? s.trim() : "";
+}
+
+export function validateMitmachen(input: Partial): MitmachenErrors {
+ const errors: MitmachenErrors = {};
+ const title = trim(input.title);
+ const description = trim(input.description);
+ const contactName = trim(input.contactName);
+ const phone = trim(input.phone);
+ const email = trim(input.email);
+
+ if (title.length < LIMITS.TITLE_MIN) {
+ errors.title = { key: "mitmachen_error_title_min", params: { n: LIMITS.TITLE_MIN } };
+ } else if (title.length > LIMITS.TITLE_MAX) {
+ errors.title = { key: "mitmachen_error_title_max", params: { n: LIMITS.TITLE_MAX } };
+ } else if (URL_RE.test(title) || CONTROL_CHAR_RE.test(title)) {
+ errors.title = { key: "mitmachen_error_title_invalid" };
+ }
+
+ if (description.length < LIMITS.DESCRIPTION_MIN) {
+ errors.description = {
+ key: "mitmachen_error_description_min",
+ params: { n: LIMITS.DESCRIPTION_MIN },
+ };
+ } else if (description.length > LIMITS.DESCRIPTION_MAX) {
+ errors.description = {
+ key: "mitmachen_error_description_max",
+ params: { n: LIMITS.DESCRIPTION_MAX },
+ };
+ } else {
+ const urlMatches = description.match(URL_RE) ?? [];
+ if (urlMatches.length > LIMITS.MAX_URLS_IN_DESCRIPTION) {
+ errors.description = {
+ key: "mitmachen_error_description_too_many_links",
+ params: { n: LIMITS.MAX_URLS_IN_DESCRIPTION },
+ };
+ } else if (CONTROL_CHAR_RE.test(description)) {
+ errors.description = { key: "mitmachen_error_description_invalid" };
+ }
+ }
+
+ if (contactName.length > LIMITS.CONTACT_NAME_MAX) {
+ errors.contactName = {
+ key: "mitmachen_error_contact_name_max",
+ params: { n: LIMITS.CONTACT_NAME_MAX },
+ };
+ } else if (CONTROL_CHAR_RE.test(contactName) || URL_RE.test(contactName)) {
+ errors.contactName = { key: "mitmachen_error_contact_name_invalid" };
+ }
+
+ if (phone.length > LIMITS.PHONE_MAX || !PHONE_RE.test(phone)) {
+ if (phone) errors.phone = { key: "mitmachen_error_phone_invalid" };
+ }
+
+ // E-Mail ist optional — nur prüfen wenn ausgefüllt.
+ if (email && (email.length > LIMITS.EMAIL_MAX || !EMAIL_RE.test(email))) {
+ errors.email = { key: "mitmachen_error_email_invalid" };
+ }
+
+ if (input.consent !== true) {
+ errors.consent = { key: "mitmachen_error_consent_required" };
+ }
+
+ return errors;
+}
+
+/** Datei-Checks (client- + serverseitig identisch). */
+export function validateFiles(
+ files: Pick[],
+): MitmachenError | null {
+ if (files.length > LIMITS.MAX_FILES) {
+ return { key: "mitmachen_error_files_count", params: { n: LIMITS.MAX_FILES } };
+ }
+ for (const f of files) {
+ if (!(ALLOWED_FILE_TYPES as readonly string[]).includes(f.type)) {
+ return { key: "mitmachen_error_files_type" };
+ }
+ if (f.size > LIMITS.MAX_FILE_MB * 1024 * 1024) {
+ return { key: "mitmachen_error_files_size", params: { mb: LIMITS.MAX_FILE_MB } };
+ }
+ if (f.size === 0) {
+ return { key: "mitmachen_error_files_type" };
+ }
+ }
+ return null;
+}
diff --git a/src/lib/translations.ts b/src/lib/translations.ts
index bda2ead..e0a98f7 100644
--- a/src/lib/translations.ts
+++ b/src/lib/translations.ts
@@ -317,6 +317,42 @@ const TRANSLATION_KEYS = [
"contact_error_consent_required",
"contact_captcha_pending",
"contact_server_error_captcha",
+ // Mitmachen form (MitmachenFormComponent.svelte). Generische Submit-/
+ // Server-Error-Texte werden aus contact_* wiederverwendet.
+ "mitmachen_label_title",
+ "mitmachen_placeholder_title",
+ "mitmachen_label_description",
+ "mitmachen_placeholder_description",
+ "mitmachen_label_contact_name",
+ "mitmachen_placeholder_contact_name",
+ "mitmachen_label_phone",
+ "mitmachen_placeholder_phone",
+ "mitmachen_label_email",
+ "mitmachen_placeholder_email",
+ "mitmachen_label_files",
+ "mitmachen_files_help", // {{n}}, {{mb}}
+ "mitmachen_files_add",
+ "mitmachen_files_remove",
+ "mitmachen_label_consent",
+ "mitmachen_privacy_note",
+ "mitmachen_submit",
+ "mitmachen_success_title",
+ "mitmachen_success_body",
+ "mitmachen_error_title_min", // {{n}}
+ "mitmachen_error_title_max", // {{n}}
+ "mitmachen_error_title_invalid",
+ "mitmachen_error_description_min", // {{n}}
+ "mitmachen_error_description_max", // {{n}}
+ "mitmachen_error_description_too_many_links", // {{n}}
+ "mitmachen_error_description_invalid",
+ "mitmachen_error_contact_name_max", // {{n}}
+ "mitmachen_error_contact_name_invalid",
+ "mitmachen_error_phone_invalid",
+ "mitmachen_error_email_invalid",
+ "mitmachen_error_consent_required",
+ "mitmachen_error_files_count", // {{n}}
+ "mitmachen_error_files_size", // {{mb}}
+ "mitmachen_error_files_type",
] as const;
export type TranslationKey = (typeof TRANSLATION_KEYS)[number];
diff --git a/src/routes/api/mitmachen/+server.ts b/src/routes/api/mitmachen/+server.ts
new file mode 100644
index 0000000..ab20afd
--- /dev/null
+++ b/src/routes/api/mitmachen/+server.ts
@@ -0,0 +1,157 @@
+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 {
+ 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`;
+
+ const payload = {
+ title: fields.title.trim(),
+ description: fields.description.trim(),
+ contact_name: fields.contactName.trim(),
+ phone: fields.phone.trim(),
+ email: fields.email.trim(),
+ 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 "";
+ }
+}