|
@@ -3,9 +3,65 @@ import { postJSON } from '../../infrastructure/fetch-json'
|
|
|
|
|
|
|
|
const grecaptcha = window.grecaptcha
|
|
const grecaptcha = window.grecaptcha
|
|
|
|
|
|
|
|
-let recaptchaId
|
|
|
|
|
|
|
+let recaptchaId, canResetCaptcha, isFromReset, resetFailed
|
|
|
const recaptchaCallbacks = []
|
|
const recaptchaCallbacks = []
|
|
|
|
|
|
|
|
|
|
+function resetCaptcha() {
|
|
|
|
|
+ if (!canResetCaptcha) return
|
|
|
|
|
+ canResetCaptcha = false
|
|
|
|
|
+ isFromReset = true
|
|
|
|
|
+ grecaptcha.reset(recaptchaId)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function handleAbortedCaptcha() {
|
|
|
|
|
+ if (recaptchaCallbacks.length > 0) {
|
|
|
|
|
+ // There is a pending captcha process and the user dismissed it by
|
|
|
|
|
+ // clicking somewhere else on the page. Show it again.
|
|
|
|
|
+ // But first clear the timeout to give the user more time to solve the
|
|
|
|
|
+ // next one.
|
|
|
|
|
+ recaptchaCallbacks.forEach(({ resetTimeout }) => resetTimeout())
|
|
|
|
|
+ validateCaptchaV2().catch(() => {
|
|
|
|
|
+ // The other callback is still there to pick up the result
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function emitToken(token) {
|
|
|
|
|
+ recaptchaCallbacks.splice(0).forEach(({ resolve, resetTimeout }) => {
|
|
|
|
|
+ resetTimeout()
|
|
|
|
|
+ resolve(token)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // Happy path, let the user solve another one -- if needed.
|
|
|
|
|
+ canResetCaptcha = true
|
|
|
|
|
+ resetCaptcha()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function getMessage(err) {
|
|
|
|
|
+ return (err && err.message) || 'no details returned'
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function emitError(err, src) {
|
|
|
|
|
+ if (isFromReset) {
|
|
|
|
|
+ resetFailed = true
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ err = new Error(
|
|
|
|
|
+ `captcha check failed: ${getMessage(err)}, please retry again`
|
|
|
|
|
+ )
|
|
|
|
|
+ // Keep a record of this error. 2nd line might request a screenshot of it.
|
|
|
|
|
+ console.error(err, src)
|
|
|
|
|
+
|
|
|
|
|
+ recaptchaCallbacks.splice(0).forEach(({ reject, resetTimeout }) => {
|
|
|
|
|
+ resetTimeout()
|
|
|
|
|
+ reject(err)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // Unhappy path: Only reset if not failed before.
|
|
|
|
|
+ // This could be a loop without human interaction: error -> reset -> error.
|
|
|
|
|
+ resetCaptcha()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
export async function canSkipCaptcha(email) {
|
|
export async function canSkipCaptcha(email) {
|
|
|
let timer
|
|
let timer
|
|
|
let canSkip
|
|
let canSkip
|
|
@@ -43,13 +99,63 @@ export async function validateCaptchaV2() {
|
|
|
const el = document.getElementById('recaptcha')
|
|
const el = document.getElementById('recaptcha')
|
|
|
recaptchaId = grecaptcha.render(el, {
|
|
recaptchaId = grecaptcha.render(el, {
|
|
|
callback: token => {
|
|
callback: token => {
|
|
|
- recaptchaCallbacks.splice(0).forEach(cb => cb(token))
|
|
|
|
|
- grecaptcha.reset(recaptchaId)
|
|
|
|
|
|
|
+ emitToken(token)
|
|
|
|
|
+ },
|
|
|
|
|
+ 'error-callback': () => {
|
|
|
|
|
+ emitError(
|
|
|
|
|
+ new Error('recaptcha: something went wrong'),
|
|
|
|
|
+ 'error-callback'
|
|
|
|
|
+ )
|
|
|
|
|
+ },
|
|
|
|
|
+ 'expired-callback': () => {
|
|
|
|
|
+ emitError(new Error('recaptcha: challenge expired'), 'expired-callback')
|
|
|
},
|
|
},
|
|
|
})
|
|
})
|
|
|
|
|
+ // Attach abort handler once when setting up the captcha.
|
|
|
|
|
+ document
|
|
|
|
|
+ .querySelector('.content')
|
|
|
|
|
+ .addEventListener('click', handleAbortedCaptcha)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (resetFailed) {
|
|
|
|
|
+ throw new Error('captcha not available. try reloading the page')
|
|
|
}
|
|
}
|
|
|
- return await new Promise(resolve => {
|
|
|
|
|
- recaptchaCallbacks.push(resolve)
|
|
|
|
|
- grecaptcha.execute(recaptchaId)
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // This is likely a human making a submit action. Let them retry on error.
|
|
|
|
|
+ canResetCaptcha = true
|
|
|
|
|
+ isFromReset = false
|
|
|
|
|
+
|
|
|
|
|
+ return await new Promise((resolve, reject) => {
|
|
|
|
|
+ const timeout = setTimeout(() => {
|
|
|
|
|
+ // We triggered this error. Ensure that we can reset to captcha.
|
|
|
|
|
+ canResetCaptcha = true
|
|
|
|
|
+
|
|
|
|
|
+ emitError(new Error('challenge expired'), 'timeout')
|
|
|
|
|
+
|
|
|
|
|
+ // The iframe title says it will expire after 2 min. Enforce that here.
|
|
|
|
|
+ }, 120 * 1000)
|
|
|
|
|
+
|
|
|
|
|
+ recaptchaCallbacks.push({
|
|
|
|
|
+ resolve,
|
|
|
|
|
+ reject,
|
|
|
|
|
+ resetTimeout: () => clearTimeout(timeout),
|
|
|
|
|
+ })
|
|
|
|
|
+ try {
|
|
|
|
|
+ grecaptcha.execute(recaptchaId).catch(err => {
|
|
|
|
|
+ emitError(new Error(`recaptcha: ${getMessage(err)}`), '.catch()')
|
|
|
|
|
+ })
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ emitError(new Error(`recaptcha: ${getMessage(err)}`), 'try/catch')
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Try to (re-)attach a handler to the backdrop element of the popup.
|
|
|
|
|
+ for (const delay of [1, 10, 100, 1000]) {
|
|
|
|
|
+ setTimeout(() => {
|
|
|
|
|
+ const el = document.body.lastChild
|
|
|
|
|
+ if (el.tagName !== 'DIV') return
|
|
|
|
|
+ el.removeEventListener('click', handleAbortedCaptcha)
|
|
|
|
|
+ el.addEventListener('click', handleAbortedCaptcha)
|
|
|
|
|
+ }, delay)
|
|
|
|
|
+ }
|
|
|
})
|
|
})
|
|
|
}
|
|
}
|