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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user