diff --git a/.env.example b/.env.example index cd5f577..c7fe0fb 100644 --- a/.env.example +++ b/.env.example @@ -109,6 +109,11 @@ DOCKER_STRAPI_URL=http://cms:1337 # Settings -> API Tokens, then paste the value here (it is shown once). STRAPI_TOKEN= +# Cloudflare Turnstile on the public forms. Both keys or neither: with either +# one empty, the forms are not checked. See apps/web/.env.example. +TURNSTILE_SITE_KEY= +TURNSTILE_SECRET_KEY= + # --------------------------------------------------------------------------- # CMS RUNTIME diff --git a/README.md b/README.md index 446aadd..905b054 100644 --- a/README.md +++ b/README.md @@ -929,6 +929,7 @@ Whichever port you pick, `SITE_URL` stays the public origin (`https://sharper.ly | `SITE_URL` | web | `https://sharper.ly` — canonical URLs, `hreflang`, JSON-LD are built from it | | `STRAPI_URL` | web | the CMS origin, e.g. `https://cms.sharper.ly`. It is also prefixed onto media URLs, so it must be resolvable **by the browser**, not only from the server | | `STRAPI_TOKEN` | web | only needed for draft/protected content; published content is public | +| `TURNSTILE_SITE_KEY` / `TURNSTILE_SECRET_KEY` | web | Cloudflare Turnstile on the public forms. Set both or neither — with either empty, no widget renders and submissions are not checked | | `PUBLIC_URL` | cms | `https://cms.sharper.ly` — admin redirects and generated links | | `PARTNER_REVIEW_NOTIFICATION_EMAILS` | cms | Optional comma-separated addresses for partner draft notices; defaults to active Super Admins | | `IS_PROXIED` | cms | `true` behind nginx / a load balancer, so `X-Forwarded-*` is trusted | diff --git a/apps/web/.env.example b/apps/web/.env.example index 74ddf7a..1fa060a 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -45,3 +45,15 @@ STRAPI_URL=http://localhost:1337 # Optional Strapi API token. Only needed to read draft or otherwise protected # content; published content is public. Settings -> API Tokens in the admin. STRAPI_TOKEN= + +# --- Bot protection -------------------------------------------------------- + +# Cloudflare Turnstile on the public forms (contact, event registration and +# both application forms). Both keys or neither: with either one empty, no +# widget renders and submissions are not checked. The site key is public and +# reaches the browser; the secret key never does. +# Cloudflare dashboard -> Turnstile -> your widget. For local testing, +# Cloudflare's always-pass pair is 1x00000000000000000000AA (site) and +# 1x0000000000000000000000000000000AA (secret). +TURNSTILE_SITE_KEY= +TURNSTILE_SECRET_KEY= diff --git a/apps/web/app/components/ui/Turnstile/Turnstile.tsx b/apps/web/app/components/ui/Turnstile/Turnstile.tsx new file mode 100644 index 0000000..7989c39 --- /dev/null +++ b/apps/web/app/components/ui/Turnstile/Turnstile.tsx @@ -0,0 +1,187 @@ +/* ============================================================================ + TURNSTILE - Cloudflare's bot check on the public forms + ---------------------------------------------------------------------------- + Rendered INSIDE a
: 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