feat: Mitmachen-Formular nativ (löst OpnForm ab)
- MitmachenFormComponent: Name der BI*, Anliegen*, Ansprechpartner, Telefon, E-Mail, Datei-Upload (max 5×5MB, JPG/PNG/WebP/GIF/PDF), Consent — Honeypots + Client-Age + Turnstile wie Kontaktformular - /api/mitmachen: Multipart-Validierung (shared Modul), Forwarding an CMS /api/forms/mitmachen/submit-multipart - internal_component: form-mitmachen registriert - ~35 neue mitmachen_*-Translation-Keys Prod braucht BODY_SIZE_LIMIT=30M (adapter-node default 512K) — via secrets .env.prod deployt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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 @@
|
||||
<div class={layoutClasses} data-block="InternalComponent" data-block-type="internal_component" data-block-slug={block._slug}>
|
||||
{#if component === "form-contact"}
|
||||
<ContactFormComponent />
|
||||
{:else if component === "form-mitmachen"}
|
||||
<MitmachenFormComponent />
|
||||
{:else}
|
||||
<p class="text-sm text-stein-500">Unbekannte interne Komponente: <code>{component}</code></p>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { env as publicEnv } from "$env/dynamic/public";
|
||||
import Button from "$lib/ui/Button.svelte";
|
||||
import TextInput from "$lib/ui/TextInput.svelte";
|
||||
import Textarea from "$lib/ui/Textarea.svelte";
|
||||
import Checkbox from "$lib/ui/Checkbox.svelte";
|
||||
import { useTranslate, T } from "$lib/translations";
|
||||
import {
|
||||
FILE_ACCEPT,
|
||||
LIMITS,
|
||||
validateFiles,
|
||||
validateMitmachen,
|
||||
type MitmachenErrors,
|
||||
type MitmachenFields,
|
||||
} from "./mitmachen-form-validation";
|
||||
|
||||
type FormState = "idle" | "pending" | "success" | "error";
|
||||
|
||||
const t = useTranslate();
|
||||
|
||||
const TURNSTILE_SITE_KEY = publicEnv.PUBLIC_TURNSTILE_SITE_KEY ?? "";
|
||||
const TURNSTILE_ENABLED = TURNSTILE_SITE_KEY !== "";
|
||||
|
||||
let title = $state("");
|
||||
let description = $state("");
|
||||
let contactName = $state("");
|
||||
let phone = $state("");
|
||||
let email = $state("");
|
||||
let consent = $state(false);
|
||||
let files = $state<File[]>([]);
|
||||
let fileInputEl = $state<HTMLInputElement | null>(null);
|
||||
|
||||
// Honeypots — three with realistic names. Real users never see them.
|
||||
let honeypot = $state("");
|
||||
let website = $state("");
|
||||
let company = $state("");
|
||||
|
||||
const pageLoadedAt = Date.now();
|
||||
|
||||
let formState = $state<FormState>("idle");
|
||||
let serverError = $state("");
|
||||
let submitAttempted = $state(false);
|
||||
|
||||
let turnstileToken = $state("");
|
||||
let turnstileEl = $state<HTMLDivElement | null>(null);
|
||||
let turnstileWidgetId: string | null = null;
|
||||
|
||||
const fields = $derived<MitmachenFields>({
|
||||
title,
|
||||
description,
|
||||
contactName,
|
||||
phone,
|
||||
email,
|
||||
consent,
|
||||
});
|
||||
const errors = $derived<MitmachenErrors>(validateMitmachen(fields));
|
||||
const fileError = $derived(validateFiles(files));
|
||||
const isValid = $derived(Object.keys(errors).length === 0 && !fileError);
|
||||
|
||||
function showError(field: keyof MitmachenFields): string {
|
||||
if (!submitAttempted) return "";
|
||||
const e = errors[field];
|
||||
return e ? t(e.key, e.params) : "";
|
||||
}
|
||||
|
||||
const descriptionRemaining = $derived(LIMITS.DESCRIPTION_MAX - description.length);
|
||||
const descriptionHelp = $derived(
|
||||
descriptionRemaining >= 0
|
||||
? t(T.contact_message_remaining, { n: descriptionRemaining })
|
||||
: t(T.contact_message_overflow, { n: -descriptionRemaining }),
|
||||
);
|
||||
|
||||
function onFilesChosen(e: Event) {
|
||||
const input = e.currentTarget as HTMLInputElement;
|
||||
const chosen = [...(input.files ?? [])];
|
||||
// Anhängen statt ersetzen — Nutzer kann in mehreren Schritten auswählen.
|
||||
files = [...files, ...chosen].slice(0, LIMITS.MAX_FILES + 1);
|
||||
input.value = "";
|
||||
}
|
||||
|
||||
function removeFile(idx: number) {
|
||||
files = files.filter((_, i) => i !== idx);
|
||||
}
|
||||
|
||||
function fmtSize(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
||||
}
|
||||
|
||||
type TurnstileAPI = {
|
||||
render: (
|
||||
el: HTMLElement,
|
||||
opts: {
|
||||
sitekey: string;
|
||||
theme?: "light" | "dark" | "auto";
|
||||
size?: "normal" | "compact" | "flexible";
|
||||
callback?: (token: string) => void;
|
||||
"expired-callback"?: () => void;
|
||||
"error-callback"?: () => void;
|
||||
},
|
||||
) => string;
|
||||
reset: (id?: string) => void;
|
||||
remove: (id: string) => void;
|
||||
};
|
||||
|
||||
function getTurnstile(): TurnstileAPI | undefined {
|
||||
return (window as unknown as { turnstile?: TurnstileAPI }).turnstile;
|
||||
}
|
||||
|
||||
function renderTurnstile() {
|
||||
if (!TURNSTILE_ENABLED || !turnstileEl) return;
|
||||
const ts = getTurnstile();
|
||||
if (!ts) return;
|
||||
if (turnstileWidgetId !== null) return;
|
||||
turnstileWidgetId = ts.render(turnstileEl, {
|
||||
sitekey: TURNSTILE_SITE_KEY,
|
||||
theme: "light",
|
||||
size: "flexible",
|
||||
callback: (token) => {
|
||||
turnstileToken = token;
|
||||
},
|
||||
"expired-callback": () => {
|
||||
turnstileToken = "";
|
||||
},
|
||||
"error-callback": () => {
|
||||
turnstileToken = "";
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function loadTurnstileScript() {
|
||||
if (!TURNSTILE_ENABLED) return;
|
||||
if (document.querySelector("script[data-turnstile]")) {
|
||||
renderTurnstile();
|
||||
return;
|
||||
}
|
||||
const s = document.createElement("script");
|
||||
s.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
|
||||
s.async = true;
|
||||
s.defer = true;
|
||||
s.dataset.turnstile = "1";
|
||||
s.onload = renderTurnstile;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
onMount(loadTurnstileScript);
|
||||
onDestroy(() => {
|
||||
if (turnstileWidgetId !== null) {
|
||||
getTurnstile()?.remove(turnstileWidgetId);
|
||||
turnstileWidgetId = null;
|
||||
}
|
||||
});
|
||||
|
||||
function resetTurnstile() {
|
||||
if (!TURNSTILE_ENABLED) return;
|
||||
turnstileToken = "";
|
||||
if (turnstileWidgetId !== null) getTurnstile()?.reset(turnstileWidgetId);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
submitAttempted = true;
|
||||
if (formState === "pending") return;
|
||||
if (!isValid) {
|
||||
const first = Object.keys(errors)[0];
|
||||
const el = document.querySelector<HTMLElement>(`[data-field="${first}"]`);
|
||||
el?.focus();
|
||||
return;
|
||||
}
|
||||
if (TURNSTILE_ENABLED && !turnstileToken) {
|
||||
serverError = t(T.contact_captcha_pending);
|
||||
formState = "error";
|
||||
return;
|
||||
}
|
||||
|
||||
formState = "pending";
|
||||
serverError = "";
|
||||
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.set("title", title);
|
||||
fd.set("description", description);
|
||||
fd.set("contactName", contactName);
|
||||
fd.set("phone", phone);
|
||||
fd.set("email", email);
|
||||
fd.set("consent", consent ? "true" : "false");
|
||||
fd.set("_honeypot", honeypot);
|
||||
fd.set("website", website);
|
||||
fd.set("company", company);
|
||||
fd.set("_client_age_ms", String(Date.now() - pageLoadedAt));
|
||||
fd.set("cf_turnstile_token", turnstileToken);
|
||||
for (const f of files) fd.append("file", f, f.name);
|
||||
|
||||
const res = await fetch("/api/mitmachen", { method: "POST", body: fd });
|
||||
|
||||
if (res.ok) {
|
||||
formState = "success";
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (res.status === 422) {
|
||||
serverError = t(T.contact_server_error_validation);
|
||||
} else if (res.status === 413) {
|
||||
serverError = t(T.mitmachen_error_files_size, { mb: LIMITS.MAX_FILE_MB });
|
||||
} else if (res.status === 429) {
|
||||
serverError = t(T.contact_server_error_throttle);
|
||||
} else if (res.status === 403) {
|
||||
serverError =
|
||||
body?.error === "captcha"
|
||||
? t(T.contact_server_error_captcha)
|
||||
: t(T.contact_server_error_blocked);
|
||||
} else {
|
||||
serverError = body?.error || t(T.contact_server_error_generic);
|
||||
}
|
||||
formState = "error";
|
||||
resetTurnstile();
|
||||
} catch {
|
||||
serverError = t(T.contact_server_error_network);
|
||||
formState = "error";
|
||||
resetTurnstile();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if formState === "success"}
|
||||
<div class="rounded-sm border border-wald-300 bg-wald-50 px-4 py-5 text-center shadow-sm">
|
||||
<p class="text-base font-semibold text-wald-700">{t(T.mitmachen_success_title)}</p>
|
||||
<p class="mt-1 text-sm text-stein-600">{t(T.mitmachen_success_body)}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<form
|
||||
onsubmit={handleSubmit}
|
||||
novalidate
|
||||
class="space-y-3"
|
||||
aria-busy={formState === "pending"}
|
||||
>
|
||||
<!-- Honeypots: visually hidden, off the keyboard, off the a11y tree -->
|
||||
<div aria-hidden="true" class="pointer-events-none absolute left-[-9999px] top-auto h-px w-px overflow-hidden opacity-0">
|
||||
<label for="mitmachen-hp-1">leer lassen</label>
|
||||
<input id="mitmachen-hp-1" name="_honeypot" type="text" tabindex="-1" autocomplete="off" bind:value={honeypot} />
|
||||
<label for="mitmachen-hp-2">Website</label>
|
||||
<input id="mitmachen-hp-2" name="website" type="url" tabindex="-1" autocomplete="off" bind:value={website} />
|
||||
<label for="mitmachen-hp-3">Firma</label>
|
||||
<input id="mitmachen-hp-3" name="company" type="text" tabindex="-1" autocomplete="off" bind:value={company} />
|
||||
</div>
|
||||
|
||||
<div data-field="title">
|
||||
<TextInput
|
||||
size="sm"
|
||||
name="title"
|
||||
label={t(T.mitmachen_label_title)}
|
||||
placeholder={t(T.mitmachen_placeholder_title)}
|
||||
required
|
||||
bind:value={title}
|
||||
error={showError("title")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div data-field="description">
|
||||
<Textarea
|
||||
size="sm"
|
||||
name="description"
|
||||
label={t(T.mitmachen_label_description)}
|
||||
placeholder={t(T.mitmachen_placeholder_description)}
|
||||
required
|
||||
rows={5}
|
||||
bind:value={description}
|
||||
error={showError("description")}
|
||||
helpText={descriptionHelp}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div data-field="contactName">
|
||||
<TextInput
|
||||
size="sm"
|
||||
name="contactName"
|
||||
label={t(T.mitmachen_label_contact_name)}
|
||||
placeholder={t(T.mitmachen_placeholder_contact_name)}
|
||||
bind:value={contactName}
|
||||
error={showError("contactName")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div data-field="phone">
|
||||
<TextInput
|
||||
size="sm"
|
||||
name="phone"
|
||||
type="tel"
|
||||
label={t(T.mitmachen_label_phone)}
|
||||
placeholder={t(T.mitmachen_placeholder_phone)}
|
||||
bind:value={phone}
|
||||
error={showError("phone")}
|
||||
/>
|
||||
</div>
|
||||
<div data-field="email">
|
||||
<TextInput
|
||||
size="sm"
|
||||
name="email"
|
||||
type="email"
|
||||
label={t(T.mitmachen_label_email)}
|
||||
placeholder={t(T.mitmachen_placeholder_email)}
|
||||
bind:value={email}
|
||||
error={showError("email")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-field="files">
|
||||
<span class="mb-1 block text-sm font-medium text-stein-700">{t(T.mitmachen_label_files)}</span>
|
||||
<input
|
||||
bind:this={fileInputEl}
|
||||
type="file"
|
||||
multiple
|
||||
accept={FILE_ACCEPT}
|
||||
class="sr-only"
|
||||
onchange={onFilesChosen}
|
||||
tabindex="-1"
|
||||
/>
|
||||
<!-- Nativer Button: ui/Button forwarded kein onclick. Klassen = Button
|
||||
secondary/sm. -->
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-9 items-center justify-center gap-2 rounded-xs border-[1.5px] border-wald-500 bg-transparent px-4 py-2 text-sm font-semibold text-wald-500 transition-colors duration-150 hover:bg-wald-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-300 focus-visible:ring-offset-2 active:bg-wald-100"
|
||||
onclick={() => fileInputEl?.click()}
|
||||
>{t(T.mitmachen_files_add)}</button>
|
||||
<p class="mt-1 text-[0.7rem] text-stein-500">
|
||||
{t(T.mitmachen_files_help, { n: LIMITS.MAX_FILES, mb: LIMITS.MAX_FILE_MB })}
|
||||
</p>
|
||||
{#if files.length > 0}
|
||||
<ul class="mt-2 space-y-1">
|
||||
{#each files as f, idx}
|
||||
<li class="flex items-center gap-2 rounded-sm border border-stein-200 bg-stein-50 px-2 py-1 text-xs">
|
||||
<span class="min-w-0 flex-1 truncate">{f.name}</span>
|
||||
<span class="shrink-0 text-stein-500">{fmtSize(f.size)}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 text-stein-500 underline hover:text-stein-700"
|
||||
onclick={() => removeFile(idx)}
|
||||
>{t(T.mitmachen_files_remove)}</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if submitAttempted && fileError}
|
||||
<p class="mt-1 text-[0.7rem] text-error" role="alert">{t(fileError.key, fileError.params)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div data-field="consent" class="pt-1">
|
||||
<Checkbox
|
||||
size="sm"
|
||||
name="consent"
|
||||
bind:checked={consent}
|
||||
label={t(T.mitmachen_label_consent)}
|
||||
/>
|
||||
{#if showError("consent")}
|
||||
<p class="mt-1 text-[0.7rem] text-error" role="alert">{showError("consent")}</p>
|
||||
{/if}
|
||||
<p class="mt-1 text-[0.7rem] leading-snug text-stein-500">{t(T.mitmachen_privacy_note)}</p>
|
||||
</div>
|
||||
|
||||
{#if TURNSTILE_ENABLED}
|
||||
<div bind:this={turnstileEl} class="pt-1"></div>
|
||||
{/if}
|
||||
|
||||
{#if serverError}
|
||||
<div class="rounded-sm border border-error bg-error-subtle px-3 py-2 text-sm text-error shadow-sm" role="alert">
|
||||
{serverError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-3 pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
loading={formState === "pending"}
|
||||
disabled={!isValid && submitAttempted}
|
||||
label={formState === "pending" ? t(T.contact_submitting) : t(T.mitmachen_submit)}
|
||||
/>
|
||||
{#if !isValid && submitAttempted && !serverError}
|
||||
<span class="text-xs text-error">{t(T.contact_fix_errors)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
@@ -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<string, string | number>;
|
||||
};
|
||||
|
||||
export type MitmachenErrors = Partial<Record<keyof MitmachenFields, MitmachenError>>;
|
||||
|
||||
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<MitmachenFields>): 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<File, "size" | "type">[],
|
||||
): 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;
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -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<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`;
|
||||
|
||||
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 "";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user