captcha.ts 5.2 KB

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