diff --git a/.env.development.enc b/.env.development.enc index a384960e..80cba719 100644 --- a/.env.development.enc +++ b/.env.development.enc @@ -1,99 +1,101 @@ -----BEGIN AGE ENCRYPTED FILE----- -YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBub2VRQzZSenNIVHNLVE53 -ZHpURXFzcDdJRUtnZlNTRnl0UWJVUXdPeUg0CnFxWXdKSFJTUjk3WldvL2dlTHNE -TVZHWENDRHJkeG52MG5BcmRkQjZuL3cKLT4gWDI1NTE5IFNDRS92OEdOcmF0c3lw -S21BR2VLRXcvMmt2ZFM1YmkrQ1JPRWhhUEVwencKZUFIWGZEQjAyd2F4U2ZaN1VZ -Y3dxUlBmZ3lLMzdqNi83ajJKayt6NWFiSQotPiBYMjU1MTkgbnNLRTZqWlZsOFUz -VkFkUU5jdzZzMHk1d0p2Uk9NbzJaaUhmdjNNa0MwVQoxNDVEMU5lUjI1UDZYTjFw -NDJZZndPTjJFSGJJMWxYQnJmZ0FzbGdKWm5BCi0+IFgyNTUxOSBRbGE0WGRxUXNX -VUR1Q1hrZzBqR0tGMCtyRmJQWGRiMUJpVHVnNWkzZ1I4CmNqZ3d5cU5DdkV4eUk5 -MGtoM05HNXEzNHYxck5NMnZUaXZkNDVEZUJ5U3MKLT4gWDI1NTE5IHFuMXgwTytj -ZG9ja3dNZGFWVmM0WnNjK1ZpeWEwMkl5ck9QWnhRZzI3aHMKSFRCNDVaNUdvUDV5 -VWtwRmtiZUphR2lHMlZBZEFpaGxRR0lxaTQ0UjF2QQotPiBYMjU1MTkgS1pPMXZB -dC9UY01GN2hadjZoT0EwVkFLY1VDR291MzJiemhacVRwN0NpMApHL0xKL1hGVmh2 -TGxnZVRiZWFPaEMwT3NVWndoZ2ROSm9vWnJTVmJTV1E0Ci0+IFgyNTUxOSBzR3Fu -UCtLejBVTm5VUm1DR2RmRHZJUnN4WHNnaDlGbDVDeE1zUzNmN2hvCmtKTmtLZXlT -ZWJEaklEUFk5WmVJbUFTaU9vZnpiQ1ZwakFKbjVuc3lpUU0KLT4gWDI1NTE5IGV0 -VkFnNzVtRXZHb1JBQmx6ekhPODlrOGlQalZOMVdwWGMwaDl1QW53MkUKTEFqd2Ev -MEhpYW5oQTloeDNINHpCYUFzaU5kZk90eXR4VUhac2p6OC9LRQotPiBYMjU1MTkg -ZXJVUVBMVE1DcDMycXhHSm5vK1JneTdnY3VLOUdpemtUWXovZWFKTjlTOApqSnMz -RDdsYVViaDdhZUN0Y3ZTMFJjQU82SUgyRkprS0ZlcS9zd1RwbDRNCi0+IFgyNTUx -OSBVWTFrVGV2Z0FoemkwdEVZTDJIL0FaQlROdHY0aEZVb3ZwM2hFWFNGS1gwCjVz -MHdxQ0FKR2pKak0rcjBCUmltNENPN1BrQWRna3JZZmFaVUZmVXRJbU0KLS0tIGpk -dkY1bVFlNVZQNHNvZmdwakJadWRRS0V0Y2hjRjFwL044QjN4anVFSGMKf8fLStbp -wQeMv2cANX78hlpsngMds7m+CwvlnnXVkHmjt6n047N11vbTwg/qiTHkOCFwbrnX -rZ9YH9I4Zqj1T92JYPU9KrPx5WuUE0wGIjXv5T9SoDsVOSgNQnOnLiTqFutNJCbj -3fI1AnzdU7aqL8Gdt2b7GjeAjq7zWUMBKq0J2fufjb+55Ul0TY9JWgBrG2oZB1GM -AjSxwpJZ48V5loAchd9Zt0QMw+gIi42eGQBoh8wf5HgyvgW8nxivho/jo4hzJvM9 -g8utIj7wslw0RvfE9x34xNZMUZESa3RBlypjodFyGzfd6T/ii/AGM6MJw04BDIsC -H+6XeJdkb+beJnr7yv8CKc80cXB8bmpD3RqvUJmB1JWJFdLIBijrSg/UPKK68w1t -ySXAUj4LtB9PY2SijszPnw1xiwcQdpPkGIKMs9GqB5tMyIbQlt/RhAdHb/sxOObB -MlrHTLw4xWLcsyhFujAhxj06lkRTAJexYhqCod1QJboulSkDzLi4SGTJqr7MR2/P -vgMrMs41jg+tjQKi3NN5HniU4U5jzREwdTh4pBgeH6Zni3r8bp9dcyWG+dNff+Zl -7CLh5PAfqa/79LnBy8Tq5UVuj5rE9bU6PahllOubjbRlZkJsvKHbbvqOpUI8vy6O -Jxv1RtY0qGkq534Ez+0NKeMWmvFLw4v2evOhz3TQ/Mm0FzlQTtPFnSt7RARv12Ob -n2bEdEjPrWOQvlgLP6rHQ3oQKLQpvR63d/lswewtjaIHecgks4VbBBRqG02wWV2R -zdnfEETwVk5QRCf5KYDvhwPcLEBTyF9f11voj9BcqLh0c5sb8ZquNGpfJFpZnVC6 -kifdflePiVb5uWwKrRTyFkpTq9mB3fNO52n/b1+j3F6/Kon+oE+ojAsu+HUfx0sj -HSX4eZcq8RbMvldRXgagvJbF7jtZwHnzYJbOmbDVQQmbmnShZ4qWwEDfBMON6hsk -wTwSR5f05Ov5CjqB/b1XD2JPXuy7bNuebz/roEHV0ntZCw5hn2shsJ5my3tT8gg5 -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 -95k= +YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBlcSt5OEZvY3o0VG1BZkNz +eEV4TG1LOXN6V2FzdlZwcExBZVR1K1kxMEdjCjNnb3llSTZ5Tk13MUVIN0NHWEFD +Zllxa1NZaVo1Nyt3MFV1NXpzSUV6dzQKLT4gWDI1NTE5IFV0WitzWXJUV093MEUr +NEJsRU83d3lUVDFGbEZXMzNnZ010eE16MHFVaVEKZWx4TkhGd0hDajdYais3ZmFW +bGVYZ05xeXJnS21mcngzZThxTFlwK2lMTQotPiBYMjU1MTkgQlk5bEdiMGh0VGc4 +dFlZWjB4bHdZZHZNUXE2YUR6aVo4NmZ3c3F4SE1rSQpSYmF0b2VES1ZFUnBRdTFT +emJlNmYrZDNpT3lYeWJkdWhKNXExMlFUZlg0Ci0+IFgyNTUxOSBnUFpvKzNHRGtq +V3lhOUtEbmZORXl0ajUvYlo1QnBoTjJTMkYwWjhGTzB3CnJVR2Vrb3IyVnpGbW1t +UkZHcG5NaTdvZ2dESml5Zi9BWjk0TFdmTk4raTQKLT4gWDI1NTE5IEtYTWNldVdx +TnpPdnJSb2pTZWFiQmN5ejVuenNpemtVcXY1L2M0RlliVmsKcjNEWlhFWFlQSnVS +NGpidkdXRUVKNUlYK0hZMDFZc3hUdjY5ZmRDcWMyMAotPiBYMjU1MTkgMlJrRldQ +YzBhN1lPSW1FTVJMZGd6Y2QvNDNMQzIxdnMraDhwMmo1UnJ3ZwpYR0VOaFBTMnpS +S3JlcGVUZ0F5enVjK1J4Wm54ZVQva1hFdEZQMFUxTU1NCi0+IFgyNTUxOSBvVXor +Nk54dG1aOVc0YjhCeHh1SU5reHRlQld4NDIyMm1kMWdjNjhvS2lnCjdXTURqN2lq +S2J4NzFodjhXQkhMRlBDV1RoeHd4b1h1ak1uakV1dlBZT2MKLT4gWDI1NTE5IFJG +bG1IelpPQnF4Vnp4cGRYejZXRmdRTmN3NE5ENnF4TEQwMFlicUdzRmsKcjc3bUVT +QWJEUXQzK3Bad3BCWnZvOFpiSk9VYVFzRGs1WlorVklobVQ0awotPiBYMjU1MTkg +OHlXMzZ0RkNvZ05EUUw0WllOa1JZTGc2ZHJRY0ZCQy81eTM3SXd1V1poawp2ZE5i +cVZhc0JCWlhsdG4rVkltZHF3NDNiR1gxQXVsZ1lqTWc1d21VOVc0Ci0+IFgyNTUx +OSBxUnptd2dlVFBBaUE0cDZ2blZ2Tm1ITE5RSVBlNGJSd1FJUG1GbTNzeGhrCjRh +eXpWc2k0TEtobDNtS3pJWWM0emFqRzMySWhscHVxL1NKZU5FaDB5ZzgKLS0tIGts +ZVZiNnFNSjUvMjh2bVI0TXZZeHFWc1o2MytwSytRTkJwTTVoVXJ3Z1UK4621nIw+ +WLp2ggb3x/DPU58z9wbyP73W3Y8+lrTEykWug8h5d0UGRqM1T5IDQOs5JrUGdzh8 +49IkCFhZHjovUUCCyjoUw90hcWYgdS8LSQl7VqWVf6SGnC4YgXf/utIY2cP+vYIO +LkfUULpb8dooiA95XMWyTp0bSh9lJzflAFVCndnsjB2hzr2dz7r0BiPKryPqVQCI +SEdnOp0HtV0Ns/PMU28bdosdybc0b3JId9Z0dVJm1OSi2lXUAsfLaC4K051US593 +uFOxpGuyHkNOeFz5j6S0SesWqoJrEzS+EZ3ipZX6AX/tLXVNxtffHJjaJwP6IUfm +efgRiK+c0nl6srcnFn6YKwchMIUmn0eO3fLClsP5uTT5IShc5XpRm358IRfZeWjx +13Wtv+RKxBP/4Hmb0q7Tl0A/Lq6hb9r4sPlnYSSVy9+Krl3JDkVGJr7MNmfsmq9G +WoB2j+i6en7Hy7AQjeBgkzzLg03xWUMMTZopdHvCg2pJhozRzJIgyKiNAZknmrjC +edwOoKdBl2n6f6qTkIRCM0PQ8muVkPoF6qRTWsIleV0pHK/B4azG9x8VG7PTT64s +EilRoxvZAE2YGMJqmU+TqRXT6Mhvg/k8/VbULMp1xO9NJedn/rhqBkYjc2ISlC4R +JQ8V4Of8xmTY+s9jEqVD+ZDhG4RoJOhpZkWKSwRKB0LsibhKSiCruIQUGev6ImVk +uX0hQhOvR4J1D/JB7MclQHXX8stWfM3T8JBE7OzWLvHqUiTimRQmIzOlHgOsd+8g +uFl8t7flN/wlLRsMT7cYmE07o4+e4gWpkmPsS5Hh/VrvgjKr71rHZHSxvUfytAhx +cs1aQeuyN15XM09QthK1y+640pJzhFGjaqBzugK72qNyfK5tz3gZtP7zVIPgJhW5 +wtxDdxEeBLA79fMa67Sx0E41V3F22rWwSgXzBTyAh/hrXEYmS47c4BRSQDT48uqK +FGf4D/wkTOLCFUsdsV0fNAX11wvpZKH8pOXWUDpZ4fK5NBX9rXev3EriCt4SxPTr +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 +YpKL4GjNzw== -----END AGE ENCRYPTED FILE----- diff --git a/.env.example b/.env.example index 9472d3e0..13f0a111 100644 --- a/.env.example +++ b/.env.example @@ -4,8 +4,8 @@ SECRET_API_TOKEN= FASTLY_KEY= FASTLY_SERVICE_ID= MAILERLITE_TOKEN= -RECAPTCHA_KEY= -RECAPTCHA_SECRET_KEY= +TURNSTILE_SITE_KEY= +TURNSTILE_SECRET_KEY= ROLLBAR_TOKEN= PIPEDRIVE_TOKEN= SLACK_TOKEN= diff --git a/Dockerfile b/Dockerfile index 6e329b8c..17b3ef66 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ FROM node:22-alpine AS base ARG PUBLIC_HOSTNAME -ARG RECAPTCHA_KEY +ARG TURNSTILE_SITE_KEY ARG KNOWLEDGE_BASE_URL ARG LINKEDIN_PARTNER_ID ARG LLMS_BLOB_BASE_URL @@ -58,7 +58,7 @@ RUN --mount=type=secret,id=DATOCMS_API_TOKEN,env=DATOCMS_API_TOKEN \ npx gql.tada generate schema https://graphql.datocms.com --header "X-Exclude-Invalid: true" --header "Authorization: Bearer $DATOCMS_API_TOKEN" # Build the actual Astro standalone production server -ENV RECAPTCHA_KEY=$RECAPTCHA_KEY +ENV TURNSTILE_SITE_KEY=$TURNSTILE_SITE_KEY ENV PUBLIC_HOSTNAME=$PUBLIC_HOSTNAME ENV KNOWLEDGE_BASE_URL=$KNOWLEDGE_BASE_URL ENV LINKEDIN_PARTNER_ID=$LINKEDIN_PARTNER_ID diff --git a/astro.config.ts b/astro.config.ts index f4e8976b..b8a24689 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -67,9 +67,12 @@ export default defineConfig({ context: 'server', access: 'secret', }), - RECAPTCHA_SECRET_KEY: envField.string({ + // Cloudflare Turnstile secret for the forms. Unset → verification is skipped + // for that environment (see `~/lib/turnstile`). + TURNSTILE_SECRET_KEY: envField.string({ context: 'server', access: 'secret', + optional: true, }), SLACK_TOKEN: envField.string({ context: 'server', @@ -107,9 +110,12 @@ export default defineConfig({ context: 'server', access: 'secret', }), - RECAPTCHA_KEY: envField.string({ + // Cloudflare Turnstile sitekey for the forms, baked at build time. Unset → no + // widget is rendered (see `~/lib/loadTurnstile`). + TURNSTILE_SITE_KEY: envField.string({ context: 'client', access: 'public', + optional: true, }), KNOWLEDGE_BASE_URL: envField.string({ context: 'client', diff --git a/cubo.json5 b/cubo.json5 index 815db7d3..3296c600 100644 --- a/cubo.json5 +++ b/cubo.json5 @@ -6,7 +6,7 @@ build: { args: { PUBLIC_HOSTNAME: 'www.datocms.com', - RECAPTCHA_KEY: '6Lda4UkgAAAAAE14pGAmMkbHKJCKOzmRoVc9_DE5', + TURNSTILE_SITE_KEY: '0x4AAAAAAErF5qflV0JALrvh', KNOWLEDGE_BASE_URL: 'https://knowledge.datocms.com', LINKEDIN_PARTNER_ID: '6497930', LLMS_BLOB_BASE_URL: 'https://llms-txt.datocms.com', @@ -80,7 +80,7 @@ build: { args: { PUBLIC_HOSTNAME: 'www.staging-datocms.com', - RECAPTCHA_KEY: '6Lda4UkgAAAAAE14pGAmMkbHKJCKOzmRoVc9_DE5', + TURNSTILE_SITE_KEY: '0x4AAAAAAErF0MvgFAYEMfsH', KNOWLEDGE_BASE_URL: 'https://knowledge.datocms.com', LLMS_BLOB_BASE_URL: 'https://llms-txt.datocms.com', }, @@ -139,7 +139,7 @@ build: { args: { PUBLIC_HOSTNAME: 'www-2.staging-datocms.com', - RECAPTCHA_KEY: '6Lda4UkgAAAAAE14pGAmMkbHKJCKOzmRoVc9_DE5', + TURNSTILE_SITE_KEY: '0x4AAAAAAErF0MvgFAYEMfsH', KNOWLEDGE_BASE_URL: 'https://knowledge.datocms.com', LLMS_BLOB_BASE_URL: 'https://llms-txt.datocms.com', }, diff --git a/package-lock.json b/package-lock.json index 4349e33f..ce97b06a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,6 @@ "react-dom-confetti": "^0.2.0", "react-hook-form": "^7.53.0", "react-hot-toast": "^2.4.1", - "react-recaptcha-hook": "^1.2.3", "react-simple-maps": "^3.0.0", "react-spring": "^9.7.4", "react-textarea-autosize": "^8.5.9", @@ -22847,19 +22846,6 @@ "async-limiter": "~1.0.0" } }, - "node_modules/react-recaptcha-hook": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/react-recaptcha-hook/-/react-recaptcha-hook-1.2.3.tgz", - "integrity": "sha512-Uq6JoPQsuCNUpDfWGlanFlqhobyr8ajPxmlM56fLp54iHASxX27VuMFUV2ExSEEEqoQLm+bzXY8/IgI0eFp1Ag==", - "license": "MIT", - "dependencies": { - "react-script-hook": "^1.2.1" - }, - "peerDependencies": { - "react": "^16.8.6 || ^17 || ^18", - "react-dom": "^16.8.6 || ^17 || ^18" - } - }, "node_modules/react-reconciler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.27.0.tgz", @@ -22896,16 +22882,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-script-hook": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/react-script-hook/-/react-script-hook-1.7.2.tgz", - "integrity": "sha512-fhyCEfXb94fag34UPRF0zry1XGwmVY+79iibWwTqAoOiCzYJQOYTiWJ7CnqglA9tMSV8g45cQpHCMcBwr7dwhA==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.6 || 17 - 18", - "react-dom": "^16.8.6 || 17 - 18" - } - }, "node_modules/react-select": { "version": "5.8.1", "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.8.1.tgz", diff --git a/package.json b/package.json index 198482df..f9cd5c86 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,6 @@ "react-dom-confetti": "^0.2.0", "react-hook-form": "^7.53.0", "react-hot-toast": "^2.4.1", - "react-recaptcha-hook": "^1.2.3", "react-simple-maps": "^3.0.0", "react-spring": "^9.7.4", "react-textarea-autosize": "^8.5.9", diff --git a/src/actions/forms/submitDvLegacyCmsRequest.ts b/src/actions/forms/submitDvLegacyCmsRequest.ts index 153f14e0..dc136d8d 100644 --- a/src/actions/forms/submitDvLegacyCmsRequest.ts +++ b/src/actions/forms/submitDvLegacyCmsRequest.ts @@ -3,7 +3,7 @@ import { z } from 'astro:schema'; import { hasMarketingConsent } from '~/lib/consent'; import { trackConversion } from '~/lib/linkedin'; import { logErrorToRollbar } from '~/lib/logToRollbar'; -import { isRecaptchaTokenValid } from '~/lib/recaptcha'; +import { isTurnstileTokenValid } from '~/lib/turnstile'; import { isSpam } from '~/lib/spam'; import { createLead, @@ -26,14 +26,16 @@ export default defineAction({ currentCms: z.string(), country: z.string(), body: z.string(), - token: z.string(), + // Empty when no Turnstile sitekey is configured for the environment; the + // verifier decides whether that is acceptable. + token: z.string().optional(), }), handler: async ({ token, ...input }, { request, cookies }) => { try { - if (!(await isRecaptchaTokenValid(token))) { + if (!(await isTurnstileTokenValid(token, { action: 'lp-modernize-cms' }))) { throw new ActionError({ code: 'UNAUTHORIZED', - message: 'Invalid recaptcha token', + message: 'Invalid anti-bot token', }); } diff --git a/src/actions/forms/submitDvRequest.ts b/src/actions/forms/submitDvRequest.ts index 88784918..de409782 100644 --- a/src/actions/forms/submitDvRequest.ts +++ b/src/actions/forms/submitDvRequest.ts @@ -3,7 +3,7 @@ import { z } from 'astro:schema'; import { hasMarketingConsent } from '~/lib/consent'; import { trackConversion } from '~/lib/linkedin'; import { logErrorToRollbar } from '~/lib/logToRollbar'; -import { isRecaptchaTokenValid } from '~/lib/recaptcha'; +import { isTurnstileTokenValid } from '~/lib/turnstile'; import { isSpam } from '~/lib/spam'; import { createLead, @@ -26,14 +26,16 @@ export default defineAction({ currentCms: z.string(), country: z.string(), body: z.string(), - token: z.string(), + // Empty when no Turnstile sitekey is configured for the environment; the + // verifier decides whether that is acceptable. + token: z.string().optional(), }), handler: async ({ token, ...input }, { request, cookies }) => { try { - if (!(await isRecaptchaTokenValid(token))) { + if (!(await isTurnstileTokenValid(token, { action: 'lp-nuxt2eol' }))) { throw new ActionError({ code: 'UNAUTHORIZED', - message: 'Invalid recaptcha token', + message: 'Invalid anti-bot token', }); } diff --git a/src/actions/forms/submitMigcsRequest.ts b/src/actions/forms/submitMigcsRequest.ts index f2fbb80b..446621b1 100644 --- a/src/actions/forms/submitMigcsRequest.ts +++ b/src/actions/forms/submitMigcsRequest.ts @@ -3,7 +3,7 @@ import { z } from 'astro:schema'; import { hasMarketingConsent } from '~/lib/consent'; import { trackConversion } from '~/lib/linkedin'; import { logErrorToRollbar } from '~/lib/logToRollbar'; -import { isRecaptchaTokenValid } from '~/lib/recaptcha'; +import { isTurnstileTokenValid } from '~/lib/turnstile'; import { isSpam } from '~/lib/spam'; import { createLead, @@ -25,14 +25,16 @@ export default defineAction({ currentCms: z.string(), country: z.string(), body: z.string(), - token: z.string(), + // Empty when no Turnstile sitekey is configured for the environment; the + // verifier decides whether that is acceptable. + token: z.string().optional(), }), handler: async ({ token, ...input }, { request, cookies }) => { try { - if (!(await isRecaptchaTokenValid(token))) { + if (!(await isTurnstileTokenValid(token, { action: 'lp-migcs' }))) { throw new ActionError({ code: 'UNAUTHORIZED', - message: 'Invalid recaptcha token', + message: 'Invalid anti-bot token', }); } diff --git a/src/actions/forms/submitPartnerProgramRequest.ts b/src/actions/forms/submitPartnerProgramRequest.ts index c546453a..47571819 100644 --- a/src/actions/forms/submitPartnerProgramRequest.ts +++ b/src/actions/forms/submitPartnerProgramRequest.ts @@ -5,7 +5,7 @@ import { hasMarketingConsent } from '~/lib/consent'; import { sendToFrontChannel } from '~/lib/front'; import { trackConversion } from '~/lib/linkedin'; import { logErrorToRollbar } from '~/lib/logToRollbar'; -import { isRecaptchaTokenValid } from '~/lib/recaptcha'; +import { isTurnstileTokenValid } from '~/lib/turnstile'; import { isSpam } from '~/lib/spam'; import { createLead, @@ -29,15 +29,17 @@ export default defineAction({ email: z.string(), country: z.string(), body: z.string(), - token: z.string(), + // Empty when no Turnstile sitekey is configured for the environment; the + // verifier decides whether that is acceptable. + token: z.string().optional(), }), handler: async ({ token, ...input }, { request, cookies }) => { try { - // Step 1: Validate reCAPTCHA token - if (!(await isRecaptchaTokenValid(token))) { + // Step 1: Validate Turnstile token + if (!(await isTurnstileTokenValid(token, { action: 'partner-program' }))) { throw new ActionError({ code: 'UNAUTHORIZED', - message: 'Invalid recaptcha token', + message: 'Invalid anti-bot token', }); } diff --git a/src/actions/forms/submitSalesRequest.ts b/src/actions/forms/submitSalesRequest.ts index bfa3d9fc..8ec20a73 100644 --- a/src/actions/forms/submitSalesRequest.ts +++ b/src/actions/forms/submitSalesRequest.ts @@ -5,7 +5,7 @@ import { hasMarketingConsent } from '~/lib/consent'; import { sendToFrontChannel } from '~/lib/front'; import { trackConversion } from '~/lib/linkedin'; import { logErrorToRollbar } from '~/lib/logToRollbar'; -import { isRecaptchaTokenValid } from '~/lib/recaptcha'; +import { isTurnstileTokenValid } from '~/lib/turnstile'; import { isSpam } from '~/lib/spam'; import { createLead, @@ -30,15 +30,17 @@ export default defineAction({ useCase: z.string(), body: z.string(), issueType: z.enum(['sales', 'enterprise']).optional(), - token: z.string(), + // Empty when no Turnstile sitekey is configured for the environment; the + // verifier decides whether that is acceptable. + token: z.string().optional(), }), handler: async ({ token, ...input }, { request, cookies }) => { try { - // Step 1: Validate reCAPTCHA token - if (!(await isRecaptchaTokenValid(token))) { + // Step 1: Validate Turnstile token + if (!(await isTurnstileTokenValid(token, { action: 'contact' }))) { throw new ActionError({ code: 'UNAUTHORIZED', - message: 'Invalid recaptcha token', + message: 'Invalid anti-bot token', }); } diff --git a/src/actions/forms/submitSupportRequest.ts b/src/actions/forms/submitSupportRequest.ts index f2fbf5f9..2f6203f1 100644 --- a/src/actions/forms/submitSupportRequest.ts +++ b/src/actions/forms/submitSupportRequest.ts @@ -3,7 +3,7 @@ import { FRONT_CHANNEL_URL_SUPPORT } from 'astro:env/server'; import { z } from 'astro:schema'; import { sendToFrontChannel } from '~/lib/front'; import { logErrorToRollbar } from '~/lib/logToRollbar'; -import { isRecaptchaTokenValid } from '~/lib/recaptcha'; +import { isTurnstileTokenValid } from '~/lib/turnstile'; import { isSpam } from '~/lib/spam'; export default defineAction({ @@ -16,15 +16,17 @@ export default defineAction({ errorId: z.string().optional(), uploads: z.instanceof(File).array().optional(), issueType: z.string().optional(), - token: z.string(), + // Empty when no Turnstile sitekey is configured for the environment; the + // verifier decides whether that is acceptable. + token: z.string().optional(), }), handler: async ({ token, ...input }) => { try { - // Step 1: Validate reCAPTCHA token - if (!(await isRecaptchaTokenValid(token))) { + // Step 1: Validate Turnstile token + if (!(await isTurnstileTokenValid(token, { action: 'support' }))) { throw new ActionError({ code: 'UNAUTHORIZED', - message: 'Invalid recaptcha token', + message: 'Invalid anti-bot token', }); } diff --git a/src/actions/inviteEmailToSlackChannel/index.ts b/src/actions/inviteEmailToSlackChannel/index.ts index 3782f550..3a2aea2b 100644 --- a/src/actions/inviteEmailToSlackChannel/index.ts +++ b/src/actions/inviteEmailToSlackChannel/index.ts @@ -3,7 +3,7 @@ import { ActionError, defineAction } from 'astro:actions'; import { SLACK_TOKEN } from 'astro:env/server'; import { z } from 'astro:schema'; import { logErrorToRollbar } from '~/lib/logToRollbar'; -import { isRecaptchaTokenValid } from '~/lib/recaptcha'; +import { isTurnstileTokenValid } from '~/lib/turnstile'; interface ErrorResponse { data: { @@ -35,15 +35,17 @@ export default defineAction({ email: z .string({ invalid_type_error: 'Please, enter your email! 😊' }) .email('Please, enter a valid email! 😊'), - token: z.string(), + // Empty when no Turnstile sitekey is configured for the environment; the + // verifier decides whether that is acceptable. + token: z.string().optional(), }), handler: async (input) => { const { email, token } = input; - if (!(await isRecaptchaTokenValid(token))) { + if (!(await isTurnstileTokenValid(token, { action: 'slack-invite' }))) { throw new ActionError({ code: 'UNAUTHORIZED', - message: 'Invalid recaptcha token', + message: 'Invalid anti-bot token', }); } diff --git a/src/actions/sendFeedbackAboutDocPage/index.ts b/src/actions/sendFeedbackAboutDocPage/index.ts index 4461df92..a56d6b91 100644 --- a/src/actions/sendFeedbackAboutDocPage/index.ts +++ b/src/actions/sendFeedbackAboutDocPage/index.ts @@ -2,7 +2,7 @@ import { ActionError, defineAction } from 'astro:actions'; import { z } from 'astro:schema'; import buildBasecampClient from '~/lib/basecamp'; import { logErrorToRollbar } from '~/lib/logToRollbar'; -import { isRecaptchaTokenValid } from '~/lib/recaptcha'; +import { isTurnstileTokenValid } from '~/lib/turnstile'; const SUPPORT_TEAM = 33592869; const TRIAGE_COLUMN = 9255769099; @@ -15,16 +15,18 @@ export default defineAction({ reaction: z.enum(['positive', 'negative']), notes: z.string().optional(), email: z.string().email().optional(), - token: z.string(), + // Empty when no Turnstile sitekey is configured for the environment; the + // verifier decides whether that is acceptable. + token: z.string().optional(), }), handler: async (input) => { const { url, reaction, notes, email, token } = input; try { - if (!(await isRecaptchaTokenValid(token))) { + if (!(await isTurnstileTokenValid(token, { action: 'docs-feedback' }))) { throw new ActionError({ code: 'UNAUTHORIZED', - message: 'Invalid recaptcha token', + message: 'Invalid anti-bot token', }); } diff --git a/src/actions/subscribeToNewsletter/index.ts b/src/actions/subscribeToNewsletter/index.ts index 4f514cc2..29f9d15d 100644 --- a/src/actions/subscribeToNewsletter/index.ts +++ b/src/actions/subscribeToNewsletter/index.ts @@ -4,7 +4,7 @@ import { MAILERLITE_TOKEN } from 'astro:env/server'; import { z } from 'astro:schema'; import { format } from 'date-fns'; import { logErrorToRollbar } from '~/lib/logToRollbar'; -import { isRecaptchaTokenValid } from '~/lib/recaptcha'; +import { isTurnstileTokenValid } from '~/lib/turnstile'; export default defineAction({ accept: 'form', @@ -12,16 +12,18 @@ export default defineAction({ email: z .string({ invalid_type_error: 'Please, enter your email! 😊' }) .email('Please, enter a valid email! 😊'), - token: z.string(), + // Empty when no Turnstile sitekey is configured for the environment; the + // verifier decides whether that is acceptable. + token: z.string().optional(), }), handler: async (input) => { const { email, token } = input; try { - if (!(await isRecaptchaTokenValid(token))) { + if (!(await isTurnstileTokenValid(token, { action: 'newsletter' }))) { throw new ActionError({ code: 'UNAUTHORIZED', - message: 'Invalid recaptcha token', + message: 'Invalid anti-bot token', }); } diff --git a/src/components/form/Form/ReactComponent.tsx b/src/components/form/Form/ReactComponent.tsx index bc5aa745..ee5589cd 100644 --- a/src/components/form/Form/ReactComponent.tsx +++ b/src/components/form/Form/ReactComponent.tsx @@ -7,18 +7,22 @@ import { type FieldValues, type SubmitHandler, } from 'react-hook-form'; -import { useRecaptcha } from 'react-recaptcha-hook'; import toast, { Toaster } from 'react-hot-toast'; import s from './style.module.css'; -import { RECAPTCHA_KEY } from 'astro:env/client'; import { ButtonReactComponent } from '~/components/Button/ReactComponent'; import { isActionError, isInputError } from 'astro:actions'; +import { TurnstileError, useTurnstile } from './useTurnstile'; type Props = { children: ReactNode; defaultValues?: DefaultValues; submitLabel?: ReactNode; onSubmit?: (data: FormData) => Promise; + /** + * Label of this form in the Cloudflare Turnstile analytics, ie. `contact`. + * The action verifying the token must expect the same value. + */ + turnstileAction: string; }; export function FormReactComponent({ @@ -26,12 +30,9 @@ export function FormReactComponent) { - const execute = useRecaptcha({ - // must be v3 Recaptcha! - sitekey: RECAPTCHA_KEY, - hideDefaultBadge: true, - }); + const turnstile = useTurnstile(turnstileAction); const methods = useForm({ defaultValues: defaultValues, @@ -42,10 +43,10 @@ export function FormReactComponent = async (_values, event) => { event?.preventDefault(); - const token = await execute('form'); - if (onSubmit && event?.target) { try { + const token = await turnstile.getToken(); + // Create FormData from the native form element const formData = new FormData(event.target as HTMLFormElement); formData.append('token', token); @@ -58,7 +59,7 @@ export function FormReactComponent {children} + {/* Invisible unless Cloudflare needs the visitor to click a checkbox */} +
+
By submitting you agree to our TOS and acknowledge our{' '} diff --git a/src/components/form/Form/style.module.css b/src/components/form/Form/style.module.css index cdedf066..19ce7a99 100644 --- a/src/components/form/Form/style.module.css +++ b/src/components/form/Form/style.module.css @@ -31,3 +31,11 @@ .toastNotification { font-size: 16px; } + +.turnstile { + padding: 0 29px; + + &:not(:empty) { + padding-top: 20px; + } +} diff --git a/src/components/form/Form/useTurnstile.ts b/src/components/form/Form/useTurnstile.ts new file mode 100644 index 00000000..606551bb --- /dev/null +++ b/src/components/form/Form/useTurnstile.ts @@ -0,0 +1,141 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { loadTurnstile, turnstileSiteKey } from '~/lib/loadTurnstile'; + +export class TurnstileError extends Error {} + +/** + * Mounts an invisible Cloudflare Turnstile widget in `containerRef` and hands + * out a `getToken()` that runs the challenge on demand — the same shape the + * reCAPTCHA v3 flow had: nothing is visible, and the token is produced right + * when the form is submitted. + * + * The widget is rendered with `appearance: 'interaction-only'`, so the only + * time the visitor sees anything is when Cloudflare isn't sure about them and + * asks for a click. That is why the container has to sit inside the form + * layout rather than off-screen. + * + * Tokens are single-use and expire after five minutes, so every `getToken()` + * call resets the widget and solves a fresh one. Don't cache the result. + * + * When no sitekey is configured the hook degrades to "no challenge" and + * resolves with an empty token: the server side is what enforces the check. + */ +export function useTurnstile(action: string) { + const containerRef = useRef(null); + const widgetIdRef = useRef(undefined); + const pendingRef = useRef< + { resolve: (token: string) => void; reject: (error: Error) => void } | undefined + >(undefined); + + const settle = useCallback((outcome: { token: string } | { error: Error }) => { + const pending = pendingRef.current; + pendingRef.current = undefined; + + if (!pending) { + return; + } + + if ('token' in outcome) { + pending.resolve(outcome.token); + } else { + pending.reject(outcome.error); + } + }, []); + + useEffect(() => { + const container = containerRef.current; + const sitekey = turnstileSiteKey; + + if (!(sitekey && container)) { + return; + } + + let cancelled = false; + + loadTurnstile() + .then((turnstile) => { + if (cancelled) { + return; + } + + widgetIdRef.current = turnstile.render(container, { + sitekey, + action, + theme: 'light', + size: 'flexible', + execution: 'execute', + appearance: 'interaction-only', + callback(token) { + settle({ token }); + }, + 'error-callback'(errorCode) { + settle({ + error: new TurnstileError( + `The anti-bot check failed (${errorCode}). Please reload the page and try again.`, + ), + }); + return true; + }, + 'expired-callback'() { + // Only relevant when a token is held for a while: `getToken()` + // always resets before solving, so there's nothing to do. + }, + 'timeout-callback'() { + settle({ + error: new TurnstileError( + 'The anti-bot check timed out. Please submit the form again.', + ), + }); + }, + }); + }) + .catch(() => { + // Surfaced later by `getToken()`, at the moment the visitor submits. + }); + + return () => { + cancelled = true; + + if (widgetIdRef.current) { + window.turnstile?.remove(widgetIdRef.current); + widgetIdRef.current = undefined; + } + }; + }, [action, settle]); + + const getToken = useCallback(async (): Promise => { + if (!turnstileSiteKey) { + return ''; + } + + let turnstile; + + try { + turnstile = await loadTurnstile(); + } catch { + throw new TurnstileError( + "The anti-bot check couldn't be loaded. Please check your connection and reload the page.", + ); + } + + const widgetId = widgetIdRef.current; + const container = containerRef.current; + + if (!(widgetId && container)) { + throw new TurnstileError('The anti-bot check is not ready yet. Please try again.'); + } + + return new Promise((resolve, reject) => { + // A submit racing an earlier one supersedes it. + settle({ error: new TurnstileError('Superseded by a newer submission') }); + pendingRef.current = { resolve, reject }; + + // Per the Cloudflare docs, `execute` addresses the container while the + // other methods take the widget ID. + turnstile.reset(widgetId); + turnstile.execute(container); + }); + }, [settle]); + + return { containerRef, getToken }; +} diff --git a/src/layouts/Layout/Footer/NewsletterForm.astro b/src/layouts/Layout/Footer/NewsletterForm.astro index fab428c1..1ca32a71 100644 --- a/src/layouts/Layout/Footer/NewsletterForm.astro +++ b/src/layouts/Layout/Footer/NewsletterForm.astro @@ -19,6 +19,7 @@ import { Button } from '~/components/Button'; +
@@ -30,8 +31,7 @@ import { Button } from '~/components/Button'; import { actions, isActionError } from 'astro:actions'; import { isInputError } from 'astro:actions'; import { WebComponent } from '~/lib/WebComponent'; - import { loadRecaptcha } from '~/lib/loadRecaptcha'; - import { RECAPTCHA_KEY } from 'astro:env/client'; + import { solveTurnstile } from '~/lib/loadTurnstile'; window.customElements.define( 'subscribe-newsletter', @@ -54,9 +54,16 @@ import { Button } from '~/components/Button'; this.button.innerText = 'Submitting...'; this.button.disabled = true; - const client = await loadRecaptcha(); + let token: string; - const token = await client.execute(RECAPTCHA_KEY, { action: 'subscribeToNewsletter' }); + try { + token = await solveTurnstile(this.$('[data-turnstile]'), 'newsletter'); + } catch (e) { + status.innerHTML = e instanceof Error ? e.message : 'Mh! Something went wrong...'; + this.button.innerText = 'Subscribe!'; + this.button.disabled = false; + return; + } const formData = new FormData(this.form); formData.set('token', token); diff --git a/src/layouts/Layout/Footer/style.module.css b/src/layouts/Layout/Footer/style.module.css index 82f20c4f..197c9099 100644 --- a/src/layouts/Layout/Footer/style.module.css +++ b/src/layouts/Layout/Footer/style.module.css @@ -276,3 +276,8 @@ text-transform: uppercase; vertical-align: middle; } + +/* Turnstile widget: empty unless Cloudflare needs the visitor to click */ +.turnstile:not(:empty) { + margin-top: 10px; +} diff --git a/src/layouts/docs/BaseLayout/Feedback/Component.astro b/src/layouts/docs/BaseLayout/Feedback/Component.astro index 684ba725..ea34abf8 100644 --- a/src/layouts/docs/BaseLayout/Feedback/Component.astro +++ b/src/layouts/docs/BaseLayout/Feedback/Component.astro @@ -45,6 +45,8 @@ import { Button } from '~/components/Button'; If you need a reply, please contact support instead.

+
+
@@ -53,9 +55,8 @@ import { Button } from '~/components/Button'; import { actions } from 'astro:actions'; import { isInputError } from 'astro:actions'; import { WebComponent } from '~/lib/WebComponent'; - import { loadRecaptcha } from '~/lib/loadRecaptcha'; + import { solveTurnstile } from '~/lib/loadTurnstile'; import { isActionError } from 'astro:actions'; - import { RECAPTCHA_KEY } from 'astro:env/client'; window.customElements.define( 'share-feedback', @@ -76,9 +77,14 @@ import { Button } from '~/components/Button'; this.button.innerText = 'Submitting...'; this.button.disabled = true; - const client = await loadRecaptcha(); + let token: string; - const token = await client.execute(RECAPTCHA_KEY, { action: 'sendFeedbackAboutDocPage' }); + try { + token = await solveTurnstile(this.$('[data-turnstile]'), 'docs-feedback'); + } catch (e) { + this.form.innerHTML = e instanceof Error ? e.message : 'Mh! Something went wrong...'; + return; + } const formData = new FormData(this.form); formData.set('token', token); diff --git a/src/layouts/docs/BaseLayout/Feedback/style.module.css b/src/layouts/docs/BaseLayout/Feedback/style.module.css index 52ca6ffa..0bd1b688 100644 --- a/src/layouts/docs/BaseLayout/Feedback/style.module.css +++ b/src/layouts/docs/BaseLayout/Feedback/style.module.css @@ -88,3 +88,8 @@ color: inherit; } } + +/* Turnstile widget: empty unless Cloudflare needs the visitor to click */ +.turnstile:not(:empty) { + margin-bottom: 15px; +} diff --git a/src/lib/loadRecaptcha.ts b/src/lib/loadRecaptcha.ts deleted file mode 100644 index 5af0a98d..00000000 --- a/src/lib/loadRecaptcha.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { RECAPTCHA_KEY } from 'astro:env/client'; - -interface Grecaptcha { - ready: (callback: () => void) => void; - execute: (siteKey: string, options: { action: string }) => Promise; -} - -export function loadRecaptcha(): Promise { - return new Promise((resolve, reject) => { - if (!('grecaptcha' in window)) { - // Define a global configuration if it doesn't exist - const globalConfig = '___grecaptcha_cfg'; - - (window as any)[globalConfig] = (window as any)[globalConfig] || {}; - - const fns = ((window as any)[globalConfig]['fns'] = - (window as any)[globalConfig]['fns'] || []); - - // Enqueue the callback - fns.push(() => { - resolve((window as any).grecaptcha); - }); - - // Dynamically load the reCAPTCHA script - const script = document.createElement('script'); - script.src = `https://www.google.com/recaptcha/api.js?render=${RECAPTCHA_KEY}`; - script.async = true; - script.onerror = () => reject(new Error('Failed to load the reCAPTCHA script.')); - - // Append the script to the document - document.head.appendChild(script); - return; - } - - const grecaptcha = window.grecaptcha as Grecaptcha; - - // Check if grecaptcha is already defined - grecaptcha.ready(() => resolve(grecaptcha)); - }); -} diff --git a/src/lib/loadTurnstile.ts b/src/lib/loadTurnstile.ts new file mode 100644 index 00000000..ea968147 --- /dev/null +++ b/src/lib/loadTurnstile.ts @@ -0,0 +1,156 @@ +/** + * Cloudflare Turnstile — script loader and typings (browser side). + * + * https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/ + * + * `api.js` is loaded with `render=explicit`, so nothing happens until a form + * asks for a widget: the script is fetched lazily on pages that host a form, + * not on every page load. + * + * A solved widget proves nothing on its own: the token is only meaningful once + * the action has verified it server-side (see `~/lib/turnstile`). Everything + * in here is UX, not a security boundary. + */ + +import { TURNSTILE_SITE_KEY } from 'astro:env/client'; + +export type TurnstileRenderOptions = { + sitekey: string; + /** Custom label surfaced in the Turnstile analytics dashboard, ie. `contact`. */ + action?: string; + theme?: 'auto' | 'light' | 'dark'; + size?: 'normal' | 'flexible' | 'compact'; + /** `execute` defers the challenge until `turnstile.execute()` is called. */ + execution?: 'render' | 'execute'; + /** `interaction-only` keeps the widget invisible unless the visitor must act. */ + appearance?: 'always' | 'execute' | 'interaction-only'; + /** Invoked with the token once the challenge is solved. */ + callback?: (token: string) => void; + /** + * Return `true` to tell Turnstile the error was handled — otherwise it also + * logs a warning with the error code to the console. + */ + 'error-callback'?: (errorCode: string) => boolean; + 'expired-callback'?: () => void; + 'timeout-callback'?: () => void; +}; + +export type TurnstileApi = { + render: (container: string | HTMLElement, options: TurnstileRenderOptions) => string; + execute: (container: string | HTMLElement, options?: TurnstileRenderOptions) => void; + reset: (widgetId?: string) => void; + remove: (widgetId?: string) => void; + getResponse: (widgetId?: string) => string | undefined; + isExpired: (widgetId?: string) => boolean; +}; + +declare global { + interface Window { + turnstile?: TurnstileApi; + __onTurnstileLoad?: () => void; + } +} + +/** + * Cloudflare's "always passes" dummy sitekey. It is accepted on any domain, + * localhost included, so the widget stays functional in development without + * provisioning real keys. See + * https://developers.cloudflare.com/turnstile/troubleshooting/testing/ + */ +const TEST_SITE_KEY = '1x00000000000000000000AA'; + +/** + * `undefined` when no sitekey is configured for the environment: callers are + * expected to skip the challenge rather than block submissions on a missing + * env var. The server side is what actually enforces the check. + */ +export const turnstileSiteKey: string | undefined = + TURNSTILE_SITE_KEY || (import.meta.env.DEV ? TEST_SITE_KEY : undefined); + +const SCRIPT_URL = + 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=__onTurnstileLoad'; + +let pendingLoad: Promise | undefined; + +export function loadTurnstile(): Promise { + if (window.turnstile) { + return Promise.resolve(window.turnstile); + } + + if (pendingLoad) { + return pendingLoad; + } + + pendingLoad = new Promise((resolve, reject) => { + const script = document.createElement('script'); + + window.__onTurnstileLoad = () => { + // The onload callback only fires once `window.turnstile` is populated. + resolve(window.turnstile!); + }; + + script.onerror = () => { + // Let a later attempt retry: a blocked request or a flaky network + // shouldn't poison the module for the rest of the session. + pendingLoad = undefined; + script.remove(); + reject(new Error('Could not load the Cloudflare Turnstile script')); + }; + + script.src = SCRIPT_URL; + script.async = true; + script.defer = true; + + document.head.appendChild(script); + }); + + return pendingLoad; +} + +const widgetsByContainer = new WeakMap(); + +/** + * One-shot helper for the non-React forms: renders an invisible + * (`interaction-only`) widget inside `container`, runs the challenge and + * resolves with the token. Any widget left in the container by a previous + * call is torn down first, since tokens are single-use. + * + * Resolves with `''` when no sitekey is configured for the environment. + */ +export async function solveTurnstile(container: HTMLElement, action: string): Promise { + const sitekey = turnstileSiteKey; + + if (!sitekey) { + return ''; + } + + const turnstile = await loadTurnstile(); + + return new Promise((resolve, reject) => { + const previous = widgetsByContainer.get(container); + + if (previous) { + turnstile.remove(previous); + } + + const widgetId = turnstile.render(container, { + sitekey, + action, + theme: 'light', + size: 'flexible', + execution: 'execute', + appearance: 'interaction-only', + callback: resolve, + 'error-callback'(errorCode) { + reject(new Error(`The anti-bot check failed (${errorCode}). Please reload and try again.`)); + return true; + }, + 'timeout-callback'() { + reject(new Error('The anti-bot check timed out. Please try again.')); + }, + }); + + widgetsByContainer.set(container, widgetId); + turnstile.execute(container); + }); +} diff --git a/src/lib/recaptcha.ts b/src/lib/recaptcha.ts deleted file mode 100644 index 6a2ac85e..00000000 --- a/src/lib/recaptcha.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { RECAPTCHA_SECRET_KEY } from 'astro:env/server'; -import ky from 'ky'; - -export async function isRecaptchaTokenValid(token: string) { - const result = await ky - .post<{ success: boolean }>('https://www.google.com/recaptcha/api/siteverify', { - body: new URLSearchParams({ - secret: RECAPTCHA_SECRET_KEY, - response: token, - }), - }) - .json(); - - return result.success; -} diff --git a/src/lib/turnstile.ts b/src/lib/turnstile.ts new file mode 100644 index 00000000..4f217813 --- /dev/null +++ b/src/lib/turnstile.ts @@ -0,0 +1,105 @@ +/** + * Server-side validation of Cloudflare Turnstile tokens. + * + * https://developers.cloudflare.com/turnstile/get-started/server-side-validation/ + * + * Mirrors the policy of `Cloudflare::Turnstile` + `VerifiesCaptcha` in the API: + * + * - no secret key configured → the check is off for that environment; + * - Cloudflare rejects the token → the submission is rejected; + * - Cloudflare cannot be reached, or reports an `internal-error` → we fail + * *open* and log it, so an outage on challenges.cloudflare.com doesn't take + * the forms down with it. + */ + +import { TURNSTILE_SECRET_KEY } from 'astro:env/server'; +import ky from 'ky'; +import logToRollbar from './logToRollbar'; + +const SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; + +/** + * Cloudflare's "always passes" dummy secret, the counterpart of the test + * sitekey used by `~/lib/loadTurnstile` in development. + */ +const TEST_SECRET_KEY = '1x0000000000000000000000000000000AA'; + +// Turnstile tokens are never longer than this, so anything bigger is junk we +// can reject without bothering Cloudflare. +const MAX_TOKEN_LENGTH = 2048; + +type SiteverifyResponse = { + success: boolean; + 'error-codes'?: string[]; + action?: string; + hostname?: string; + challenge_ts?: string; + metadata?: { result_with_testing_key?: boolean }; +}; + +type Options = { + /** + * The `action` the widget was rendered with. A token solved for a different + * form (or none) is rejected, so a token harvested from one page can't be + * replayed on another. + */ + action: string; +}; + +function secretKey(): string | undefined { + return TURNSTILE_SECRET_KEY || (import.meta.env.DEV ? TEST_SECRET_KEY : undefined); +} + +export async function isTurnstileTokenValid( + token: string | undefined, + { action }: Options, +): Promise { + const secret = secretKey(); + + if (!secret) { + return true; + } + + if (!token || token.length > MAX_TOKEN_LENGTH) { + return false; + } + + let result: SiteverifyResponse; + + try { + result = await ky + .post(SITEVERIFY_URL, { + body: new URLSearchParams({ secret, response: token }), + }) + .json(); + } catch (e) { + logToRollbar(e, { context: { action: 'turnstile.verify', formAction: action } }); + return true; + } + + const errorCodes = result['error-codes'] ?? []; + + // `internal-error` is Cloudflare telling us it broke, not the visitor + // failing the challenge. + if (errorCodes.includes('internal-error')) { + logToRollbar('Turnstile verification unavailable, letting submission through', { + context: { action: 'turnstile.verify', formAction: action, errorCodes }, + }); + return true; + } + + if (!result.success) { + return false; + } + + // Cloudflare's dummy testing keys never echo the action back, so the check + // would reject every submission made in development. + if (!result.metadata?.result_with_testing_key && result.action !== action) { + logToRollbar('Turnstile token solved for a different action', { + context: { action: 'turnstile.verify', formAction: action, tokenAction: result.action }, + }); + return false; + } + + return true; +} diff --git a/src/pages/contact/_Form/index.tsx b/src/pages/contact/_Form/index.tsx index f1ade80f..f8a0f006 100644 --- a/src/pages/contact/_Form/index.tsx +++ b/src/pages/contact/_Form/index.tsx @@ -39,6 +39,7 @@ export function Form({ issueType }: { issueType: 'sales' | 'enterprise' }) { return (
{ diff --git a/src/pages/lp/migcs/_Form/index.tsx b/src/pages/lp/migcs/_Form/index.tsx index 4da3223b..ff3766d4 100644 --- a/src/pages/lp/migcs/_Form/index.tsx +++ b/src/pages/lp/migcs/_Form/index.tsx @@ -43,6 +43,7 @@ export function Form() { return (
{ diff --git a/src/pages/lp/modernize-cms/_Form/index.tsx b/src/pages/lp/modernize-cms/_Form/index.tsx index 6ebe4703..29b3cd90 100644 --- a/src/pages/lp/modernize-cms/_Form/index.tsx +++ b/src/pages/lp/modernize-cms/_Form/index.tsx @@ -43,6 +43,7 @@ export function Form() { return (
{ diff --git a/src/pages/lp/nuxt2eol/_Form/index.tsx b/src/pages/lp/nuxt2eol/_Form/index.tsx index 97a8abae..f9e6f840 100644 --- a/src/pages/lp/nuxt2eol/_Form/index.tsx +++ b/src/pages/lp/nuxt2eol/_Form/index.tsx @@ -44,6 +44,7 @@ export function Form() { return (
{ diff --git a/src/pages/partner-program/_Form/index.tsx b/src/pages/partner-program/_Form/index.tsx index 4d114726..2df22e4f 100644 --- a/src/pages/partner-program/_Form/index.tsx +++ b/src/pages/partner-program/_Form/index.tsx @@ -35,6 +35,7 @@ export function Form() { return (
{ diff --git a/src/pages/slack/_Form/index.tsx b/src/pages/slack/_Form/index.tsx index fe2adce0..919a95cd 100644 --- a/src/pages/slack/_Form/index.tsx +++ b/src/pages/slack/_Form/index.tsx @@ -6,6 +6,7 @@ import { navigate } from 'astro:transitions/client'; export function Form() { return ( { diff --git a/src/pages/support/_Form/index.tsx b/src/pages/support/_Form/index.tsx index d35eeea5..d2e8cdf9 100644 --- a/src/pages/support/_Form/index.tsx +++ b/src/pages/support/_Form/index.tsx @@ -34,6 +34,7 @@ export function Form({ initialValues = {} }: Props) { return (
{