@swetrix/captcha 1.0.3 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/captcha.ts CHANGED
@@ -1,25 +1,26 @@
1
+ export {}
2
+
1
3
  // @ts-ignore
2
4
  const isDevelopment = window.__SWETRIX_CAPTCHA_DEV || false
3
5
 
4
6
  const API_URL = isDevelopment ? 'http://localhost:5005/v1/captcha' : 'https://api.swetrix.com/v1/captcha'
7
+ const WORKER_URL = isDevelopment ? './pow-worker.js' : 'https://cap.swetrix.com/pow-worker.js'
5
8
  const MSG_IDENTIFIER = 'swetrix-captcha'
6
- const DEFAULT_THEME = 'light'
7
9
  const CAPTCHA_TOKEN_LIFETIME = 300 // seconds (5 minutes).
8
- let TOKEN = ''
9
- let HASH = ''
10
+
11
+ // Main-thread fallback limits (same as worker)
12
+ const MAX_ITERATIONS = 100_000_000 // 100 million attempts
13
+ const TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
10
14
 
11
15
  const ENDPOINTS = {
12
- VERIFY: '/verify',
13
16
  GENERATE: '/generate',
14
- VERIFY_MANUAL: '/verify-manual',
17
+ VERIFY: '/verify',
15
18
  }
16
19
 
17
20
  enum IFRAME_MESSAGE_TYPES {
18
21
  SUCCESS = 'success',
19
22
  FAILURE = 'failure',
20
23
  TOKEN_EXPIRED = 'tokenExpired',
21
- MANUAL_STARTED = 'manualStarted',
22
- MANUAL_FINISHED = 'manualFinished',
23
24
  }
24
25
 
