feat(contact): add Cloudflare Turnstile captcha
Deploy / verify (push) Failing after 44s
Deploy / deploy (push) Has been skipped

Client renders explicit Turnstile widget (flexible, light theme) when
PUBLIC_TURNSTILE_SITE_KEY is set. Token sent with form payload, server
verifies via siteverify endpoint before forwarding to CMS. Skipped
entirely when no site key configured (dev fallback). New i18n keys:
contact_captcha_pending, contact_server_error_captcha. Deploy workflow
forwards PUBLIC_TURNSTILE_SITE_KEY + TURNSTILE_SECRET.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-05-21 11:37:09 +02:00
parent 94a8feeb67
commit d03afe780e
4 changed files with 129 additions and 1 deletions
@@ -1,4 +1,6 @@
<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";
@@ -15,6 +17,9 @@
const t = useTranslate();
const TURNSTILE_SITE_KEY = publicEnv.PUBLIC_TURNSTILE_SITE_KEY ?? "";
const TURNSTILE_ENABLED = TURNSTILE_SITE_KEY !== "";
let name = $state("");
let email = $state("");
let subject = $state("");
@@ -32,6 +37,10 @@
let serverError = $state("");
let submitAttempted = $state(false);
let turnstileToken = $state("");
let turnstileEl: HTMLDivElement | null = null;
let turnstileWidgetId: string | null = null;
const fields = $derived<ContactFields>({ name, email, subject, message, consent });
const errors = $derived<ContactErrors>(validateContact(fields));
const isValid = $derived(Object.keys(errors).length === 0);
@@ -43,6 +52,70 @@
}
const messageRemaining = $derived(LIMITS.MESSAGE_MAX - message.length);
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;
}
});
const messageHelp = $derived(
messageRemaining >= 0
? t(T.contact_message_remaining, { n: messageRemaining })
@@ -59,6 +132,11 @@
el?.focus();
return;
}
if (TURNSTILE_ENABLED && !turnstileToken) {
serverError = t(T.contact_captcha_pending);
formState = "error";
return;
}
formState = "pending";
serverError = "";
@@ -77,6 +155,7 @@
website,
company,
_client_age_ms: Date.now() - pageLoadedAt,
cf_turnstile_token: turnstileToken,
}),
});
@@ -91,14 +170,24 @@
} else if (res.status === 429) {
serverError = t(T.contact_server_error_throttle);
} else if (res.status === 403) {
serverError = t(T.contact_server_error_blocked);
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";
if (TURNSTILE_ENABLED) {
turnstileToken = "";
if (turnstileWidgetId !== null) getTurnstile()?.reset(turnstileWidgetId);
}
} catch {
serverError = t(T.contact_server_error_network);
formState = "error";
if (TURNSTILE_ENABLED) {
turnstileToken = "";
if (turnstileWidgetId !== null) getTurnstile()?.reset(turnstileWidgetId);
}
}
}
</script>
@@ -187,6 +276,10 @@
<p class="mt-1 text-xs text-stein-500">{t(T.contact_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}