Skip to content
Open
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
10 changes: 10 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
node_modules/
# Generated web example bundle
dist/
# Generated E2E host app
__e2e__/host/
# Reassure output
.reassure/
output/
# Jest coverage output
coverage/
11 changes: 11 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
module.exports = {
root: true,
extends: '@react-native',
overrides: [
{
// The react-native-web suite lives outside __tests__ so the native Jest project
// does not pick it up; ESLint still needs the Jest globals here.
files: ['**/__tests_web__/**/*.js'],
env: {
jest: true,
'jest/globals': true,
},
},
],
};
1 change: 1 addition & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ jobs:
- run: npm install
- run: npm test
- run: npm run lint
- run: npm run build:web
- if: github.ref == 'refs/heads/master' && github.event_name == 'push'
run: npm run perf:baseline
- if: github.ref == 'refs/heads/master' && github.event_name == 'push'
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,9 @@ output/

# Generated E2E host app
__e2e__/host/

# Web example build output
dist/

# Jest coverage output
coverage/
97 changes: 97 additions & 0 deletions Example.Web.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import React, { useRef, useState } from 'react';
import { AppRegistry, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import ConfirmHcaptcha from './index';

// demo sitekey
// Swap in hCaptcha's always-pass test key '10000000-ffff-ffff-ffff-000000000001'
// to exercise the token path without solving a visual challenge.
const siteKey = '00000000-0000-0000-0000-000000000000';
const baseUrl = 'https://hcaptcha.com';

const App = () => {
const [code, setCode] = useState(null);
const captchaForm = useRef(null);

const onMessage = event => {
if (event && event.nativeEvent.data) {
if (event.nativeEvent.data === 'open') {
console.log('Visual challenge opened');
} else if (event.success) {
setCode(event.nativeEvent.data);
captchaForm.current.hide();
event.markUsed();
console.log('Verified code from hCaptcha', event.nativeEvent.data);
} else if (event.nativeEvent.data === 'challenge-expired') {
event.reset();
console.log('Visual challenge expired, reset...', event.nativeEvent.data);
} else /* other errors */ {
setCode(event.nativeEvent.data);
captchaForm.current.hide();
console.log('Verification failed', event.nativeEvent.data);
}
}
};

return (
<View style={styles.container}>
<Text style={styles.heading}>hCaptcha · React Native Web</Text>
<ConfirmHcaptcha
ref={captchaForm}
siteKey={siteKey}
baseUrl={baseUrl}
languageCode="en"
onMessage={onMessage}
/>
<TouchableOpacity
onPress={() => {
captchaForm.current.show();
}}
testID="launch-button">
<Text style={styles.paragraph}>Click to launch</Text>
</TouchableOpacity>
{code && (
<Text style={styles.codeContainer} testID="result">
{'passcode or status: '}
<Text style={styles.codeText}>
{code}
</Text>
</Text>
)}
</View>
);
};

const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
backgroundColor: '#ecf0f1',
padding: 8,
},
heading: {
fontSize: 22,
fontWeight: 'bold',
textAlign: 'center',
},
paragraph: {
margin: 24,
fontSize: 18,
fontWeight: 'bold',
textAlign: 'center',
},
codeContainer: {
alignSelf: 'center',
},
codeText: {
color: 'darkviolet',
fontSize: 10,
fontWeight: 'bold',
},
});

AppRegistry.registerComponent('HcaptchaWebExample', () => App);
AppRegistry.runApplication('HcaptchaWebExample', {
rootTag: document.getElementById('root'),
});

export default App;
167 changes: 16 additions & 151 deletions Hcaptcha.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import hCaptchaLoaderInlineScript from '@hcaptcha/loader/inline';
import WebView from 'react-native-webview';
import { ActivityIndicator, Linking, Platform, StyleSheet, TouchableWithoutFeedback, View } from 'react-native';
import ReactNativeVersion from 'react-native/Libraries/Core/ReactNativeVersion';
import { ActivityIndicator, Linking, StyleSheet, TouchableWithoutFeedback, View } from 'react-native';

import md5 from './md5';
import hcaptchaPackage from './package.json';
import {
buildDebugInfo,
buildHcaptchaLoaderConfig,
buildVerifyData,
HCAPTCHA_READY_EVENT,
LOADING_TIMEOUT,
normalizeSize,
normalizeTheme,
serializeForInlineScript,
TOKEN_MIN_LENGTH,
TOKEN_TIMEOUT,
} from './hcaptchaShared';
import {
clearJourneyEvents,
disableJourneyConsumer,
Expand All @@ -27,151 +36,9 @@ const patchPostMessageJsCode = `(${String(function () {
window.ReactNativeWebView.postMessage = patchedPostMessage;
})})();`;

const HCAPTCHA_READY_EVENT = '__hcaptcha_ready__';

const serializeForInlineScript = (value) =>
JSON.stringify(value)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');

const normalizeTheme = (value) => {
if (value == null) {
return null;
}

if (typeof value === 'object') {
return value;
}

if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch (_) {
return value;
}
}

return value;
};

