Last active
December 31, 2024 02:26
-
-
Save suhaotian/c2851d1938da31d349e8cfe65c97c47e to your computer and use it in GitHub Desktop.
Cloudflare turnstile for next.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
'use client'; | |
import Script from 'next/script'; | |
import { useEffect, useRef, useState } from 'react'; | |
const scriptLink = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; | |
type TurnstileRenderParameters = Turnstile.RenderParameters; | |
export default function Captcha( | |
props: Pick< | |
TurnstileRenderParameters, | |
'action' | 'cData' | 'callback' | 'tabindex' | 'theme' | 'language' | |
> & { | |
sitekey?: TurnstileRenderParameters['sitekey']; | |
errorCallback?: TurnstileRenderParameters['error-callback']; | |
expiredCallback?: TurnstileRenderParameters['expired-callback']; | |
} | |
) { | |
const { sitekey, errorCallback, expiredCallback, ...rest } = props; | |
const widgetID = useRef<string>(); | |
const [isError, setIsError] = useState(false); | |
function retry() { | |
setIsError(false); | |
} | |
function onError(e?: string | Error) { | |
console.log(`Captcha error`, e); | |
setIsError(true); | |
if (errorCallback) { | |
errorCallback(); | |
} | |
} | |
function renderWidget() { | |
try { | |
widgetID.current = turnstile.render('#captcha-container', { | |
...rest, | |
// Refer: https://developers.cloudflare.com/turnstile/reference/testing/ | |
sitekey: sitekey || process.env.NEXT_PUBLIC_TURNSLITE_SITE_KEY || '', | |
'error-callback': onError, | |
'expired-callback': expiredCallback, | |
}); | |
if (!widgetID.current) { | |
throw new Error(`turnstile.render return widgetID=${widgetID.current}`); | |
} | |
} catch (e: unknown) { | |
onError(e as Error); | |
} | |
} | |
function onLoad() { | |
renderWidget(); | |
} | |
useEffect(() => { | |
if (!widgetID.current && (window as any).turnstile) { | |
renderWidget(); | |
} | |
return () => { | |
(window as any).turnstile?.remove(widgetID.current || ''); | |
widgetID.current = undefined; | |
}; | |
// eslint-disable-next-line react-hooks/exhaustive-deps | |
}, []); | |
if (isError) { | |
return ( | |
<div className="text-red-500 bg-white shadow-lg" onClick={retry}> | |
Load captcha error | |
<span className="text-blue-500 cursor-pointer inline-block text-sm font-semibold ml-2"> | |
Retry | |
</span> | |
</div> | |
); | |
} | |
return ( | |
<> | |
<div id="captcha-container"></div> | |
<Script | |
src={scriptLink} | |
onLoad={onLoad} | |
onError={(e) => onError('load error: ' + e.message)} | |
/> | |
</> | |
); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* | |
* @category captcha | |
*/ | |
type CaptchaTurnstileVerifyRes = { | |
success: boolean; | |
'error-codes': string[]; | |
challenge_ts: string; | |
hostname: string; | |
}; | |
function captchaTurnstileVerify({ validateToken, ip }: { validateToken: string; ip: string }) { | |
const data = { | |
// Refer: https://developers.cloudflare.com/turnstile/reference/testing/ | |
secret: config.TURNSLITE_SECRET_KEY, | |
response: validateToken, | |
remoteip: ip, | |
}; | |
return axios | |
.post<CaptchaTurnstileVerifyRes>( | |
'https://challenges.cloudflare.com/turnstile/v0/siteverify', | |
data, | |
{ | |
headers: { 'Content-Type': 'application/json' }, | |
} | |
) | |
.then((res) => res.data); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/ | |
declare const turnstile: Turnstile.Turnstile; | |
declare namespace Turnstile { | |
interface Turnstile { | |
/** | |
* Invokes a Turnstile widget and returns the ID of the newly created widget. | |
* @param container The HTML element to render the Turnstile widget into. Specify either the ID of HTML element (string), or the DOM element itself. | |
* @param params An object containing render parameters as key=value pairs, for example, {"sitekey": "your_site_key", "theme": "auto"}. | |
* @return the ID of the newly created widget, or undefined if invocation is unsuccessful. | |
*/ | |
render(container: string | HTMLElement, params: RenderParameters): string | undefined; | |
/** | |
* Resets a Turnstile widget. | |
* @param widgetId The ID of the Turnstile widget to be reset. | |
*/ | |
reset(widgetId: string): void; | |
/** | |
* remove a Turnstile widget. | |
* @param widgetId The ID of the Turnstile widget to be removed. | |
*/ | |
remove(widgetId: string): void; | |
/** | |
* Gets the response of a Turnstile widget. | |
* @param widgetId The ID of the Turnstile widget to get the response for. | |
* @return the response of the Turnstile widget. | |
*/ | |
getResponse(widgetId: string): string; | |
} | |
/** | |
* The theme of the Turnstile widget. | |
* The default is "auto", which respects the user preference. This can be forced to "light" or "dark" by setting the theme accordingly. | |
*/ | |
type Theme = 'auto' | 'light' | 'dark'; | |
/** | |
* Parameters for the turnstile.render() method. | |
*/ | |
interface RenderParameters { | |
/** | |
* Your Cloudflare Turnstile sitekey. This sitekey is associated with the corresponding widget configuration and is created upon the widget creation. | |
*/ | |
sitekey: string; | |
/** | |
* Optional. A customer value that can be used to differentiate widgets under the same sitekey in analytics and which is returned upon validation. | |
*/ | |
action?: string | undefined; | |
/** | |
* Optional. A customer payload that can be used to attach customer data to the challenge throughout its issuance and which is returned upon validation. | |
*/ | |
cData?: string | undefined; | |
/** | |
* Optional. A JavaScript callback that is invoked upon success of the challenge. | |
* The callback is passed a token that can be validated. | |
*/ | |
callback?: (token: string) => void; | |
/** | |
* Optional. A JavaScript callback that is invoked when a challenge expires. | |
*/ | |
'expired-callback'?: VoidFunction; | |
/** | |
* Optional. A JavaScript callback that is invoked when there is a network error. | |
*/ | |
'error-callback'?: (error?: string | Error) => void; | |
/** | |
* Optional. The widget theme. | |
* Accepted values: "auto", "light", "dark" | |
* @default "auto" | |
*/ | |
theme?: Theme | undefined; | |
/** | |
* Optional. The tabindex of Turnstile’s iframe for accessibility purposes. | |
* @default 0 | |
*/ | |
tabindex?: number | undefined; | |
/** | |
* Language to display, must be either: auto (default) to use the language that the visitor has chosen, or an ISO 639-1 two-letter language code (e.g. en) or language and country code (e.g. en-US). | |
* refer: https://developers.cloudflare.com/turnstile/reference/supported-languages/ | |
*/ | |
language?: string; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
'use client'; | |
import Link from 'next/link'; | |
import { useForm, SubmitHandler } from 'react-hook-form'; | |
import { ZodError } from 'zod'; | |
import CaptchaWidget from '@/components/CaptchaWidget'; | |
import { CaptchaTurnstileVerifyReq, CaptchaTurnstileVerify } from '@/lib/admin-api'; | |
export default function CaptchaDemo() { | |
const { | |
register, | |
handleSubmit, | |
watch, | |
formState: { errors }, | |
getValues, | |
setValue, | |
setError, | |
} = useForm<CaptchaTurnstileVerifyReq>(); | |
const onSubmit: SubmitHandler<CaptchaTurnstileVerifyReq> = (data) => verify(); | |
async function verify() { | |
try { | |
const res = await CaptchaTurnstileVerify(getValues()); | |
if (res.success) { | |
alert('Success'); | |
} else { | |
alert(res['error-codes'].join(', ')); | |
} | |
} catch (e) { | |
if (Array.isArray(e)) { | |
(e as ZodError['errors']).forEach((item) => { | |
setError(item.path[0] as any, { message: item.message }); | |
}); | |
} | |
} | |
} | |
return ( | |
<div> | |
<h3> | |
Captcha Demo | |
<Link className="text-blue-500 ml-4 cursor-pointer" href="/demoss"> | |
return | |
</Link> | |
</h3> | |
<br /> | |
<input {...register('email')} className="border" /> | |
{errors.email ? <div>{errors.email.message}</div> : null} | |
<br /> | |
<input {...register('firstName')} className="border" /> | |
{errors.firstName ? <div>{errors.firstName.message}</div> : null} | |
<br /> | |
<div> | |
<CaptchaWidget | |
callback={(validateToken) => { | |
setValue('validateToken', validateToken); | |
}} | |
/> | |
</div> | |
{errors.validateToken ? <div>{errors.validateToken.message}</div> : null} | |
<button type="submit" onClick={verify} className="bg-blue-500 p-4 rounded-md text-white"> | |
Verify | |
</button> | |
</div> | |
); | |
} |
@subvertallchris Cool! Thanks!
You just saved me a LOT of time, thanks dude. Wish you the best
Thank you!
How can i use this along with react-hook-form
and zod
?
How can i use this along with
react-hook-form
andzod
?
You can add a onChange
props to the component, and change it to callback
in turnstile.render
method
Indeed. I ended up something like this for now.
'use client';
import { createContext, ReactNode, useContext, useState } from 'react';
import TurnstileCaptcha from '@/app/shared/turnstile-captcha';
const TurnstileContext = createContext<Partial<{ token: string }>>({});
interface Props {
children: ReactNode;
className?: string;
}
export function useTurnstile() {
const ctx = useContext(TurnstileContext);
if (!ctx) {
throw new Error('useTurnstile must be used within a TurnstileProvider');
}
return ctx;
}
export default function TurnstileProvider({ children }: Props) {
const [token, setToken] = useState<string | undefined>(undefined);
return (
<TurnstileContext.Provider value={{ token }}>
<>
{children}
<TurnstileCaptcha
onValidationToken={(token) => {
setToken(token);
}}
sitekey="sitekey" />
</>
</TurnstileContext.Provider>
);
}
interface Props {
onValidationToken: (token: string) => void;
sitekey: string;
}
function TurnstileCaptcha({ onValidationToken, sitekey }: Props) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) return;
const isTurnstileExisit = ref.current.querySelector('input[name="cf-turnstile-response"]');
if (isTurnstileExisit) return;
if (window.turnstile) {
window.turnstile.render(ref.current, {
sitekey,
callback: (token: string) => {
onValidationToken(token);
},
});
}
}, [ref.current]);
return (
<div ref={ref} />
);
}
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Thank you for this! It works great.
You might consider adding
turnstile
to the Window object instead of usingany
: