Skip to content

Commit

Permalink
[Dashboard] Add Cloudflare Turnstile to Faucet page (thirdweb-dev#5067)
Browse files Browse the repository at this point in the history
## 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 -->
  • Loading branch information
kien-ngo committed Oct 18, 2024
1 parent 0f4e662 commit 0dd1438
Show file tree
Hide file tree
Showing 7 changed files with 124 additions and 39 deletions.
6 changes: 5 additions & 1 deletion apps/dashboard/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,8 @@ UNTHREAD_PRO_TIER_ID=""
NEXT_PUBLIC_DEMO_ENGINE_URL=""

# API server secret (required for thirdweb.com SIWE login). Copy from Vercel.
API_SERVER_SECRET=""
API_SERVER_SECRET=""

# Used for the Faucet page (/<chain_id>)
NEXT_PUBLIC_TURNSTILE_SITE_KEY=""
TURNSTILE_SECRET_KEY=""
2 changes: 1 addition & 1 deletion apps/dashboard/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const ContentSecurityPolicy = `
style-src 'self' 'unsafe-inline' vercel.live;
font-src 'self' vercel.live assets.vercel.com framerusercontent.com;
frame-src * data:;
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;
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;
connect-src * data: blob:;
worker-src 'self' blob:;
block-all-mixed-content;
Expand Down
1 change: 1 addition & 0 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@emotion/react": "11.13.3",
"@emotion/styled": "11.13.0",
"@hookform/resolvers": "^3.9.0",
"@marsidev/react-turnstile": "^1.0.2",
"@n8tb1t/use-scroll-position": "^2.0.3",
"@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-avatar": "^1.1.1",
Expand Down
3 changes: 3 additions & 0 deletions apps/dashboard/src/@/constants/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,6 @@ export const DASHBOARD_STORAGE_URL =

export const API_SERVER_URL =
process.env.NEXT_PUBLIC_THIRDWEB_API_HOST || "https://api.thirdweb.com";

export const TURNSTILE_SITE_KEY =
process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || "";
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,24 @@

import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import { THIRDWEB_ENGINE_FAUCET_WALLET } from "@/constants/env";
import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
import {
THIRDWEB_ENGINE_FAUCET_WALLET,
TURNSTILE_SITE_KEY,
} from "@/constants/env";
import { useThirdwebClient } from "@/constants/thirdweb.client";
import { CustomConnectWallet } from "@3rdweb-sdk/react/components/connect-wallet";
import { Turnstile } from "@marsidev/react-turnstile";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { CanClaimResponseType } from "app/api/testnet-faucet/can-claim/CanClaimResponseType";
import { mapV4ChainToV5Chain } from "contexts/map-chains";
import { useTrack } from "hooks/analytics/useTrack";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { toUnits } from "thirdweb";
import type { ChainMetadata } from "thirdweb/chains";
import { useActiveAccount, useWalletBalance } from "thirdweb/react";
import { z } from "zod";

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

const claimFaucetSchema = z.object({
turnstileToken: z.string().min(1, {
message: "Captcha validation is required.",
}),
});

export function FaucetButton({
chain,
amount,
Expand All @@ -52,7 +65,7 @@ export function FaucetButton({
const queryClient = useQueryClient();

const claimMutation = useMutation({
mutationFn: async () => {
mutationFn: async (turnstileToken: string) => {
trackEvent({
category: "faucet",
action: "claim",
Expand All @@ -67,6 +80,7 @@ export function FaucetButton({
body: JSON.stringify({
chainId: chainId,
toAddress: address,
turnstileToken,
}),
});

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

const form = useForm<z.infer<typeof claimFaucetSchema>>();

// loading state
if (faucetWalletBalanceQuery.isPending || canClaimFaucetQuery.isPending) {
return (
Expand Down Expand Up @@ -161,28 +177,46 @@ export function FaucetButton({
);
}

const claimFunds = (values: z.infer<typeof claimFaucetSchema>) => {
// Instead of having a dedicated endpoint (/api/verify-token),
// we can just attach the token in the payload and send it to the claim-faucet endpoint, to avoid a round-trip request
const claimPromise = claimMutation.mutateAsync(values.turnstileToken);
toast.promise(claimPromise, {
success: `${amount} ${chain.nativeCurrency.symbol} sent successfully`,
error: `Failed to claim ${amount} ${chain.nativeCurrency.symbol}`,
});
};

// eligible to claim and faucet has balance
return (
<div className="flex w-full flex-col text-center">
<Button
variant="primary"
className="w-full gap-2"
onClick={() => {
const claimPromise = claimMutation.mutateAsync();
toast.promise(claimPromise, {
success: `${amount} ${chain.nativeCurrency.symbol} sent successfully`,
error: `Failed to claim ${amount} ${chain.nativeCurrency.symbol}`,
});
}}
>
{claimMutation.isPending ? (
<>
Claiming <Spinner className="size-3" />
</>
) : (
`Get ${amount} ${chain.nativeCurrency.symbol}`
)}
</Button>
<Form {...form}>
<form onSubmit={form.handleSubmit(claimFunds)}>
<Button variant="primary" className="w-full gap-2" type="submit">
{claimMutation.isPending ? (
<>
Claiming <Spinner className="size-3" />
</>
) : (
`Get ${amount} ${chain.nativeCurrency.symbol}`
)}
</Button>
<FormField
control={form.control}
name="turnstileToken"
render={({ field }) => (
<FormItem>
<FormControl>
<Turnstile
siteKey={TURNSTILE_SITE_KEY}
onSuccess={(token) => field.onChange(token)}
/>
</FormControl>
</FormItem>
)}
/>
</form>
</Form>

{faucetWalletBalanceQuery.data && (
<p className="mt-3 text-muted-foreground text-xs">
Expand Down
41 changes: 40 additions & 1 deletion apps/dashboard/src/app/api/testnet-faucet/claim/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,15 @@ const THIRDWEB_ACCESS_TOKEN = process.env.THIRDWEB_ACCESS_TOKEN;
interface RequestTestnetFundsPayload {
chainId: number;
toAddress: string;

// Cloudflare Turnstile token received from the client-side
turnstileToken: string;
}

// Note: This handler cannot use "edge" runtime because of Redis usage.
export const POST = async (req: NextRequest) => {
const requestBody = (await req.json()) as RequestTestnetFundsPayload;
const { chainId, toAddress } = requestBody;
const { chainId, toAddress, turnstileToken } = requestBody;
if (Number.isNaN(chainId)) {
throw new Error("Invalid chain ID.");
}
Expand Down Expand Up @@ -46,6 +49,42 @@ export const POST = async (req: NextRequest) => {
);
}

if (!turnstileToken) {
return NextResponse.json(
{
error: "Missing Turnstile token.",
},
{ status: 400 },
);
}

// https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
// Validate the token by calling the "/siteverify" API endpoint.
const result = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
body: JSON.stringify({
secret: process.env.TURNSTILE_SECRET_KEY,
response: turnstileToken,
remoteip: ipAddress,
}),
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
);

const outcome = await result.json();
if (!outcome.success) {
return NextResponse.json(
{
error: "Could not validate captcha.",
},
{ status: 400 },
);
}

const ipCacheKey = `testnet-faucet:${chainId}:${ipAddress}`;
const addressCacheKey = `testnet-faucet:${chainId}:${toAddress}`;

Expand Down
34 changes: 19 additions & 15 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 0dd1438

Please sign in to comment.