25
26
  enum ACTION {
@@ -29,20 +30,41 @@ enum ACTION {
29
30
  loading = 'loading',
30
31
  }
31
32
 
33
+ interface PowChallenge {
34
+ challenge: string
35
+ difficulty: number
36
+ }
37
+
38
+ interface PowResult {
39
+ type: 'result'
40
+ nonce: number
41
+ solution: string
42
+ }
43
+
44
+ interface PowProgress {
45
+ type: 'progress'
46
+ attempts: number
47
+ hashRate: number
48
+ }
49
+
32
50
  let activeAction: ACTION = ACTION.checkbox
51
+ let powWorker: Worker | null = null
33
52
 
34
53
  const sendMessageToLoader = (event: IFRAME_MESSAGE_TYPES, data = {}) => {
35
- window.parent.postMessage({
36
- event,
37
- type: MSG_IDENTIFIER,
38
- // @ts-ignore
39
- cid: window.__SWETRIX_CAPTCHA_ID,
40
- ...data,
41
- }, '*')
54
+ window.parent.postMessage(
55
+ {
56
+ event,
57
+ type: MSG_IDENTIFIER,
58
+ // @ts-ignore
59
+ cid: window.__SWETRIX_CAPTCHA_ID,
60
+ ...data,
61
+ },
62
+ '*',
63
+ )
42
64
  }
43
65
 
44
66
  /**
45
- * Sets the provided action visible and the rest hidden
67
+ * Sets the provided action visible and the rest hidden with smooth transitions
46
68
  * @param {*} action checkbox | failure | completed | loading
47
69
  */
48
70
  const activateAction = (action: ACTION) => {
@@ -50,6 +72,7 @@ const activateAction = (action: ACTION) => {
50
72
 
51
73
  const statusDefault = document.querySelector('#status-default')
52
74
  const statusFailure = document.querySelector('#status-failure')
75
+ const statusComputing = document.querySelector('#status-computing')
53
76
 
54
77
  const actions = {
55
78
  checkbox: document.querySelector('#checkbox'),
@@ -58,23 +81,39 @@ const activateAction = (action: ACTION) => {
58
81
  loading: document.querySelector('#loading'),
59
82
  }
60
83
 
61
- // Apply hidden class to all actions
62
- actions.checkbox?.classList.add('hidden')
63
- actions.failure?.classList.add('hidden')
64
- actions.completed?.classList.add('hidden')
65
- actions.loading?.classList.add('hidden')
84
+ // Hide all action elements with transitions
85
+ // Checkbox uses fade-out class for smooth transition
86
+ if (action !== ACTION.checkbox) {
87
+ actions.checkbox?.classList.add('fade-out')
88
+ } else {
89
+ actions.checkbox?.classList.remove('fade-out')
90
+ }
91
+
92
+ // Loading, failure, completed use .show class for visibility
93
+ actions.loading?.classList.remove('show')
94
+ actions.failure?.classList.remove('show')
95
+ actions.completed?.classList.remove('show')
66
96
 
67
97
  // Change the status text
98
+ statusDefault?.classList.add('hidden')
99
+ statusFailure?.classList.add('hidden')
100
+ statusComputing?.classList.add('hidden')
101
+
68
102
  if (action === 'failure') {
69
- statusDefault?.classList.add('hidden')
70
103
  statusFailure?.classList.remove('hidden')
104
+ } else if (action === 'loading') {
105
+ statusComputing?.classList.remove('hidden')
71
106
  } else {
72
107
  statusDefault?.classList.remove('hidden')
73
- statusFailure?.classList.add('hidden')
74
108
  }
75
109
 
76
- // Remove hidden class from the provided action
77
- actions[action]?.classList.remove('hidden')
110
+ // Show the active action element with animation
111
+ if (action !== ACTION.checkbox) {
112
+ // Small delay to ensure CSS transitions work properly
113
+ requestAnimationFrame(() => {
114
+ actions[action]?.classList.add('show')
115
+ })
116
+ }
78
117
  }
79
118
 
80
119
  const setLifetimeTimeout = () => {
@@ -84,41 +123,7 @@ const setLifetimeTimeout = () => {
84
123
  }, CAPTCHA_TOKEN_LIFETIME * 1000)
85
124
  }
86
125
 
87
- const enableManualChallenge = (svg: string) => {
88
- const manualChallenge = document.querySelector('#manual-challenge')
89
- const svgCaptcha = document.querySelector('#svg-captcha')
90
-
91
- if (!svgCaptcha) {
92
- return
93
- }
94
-
95
- if (!svg) {
96
- const error = document.createElement('p')
97
- error.innerText = 'Error loading captcha'
98
- error.style.color = '#d6292a'
99
- svgCaptcha?.appendChild(error)
100
- } else {
101
- svgCaptcha.innerHTML = svg
102
- }
103
-
104
- sendMessageToLoader(IFRAME_MESSAGE_TYPES.MANUAL_STARTED)
105
- manualChallenge?.classList.remove('hidden')
106
- }
107
-
108
- const disableManualChallenge = () => {
109
- const manualChallenge = document.querySelector('#manual-challenge')
110
- const svgCaptcha = document.querySelector('#svg-captcha')
111
-
112
- if (!svgCaptcha) {
113
- return
114
- }
115
-
116
- sendMessageToLoader(IFRAME_MESSAGE_TYPES.MANUAL_FINISHED)
117
- svgCaptcha.innerHTML = ''
118
- manualChallenge?.classList.add('hidden')
119
- }
120
-
121
- const generateCaptcha = async () => {
126
+ const generateChallenge = async (): Promise<PowChallenge | null> => {
122
127
  try {
123
128
  const response = await fetch(`${API_URL}${ENDPOINTS.GENERATE}`, {
124
129
  method: 'POST',
@@ -126,27 +131,24 @@ const generateCaptcha = async () => {
126
131
  'Content-Type': 'application/json',
127
132
  },
128
133
  body: JSON.stringify({
129
- // @ts-ignore
130
- theme: window.__SWETRIX_CAPTCHA_THEME || DEFAULT_THEME,
131
134
  // @ts-ignore
132
135
  pid: window.__SWETRIX_PROJECT_ID,
133
136
  }),
134
137
  })
135
-
138
+
136
139
  if (!response.ok) {
137
- throw ''
140
+ throw new Error('Failed to generate challenge')
138
141
  }
139
-
140
- const data = await response.json()
141
- return data
142
+
143
+ return await response.json()
142
144
  } catch (e) {
143
145
  sendMessageToLoader(IFRAME_MESSAGE_TYPES.FAILURE)
144
146
  activateAction(ACTION.failure)
145
- return {}
147
+ return null
146
148
  }
147
149
  }
148
150
 
149
- const verify = async () => {
151
+ const verifySolution = async (challenge: string, nonce: number, solution: string): Promise<string | null> => {
150
152
  try {
151
153
  const response = await fetch(`${API_URL}${ENDPOINTS.VERIFY}`, {
152
154
  method: 'POST',
@@ -154,99 +156,183 @@ const verify = async () => {
154
156
  'Content-Type': 'application/json',
155
157
  },
156
158
  body: JSON.stringify({
159
+ challenge,
160
+ nonce,
161
+ solution,
157
162
  // @ts-ignore
158
163
  pid: window.__SWETRIX_PROJECT_ID,
159
164
  }),
160
165
  })
161
166
 
162
167
  if (!response.ok) {
163
- return {}
168
+ throw new Error('Verification failed')
164
169
  }
165
170
 
166
171
  const data = await response.json()
167
- return data
172
+
173
+ if (!data.success) {
174
+ throw new Error('Verification failed')
175
+ }
176
+
177
+ return data.token
168
178
  } catch (e) {
169
179
  sendMessageToLoader(IFRAME_MESSAGE_TYPES.FAILURE)
170
180
  activateAction(ACTION.failure)
171
- return {}
181
+ return null
172
182
  }
173
183
  }
174
184
 
175
- document.addEventListener('DOMContentLoaded', () => {
176
- const captchaComponent = document.querySelector('#swetrix-captcha')
177
- const branding = document.querySelector('#branding')
178
- const svgCaptchaInput = document.querySelector('#svg-captcha-input')
179
- const manualSubmitBtn = document.querySelector('#manual-submit-btn')
180
-
181
- branding?.addEventListener('click', (e: Event) => {
182
- e.stopPropagation()
183
- })
184
-
185
- manualSubmitBtn?.addEventListener('click', async (e: Event) => {
186
- e.stopPropagation()
185
+ const solveChallenge = async (challenge: PowChallenge): Promise<void> => {
186
+ return new Promise((resolve, reject) => {
187
+ // Terminate any existing worker
188
+ if (powWorker) {
189
+ powWorker.terminate()
190
+ }
187
191
 
188
- if (!svgCaptchaInput) {
192
+ try {
193
+ powWorker = new Worker(WORKER_URL)
194
+ } catch (e) {
195
+ // Fallback: solve in main thread if worker fails
196
+ solveInMainThread(challenge).then(resolve).catch(reject)
189
197
  return
190
198
  }
191
199
 
192
- // @ts-ignore
193
- const code = svgCaptchaInput.value
200
+ powWorker.onmessage = async (
201
+ event: MessageEvent<
202
+ PowResult | PowProgress | { type: 'timeout'; reason: string } | { type: 'error'; message?: string }
203
+ >,
204
+ ) => {
205
+ const data = event.data
194
206
 
195
- if (!code) {
196
- return
197
- }
207
+ if (data.type === 'progress') {
208
+ return
209
+ }
198
210
 
199
- let response
211
+ if (data.type === 'timeout') {
212
+ // Worker timed out or hit max iterations
213
+ console.error('PoW worker timeout:', (data as { type: 'timeout'; reason: string }).reason)
214
+ sendMessageToLoader(IFRAME_MESSAGE_TYPES.FAILURE)
215
+ activateAction(ACTION.failure)
216
+ powWorker?.terminate()
217
+ powWorker = null
218
+ resolve()
219
+ return
220
+ }
200
221
 
201
- try {
202
- response = await fetch(`${API_URL}${ENDPOINTS.VERIFY_MANUAL}`, {
203
- method: 'POST',
204
- body: JSON.stringify({
205
- hash: HASH,
206
- code,
207
- // @ts-ignore
208
- pid: window.__SWETRIX_PROJECT_ID,
209
- }),
210
- headers: {
211
- 'Content-Type': 'application/json',
212
- },
213
- })
214
- } catch (e) {
215
- disableManualChallenge()
222
+ if (data.type === 'result') {
223
+ // Worker found the solution
224
+ const token = await verifySolution(challenge.challenge, (data as PowResult).nonce, (data as PowResult).solution)
225
+
226
+ if (token) {
227
+ sendMessageToLoader(IFRAME_MESSAGE_TYPES.SUCCESS, { token })
228
+ setLifetimeTimeout()
229
+ activateAction(ACTION.completed)
230
+ }
231
+
232
+ powWorker?.terminate()
233
+ powWorker = null
234
+ resolve()
235
+ return
236
+ }
237
+
238
+ // Handle error message from worker
239
+ if (data.type === 'error') {
240
+ const errorData = data as { type: 'error'; message?: string }
241
+ console.error('PoW worker error message:', errorData.message || 'Unknown error')
242
+ sendMessageToLoader(IFRAME_MESSAGE_TYPES.FAILURE)
243
+ activateAction(ACTION.failure)
244
+ powWorker?.terminate()
245
+ powWorker = null
246
+ resolve()
247
+ return
248
+ }
249
+
250
+ // Fallback for unexpected message types
251
+ console.warn('PoW worker received unexpected message type:', (data as { type?: unknown }).type, 'Raw data:', data)
216
252
  sendMessageToLoader(IFRAME_MESSAGE_TYPES.FAILURE)
217
253
  activateAction(ACTION.failure)
218
- // @ts-ignore
219
- svgCaptchaInput.value = ''
220
- return
254
+ powWorker?.terminate()
255
+ powWorker = null
256
+ resolve()
221
257
  }
222
258
 
223
- if (!response.ok) {
224
- disableManualChallenge()
259
+ powWorker.onerror = (error) => {
260
+ console.error('PoW worker error:', error)
261
+ powWorker?.terminate()
262
+ powWorker = null
263
+
264
+ // Fallback to main thread
265
+ solveInMainThread(challenge).then(resolve).catch(reject)
266
+ }
267
+
268
+ // Start the worker
269
+ powWorker.postMessage({
270
+ challenge: challenge.challenge,
271
+ difficulty: challenge.difficulty,
272
+ })
273
+ })
274
+ }
275
+
276
+ // Fallback solution for environments where workers don't work
277
+ const solveInMainThread = async (challenge: PowChallenge): Promise<void> => {
278
+ const { challenge: challengeStr, difficulty } = challenge
279
+ let nonce = 0
280
+ const startTime = Date.now()
281
+
282
+ const sha256 = async (message: string): Promise<string> => {
283
+ const encoder = new TextEncoder()
284
+ const data = encoder.encode(message)
285
+ const hashBuffer = await crypto.subtle.digest('SHA-256', data)
286
+ const hashArray = Array.from(new Uint8Array(hashBuffer))
287
+ return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
288
+ }
289
+
290
+ const hasValidPrefix = (hash: string, diff: number): boolean => {
291
+ for (let i = 0; i < diff; i++) {
292
+ if (hash[i] !== '0') return false
293
+ }
294
+ return true
295
+ }
296
+
297
+ while (nonce < MAX_ITERATIONS) {
298
+ // Check overall timeout
299
+ const elapsedMs = Date.now() - startTime
300
+ if (elapsedMs >= TIMEOUT_MS) {
301
+ console.error(`PoW main-thread timeout: ${TIMEOUT_MS}ms elapsed after ${nonce} attempts`)
225
302
  sendMessageToLoader(IFRAME_MESSAGE_TYPES.FAILURE)
226
303
  activateAction(ACTION.failure)
227
- // @ts-ignore
228
- svgCaptchaInput.value = ''
229
304
  return
230
305
  }
231
306
 
232
- const { success, token } = await response.json()
307
+ const input = `${challengeStr}:${nonce}`
308
+ const hash = await sha256(input)
233
309
 
234
- if (!success) {
235
- disableManualChallenge()
236
- sendMessageToLoader(IFRAME_MESSAGE_TYPES.FAILURE)
237
- activateAction(ACTION.failure)
238
- // @ts-ignore
239
- svgCaptchaInput.value = ''
310
+ if (hasValidPrefix(hash, difficulty)) {
311
+ const token = await verifySolution(challengeStr, nonce, hash)
312
+
313
+ if (token) {
314
+ sendMessageToLoader(IFRAME_MESSAGE_TYPES.SUCCESS, { token })
315
+ setLifetimeTimeout()
316
+ activateAction(ACTION.completed)
317
+ }
240
318
  return
241
319
  }
242
320
 
243
- // @ts-ignore
244
- svgCaptchaInput.value = ''
321
+ nonce++
322
+ }
323
+
324
+ // Max iterations reached without finding solution
325
+ console.error(`PoW main-thread max iterations reached: ${MAX_ITERATIONS} attempts`)
326
+ sendMessageToLoader(IFRAME_MESSAGE_TYPES.FAILURE)
327
+ activateAction(ACTION.failure)
328
+ }
329
+
330
+ document.addEventListener('DOMContentLoaded', () => {
331
+ const captchaComponent = document.querySelector('#swetrix-captcha')
332
+ const branding = document.querySelector('#branding')
245
333
 
246
- sendMessageToLoader(IFRAME_MESSAGE_TYPES.SUCCESS, { token })
247
- setLifetimeTimeout()
248
- activateAction(ACTION.completed)
249
- disableManualChallenge()
334
+ branding?.addEventListener('click', (e: Event) => {
335
+ e.stopPropagation()
250
336
  })
251
337
 
252
338
  captchaComponent?.addEventListener('click', async () => {
@@ -261,24 +347,12 @@ document.addEventListener('DOMContentLoaded', () => {
261
347
 
262
348
  activateAction(ACTION.loading)
263
349
 
264
- try {
265
- const { token } = await verify()
266
-
267
- if (!token) {
268
- throw ''
269
- }
270
-
271
- TOKEN = token
272
- sendMessageToLoader(IFRAME_MESSAGE_TYPES.SUCCESS, { token })
273
- setLifetimeTimeout()
274
- activateAction(ACTION.completed)
275
- return
276
- } catch (e) {
277
- const { data, hash } = await generateCaptcha()
350
+ const challenge = await generateChallenge()
278
351
 
279
- HASH = hash
280
- enableManualChallenge(data)
352
+ if (!challenge) {
281
353
  return
282
354
  }
355
+
356
+ await solveChallenge(challenge)
283
357
  })
284
358
  })