From 54b270bc107e1bf58c9d0ec027ca02bc7cdad529 Mon Sep 17 00:00:00 2001 From: Tiago Rangel <tr@tiagorangel.com> Date: Sat, 18 Jan 2025 14:36:36 +0000 Subject: [PATCH] A bunch of fixes --- demo/index.js | 1 + demo/package.json | 1 - demo/public/index.html | 3 +- docs/guide/invisible.md | 2 +- widget/cap.min.js | 2 +- widget/package.json | 2 +- widget/src/cap.js | 390 ++++++++++++++++++---------------------- 7 files changed, 185 insertions(+), 216 deletions(-) diff --git a/demo/index.js b/demo/index.js index 97d0bcc..88e91b8 100644 --- a/demo/index.js +++ b/demo/index.js @@ -5,6 +5,7 @@ const app = express(); app.use(express.json()); app.use(express.static('public')); +/* app.use("/capsource", express.static('../widget/src/cap.js')); */ const cap = new Cap({ tokens_store_path: '.data/tokensList.json' diff --git a/demo/package.json b/demo/package.json index 19f7cab..ab6ddc0 100644 --- a/demo/package.json +++ b/demo/package.json @@ -1,6 +1,5 @@ { "name": "cap-demo", - "version": "0.0.1", "description": "Cap demo", "license": "AGPL-3.0", "author": "Tiago Rangel", diff --git a/demo/public/index.html b/demo/public/index.html index 897a0d3..64048ff 100644 --- a/demo/public/index.html +++ b/demo/public/index.html @@ -24,9 +24,10 @@ </style> </head> <body> - <cap-widget id="cap" data-cap-api-endpoint="/api/"></cap-widget> + <cap-widget id="cap" data-cap-api-endpoint="/api/" data-cap-worker-count="30"></cap-widget> <a href="https://github.com/tiagorangel1/cap/tree/main/demo" target="_blank" class="source">Source code</a> </body> <script src="https://cdn.jsdelivr.net/npm/@cap.js/widget"></script> + <!-- <script src="/capsource"></script> --> </html> diff --git a/docs/guide/invisible.md b/docs/guide/invisible.md index 4a20df4..a2422a3 100644 --- a/docs/guide/invisible.md +++ b/docs/guide/invisible.md @@ -17,7 +17,7 @@ console.log(result.token); The following methods are supported: #### `new Cap({ ... })` -Creates a new Cap instance. +Creates a new Cap instance. If a 2nd argument is provided, it will use that element instead of creating a new one in memory. **Arguments** ```json diff --git a/widget/cap.min.js b/widget/cap.min.js index 7204b53..31f1dbc 100644 --- a/widget/cap.min.js +++ b/widget/cap.min.js @@ -1 +1 @@ -"use strict";!function(){let e;const t=(e,t=1e4)=>new Promise(((r,s)=>{const a=setTimeout((()=>{s(new Error("Initialize timeout"))}),t),i=()=>{e()?(clearTimeout(a),r()):setTimeout(i,500)};i()}));class r{#e="";#t=null;#r=null;#s=navigator.hardwareConcurrency||8;#a=null;async initialize(){this.#e&&URL.revokeObjectURL(this.#e);try{await t((()=>!!e)),this.#e=URL.createObjectURL(new Blob([e],{type:"application/javascript"}))}catch(e){throw this.error("Failed to initialize worker"),e}}async solve(){await t((()=>!!this.#e)),this.dispatchEvent("progress",{progress:0});try{const e=this.#t.getAttribute("data-cap-api-endpoint");if(!e)throw new Error("Missing API endpoint");const{challenge:t,target:r,token:s}=await(await fetch(`${e}challenge`,{method:"POST"})).json(),a=await this.solveChallenges({challenge:t,target:r,token:s}),i=await(await fetch(`${e}redeem`,{method:"POST",body:JSON.stringify({token:s,solutions:a}),headers:{"Content-Type":"application/json"}})).json();if(!i.success)throw new Error("Invalid solution");this.dispatchEvent("progress",{progress:100}),this.dispatchEvent("solve",{token:i.token}),this.#a=i.token,this.#t.querySelector("input[name='cap-token']")&&(this.#t.querySelector("input[name='cap-token']").value=i.token),this.#r&&clearTimeout(this.#r);const n=new Date(i.expires).getTime()-Date.now();return n>0&&n<864e5?this.#r=setTimeout((()=>this.reset()),n):this.error("Invalid expiration time"),{success:!0,token:this.#a}}catch(e){throw this.error(e.message),e}}async solveChallenges({challenge:e,target:t}){const r=e.length;let s=0;const a=Array(this.#s).fill(null).map((()=>new Worker(this.#e))),i=([e,t],i)=>new Promise(((n,o)=>{const c=a[i],d=setTimeout((()=>{c.terminate(),a[i]=new Worker(this.#e),o(new Error("Worker timeout"))}),3e4);c.onmessage=({data:a})=>{a.found&&(clearTimeout(d),s++,this.dispatchEvent("progress",{progress:Math.round(s/r*100)}),n([e,t,a.nonce]))},c.onerror=e=>{clearTimeout(d),this.error(`Error in worker: ${e}`),o(e)},c.postMessage({salt:e,target:t})})),n=[];try{for(let t=0;t<e.length;t+=this.#s){const r=e.slice(t,Math.min(t+this.#s,e.length)),s=await Promise.all(r.map(((e,t)=>i(e,t))));n.push(...s)}}finally{a.forEach((e=>e.terminate()))}return n}reset(){this.#r&&(clearTimeout(this.#r),this.#r=null),this.dispatchEvent("reset"),this.#a=null,this.#t.querySelector("input[name='cap-token']")&&(this.#t.querySelector("input[name='cap-token']").value="")}error(e="Unknown error"){console.error("[Cap] Error:",e),this.dispatchEvent("error",{isCap:!0,message:e})}dispatchEvent(e,t={}){const r=new CustomEvent(e,{bubbles:!0,composed:!0,detail:t});this.#t.dispatchEvent(r)}setElement(e){this.#t=e}setWorkersCount(e){const t=parseInt(e,10),r=Math.min(navigator.hardwareConcurrency||8,16);this.#s=!isNaN(t)&&t>0&&t<=r?t:navigator.hardwareConcurrency||8}getToken(){return this.#a}cleanup(){this.#r&&(clearTimeout(this.#r),this.#r=null),this.#e&&(URL.revokeObjectURL(this.#e),this.#e="")}}class s extends HTMLElement{#i=new r;#n;#o;#c;#d=!1;eventHandlers;static get observedAttributes(){return["onsolve","onprogress","onreset","onerror","workers"]}constructor(){super(),this.eventHandlers&&this.eventHandlers.forEach(((e,t)=>{this.removeEventListener(t.slice(2),e)})),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)}attributeChangedCallback(e,t,r){if(e.startsWith("on")){const t=e.slice(2),s=this.eventHandlers.get(e);if(s&&this.removeEventListener(t,s),r){const r=t=>{const r=this.getAttribute(e);"function"==typeof window[r]&&window[r].call(this,t)};this.eventHandlers.set(e,r),this.addEventListener(t,r)}}}async connectedCallback(){this.#c=this,this.#n=this.attachShadow({mode:"open"}),this.#i.setElement(this),this.#o=document.createElement("div"),this.createUI(),this.addEventListeners(),await this.#i.initialize(),this.#o.removeAttribute("disabled");const e=this.getAttribute("data-cap-worker-count");this.#i.setWorkersCount(parseInt(e)?parseInt(e,10):navigator.hardwareConcurrency||8),this.#c.innerHTML='<input type="hidden" name="cap-token">'}createUI(){this.#o.classList.add("captcha"),this.#o.setAttribute("role","button"),this.#o.setAttribute("tabindex","0"),this.#o.setAttribute("disabled","true"),this.#o.innerHTML='<div class="checkbox"></div><p>I\'m a human</p><a href="#" class="credits" target="_blank"><span>Secured by </span>Cap</a>',this.#n.innerHTML='<style>.captcha{background-color:var(--cap-background);border:1px solid var(--cap-border-color);border-radius:var(--cap-border-radius);width:var(--cap-widget-width);display:flex;align-items:center;padding:var(--cap-widget-padding);gap:var(--cap-gap);cursor:pointer;transition:filter var(--cap-transition-duration),transform var(--cap-transition-duration);position:relative;-webkit-tap-highlight-color:rgba(255,255,255,0);overflow:hidden;color:var(--cap-color)}.captcha:hover{filter:var(--cap-hover-filter)}.captcha:not([disabled]):active{transform:scale(var(--cap-active-scale))}.checkbox{width:var(--cap-checkbox-size);height:var(--cap-checkbox-size);border:var(--cap-checkbox-border);border-radius:var(--cap-checkbox-border-radius);background-color:var(--cap-checkbox-background);transition:opacity var(--cap-transition-duration);margin-top:var(--cap-checkbox-margin);margin-bottom:var(--cap-checkbox-margin)}.captcha *{font-family:var(--cap-font)}.captcha p{margin:0;font-weight:500;font-size:15px;user-select:none;transition:opacity var(--cap-transition-duration)}.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: conic-gradient(var(--cap-spinner-color) 0%, var(--cap-spinner-color) var(--progress, 0%), var(--cap-spinner-background-color) var(--progress, 0%), var(--cap-spinner-background-color) 100%);position: relative;}.captcha[data-state=verifying] .checkbox::after {content: "";background-color: var(--cap-background);width: calc(100% - var(--cap-spinner-thickness));height: calc(100% - var(--cap-spinner-thickness));border-radius: 50%;margin:calc(var(--cap-spinner-thickness) / 2)}.captcha[data-state=done] .checkbox{border:1px solid transparent;background-image:var(--cap-checkmark);background-size:cover}.captcha[data-state=error] .checkbox{border:1px solid transparent;background-image:var(--cap-error-cross);background-size:cover}.captcha[disabled]{\ncursor: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:var(--cap-credits-font-size);color:var(--cap-color);opacity:var(--cap-opacity-hover)}.captcha .credits span{display:none;text-decoration:underline}.captcha .credits:hover span{display:inline-block}</style>',this.#n.appendChild(this.#o)}addEventListeners(){this.#o.querySelector("a").addEventListener("click",(e=>{e.stopPropagation(),e.preventDefault(),window.open("#","_blank")})),this.#o.addEventListener("click",(()=>{this.#o.hasAttribute("disabled")||this.solve()})),this.addEventListener("progress",this.boundHandleProgress),this.addEventListener("solve",this.boundHandleSolve),this.addEventListener("error",this.boundHandleError),this.addEventListener("reset",this.boundHandleReset)}async solve(){if(!this.#d)try{this.#d=!0,this.updateUI("verifying","Verifying...",!0);return await this.#i.solve()}finally{this.#d=!1}}updateUI(e,t,r=!1){this.#o.setAttribute("data-state",e),this.#o.querySelector("p").innerText=t,r?this.#o.setAttribute("disabled","true"):this.#o.removeAttribute("disabled")}handleProgress(e){const t=this.#o.querySelector("p");t&&(this.#o.querySelector(".checkbox").style.setProperty("--progress",`${e.detail.progress}%`),t.innerText=`Verifying... ${e.detail.progress}%`),this.executeAttributeCode("onprogress",e)}handleSolve(e){this.updateUI("done","You're a human",!0),this.executeAttributeCode("onsolve",e)}handleError(e){this.updateUI("error","Error. Try again."),this.executeAttributeCode("onerror",e)}handleReset(e){this.updateUI("","I'm a human"),this.executeAttributeCode("onreset",e)}executeAttributeCode(e,t){const r=this.getAttribute(e);if(!r)return;new Function("event",r).call(this,t)}reset(){this.#i.reset()}get token(){return this.#i.getToken()}disconnectedCallback(){this.removeEventListener("progress",this.boundHandleProgress),this.removeEventListener("solve",this.boundHandleSolve),this.removeEventListener("error",this.boundHandleError),this.removeEventListener("reset",this.boundHandleReset),this.eventHandlers.forEach(((e,t)=>{this.removeEventListener(t.slice(2),e)})),this.eventHandlers.clear(),this.#n&&(this.#n.innerHTML=""),this.#i.reset(),this.#i.cleanup()}}class a{constructor(e,t={}){let s=new r,a=e||document.createElement("div");if(e||(a.style.display="none"),Object.entries(t).forEach((([e,t])=>{a.setAttribute(e,t)})),!t.apiEndpoint)throw a.remove(),new Error("Missing API endpoint");a.setAttribute("data-cap-api-endpoint",t.apiEndpoint),s.setElement(a),s.setWorkersCount(t.workers||navigator.hardwareConcurrency||8),s.initialize(),this.solve=async function(){return await s.solve()},this.reset=function(){s.reset()},this.addEventListener=function(e,t){a.addEventListener(e,t)},Object.defineProperty(this,"token",{get:()=>s.getToken(),configurable:!0,enumerable:!0})}}const i=new CSSStyleSheet;i.replaceSync('html{--cap-font:system,-apple-system,"BlinkMacSystemFont",".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande","Ubuntu","arial",sans-serif;--cap-color:#212121;--cap-background:#fdfdfd;--cap-border-color:#dddddd8f;--cap-border-radius:14px;--cap-checkbox-border:1px solid #aaaaaad1;--cap-checkbox-border-radius:6px;--cap-checkbox-background:#fafafa91;--cap-widget-width:240px;--cap-widget-padding:14px;--cap-checkbox-size:24px;--cap-checkbox-margin:2px;--cap-transition-duration:0.2s;--cap-gap:15px;--cap-opacity-hover:0.8;--cap-hover-filter:brightness(97%);--cap-active-scale:0.98;--cap-credits-font-size:12px;--cap-spinner-color:black;--cap-spinner-background-color:#eee;--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");--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");--cap-spinner-thickness:5px;}'),document.adoptedStyleSheets.push(i);const n=function(){let e;self.onmessage=async({data:{salt:t,target:r}})=>{e||(e=await hashwasm.createSHA256());let s=0;let a=0;const i=new Uint8Array(128),n=new TextEncoder;for(;;)try{for(let a=0;a<5e4;a++){const a=t+s.toString(),o=n.encode(a);i.set(o),e.init(),e.update(i.subarray(0,o.length));if(e.digest("hex").startsWith(r))return void self.postMessage({nonce:s,found:!0});s++}a+=5e4,a>=5e5&&(self.postMessage({nonce:s,found:!1}),a=0)}catch(e){return void self.postMessage({found:!1,error:e.message})}}};setTimeout((async function(){e=await(await fetch("https://cdn.jsdelivr.net/npm/@cap.js/widget/wasm-hashes.min.js")).text()+n.toString().replace(/^function\s*\([^\)]*\)\s*{|\}$/g,"").trim()}),1),window.Cap=a,customElements.get("cap-widget")?console.warn("The cap-widget element has already been defined. Skipping re-defining it."):customElements.define("cap-widget",s),"object"==typeof exports&&"undefined"!=typeof module?module.exports=a:"function"==typeof define&&define.amd&&define([],(function(){return a})),"undefined"!=typeof exports&&(exports.default=a)}(); \ No newline at end of file +"use strict";!function(){let e;const t=(e,t=1e4)=>new Promise(((r,s)=>{const a=setTimeout((()=>{s(new Error("Initialize timeout"))}),t),i=()=>{e()?(clearTimeout(a),r()):setTimeout(i,500)};i()}));class r extends HTMLElement{#e="";#t=null;#r=navigator.hardwareConcurrency||8;#s=null;#a;#i;#n;#o=!1;#c;static get observedAttributes(){return["onsolve","onprogress","onreset","onerror","workers"]}constructor(){super(),this.#c&&this.#c.forEach(((e,t)=>{this.removeEventListener(t.slice(2),e)})),this.#c=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)}async initialize(){this.#e&&URL.revokeObjectURL(this.#e);try{await t((()=>!!e)),this.#e=URL.createObjectURL(new Blob([e],{type:"application/javascript"}))}catch(e){throw this.error("Failed to initialize worker"),e}}attributeChangedCallback(e,t,r){if(e.startsWith("on")){const t=e.slice(2),s=this.#c.get(e);if(s&&this.removeEventListener(t,s),r){const r=t=>{const r=this.getAttribute(e);"function"==typeof window[r]&&window[r].call(this,t)};this.#c.set(e,r),this.addEventListener(t,r)}}}async connectedCallback(){this.#n=this,this.#a=this.attachShadow({mode:"open"}),this.#i=document.createElement("div"),this.createUI(),this.addEventListeners(),await this.initialize(),this.#i.removeAttribute("disabled");const e=this.getAttribute("data-cap-worker-count");this.setWorkersCount(parseInt(e)?parseInt(e,10):navigator.hardwareConcurrency||8),this.#n.innerHTML='<input type="hidden" name="cap-token">'}async solve(){if(!this.#o)try{this.#o=!0,this.updateUI("verifying","Verifying...",!0),await t((()=>!!this.#e)),this.dispatchEvent("progress",{progress:0});try{const e=this.getAttribute("data-cap-api-endpoint");if(!e)throw new Error("Missing API endpoint");const{challenge:t,target:r,token:s}=await(await fetch(`${e}challenge`,{method:"POST"})).json(),a=await this.solveChallenges({challenge:t,target:r,token:s}),i=await(await fetch(`${e}redeem`,{method:"POST",body:JSON.stringify({token:s,solutions:a}),headers:{"Content-Type":"application/json"}})).json();if(!i.success)throw new Error("Invalid solution");this.dispatchEvent("progress",{progress:100}),this.dispatchEvent("solve",{token:i.token}),this.#s=i.token,this.querySelector("input[name='cap-token']")&&(this.querySelector("input[name='cap-token']").value=i.token),this.#t&&clearTimeout(this.#t);const n=new Date(i.expires).getTime()-Date.now();return n>0&&n<864e5?this.#t=setTimeout((()=>this.reset()),n):this.error("Invalid expiration time"),{success:!0,token:this.#s}}catch(e){throw this.error(e.message),e}}finally{this.#o=!1}}async solveChallenges({challenge:e,target:t}){const r=e.length;let s=0;const a=Array(this.#r).fill(null).map((()=>new Worker(this.#e))),i=([e,t],i)=>new Promise(((n,o)=>{const c=a[i],d=setTimeout((()=>{c.terminate(),a[i]=new Worker(this.#e),o(new Error("Worker timeout"))}),3e4);c.onmessage=({data:a})=>{a.found&&(clearTimeout(d),s++,this.dispatchEvent("progress",{progress:Math.round(s/r*100)}),n([e,t,a.nonce]))},c.onerror=e=>{clearTimeout(d),this.error(`Error in worker: ${e}`),o(e)},c.postMessage({salt:e,target:t})})),n=[];try{for(let t=0;t<e.length;t+=this.#r){const r=e.slice(t,Math.min(t+this.#r,e.length)),s=await Promise.all(r.map(((e,t)=>i(e,t))));n.push(...s)}}finally{a.forEach((e=>e.terminate()))}return n}setWorkersCount(e){const t=parseInt(e,10),r=Math.min(navigator.hardwareConcurrency||8,16);this.#r=!isNaN(t)&&t>0&&t<=r?t:navigator.hardwareConcurrency||8}createUI(){this.#i.classList.add("captcha"),this.#i.setAttribute("role","button"),this.#i.setAttribute("tabindex","0"),this.#i.setAttribute("disabled","true"),this.#i.innerHTML='<div class="checkbox"></div><p>I\'m a human</p><a href="#" class="credits" target="_blank"><span>Secured by </span>Cap</a>',this.#a.innerHTML='<style>.captcha{background-color:var(--cap-background);border:1px solid var(--cap-border-color);border-radius:var(--cap-border-radius);width:var(--cap-widget-width);display:flex;align-items:center;padding:var(--cap-widget-padding);gap:var(--cap-gap);cursor:pointer;transition:filter var(--cap-transition-duration),transform var(--cap-transition-duration);position:relative;-webkit-tap-highlight-color:rgba(255,255,255,0);overflow:hidden;color:var(--cap-color)}.captcha:hover{filter:var(--cap-hover-filter)}.captcha:not([disabled]):active{transform:scale(var(--cap-active-scale))}.checkbox{width:var(--cap-checkbox-size);height:var(--cap-checkbox-size);border:var(--cap-checkbox-border);border-radius:var(--cap-checkbox-border-radius);background-color:var(--cap-checkbox-background);transition:opacity var(--cap-transition-duration);margin-top:var(--cap-checkbox-margin);margin-bottom:var(--cap-checkbox-margin)}.captcha *{font-family:var(--cap-font)}.captcha p{margin:0;font-weight:500;font-size:15px;user-select:none;transition:opacity var(--cap-transition-duration)}.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: conic-gradient(var(--cap-spinner-color) 0%, var(--cap-spinner-color) var(--progress, 0%), var(--cap-spinner-background-color) var(--progress, 0%), var(--cap-spinner-background-color) 100%);position: relative;}.captcha[data-state=verifying] .checkbox::after {content: "";background-color: var(--cap-background);width: calc(100% - var(--cap-spinner-thickness));height: calc(100% - var(--cap-spinner-thickness));border-radius: 50%;margin:calc(var(--cap-spinner-thickness) / 2)}.captcha[data-state=done] .checkbox{border:1px solid transparent;background-image:var(--cap-checkmark);background-size:cover}.captcha[data-state=error] .checkbox{border:1px solid transparent;background-image:var(--cap-error-cross);background-size:cover}.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:var(--cap-credits-font-size);color:var(--cap-color);opacity:var(--cap-opacity-hover)}.captcha .credits span{display:none;text-decoration:underline}.captcha .credits:hover span{display:inline-block}</style>',this.#a.appendChild(this.#i)}addEventListeners(){this.#i.querySelector("a").addEventListener("click",(e=>{e.stopPropagation(),e.preventDefault(),window.open("#","_blank")})),this.#i.addEventListener("click",(()=>{this.#i.hasAttribute("disabled")||this.solve()})),this.addEventListener("progress",this.boundHandleProgress),this.addEventListener("solve",this.boundHandleSolve),this.addEventListener("error",this.boundHandleError),this.addEventListener("reset",this.boundHandleReset)}updateUI(e,t,r=!1){this.#i.setAttribute("data-state",e),this.#i.querySelector("p").innerText=t,r?this.#i.setAttribute("disabled","true"):this.#i.removeAttribute("disabled")}handleProgress(e){const t=this.#i.querySelector("p");t&&(this.#i.querySelector(".checkbox").style.setProperty("--progress",`${e.detail.progress}%`),t.innerText=`Verifying... ${e.detail.progress}%`),this.executeAttributeCode("onprogress",e)}handleSolve(e){this.updateUI("done","You're a human",!0),this.executeAttributeCode("onsolve",e)}handleError(e){this.updateUI("error","Error. Try again."),this.executeAttributeCode("onerror",e)}handleReset(e){this.updateUI("","I'm a human"),this.executeAttributeCode("onreset",e)}executeAttributeCode(e,t){const r=this.getAttribute(e);if(!r)return;new Function("event",r).call(this,t)}error(e="Unknown error"){console.error("[Cap] Error:",e),this.dispatchEvent("error",{isCap:!0,message:e})}dispatchEvent(e,t={}){const r=new CustomEvent(e,{bubbles:!0,composed:!0,detail:t});super.dispatchEvent(r)}reset(){this.#t&&(clearTimeout(this.#t),this.#t=null),this.dispatchEvent("reset"),this.#s=null,this.querySelector("input[name='cap-token']")&&(this.querySelector("input[name='cap-token']").value="")}get token(){return this.#s}disconnectedCallback(){this.removeEventListener("progress",this.boundHandleProgress),this.removeEventListener("solve",this.boundHandleSolve),this.removeEventListener("error",this.boundHandleError),this.removeEventListener("reset",this.boundHandleReset),this.#c.forEach(((e,t)=>{this.removeEventListener(t.slice(2),e)})),this.#c.clear(),this.#a&&(this.#a.innerHTML=""),this.reset(),this.cleanup()}cleanup(){this.#t&&(clearTimeout(this.#t),this.#t=null),this.#e&&(URL.revokeObjectURL(this.#e),this.#e="")}}class s{constructor(e={},t){let r=t||document.createElement("cap-widget");if(Object.entries(e).forEach((([e,t])=>{r.setAttribute(e,t)})),!e.apiEndpoint)throw r.remove(),new Error("Missing API endpoint");console.log(e),r.setAttribute("data-cap-api-endpoint",e.apiEndpoint),this.widget=r,this.solve=r.solve,this.reset=r.reset,this.addEventListener=r.addEventListener,Object.defineProperty(this,"token",{get:()=>r.getToken(),configurable:!0,enumerable:!0})}}const a=new CSSStyleSheet;a.replaceSync('html{--cap-font:system,-apple-system,"BlinkMacSystemFont",".SFNSText-Regular","San Francisco","Roboto","Segoe UI","Helvetica Neue","Lucida Grande","Ubuntu","arial",sans-serif;--cap-color:#212121;--cap-background:#fdfdfd;--cap-border-color:#dddddd8f;--cap-border-radius:14px;--cap-checkbox-border:1px solid #aaaaaad1;--cap-checkbox-border-radius:6px;--cap-checkbox-background:#fafafa91;--cap-widget-width:240px;--cap-widget-padding:14px;--cap-checkbox-size:24px;--cap-checkbox-margin:2px;--cap-transition-duration:0.2s;--cap-gap:15px;--cap-opacity-hover:0.8;--cap-hover-filter:brightness(97%);--cap-active-scale:0.98;--cap-credits-font-size:12px;--cap-spinner-color:black;--cap-spinner-background-color:#eee;--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");--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");--cap-spinner-thickness:5px;}'),document.adoptedStyleSheets.push(a);const i=function(){let e;self.onmessage=async({data:{salt:t,target:r}})=>{e||(e=await hashwasm.createSHA256());let s=0;let a=0;const i=new Uint8Array(128),n=new TextEncoder;for(;;)try{for(let a=0;a<5e4;a++){const a=t+s.toString(),o=n.encode(a);i.set(o),e.init(),e.update(i.subarray(0,o.length));if(e.digest("hex").startsWith(r))return void self.postMessage({nonce:s,found:!0});s++}a+=5e4,a>=5e5&&(self.postMessage({nonce:s,found:!1}),a=0)}catch(e){return void self.postMessage({found:!1,error:e.message})}}};setTimeout((async function(){e=await(await fetch("https://cdn.jsdelivr.net/npm/@cap.js/widget/wasm-hashes.min.js")).text()+i.toString().replace(/^function\s*\([^\)]*\)\s*{|\}$/g,"").trim()}),1),window.Cap=s,customElements.get("cap-widget")?console.warn("The cap-widget element has already been defined. Skipping re-defining it."):customElements.define("cap-widget",r),"object"==typeof exports&&"undefined"!=typeof module?module.exports=s:"function"==typeof define&&define.amd&&define([],(function(){return s})),"undefined"!=typeof exports&&(exports.default=s)}(); \ No newline at end of file diff --git a/widget/package.json b/widget/package.json index 0898619..9074515 100644 --- a/widget/package.json +++ b/widget/package.json @@ -1,6 +1,6 @@ { "name": "@cap.js/widget", - "version": "0.0.9", + "version": "0.0.10", "description": "Cap widget", "keywords": [ "captcha", diff --git a/widget/src/cap.js b/widget/src/cap.js index 8d3b0dc..3371726 100644 --- a/widget/src/cap.js +++ b/widget/src/cap.js @@ -19,12 +19,35 @@ }); }; - class CapBase { + class CapWidget extends HTMLElement { #workerUrl = ""; - #el = null; #resetTimer = null; #workersCount = navigator.hardwareConcurrency || 8; #token = null; + #shadow; + #div; + #host; + #solving = false; + #eventHandlers; + + static get observedAttributes() { + return ["onsolve", "onprogress", "onreset", "onerror", "workers"]; + } + + 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); + } async initialize() { if (this.#workerUrl) { @@ -44,60 +67,104 @@ } } + attributeChangedCallback(name, oldValue, newValue) { + if (name.startsWith("on")) { + const eventName = name.slice(2); + const oldHandler = this.#eventHandlers.get(name); + if (oldHandler) { + this.removeEventListener(eventName, oldHandler); + } + + if (newValue) { + 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); + } + } + } + + async connectedCallback() { + this.#host = this; + this.#shadow = this.attachShadow({ mode: "open" }); + this.#div = document.createElement("div"); + this.createUI(); + this.addEventListeners(); + await this.initialize(); + this.#div.removeAttribute("disabled"); + + const workers = this.getAttribute("data-cap-worker-count"); + this.setWorkersCount( + parseInt(workers) + ? parseInt(workers, 10) + : navigator.hardwareConcurrency || 8 + ); + this.#host.innerHTML = `<input type="hidden" name="cap-token">`; + } + async solve() { - await until(() => !!this.#workerUrl); - this.dispatchEvent("progress", { progress: 0 }); + if (this.#solving) { + return; + } try { - const apiEndpoint = this.#el.getAttribute("data-cap-api-endpoint"); - if (!apiEndpoint) throw new Error("Missing API endpoint"); + this.#solving = true; + this.updateUI("verifying", "Verifying...", true); - const { challenge, target, token } = await ( - await fetch( - `${apiEndpoint}challenge`, - { - method: "POST", - } - ) - ).json(); - const solutions = await this.solveChallenges({ - challenge, - target, - token, - }); + await until(() => !!this.#workerUrl); + this.dispatchEvent("progress", { progress: 0 }); + + try { + const apiEndpoint = this.getAttribute("data-cap-api-endpoint"); + if (!apiEndpoint) throw new Error("Missing API endpoint"); - const resp = await ( - await fetch( - `${apiEndpoint}redeem`, - { + const { challenge, target, token } = await ( + await fetch(`${apiEndpoint}challenge`, { + method: "POST", + }) + ).json(); + const solutions = await this.solveChallenges({ + challenge, + target, + token, + }); + + const resp = await ( + await fetch(`${apiEndpoint}redeem`, { method: "POST", body: JSON.stringify({ token, solutions }), headers: { "Content-Type": "application/json" }, - } - ) - ).json(); + }) + ).json(); - if (!resp.success) throw new Error("Invalid solution"); + if (!resp.success) throw new Error("Invalid solution"); - this.dispatchEvent("progress", { progress: 100 }); - this.dispatchEvent("solve", { token: resp.token }); - this.#token = resp.token; - if (this.#el.querySelector("input[name='cap-token']")) { - this.#el.querySelector("input[name='cap-token']").value = resp.token; - } + this.dispatchEvent("progress", { progress: 100 }); + this.dispatchEvent("solve", { token: resp.token }); + this.#token = resp.token; + if (this.querySelector("input[name='cap-token']")) { + this.querySelector("input[name='cap-token']").value = 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); // 24h - } else { - this.error("Invalid expiration time"); - } + 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"); + } - return { success: true, token: this.#token }; - } catch (err) { - this.error(err.message); - throw err; + return { success: true, token: this.#token }; + } catch (err) { + this.error(err.message); + throw err; + } + } finally { + this.#solving = false; } } @@ -156,36 +223,6 @@ return results; } - reset() { - if (this.#resetTimer) { - clearTimeout(this.#resetTimer); - this.#resetTimer = null; - } - this.dispatchEvent("reset"); - this.#token = null; - if (this.#el.querySelector("input[name='cap-token']")) { - this.#el.querySelector("input[name='cap-token']").value = ""; - } - } - - error(message = "Unknown error") { - console.error("[Cap] Error:", message); - this.dispatchEvent("error", { isCap: true, message }); - } - - dispatchEvent(eventName, detail = {}) { - const event = new CustomEvent(eventName, { - bubbles: true, - composed: true, - detail, - }); - this.#el.dispatchEvent(event); - } - - setElement(el) { - this.#el = el; - } - setWorkersCount(workers) { const parsedWorkers = parseInt(workers, 10); const maxWorkers = Math.min(navigator.hardwareConcurrency || 8, 16); @@ -197,89 +234,6 @@ : navigator.hardwareConcurrency || 8; } - getToken() { - return this.#token; - } - - cleanup() { - if (this.#resetTimer) { - clearTimeout(this.#resetTimer); - this.#resetTimer = null; - } - - if (this.#workerUrl) { - URL.revokeObjectURL(this.#workerUrl); - this.#workerUrl = ""; - } - } - } - - class CapWidget extends HTMLElement { - #capBase = new CapBase(); - #shadow; - #div; - #host; - #solving = false; - eventHandlers; - - static get observedAttributes() { - return ["onsolve", "onprogress", "onreset", "onerror", "workers"]; - } - - 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); - } - - attributeChangedCallback(name, oldValue, newValue) { - if (name.startsWith("on")) { - const eventName = name.slice(2); - const oldHandler = this.eventHandlers.get(name); - if (oldHandler) { - this.removeEventListener(eventName, oldHandler); - } - - if (newValue) { - 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); - } - } - } - - async connectedCallback() { - this.#host = this; - this.#shadow = this.attachShadow({ mode: "open" }); - this.#capBase.setElement(this); - this.#div = document.createElement("div"); - this.createUI(); - this.addEventListeners(); - await this.#capBase.initialize(); - this.#div.removeAttribute("disabled"); - const workers = this.getAttribute("data-cap-worker-count"); - - this.#capBase.setWorkersCount( - parseInt(workers) - ? parseInt(workers, 10) - : navigator.hardwareConcurrency || 8 - ); - this.#host.innerHTML = `<input type="hidden" name="cap-token">`; - } - createUI() { this.#div.classList.add("captcha"); this.#div.setAttribute("role", "button"); @@ -309,29 +263,14 @@ this.addEventListener("reset", this.boundHandleReset); } - async solve() { - if (this.#solving) { - return; - } - - try { - this.#solving = true; - this.updateUI("verifying", "Verifying...", true); - const result = await this.#capBase.solve(); - return result; - } finally { - this.#solving = false; - } - } - updateUI(state, text, disabled = false) { - this.#div.setAttribute("data-state", state); - this.#div.querySelector("p").innerText = text; - if (disabled) { - this.#div.setAttribute("disabled", "true"); - } else { - this.#div.removeAttribute("disabled"); - } + 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) { @@ -369,12 +308,34 @@ func.call(this, event); } + error(message = "Unknown error") { + console.error("[Cap] Error:", message); + this.dispatchEvent("error", { isCap: true, message }); + } + + dispatchEvent(eventName, detail = {}) { + const event = new CustomEvent(eventName, { + bubbles: true, + composed: true, + detail, + }); + super.dispatchEvent(event); + } + reset() { - this.#capBase.reset(); + if (this.#resetTimer) { + clearTimeout(this.#resetTimer); + this.#resetTimer = null; + } + this.dispatchEvent("reset"); + this.#token = null; + if (this.querySelector("input[name='cap-token']")) { + this.querySelector("input[name='cap-token']").value = ""; + } } get token() { - return this.#capBase.getToken(); + return this.#token; } disconnectedCallback() { @@ -383,60 +344,63 @@ this.removeEventListener("error", this.boundHandleError); this.removeEventListener("reset", this.boundHandleReset); - this.eventHandlers.forEach((handler, eventName) => { + this.#eventHandlers.forEach((handler, eventName) => { this.removeEventListener(eventName.slice(2), handler); }); - this.eventHandlers.clear(); + this.#eventHandlers.clear(); if (this.#shadow) { this.#shadow.innerHTML = ""; } - this.#capBase.reset(); - this.#capBase.cleanup(); + this.reset(); + this.cleanup(); + } + + cleanup() { + if (this.#resetTimer) { + clearTimeout(this.#resetTimer); + this.#resetTimer = null; + } + + if (this.#workerUrl) { + URL.revokeObjectURL(this.#workerUrl); + this.#workerUrl = ""; + } } } class Cap { - constructor(el, config = {}) { - let capBase = new CapBase(); - let element = el || document.createElement("div"); + constructor(config = {}, el) { + let widget = el || document.createElement("cap-widget"); - if (!el) element.style.display = "none"; Object.entries(config).forEach(([a, b]) => { - element.setAttribute(a, b); + widget.setAttribute(a, b); }); if (config.apiEndpoint) { - element.setAttribute("data-cap-api-endpoint", config.apiEndpoint); + console.log(config); + widget.setAttribute("data-cap-api-endpoint", config.apiEndpoint); } else { - element.remove(); + widget.remove(); throw new Error("Missing API endpoint"); } - capBase.setElement(element); - capBase.setWorkersCount( - config.workers || navigator.hardwareConcurrency || 8 - ); - capBase.initialize(); - - this.solve = async function () { - return await capBase.solve(); - }; - - this.reset = function () { - capBase.reset(); - }; - - this.addEventListener = function (event, callback) { - element.addEventListener(event, callback); - }; + 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: () => capBase.getToken(), + get: () => widget.getToken(), configurable: true, enumerable: true, }); + + if (!el) { + widget.style.display = "none"; + document.documentElement.appendChild(widget); + } } } @@ -496,7 +460,11 @@ setTimeout(async function () { workerScript = - (await (await fetch("https://cdn.jsdelivr.net/npm/@cap.js/widget/wasm-hashes.min.js")).text()) + + (await ( + await fetch( + "https://cdn.jsdelivr.net/npm/@cap.js/widget/wasm-hashes.min.js" + ) + ).text()) + workerFunct .toString() .replace(/^function\s*\([^\)]*\)\s*{|\}$/g, "")