UNPKG

react-native-altcha

Version:
471 lines (470 loc) 14.2 kB
"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