Skip to content

Commit

Permalink
feat(turnstile): Use Cloudflare Turnstile instead of Google reCAPTCHA
Browse files Browse the repository at this point in the history
resolves #3313

with an embedded copy of @marsidev/react-turnstile to circumvent the React v17 dependency problems
https://github.com/marsidev/react-turnstile/tree/main/packages/lib/src

after React v18, this can be move back to use @marsidev/react-turnstile instead
  • Loading branch information
49659410+tx0c committed Sep 26, 2023
1 parent 0dc46ad commit 16f57eb
Show file tree
Hide file tree
Showing 12 changed files with 1,026 additions and 17 deletions.
1 change: 1 addition & 0 deletions .env.dev
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ NEXT_PUBLIC_CURATION_CONTRACT_ADDRESS=0xa219c6722008aa22828b31a13ab9ba93bb91222c
NEXT_PUBLIC_GOOGLE_CLIENT_ID=315393900359-2r9fundftis7dc0tdeo2hf8630nfdd8h.apps.googleusercontent.com
NEXT_PUBLIC_TWITTER_CLIENT_ID=X3d6Szg5bnVCMm5wRWxSVmhXUTc6MTpjaQ
NEXT_PUBLIC_FACEBOOK_CLIENT_ID=823885921293850
NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITE_KEY=0x4AAAAAAAKiedvR5qiLUhIs
DEBUG=false
PLAYWRIGHT_RUNTIME_ENV=ci
PLAYWRIGHT_TEST_BASE_URL=https://web-develop.matters.town
Expand Down
1 change: 1 addition & 0 deletions .env.prod
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ NEXT_PUBLIC_LOGBOOKS_URL=https://logbook.matters.town
NEXT_PUBLIC_ALCHEMY_KEY=bOu-fCphi9mvePsxg968Qe-pidHQNdlT
NEXT_PUBLIC_USDT_CONTRACT_ADDRESS=0xc2132D05D31c914a87C6611C10748AEb04B58e8F
NEXT_PUBLIC_CURATION_CONTRACT_ADDRESS=0x5edebbdae7b5c79a69aacf7873796bb1ec664db8
NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITE_KEY=0x4AAAAAAAKVODkJMwfIxG78
DEBUG=false
NEXT_PUBLIC_GOOGLE_CLIENT_ID=
NEXT_PUBLIC_TWITTER_CLIENT_ID=cmdKbUlyd1ZZZDZYa3dTampidGo6MTpjaQ
Expand Down
6 changes: 6 additions & 0 deletions src/common/enums/csp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ const SCRIPT_SRC = [
'www.google.com/recaptcha/',
'www.gstatic.com/recaptcha/',

// Turnstile
'challenges.cloudflare.com',

// Programmable Google Search
'cse.google.com',
'www.google.com/cse/',
Expand Down Expand Up @@ -158,6 +161,9 @@ const FRAME_SRC = [
'www.google.com/recaptcha/',
'recaptcha.google.com/recaptcha/',

// Turnstile
'challenges.cloudflare.com',

// Stripe
'js.stripe.com',
'hooks.stripe.com',
Expand Down
21 changes: 21 additions & 0 deletions src/components/Context/Turnstile/container.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { ComponentPropsWithoutRef, ElementType, ForwardedRef } from 'react'
import { forwardRef } from 'react'

type Props<Tag extends ElementType> = {
as?: Tag
} & ComponentPropsWithoutRef<Tag>

type ComponentProps<Tag extends ElementType = 'div'> =
Tag extends keyof JSX.IntrinsicElements
? Props<Tag> // @typescript-eslint/no-explicit-any
: // eslint-disable-next-line
Props<any>

const Component = <Tag extends ElementType = 'div'>(
{ as: Element = 'div', ...props }: ComponentProps<Tag>,
ref: ForwardedRef<Element>
) => {
return <Element {...props} ref={ref} />
}

export default forwardRef(Component)
19 changes: 19 additions & 0 deletions src/components/Context/Turnstile/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// mostly are MIT License code from
// https://github.com/marsidev/react-turnstile/tree/main/packages/lib/src
// after migrated to React 18, this directory can be dropped to use `@marsidev/react-turnstile` instead

export * from './lib'
export type {
TurnstileInstance,
TurnstileLangCode,
TurnstileProps,
TurnstileServerValidationErrorCode,
TurnstileServerValidationResponse,
TurnstileTheme,
} from './types'
export {
DEFAULT_CONTAINER_ID as TURNSTILE_DEFAULT_CONTAINER_ID,
DEFAULT_ONLOAD_NAME as TURNSTILE_DEFAULT_ONLOAD_NAME,
DEFAULT_SCRIPT_ID as TURNSTILE_DEFAULT_SCRIPT_ID,
SCRIPT_URL as TURNSTILE_SCRIPT_URL,
} from './utils'
304 changes: 304 additions & 0 deletions src/components/Context/Turnstile/lib.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'

import Container from './container'
import { RenderOptions, TurnstileInstance, TurnstileProps } from './types'
import useObserveScript from './use-observe-script'
import {
checkElementExistence,
CONTAINER_STYLE_SET,
DEFAULT_CONTAINER_ID,
DEFAULT_ONLOAD_NAME,
DEFAULT_SCRIPT_ID,
getTurnstileSizeOpts,
injectTurnstileScript,
} from './utils'

export const Turnstile = forwardRef<
TurnstileInstance | undefined,
TurnstileProps
>((props, ref) => {
const {
scriptOptions,
options = {},
siteKey,
onSuccess,
onExpire,
onError,
onBeforeInteractive,
onAfterInteractive,
onUnsupported,
id,
style,
as = 'div',
injectScript = true,
...divProps
} = props
const widgetSize = options.size ?? 'normal'

const [containerStyle, setContainerStyle] = useState(
options.execution === 'execute'
? CONTAINER_STYLE_SET.invisible
: options.appearance === 'interaction-only'
? CONTAINER_STYLE_SET.interactionOnly
: CONTAINER_STYLE_SET[widgetSize]
)
const containerRef = useRef<HTMLElement | null>(null)
const firstRendered = useRef(false)
const [widgetId, setWidgetId] = useState<string | undefined | null>()
const [turnstileLoaded, setTurnstileLoaded] = useState(false)
const containerId = id ?? DEFAULT_CONTAINER_ID
const scriptId = injectScript
? scriptOptions?.id || `${DEFAULT_SCRIPT_ID}__${containerId}`
: scriptOptions?.id || DEFAULT_SCRIPT_ID
const scriptLoaded = useObserveScript(scriptId)

const onLoadCallbackName = scriptOptions?.onLoadCallbackName
? `${scriptOptions.onLoadCallbackName}__${containerId}`
: `${DEFAULT_ONLOAD_NAME}__${containerId}`

const renderConfig = useMemo(
(): RenderOptions => ({
sitekey: siteKey,
action: options.action,
cData: options.cData,
callback: onSuccess,
'error-callback': onError,
'expired-callback': onExpire,
'before-interactive-callback': onBeforeInteractive,
'after-interactive-callback': onAfterInteractive,
'unsupported-callback': onUnsupported,
theme: options.theme ?? 'auto',
language: options.language ?? 'auto',
tabindex: options.tabIndex,
'response-field': options.responseField,
'response-field-name': options.responseFieldName,
size: getTurnstileSizeOpts(widgetSize),
retry: options.retry ?? 'auto',
'retry-interval': options.retryInterval ?? 8000,
'refresh-expired': options.refreshExpired ?? 'auto',
execution: options.execution ?? 'render',
appearance: options.appearance ?? 'always',
}),
[
siteKey,
options,
onSuccess,
onError,
onExpire,
widgetSize,
onBeforeInteractive,
onAfterInteractive,
onUnsupported,
]
)

const renderConfigStringified = useMemo(
() => JSON.stringify(renderConfig),
[renderConfig]
)

useImperativeHandle(
ref,
() => {
if (typeof window === 'undefined' || !scriptLoaded) {
return
}

const { turnstile } = window
return {
getResponse() {
if (!turnstile?.getResponse || !widgetId) {
console.warn('Turnstile has not been loaded')
return
}

return turnstile.getResponse(widgetId)
},

reset() {
if (!turnstile?.reset || !widgetId) {
console.warn('Turnstile has not been loaded')
return
}

if (options.execution === 'execute') {
setContainerStyle(CONTAINER_STYLE_SET.invisible)
}

try {
turnstile.reset(widgetId)
} catch (error) {
console.warn(`Failed to reset Turnstile widget ${widgetId}`, error)
}
},

remove() {
if (!turnstile?.remove || !widgetId) {
console.warn('Turnstile has not been loaded')
return
}

setWidgetId('')
setContainerStyle(CONTAINER_STYLE_SET.invisible)
turnstile.remove(widgetId)
},

render() {
if (!turnstile?.render || !containerRef.current || widgetId) {
console.warn(
'Turnstile has not been loaded or widget already rendered'
)
return
}

const id = turnstile.render(containerRef.current, renderConfig)
setWidgetId(id)

if (options.execution !== 'execute') {
setContainerStyle(CONTAINER_STYLE_SET[widgetSize])
}

return id
},

execute() {
if (options.execution !== 'execute') {
return
}

if (!turnstile?.execute || !containerRef.current || !widgetId) {
console.warn(
'Turnstile has not been loaded or widget has not been rendered'
)
return
}

turnstile.execute(containerRef.current, renderConfig)
setContainerStyle(CONTAINER_STYLE_SET[widgetSize])
},
}
},
[
scriptLoaded,
widgetId,
options.execution,
widgetSize,
renderConfig,
containerRef,
]
)

useEffect(() => {
// @ts-expect-error implicit any
window[onLoadCallbackName] = () => setTurnstileLoaded(true)

return () => {
// @ts-expect-error implicit any
delete window[onLoadCallbackName]
}
}, [onLoadCallbackName])

useEffect(() => {
if (injectScript && !turnstileLoaded) {
injectTurnstileScript({
onLoadCallbackName,
scriptOptions: {
...scriptOptions,
id: scriptId,
},
})
}
}, [
injectScript,
turnstileLoaded,
onLoadCallbackName,
scriptOptions,
scriptId,
])

/* Set the turnstile as loaded, in case the onload callback never runs. (e.g., when manually injecting the script without specifying the `onload` param) */
useEffect(() => {
if (scriptLoaded && !turnstileLoaded && window.turnstile) {
setTurnstileLoaded(true)
}
}, [turnstileLoaded, scriptLoaded])

useEffect(() => {
if (!siteKey) {
console.warn('sitekey was not provided')
return
}

if (
!scriptLoaded ||
!containerRef.current ||
!turnstileLoaded ||
firstRendered.current
) {
return
}

const id = window.turnstile!.render(containerRef.current, renderConfig)
setWidgetId(id)
firstRendered.current = true
}, [scriptLoaded, siteKey, renderConfig, firstRendered, turnstileLoaded])

// re-render widget when renderConfig changes
useEffect(() => {
if (!window.turnstile) return

if (containerRef.current && widgetId) {
if (checkElementExistence(widgetId)) {
window.turnstile.remove(widgetId)
}
const newWidgetId = window.turnstile.render(
containerRef.current,
renderConfig
)
setWidgetId(newWidgetId)
firstRendered.current = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [renderConfigStringified, siteKey])

useEffect(() => {
if (!window.turnstile) return
if (!widgetId) return
if (!checkElementExistence(widgetId)) return

return () => {
window.turnstile!.remove(widgetId)
}
}, [widgetId])

useEffect(() => {
setContainerStyle(
options.execution === 'execute'
? CONTAINER_STYLE_SET.invisible
: renderConfig.appearance === 'interaction-only'
? CONTAINER_STYLE_SET.interactionOnly
: CONTAINER_STYLE_SET[widgetSize]
)
}, [options.execution, widgetSize, renderConfig.appearance])

return (
<Container
ref={containerRef}
as={as}
id={containerId}
style={{ ...containerStyle, ...style }}
{...divProps}
/>
)
})

Turnstile.displayName = 'Turnstile'

export default Turnstile
Loading

0 comments on commit 16f57eb

Please sign in to comment.