Skip to content

Commit 0dd1438

Browse files
committed
[Dashboard] Add Cloudflare Turnstile to Faucet page (#5067)
## Problem solved Short description of the bug fixed or feature added <!-- start pr-codex --> --- ## PR-Codex overview This PR introduces support for Cloudflare Turnstile captcha validation in the faucet functionality of the dashboard application, enhancing security during fund claims. It also updates dependencies and configuration for the new feature. ### Detailed summary - Added `TURNSTILE_SITE_KEY` constant to `env.ts`. - Updated `package.json` to include `@marsidev/react-turnstile`. - Modified `.env.example` to add `NEXT_PUBLIC_TURNSTILE_SITE_KEY` and `TURNSTILE_SECRET_KEY`. - Updated `next.config.js` to allow scripts from Cloudflare. - Enhanced `POST` handler in `route.ts` to validate `turnstileToken`. - Updated `FaucetButton.tsx` to integrate Turnstile captcha and form handling. - Introduced `claimFaucetSchema` for form validation using `zod`. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` <!-- end pr-codex -->
1 parent 0f4e662 commit 0dd1438

File tree

7 files changed

+124
-39
lines changed

7 files changed

+124
-39
lines changed

apps/dashboard/.env.example

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,4 +96,8 @@ UNTHREAD_PRO_TIER_ID=""
9696
NEXT_PUBLIC_DEMO_ENGINE_URL=""
9797

9898
# API server secret (required for thirdweb.com SIWE login). Copy from Vercel.
99-
API_SERVER_SECRET=""
99+
API_SERVER_SECRET=""
100+
101+
# Used for the Faucet page (/<chain_id>)
102+
NEXT_PUBLIC_TURNSTILE_SITE_KEY=""
103+
TURNSTILE_SECRET_KEY=""

apps/dashboard/next.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const ContentSecurityPolicy = `
88
style-src 'self' 'unsafe-inline' vercel.live;
99
font-src 'self' vercel.live assets.vercel.com framerusercontent.com;
1010
frame-src * data:;
11-
script-src 'self' 'unsafe-eval' 'unsafe-inline' 'wasm-unsafe-eval' 'inline-speculation-rules' *.thirdweb.com *.thirdweb-dev.com vercel.live js.stripe.com framerusercontent.com events.framer.com;
11+
script-src 'self' 'unsafe-eval' 'unsafe-inline' 'wasm-unsafe-eval' 'inline-speculation-rules' *.thirdweb.com *.thirdweb-dev.com vercel.live js.stripe.com framerusercontent.com events.framer.com challenges.cloudflare.com;
1212
connect-src * data: blob:;
1313
worker-src 'self' blob:;
1414
block-all-mixed-content;

apps/dashboard/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"@emotion/react": "11.13.3",
3030
"@emotion/styled": "11.13.0",
3131
"@hookform/resolvers": "^3.9.0",
32+
"@marsidev/react-turnstile": "^1.0.2",
3233
"@n8tb1t/use-scroll-position": "^2.0.3",
3334
"@radix-ui/react-alert-dialog": "^1.1.2",
3435
"@radix-ui/react-avatar": "^1.1.1",

apps/dashboard/src/@/constants/env.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,6 @@ export const DASHBOARD_STORAGE_URL =
2323

2424
export const API_SERVER_URL =
2525
process.env.NEXT_PUBLIC_THIRDWEB_API_HOST || "https://api.thirdweb.com";
26+
27+
export const TURNSTILE_SITE_KEY =
28+
process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || "";

apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/(chainPage)/components/client/FaucetButton.tsx

Lines changed: 55 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,24 @@
22

33
import { Spinner } from "@/components/ui/Spinner/Spinner";
44
import { Button } from "@/components/ui/button";
5-
import { THIRDWEB_ENGINE_FAUCET_WALLET } from "@/constants/env";
5+
import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
6+
import {
7+
THIRDWEB_ENGINE_FAUCET_WALLET,
8+
TURNSTILE_SITE_KEY,
9+
} from "@/constants/env";
610
import { useThirdwebClient } from "@/constants/thirdweb.client";
711
import { CustomConnectWallet } from "@3rdweb-sdk/react/components/connect-wallet";
12+
import { Turnstile } from "@marsidev/react-turnstile";
813
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
914
import type { CanClaimResponseType } from "app/api/testnet-faucet/can-claim/CanClaimResponseType";
1015
import { mapV4ChainToV5Chain } from "contexts/map-chains";
1116
import { useTrack } from "hooks/analytics/useTrack";
17+
import { useForm } from "react-hook-form";
1218
import { toast } from "sonner";
1319
import { toUnits } from "thirdweb";
1420
import type { ChainMetadata } from "thirdweb/chains";
1521
import { useActiveAccount, useWalletBalance } from "thirdweb/react";
22+
import { z } from "zod";
1623

1724
function formatTime(seconds: number) {
1825
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
@@ -29,6 +36,12 @@ function formatTime(seconds: number) {
2936
return rtf.format(+seconds, "second");
3037
}
3138

39+
const claimFaucetSchema = z.object({
40+
turnstileToken: z.string().min(1, {
41+
message: "Captcha validation is required.",
42+
}),
43+
});
44+
3245
export function FaucetButton({
3346
chain,
3447
amount,
@@ -52,7 +65,7 @@ export function FaucetButton({
5265
const queryClient = useQueryClient();
5366

5467
const claimMutation = useMutation({
55-
mutationFn: async () => {
68+
mutationFn: async (turnstileToken: string) => {
5669
trackEvent({
5770
category: "faucet",
5871
action: "claim",
@@ -67,6 +80,7 @@ export function FaucetButton({
6780
body: JSON.stringify({
6881
chainId: chainId,
6982
toAddress: address,
83+
turnstileToken,
7084
}),
7185
});
7286

@@ -117,6 +131,8 @@ export function FaucetButton({
117131
faucetWalletBalanceQuery.data !== undefined &&
118132
faucetWalletBalanceQuery.data.value < toUnits("1", 17);
119133

134+
const form = useForm<z.infer<typeof claimFaucetSchema>>();
135+
120136
// loading state
121137
if (faucetWalletBalanceQuery.isPending || canClaimFaucetQuery.isPending) {
122138
return (
@@ -161,28 +177,46 @@ export function FaucetButton({
161177
);
162178
}
163179

180+
const claimFunds = (values: z.infer<typeof claimFaucetSchema>) => {
181+
// Instead of having a dedicated endpoint (/api/verify-token),
182+
// we can just attach the token in the payload and send it to the claim-faucet endpoint, to avoid a round-trip request
183+
const claimPromise = claimMutation.mutateAsync(values.turnstileToken);
184+
toast.promise(claimPromise, {
185+
success: `${amount} ${chain.nativeCurrency.symbol} sent successfully`,
186+
error: `Failed to claim ${amount} ${chain.nativeCurrency.symbol}`,
187+
});
188+
};
189+
164190
// eligible to claim and faucet has balance
165191
return (
166192
<div className="flex w-full flex-col text-center">
167-
<Button
168-
variant="primary"
169-
className="w-full gap-2"
170-
onClick={() => {
171-
const claimPromise = claimMutation.mutateAsync();
172-
toast.promise(claimPromise, {
173-
success: `${amount} ${chain.nativeCurrency.symbol} sent successfully`,
174-
error: `Failed to claim ${amount} ${chain.nativeCurrency.symbol}`,
175-
});
176-
}}
177-
>
178-
{claimMutation.isPending ? (
179-
<>
180-
Claiming <Spinner className="size-3" />
181-
</>
182-
) : (
183-
`Get ${amount} ${chain.nativeCurrency.symbol}`
184-
)}
185-
</Button>
193+
<Form {...form}>
194+
<form onSubmit={form.handleSubmit(claimFunds)}>
195+
<Button variant="primary" className="w-full gap-2" type="submit">
196+
{claimMutation.isPending ? (
197+
<>
198+
Claiming <Spinner className="size-3" />
199+
</>
200+
) : (
201+
`Get ${amount} ${chain.nativeCurrency.symbol}`
202+
)}
203+
</Button>
204+
<FormField
205+
control={form.control}
206+
name="turnstileToken"
207+
render={({ field }) => (
208+
<FormItem>
209+
<FormControl>
210+
<Turnstile
211+
siteKey={TURNSTILE_SITE_KEY}
212+
onSuccess={(token) => field.onChange(token)}
213+
/>
214+
</FormControl>
215+
</FormItem>
216+
)}
217+
/>
218+
</form>
219+
</Form>
186220

187221
{faucetWalletBalanceQuery.data && (
188222
<p className="mt-3 text-muted-foreground text-xs">

apps/dashboard/src/app/api/testnet-faucet/claim/route.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,15 @@ const THIRDWEB_ACCESS_TOKEN = process.env.THIRDWEB_ACCESS_TOKEN;
1111
interface RequestTestnetFundsPayload {
1212
chainId: number;
1313
toAddress: string;
14+
15+
// Cloudflare Turnstile token received from the client-side
16+
turnstileToken: string;
1417
}
1518

1619
// Note: This handler cannot use "edge" runtime because of Redis usage.
1720
export const POST = async (req: NextRequest) => {
1821
const requestBody = (await req.json()) as RequestTestnetFundsPayload;
19-
const { chainId, toAddress } = requestBody;
22+
const { chainId, toAddress, turnstileToken } = requestBody;
2023
if (Number.isNaN(chainId)) {
2124
throw new Error("Invalid chain ID.");
2225
}
@@ -46,6 +49,42 @@ export const POST = async (req: NextRequest) => {
4649
);
4750
}
4851

52+
if (!turnstileToken) {
53+
return NextResponse.json(
54+
{
55+
error: "Missing Turnstile token.",
56+
},
57+
{ status: 400 },
58+
);
59+
}
60+
61+
// https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
62+
// Validate the token by calling the "/siteverify" API endpoint.
63+
const result = await fetch(
64+
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
65+
{
66+
body: JSON.stringify({
67+
secret: process.env.TURNSTILE_SECRET_KEY,
68+
response: turnstileToken,
69+
remoteip: ipAddress,
70+
}),
71+
method: "POST",
72+
headers: {
73+
"Content-Type": "application/json",
74+
},
75+
},
76+
);
77+
78+
const outcome = await result.json();
79+
if (!outcome.success) {
80+
return NextResponse.json(
81+
{
82+
error: "Could not validate captcha.",
83+
},
84+
{ status: 400 },
85+
);
86+
}
87+
4988
const ipCacheKey = `testnet-faucet:${chainId}:${ipAddress}`;
5089
const addressCacheKey = `testnet-faucet:${chainId}:${toAddress}`;
5190

pnpm-lock.yaml

Lines changed: 19 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)