captcha.js 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. import 'abort-controller/polyfill'
  2. import { postJSON } from '../../infrastructure/fetch-json'
  3. import { debugConsole } from '@/utils/debugging'
  4. const grecaptcha = window.grecaptcha
  5. let recaptchaId, canResetCaptcha, isFromReset, resetFailed
  6. const recaptchaCallbacks = []
  7. function resetCaptcha() {
  8. if (!canResetCaptcha) return
  9. canResetCaptcha = false
  10. isFromReset = true
  11. grecaptcha.reset(recaptchaId)
  12. }
  13. function handleAbortedCaptcha() {
  14. if (recaptchaCallbacks.length > 0) {
  15. // There is a pending captcha process and the user dismissed it by
  16. // clicking somewhere else on the page. Show it again.
  17. // But first clear the timeout to give the user more time to solve the
  18. // next one.
  19. recaptchaCallbacks.forEach(({ resetTimeout }) => resetTimeout())
  20. validateCaptchaV2().catch(() => {
  21. // The other callback is still there to pick up the result
  22. })
  23. }
  24. }
  25. function emitToken(token) {
  26. recaptchaCallbacks.splice(0).forEach(({ resolve, resetTimeout }) => {
  27. resetTimeout()
  28. resolve(token)
  29. })
  30. // Happy path, let the user solve another one -- if needed.
  31. canResetCaptcha = true
  32. resetCaptcha()
  33. }
  34. function getMessage(err) {
  35. return (err && err.message) || 'no details returned'
  36. }
  37. function emitError(err, src) {
  38. if (isFromReset) {
  39. resetFailed = true
  40. }
  41. err = new Error(
  42. `captcha check failed: ${getMessage(err)}, please retry again`
  43. )
  44. // Keep a record of this error. 2nd line might request a screenshot of it.
  45. debugConsole.error(err, src)
  46. recaptchaCallbacks.splice(0).forEach(({ reject, resetTimeout }) => {
  47. resetTimeout()
  48. reject(err)
  49. })
  50. // Unhappy path: Only reset if not failed before.
  51. // This could be a loop without human interaction: error -> reset -> error.
  52. resetCaptcha()
  53. }
  54. export async function canSkipCaptcha(email) {
  55. let timer
  56. let canSkip
  57. try {
  58. const controller = new AbortController()
  59. const signal = controller.signal
  60. timer = setTimeout(() => {
  61. controller.abort()
  62. }, 1000)
  63. canSkip = await postJSON('/login/can-skip-captcha', {
  64. signal,
  65. body: { email },
  66. swallowAbortError: false,
  67. })
  68. } catch (e) {
  69. canSkip = false
  70. } finally {
  71. clearTimeout(timer)
  72. }
  73. return canSkip
  74. }
  75. export async function validateCaptchaV2() {
  76. if (
  77. // Detect blocked recaptcha
  78. typeof grecaptcha === 'undefined' ||
  79. // Detect stubbed recaptcha
  80. typeof grecaptcha.render !== 'function' ||
  81. typeof grecaptcha.execute !== 'function' ||
  82. typeof grecaptcha.reset !== 'function'
  83. ) {
  84. return
  85. }
  86. if (recaptchaId === undefined) {
  87. const el = document.getElementById('recaptcha')
  88. recaptchaId = grecaptcha.render(el, {
  89. callback: token => {
  90. emitToken(token)
  91. },
  92. 'error-callback': () => {
  93. emitError(
  94. new Error('recaptcha: something went wrong'),
  95. 'error-callback'
  96. )
  97. },
  98. 'expired-callback': () => {
  99. emitError(new Error('recaptcha: challenge expired'), 'expired-callback')
  100. },
  101. })
  102. // Attach abort handler once when setting up the captcha.
  103. document
  104. .querySelector('[data-ol-captcha-retry-trigger-area]')
  105. .addEventListener('click', handleAbortedCaptcha)
  106. }
  107. if (resetFailed) {
  108. throw new Error('captcha not available. try reloading the page')
  109. }
  110. // This is likely a human making a submit action. Let them retry on error.
  111. canResetCaptcha = true
  112. isFromReset = false
  113. return await new Promise((resolve, reject) => {
  114. const timeout = setTimeout(() => {
  115. // We triggered this error. Ensure that we can reset to captcha.
  116. canResetCaptcha = true
  117. emitError(new Error('challenge expired'), 'timeout')
  118. // The iframe title says it will expire after 2 min. Enforce that here.
  119. }, 120 * 1000)
  120. recaptchaCallbacks.push({
  121. resolve,
  122. reject,
  123. resetTimeout: () => clearTimeout(timeout),
  124. })
  125. try {
  126. grecaptcha.execute(recaptchaId).catch(err => {
  127. emitError(new Error(`recaptcha: ${getMessage(err)}`), '.catch()')
  128. })
  129. } catch (err) {
  130. emitError(new Error(`recaptcha: ${getMessage(err)}`), 'try/catch')
  131. }
  132. // Try to (re-)attach a handler to the backdrop element of the popup.
  133. for (const delay of [1, 10, 100, 1000]) {
  134. setTimeout(() => {
  135. const el = document.body.lastChild
  136. if (el.tagName !== 'DIV') return
  137. el.removeEventListener('click', handleAbortedCaptcha)
  138. el.addEventListener('click', handleAbortedCaptcha)
  139. }, delay)
  140. }
  141. })
  142. }