const normalizeSize = (value) => {
if (value == null) {
return 'invisible';
}

return value === 'checkbox' ? 'normal' : value;
};

const getVersionPart = (value) => (
typeof value === 'number' && Number.isFinite(value) && value >= 0 && value < 100
? value
: null
);

const parseReactNativeVersion = (value) => {
const candidate = value && typeof value === 'object' && value.version ? value.version : value;
const major = getVersionPart(candidate?.major);
const minor = getVersionPart(candidate?.minor);
const patch = getVersionPart(candidate?.patch);

if (major == null || minor == null || patch == null) {
return null;
}

return { major, minor, patch };
};

const getReactNativeVersion = (value = Platform?.constants?.reactNativeVersion) =>
parseReactNativeVersion(value) || parseReactNativeVersion(ReactNativeVersion?.version);

const buildDebugInfo = (debug, reactNativeVersion = Platform?.constants?.reactNativeVersion) => {
const result = { ...(debug || {}) };

try {
const version = getReactNativeVersion(reactNativeVersion);
if (version) {
result[`rnver_${version.major}_${version.minor}_${version.patch}`] = true;
}
result['dep_' + md5(Object.keys(global).join(''))] = true;
result['sdk_' + hcaptchaPackage.version.toString().replace(/\./g, '_')] = true;
} catch (e) {
console.log(e);
}

return result;
};

const buildVerifyData = ({
phoneNumber,
phonePrefix,
rqdata,
userJourney,
verifyParams,
}) => {
const normalizedVerifyParams = verifyParams || {};
const data = {};
const finalRqdata = normalizedVerifyParams.rqdata ?? rqdata ?? undefined;
const finalPhonePrefix = normalizedVerifyParams.phonePrefix ?? phonePrefix ?? undefined;
const finalPhoneNumber = normalizedVerifyParams.phoneNumber ?? phoneNumber ?? undefined;

if (finalRqdata) {
data.rqdata = finalRqdata;
}
if (finalPhonePrefix) {
data.mfa_phoneprefix = finalPhonePrefix;
}
if (finalPhoneNumber) {
data.mfa_phone = finalPhoneNumber;
}
if (Array.isArray(userJourney) && userJourney.length > 0) {
data.userjourney = userJourney;
}

return data;
};

const buildVerifyInjectionScript = (payload, resetFirst = false) =>
`try { ${resetFirst ? 'reset(); ' : ''}setData(${serializeForInlineScript(payload)}); execute(); } catch (e) { window.ReactNativeWebView.postMessage((e && e.name) || 'error'); } true;`;

const getHcaptchaHost = (host, siteKey) => {
if (host) {
return host;
} else if (siteKey) {
return `${siteKey}.react-native.hcaptcha.com`;
} else {
return 'missing-sitekey.react-native.hcaptcha.com';
}
};

function buildHcaptchaLoaderConfig({
scriptSource,
siteKey,
hl,
theme,
host,
sentry,
endpoint,
assethost,
imghost,
reportapi,
}) {
return {
scriptSource: scriptSource || 'https://hcaptcha.com/1/api.js',
render: 'explicit',
host: getHcaptchaHost(host, siteKey),
hl,
custom: typeof theme === 'object',
sentry,
endpoint,
assethost,
imghost,
reportapi,
};
}

/**
*
* @param {*} onMessage: callback after receiving response, error, or when user cancels
Expand Down Expand Up @@ -228,8 +95,6 @@ const Hcaptcha = ({
verifyParams,
_journeyManagedExternally,
}) => {
const tokenTimeout = 120000;
const loadingTimeout = 15000;
const [isLoading, setIsLoading] = useState(true);
const isLoadingRef = useRef(true);
const journeyEnabled = Boolean(userJourney);
Expand Down Expand Up @@ -388,7 +253,7 @@ const Hcaptcha = ({
if (isLoadingRef.current) {
onMessage({ nativeEvent: { data: 'error', description: 'loading timeout' } });
}
}, loadingTimeout);
}, LOADING_TIMEOUT);

return () => clearTimeout(timeoutId);
}, [onMessage]);
Expand Down Expand Up @@ -470,8 +335,8 @@ const Hcaptcha = ({
}
e.success = true;
if (e.nativeEvent.data === 'open') {
} else if (e.nativeEvent.data.length > 35) {
const expiredTokenTimerId = setTimeout(() => onMessage({ nativeEvent: { data: 'expired' }, success: false, reset }), tokenTimeout);
} else if (e.nativeEvent.data.length > TOKEN_MIN_LENGTH) {
const expiredTokenTimerId = setTimeout(() => onMessage({ nativeEvent: { data: 'expired' }, success: false, reset }), TOKEN_TIMEOUT);
e.markUsed = () => clearTimeout(expiredTokenTimerId);
if (journeyEnabled) {
clearJourneyEvents();
Expand Down
Loading
Loading