diff --git a/rollup.config.js b/rollup.config.js index 6f7b50a..1d58266 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -8,6 +8,7 @@ import pkg from './package.json' const CAPTCHA_PATH = 'src/captcha.ts' const CAPTCHA_LOADER_PATH = 'src/captcha-loader.ts' +const POW_WORKER_PATH = 'src/pow-worker.ts' export default [ { @@ -49,4 +50,18 @@ export default [ uglify(), ], }, + { + input: POW_WORKER_PATH, + output: [ + { + file: 'dist/pow-worker.js', + format: 'iife', + name: 'powWorker', + }, + ], + plugins: [ + typescript(), + uglify(), + ], + }, ] diff --git a/src/captcha-loader.ts b/src/captcha-loader.ts index a48fef4..1a6d9af 100644 --- a/src/captcha-loader.ts +++ b/src/captcha-loader.ts @@ -21,10 +21,7 @@ const DUMMY_PIDS = ['AP00000000000', 'FAIL000000000'] const isValidPID = (pid: string) => DUMMY_PIDS.includes(pid) || PID_REGEX.test(pid) -const FRAME_HEIGHT_MAPPING = { - default: '66px', - manual: '200px', -} +const FRAME_HEIGHT = '66px' const getFrameID = (cid: string) => `${cid}-frame` @@ -130,32 +127,6 @@ const postMessageCallback = (pmEvent: MessageEvent) => { break } - - case 'manualStarted': { - const frame = document.getElementById(getFrameID(cid)) - - if (!frame) { - log(LOG_ACTIONS.error, '[PM -> manualStarted] Frame does not exist.') - return - } - - frame.style.height = FRAME_HEIGHT_MAPPING.manual - - break - } - - case 'manualFinished': { - const frame = document.getElementById(getFrameID(cid)) - - if (!frame) { - log(LOG_ACTIONS.error, '[PM -> manualFinished] Frame does not exist.') - return - } - - frame.style.height = FRAME_HEIGHT_MAPPING.default - - break - } } } @@ -168,7 +139,7 @@ const generateCaptchaFrame = (params: any) => { theme === 'dark' ? appendParamsToURL(DARK_CAPTCHA_IFRAME_URL, params) : appendParamsToURL(LIGHT_CAPTCHA_IFRAME_URL, params) - captchaFrame.style.height = FRAME_HEIGHT_MAPPING.default + captchaFrame.style.height = FRAME_HEIGHT captchaFrame.title = 'Swetrix Captcha' captchaFrame.style.border = 'none' captchaFrame.style.width = '302px' diff --git a/src/captcha.ts b/src/captcha.ts index ff0c105..0150140 100644 --- a/src/captcha.ts +++ b/src/captcha.ts @@ -1,12 +1,16 @@ +export {} + // @ts-ignore const isDevelopment = window.__SWETRIX_CAPTCHA_DEV || false const API_URL = isDevelopment ? 'http://localhost:5005/v1/captcha' : 'https://api.swetrix.com/v1/captcha' +const WORKER_URL = isDevelopment ? './pow-worker.js' : 'https://cap.swetrix.com/pow-worker.js' const MSG_IDENTIFIER = 'swetrix-captcha' -const DEFAULT_THEME = 'light' const CAPTCHA_TOKEN_LIFETIME = 300 // seconds (5 minutes). -let TOKEN = '' -let HASH = '' + +// Main-thread fallback limits (same as worker) +const MAX_ITERATIONS = 100_000_000 // 100 million attempts +const TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes const ENDPOINTS = { GENERATE: '/generate', @@ -17,8 +21,6 @@ enum IFRAME_MESSAGE_TYPES { SUCCESS = 'success', FAILURE = 'failure', TOKEN_EXPIRED = 'tokenExpired', - MANUAL_STARTED = 'manualStarted', - MANUAL_FINISHED = 'manualFinished', } enum ACTION { @@ -28,7 +30,25 @@ enum ACTION { loading = 'loading', } +interface PowChallenge { + challenge: string + difficulty: number +} + +interface PowResult { + type: 'result' + nonce: number + solution: string +} + +interface PowProgress { + type: 'progress' + attempts: number + hashRate: number +} + let activeAction: ACTION = ACTION.checkbox +let powWorker: Worker | null = null const sendMessageToLoader = (event: IFRAME_MESSAGE_TYPES, data = {}) => { window.parent.postMessage( @@ -52,6 +72,7 @@ const activateAction = (action: ACTION) => { const statusDefault = document.querySelector('#status-default') const statusFailure = document.querySelector('#status-failure') + const statusComputing = document.querySelector('#status-computing') const actions = { checkbox: document.querySelector('#checkbox'), @@ -67,18 +88,29 @@ const activateAction = (action: ACTION) => { actions.loading?.classList.add('hidden') // Change the status text + statusDefault?.classList.add('hidden') + statusFailure?.classList.add('hidden') + statusComputing?.classList.add('hidden') + if (action === 'failure') { - statusDefault?.classList.add('hidden') statusFailure?.classList.remove('hidden') + } else if (action === 'loading') { + statusComputing?.classList.remove('hidden') } else { statusDefault?.classList.remove('hidden') - statusFailure?.classList.add('hidden') } // Remove hidden class from the provided action actions[action]?.classList.remove('hidden') } +const updateProgress = (attempts: number, hashRate: number) => { + const progressEl = document.querySelector('#pow-progress') + if (progressEl) { + progressEl.textContent = `${(attempts / 1000).toFixed(0)}k hashes (${hashRate}/s)` + } +} + const setLifetimeTimeout = () => { setTimeout(() => { sendMessageToLoader(IFRAME_MESSAGE_TYPES.TOKEN_EXPIRED) @@ -86,143 +118,227 @@ const setLifetimeTimeout = () => { }, CAPTCHA_TOKEN_LIFETIME * 1000) } -const enableManualChallenge = (svg: string) => { - const manualChallenge = document.querySelector('#manual-challenge') - const svgCaptcha = document.querySelector('#svg-captcha') - - if (!svgCaptcha) { - return - } - - if (!svg) { - const error = document.createElement('p') - error.innerText = 'Error loading captcha' - error.style.color = '#d6292a' - svgCaptcha?.appendChild(error) - } else { - svgCaptcha.innerHTML = svg - } - - sendMessageToLoader(IFRAME_MESSAGE_TYPES.MANUAL_STARTED) - manualChallenge?.classList.remove('hidden') -} +const generateChallenge = async (): Promise => { + try { + const response = await fetch(`${API_URL}${ENDPOINTS.GENERATE}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + // @ts-ignore + pid: window.__SWETRIX_PROJECT_ID, + }), + }) -const disableManualChallenge = () => { - const manualChallenge = document.querySelector('#manual-challenge') - const svgCaptcha = document.querySelector('#svg-captcha') + if (!response.ok) { + throw new Error('Failed to generate challenge') + } - if (!svgCaptcha) { - return + return await response.json() + } catch (e) { + sendMessageToLoader(IFRAME_MESSAGE_TYPES.FAILURE) + activateAction(ACTION.failure) + return null } - - sendMessageToLoader(IFRAME_MESSAGE_TYPES.MANUAL_FINISHED) - svgCaptcha.innerHTML = '' - manualChallenge?.classList.add('hidden') } -const generateCaptcha = async () => { +const verifySolution = async (challenge: string, nonce: number, solution: string): Promise => { try { - const response = await fetch(`${API_URL}${ENDPOINTS.GENERATE}`, { + const response = await fetch(`${API_URL}${ENDPOINTS.VERIFY}`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ - // @ts-ignore - theme: window.__SWETRIX_CAPTCHA_THEME || DEFAULT_THEME, + challenge, + nonce, + solution, // @ts-ignore pid: window.__SWETRIX_PROJECT_ID, }), }) if (!response.ok) { - throw '' + throw new Error('Verification failed') } const data = await response.json() - return data + + if (!data.success) { + throw new Error('Verification failed') + } + + return data.token } catch (e) { sendMessageToLoader(IFRAME_MESSAGE_TYPES.FAILURE) activateAction(ACTION.failure) - return {} + return null } } -document.addEventListener('DOMContentLoaded', () => { - const captchaComponent = document.querySelector('#swetrix-captcha') - const branding = document.querySelector('#branding') - const svgCaptchaInput = document.querySelector('#svg-captcha-input') - const manualSubmitBtn = document.querySelector('#manual-submit-btn') - - branding?.addEventListener('click', (e: Event) => { - e.stopPropagation() - }) - - manualSubmitBtn?.addEventListener('click', async (e: Event) => { - e.stopPropagation() +const solveChallenge = async (challenge: PowChallenge): Promise => { + return new Promise((resolve, reject) => { + // Terminate any existing worker + if (powWorker) { + powWorker.terminate() + } - if (!svgCaptchaInput) { + try { + powWorker = new Worker(WORKER_URL) + } catch (e) { + // Fallback: solve in main thread if worker fails + solveInMainThread(challenge).then(resolve).catch(reject) return } - // @ts-ignore - const code = svgCaptchaInput.value + powWorker.onmessage = async ( + event: MessageEvent< + PowResult | PowProgress | { type: 'timeout'; reason: string } | { type: 'error'; message?: string } + >, + ) => { + const data = event.data + + if (data.type === 'progress') { + updateProgress((data as PowProgress).attempts, (data as PowProgress).hashRate) + return + } + + if (data.type === 'timeout') { + // Worker timed out or hit max iterations + console.error('PoW worker timeout:', (data as { type: 'timeout'; reason: string }).reason) + sendMessageToLoader(IFRAME_MESSAGE_TYPES.FAILURE) + activateAction(ACTION.failure) + powWorker?.terminate() + powWorker = null + resolve() + return + } + + if (data.type === 'result') { + // Worker found the solution + const token = await verifySolution(challenge.challenge, (data as PowResult).nonce, (data as PowResult).solution) + + if (token) { + sendMessageToLoader(IFRAME_MESSAGE_TYPES.SUCCESS, { token }) + setLifetimeTimeout() + activateAction(ACTION.completed) + } + + powWorker?.terminate() + powWorker = null + resolve() + return + } + + // Handle error message from worker + if (data.type === 'error') { + const errorData = data as { type: 'error'; message?: string } + console.error('PoW worker error message:', errorData.message || 'Unknown error') + sendMessageToLoader(IFRAME_MESSAGE_TYPES.FAILURE) + activateAction(ACTION.failure) + powWorker?.terminate() + powWorker = null + resolve() + return + } + + // Fallback for unexpected message types + console.warn('PoW worker received unexpected message type:', (data as { type?: unknown }).type, 'Raw data:', data) + sendMessageToLoader(IFRAME_MESSAGE_TYPES.FAILURE) + activateAction(ACTION.failure) + powWorker?.terminate() + powWorker = null + resolve() + } + + powWorker.onerror = (error) => { + console.error('PoW worker error:', error) + powWorker?.terminate() + powWorker = null - if (!code) { - return + // Fallback to main thread + solveInMainThread(challenge).then(resolve).catch(reject) } - let response + // Start the worker + powWorker.postMessage({ + challenge: challenge.challenge, + difficulty: challenge.difficulty, + }) + }) +} - try { - response = await fetch(`${API_URL}${ENDPOINTS.VERIFY}`, { - method: 'POST', - body: JSON.stringify({ - hash: HASH, - code, - // @ts-ignore - pid: window.__SWETRIX_PROJECT_ID, - }), - headers: { - 'Content-Type': 'application/json', - }, - }) - } catch (e) { - disableManualChallenge() - sendMessageToLoader(IFRAME_MESSAGE_TYPES.FAILURE) - activateAction(ACTION.failure) - // @ts-ignore - svgCaptchaInput.value = '' - return +// Fallback solution for environments where workers don't work +const solveInMainThread = async (challenge: PowChallenge): Promise => { + const { challenge: challengeStr, difficulty } = challenge + let nonce = 0 + const startTime = Date.now() + + const sha256 = async (message: string): Promise => { + const encoder = new TextEncoder() + const data = encoder.encode(message) + const hashBuffer = await crypto.subtle.digest('SHA-256', data) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') + } + + const hasValidPrefix = (hash: string, diff: number): boolean => { + for (let i = 0; i < diff; i++) { + if (hash[i] !== '0') return false } + return true + } - if (!response.ok) { - disableManualChallenge() + while (nonce < MAX_ITERATIONS) { + // Check overall timeout + const elapsedMs = Date.now() - startTime + if (elapsedMs >= TIMEOUT_MS) { + console.error(`PoW main-thread timeout: ${TIMEOUT_MS}ms elapsed after ${nonce} attempts`) sendMessageToLoader(IFRAME_MESSAGE_TYPES.FAILURE) activateAction(ACTION.failure) - // @ts-ignore - svgCaptchaInput.value = '' return } - const { success, token } = await response.json() + const input = `${challengeStr}:${nonce}` + const hash = await sha256(input) - if (!success) { - disableManualChallenge() - sendMessageToLoader(IFRAME_MESSAGE_TYPES.FAILURE) - activateAction(ACTION.failure) - // @ts-ignore - svgCaptchaInput.value = '' + if (hasValidPrefix(hash, difficulty)) { + const token = await verifySolution(challengeStr, nonce, hash) + + if (token) { + sendMessageToLoader(IFRAME_MESSAGE_TYPES.SUCCESS, { token }) + setLifetimeTimeout() + activateAction(ACTION.completed) + } return } - // @ts-ignore - svgCaptchaInput.value = '' + nonce++ + + // Update progress every 10k iterations + if (nonce % 10000 === 0) { + const elapsed = (Date.now() - startTime) / 1000 + const hashRate = Math.round(nonce / elapsed) + updateProgress(nonce, hashRate) + + // Yield to the main thread to prevent blocking + await new Promise((r) => setTimeout(r, 0)) + } + } + + // Max iterations reached without finding solution + console.error(`PoW main-thread max iterations reached: ${MAX_ITERATIONS} attempts`) + sendMessageToLoader(IFRAME_MESSAGE_TYPES.FAILURE) + activateAction(ACTION.failure) +} + +document.addEventListener('DOMContentLoaded', () => { + const captchaComponent = document.querySelector('#swetrix-captcha') + const branding = document.querySelector('#branding') - sendMessageToLoader(IFRAME_MESSAGE_TYPES.SUCCESS, { token }) - setLifetimeTimeout() - activateAction(ACTION.completed) - disableManualChallenge() + branding?.addEventListener('click', (e: Event) => { + e.stopPropagation() }) captchaComponent?.addEventListener('click', async () => { @@ -237,9 +353,12 @@ document.addEventListener('DOMContentLoaded', () => { activateAction(ACTION.loading) - const { data, hash } = await generateCaptcha() + const challenge = await generateChallenge() + + if (!challenge) { + return + } - HASH = hash - enableManualChallenge(data) + await solveChallenge(challenge) }) }) diff --git a/src/pages/dark.html b/src/pages/dark.html index 2b697ca..8350db2 100644 --- a/src/pages/dark.html +++ b/src/pages/dark.html @@ -65,10 +65,21 @@ } #status { - font-size: 16px; + font-size: 14px; color: #f9fafb; } + #status-computing { + display: flex; + flex-direction: column; + } + + #pow-progress { + font-size: 10px; + color: #9ca3af; + margin-top: 2px; + } + .hidden { display: none !important; } @@ -140,53 +151,6 @@ border-top-color: #4b5563; animation: spin 2s infinite linear; } - - #manual-challenge { - display: flex; - justify-content: center; - align-items: center; - flex-direction: row; - border: 1px solid #1e293b; - /* bg-slate-900 */ - background-color: #0f172a; - border-top: none; - height: 130px; - width: 280px; - padding-left: 10px; - padding-right: 10px; - gap: 10px; - } - - #input-n-captcha { - display: flex; - flex-direction: column; - align-items: center; - cursor: pointer; - flex: 5; - } - - #input-n-captcha > input { - width: 100%; - background-color: #1e293b; - color: #f9fafb; - border: 2px solid #1e293b; - border-radius: 3px; - } - - #manual-submit-btn { - background-color: #1e293b; - border: 1px solid #1e293b; - color: #f9fafb; - padding-left: 5px; - padding-right: 5px; - border-radius: 3px; - height: 75px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - flex: 1; - }