feat(contact): full validation, CMS-translated strings, smaller UI
- 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:
@@ -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 "";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user