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
+43 -4
View File
@@ -39,6 +39,7 @@ import {
import { ArrowIcon, Button } from "~/components/ui/Button";
import { Input } from "~/components/ui/Input";
import { Turnstile } from "~/components/ui/Turnstile";
import {
formatDate,
interpolate,
@@ -51,6 +52,7 @@ import {
import { safeHref } from "~/lib/safe-href";
import { buildBreadcrumbJsonLd, buildEventJsonLd, buildMeta } from "~/lib/seo";
import { isStrapiError, requireEntity } from "~/lib/strapi";
import { verifyTurnstile } from "~/lib/turnstile.server";
import type { Route } from "./+types/events-calendar.$slug";
import { getRouteCopy } from "./_events-opportunities/copy";
@@ -161,6 +163,8 @@ type ActionResult =
readonly values: RegistrationValues;
/** True when validation passed but the CMS refused the write. */
readonly failed: boolean;
/** True when validation passed but the Turnstile check did not. */
readonly verificationFailed: boolean;
};
export async function action({ params, request }: Route.ActionArgs) {
@@ -185,11 +189,30 @@ export async function action({ params, request }: Route.ActionArgs) {
if (!valid) {
return data(
{ ok: false, errors, values, failed: false } satisfies ActionResult,
{
ok: false,
errors,
values,
failed: false,
verificationFailed: false,
} satisfies ActionResult,
{ status: 400 },
);
}
if (!(await verifyTurnstile(form))) {
return data(
{
ok: false,
errors: {},
values,
failed: false,
verificationFailed: true,
} satisfies ActionResult,
{ status: 403 },
);
}
let registrationState: "confirmed" | "waitlisted";
try {
/* Only the fields the CMS whitelist accepts are sent - see
@@ -215,7 +238,13 @@ export async function action({ params, request }: Route.ActionArgs) {
`[events] registration for "${slug}" was rejected: ${error.status} ${error.message}`,
);
return data(
{ ok: false, errors: {}, values, failed: true } satisfies ActionResult,
{
ok: false,
errors: {},
values,
failed: true,
verificationFailed: false,
} satisfies ActionResult,
{ status: 502 },
);
}
@@ -630,8 +659,12 @@ function RegistrationSection({
</Notice>
) : null}
{/* A plain POST to this same URL: no fetcher, no client validation, and
therefore nothing that stops working with scripting off. */}
{result && !result.ok && result.verificationFailed ? (
<Notice tone="danger" role="alert" title={t("form.errorVerification")} />
) : null}
{/* A plain POST to this same URL: no fetcher and no client validation.
Only the Turnstile check, when configured, needs scripting. */}
<Form method="post" action={detailPath} className="flex flex-col gap-md">
<div className="grid gap-md sm:grid-cols-2">
<Input
@@ -693,6 +726,12 @@ function RegistrationSection({
/>
</div>
<Turnstile
action="event-registration"
locale={locale}
resetKey={result}
/>
<div className="flex flex-wrap items-center gap-sm">
<Button
type="submit"