diff options
Diffstat (limited to 'src/screens/Signup/StepCaptcha')
-rw-r--r-- | src/screens/Signup/StepCaptcha/CaptchaWebView.tsx | 87 | ||||
-rw-r--r-- | src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx | 61 | ||||
-rw-r--r-- | src/screens/Signup/StepCaptcha/index.tsx | 95 |
3 files changed, 243 insertions, 0 deletions
diff --git a/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx b/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx new file mode 100644 index 000000000..50918c4ce --- /dev/null +++ b/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx @@ -0,0 +1,87 @@ +import React from 'react' +import {StyleSheet} from 'react-native' +import {WebView, WebViewNavigation} from 'react-native-webview' +import {ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes' + +import {SignupState} from '#/screens/Signup/state' + +const ALLOWED_HOSTS = [ + 'bsky.social', + 'bsky.app', + 'staging.bsky.app', + 'staging.bsky.dev', + 'js.hcaptcha.com', + 'newassets.hcaptcha.com', + 'api2.hcaptcha.com', +] + +export function CaptchaWebView({ + url, + stateParam, + state, + onSuccess, + onError, +}: { + url: string + stateParam: string + state?: SignupState + onSuccess: (code: string) => void + onError: () => void +}) { + const redirectHost = React.useMemo(() => { + if (!state?.serviceUrl) return 'bsky.app' + + return state?.serviceUrl && + new URL(state?.serviceUrl).host === 'staging.bsky.dev' + ? 'staging.bsky.app' + : 'bsky.app' + }, [state?.serviceUrl]) + + const wasSuccessful = React.useRef(false) + + const onShouldStartLoadWithRequest = React.useCallback( + (event: ShouldStartLoadRequest) => { + const urlp = new URL(event.url) + return ALLOWED_HOSTS.includes(urlp.host) + }, + [], + ) + + const onNavigationStateChange = React.useCallback( + (e: WebViewNavigation) => { + if (wasSuccessful.current) return + + const urlp = new URL(e.url) + if (urlp.host !== redirectHost) return + + const code = urlp.searchParams.get('code') + if (urlp.searchParams.get('state') !== stateParam || !code) { + onError() + return + } + + wasSuccessful.current = true + onSuccess(code) + }, + [redirectHost, stateParam, onSuccess, onError], + ) + + return ( + <WebView + source={{uri: url}} + javaScriptEnabled + style={styles.webview} + onShouldStartLoadWithRequest={onShouldStartLoadWithRequest} + onNavigationStateChange={onNavigationStateChange} + scrollEnabled={false} + /> + ) +} + +const styles = StyleSheet.create({ + webview: { + flex: 1, + backgroundColor: 'transparent', + borderRadius: 10, + }, +}) diff --git a/src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx b/src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx new file mode 100644 index 000000000..7791a58dd --- /dev/null +++ b/src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import {StyleSheet} from 'react-native' + +// @ts-ignore web only, we will always redirect to the app on web (CORS) +const REDIRECT_HOST = new URL(window.location.href).host + +export function CaptchaWebView({ + url, + stateParam, + onSuccess, + onError, +}: { + url: string + stateParam: string + onSuccess: (code: string) => void + onError: () => void +}) { + const onLoad = React.useCallback(() => { + // @ts-ignore web + const frame: HTMLIFrameElement = document.getElementById( + 'captcha-iframe', + ) as HTMLIFrameElement + + try { + // @ts-ignore web + const href = frame?.contentWindow?.location.href + if (!href) return + const urlp = new URL(href) + + // This shouldn't happen with CORS protections, but for good measure + if (urlp.host !== REDIRECT_HOST) return + + const code = urlp.searchParams.get('code') + if (urlp.searchParams.get('state') !== stateParam || !code) { + onError() + return + } + onSuccess(code) + } catch (e) { + // We don't need to handle this + } + }, [stateParam, onSuccess, onError]) + + return ( + <iframe + src={url} + style={styles.iframe} + id="captcha-iframe" + onLoad={onLoad} + /> + ) +} + +const styles = StyleSheet.create({ + iframe: { + flex: 1, + borderWidth: 0, + borderRadius: 10, + backgroundColor: 'transparent', + }, +}) diff --git a/src/screens/Signup/StepCaptcha/index.tsx b/src/screens/Signup/StepCaptcha/index.tsx new file mode 100644 index 000000000..311c697e7 --- /dev/null +++ b/src/screens/Signup/StepCaptcha/index.tsx @@ -0,0 +1,95 @@ +import React from 'react' +import {ActivityIndicator, StyleSheet, View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {nanoid} from 'nanoid/non-secure' + +import {createFullHandle} from '#/lib/strings/handles' +import {isWeb} from '#/platform/detection' +import {ScreenTransition} from '#/screens/Login/ScreenTransition' +import {useSignupContext, useSubmitSignup} from '#/screens/Signup/state' +import {CaptchaWebView} from '#/screens/Signup/StepCaptcha/CaptchaWebView' +import {atoms as a, useTheme} from '#/alf' +import {FormError} from '#/components/forms/FormError' + +const CAPTCHA_PATH = '/gate/signup' + +export function StepCaptcha() { + const {_} = useLingui() + const theme = useTheme() + const {state, dispatch} = useSignupContext() + const submit = useSubmitSignup({state, dispatch}) + + const [completed, setCompleted] = React.useState(false) + + const stateParam = React.useMemo(() => nanoid(15), []) + const url = React.useMemo(() => { + const newUrl = new URL(state.serviceUrl) + newUrl.pathname = CAPTCHA_PATH + newUrl.searchParams.set( + 'handle', + createFullHandle(state.handle, state.userDomain), + ) + newUrl.searchParams.set('state', stateParam) + newUrl.searchParams.set('colorScheme', theme.name) + + return newUrl.href + }, [state.serviceUrl, state.handle, state.userDomain, stateParam, theme.name]) + + const onSuccess = React.useCallback( + (code: string) => { + setCompleted(true) + submit(code) + }, + [submit], + ) + + const onError = React.useCallback(() => { + dispatch({ + type: 'setError', + value: _(msg`Error receiving captcha response.`), + }) + }, [_, dispatch]) + + return ( + <ScreenTransition> + <View style={[a.gap_lg]}> + <View style={[styles.container, completed && styles.center]}> + {!completed ? ( + <CaptchaWebView + url={url} + stateParam={stateParam} + state={state} + onSuccess={onSuccess} + onError={onError} + /> + ) : ( + <ActivityIndicator size="large" /> + )} + </View> + <FormError error={state.error} /> + </View> + </ScreenTransition> + ) +} + +const styles = StyleSheet.create({ + error: { + borderRadius: 6, + marginTop: 10, + }, + // @ts-expect-error: Suppressing error due to incomplete `ViewStyle` type definition in react-native-web, missing `cursor` prop as discussed in https://github.com/necolas/react-native-web/issues/832. + touchable: { + ...(isWeb && {cursor: 'pointer'}), + }, + container: { + minHeight: 500, + width: '100%', + paddingBottom: 20, + overflow: 'hidden', + }, + center: { + alignItems: 'center', + justifyContent: 'center', + }, +}) |