188 lines
6.2 KiB
TypeScript
188 lines
6.2 KiB
TypeScript
/* ============================================================================
|
|||
|
|
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} />
|
||
|
|
------------------------------------------------------------------------ */
|