react-native-altcha
Version:
471 lines (470 loc) • 14.2 kB
JavaScript
"use strict";
import { forwardRef, useEffect, useRef, useState } from 'react';
import { Modal, View, Text, TouchableOpacity, StyleSheet, useColorScheme, ActivityIndicator } from 'react-native';
import { getLocales, getCalendars } from 'expo-localization';
import { defaultThemes } from "./theme.js";
import { AlertSvg, AltchaLogoSvg, CheckSvg } from "./svg.js";
import { applyColorOpacity, hashHex } from "./helpers.js";
import { AltchaCodeChallenge } from "./AltchaCodeChallenge.js";
import { defaultTranslations } from "./i18n.js";
import React from 'react';
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
const _fetch = fetch;
export const AltchaWidget = /*#__PURE__*/forwardRef(({
challengeJson,
challengeUrl,
colorScheme,
customTranslations,
debug,
delay,
fetch = _fetch,
locale = getLocales()[0]?.languageCode || 'en',
onFailed,
onServerVerification,
onVerified,
hideFooter,
hideLogo,
httpHeaders,
style,
themes = {},
verifyUrl
}, ref) => {
const systemColorScheme = useColorScheme();
const selectedColorScheme = colorScheme || systemColorScheme || 'light';
const theme = {
...defaultThemes[selectedColorScheme],
...themes[selectedColorScheme]
};
const flattenedStyle = StyleSheet.flatten([{
backgroundColor: theme.backgroundColor,
borderColor: theme.borderColor,
color: theme.textColor,
fontSize: 16
}, style || {}]);
const t = {
...(defaultTranslations[locale] || defaultTranslations.en),
...customTranslations?.[locale]
};
const [checked, setChecked] = useState(false);
const [codeChallenge, setCodeChallenge] = useState(null);
const [codeChallengeCallback, setCodeChallengeCallback] = useState(null);
const currentVerifyUrlRef = useRef(verifyUrl || null);
const [expires, setExpires] = useState(null);
const [sentinelTimeZone, setSentinelTimeZone] = useState(false);
const [status, setStatus] = useState('unverified');
useEffect(() => {
const timer = expires ? setTimeout(() => {
reset();
setStatus('expired');
}, expires * 1000) : null;
return () => {
timer && clearTimeout(timer);
};
}, [expires]);
React.useImperativeHandle(ref, () => ({
reset,
verify
}));
const toggleCheckbox = () => {
if (status === 'verified') {
return;
}
if (checked) {
setChecked(false);
} else {
verify();
}
};
async function fetchChallenge() {
if (challengeJson) {
return challengeJson;
}
if (!challengeUrl) {
throw new Error('challengeUrl must be set.');
}
const resp = await fetch(challengeUrl, {
headers: {
...httpHeaders
}
});
if (resp.status !== 200) {
throw new Error(`Server responded with ${resp.status}.`);
}
const json = await resp.json();
if (typeof json !== 'object' || !('challenge' in json) || !('salt' in json)) {
throw new Error('Invalid JSON payload received.');
}
const salt = json.salt;
const params = new URLSearchParams(salt.split('?')[1] || '');
if (params.has('expires')) {
const timestamp = parseInt(params.get('expires') || '0', 10);
if (timestamp) {
setExpires(timestamp * 1000 - Date.now());
}
}
const configHeader = resp.headers.get('x-altcha-config');
if (configHeader) {
try {
const config = JSON.parse(configHeader);
if (config && typeof config === 'object') {
if (config.verifyurl) {
currentVerifyUrlRef.current = constructUrl(config.verifyurl);
}
if (config.sentinel?.timeZone) {
setSentinelTimeZone(true);
}
}
} catch {
// noop
}
}
return json;
}
function log(...args) {
if (debug || args.some(a => a instanceof Error)) {
console[args[0] instanceof Error ? 'error' : 'log']('[ALTCHA]', ...args);
}
}
function reset() {
setCodeChallenge(null);
setChecked(false);
setExpires(null);
setStatus('unverified');
}
function constructUrl(url, params) {
if (challengeUrl) {
const baseUrl = new URL(challengeUrl);
const resultUrl = new URL(url, baseUrl.origin);
if (!resultUrl.search) {
resultUrl.search = baseUrl.search;
}
if (params) {
for (const key in params) {
if (params[key] !== undefined && params[key] !== null) {
resultUrl.searchParams.set(key, params[key]);
}
}
}
return resultUrl.toString();
}
return url;
}
async function onCodeChallengeSubmit(payload, code) {
setCodeChallenge(null);
try {
const serverPayload = await requestServerVerification(payload, code);
codeChallengeCallback?.(serverPayload);
} catch (err) {
log(err);
setStatus('error');
}
}
async function requestVerification(challenge) {
const {
promise
} = solveChallenge(challenge.challenge, challenge.salt, challenge.algorithm, challenge.maxnumber || challenge.maxNumber);
const solution = await promise;
if (solution) {
const payloadObject = {
algorithm: challenge.algorithm,
challenge: challenge.challenge,
number: solution.number,
salt: challenge.salt,
signature: challenge.signature,
took: solution.took
};
const payload = btoa(JSON.stringify(payloadObject));
if (challenge.codeChallenge) {
const _codeChallenge = challenge.codeChallenge;
return new Promise(resolve => {
setStatus('code');
setCodeChallenge({
..._codeChallenge,
audio: _codeChallenge.audio && constructUrl(_codeChallenge.audio, {
language: locale
}),
payload
});
setCodeChallengeCallback(() => resolve);
});
} else if (currentVerifyUrlRef.current) {
return requestServerVerification(payload);
}
return payload;
}
return null;
}
async function requestServerVerification(payload, code) {
if (!currentVerifyUrlRef.current) {
throw new Error('Parameter verifyUrl must be set.');
}
if (!payload) {
throw new Error('Payload is not set.');
}
const resp = await fetch(currentVerifyUrlRef.current, {
body: JSON.stringify({
code,
payload,
timeZone: sentinelTimeZone ? getCalendars()[0]?.timeZone : undefined
}),
headers: {
'Content-Type': 'application/json',
...httpHeaders
},
method: 'POST'
});
if (resp.status !== 200) {
throw new Error(`Server verification failed with status ${resp.status}.`);
}
const serverVerification = await resp.json();
onServerVerification?.(serverVerification);
if (!serverVerification.verified) {
throw new Error('Server verification failed.');
}
return serverVerification.payload;
}
async function verify() {
if (status === 'verifying') {
return;
}
setStatus('verifying');
try {
if (delay) {
await new Promise(resolve => setTimeout(resolve, delay));
}
const challenge = await fetchChallenge();
const payload = await requestVerification(challenge);
if (payload) {
onVerified(payload);
setStatus('verified');
setChecked(true);
} else {
reset();
}
} catch (err) {
log(err);
setStatus('error');
onFailed?.(String(err.message || err));
}
}
function solveChallenge(challenge, salt, algorithm = 'SHA-256', max = 1e6, start = 0) {
const controller = new AbortController();
const startTime = Date.now();
const fn = async () => {
for (let n = start; n <= max; n += 1) {
if (controller.signal.aborted) {
return null;
}
const hash = await hashHex(algorithm, salt + n);
if (hash === challenge) {
return {
number: n,
took: Date.now() - startTime
};
}
// Yield control periodically to prevent blocking the main thread
if (n % 1000 === 0) {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
return null;
};
return {
promise: fn(),
controller
};
}
return /*#__PURE__*/_jsxs(_Fragment, {
children: [/*#__PURE__*/_jsxs(View, {
style: [styles.container, flattenedStyle],
children: [/*#__PURE__*/_jsxs(View, {
style: styles.topRow,
children: [/*#__PURE__*/_jsxs(TouchableOpacity, {
style: styles.checkboxContainer,
onPress: toggleCheckbox,
disabled: status === 'verifying',
accessibilityRole: "checkbox",
accessibilityLabel: t.label,
importantForAccessibility: "yes",
testID: "checkbox",
children: [/*#__PURE__*/_jsx(View, {
style: styles.checkboxWrap,
children: status === 'verifying' ? /*#__PURE__*/_jsx(ActivityIndicator, {
size: "small",
color: theme.primaryColor
}) : status === 'code' ? /*#__PURE__*/_jsx(View, {
style: styles.alertIcon,
children: /*#__PURE__*/_jsx(AlertSvg, {
color: flattenedStyle.color
})
}) : /*#__PURE__*/_jsx(View, {
style: [styles.checkbox, {
borderColor: theme.primaryColor
}, checked && styles.checkboxChecked && {
backgroundColor: theme.primaryColor
}],
children: checked && /*#__PURE__*/_jsx(CheckSvg, {
color: theme.primaryContentColor
})
})
}), /*#__PURE__*/_jsx(Text, {
style: [{
color: flattenedStyle.color,
fontSize: flattenedStyle.fontSize
}],
children: codeChallenge ? t.verificationRequired : status === 'verifying' ? t.verifying : t.label
})]
}), hideLogo !== true && /*#__PURE__*/_jsx(View, {
testID: "logo",
children: /*#__PURE__*/_jsx(AltchaLogoSvg, {
color: flattenedStyle.color
})
})]
}), status === 'error' && /*#__PURE__*/_jsx(View, {
style: styles.errorContainer,
importantForAccessibility: "yes",
accessibilityRole: "alert",
testID: "error",
children: /*#__PURE__*/_jsx(Text, {
style: [styles.errorText, {
color: theme.errorColor,
fontSize: flattenedStyle.fontSize && flattenedStyle.fontSize * 0.8
}],
children: t.error
})
}), status === 'expired' && /*#__PURE__*/_jsx(View, {
style: styles.errorContainer,
importantForAccessibility: "yes",
accessibilityRole: "alert",
children: /*#__PURE__*/_jsx(Text, {
style: [styles.errorText, {
color: theme.errorColor,
fontSize: flattenedStyle.fontSize && flattenedStyle.fontSize * 0.8
}],
children: t.expired
})
}), hideFooter !== true && /*#__PURE__*/_jsx(View, {
style: styles.footerContainer,
testID: "footer",
children: /*#__PURE__*/_jsx(Text, {
style: [styles.footerText, {
color: applyColorOpacity(flattenedStyle.color, 0.7),
fontSize: flattenedStyle.fontSize && flattenedStyle.fontSize * 0.8
}],
importantForAccessibility: "no-hide-descendants",
children: t.footer
})
})]
}), /*#__PURE__*/_jsx(Modal, {
animationType: "slide",
transparent: true,
visible: !!codeChallenge,
onRequestClose: () => {
codeChallengeCallback?.();
},
children: /*#__PURE__*/_jsx(View, {
style: [styles.codeChallengeModal],
children: /*#__PURE__*/_jsx(View, {
style: [styles.codeChallengeContainer, {
backgroundColor: flattenedStyle.backgroundColor,
borderColor: applyColorOpacity(flattenedStyle.color, 0.2)
}],
children: codeChallenge && /*#__PURE__*/_jsx(AltchaCodeChallenge, {
audio: codeChallenge.audio,
image: codeChallenge.image,
codeLength: codeChallenge.length,
payload: codeChallenge.payload,
onCancel: () => {
reset();
},
onReload: () => {
reset();
// call verify after a short delay to account for animations
setTimeout(() => verify(), 350);
},
onSubmit: (payload, code) => {
onCodeChallengeSubmit(payload, code);
},
t: t,
theme: theme
})
})
})
})]
});
});
const styles = StyleSheet.create({
container: {
padding: 12,
borderRadius: 5,
borderWidth: 1,
borderColor: '#ccc',
width: 260,
backgroundColor: '#fff'
},
topRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center'
},
checkboxContainer: {
flexDirection: 'row',
alignItems: 'center'
},
checkboxWrap: {
marginRight: 12
},
checkbox: {
width: 20,
height: 20,
borderWidth: 2,
borderColor: '#007AFF',
borderRadius: 3,
justifyContent: 'center',
alignItems: 'center'
},
checkboxChecked: {
backgroundColor: '#007AFF'
},
alertIcon: {
width: 20,
height: 20
},
codeChallengeModal: {
flexGrow: 1,
flex: 1,
justifyContent: 'center',
alignItems: 'center'
},
codeChallengeContainer: {
backgroundColor: '#fff',
borderRadius: 10,
borderColor: '#000',
borderWidth: 1,
padding: 20,
alignItems: 'center',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2
},
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
maxWidth: 320,
width: '100%'
},
errorContainer: {
marginTop: 8
},
errorText: {
fontSize: 14
},
footerContainer: {
marginTop: 12
},
footerText: {
textAlign: 'right'
}
});
//# sourceMappingURL=AltchaWidget.js.map