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