feat(contact): full validation, CMS-translated strings, smaller UI
Deploy / verify (push) Failing after 47s
Deploy / deploy (push) Has been skipped

- contact-form-validation.ts: shared client+server validator returning
  {key, params} so messages can be translated via the CMS bundle.
- ContactFormComponent: 3 honeypots, subject field, GDPR consent
  checkbox, character counter, inline per-field errors after submit,
  uses useTranslate() everywhere.
- /api/contact: origin allowlist, multiple honeypot check, client-age
  gate, server-side re-validation, forwards X-Forwarded-For + UA to
  CMS forms plugin with consent timestamp in payload.
- TextInput/Textarea: new size="sm" variant (rounded-sm, shadow-sm,
  h-10, smaller padding/text). Default "md" unchanged.
- translations.ts: 37 new contact_* keys.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-05-21 11:28:23 +02:00
parent 8575572183
commit 94a8feeb67
7 changed files with 409 additions and 70 deletions
-1
View File
@@ -428,7 +428,6 @@ export type InternalComponentId = "form-contact";
export interface InternalComponentBlockData {
_type?: "internal_component";
_slug?: string;
id?: string;
component?: InternalComponentId;
layout?: BlockLayout;
}
@@ -2,23 +2,66 @@
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 {
LIMITS,
validateContact,
type ContactErrors,
type ContactFields,
} from "./contact-form-validation";
type State = "idle" | "pending" | "success" | "error";
type FormState = "idle" | "pending" | "success" | "error";
const t = useTranslate();
let name = $state("");
let email = $state("");
let subject = $state("");
let message = $state("");
let consent = $state(false);
// Honeypots — three with realistic names. Real users never see them.
let honeypot = $state("");
let pageLoadedAt = Date.now();
let formState = $state<State>("idle");
let errorMessage = $state("");
let website = $state("");
let company = $state("");
const pageLoadedAt = Date.now();
let formState = $state<FormState>("idle");
let serverError = $state("");
let submitAttempted = $state(false);
const fields = $derived<ContactFields>({ name, email, subject, message, consent });
const errors = $derived<ContactErrors>(validateContact(fields));
const isValid = $derived(Object.keys(errors).length === 0);
function showError(field: keyof ContactFields): string {
if (!submitAttempted) return "";
const e = errors[field];
return e ? t(e.key, e.params) : "";
}
const messageRemaining = $derived(LIMITS.MESSAGE_MAX - message.length);
const messageHelp = $derived(
messageRemaining >= 0
? t(T.contact_message_remaining, { n: messageRemaining })
: t(T.contact_message_overflow, { n: -messageRemaining }),
);
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;
}
formState = "pending";
errorMessage = "";
serverError = "";
try {
const res = await fetch("/api/contact", {
@@ -27,66 +70,141 @@
body: JSON.stringify({
name,
email,
subject,
message,
consent,
_honeypot: honeypot,
website,
company,
_client_age_ms: Date.now() - pageLoadedAt,
}),
});
if (res.ok) {
formState = "success";
} else {
const body = await res.json().catch(() => ({}));
errorMessage = body?.error || "Unbekannter Fehler";
formState = "error";
return;
}
const body = await res.json().catch(() => ({}));
if (res.status === 422) {
serverError = t(T.contact_server_error_validation);
} else if (res.status === 429) {
serverError = t(T.contact_server_error_throttle);
} else if (res.status === 403) {
serverError = t(T.contact_server_error_blocked);
} else {
serverError = body?.error || t(T.contact_server_error_generic);
}
formState = "error";
} catch {
errorMessage = "Netzwerkfehler bitte erneut versuchen.";
serverError = t(T.contact_server_error_network);
formState = "error";
}
}
</script>
{#if formState === "success"}
<div class="rounded-lg border border-wald-300 bg-wald-50 px-6 py-8 text-center">
<p class="text-lg font-semibold text-wald-700">Nachricht gesendet!</p>
<p class="mt-1 text-sm text-stein-600">Wir melden uns so schnell wie möglich.</p>
<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.contact_success_title)}</p>
<p class="mt-1 text-sm text-stein-600">{t(T.contact_success_body)}</p>
</div>
{:else}
<form onsubmit={handleSubmit} novalidate class="space-y-4">
<!-- Honeypot per CSS versteckt, von Bots ausgefüllt -->
<div aria-hidden="true" style="position:absolute;left:-9999px;opacity:0;pointer-events:none;tab-index:-1;">
<label for="contact-hp">Website</label>
<input id="contact-hp" name="_honeypot" tabindex="-1" autocomplete="off" bind:value={honeypot} />
<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="contact-hp-1">leer lassen</label>
<input id="contact-hp-1" name="_honeypot" type="text" tabindex="-1" autocomplete="off" bind:value={honeypot} />
<label for="contact-hp-2">Website</label>
<input id="contact-hp-2" name="website" type="url" tabindex="-1" autocomplete="off" bind:value={website} />
<label for="contact-hp-3">Firma</label>
<input id="contact-hp-3" name="company" type="text" tabindex="-1" autocomplete="off" bind:value={company} />
</div>
<TextInput
name="name"
label="Name"
placeholder="Dein Name"
required
bind:value={name}
/>
<TextInput
name="email"
label="E-Mail"
placeholder="deine@email.de"
required
bind:value={email}
/>
<Textarea
name="message"
label="Nachricht"
placeholder="Deine Nachricht …"
required
rows={5}
bind:value={message}
/>
<div data-field="name">
<TextInput
size="sm"
name="name"
label={t(T.contact_label_name)}
placeholder={t(T.contact_placeholder_name)}
required
bind:value={name}
error={showError("name")}
/>
</div>
{#if formState === "error"}
<p class="text-sm text-error" role="alert">{errorMessage}</p>
<div data-field="email">
<TextInput
size="sm"
name="email"
type="email"
label={t(T.contact_label_email)}
placeholder={t(T.contact_placeholder_email)}
required
bind:value={email}
error={showError("email")}
/>
</div>
<div data-field="subject">
<TextInput
size="sm"
name="subject"
label={t(T.contact_label_subject)}
placeholder={t(T.contact_placeholder_subject)}
bind:value={subject}
error={showError("subject")}
/>
</div>
<div data-field="message">
<Textarea
size="sm"
name="message"
label={t(T.contact_label_message)}
placeholder={t(T.contact_placeholder_message)}
required
rows={5}
bind:value={message}
error={showError("message")}
helpText={messageHelp}
/>
</div>
<div data-field="consent" class="pt-1">
<Checkbox
name="consent"
bind:checked={consent}
label={t(T.contact_label_consent)}
/>
{#if showError("consent")}
<p class="mt-1 text-[0.75rem] text-error" role="alert">{showError("consent")}</p>
{/if}
<p class="mt-1 text-xs text-stein-500">{t(T.contact_privacy_note)}</p>
</div>
{#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}
<Button type="submit" variant="primary" loading={formState === "pending"} label="Senden" />
<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.contact_submit)}
/>
{#if !isValid && submitAttempted && !serverError}
<span class="text-xs text-error">{t(T.contact_fix_errors)}</span>
{/if}
</div>
</form>
{/if}
@@ -0,0 +1,92 @@
/**
* Shared validation for ContactFormComponent + /api/contact server route.
* Single source of truth for limits and rules.
*/
export const LIMITS = {
NAME_MIN: 2,
NAME_MAX: 80,
EMAIL_MAX: 200,
SUBJECT_MAX: 120,
MESSAGE_MIN: 10,
MESSAGE_MAX: 4000,
MAX_URLS_IN_MESSAGE: 2,
MIN_CLIENT_AGE_MS: 3000,
} as const;
export const HONEYPOT_FIELDS = ["_honeypot", "website", "company"] as const;
export type ContactFields = {
name: string;
email: string;
subject: string;
message: string;
consent: boolean;
};
/** Error as translation-key + optional params (resolved in the UI). */
export type ContactError = {
key: string;
params?: Record<string, string | number>;
};
export type ContactErrors = Partial<Record<keyof ContactFields, ContactError>>;
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]/;
function trim(s: unknown): string {
return typeof s === "string" ? s.trim() : "";
}
export function validateContact(input: Partial<ContactFields>): ContactErrors {
const errors: ContactErrors = {};
const name = trim(input.name);
const email = trim(input.email);
const subject = trim(input.subject);
const message = trim(input.message);
if (name.length < LIMITS.NAME_MIN) {
errors.name = { key: "contact_error_name_min", params: { n: LIMITS.NAME_MIN } };
} else if (name.length > LIMITS.NAME_MAX) {
errors.name = { key: "contact_error_name_max", params: { n: LIMITS.NAME_MAX } };
} else if (URL_RE.test(name)) {
errors.name = { key: "contact_error_name_no_links" };
} else if (CONTROL_CHAR_RE.test(name)) {
errors.name = { key: "contact_error_name_invalid_chars" };
}
if (!email) {
errors.email = { key: "contact_error_email_required" };
} else if (email.length > LIMITS.EMAIL_MAX) {
errors.email = { key: "contact_error_email_max", params: { n: LIMITS.EMAIL_MAX } };
} else if (!EMAIL_RE.test(email)) {
errors.email = { key: "contact_error_email_invalid" };
}
if (subject.length > LIMITS.SUBJECT_MAX) {
errors.subject = { key: "contact_error_subject_max", params: { n: LIMITS.SUBJECT_MAX } };
} else if (CONTROL_CHAR_RE.test(subject)) {
errors.subject = { key: "contact_error_subject_invalid_chars" };
}
if (message.length < LIMITS.MESSAGE_MIN) {
errors.message = { key: "contact_error_message_min", params: { n: LIMITS.MESSAGE_MIN } };
} else if (message.length > LIMITS.MESSAGE_MAX) {
errors.message = { key: "contact_error_message_max", params: { n: LIMITS.MESSAGE_MAX } };
} else {
const urlMatches = message.match(URL_RE) ?? [];
if (urlMatches.length > LIMITS.MAX_URLS_IN_MESSAGE) {
errors.message = { key: "contact_error_message_too_many_links", params: { n: LIMITS.MAX_URLS_IN_MESSAGE } };
} else if (CONTROL_CHAR_RE.test(message)) {
errors.message = { key: "contact_error_message_invalid_chars" };
}
}
if (input.consent !== true) {
errors.consent = { key: "contact_error_consent_required" };
}
return errors;
}
+38
View File
@@ -276,6 +276,44 @@ const TRANSLATION_KEYS = [
"strommix_trend_flat",
"strommix_sparkline_aria", // {{from}}, {{to}}
"strommix_stale_badge",
// Contact form (ContactFormComponent.svelte).
"contact_label_name",
"contact_placeholder_name",
"contact_label_email",
"contact_placeholder_email",
"contact_label_subject",
"contact_placeholder_subject",
"contact_label_message",
"contact_placeholder_message",
"contact_message_remaining", // {{n}}
"contact_message_overflow", // {{n}}
"contact_label_consent",
"contact_privacy_note",
"contact_submit",
"contact_submitting",
"contact_success_title",
"contact_success_body",
"contact_fix_errors",
"contact_server_error_generic",
"contact_server_error_network",
"contact_server_error_blocked",
"contact_server_error_throttle",
"contact_server_error_validation",
// Per-field validation errors (params: n).
"contact_error_name_min", // {{n}}
"contact_error_name_max", // {{n}}
"contact_error_name_no_links",
"contact_error_name_invalid_chars",
"contact_error_email_required",
"contact_error_email_max", // {{n}}
"contact_error_email_invalid",
"contact_error_subject_max", // {{n}}
"contact_error_subject_invalid_chars",
"contact_error_message_min", // {{n}}
"contact_error_message_max", // {{n}}
"contact_error_message_too_many_links", // {{n}}
"contact_error_message_invalid_chars",
"contact_error_consent_required",
] as const;
export type TranslationKey = (typeof TRANSLATION_KEYS)[number];
+11 -4
View File
@@ -11,18 +11,25 @@
error = '',
helpText = '',
invalid = false,
size = 'md' as 'sm' | 'md',
} = $props();
const inputId = $derived(id || `input-${Math.random().toString(36).slice(2)}`);
const errorId = $derived(`${inputId}-error`);
const helpId = $derived(`${inputId}-help`);
const sizeClasses = $derived(
size === 'sm'
? 'h-10 rounded-sm px-3 py-2 text-sm shadow-sm'
: 'h-12 rounded-lg px-4 py-3 text-base'
);
</script>
<div>
{#if label}
<label
for={inputId}
class="mb-1.5 block text-sm font-medium text-stein-700"
class="mb-1.5 block {size === 'sm' ? 'text-xs' : 'text-sm'} font-medium text-stein-700"
>
{label}
{#if required}
@@ -41,16 +48,16 @@
aria-required={required}
aria-invalid={invalid || !!error}
aria-describedby={[error ? errorId : '', helpText ? helpId : ''].filter(Boolean).join(' ')}
class="h-12 w-full rounded-lg border bg-white px-4 py-3 text-base text-stein-800 outline-none placeholder:text-stein-400 {error || invalid
class="{sizeClasses} w-full border bg-white text-stein-800 outline-none placeholder:text-stein-400 {error || invalid
? 'border-error ring-2 ring-error-subtle'
: 'border-stein-300 hover:border-stein-400 focus:border-wald-500 focus:ring-2 focus:ring-wald-100'} disabled:bg-stein-100 disabled:opacity-60"
/>
{#if error}
<p id={errorId} class="mt-1.5 text-[0.8125rem] text-error" role="alert">
<p id={errorId} class="mt-1 text-[0.75rem] text-error" role="alert">
{error}
</p>
{:else if helpText}
<p id={helpId} class="mt-1.5 text-[0.8125rem] text-stein-500">
<p id={helpId} class="mt-1 text-[0.75rem] text-stein-500">
{helpText}
</p>
{/if}
+11 -4
View File
@@ -11,18 +11,25 @@
helpText = '',
invalid = false,
rows = 4,
size = 'md' as 'sm' | 'md',
} = $props();
const inputId = $derived(id || `textarea-${Math.random().toString(36).slice(2)}`);
const errorId = $derived(`${inputId}-error`);
const helpId = $derived(`${inputId}-help`);
const sizeClasses = $derived(
size === 'sm'
? 'min-h-[80px] rounded-sm px-3 py-2 text-sm shadow-sm'
: 'min-h-[120px] rounded-lg px-4 py-3 text-base'
);
</script>
<div>
{#if label}
<label
for={inputId}
class="mb-1.5 block text-sm font-medium text-stein-700"
class="mb-1.5 block {size === 'sm' ? 'text-xs' : 'text-sm'} font-medium text-stein-700"
>
{label}
{#if required}
@@ -41,16 +48,16 @@
aria-required={required}
aria-invalid={invalid || !!error}
aria-describedby={[error ? errorId : '', helpText ? helpId : ''].filter(Boolean).join(' ')}
class="min-h-[120px] w-full resize-y rounded-lg border bg-white px-4 py-3 text-base text-stein-800 outline-none placeholder:text-stein-400 {error || invalid
class="{sizeClasses} w-full resize-y border bg-white text-stein-800 outline-none placeholder:text-stein-400 {error || invalid
? 'border-error ring-2 ring-error-subtle'
: 'border-stein-300 hover:border-stein-400 focus:border-wald-500 focus:ring-2 focus:ring-wald-100'} disabled:bg-stein-100 disabled:opacity-60"
></textarea>
{#if error}
<p id={errorId} class="mt-1.5 text-[0.8125rem] text-error" role="alert">
<p id={errorId} class="mt-1 text-[0.75rem] text-error" role="alert">
{error}
</p>
{:else if helpText}
<p id={helpId} class="mt-1.5 text-[0.8125rem] text-stein-500">
<p id={helpId} class="mt-1 text-[0.75rem] text-stein-500">
{helpText}
</p>
{/if}
+96 -18
View File
@@ -1,32 +1,110 @@
import { json } from "@sveltejs/kit";
import { env } from "$env/dynamic/private";
import { env as publicEnv } from "$env/dynamic/public";
import type { RequestHandler } from "./$types";
import {
HONEYPOT_FIELDS,
LIMITS,
validateContact,
} from "$lib/components/internal/contact-form-validation";
export const POST: RequestHandler = async ({ request, getClientAddress }) => {
const body = await request.json().catch(() => null);
if (!body || typeof body !== "object") {
return json({ error: "Ungültige Anfrage" }, { status: 400 });
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, url }) => {
// 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 cmsBase = (env.PUBLIC_CMS_URL || "http://localhost:3000").replace(/\/$/, "");
const cmsEnv = env.PUBLIC_CMS_ENV || "";
const url = cmsEnv
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<string, unknown>;
// Honeypots — must all be empty
for (const hp of HONEYPOT_FIELDS) {
const val = body[hp];
if (typeof val === "string" && val.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(body._client_age_ms);
if (!Number.isFinite(clientAge) || clientAge < LIMITS.MIN_CLIENT_AGE_MS) {
return json({ error: "Bitte etwas mehr Zeit lassen." }, { status: 429 });
}
// Full validation
const fields = {
name: typeof body.name === "string" ? body.name : "",
email: typeof body.email === "string" ? body.email : "",
subject: typeof body.subject === "string" ? body.subject : "",
message: typeof body.message === "string" ? body.message : "",
consent: body.consent === true,
};
const errors = validateContact(fields);
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/kontakt/submit?_environment=${encodeURIComponent(cmsEnv)}`
: `${cmsBase}/api/forms/kontakt/submit`;
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Forwarded-For": getClientAddress(),
},
body: JSON.stringify(body),
});
const payload = {
name: fields.name.trim(),
email: fields.email.trim(),
subject: fields.subject.trim(),
message: fields.message.trim(),
consent_at: new Date().toISOString(),
submitted_via: "windwiderstand.de/api/contact",
user_agent: (request.headers.get("user-agent") ?? "").slice(0, 200),
};
let res: Response;
try {
res = await fetch(cmsUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Forwarded-For": getClientAddress(),
Origin: url.origin,
},
body: JSON.stringify(payload),
});
} catch (e) {
console.error("[contact] 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(() => "");
return json({ error: text || "Fehler beim Senden" }, { status: res.status });
console.error("[contact] 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 "";
}
}