Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 88 additions & 108 deletions Hcaptcha.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ import ReactNativeVersion from 'react-native/Libraries/Core/ReactNativeVersion';

import md5 from './md5';
import hcaptchaPackage from './package.json';
import { reportApiLoadFailure } from './loaderSentry';
import { generateWebViewContent } from './webviewContent';
import {
HCAPTCHA_LOADER_PREFIX,
HCAPTCHA_READY_EVENT,
parseInternalWebViewMessage,
} from './webviewMessages';
import {
clearJourneyEvents,
disableJourneyConsumer,
Expand All @@ -26,7 +33,8 @@ const patchPostMessageJsCode = `(${String(function () {
window.ReactNativeWebView.postMessage = patchedPostMessage;
})})();`;

const HCAPTCHA_READY_EVENT = '__hcaptcha_ready__';
const API_LOAD_MAX_RETRIES = 2;
const API_LOAD_RETRY_DELAY_MS = 1000;

const serializeForInlineScript = (value) =>
JSON.stringify(value)
Expand Down Expand Up @@ -146,7 +154,8 @@ const buildHcaptchaApiUrl = (jsSrc, siteKey, hl, theme, host, sentry, endpoint,
}

for (let [key, value] of Object.entries({ host: effectiveHost, hl, custom: typeof theme === 'object', sentry, endpoint, assethost, imghost, reportapi, orientation })) {
if (value) {
const shouldInclude = key === 'sentry' ? typeof value === 'boolean' : Boolean(value);
if (shouldInclude) {
url += `&${key}=${encodeURIComponent(value)}`;
}
}
Expand Down Expand Up @@ -215,6 +224,7 @@ const Hcaptcha = ({
const [isLoading, setIsLoading] = useState(true);
const journeyEnabled = Boolean(userJourney);
const hasJourneyConsumerRef = useRef(false);
const webViewRef = useRef(null);
const normalizedTheme = useMemo(() => normalizeTheme(theme), [theme]);
const normalizedSize = useMemo(() => normalizeSize(size), [size]);
const apiUrl = useMemo(
Expand All @@ -232,8 +242,10 @@ const Hcaptcha = ({
apiUrl,
backgroundColor: backgroundColor ?? '',
debugInfo,
maxRetries: API_LOAD_MAX_RETRIES,
phoneNumber: phoneNumber ?? null,
phonePrefix: phonePrefix ?? null,
retryDelay: API_LOAD_RETRY_DELAY_MS,
rqdata: rqdata ?? null,
siteKey: siteKey || '',
size: normalizedSize,
Expand All @@ -242,88 +254,12 @@ const Hcaptcha = ({
[apiUrl, backgroundColor, debugInfo, normalizedSize, normalizedTheme, phoneNumber, phonePrefix, rqdata, siteKey]
);

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">
var hcaptchaConfig = ${serializedWebViewConfig};
Object.entries(hcaptchaConfig.debugInfo || {}).forEach(function (entry) { window[entry[0]] = entry[1] });
</script>
<script type="text/javascript">
var loadApiScript = function() {
var script = document.createElement('script');
script.async = true;
script.defer = true;
script.src = hcaptchaConfig.apiUrl;
document.head.appendChild(script);
};
var hcaptchaWidgetId = null;
var setData = function(data) {
hcaptcha.setData(hcaptchaWidgetId, data || {});
};
var execute = function() {
hcaptcha.execute(hcaptchaWidgetId);
};
var reset = function() {
hcaptcha.reset(hcaptchaWidgetId);
};
var onloadCallback = function() {
try {
console.log("challenge onload starting");
hcaptchaWidgetId = hcaptcha.render("hcaptcha-container", getRenderConfig(hcaptchaConfig.siteKey, hcaptchaConfig.theme, hcaptchaConfig.size));
window.ReactNativeWebView.postMessage("${HCAPTCHA_READY_EVENT}");
// 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);
}
};
var onDataCallback = function(response) {
window.ReactNativeWebView.postMessage(response);
};
var onCancel = function() {
window.ReactNativeWebView.postMessage("challenge-closed");
};
var onOpen = function() {
document.body.style.backgroundColor = hcaptchaConfig.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;
};
loadApiScript();
</script>
</head>
<body>
<div id="hcaptcha-container"></div>
</body>
</html>`,
const webViewContent = useMemo(
() => generateWebViewContent({
loaderMessagePrefix: HCAPTCHA_LOADER_PREFIX,
readyEvent: HCAPTCHA_READY_EVENT,
serializedConfig: serializedWebViewConfig,
}),
[serializedWebViewConfig]
);

Expand Down Expand Up @@ -353,7 +289,12 @@ const Hcaptcha = ({
return () => clearTimeout(timeoutId);
}, [isLoading, onMessage]);

const webViewRef = useRef(null);
useEffect(() => () => {
if (webViewRef.current) {
webViewRef.current.injectJavaScript('cancelApiLoad(); true;');
}
}, []);

const injectVerifyData = (resetFirst = false) => {
if (!webViewRef.current) {
return;
Expand Down Expand Up @@ -381,6 +322,61 @@ const Hcaptcha = ({
injectVerifyData(true);
};

const handleInternalMessage = (message) => {
switch (message.type) {
case 'load-failed':
if (sentry) {
reportApiLoadFailure({
attempts: message.attempts,
jsSrc: jsSrc || 'https://js.hcaptcha.com/1/api.js',
siteKey,
});
}

return;

case 'api-ready':
injectVerifyData();

return;

default:
return;
}
};

const handleChallengeMessage = (event) => {
event.reset = reset;
event.success = true;

if (event.nativeEvent.data === 'open') {
setIsLoading(false);
} else if (event.nativeEvent.data.length > 35) {
const expiredTokenTimerId = setTimeout(() => onMessage({ nativeEvent: { data: 'expired' }, success: false, reset }), tokenTimeout);
event.markUsed = () => clearTimeout(expiredTokenTimerId);
if (journeyEnabled) {
clearJourneyEvents();
}
} else /* error */ {
event.success = false;
}

onMessage(event);
};

const handleWebViewMessage = (event) => {
const internalMessage = parseInternalWebViewMessage(
event.nativeEvent.data
);

if (internalMessage) {
handleInternalMessage(internalMessage);
return;
}

handleChallengeMessage(event);
};

return (
<View style={styles.container}>
<WebView
Expand All @@ -406,33 +402,13 @@ const Hcaptcha = ({
return true;
}}
mixedContentMode={'always'}
onMessage={(e) => {
if (e.nativeEvent.data === HCAPTCHA_READY_EVENT) {
injectVerifyData();
return;
}

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);
if (journeyEnabled) {
clearJourneyEvents();
}
} else /* error */ {
e.success = false;
}
onMessage(e);
}}
onMessage={handleWebViewMessage}
javaScriptEnabled
injectedJavaScript={patchPostMessageJsCode}
automaticallyAdjustContentInsets
style={[styles.webview, style]}
source={{
html: generateTheWebViewContent,
html: webViewContent,
baseUrl: `${url}`,
}}
/>
Expand All @@ -456,4 +432,8 @@ const styles = StyleSheet.create({
});

export default Hcaptcha;
export { buildDebugInfo, buildVerifyData, HCAPTCHA_READY_EVENT };
export {
buildDebugInfo,
buildVerifyData,
HCAPTCHA_READY_EVENT,
};
37 changes: 27 additions & 10 deletions __tests__/ConfirmHcaptcha.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ describe('ConfirmHcaptcha', () => {
beforeEach(() => {
jest.restoreAllMocks();
jest.clearAllMocks();
jest.useRealTimers();
__unsafeResetJourneyRuntime();
});

Expand All @@ -32,11 +33,18 @@ describe('ConfirmHcaptcha', () => {
);
const instance = getInstance(component);

expect(component.UNSAFE_queryByType(Hcaptcha)).toBeNull();

act(() => {
instance.show();
});

expect(component).toMatchSnapshot();
expect(getModal(component).props.visible).toBe(true);
expect(getHcaptchaChild(component).props).toMatchObject({
siteKey: '00000000-0000-0000-0000-000000000000',
url: 'https://hcaptcha.com',
languageCode: 'en',
});
});

it('forwards every shared prop to the embedded Hcaptcha component', () => {
Expand Down Expand Up @@ -100,16 +108,21 @@ describe('ConfirmHcaptcha', () => {
});
});

it('renders nothing until show() is called', () => {
const component = render(
<ConfirmHcaptcha
siteKey="00000000-0000-0000-0000-000000000000"
baseUrl="https://hcaptcha.com"
languageCode="en"
/>
);

expect(component.toJSON()).toBeNull();
it('does not mount a visual or passive WebView before show() is called', () => {
[false, true].forEach((passiveSiteKey) => {
const component = render(
<ConfirmHcaptcha
siteKey="00000000-0000-0000-0000-000000000000"
passiveSiteKey={passiveSiteKey}
baseUrl="https://hcaptcha.com"
onMessage={jest.fn()}
/>
);

expect(component.toJSON()).toBeNull();
expect(component.UNSAFE_queryByType(Hcaptcha)).toBeNull();
});
});

it('applies wrapper-only props to the modal and backdrop container', () => {
Expand Down Expand Up @@ -251,6 +264,7 @@ describe('ConfirmHcaptcha', () => {
});

expect(component.toJSON()).toBeNull();
expect(component.UNSAFE_queryByType(Hcaptcha)).toBeNull();
expect(onMessage).not.toHaveBeenCalled();

act(() => {
Expand All @@ -262,6 +276,7 @@ describe('ConfirmHcaptcha', () => {
});

expect(component.toJSON()).toBeNull();
expect(component.UNSAFE_queryByType(Hcaptcha)).toBeNull();
expect(onMessage).toHaveBeenCalledWith({ nativeEvent: { data: 'cancel' } });
});

Expand Down Expand Up @@ -467,6 +482,7 @@ describe('ConfirmHcaptcha', () => {

expect(onMessage).toHaveBeenCalledWith({ nativeEvent: { data: 'cancel' } });
expect(component.toJSON()).toBeNull();
expect(component.UNSAFE_queryByType(Hcaptcha)).toBeNull();

onMessage.mockClear();

Expand All @@ -480,5 +496,6 @@ describe('ConfirmHcaptcha', () => {

expect(onMessage).toHaveBeenCalledWith({ nativeEvent: { data: 'cancel' } });
expect(component.toJSON()).toBeNull();
expect(component.UNSAFE_queryByType(Hcaptcha)).toBeNull();
});
});
Loading
Loading