UNPKG

@cap.js/widget

Version:

Client-side widget for Cap, a lightweight, modern open-source CAPTCHA alternative designed using SHA-256 PoW.

624 lines (530 loc) 20.5 kB
(() => { const WASM_VERSION = "0.0.6"; if (typeof window === "undefined") { return; } const capFetch = (...args) => { if (window?.CAP_CUSTOM_FETCH) { return window.CAP_CUSTOM_FETCH(...args); } return fetch(...args); }; function prng(seed, length) { function fnv1a(str) { let hash = 2166136261; for (let i = 0; i < str.length; i++) { hash ^= str.charCodeAt(i); hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24); } return hash >>> 0; } let state = fnv1a(seed); let result = ""; function next() { state ^= state << 13; state ^= state >>> 17; state ^= state << 5; return state >>> 0; } while (result.length < length) { const rnd = next(); result += rnd.toString(16).padStart(8, "0"); } return result.substring(0, length); } if (!window.CAP_CUSTOM_WASM_URL) { // preloads the wasm files to save up time on solve [ `https://cdn.jsdelivr.net/npm/@cap.js/wasm@${WASM_VERSION}/browser/cap_wasm.min.js`, `https://cdn.jsdelivr.net/npm/@cap.js/wasm@${WASM_VERSION}/browser/cap_wasm_bg.wasm`, ].forEach((url) => { const link = document.createElement("link"); link.rel = "prefetch"; link.href = url; link.as = url.endsWith(".wasm") ? "fetch" : "script"; document.head.appendChild(link); }); } class CapWidget extends HTMLElement { #workerUrl = ""; #resetTimer = null; #workersCount = navigator.hardwareConcurrency || 8; token = null; #shadow; #div; #host; #solving = false; #eventHandlers; getI18nText(key, defaultValue) { return this.getAttribute(`data-cap-i18n-${key}`) || defaultValue; } static get observedAttributes() { return [ "onsolve", "onprogress", "onreset", "onerror", "data-cap-worker-count", "data-cap-i18n-initial-state", "[cap]", ]; } constructor() { super(); if (this.#eventHandlers) { this.#eventHandlers.forEach((handler, eventName) => { this.removeEventListener(eventName.slice(2), handler); }); } this.#eventHandlers = new Map(); this.boundHandleProgress = this.handleProgress.bind(this); this.boundHandleSolve = this.handleSolve.bind(this); this.boundHandleError = this.handleError.bind(this); this.boundHandleReset = this.handleReset.bind(this); } initialize() { this.#workerUrl = URL.createObjectURL( // this placeholder will be replaced with the actual worker by the build script new Blob([`%%workerScript%%`], { type: "application/javascript", }), ); } attributeChangedCallback(name, _, value) { if (name.startsWith("on")) { const eventName = name.slice(2); const oldHandler = this.#eventHandlers.get(name); if (oldHandler) { this.removeEventListener(eventName, oldHandler); } if (value) { const handler = (event) => { const callback = this.getAttribute(name); if (typeof window[callback] === "function") { window[callback].call(this, event); } }; this.#eventHandlers.set(name, handler); this.addEventListener(eventName, handler); } } if (name === "data-cap-worker-count") { this.setWorkersCount(parseInt(value, 10)); } if ( name === "data-cap-i18n-initial-state" && this.#div && this.#div?.querySelector("p")?.innerText ) { this.#div.querySelector("p").innerText = this.getI18nText( "initial-state", "I'm a human", ); } } async connectedCallback() { this.#host = this; this.#shadow = this.attachShadow({ mode: "open" }); this.#div = document.createElement("div"); this.createUI(); this.addEventListeners(); this.initialize(); this.#div.removeAttribute("disabled"); const workers = this.getAttribute("data-cap-worker-count"); const parsedWorkers = workers ? parseInt(workers, 10) : null; this.setWorkersCount(parsedWorkers || navigator.hardwareConcurrency || 8); const fieldName = this.getAttribute("data-cap-hidden-field-name") || "cap-token"; this.#host.innerHTML = `<input type="hidden" name="${fieldName}">`; } async solve() { if (this.#solving) { return; } try { this.#solving = true; this.updateUI( "verifying", this.getI18nText("verifying-label", "Verifying..."), true, ); this.#div.setAttribute( "aria-label", this.getI18nText( "verifying-aria-label", "Verifying you're a human, please wait", ), ); this.dispatchEvent("progress", { progress: 0 }); try { let apiEndpoint = this.getAttribute("data-cap-api-endpoint"); if (!apiEndpoint && window?.CAP_CUSTOM_FETCH) { apiEndpoint = "/"; } else if (!apiEndpoint) throw new Error( "Missing API endpoint. Either custom fetch or an API endpoint must be provided.", ); const { challenge, token } = await ( await capFetch(`${apiEndpoint}challenge`, { method: "POST", }) ).json(); let challenges = challenge; if (!Array.isArray(challenges)) { let i = 0; challenges = Array.from({ length: challenge.c }, () => { i = i + 1; return [ prng(`${token}${i}`, challenge.s), prng(`${token}${i}d`, challenge.d), ]; }); } const solutions = await this.solveChallenges(challenges); const resp = await ( await capFetch(`${apiEndpoint}redeem`, { method: "POST", body: JSON.stringify({ token, solutions }), headers: { "Content-Type": "application/json" }, }) ).json(); this.dispatchEvent("progress", { progress: 100 }); if (!resp.success) throw new Error("Invalid solution"); const fieldName = this.getAttribute("data-cap-hidden-field-name") || "cap-token"; if (this.querySelector(`input[name='${fieldName}']`)) { this.querySelector(`input[name='${fieldName}']`).value = resp.token; } this.dispatchEvent("solve", { token: resp.token }); this.token = resp.token; if (this.#resetTimer) clearTimeout(this.#resetTimer); const expiresIn = new Date(resp.expires).getTime() - Date.now(); if (expiresIn > 0 && expiresIn < 24 * 60 * 60 * 1000) { this.#resetTimer = setTimeout(() => this.reset(), expiresIn); } else { this.error("Invalid expiration time"); } this.#div.setAttribute( "aria-label", this.getI18nText( "verified-aria-label", "We have verified you're a human, you may now continue", ), ); return { success: true, token: this.token }; } catch (err) { this.#div.setAttribute( "aria-label", this.getI18nText( "error-aria-label", "An error occurred, please try again", ), ); this.error(err.message); throw err; } } finally { this.#solving = false; } } async solveChallenges(challenge) { const total = challenge.length; let completed = 0; const workers = Array(this.#workersCount) .fill(null) .map(() => { try { return new Worker(this.#workerUrl); } catch (error) { console.error("[cap] Failed to create worker:", error); throw new Error("Worker creation failed"); } }); const solveSingleChallenge = ([salt, target], workerId) => new Promise((resolve, reject) => { const worker = workers[workerId]; if (!worker) { reject(new Error("Worker not available")); return; } const timeout = setTimeout(() => { try { worker.terminate(); workers[workerId] = new Worker(this.#workerUrl); } catch (error) { console.error( "[cap] error terminating/recreating worker:", error, ); } reject(new Error("Worker timeout")); }, 30000); worker.onmessage = ({ data }) => { if (!data.found) return; clearTimeout(timeout); completed++; this.dispatchEvent("progress", { progress: Math.round((completed / total) * 100), }); resolve(data.nonce); }; worker.onerror = (err) => { clearTimeout(timeout); this.error(`Error in worker: ${err.message || err}`); reject(err); }; worker.postMessage({ salt, target, wasmUrl: window.CAP_CUSTOM_WASM_URL || `https://cdn.jsdelivr.net/npm/@cap.js/wasm@${WASM_VERSION}/browser/cap_wasm.min.js`, }); if ( typeof WebAssembly !== "object" || typeof WebAssembly?.instantiate !== "function" ) { if (this.#shadow.querySelector(".warning")) return; const warningEl = document.createElement("div"); warningEl.className = "warning"; warningEl.style.cssText = `width: var(--cap-widget-width, 230px);background: rgb(237, 56, 46);color: white;padding: 4px 6px;padding-bottom: calc(var(--cap-border-radius, 14px) + 5px);font-size: 10px;box-sizing: border-box;font-family: system-ui;border-top-left-radius: 8px;border-top-right-radius: 8px;text-align: center;padding-bottom:calc(var(--cap-border-radius,14px) + 5px);user-select:none;margin-bottom: -35.5px;opacity: 0;transition: margin-bottom .3s,opacity .3s;`; warningEl.innerText = this.getI18nText("wasm-disabled", "Enable WASM for significantly faster solving"); this.#shadow.insertBefore(warningEl, this.#shadow.firstChild); setTimeout(() => { warningEl.style.marginBottom = `calc(-1 * var(--cap-border-radius, 14px))` warningEl.style.opacity = 1; }, 10); } }); const results = []; try { for (let i = 0; i < challenge.length; i += this.#workersCount) { const chunk = challenge.slice( i, Math.min(i + this.#workersCount, challenge.length), ); const chunkResults = await Promise.all( chunk.map((c, idx) => solveSingleChallenge(c, idx)), ); results.push(...chunkResults); } } finally { workers.forEach((w) => { if (w) { try { w.terminate(); } catch (error) { console.error("[cap] error terminating worker:", error); } } }); } return results; } setWorkersCount(workers) { const parsedWorkers = parseInt(workers, 10); const maxWorkers = Math.min(navigator.hardwareConcurrency || 8, 16); this.#workersCount = !Number.isNaN(parsedWorkers) && parsedWorkers > 0 && parsedWorkers <= maxWorkers ? parsedWorkers : navigator.hardwareConcurrency || 8; } createUI() { this.#div.classList.add("captcha"); this.#div.setAttribute("role", "button"); this.#div.setAttribute("tabindex", "0"); this.#div.setAttribute( "aria-label", this.getI18nText("verify-aria-label", "Click to verify you're a human"), ); this.#div.setAttribute("aria-live", "polite"); this.#div.setAttribute("disabled", "true"); this.#div.innerHTML = `<div class="checkbox" part="checkbox"><svg class="progress-ring" viewBox="0 0 32 32"><circle class="progress-ring-bg" cx="16" cy="16" r="14"></circle><circle class="progress-ring-circle" cx="16" cy="16" r="14"></circle></svg></div><p part="label">${this.getI18nText( "initial-state", "I'm a human", )}</p><a part="attribution" aria-label="Secured by Cap" href="https://capjs.js.org/" class="credits" target="_blank" rel="follow noopener">Cap</a>`; this.#shadow.innerHTML = `<style${window.CAP_CSS_NONCE ? ` nonce=${window.CAP_CSS_NONCE}` : ""}>.captcha,.captcha * {box-sizing:border-box;}.captcha{background-color:var(--cap-background,#fdfdfd);border:1px solid var(--cap-border-color,#dddddd8f);border-radius:var(--cap-border-radius,14px);user-select:none;height:var(--cap-widget-height, 58px);width:var(--cap-widget-width, 230px);display:flex;align-items:center;padding:var(--cap-widget-padding,14px);gap:var(--cap-gap,15px);cursor:pointer;transition:filter .2s,transform .2s;position:relative;-webkit-tap-highlight-color:rgba(255,255,255,0);overflow:hidden;color:var(--cap-color,#212121)}.captcha:hover{filter:brightness(98%)}.checkbox{width:var(--cap-checkbox-size,25px);height:var(--cap-checkbox-size,25px);border:var(--cap-checkbox-border,1px solid #aaaaaad1);border-radius:var(--cap-checkbox-border-radius,6px);background-color:var(--cap-checkbox-background,#fafafa91);transition:opacity .2s;margin-top:var(--cap-checkbox-margin,2px);margin-bottom:var(--cap-checkbox-margin,2px)}.captcha *{font-family:var(--cap-font,system,-apple-system,"BlinkMacSystemFont",".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande","Ubuntu","arial",sans-serif)}.captcha p{margin:0;font-weight:500;font-size:15px;user-select:none;transition:opacity .2s}.checkbox .progress-ring{display:none;width:100%;height:100%;transform:rotate(-90deg)}.checkbox .progress-ring-bg{fill:none;stroke:var(--cap-spinner-background-color,#eee);stroke-width:var(--cap-spinner-thickness,3)}.checkbox .progress-ring-circle{fill:none;stroke:var(--cap-spinner-color,#000);stroke-width:var(--cap-spinner-thickness,3);stroke-linecap:round;stroke-dasharray:87.96;stroke-dashoffset:87.96;transition:stroke-dashoffset 0.3s ease}.captcha[data-state=verifying] .checkbox{background:none;display:flex;align-items:center;justify-content:center;transform:scale(1.1);border:none;border-radius:50%;background-color:transparent}.captcha[data-state=verifying] .checkbox .progress-ring{display:block}.captcha[data-state=done] .checkbox{border:1px solid transparent;background-image:var(--cap-checkmark,url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%3E%3Cstyle%3E%40keyframes%20anim%7B0%25%7Bstroke-dashoffset%3A23.21320343017578px%7Dto%7Bstroke-dashoffset%3A0%7D%7D%3C%2Fstyle%3E%3Cpath%20fill%3D%22none%22%20stroke%3D%22%2300a67d%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke-width%3D%222%22%20d%3D%22m5%2012%205%205L20%207%22%20style%3D%22stroke-dashoffset%3A0%3Bstroke-dasharray%3A23.21320343017578px%3Banimation%3Aanim%20.5s%20ease%22%2F%3E%3C%2Fsvg%3E"));background-size:cover}.captcha[data-state=done] .checkbox .progress-ring{display:none}.captcha[data-state=error] .checkbox{border:1px solid transparent;background-image:var(--cap-error-cross,url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='96' height='96' viewBox='0 0 24 24'%3E%3Cpath fill='%23f55b50' d='M11 15h2v2h-2zm0-8h2v6h-2zm1-5C6.47 2 2 6.5 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2m0 18a8 8 0 0 1-8-8a8 8 0 0 1 8-8a8 8 0 0 1 8 8a8 8 0 0 1-8 8'/%3E%3C/svg%3E"));background-size:cover}.captcha[data-state=error] .checkbox .progress-ring{display:none}.captcha[disabled]{cursor:not-allowed}.captcha[disabled][data-state=verifying]{cursor:progress}.captcha[disabled][data-state=done]{cursor:default}.captcha .credits{position:absolute;bottom:10px;right:10px;font-size:12px;color:var(--cap-color,#212121);opacity:0.8;text-underline-offset: 1.5px;}</style>`; this.#shadow.appendChild(this.#div); } addEventListeners() { if (!this.#div) return; this.#div.querySelector("a").addEventListener("click", (e) => { e.stopPropagation(); e.preventDefault(); window.open("https://capjs.js.org", "_blank"); }); this.#div.addEventListener("click", () => { if (!this.#div.hasAttribute("disabled")) this.solve(); }); this.#div.addEventListener("keydown", (e) => { if ( (e.key === "Enter" || e.key === " ") && !this.#div.hasAttribute("disabled") ) { e.preventDefault(); e.stopPropagation(); this.solve(); } }); this.addEventListener("progress", this.boundHandleProgress); this.addEventListener("solve", this.boundHandleSolve); this.addEventListener("error", this.boundHandleError); this.addEventListener("reset", this.boundHandleReset); } updateUI(state, text, disabled = false) { if (!this.#div) return; this.#div.setAttribute("data-state", state); this.#div.querySelector("p").innerText = text; if (disabled) { this.#div.setAttribute("disabled", "true"); } else { this.#div.removeAttribute("disabled"); } } handleProgress(event) { if (!this.#div) return; const progressElement = this.#div.querySelector("p"); const progressCircle = this.#div.querySelector(".progress-ring-circle"); if (progressElement && progressCircle) { const circumference = 2 * Math.PI * 14; const offset = circumference - (event.detail.progress / 100) * circumference; progressCircle.style.strokeDashoffset = offset; progressElement.innerText = `${this.getI18nText( "verifying-label", "Verifying...", )} ${event.detail.progress}%`; } this.executeAttributeCode("onprogress", event); } handleSolve(event) { this.updateUI( "done", this.getI18nText("solved-label", "You're a human"), true, ); this.executeAttributeCode("onsolve", event); } handleError(event) { this.updateUI( "error", this.getI18nText("error-label", "Error. Try again."), ); this.executeAttributeCode("onerror", event); } handleReset(event) { this.updateUI("", this.getI18nText("initial-state", "I'm a human")); this.executeAttributeCode("onreset", event); } executeAttributeCode(attributeName, event) { const code = this.getAttribute(attributeName); if (!code) { return; } new Function("event", code).call(this, event); } error(message = "Unknown error") { console.error("[cap]", message); this.dispatchEvent("error", { isCap: true, message }); } dispatchEvent(eventName, detail = {}) { const event = new CustomEvent(eventName, { bubbles: true, composed: true, detail, }); super.dispatchEvent(event); } reset() { if (this.#resetTimer) { clearTimeout(this.#resetTimer); this.#resetTimer = null; } this.dispatchEvent("reset"); this.token = null; const fieldName = this.getAttribute("data-cap-hidden-field-name") || "cap-token"; if (this.querySelector(`input[name='${fieldName}']`)) { this.querySelector(`input[name='${fieldName}']`).value = ""; } } get tokenValue() { return this.token; } disconnectedCallback() { this.removeEventListener("progress", this.boundHandleProgress); this.removeEventListener("solve", this.boundHandleSolve); this.removeEventListener("error", this.boundHandleError); this.removeEventListener("reset", this.boundHandleReset); this.#eventHandlers.forEach((handler, eventName) => { this.removeEventListener(eventName.slice(2), handler); }); this.#eventHandlers.clear(); if (this.#shadow) { this.#shadow.innerHTML = ""; } this.reset(); this.cleanup(); } cleanup() { if (this.#resetTimer) { clearTimeout(this.#resetTimer); this.#resetTimer = null; } if (this.#workerUrl) { URL.revokeObjectURL(this.#workerUrl); this.#workerUrl = ""; } } } // MARK: Invisible class Cap { constructor(config = {}, el) { const widget = el || document.createElement("cap-widget"); Object.entries(config).forEach(([a, b]) => { widget.setAttribute(a, b); }); if (!config.apiEndpoint && !window?.CAP_CUSTOM_FETCH) { widget.remove(); throw new Error( "Missing API endpoint. Either custom fetch or an API endpoint must be provided.", ); } if (config.apiEndpoint) { widget.setAttribute("data-cap-api-endpoint", config.apiEndpoint); } this.widget = widget; this.solve = this.widget.solve.bind(this.widget); this.reset = this.widget.reset.bind(this.widget); this.addEventListener = this.widget.addEventListener.bind(this.widget); Object.defineProperty(this, "token", { get: () => widget.token, configurable: true, enumerable: true, }); if (!el) { widget.style.display = "none"; document.documentElement.appendChild(widget); } } } window.Cap = Cap; if (!customElements.get("cap-widget") && !window?.CAP_DONT_SKIP_REDEFINE) { customElements.define("cap-widget", CapWidget); } else { console.warn( "[cap] the cap-widget element has already been defined, skipping re-defining it.\nto prevent this, set window.CAP_DONT_SKIP_REDEFINE to true", ); } if (typeof exports === "object" && typeof module !== "undefined") { module.exports = Cap; } else if (typeof define === "function" && define.amd) { define([], () => Cap); } if (typeof exports !== "undefined") { exports.default = Cap; } })();