captcha.js 4.3 KB

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