From f32963415a70e9086227bc44ffa127250a730c2a Mon Sep 17 00:00:00 2001 From: 49659410+tx0c <> Date: Wed, 20 Sep 2023 23:30:16 +0000 Subject: [PATCH] feat(turnstile): Use Cloudflare Turnstile instead of Google reCAPTCHA 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 --- .env.dev | 1 + .env.prod | 1 + src/common/enums/csp.ts | 6 + .../Context/Turnstile/container.tsx | 21 + src/components/Context/Turnstile/index.ts | 19 + src/components/Context/Turnstile/lib.tsx | 304 +++++++++++++ src/components/Context/Turnstile/types.ts | 428 ++++++++++++++++++ .../Context/Turnstile/use-observe-script.ts | 26 ++ src/components/Context/Turnstile/utils.ts | 126 ++++++ src/components/Context/index.ts | 1 + src/components/Forms/EmailSignUpForm/Init.tsx | 31 +- .../AppreciationButton/index.tsx | 57 ++- 12 files changed, 1004 insertions(+), 17 deletions(-) create mode 100644 src/components/Context/Turnstile/container.tsx create mode 100644 src/components/Context/Turnstile/index.ts create mode 100644 src/components/Context/Turnstile/lib.tsx create mode 100644 src/components/Context/Turnstile/types.ts create mode 100644 src/components/Context/Turnstile/use-observe-script.ts create mode 100644 src/components/Context/Turnstile/utils.ts diff --git a/.env.dev b/.env.dev index e71fbcf7d4..951b40aa01 100644 --- a/.env.dev +++ b/.env.dev @@ -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 diff --git a/.env.prod b/.env.prod index b21a036455..0af85a78ac 100644 --- a/.env.prod +++ b/.env.prod @@ -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 diff --git a/src/common/enums/csp.ts b/src/common/enums/csp.ts index 998fe7e903..de556a8d1b 100644 --- a/src/common/enums/csp.ts +++ b/src/common/enums/csp.ts @@ -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/', @@ -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', diff --git a/src/components/Context/Turnstile/container.tsx b/src/components/Context/Turnstile/container.tsx new file mode 100644 index 0000000000..a6c1d69ce2 --- /dev/null +++ b/src/components/Context/Turnstile/container.tsx @@ -0,0 +1,21 @@ +import type { ComponentPropsWithoutRef, ElementType, ForwardedRef } from 'react' +import { forwardRef } from 'react' + +type Props = { + as?: Tag +} & ComponentPropsWithoutRef + +type ComponentProps = + Tag extends keyof JSX.IntrinsicElements + ? Props // @typescript-eslint/no-explicit-any + : // eslint-disable-next-line + Props + +const Component = ( + { as: Element = 'div', ...props }: ComponentProps, + ref: ForwardedRef +) => { + return +} + +export default forwardRef(Component) diff --git a/src/components/Context/Turnstile/index.ts b/src/components/Context/Turnstile/index.ts new file mode 100644 index 0000000000..c98623ccdf --- /dev/null +++ b/src/components/Context/Turnstile/index.ts @@ -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' diff --git a/src/components/Context/Turnstile/lib.tsx b/src/components/Context/Turnstile/lib.tsx new file mode 100644 index 0000000000..dc29d2be25 --- /dev/null +++ b/src/components/Context/Turnstile/lib.tsx @@ -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(null) + const firstRendered = useRef(false) + const [widgetId, setWidgetId] = useState() + 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 ( + + ) +}) + +Turnstile.displayName = 'Turnstile' + +export default Turnstile diff --git a/src/components/Context/Turnstile/types.ts b/src/components/Context/Turnstile/types.ts new file mode 100644 index 0000000000..1eef131232 --- /dev/null +++ b/src/components/Context/Turnstile/types.ts @@ -0,0 +1,428 @@ +declare global { + interface Window { + turnstile?: _Turnstile + } +} + +/* Available methods in the turnstile instance. */ +interface _Turnstile { + /** + * Method to explicit render a widget. + * @param container - Element ID or HTML node element. + * @param params - Optional. Render parameter options. See {@link https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#configurations the docs} for more info about this options. + * @returns The rendered widget ID. + */ + render: ( + container?: string | HTMLElement, + params?: RenderOptions + ) => string | undefined + + /** + * Method to render a widget when `execution` is set to `'execute'`. This method should be called after the `.render()` method. If `execution` is set to `'render'` this method has no effect. + * @param container - Element ID or HTML node element. + * @param params - Optional. Render parameter options. See {@link https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#configurations the docs} for more info about this options. + */ + execute: (container?: string | HTMLElement, params?: RenderOptions) => void + + /** + * Method to reset a widget. + * @param id - Optional. ID of the widget to reset, if not provided will target the last rendered widget. + */ + reset: (id?: string) => void + + /** + * Method to remove a widget. + * @param id - Optional. ID of the widget to remove, if not provided will target the last rendered widget. + */ + remove: (id?: string) => void + + /** + * Method to get the token of a widget. + * @param id - Optional. ID of the widget to get the token from, if not provided will target the last rendered widget. + * @returns The token response. + */ + getResponse: (id?: string) => string | undefined +} + +/* Same as _Turnstile but without custom widget IDs. */ +interface TurnstileInstance { + /** + * Method to explicit render a widget. + * @returns The rendered widget ID. + */ + render: () => string | undefined + + /** + * Method to render a widget when `options.execution` is set to `'execute'`. This method should be called after the `.render()` method. If `options.execution` is set to `'render'` this method has no effect. + */ + execute: () => void + + /** + * Method to reset the current rendered widget. + */ + reset: () => void + + /** + * Method to remove the current rendered widget. + */ + remove: () => void + + /** + * Method to get the token of the current rendered widget. + * @returns The token response. + */ + getResponse: () => string | undefined +} + +interface RenderOptions { + /** + * The sitekey of your widget. This sitekey is created upon the widget creation. + */ + sitekey: string + + /** + * A customer value that can be used to differentiate widgets under the same sitekey in analytics and which is returned upon validation. This can only contain up to 32 alphanumeric characters including _ and -. + * @default undefined + */ + action?: string + + /** + * A customer payload that can be used to attach customer data to the challenge throughout its issuance and which is returned upon validation. This can only contain up to 255 alphanumeric characters including _ and -. + * @default undefined + */ + cData?: string + + /** + * Callback invoked upon success of the challenge. The callback is passed a token that can be validated. + * @param token - Token response. + */ + callback?: (token: string) => void + + /** + * Callback invoked when there is an error (e.g. network error or the challenge failed). + */ + 'error-callback'?: () => void + + /** + * Execution controls when to obtain the token of the widget and can be on `'render'` (default) or on `'execute'`. See {@link https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#execution-modes the docs} for more info. + */ + execution?: 'render' | 'execute' + + /** + * Callback invoked when a challenge expires and does not reset the widget. + */ + 'expired-callback'?: () => void + + /** + * Callback invoked before the challenge enters interactive mode. + */ + 'before-interactive-callback'?: () => void + + /** + * Callback invoked when challenge has left interactive mode. + */ + 'after-interactive-callback'?: () => void + + /** + * Callback invoked when a given client/browser is not supported by Turnstile. + */ + 'unsupported-callback'?: () => void + + /** + * The widget theme. This can be forced to light or dark by setting the theme accordingly. + * + * @default `auto` + */ + theme?: TurnstileTheme + + /** + * 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`). + * @default `auto` + */ + language?: 'auto' | TurnstileLangCode | (string & Record) + + /** + * The tabindex of Turnstile’s iframe for accessibility purposes. + * @default 0 + */ + tabindex?: number + + /** + * A boolean that controls if an input element with the response token is created. + * @default true + */ + 'response-field'?: boolean + + /** + * Name of the input element. + * @default `cf-turnstile-response` + */ + 'response-field-name'?: string + + /** + * The widget size. Can take the following values: `normal`, `compact`. The normal size is 300x65px, the compact size is 130x120px. + * @default `normal` + */ + size?: 'normal' | 'compact' + + /** + * Controls whether the widget should automatically retry to obtain a token if it did not succeed. The default is `'auto'`, which will retry automatically. This can be set to `'never'` to disable retry upon failure. + * @default `auto` + */ + retry?: 'auto' | 'never' + + /** + * When `retry` is set to `'auto'`, `retry-interval` controls the time between retry attempts in milliseconds. The value must be a positive integer less than `900000`. When `retry` is set to `'never'`, this parameter has no effect. + * @default 8000 + */ + 'retry-interval'?: number + + /** + * Automatically refreshes the token when it expires. Can take `'auto'`, `'manual'` or `'never'`. + * @default `auto` + */ + 'refresh-expired'?: 'auto' | 'manual' | 'never' + + /** + * Appearance controls when the widget is visible. It can be `'always'` (default), `'execute'`, or `'interaction-only'`. See {@link https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#appearance-modes the docs} for more info. + */ + appearance?: 'always' | 'execute' | 'interaction-only' +} + +/** Props needed for the `options` prop in the `` component. */ +interface ComponentRenderOptions + extends Pick< + RenderOptions, + | 'action' + | 'cData' + | 'theme' + | 'retry' + | 'language' + | 'execution' + | 'appearance' + > { + /** + * The tabindex of Turnstile’s iframe for accessibility purposes. + * @default 0 + */ + tabIndex?: RenderOptions['tabindex'] + + /** + * A boolean that controls if an input element with the response token is created. + * @default true + */ + responseField?: RenderOptions['response-field'] + + /** + * Name of the input element. + * @default `cf-turnstile-response` + */ + responseFieldName?: RenderOptions['response-field-name'] + + /** + * When `retry` is set to `'auto'`, `retryInterval` controls the time between retry attempts in milliseconds. The value must be a positive integer less than `900000`. When `retry` is set to `'never'`, this parameter has no effect. + * @default 8000 + */ + retryInterval?: RenderOptions['retry-interval'] + + /** + * The widget size. Can take the following values: `normal`, `compact`, and `invisible`. The normal size is 300x65px, the compact size is 130x120px, invisible will show no widget. + * @default `normal` + */ + size?: RenderOptions['size'] | 'invisible' + + /** + * Automatically refreshes the token when it expires. Can take `'auto'`, `'manual'` or `'never'`. + * @default `auto` + */ + refreshExpired?: RenderOptions['refresh-expired'] +} + +/** Custom options for the injected script. */ +interface ScriptOptions { + /** + * Custom nonce for the injected script. + * @default undefined + */ + nonce?: string + + /** + * Define if set the injected script as defer. + * @default true + */ + defer?: boolean + + /** + * Define if set the injected script as async. + * @default true + */ + async?: boolean + + /** + * compat mode for migrating. + * @default undefined + */ + compat?: 'recaptcha' + + /** + * Define if inject the script in the head or in the body. + * @default `head` + */ + appendTo?: 'head' | 'body' + + /** + * Custom ID of the injected script. + * @default `cf-turnstile-script` + */ + id?: string + + /** + * Custom name of the onload callback. + * @default `onloadTurnstileCallback` + */ + onLoadCallbackName?: string +} + +/** `` component props */ +interface TurnstileProps extends React.HTMLAttributes { + /** + * The sitekey of your widget. This sitekey is created upon the widget creation. + */ + siteKey: RenderOptions['sitekey'] + + /** + * Callback invoked upon success of the challenge. The callback is passed a token that can be validated. + * @param token - Token response. + */ + onSuccess?: RenderOptions['callback'] + + /** + * Callback invoked when a challenge expires and does not reset the widget. + */ + onExpire?: RenderOptions['expired-callback'] + + /** + * Callback invoked when there is an error (e.g. network error or the challenge failed). + */ + onError?: RenderOptions['error-callback'] + + /** + * Callback invoked before the challenge enters interactive mode. + */ + onBeforeInteractive?: RenderOptions['before-interactive-callback'] + + /** + * Callback invoked when challenge has left interactive mode. + */ + onAfterInteractive?: RenderOptions['after-interactive-callback'] + + /** + * Callback invoked when a given client/browser is not supported by Turnstile. + */ + onUnsupported?: RenderOptions['unsupported-callback'] + + /** + * Custom widget render options. See {@link https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#configurations the docs} for more info about this options. + */ + options?: ComponentRenderOptions + + /** + * Custom injected script options. + */ + scriptOptions?: ScriptOptions + + /** + * Define the HTML tag of the widget container. Default to `'div'`. + */ + as?: React.ElementType + + /** + * Controls if the script is automatically injected or not. If you want to inject the script manually, set this property to `false`. Default to `true`. + */ + injectScript?: boolean +} + +interface InjectTurnstileScriptParams { + render?: string + onLoadCallbackName?: string + scriptOptions?: Omit +} + +type ContainerSizeSet = { + [size in + | NonNullable + | 'interactionOnly']: React.CSSProperties +} + +type TurnstileLangCode = + | 'ar' + | 'ar-EG' + | 'de' + | 'en' + | 'es' + | 'fa' + | 'fr' + | 'id' + | 'it' + | 'ja' + | 'ko' + | 'nl' + | 'pl' + | 'pt' + | 'pt-BR' + | 'ru' + | 'tr' + | 'zh-CN' + | 'zh-TW' + +type TurnstileTheme = 'light' | 'dark' | 'auto' + +interface TurnstileServerValidationResponse { + /** Indicate if the token validation was successful or not. */ + success: boolean + /** The ISO timestamp for the time the challenge was solved. */ + challenge_ts?: string + /** The hostname for which the challenge was served. */ + hostname?: string + /** A list of errors that occurred. */ + 'error-codes'?: TurnstileServerValidationErrorCode[] + /** The customer widget identifier passed to the widget on the client side. This is used to differentiate widgets using the same sitekey in analytics. Its integrity is protected by modifications from an attacker. It is recommended to validate that the action matches an expected value. */ + action?: string + /** The customer data passed to the widget on the client side. This can be used by the customer to convey state. It is integrity protected by modifications from an attacker. */ + cdata?: string +} + +/** + * See {@link https://developers.cloudflare.com/turnstile/get-started/server-side-validation/#error-codes the docs} for more info about this error codes. + */ +type TurnstileServerValidationErrorCode = + /** The secret parameter was not passed. */ + | 'missing-input-secret' + /** The secret parameter was invalid or did not exist. */ + | 'invalid-input-secret' + /** The response parameter was not passed. */ + | 'missing-input-response' + /** The response parameter is invalid or has expired. */ + | 'invalid-input-response' + /** The widget ID extracted from the parsed site secret key was invalid or did not exist. */ + | 'invalid-widget-id' + /** The secret extracted from the parsed site secret key was invalid. */ + | 'invalid-parsed-secret' + /** The request was rejected because it was malformed. */ + | 'bad-request' + /** The response parameter has already been validated before. */ + | 'timeout-or-duplicate' + /** An internal error happened while validating the response. The request can be retried. */ + | 'internal-error' + +export type { + ContainerSizeSet, + InjectTurnstileScriptParams, + RenderOptions, + TurnstileInstance, + TurnstileLangCode, + TurnstileProps, + TurnstileServerValidationErrorCode, + TurnstileServerValidationResponse, + TurnstileTheme, +} diff --git a/src/components/Context/Turnstile/use-observe-script.ts b/src/components/Context/Turnstile/use-observe-script.ts new file mode 100644 index 0000000000..92e388eb1d --- /dev/null +++ b/src/components/Context/Turnstile/use-observe-script.ts @@ -0,0 +1,26 @@ +import { useEffect, useState } from 'react' + +import { checkElementExistence, DEFAULT_SCRIPT_ID } from './utils' + +export default function useObserveScript(scriptId = DEFAULT_SCRIPT_ID) { + const [scriptLoaded, setScriptLoaded] = useState(false) + + useEffect(() => { + const checkScriptExists = () => { + if (checkElementExistence(scriptId)) { + setScriptLoaded(true) + } + } + + const observer = new MutationObserver(checkScriptExists) + observer.observe(document, { childList: true, subtree: true }) + + checkScriptExists() + + return () => { + observer.disconnect() + } + }, [scriptId]) + + return scriptLoaded +} diff --git a/src/components/Context/Turnstile/utils.ts b/src/components/Context/Turnstile/utils.ts new file mode 100644 index 0000000000..7bd4770528 --- /dev/null +++ b/src/components/Context/Turnstile/utils.ts @@ -0,0 +1,126 @@ +import { + ContainerSizeSet, + InjectTurnstileScriptParams, + RenderOptions, +} from './types' + +export const SCRIPT_URL = + 'https://challenges.cloudflare.com/turnstile/v0/api.js' +export const DEFAULT_SCRIPT_ID = 'cf-turnstile-script' +export const DEFAULT_CONTAINER_ID = 'cf-turnstile' +export const DEFAULT_ONLOAD_NAME = 'onloadTurnstileCallback' + +/** + * Function to check if an element with the given id exists in the document. + * + * @param id Id of the element to check. + * @returns + */ +export const checkElementExistence = (id: string) => + !!document.getElementById(id) + +/** + * Function to inject the cloudflare turnstile script + * + * @param param0 + * @returns + */ +export const injectTurnstileScript = ({ + render = 'explicit', + onLoadCallbackName = DEFAULT_ONLOAD_NAME, + scriptOptions: { + nonce = '', + defer = true, + async = true, + id = '', + compat, + appendTo, + } = {}, +}: InjectTurnstileScriptParams) => { + const scriptId = id || DEFAULT_SCRIPT_ID + + if (checkElementExistence(scriptId)) { + return + } + + const script = document.createElement('script') + script.id = scriptId + + script.src = `${SCRIPT_URL}?onload=${onLoadCallbackName}&render=${render}` + if (compat) { + script.src = script.src + `&compat=${compat}` + } + + // Prevent duplicate script injection with the same src + if (document.querySelector(`script[src="${script.src}"]`)) { + return + } + + script.defer = !!defer + script.async = !!async + + if (nonce) { + script.nonce = nonce + } + + const parentEl = + appendTo === 'body' + ? document.body + : document.getElementsByTagName('head')[0] + + parentEl!.appendChild(script) +} + +/** + * A list of possible sizes for the container to reserve a place for the widget + * to load. + * + * A note for `invisible` size: The option added for the Invisible type of + * Turnstile. Invisible Turnstile will not show any of the widget, hence there + * is no height and width set to the style. This is only consumed in this + * library, and will not be forwarded to Turnstile. See + * {@link https://github.com/marsidev/react-turnstile/issues/7 marsidev/react-turnstile#7} + * to learn more. + * + * @link https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#widget-size + * + * @link https://developers.cloudflare.com/turnstile/reference/widget-types/#invisible + */ +export const CONTAINER_STYLE_SET: ContainerSizeSet = { + normal: { + width: 300, + height: 65, + }, + compact: { + width: 130, + height: 120, + }, + invisible: { + width: 0, + height: 0, + overflow: 'hidden', + }, + interactionOnly: { + width: 'fit-content', + height: 'auto', + }, +} + +/** + * Convert the size from component props, and filter it for Turnstile parameters + * while keeping the types. + * + * @param size Size from props. + * @returns + */ +export function getTurnstileSizeOpts( + size: keyof ContainerSizeSet +): RenderOptions['size'] { + let result: RenderOptions['size'] + + if (size !== 'invisible') { + result = size as RenderOptions['size'] + } + + return result +} diff --git a/src/components/Context/index.ts b/src/components/Context/index.ts index aad5d0177e..35f6927a7e 100644 --- a/src/components/Context/index.ts +++ b/src/components/Context/index.ts @@ -1,4 +1,5 @@ export * from './Features' export * from './Language' export * from './ReCaptcha' +export * from './Turnstile' export * from './Viewer' diff --git a/src/components/Forms/EmailSignUpForm/Init.tsx b/src/components/Forms/EmailSignUpForm/Init.tsx index b5265853e7..bf16ebb00b 100644 --- a/src/components/Forms/EmailSignUpForm/Init.tsx +++ b/src/components/Forms/EmailSignUpForm/Init.tsx @@ -1,6 +1,6 @@ import { useFormik } from 'formik' import _pickBy from 'lodash/pickBy' -import { useContext } from 'react' +import { useContext, useRef } from 'react' import { FormattedMessage, useIntl } from 'react-intl' import { @@ -20,9 +20,13 @@ import { Media, ReCaptchaContext, TextIcon, + Turnstile, + TurnstileInstance, useMutation, + ViewerContext, } from '~/components' import SEND_CODE from '~/components/GQL/mutations/sendCode' +import { UserGroup } from '~/gql/graphql' import { SendVerificationCodeMutation } from '~/gql/graphql' import styles from './styles.module.css' @@ -52,6 +56,7 @@ const Init: React.FC = ({ setAuthFeedType, back, }) => { + const viewer = useContext(ViewerContext) const { lang } = useContext(LanguageContext) const formId = 'email-sign-up-init-form' @@ -60,8 +65,8 @@ const Init: React.FC = ({ const isNormal = authFeedType === 'normal' const isWallet = authFeedType === 'wallet' const { token, refreshToken } = useContext(ReCaptchaContext) + const turnstileRef = useRef(null) - // const { token, refreshToken } = useContext(ReCaptchaContext) const [sendCode] = useMutation( SEND_CODE, undefined, @@ -93,7 +98,15 @@ const Init: React.FC = ({ const redirectUrl = signupCallbackUrl(email) await sendCode({ variables: { - input: { email, type: 'register', token, redirectUrl }, + input: { + email, + type: 'register', + token: + (viewer.info.group === UserGroup.A && + turnstileRef.current?.getResponse()) || // fallback to ReCaptchaContext token + token, + redirectUrl, + }, }, }) @@ -105,15 +118,21 @@ const Init: React.FC = ({ const [messages, codes] = parseFormSubmitErrors(error as any) setFieldError('email', intl.formatMessage(messages[codes[0]])) - if (refreshToken) { - refreshToken() - } + refreshToken?.() + turnstileRef.current?.reset() } }, }) + const siteKey = process.env + .NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITE_KEY as string const InnerForm = (
+ } type="email" diff --git a/src/views/ArticleDetail/AppreciationButton/index.tsx b/src/views/ArticleDetail/AppreciationButton/index.tsx index 4691d137d4..90c17e5f4e 100644 --- a/src/views/ArticleDetail/AppreciationButton/index.tsx +++ b/src/views/ArticleDetail/AppreciationButton/index.tsx @@ -1,5 +1,6 @@ import { useQuery } from '@apollo/react-hooks' -import { useContext, useState } from 'react' +import Script from 'next/script' +import { useContext, useRef, useState } from 'react' import { useDebouncedCallback } from 'use-debounce' import { APPRECIATE_DEBOUNCE, EXTERNAL_LINKS, Z_INDEX } from '~/common/enums' @@ -8,11 +9,16 @@ import { toast, Tooltip, Translate, + Turnstile, + TURNSTILE_DEFAULT_SCRIPT_ID, + // TURNSTILE_SCRIPT_URL, + TurnstileInstance, useMutation, ViewerContext, } from '~/components' import { updateAppreciation } from '~/components/GQL' import CLIENT_PREFERENCE from '~/components/GQL/queries/clientPreference' +import { UserGroup } from '~/gql/graphql' import { AppreciateArticleMutation, AppreciationButtonArticlePrivateFragment, @@ -41,7 +47,10 @@ const AppreciationButton = ({ disabled, }: AppreciationButtonProps) => { const viewer = useContext(ViewerContext) + + const turnstileRef = useRef(null) const { token, refreshToken } = useContext(ReCaptchaContext) + const { data, client } = useQuery(CLIENT_PREFERENCE, { variables: { id: 'local' }, }) @@ -67,11 +76,17 @@ const AppreciationButton = ({ variables: { id: article.id, amount, - token, + token: + (viewer.info.group === UserGroup.A && + turnstileRef.current?.getResponse()) || // fallback to ReCaptchaContext token + token, }, - }).then(refreshToken) + }) // .then(refreshToken) } catch (e) { console.error(e) + } finally { + refreshToken?.() + turnstileRef.current?.reset() } }, APPRECIATE_DEBOUNCE) @@ -87,7 +102,11 @@ const AppreciationButton = ({ variables: { id: article.id, amount: 1, - token, + // turnstileRef.current?.getResponse(), + token: + (viewer.info.group === UserGroup.A && + turnstileRef.current?.getResponse()) || // fallback to ReCaptchaContext token + token, superLike: true, }, update: (cache) => { @@ -213,16 +232,32 @@ const AppreciationButton = ({ return } + const siteKey = process.env + .NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITE_KEY as string // Appreciable if (canAppreciate && !disabled) { return ( - 0 ? appreciatedCount : undefined} - total={total} - isSuperLike={isSuperLike} - superLiked={superLiked} - /> + <> + + 0 ? appreciatedCount : undefined} + total={total} + isSuperLike={isSuperLike} + superLiked={superLiked} + /> +