UNPKG

@hcaptcha/react-native-hcaptcha

Version:

hCaptcha Library for React Native (both Android and iOS)

295 lines (273 loc) 10.8 kB
import React, { useEffect, useMemo, useRef, useState } from 'react'; import WebView from 'react-native-webview'; import { ActivityIndicator, Linking, StyleSheet, TouchableWithoutFeedback, View } from 'react-native'; import ReactNativeVersion from 'react-native/Libraries/Core/ReactNativeVersion'; import md5 from './md5'; import hcaptchaPackage from './package.json'; const patchPostMessageJsCode = `(${String(function () { var originalPostMessage = window.ReactNativeWebView.postMessage; var patchedPostMessage = function (message, targetOrigin, transfer) { originalPostMessage(message, targetOrigin, transfer); }; patchedPostMessage.toString = function () { return String(Object.hasOwnProperty).replace( 'hasOwnProperty', 'postMessage' ); }; window.ReactNativeWebView.postMessage = patchedPostMessage; })})();`; const buildHcaptchaApiUrl = (jsSrc, siteKey, hl, theme, host, sentry, endpoint, assethost, imghost, reportapi, orientation) => { var url = `${jsSrc || 'https://hcaptcha.com/1/api.js'}?render=explicit&onload=onloadCallback`; let effectiveHost; if (host) { effectiveHost = encodeURIComponent(host); } else { effectiveHost = (siteKey || 'missing-sitekey') + '.react-native.hcaptcha.com'; } for (let [key, value] of Object.entries({ host: effectiveHost, hl, custom: typeof theme === 'object', sentry, endpoint, assethost, imghost, reportapi, orientation })) { if (value) { url += `&${key}=${encodeURIComponent(value)}`; } } return url; }; /** * * @param {*} onMessage: callback after receiving response, error, or when user cancels * @param {*} siteKey: your hCaptcha sitekey * @param {string} size: The size of the checkbox, can be 'invisible', 'compact' or 'checkbox', Default: 'invisible' * @param {*} style: custom style * @param {*} url: base url * @param {*} languageCode: can be found at https://docs.hcaptcha.com/languages * @param {*} showLoading: loading indicator for webview till hCaptcha web content loads * @param {*} closableLoading: allow user to cancel hcaptcha during loading by touch loader overlay * @param {*} loadingIndicatorColor: color for the ActivityIndicator * @param {*} backgroundColor: backgroundColor which can be injected into HTML to alter css backdrop colour * @param {string|object} theme: can be 'light', 'dark', 'contrast' or custom theme object * @param {string} rqdata: see Enterprise docs * @param {boolean} sentry: sentry error reporting * @param {string} jsSrc: The url of api.js. Default: https://js.hcaptcha.com/1/api.js (Override only if using first-party hosting feature.) * @param {string} endpoint: Point hCaptcha JS Ajax Requests to alternative API Endpoint. Default: https://api.hcaptcha.com (Override only if using first-party hosting feature.) * @param {string} reportapi: Point hCaptcha Bug Reporting Request to alternative API Endpoint. Default: https://accounts.hcaptcha.com (Override only if using first-party hosting feature.) * @param {string} assethost: Points loaded hCaptcha assets to a user defined asset location, used for proxies. Default: https://newassets.hcaptcha.com (Override only if using first-party hosting feature.) * @param {string} imghost: Points loaded hCaptcha challenge images to a user defined image location, used for proxies. Default: https://imgs.hcaptcha.com (Override only if using first-party hosting feature.) * @param {string} host: hCaptcha SDK host identifier. null value means that it will be generated by SDK * @param {object} debug: debug information * @param {string} orientation: hCaptcha challenge orientation */ const Hcaptcha = ({ onMessage, size, siteKey, style, url, languageCode, showLoading, closableLoading, loadingIndicatorColor, backgroundColor, theme, rqdata, sentry, jsSrc, endpoint, reportapi, assethost, imghost, host, debug, orientation, }) => { const apiUrl = buildHcaptchaApiUrl(jsSrc, siteKey, languageCode, theme, host, sentry, endpoint, assethost, imghost, reportapi, orientation); const tokenTimeout = 120000; const loadingTimeout = 15000; const [isLoading, setIsLoading] = useState(true); if (theme && typeof theme === 'string') { try { JSON.parse(theme); } catch (_) { theme = `"${theme}"`; } } if (theme && typeof theme === 'object') { theme = `${JSON.stringify(theme)}`; } if (rqdata && typeof rqdata === 'string') { rqdata = `"${rqdata}"`; } const debugInfo = useMemo( () => { var result = debug || {}; try { const {major, minor, patch} = ReactNativeVersion.version; result[`rnver_${major}_${minor}_${patch}`] = true; result['dep_' + md5(Object.keys(global).join(''))] = true; result['sdk_' + hcaptchaPackage.version.toString().replace(/\./g, '_')] = true; } catch (e) { console.log(e); } finally { return result; } }, [debug] ); const generateTheWebViewContent = useMemo( () => `<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <script type="text/javascript"> Object.entries(${JSON.stringify(debugInfo)}).forEach(function (entry) { window[entry[0]] = entry[1] }) </script> <script src="${apiUrl}" async defer></script> <script type="text/javascript"> var onloadCallback = function() { try { console.log("challenge onload starting"); hcaptcha.render("hcaptcha-container", getRenderConfig("${siteKey || ''}", ${theme}, "${size || 'invisible'}")); // have loaded by this point; render is sync. console.log("challenge render complete"); } catch (e) { console.log("challenge failed to render:", e); window.ReactNativeWebView.postMessage(e.name); } try { console.log("showing challenge"); hcaptcha.execute(getExecuteOpts()); } catch (e) { console.log("failed to show challenge:", e); window.ReactNativeWebView.postMessage(e.name); } }; var onDataCallback = function(response) { window.ReactNativeWebView.postMessage(response); }; var onCancel = function() { window.ReactNativeWebView.postMessage("challenge-closed"); }; var onOpen = function() { document.body.style.backgroundColor = '${backgroundColor}'; window.ReactNativeWebView.postMessage("open"); console.log("challenge opened"); }; var onDataExpiredCallback = function(error) { window.ReactNativeWebView.postMessage(error); }; var onChalExpiredCallback = function(error) { window.ReactNativeWebView.postMessage(error); }; var onDataErrorCallback = function(error) { console.warn("challenge error callback fired"); window.ReactNativeWebView.postMessage(error); }; const getRenderConfig = function(siteKey, theme, size) { var config = { sitekey: siteKey, size: size, callback: onDataCallback, "close-callback": onCancel, "open-callback": onOpen, "expired-callback": onDataExpiredCallback, "chalexpired-callback": onChalExpiredCallback, "error-callback": onDataErrorCallback }; if (theme) { config.theme = theme; } return config; }; const getExecuteOpts = function() { var opts; const rqdata = ${rqdata}; if (rqdata) { opts = {"rqdata": rqdata}; } return opts; }; </script> </head> <body> <div id="hcaptcha-container"></div> </body> </html>`, [debugInfo, apiUrl, siteKey, theme, size, backgroundColor, rqdata] ); useEffect(() => { const timeoutId = setTimeout(() => { if (isLoading) { onMessage({ nativeEvent: { data: 'error', description: 'loading timeout' } }); } }, loadingTimeout); return () => clearTimeout(timeoutId); }, [isLoading, onMessage]); const webViewRef = useRef(null); // This shows ActivityIndicator till webview loads hCaptcha images const renderLoading = () => ( <TouchableWithoutFeedback onPress={() => closableLoading && onMessage({ nativeEvent: { data: 'cancel' } })}> <View style={styles.loadingOverlay}> <ActivityIndicator size="large" color={loadingIndicatorColor} /> </View> </TouchableWithoutFeedback> ); const reset = () => { if (webViewRef.current) { webViewRef.current.injectJavaScript('onloadCallback();'); } }; return ( <View style={{ flex: 1 }}> <WebView ref={webViewRef} originWhitelist={['*']} onShouldStartLoadWithRequest={(event) => { if (event.url.slice(0, 24) === 'https://www.hcaptcha.com') { Linking.openURL(event.url); return false; } else if (event.url.toLowerCase().startsWith('sms:')) { Linking.openURL(event.url).catch((err) => { onMessage({ nativeEvent: { data: 'sms-open-failed', description: err.message, }, success: false }); }); return false; } return true; }} mixedContentMode={'always'} onMessage={(e) => { e.reset = reset; e.success = true; if (e.nativeEvent.data === 'open') { setIsLoading(false); } else if (e.nativeEvent.data.length > 35) { const expiredTokenTimerId = setTimeout(() => onMessage({ nativeEvent: { data: 'expired' }, success: false, reset }), tokenTimeout); e.markUsed = () => clearTimeout(expiredTokenTimerId); } else /* error */ { e.success = false; } onMessage(e); }} javaScriptEnabled injectedJavaScript={patchPostMessageJsCode} automaticallyAdjustContentInsets style={[{ backgroundColor: 'transparent', width: '100%' }, style]} source={{ html: generateTheWebViewContent, baseUrl: `${url}`, }} /> {showLoading && isLoading && renderLoading()} </View> ); }; const styles = StyleSheet.create({ loadingOverlay: { ...StyleSheet.absoluteFillObject, justifyContent: 'center', }, }); export default Hcaptcha;