feat(web): add Cloudflare Turnstile to the public forms

Render the widget on the contact, event registration and both application
forms, and verify the token with Siteverify in each action before anything
is written to the CMS. Verification fails closed. The check is off unless
both TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY are set.

Allow challenges.cloudflare.com in script-src and frame-src.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-24 12:05:51 +02:00
co-authored by Claude Opus 5.5
parent 0b2c8d581f
commit 709a978da5
17 changed files with 475 additions and 15 deletions
@@ -0,0 +1,187 @@
/* ============================================================================
TURNSTILE - Cloudflare's bot check on the public forms
----------------------------------------------------------------------------
Rendered INSIDE a <Form>: the widget injects a hidden `cf-turnstile-response`
input into its container, so the token travels with the other fields and
the route's action checks it with `verifyTurnstile` (~/lib/turnstile.server).
The site key is read off the root loader. Without one the component renders
nothing and the server skips the check, so an unconfigured deploy keeps
working exactly as before.
EXPLICIT RENDERING, NOT THE `cf-turnstile` CLASS
Implicit rendering scans the DOM once, when api.js loads - a form reached by
a client-side navigation would never get a widget. Rendering from an effect
ties the widget to the component instead, and `remove` on unmount keeps
Cloudflare's iframes from piling up across navigations.
THE SCRIPT AND THE CSP
api.js is appended from here, once per page, the first time a form mounts -
so pages without a form never load it. The CSP (entry.server.tsx) admits it
through 'strict-dynamic', because the bundle that appends it is nonced; the
widget's iframe needs `frame-src https://challenges.cloudflare.com`.
SCRIPTING OFF
The check cannot run without JavaScript, so the forms that otherwise work
as plain posts cannot pass it. The <noscript> line says so up front instead
of letting the reader fill everything in first.
========================================================================= */
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouteLoaderData } from "react-router";
import { createTranslator } from "~/i18n/t";
import type { AppTheme } from "~/lib/theme";
import type { Locale } from "../LanguageSwitcher";
/** Cloudflare requires this exact URL - api.js must not be proxied or cached. */
const SCRIPT_URL =
"https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
/**
* Which form a token was issued for. Reported per action in the Cloudflare
* dashboard. Up to 32 characters of [A-Za-z0-9_-].
*/
export type TurnstileAction =
| "contact"
| "event-registration"
| "opportunity-application"
| "get-involved";
/** The slice of the root loader this component reads. */
interface RootLoaderData {
theme?: AppTheme;
turnstileSiteKey?: string | null;
}
/** The slice of `window.turnstile` used here. */
interface TurnstileApi {
render(
container: HTMLElement,
options: {
sitekey: string;
action: string;
theme: AppTheme;
language: Locale;
size: "flexible";
},
): string | undefined;
reset(widgetId: string): void;
remove(widgetId: string): void;
}
declare global {
interface Window {
turnstile?: TurnstileApi;
}
}
let scriptLoad: Promise<TurnstileApi> | undefined;
/** Appends api.js once and resolves with the API. A failed load may retry. */
function loadTurnstile(): Promise<TurnstileApi> {
if (window.turnstile) return Promise.resolve(window.turnstile);
scriptLoad ??= new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = SCRIPT_URL;
script.async = true;
script.onload = () =>
window.turnstile
? resolve(window.turnstile)
: reject(new Error("api.js loaded without defining window.turnstile"));
script.onerror = () => {
script.remove();
scriptLoad = undefined;
reject(new Error("api.js could not be loaded"));
};
document.head.append(script);
});
return scriptLoad;
}
export interface TurnstileProps {
/** Which form this is. */
action: TurnstileAction;
/** Reading locale, for the widget's own language and the fallback line. */
locale: Locale;
/**
* Anything that changes once per submission - pass the route's action data.
* Siteverify accepts a token only once, so every new result asks the widget
* for a fresh one.
*/
resetKey?: unknown;
className?: string;
}
export function Turnstile({ action, locale, resetKey, className }: TurnstileProps) {
const rootData = useRouteLoaderData<RootLoaderData>("root");
const t = useMemo(() => createTranslator(locale), [locale]);
const siteKey = rootData?.turnstileSiteKey ?? null;
const theme: AppTheme = rootData?.theme ?? "dark";
const containerRef = useRef<HTMLDivElement>(null);
const widgetIdRef = useRef<string | undefined>(undefined);
const [unavailable, setUnavailable] = useState(false);
useEffect(() => {
if (!siteKey) return;
let cancelled = false;
loadTurnstile().then(
(turnstile) => {
if (cancelled || !containerRef.current) return;
widgetIdRef.current = turnstile.render(containerRef.current, {
sitekey: siteKey,
action,
theme,
language: locale,
size: "flexible",
});
},
() => {
if (!cancelled) setUnavailable(true);
},
);
return () => {
cancelled = true;
if (widgetIdRef.current) window.turnstile?.remove(widgetIdRef.current);
widgetIdRef.current = undefined;
};
}, [siteKey, action, theme, locale]);
useEffect(() => {
if (widgetIdRef.current) window.turnstile?.reset(widgetIdRef.current);
}, [resetKey]);
if (!siteKey) return null;
const fallback = (
<p className="body-sm text-[var(--text-danger)]">
{t("form.verificationUnavailable")}
</p>
);
return (
<div className={className}>
{/* Cloudflare owns everything inside this element; React renders no
children into it, so the two never fight over the same nodes. */}
{unavailable ? fallback : <div ref={containerRef} />}
<noscript>{fallback}</noscript>
</div>
);
}
export default Turnstile;
/* ---------------------------------------------------------------------------
USAGE
---------------------------------------------------------------------------
// Inside the <Form>, just above the submit button. In the action, call
// `verifyTurnstile(form)` from ~/lib/turnstile.server after validation.
<Turnstile action="contact" locale={locale} resetKey={actionData} />
------------------------------------------------------------------------ */