From d6dbe5f557baae59f0ab8bb27c1fe4da10903fee Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sun, 24 May 2026 22:17:47 +0200 Subject: [PATCH 1/5] feat: replace reCAPTCHA with Cloudflare Turnstile for human verification --- .env.docker.example | 9 +- .env.example | 7 +- .env.test.example | 7 +- docs/DEVELOPMENT_SETUP.md | 2 +- eslint.config.mjs | 16 + next.config.ts | 15 - package.json | 1 - pnpm-lock.yaml | 22 -- .../listings/[id]/components/CommentForm.tsx | 9 +- .../[id]/components/CommentThread.tsx | 9 +- .../listings/[id]/components/VoteButtons.tsx | 8 - .../shared/details/utils/logVoteError.test.ts | 2 +- src/app/listings/new/NewListingPage.tsx | 28 +- .../[id]/components/PcCommentForm.tsx | 8 +- .../[id]/components/PcCommentThread.tsx | 8 +- .../[id]/components/PcVoteButtons.tsx | 10 - src/app/pc-listings/new/NewPcListingPage.tsx | 22 +- src/components/Providers.tsx | 27 +- .../comments/GenericCommentForm.tsx | 27 +- .../comments/GenericCommentThread.tsx | 1 - .../client/HumanVerificationProvider.tsx | 176 +++++++++++ .../getHumanVerificationRequest.test.ts | 27 ++ .../client/getHumanVerificationRequest.ts | 36 +++ .../human-verification/client/index.ts | 4 + .../client/useSubmitWithHumanVerification.ts | 24 ++ .../server/providers/turnstile.test.ts | 83 +++++ .../server/providers/turnstile.ts | 69 +++++ .../human-verification/shared/constants.ts | 17 + src/lib/app-error-cause.ts | 6 + src/lib/captcha/config.ts | 42 --- src/lib/captcha/hooks.ts | 86 ------ src/lib/captcha/verify.test.ts | 118 ------- src/lib/captcha/verify.ts | 135 -------- src/lib/errors.ts | 43 ++- src/lib/trpc-client-errors.test.ts | 1 + src/schemas/listing.test.ts | 7 +- src/schemas/listing.ts | 6 +- src/schemas/mobile.ts | 3 + src/schemas/pcListing.ts | 5 +- src/server/api/mobileContext.ts | 2 + .../api/routers/listings/comments.test.ts | 49 ++- src/server/api/routers/listings/comments.ts | 29 +- src/server/api/routers/listings/core.test.ts | 43 +-- src/server/api/routers/listings/core.ts | 40 +-- src/server/api/routers/mobile/listings.ts | 2 +- src/server/api/routers/mobile/pcListings.ts | 8 + src/server/api/routers/pcListings.test.ts | 155 ++++------ src/server/api/routers/pcListings.ts | 52 ++-- src/server/api/trpc.ts | 7 +- src/server/utils/spam-check.test.ts | 163 +++++++++- src/server/utils/spam-check.ts | 47 ++- src/server/utils/spamDetection.test.ts | 173 ++++++----- src/server/utils/spamDetection.ts | 292 ++++++++++++------ 53 files changed, 1240 insertions(+), 948 deletions(-) create mode 100644 src/features/human-verification/client/HumanVerificationProvider.tsx create mode 100644 src/features/human-verification/client/getHumanVerificationRequest.test.ts create mode 100644 src/features/human-verification/client/getHumanVerificationRequest.ts create mode 100644 src/features/human-verification/client/index.ts create mode 100644 src/features/human-verification/client/useSubmitWithHumanVerification.ts create mode 100644 src/features/human-verification/server/providers/turnstile.test.ts create mode 100644 src/features/human-verification/server/providers/turnstile.ts create mode 100644 src/features/human-verification/shared/constants.ts create mode 100644 src/lib/app-error-cause.ts delete mode 100644 src/lib/captcha/config.ts delete mode 100644 src/lib/captcha/hooks.ts delete mode 100644 src/lib/captcha/verify.test.ts delete mode 100644 src/lib/captcha/verify.ts diff --git a/.env.docker.example b/.env.docker.example index 70841edd0..9223417d4 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -35,12 +35,11 @@ THE_GAMES_DB_API_KEY="your_tgdb_api_key_here" TUNNEL_TOKEN="your_cloudflare_tunnel_token_here" # =========================================== -# RECAPTCHA (Optional) +# CLOUDFLARE TURNSTILE (Optional) # =========================================== -# Google reCAPTCHA for form protection -NEXT_PUBLIC_RECAPTCHA_SITE_KEY="your_recaptcha_site_key_here" -RECAPTCHA_SECRET_KEY="your_recaptcha_secret_key_here" -NEXT_PUBLIC_DISABLE_RECAPTCHA="false" +# Human verification +NEXT_PUBLIC_TURNSTILE_SITE_KEY="your_turnstile_site_key_here" +TURNSTILE_SECRET_KEY="your_turnstile_secret_key_here" # =========================================== # EMAIL SERVICES (Optional) diff --git a/.env.example b/.env.example index 5efdaeff9..592c1934d 100644 --- a/.env.example +++ b/.env.example @@ -16,10 +16,9 @@ NEXT_PUBLIC_THE_GAMES_DB_API_KEY="The-Games-DB-Public-API-KEY" NEXT_PUBLIC_IGDB_CLIENT_ID="IGDB-Client-ID" IGDB_CLIENT_KEY="IGDB-Client-Secret" -# reCAPTCHA v3 -NEXT_PUBLIC_RECAPTCHA_SITE_KEY=site-key-here -RECAPTCHA_SECRET_KEY=secret-key-here -NEXT_PUBLIC_DISABLE_RECAPTCHA=false +# Cloudflare Turnstile +NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4XXXXXXXXXXXXXXXXXXXXX +TURNSTILE_SECRET_KEY=0x4XXXXXXXXXXXXXXXXXXXXXXXX-XXXXXXX # Email Providers (For the future) EMAIL_ENABLED=false diff --git a/.env.test.example b/.env.test.example index ff39873e6..8f23094b8 100644 --- a/.env.test.example +++ b/.env.test.example @@ -6,10 +6,9 @@ DATABASE_DIRECT_URL="postgres://postgres:pooler.supabase.com:5432/postgres" NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="pk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" CLERK_SECRET_KEY="sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" -# reCAPTCHA V3 -NEXT_PUBLIC_RECAPTCHA_SITE_KEY= -RECAPTCHA_SECRET_KEY= -NEXT_PUBLIC_DISABLE_RECAPTCHA=false +# Cloudflare Turnstile +NEXT_PUBLIC_TURNSTILE_SITE_KEY= +TURNSTILE_SECRET_KEY= # Email (For the future) EMAIL_ENABLED=false diff --git a/docs/DEVELOPMENT_SETUP.md b/docs/DEVELOPMENT_SETUP.md index a2d2f52ae..34f096c90 100644 --- a/docs/DEVELOPMENT_SETUP.md +++ b/docs/DEVELOPMENT_SETUP.md @@ -36,7 +36,7 @@ CLERK_SECRET_KEY=... NEXT_PUBLIC_APP_URL=http://localhost:3000 ``` -External provider keys such as `RAWG_API_KEY`, `THE_GAMES_DB_API_KEY`, and reCAPTCHA keys are only needed for the features that call those services. +External provider keys such as `RAWG_API_KEY`, `THE_GAMES_DB_API_KEY`, and Cloudflare Turnstile keys are only needed for the features that call those services. ## Troubleshooting diff --git a/eslint.config.mjs b/eslint.config.mjs index 235e19c2f..64b1f9ba1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,9 +1,24 @@ +import { existsSync, readdirSync } from 'node:fs' + import typescriptEslint from '@typescript-eslint/eslint-plugin' import typescriptParser from '@typescript-eslint/parser' import eslintConfigPrettier from 'eslint-config-prettier' import nextCoreWebVitals from 'eslint-config-next/core-web-vitals' import nextTypeScript from 'eslint-config-next/typescript' +const featureNames = existsSync('./src/features') + ? readdirSync('./src/features', { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + : [] + +const featureBoundaryZones = featureNames.map((featureName) => ({ + target: `./src/features/${featureName}`, + from: './src/features', + except: [`./${featureName}`], + message: 'Features must not import from other features. Compose features at the route layer.', +})) + const eslintConfig = [ { ignores: [ @@ -137,6 +152,7 @@ const eslintConfig = [ tsx: 'never', }, ], + 'import/no-restricted-paths': ['error', { zones: featureBoundaryZones }], }, }, // UI component import cycle override { diff --git a/next.config.ts b/next.config.ts index 9dff5afa0..c043f273e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,18 +5,6 @@ import type { Configuration as WebpackConfiguration } from 'webpack' const isVercelBuild = process.env.VERCEL === '1' -const recaptchaScriptSources = [ - 'https://www.google.com/recaptcha/', - 'https://www.gstatic.com/recaptcha/', -] - -const recaptchaConnectSources = ['https://www.google.com/recaptcha/'] - -const recaptchaFrameSources = [ - 'https://www.google.com/recaptcha/', - 'https://recaptcha.google.com/recaptcha/', -] - const contentSecurityPolicyDirectives = [ { name: 'default-src', @@ -40,7 +28,6 @@ const contentSecurityPolicyDirectives = [ 'https://storage.ko-fi.com', 'https://ko-fi.com', 'https://unpkg.com', - ...recaptchaScriptSources, ], }, { @@ -117,7 +104,6 @@ const contentSecurityPolicyDirectives = [ 'https://*.r2.cloudflarestorage.com', 'https://cdn.emuready.com', 'https://retrocatalog.com', - ...recaptchaConnectSources, ], }, { @@ -132,7 +118,6 @@ const contentSecurityPolicyDirectives = [ 'https://vercel.live', 'https://*.vercel.live', 'https://ko-fi.com', - ...recaptchaFrameSources, ], }, { diff --git a/package.json b/package.json index 3005b00cb..c6b2216e6 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,6 @@ "react": "19.2.6", "react-dom": "19.2.6", "react-error-boundary": "^6.0.0", - "react-google-recaptcha-v3": "^1.11.0", "react-hook-form": "^7.56.4", "react-syntax-highlighter": "^15.6.6", "remeda": "^2.22.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f77ee367..079b3450a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -173,9 +173,6 @@ importers: react-error-boundary: specifier: ^6.0.0 version: 6.0.0(react@19.2.6) - react-google-recaptcha-v3: - specifier: ^1.11.0 - version: 1.11.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react-hook-form: specifier: ^7.56.4 version: 7.58.1(react@19.2.6) @@ -5518,9 +5515,6 @@ packages: highlightjs-vue@1.0.0: resolution: {integrity: sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==} - hoist-non-react-statics@3.3.2: - resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} - hono@4.12.21: resolution: {integrity: sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==} engines: {node: '>=16.9.0'} @@ -6733,12 +6727,6 @@ packages: peerDependencies: react: '>=16.13.1' - react-google-recaptcha-v3@1.11.0: - resolution: {integrity: sha512-kLQqpz/77m8+trpBwzqcxNtvWZYoZ/YO6Vm2cVTHW8hs80BWUfDpC7RDwuAvpswwtSYApWfaSpIDFWAIBNIYxQ==} - peerDependencies: - react: ^16.3 || ^17.0 || ^18.0 || ^19.0 - react-dom: ^17.0 || ^18.0 || ^19.0 - react-hook-form@7.58.1: resolution: {integrity: sha512-Lml/KZYEEFfPhUVgE0RdCVpnC4yhW+PndRhbiTtdvSlQTL8IfVR+iQkBjLIvmmc6+GGoVeM11z37ktKFPAb0FA==} engines: {node: '>=18.0.0'} @@ -14195,10 +14183,6 @@ snapshots: highlightjs-vue@1.0.0: {} - hoist-non-react-statics@3.3.2: - dependencies: - react-is: 16.13.1 - hono@4.12.21: {} hookable@5.5.3: {} @@ -15551,12 +15535,6 @@ snapshots: '@babel/runtime': 7.27.6 react: 19.2.6 - react-google-recaptcha-v3@1.11.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): - dependencies: - hoist-non-react-statics: 3.3.2 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - react-hook-form@7.58.1(react@19.2.6): dependencies: react: 19.2.6 diff --git a/src/app/listings/[id]/components/CommentForm.tsx b/src/app/listings/[id]/components/CommentForm.tsx index 4bb2653eb..22ee9dadf 100644 --- a/src/app/listings/[id]/components/CommentForm.tsx +++ b/src/app/listings/[id]/components/CommentForm.tsx @@ -3,7 +3,6 @@ import { GenericCommentForm, type CommentFormConfig } from '@/components/comments' import analytics from '@/lib/analytics' import { api } from '@/lib/api' -import { useRecaptchaForComment } from '@/lib/captcha/hooks' import { type RouterInput } from '@/types/trpc' interface Props { @@ -20,8 +19,6 @@ interface Props { } function CommentForm(props: Props) { - const { executeForComment, isCaptchaEnabled } = useRecaptchaForComment() - const createComment = api.listings.createComment.useMutation({ onSuccess: (data) => { if (data?.id) { @@ -63,7 +60,6 @@ function CommentForm(props: Props) { reply: 'Write your reply...', }, maxLength: 1000, - enableRecaptcha: isCaptchaEnabled, showSignInPrompt: true, buttonStyle: 'default', } @@ -71,13 +67,13 @@ function CommentForm(props: Props) { const handleSubmit = async (data: { content: string parentId?: string - recaptchaToken?: string | null + humanVerificationToken?: string }) => { await createComment.mutateAsync({ listingId: props.listingId, content: data.content, parentId: data.parentId, - ...(data.recaptchaToken && { recaptchaToken: data.recaptchaToken }), + humanVerificationToken: data.humanVerificationToken, } satisfies RouterInput['listings']['createComment']) } @@ -105,7 +101,6 @@ function CommentForm(props: Props) { onUpdate={handleUpdate} onSuccess={props.onCommentSuccess} onCancel={props.onCancelEdit} - getRecaptchaToken={isCaptchaEnabled ? executeForComment : undefined} isCreating={createComment.isPending} isUpdating={editComment.isPending} /> diff --git a/src/app/listings/[id]/components/CommentThread.tsx b/src/app/listings/[id]/components/CommentThread.tsx index 08cd630a8..1a01675ba 100644 --- a/src/app/listings/[id]/components/CommentThread.tsx +++ b/src/app/listings/[id]/components/CommentThread.tsx @@ -11,7 +11,6 @@ import { import useVerifiedDeveloper from '@/hooks/useVerifiedDeveloper' import analytics from '@/lib/analytics' import { api } from '@/lib/api' -import { useRecaptchaForComment } from '@/lib/captcha/hooks' import { type RouterInput } from '@/types/trpc' import { hasRolePermission } from '@/utils/permissions' import { Role } from '@orm' @@ -28,7 +27,6 @@ interface Props { function CommentThread(props: Props) { const { user } = useUser() const [sortBy, setSortBy] = useState(props.initialSortBy ?? 'newest') - const { executeForComment, isCaptchaEnabled } = useRecaptchaForComment() const listingsQuery = api.listings.getSortedComments.useQuery( { listingId: props.listingId, sortBy }, @@ -120,7 +118,6 @@ function CommentThread(props: Props) { entityIdField: 'listingId', entityType: 'listing', enableVoting: true, - enableRecaptcha: true, enableAnalytics: true, sortOptions: [ { value: 'newest', label: 'Newest' }, @@ -141,7 +138,6 @@ function CommentThread(props: Props) { reply: 'Write your reply...', }, maxLength: 1000, - enableRecaptcha: isCaptchaEnabled, showSignInPrompt: true, buttonStyle: 'default', } @@ -162,13 +158,13 @@ function CommentThread(props: Props) { const handleCreateComment = async (data: { content: string parentId?: string - recaptchaToken?: string | null + humanVerificationToken?: string }) => { const mutation = createComment.mutateAsync({ listingId: props.listingId, content: data.content, parentId: data.parentId, - ...(data.recaptchaToken && { recaptchaToken: data.recaptchaToken }), + humanVerificationToken: data.humanVerificationToken, } satisfies RouterInput['listings']['createComment']) // Track analytics for replies @@ -234,7 +230,6 @@ function CommentThread(props: Props) { onUpdate={handleEditComment} onSuccess={onSuccess} onCancel={onCancel} - getRecaptchaToken={isCaptchaEnabled ? executeForComment : undefined} isCreating={createComment.isPending} isUpdating={editComment.isPending} /> diff --git a/src/app/listings/[id]/components/VoteButtons.tsx b/src/app/listings/[id]/components/VoteButtons.tsx index 331281403..14aa19b64 100644 --- a/src/app/listings/[id]/components/VoteButtons.tsx +++ b/src/app/listings/[id]/components/VoteButtons.tsx @@ -2,7 +2,6 @@ import { VoteButtons as SharedVoteButtons } from '@/components/ui' import { api } from '@/lib/api' -import { useRecaptchaForVote } from '@/lib/captcha/hooks' import { type RouterInput } from '@/types/trpc' import VotingHelpModal from './VotingHelpModal' @@ -19,8 +18,6 @@ interface Props { } export function VoteButtons(props: Props) { - const { executeForVote, isCaptchaEnabled } = useRecaptchaForVote() - const voteMutation = api.listings.vote.useMutation({ onSuccess: () => { props.onVoteSuccess?.() @@ -28,14 +25,9 @@ export function VoteButtons(props: Props) { }) const handleVote = async (value: boolean) => { - // Get CAPTCHA token if enabled - let recaptchaToken: string | null = null - if (isCaptchaEnabled) recaptchaToken = await executeForVote() - voteMutation.mutate({ listingId: props.listingId, value, - ...(recaptchaToken && { recaptchaToken }), } satisfies RouterInput['listings']['vote']) } diff --git a/src/app/listings/components/shared/details/utils/logVoteError.test.ts b/src/app/listings/components/shared/details/utils/logVoteError.test.ts index 8722b1544..6c1329967 100644 --- a/src/app/listings/components/shared/details/utils/logVoteError.test.ts +++ b/src/app/listings/components/shared/details/utils/logVoteError.test.ts @@ -58,7 +58,7 @@ describe('logPcVoteError', () => { const PC_LISTING_ID = '00000000-0000-4000-a000-000000000011' it('passes pcListingId as extra context to logger.error', () => { - const error = new Error('CAPTCHA failed') + const error = new Error('Vote failed') logPcVoteError({ error, pcListingId: PC_LISTING_ID }) diff --git a/src/app/listings/new/NewListingPage.tsx b/src/app/listings/new/NewListingPage.tsx index baded4f21..48f5efb75 100644 --- a/src/app/listings/new/NewListingPage.tsx +++ b/src/app/listings/new/NewListingPage.tsx @@ -18,9 +18,9 @@ import '@/shared/emulator-config/eden' import '@/shared/emulator-config/azahar' import '@/shared/emulator-config/gamenative' import { Button, LoadingSpinner } from '@/components/ui' +import { useSubmitWithHumanVerification } from '@/features/human-verification/client' import analytics from '@/lib/analytics' import { api } from '@/lib/api' -import { useRecaptchaForCreateListing } from '@/lib/captcha/hooks' import { MarkdownEditor } from '@/lib/dynamic-imports' import toast from '@/lib/toast' import { cn } from '@/lib/utils' @@ -56,7 +56,7 @@ function AddListingPage() { const router = useRouter() const searchParams = useSearchParams() const utils = api.useUtils() - const { executeForCreateListing, isCaptchaEnabled } = useRecaptchaForCreateListing() + const submitWithHumanVerification = useSubmitWithHumanVerification() const gameIdFromUrl = searchParams.get('gameId') @@ -409,9 +409,6 @@ function AddListingPage() { toast.success('Handheld Report successfully submitted for review!') router.push('/listings') }, - onError: (error) => { - toast.error(`Error creating Handheld Report: ${getErrorMessage(error)}`) - }, }) useEffect(() => { @@ -424,19 +421,16 @@ function AddListingPage() { return toast.error('You must be signed in to create a Compatibility Report.') } - // Get CAPTCHA token if enabled - let recaptchaToken: string | null = null - if (isCaptchaEnabled) { - recaptchaToken = await executeForCreateListing() - if (!recaptchaToken) { - return toast.error('CAPTCHA verification could not start. Please refresh and try again.') - } + try { + await submitWithHumanVerification((humanVerificationToken) => + createListingMutation.mutateAsync({ + ...data, + humanVerificationToken, + }), + ) + } catch (error) { + toast.error(`Error creating Handheld Report: ${getErrorMessage(error)}`) } - - createListingMutation.mutate({ - ...data, - ...(recaptchaToken && { recaptchaToken }), - }) } return ( diff --git a/src/app/pc-listings/[id]/components/PcCommentForm.tsx b/src/app/pc-listings/[id]/components/PcCommentForm.tsx index 7f1277075..2fc9c717e 100644 --- a/src/app/pc-listings/[id]/components/PcCommentForm.tsx +++ b/src/app/pc-listings/[id]/components/PcCommentForm.tsx @@ -25,16 +25,20 @@ function PcCommentForm(props: Props) { reply: 'Write your reply...', }, maxLength: 2000, - enableRecaptcha: false, showSignInPrompt: false, buttonStyle: 'compact', } - const handleSubmit = async (data: { content: string; parentId?: string }) => { + const handleSubmit = async (data: { + content: string + parentId?: string + humanVerificationToken?: string + }) => { await createComment.mutateAsync({ pcListingId: props.pcListingId, content: data.content, parentId: data.parentId, + humanVerificationToken: data.humanVerificationToken, }) } diff --git a/src/app/pc-listings/[id]/components/PcCommentThread.tsx b/src/app/pc-listings/[id]/components/PcCommentThread.tsx index aac881288..c1f80821f 100644 --- a/src/app/pc-listings/[id]/components/PcCommentThread.tsx +++ b/src/app/pc-listings/[id]/components/PcCommentThread.tsx @@ -104,7 +104,6 @@ function PcCommentThread(props: Props) { reply: 'Write your reply...', }, maxLength: 2000, - enableRecaptcha: false, showSignInPrompt: false, buttonStyle: 'compact', } @@ -113,11 +112,16 @@ function PcCommentThread(props: Props) { await deleteComment.mutateAsync({ commentId }) } - const handleCreateComment = async (data: { content: string; parentId?: string }) => { + const handleCreateComment = async (data: { + content: string + parentId?: string + humanVerificationToken?: string + }) => { await createComment.mutateAsync({ pcListingId: props.pcListingId, content: data.content, parentId: data.parentId, + humanVerificationToken: data.humanVerificationToken, }) } diff --git a/src/app/pc-listings/[id]/components/PcVoteButtons.tsx b/src/app/pc-listings/[id]/components/PcVoteButtons.tsx index d193a5cb6..9b89fb900 100644 --- a/src/app/pc-listings/[id]/components/PcVoteButtons.tsx +++ b/src/app/pc-listings/[id]/components/PcVoteButtons.tsx @@ -3,7 +3,6 @@ import VotingHelpModal from '@/app/listings/[id]/components/VotingHelpModal' import { VoteButtons } from '@/components/ui' import { api } from '@/lib/api' -import { useRecaptchaForVote } from '@/lib/captcha/hooks' import { type RouterInput } from '@/types/trpc' interface Props { @@ -20,8 +19,6 @@ interface Props { } function PcVoteButtons(props: Props) { - const { executeForVote, isCaptchaEnabled } = useRecaptchaForVote() - const voteMutation = api.pcListings.vote.useMutation({ onSuccess: () => { props.onVoteSuccess?.() @@ -29,16 +26,9 @@ function PcVoteButtons(props: Props) { }) const handleVote = async (value: boolean) => { - // Get CAPTCHA token if enabled - let recaptchaToken: string | null = null - if (isCaptchaEnabled) { - recaptchaToken = await executeForVote() - } - voteMutation.mutate({ pcListingId: props.pcListingId, value, - ...(recaptchaToken && { recaptchaToken }), } satisfies RouterInput['pcListings']['vote']) } diff --git a/src/app/pc-listings/new/NewPcListingPage.tsx b/src/app/pc-listings/new/NewPcListingPage.tsx index e79c13518..55772811d 100644 --- a/src/app/pc-listings/new/NewPcListingPage.tsx +++ b/src/app/pc-listings/new/NewPcListingPage.tsx @@ -22,9 +22,9 @@ import { } from '@/app/listings/hooks' import { Autocomplete, Button, Input, LoadingSpinner, SelectInput } from '@/components/ui' import { PC_OS_OPTIONS } from '@/data/pc-os' +import { useSubmitWithHumanVerification } from '@/features/human-verification/client' import analytics from '@/lib/analytics' import { api } from '@/lib/api' -import { useRecaptchaForCreateListing } from '@/lib/captcha/hooks' import { MarkdownEditor } from '@/lib/dynamic-imports' import toast from '@/lib/toast' import { type RouterInput, type RouterOutput } from '@/types/trpc' @@ -46,6 +46,7 @@ function AddPcListingPage() { const router = useRouter() const searchParams = useSearchParams() const currentUserQuery = api.users.me.useQuery() + const submitWithHumanVerification = useSubmitWithHumanVerification() const gameIdFromUrl = searchParams.get('gameId') @@ -64,7 +65,6 @@ function AddPcListingPage() { const createPcListing = api.pcListings.create.useMutation() const performanceScalesQuery = api.performanceScales.get.useQuery() const presetsQuery = api.pcListings.presets.get.useQuery({}) - const { executeForCreateListing, isCaptchaEnabled } = useRecaptchaForCreateListing() const { handleKeyDown } = useFormKeyDown() const { gameSearchTerm, setGameSearchTerm, loadGameItems } = useGameLoader() @@ -194,15 +194,12 @@ function AddPcListingPage() { return toast.error('You must be signed in to create a Compatibility Report.') } try { - const recaptchaToken = isCaptchaEnabled ? await executeForCreateListing() : null - if (isCaptchaEnabled && !recaptchaToken) { - return toast.error('CAPTCHA verification could not start. Please refresh and try again.') - } - - const result = await createPcListing.mutateAsync({ - ...data, - ...(recaptchaToken && { recaptchaToken }), - }) + const result = await submitWithHumanVerification((humanVerificationToken) => + createPcListing.mutateAsync({ + ...data, + humanVerificationToken, + }), + ) analytics.listing.created({ listingId: result.id, @@ -230,9 +227,8 @@ function AddPcListingPage() { }, [ currentUserQuery.data?.id, - executeForCreateListing, - isCaptchaEnabled, createPcListing, + submitWithHumanVerification, selectedGame?.system?.id, parsedCustomFields.length, router, diff --git a/src/components/Providers.tsx b/src/components/Providers.tsx index 342ec70a1..e0d99140d 100644 --- a/src/components/Providers.tsx +++ b/src/components/Providers.tsx @@ -1,35 +1,18 @@ 'use client' -import { type PropsWithChildren, type ReactNode } from 'react' -import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3' +import { type PropsWithChildren } from 'react' +import { HumanVerificationProvider } from '@/features/human-verification/client' import { TRPCProvider } from '@/lib/api' -import { RECAPTCHA_CONFIG, isCaptchaClientEnabled } from '@/lib/captcha/config' import ThemeProvider from './ThemeProvider' import { ConfirmDialogProvider } from './ui' function Providers(props: PropsWithChildren) { - const recaptchaWrapper = (children: ReactNode) => { - return !isCaptchaClientEnabled() ? ( - <>{children} - ) : ( - - {children} - - ) - } - return ( - {recaptchaWrapper(props.children)} + + {props.children} + ) diff --git a/src/components/comments/GenericCommentForm.tsx b/src/components/comments/GenericCommentForm.tsx index cad70589a..337519ff8 100644 --- a/src/components/comments/GenericCommentForm.tsx +++ b/src/components/comments/GenericCommentForm.tsx @@ -4,6 +4,7 @@ import { useUser, SignInButton } from '@clerk/nextjs' import { Send, X } from 'lucide-react' import { useState, type FormEvent } from 'react' import { Button } from '@/components/ui' +import { useSubmitWithHumanVerification } from '@/features/human-verification/client' import { MarkdownEditor } from '@/lib/dynamic-imports' import toast from '@/lib/toast' import { cn } from '@/lib/utils' @@ -16,7 +17,6 @@ export interface CommentFormConfig { reply?: string } maxLength?: number - enableRecaptcha?: boolean showSignInPrompt?: boolean buttonStyle?: 'default' | 'compact' } @@ -30,15 +30,12 @@ interface GenericCommentFormProps { onSubmit: (data: { content: string parentId?: string - recaptchaToken?: string | null + humanVerificationToken?: string }) => Promise onUpdate?: (data: { commentId: string; content: string }) => Promise onSuccess?: () => void onCancel?: () => void - getRecaptchaToken?: () => Promise - - // Loading states isCreating?: boolean isUpdating?: boolean } @@ -46,6 +43,7 @@ interface GenericCommentFormProps { export function GenericCommentForm(props: GenericCommentFormProps) { const { user } = useUser() const [content, setContent] = useState(props.editingComment?.content ?? '') + const submitWithHumanVerification = useSubmitWithHumanVerification() const isLoading = props.isCreating || props.isUpdating const maxLength = props.config.maxLength ?? 2000 @@ -77,24 +75,18 @@ export function GenericCommentForm(props: GenericCommentFormProps) { try { if (props.editingComment && props.onUpdate) { - // Update existing comment await props.onUpdate({ commentId: props.editingComment.id, content: trimmedContent, }) toast.success('Comment updated successfully') } else { - // Get CAPTCHA token if enabled - let recaptchaToken: string | null = null - if (props.config.enableRecaptcha && props.getRecaptchaToken) { - recaptchaToken = await props.getRecaptchaToken() - } - - // Create new comment - await props.onSubmit({ - content: trimmedContent, - parentId: props.parentId, - recaptchaToken, + await submitWithHumanVerification((humanVerificationToken) => { + return props.onSubmit({ + content: trimmedContent, + parentId: props.parentId, + humanVerificationToken, + }) }) toast.success('Comment posted successfully') } @@ -187,7 +179,6 @@ export function GenericCommentForm(props: GenericCommentFormProps) { ) } - // Default style return (
, + ) => TurnstileWidgetId | undefined + remove: (widgetId: TurnstileWidgetId) => void + reset: (widgetId: TurnstileWidgetId) => void +} + +declare global { + interface Window { + turnstile?: TurnstileApi + } +} + +interface VerificationRequest { + action: typeof HUMAN_VERIFICATION_ACTION +} + +type RequestVerification = (request: VerificationRequest) => Promise +type ScriptStatus = 'idle' | 'ready' | 'failed' + +const HumanVerificationContext = createContext(null) + +const turnstileSiteKey = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY?.trim() ?? '' + +interface PendingRequest { + action: typeof HUMAN_VERIFICATION_ACTION + resolve: (token: string) => void + reject: (error: Error) => void +} + +export function HumanVerificationProvider(props: PropsWithChildren) { + const [pendingRequest, setPendingRequest] = useState(null) + const [scriptStatus, setScriptStatus] = useState('idle') + const widgetContainerRef = useRef(null) + const widgetIdRef = useRef(null) + const pendingRequestRef = useRef(null) + + const requestVerification = useCallback( + (request) => { + if (!turnstileSiteKey) { + return Promise.reject(new Error('Human verification is not configured.')) + } + + if (scriptStatus === 'failed') { + return Promise.reject(new Error('Human verification failed to load.')) + } + + return new Promise((resolve, reject) => { + const nextRequest = { action: request.action, resolve, reject } + pendingRequestRef.current = nextRequest + setPendingRequest(nextRequest) + }) + }, + [scriptStatus], + ) + + const closeDialog = useCallback(() => { + if (widgetIdRef.current && window.turnstile) { + window.turnstile.remove(widgetIdRef.current) + widgetIdRef.current = null + } + pendingRequestRef.current = null + setPendingRequest(null) + }, []) + + useEffect(() => { + if ( + !pendingRequest || + scriptStatus !== 'ready' || + !widgetContainerRef.current || + !window.turnstile + ) { + return + } + + if (widgetIdRef.current) { + window.turnstile.remove(widgetIdRef.current) + widgetIdRef.current = null + } + + const widgetId = window.turnstile.render(widgetContainerRef.current, { + sitekey: turnstileSiteKey, + action: pendingRequest.action, + theme: 'auto', + callback: (token: string) => { + pendingRequest.resolve(token) + closeDialog() + }, + 'error-callback': () => { + pendingRequest.reject(new Error('Human verification failed. Please try again.')) + closeDialog() + }, + 'expired-callback': () => { + if (widgetIdRef.current && window.turnstile) { + window.turnstile.reset(widgetIdRef.current) + } + }, + }) + + widgetIdRef.current = widgetId ?? null + }, [closeDialog, pendingRequest, scriptStatus]) + + const handleOpenChange = (open: boolean) => { + if (open || !pendingRequest) return + pendingRequest.reject(new Error('Human verification was cancelled.')) + closeDialog() + } + + return ( + + {turnstileSiteKey && ( +