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:
@@ -7,7 +7,8 @@
|
||||
|
||||
Both forms work without JavaScript: they are plain <Form method="post">
|
||||
submissions, so validation has to be complete on the server. There is no
|
||||
client mirror of these rules to drift out of sync.
|
||||
client mirror of these rules to drift out of sync. (The Cloudflare
|
||||
Turnstile check, when configured, is the one part that needs scripting.)
|
||||
========================================================================= */
|
||||
|
||||
import type { TranslationKey } from "~/i18n";
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
|
||||
import { ArrowIcon, Button } from "~/components/ui/Button";
|
||||
import { Input, Textarea } from "~/components/ui/Input";
|
||||
import { Turnstile } from "~/components/ui/Turnstile";
|
||||
import {
|
||||
interpolate,
|
||||
localizedPath,
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
import { safeHref } from "~/lib/safe-href";
|
||||
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
|
||||
import { isStrapiError, requireEntity } from "~/lib/strapi";
|
||||
import { verifyTurnstile } from "~/lib/turnstile.server";
|
||||
|
||||
import type { Route } from "./+types/applications-and-opportunities.$slug";
|
||||
import { getRouteCopy } from "./_events-opportunities/copy";
|
||||
@@ -98,6 +100,8 @@ type ActionResult =
|
||||
readonly values: ApplicationValues;
|
||||
/** 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) {
|
||||
@@ -121,11 +125,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 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await submitApplication({
|
||||
values,
|
||||
@@ -138,7 +161,13 @@ export async function action({ params, request }: Route.ActionArgs) {
|
||||
`[opportunities] application 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 },
|
||||
);
|
||||
}
|
||||
@@ -492,6 +521,10 @@ function ApplicationSection({
|
||||
</Notice>
|
||||
) : null}
|
||||
|
||||
{result && !result.ok && result.verificationFailed ? (
|
||||
<Notice tone="danger" role="alert" title={t("form.errorVerification")} />
|
||||
) : null}
|
||||
|
||||
<Form method="post" action={detailPath} className="flex flex-col gap-md">
|
||||
<div className="grid gap-md sm:grid-cols-2">
|
||||
<Input
|
||||
@@ -544,6 +577,12 @@ function ApplicationSection({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Turnstile
|
||||
action="opportunity-application"
|
||||
locale={locale}
|
||||
resetKey={result}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-sm">
|
||||
<Button
|
||||
type="submit"
|
||||
|
||||
@@ -37,7 +37,8 @@
|
||||
an enhancement only: all three steps are in the server-rendered HTML, no
|
||||
field is hidden behind a step, and the whole thing submits and reports its
|
||||
errors with JavaScript switched off. `useNavigation()` only swaps the button
|
||||
into its loading state.
|
||||
into its loading state. The one exception is the Cloudflare Turnstile check,
|
||||
which needs scripting whenever it is configured (~/components/ui/Turnstile).
|
||||
|
||||
WHAT THE DRAFT PROMISES AND THIS PAGE DOES NOT (CLAUDE.md rule 5 - the
|
||||
wording lives in ./_events-opportunities/copy.ts, where each omission is
|
||||
@@ -65,6 +66,7 @@ import { data, Form, Link, useActionData, useNavigation } from "react-router";
|
||||
import { ArrowIcon, Button } from "~/components/ui/Button";
|
||||
import { CardLink, CardShell } from "~/components/ui/Card";
|
||||
import { Input, Textarea } from "~/components/ui/Input";
|
||||
import { Turnstile } from "~/components/ui/Turnstile";
|
||||
import {
|
||||
interpolate,
|
||||
localizedPath,
|
||||
@@ -76,6 +78,7 @@ import {
|
||||
} from "~/i18n";
|
||||
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
|
||||
import { isStrapiError } from "~/lib/strapi";
|
||||
import { verifyTurnstile } from "~/lib/turnstile.server";
|
||||
|
||||
import type { Route } from "./+types/applications-and-opportunities";
|
||||
import { Container } from "./_page-kit/sections";
|
||||
@@ -192,6 +195,8 @@ type ActionResult =
|
||||
readonly values: PathwayApplicationValues;
|
||||
/** 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) {
|
||||
@@ -219,11 +224,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 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await submitPathwayApplication({
|
||||
values,
|
||||
@@ -235,7 +259,13 @@ export async function action({ params, request }: Route.ActionArgs) {
|
||||
`[opportunities] application from /${locale} 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 },
|
||||
);
|
||||
}
|
||||
@@ -678,13 +708,15 @@ function ApplicationForm({
|
||||
result: ActionResult | undefined;
|
||||
route: ApplicationRoute;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { locale, t } = useTranslation();
|
||||
const navigation = useNavigation();
|
||||
const isSubmitting = navigation.state === "submitting";
|
||||
|
||||
const errors = result && !result.ok ? result.errors : {};
|
||||
const values = result && !result.ok ? result.values : EMPTY_VALUES;
|
||||
const hasFailed = result !== undefined && !result.ok && result.failed;
|
||||
const hasFailedVerification =
|
||||
result !== undefined && !result.ok && result.verificationFailed;
|
||||
|
||||
/* This form has no consent checkbox - only the event registration does - so
|
||||
the "consent" code cannot occur here; it is narrowed away rather than
|
||||
@@ -723,7 +755,7 @@ function ApplicationForm({
|
||||
className="flex flex-col gap-lg"
|
||||
noValidate
|
||||
>
|
||||
{invalidFields.length > 0 || hasFailed ? (
|
||||
{invalidFields.length > 0 || hasFailed || hasFailedVerification ? (
|
||||
<div
|
||||
ref={summaryRef}
|
||||
tabIndex={-1}
|
||||
@@ -731,7 +763,11 @@ function ApplicationForm({
|
||||
className="flex flex-col gap-xs rounded-card border border-border-danger bg-surface-danger-subtle p-md"
|
||||
>
|
||||
<p className="body-md-bold text-text-danger">
|
||||
{hasFailed ? t("form.errorGeneric") : copy.apply.errorSummaryTitle}
|
||||
{hasFailed
|
||||
? t("form.errorGeneric")
|
||||
: hasFailedVerification
|
||||
? t("form.errorVerification")
|
||||
: copy.apply.errorSummaryTitle}
|
||||
</p>
|
||||
|
||||
{hasFailed ? (
|
||||
@@ -887,6 +923,8 @@ function ApplicationForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Turnstile action="get-involved" locale={locale} resetKey={result} />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-md border-t border-border-subtle pt-lg">
|
||||
<p className="body-sm max-w-[26rem] text-text-tertiary">
|
||||
{copy.apply.acknowledgement}
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
Client-side JS is an enhancement only - the page works as a plain HTML form
|
||||
post, and `useNavigation()` merely swaps the button into its loading state.
|
||||
The one exception is the Cloudflare Turnstile check, which needs scripting
|
||||
whenever it is configured (see ~/components/ui/Turnstile).
|
||||
|
||||
Every message the reader can see is localised: field labels and the generic
|
||||
errors come from the i18n catalogue, the page prose from the approved draft.
|
||||
@@ -41,9 +43,11 @@ import { data, Form, Link, useLocation, useNavigation, useRouteLoaderData } from
|
||||
import { ArrowIcon, Button } from "~/components/ui/Button";
|
||||
import { EuBadge } from "~/components/ui/EuBadge";
|
||||
import { Input, Textarea } from "~/components/ui/Input";
|
||||
import { Turnstile } from "~/components/ui/Turnstile";
|
||||
import { createTranslator, sectionPath, type Locale } from "~/i18n";
|
||||
import { getServerEnv } from "~/lib/env.server";
|
||||
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
|
||||
import { verifyTurnstile } from "~/lib/turnstile.server";
|
||||
|
||||
import type { Route } from "./+types/contact";
|
||||
import type { CmsContactPage } from "./_page-kit/cms-types";
|
||||
@@ -274,6 +278,20 @@ export async function action({ params, request }: Route.ActionArgs) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await verifyTurnstile(form))) {
|
||||
const t = createTranslator(locale);
|
||||
|
||||
return data(
|
||||
{
|
||||
status: "failed" as const,
|
||||
errors: {} as ContactErrors,
|
||||
values,
|
||||
formError: t("form.errorVerification"),
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await createContactSubmission(
|
||||
values,
|
||||
@@ -548,6 +566,8 @@ export default function Contact({
|
||||
{...(errors.message ? { error: errors.message } : {})}
|
||||
/>
|
||||
|
||||
<Turnstile action="contact" locale={locale} resetKey={actionData} />
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
|
||||
@@ -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