|
/** |
|
* captcha.js |
|
* |
|
* Proof-of-Work, Privacy-First Captcha |
|
* Lightweight, Unobtrusive, Semantic, Accessible with ES5 Fallback for WebWorkers |
|
* |
|
* The Captcha check needs an API endpoint that can handle SHA-256 |
|
* and session storage, preferably PHP with ngix and/or Apache. |
|
* |
|
* @version 1.3 |
|
* @creationdate 03.12.2025, 09:15h |
|
* @revisiondate 05.12.2025, 21:51h |
|
* @author Dan (rakooooon@live.com) |
|
*/ |
|
|
|
(function Captcha () { |
|
|
|
var box = document.getElementById('captcha'); |
|
if (!box) return; // Early bail |
|
|
|
var input = box.querySelector('#captcha-checkbox'), |
|
label = box.querySelector('[for='+ input.id +']'), |
|
apiuri = box.getAttribute('data-endpoint-uri'); |
|
if(!input || !label || !apiuri) return; // Late bail |
|
|
|
// Timeout (15 seconds default) |
|
var timeout = parseInt(box.getAttribute('data-timeout-sec') || 15) * 1000, |
|
inTimeout = false; |
|
|
|
// Track live workers for termination |
|
var workers = []; |
|
|
|
// i18n - Internationalization |
|
var i18n = { |
|
box: { |
|
default: box.getAttribute('aria-label') || 'Click to verify captcha.', |
|
check: box.getAttribute('data-i18n-check') || 'Checking captcha solution...', |
|
success: box.getAttribute('data-i18n-success') || 'The captcha check was successful.', |
|
error: box.getAttribute('data-i18n-error') || 'The captcha check failed: Try again.' |
|
}, |
|
label: { |
|
default: label.innerText, |
|
network: label.getAttribute('data-i18n-network') || 'Network not available.', |
|
progress: label.getAttribute('data-i18n-progress') || 'Verifying %per% ...', |
|
error: label.getAttribute('data-i18n-error') || 'Error checking captcha.', |
|
success: label.getAttribute('data-i18n-success') || 'You are a human.' |
|
} |
|
}; |
|
|
|
// Progress circle svg |
|
var progress = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); |
|
progress.setAttribute('role', 'presentation'); |
|
progress.setAttribute('viewBox', '0 0 9 9'); |
|
progress.innerHTML = '<circle cx="4.5" cy="4.5" r="4"/><circle cx="4.5" cy="4.5" r="4"/>'; |
|
|
|
// Enable captcha |
|
box.removeAttribute('hidden'); |
|
box.setAttribute('aria-live', 'polite'); |
|
input.disabled = false; |
|
input.required = true; |
|
label.setAttribute('role', 'status'); |
|
|
|
// Hook up reset button if available |
|
var resetBtn = input.form.querySelector('[type=reset]'); |
|
if (resetBtn) resetBtn.addEventListener('click', reset, false); |
|
|
|
// Minimal bot‑detection layer alongside e.isrusted |
|
var challengeStart = null; |
|
|
|
// Label event listener |
|
label.addEventListener('click', function (e) { |
|
// Require a genuine user interaction |
|
if (!e.isTrusted) { |
|
e.preventDefault(); |
|
return; |
|
} |
|
if (!input.checked) { |
|
e.preventDefault(); |
|
challengeStart = Date.now(); |
|
start(); |
|
} |
|
}); |
|
|
|
// Helper functions |
|
function reset () { |
|
if (inTimeout) clearTimeout(inTimeout); |
|
inTimeout = false; |
|
challengeStart = null; |
|
box.removeAttribute('data-status'); |
|
box.setAttribute('aria-label', i18n.box.default); |
|
input.indeterminate = false; |
|
input.disabled = false; |
|
input.checked = false; |
|
input.removeAttribute('required'); |
|
input.classList.remove('invalid'); |
|
if (input.setCustomValidity) input.setCustomValidity(''); |
|
input.required = true; |
|
if (progress.parentNode) progress.parentNode.removeChild(progress); |
|
label.innerText = i18n.label.default; |
|
// Clean up possibly started workers |
|
if (workers.length) terminateWorkers(); |
|
} |
|
|
|
function handleError (status, labelText) { |
|
box.setAttribute('data-status', status); |
|
box.setAttribute('aria-label', i18n.box.error); |
|
box.removeAttribute('aria-busy'); |
|
input.indeterminate = false; |
|
label.innerText = labelText; |
|
if (inTimeout) clearTimeout(inTimeout); |
|
inTimeout = setTimeout(function(){ reset(); }, timeout); |
|
if (progress.parentNode) progress.parentNode.removeChild(progress); |
|
// Clean up possibly started workers |
|
if (workers.length) terminateWorkers(); |
|
} |
|
|
|
function terminateWorkers () { |
|
for (var j = 0; j < workers.length; j++) workers[j].terminate(); |
|
workers.length = 0; |
|
} |
|
|
|
// Main captcha functiona |
|
function start () { |
|
// Prepare input |
|
input.classList.remove('invalid'); |
|
input.defaultValue = null; |
|
// NOT working as per now: |
|
if (input.setCustomValidity) input.setCustomValidity(''); |
|
input.indeterminate = true; |
|
box.setAttribute('data-status', 'start'); |
|
box.setAttribute('aria-label', i18n.box.check); |
|
box.appendChild(progress); |
|
label.innerText = i18n.label.progress.replace('%per', 0); |
|
// Start fetching / XHR |
|
var xhr = new XMLHttpRequest(); |
|
xhr.open('GET', apiuri + 'start', true); |
|
xhr.setRequestHeader("Content-Type", "application/json"); |
|
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); |
|
xhr.onreadystatechange = function () { |
|
// Success |
|
if (xhr.readyState === 4 && xhr.status === 200) { |
|
var res; |
|
try { res = JSON.parse(xhr.responseText); } |
|
catch (e) { res = null; } |
|
// Handle already-verified session |
|
if (res && res.data && res.data.verified === true) { |
|
input.indeterminate = false; |
|
input.disabled = false; |
|
input.checked = true; |
|
box.setAttribute('data-status', 'success'); |
|
box.setAttribute('aria-label', i18n.box.success); |
|
label.innerText = i18n.label.success; |
|
if (progress.parentNode) progress.parentNode.removeChild(progress); |
|
return; |
|
} |
|
// Continue with challenge |
|
if (res && res.data && res.data.nonce && res.data.difficulty) { |
|
check(res.data.nonce, res.data.difficulty); |
|
} else { |
|
handleError('error', i18n.label.error); |
|
} |
|
} |
|
// Error handling of non-200 HTTP statuses |
|
else if (xhr.readyState === 4) { |
|
var res; |
|
try { res = JSON.parse(xhr.responseText); } |
|
catch (e) { res = null; } |
|
// Server API errors |
|
if (res && res.errors) { |
|
console.log('Captcha start failed: ' + res.errors); |
|
handleError('error', i18n.label.error); |
|
} |
|
// Connection / network errors (onerror / on timeout) |
|
else handleError('warning', i18n.label.network); |
|
} |
|
}; |
|
// Network error handler |
|
xhr.onerror = function () { console.log('Captcha start failed: network error'); }; |
|
xhr.ontimeout = function () { console.log('Captcha start failed: timeout'); }; |
|
xhr.timeout = timeout; |
|
xhr.send(); |
|
} |
|
|
|
function check (nonce, difficulty) { |
|
if (!challengeStart) { reset(); return; } |
|
|
|
var interval = 1000; |
|
box.setAttribute('data-status', 'check'); |
|
box.setAttribute('aria-label', i18n.box.check); |
|
box.setAttribute('aria-busy', 'true'); |
|
|
|
function countLeadingZeroBits(buffer) { |
|
var bytes = new Uint8Array(buffer); |
|
var bits = 0; |
|
for (var i = 0; i < bytes.length; i++) { |
|
var val = bytes[i]; |
|
if (val === 0) { bits += 8; } |
|
else { |
|
var tz = 0; |
|
while ((val & 1) === 0 && tz < 8) { tz++; val >>= 1; } |
|
bits += tz; |
|
break; |
|
} |
|
} |
|
return bits; |
|
} |
|
|
|
// Modern Webworker on SSL |
|
if (window.Worker && location.protocol === 'https:') { |
|
var numWorkers = navigator.hardwareConcurrency || 2, |
|
solved = false; |
|
var workerCode = ` |
|
${countLeadingZeroBits.toString()} |
|
|
|
self.onmessage = async function(e) { |
|
var nonce = e.data.nonce, |
|
interval = e.data.interval, |
|
difficulty = parseInt(e.data.difficulty, 10), |
|
enc = new TextEncoder(), |
|
start = e.data.start, |
|
step = e.data.step, |
|
solution = start; |
|
|
|
while (true) { |
|
var data = enc.encode(nonce + solution), |
|
digest = await crypto.subtle.digest('SHA-256', data), |
|
bits = countLeadingZeroBits(digest); |
|
if (bits >= difficulty) { |
|
self.postMessage({ solution: solution }); |
|
break; |
|
} |
|
solution += step; |
|
if (solution % interval === 0) { |
|
var percent = Math.min(100, Math.round((solution / (1 << difficulty)) * 100)); |
|
self.postMessage({ progress: percent }); |
|
} |
|
} |
|
}; |
|
`; |
|
|
|
var blob = new Blob([workerCode], { type: "application/javascript" }), |
|
url = URL.createObjectURL(blob); |
|
|
|
for (var i = 0; i < numWorkers; i++) { |
|
var worker = new Worker(url); |
|
workers.push(worker); |
|
worker.onmessage = function(e) { |
|
if (e.data.solution !== undefined && !solved) { |
|
var s = e.data.solution, |
|
enc2 = new TextEncoder(); |
|
crypto.subtle.digest('SHA-256', enc2.encode(nonce + s)).then(function(d){ |
|
var bits = countLeadingZeroBits(d); |
|
if (bits >= difficulty) { |
|
solved = true; |
|
verify(s); |
|
box.removeAttribute('aria-busy'); |
|
for (var j = 0; j < workers.length; j++) workers[j].terminate(); |
|
workers.length = 0; |
|
URL.revokeObjectURL(url); |
|
} |
|
}); |
|
} else if (e.data.progress !== undefined && !solved) { |
|
var percent = e.data.progress; |
|
box.style.setProperty('--progress', percent); |
|
label.innerText = i18n.label.progress.replace('%per', percent); |
|
} |
|
}; |
|
worker.postMessage({ nonce: nonce, difficulty: difficulty, start: i, step: numWorkers, interval: interval }); |
|
} |
|
} |
|
// ES5 fallback |
|
else { |
|
var solution = 0, batchSize = 200, solved = false; |
|
|
|
function loop() { |
|
var count = 0; |
|
while (count < batchSize && !solved) { |
|
var hex = sha256Hex(nonce + solution), |
|
bytes = new Uint8Array(hex.match(/.{2}/g).map(function(h){ return parseInt(h, 16); })), |
|
bits = countLeadingZeroBits(bytes.buffer); |
|
if (bits >= difficulty) { |
|
solved = true; |
|
verify(solution); |
|
box.removeAttribute('aria-busy'); |
|
break; |
|
} |
|
solution++; |
|
count++; |
|
if (solution % interval === 0) { |
|
var percent = Math.min(100, Math.round((solution / (1 << difficulty)) * 100)); |
|
box.style.setProperty('--progress', percent); |
|
label.innerText = i18n.label.progress.replace('%per', percent); |
|
} |
|
} |
|
if (!solved) setTimeout(loop, 0); |
|
} |
|
loop(); |
|
} |
|
} |
|
|
|
function verify (solution) { |
|
var xhr = new XMLHttpRequest(); |
|
xhr.open('POST', apiuri + 'verify', true); |
|
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); |
|
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); |
|
xhr.onreadystatechange = function () { |
|
if (xhr.readyState === 4) { |
|
var res; |
|
try { res = JSON.parse(xhr.responseText); } |
|
catch (e) { res = null; } |
|
// Success path |
|
if (xhr.status === 200 && res && res.success && res.data && res.data.verified) { |
|
box.style.setProperty('--progress', 100); |
|
box.removeAttribute('aria-busy'); |
|
box.setAttribute('data-status', 'success'); |
|
box.setAttribute('aria-label', i18n.box.success); |
|
input.indeterminate = false; |
|
input.disabled = false; |
|
input.checked = true; |
|
input.value = 'verified'; |
|
label.innerText = i18n.label.success; |
|
// Always remove progress svg |
|
if (progress.parentNode) progress.parentNode.removeChild(progress); |
|
} |
|
// Error handling of non-200 HTTP statuses or failed verification |
|
else { |
|
// Server API errors |
|
if (res && res.errors) { |
|
console.log('Captcha verify failed: ' + res.errors); |
|
handleError('error', i18n.label.error); |
|
} |
|
// Connection / network errors (onerror / ontimeout) |
|
else handleError('warning', i18n.label.network); |
|
} |
|
} |
|
}; |
|
// Network error handler |
|
xhr.onerror = function () { console.log('Captcha verify failed: network error'); }; |
|
xhr.ontimeout = function () { console.log('Captcha verify failed: timeout'); }; |
|
xhr.timeout = timeout; |
|
xhr.send('solution=' + encodeURIComponent(solution) + '&ts=' + encodeURIComponent(challengeStart - 1000)); |
|
} |
|
|
|
})(); |