@hcaptcha/react-native-hcaptcha
Version:
hCaptcha Library for React Native (both Android and iOS)
295 lines (273 loc) • 10.8 kB
JavaScript
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;