overleaf/services/web/frontend/js/features/form-helpers/captcha.js
Jakob Ackermann 1f4c8d4ed9 Merge pull request #7424 from overleaf/jpa-captcha-error-handling
[web] double down on error handling in captcha process

GitOrigin-RevId: 91692978a24b02e9fa1ca55193a462ca29f68a7e
2022-04-12 08:03:07 +00:00

161 lines
4.3 KiB
JavaScript

import 'abort-controller/polyfill'
import { postJSON } from '../../infrastructure/fetch-json'
const grecaptcha = window.grecaptcha
let recaptchaId, canResetCaptcha, isFromReset, resetFailed
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) {
let timer
let canSkip
try {
const controller = new AbortController()
const signal = controller.signal
timer = setTimeout(() => {
controller.abort()
}, 1000)
canSkip = await postJSON('/login/can-skip-captcha', {
signal,
body: { email },
swallowAbortError: false,
})
} catch (e) {
canSkip = false
} finally {
clearTimeout(timer)
}
return canSkip
}
export async function validateCaptchaV2() {
if (
// Detect blocked recaptcha
typeof grecaptcha === 'undefined' ||
// Detect stubbed recaptcha
typeof grecaptcha.render !== 'function' ||
typeof grecaptcha.execute !== 'function' ||
typeof grecaptcha.reset !== 'function'
) {
return
}
if (recaptchaId === undefined) {
const el = document.getElementById('recaptcha')
recaptchaId = grecaptcha.render(el, {
callback: token => {
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')
}
// 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)
}
})
}