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:
@@ -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} />
|
||||
------------------------------------------------------------------------ */
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Turnstile, default } from "./Turnstile";
|
||||
export type { TurnstileAction, TurnstileProps } from "./Turnstile";
|
||||
Reference in New Issue
Block a user