@kcaptcha/client 0.1.0

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.
@@ -0,0 +1,191 @@
1
+ "use strict";(()=>{var m={width:300,height:74,color:"#3fb950",name:"kcaptcha_token"},j=[160,600],X=[40,120],y=class extends Error{constructor(e){super(`[kCAPTCHA] ${e}`),this.name="KCaptchaOptionError"}},x=i=>console.warn(`[kCAPTCHA] ${i}`);function J(i){if(typeof i!="string"||i.trim()==="")throw new y('Missing required "endpoint" option, so the widget cannot work. Example: <div data-kcaptcha data-endpoint="/login/kcaptcha"></div>');let e=i.trim().replace(/\/+$/,""),t=/^\/(?!\/)/.test(e),n=/^https?:\/\/[^\s/]+/i.test(e);if(!t&&!n)throw new y(`Invalid "endpoint": ${JSON.stringify(i)}. It must start with "/" (e.g. "/login/kcaptcha") or be a full http(s) URL.`);return e}function R(i,e,t,[n,s]){if(e==null||e==="")return t;let o=typeof e=="number"?e:Number(e);if(!Number.isFinite(o))return x(`"${i}" must be a number (got ${JSON.stringify(e)}). Using ${t}.`),t;let r=Math.round(o),a=Math.min(s,Math.max(n,r));return a!==r&&x(`"${i}" must be between ${n} and ${s} (got ${r}). Using ${a}.`),a}function Y(i){return i==null||i===""?m.color:typeof i=="string"&&typeof CSS!="undefined"&&CSS.supports("color",i)?i.trim():(x(`"color" is not a valid CSS colour (got ${JSON.stringify(i)}). Using ${m.color}.`),m.color)}function Z(i){return i==null||i===""?m.name:typeof i=="string"&&/^[A-Za-z_][\w.-]{0,63}$/.test(i)?i:(x(`"name" is not a valid field name (got ${JSON.stringify(i)}). Using "${m.name}".`),m.name)}function B(i){let e=J(i.endpoint);return i.expire!==void 0&&x('"expire" cannot be set in the browser. Set it on the server: createKCaptcha({ expire }). Ignored.'),i.rateLimit!==void 0&&x('"rateLimit" cannot be set in the browser. Set it on the server: createKCaptcha({ rateLimit }). Ignored.'),{endpoint:e,width:R("width",i.width,m.width,j),height:R("height",i.height,m.height,X),color:Y(i.color),name:Z(i.name),onVerify:typeof i.onVerify=="function"?i.onVerify:void 0,onExpire:typeof i.onExpire=="function"?i.onExpire:void 0}}var f=class extends Error{constructor(t,n,s){super(t);this.code=t;this.status=n;this.retryAfter=s;this.name="ApiError"}},Q=1e4,ee=200,te=2e3,I=2e3,N=1024,D=i=>typeof i=="object"&&i!==null&&!Array.isArray(i),w=(i,e)=>typeof i=="number"&&Number.isFinite(i)&&i>0&&i<=e;function u(){throw new f("bad_response",200)}function ie(i){if(!D(i))return u();let{width:e,height:t,spacing:n,pipes:s}=i;if(!w(e,I)||!w(t,I)||!w(n,100)||!Array.isArray(s)||s.length===0||s.length>ee)return u();for(let o of s){if(!Array.isArray(o)||o.length<4||o.length%2!==0||o.length>te)return u();for(let r of o)if(typeof r!="number"||!Number.isFinite(r))return u()}return{width:e,height:t,spacing:n,pipes:s}}function ne(i){let{token:e,expiresIn:t}=i;return typeof e!="string"||e.length===0||e.length>N||!w(t,3600)?u():{token:e,expiresIn:t,geometry:ie(i.geometry)}}var k=class{constructor(e){this.endpoint=e}async newCode(){return ne(await this.post("/new-code"))}async verify(e,t){let n=await this.post("/verify-captcha",{token:e,answer:t}),{token:s,expiresIn:o}=n;return typeof s!="string"||s.length===0||s.length>N||!w(o,86400)?u():{token:s,expiresIn:o}}async post(e,t){let n=new AbortController,s=setTimeout(()=>n.abort(),Q),o;try{o=await fetch(`${this.endpoint}${e}`,{method:"POST",headers:t===void 0?{Accept:"application/json"}:{Accept:"application/json","Content-Type":"application/json"},body:t===void 0?void 0:JSON.stringify(t),credentials:"same-origin",cache:"no-store",signal:n.signal})}catch{throw new f("network_error",0)}finally{clearTimeout(s)}let r=null;try{r=await o.json()}catch{}let a=D(r)?r:null;if(!o.ok||!a||a.ok!==!0){let l=a&&typeof a.error=="string"?a.error:`http_${o.status}`,d=Number(o.headers.get("Retry-After")),h=a&&typeof a.retryAfter=="number"?a.retryAfter:Number.isFinite(d)&&d>0?d:void 0;throw new f(l,o.status,h)}return a}};var S=Math.PI*2,oe=1e3/60,E=1.2,se="#000000",T=3e3,re=T/(400*240),L=15,_=20,ae=20,$=.1,le=.1,G=.5,W="QWERTYUIPASDFGHJKLZXCVBNM123456789";function K(){let i="";for(let e=0;e<5;e++)i+=W[Math.floor(Math.random()*W.length)];return i}function de(i){let e=i.length/2,t=[],n=[],s=[0];for(let o=0;o<e;o++)t.push(i[o*2]),n.push(i[o*2+1]),o>0&&s.push(s[o-1]+Math.hypot(t[o]-t[o-1],n[o]-n[o-1]));return{xs:t,ys:n,cum:s,total:s[e-1]}}var g={x:0,y:0,nx:0,ny:0};function he(i,e){let{xs:t,ys:n,cum:s}=i,o=1,r=s.length-1;for(;o<r;){let v=o+r>>1;s[v]<e?o=v+1:r=v}let a=s[o-1],l=s[o],d=l>a?(e-a)/(l-a):0,h=t[o]-t[o-1],c=n[o]-n[o-1],b=Math.hypot(h,c)||1;g.x=t[o-1]+h*d,g.y=n[o-1]+c*d,g.nx=-c/b,g.ny=h/b}var C=class{constructor(e){this.particles=new Float32Array(T*4);this.geometry=null;this.dots=[];this.mask=null;this.maskW=0;this.maskH=0;this.watermark=K();this.running=!1;this.raf=0;this.last=0;this.drift=0;this.tick=e=>{if(!this.running)return;let t=Math.min(Math.max((e-this.last)/oe,0),3);this.last=e,this.draw(t),this.raf=requestAnimationFrame(this.tick)};let t=e.getContext("2d");if(!t)throw new Error("[kCAPTCHA] Canvas 2D is not supported in this browser.");this.ctx=t;let n=Math.min(window.devicePixelRatio||1,2);e.width=Math.round(400*n),e.height=Math.round(240*n),t.setTransform(n,0,0,n,0,0);for(let s=0;s<T;s++){let o=s*4,r=Math.random()*S,a=$+Math.random()*(le-$);this.particles[o]=Math.random()*400,this.particles[o+1]=Math.random()*240,this.particles[o+2]=Math.cos(r)*a,this.particles[o+3]=Math.sin(r)*a}}setGeometry(e){if(this.geometry=e,this.dots=[],this.mask=null,this.drift=0,this.watermark=K(),!!e){for(let t of e.pipes){let n=de(t),s=n.total*L*re,o=Math.floor(s+Math.random());for(let r=0;r<o;r++){let a=Math.random()<.5?-1:1,l=_+Math.random()*(ae-_);this.dots.push({pipe:n,s:Math.random()*n.total,v:a*l,u:(Math.random()-.5)*L})}}this.buildMask(e)}}start(){this.running||(this.running=!0,this.last=performance.now(),this.raf=requestAnimationFrame(this.tick))}stop(){this.running=!1,cancelAnimationFrame(this.raf)}destroy(){this.stop(),this.geometry=null,this.dots=[],this.mask=null}buildMask(e){let t=Math.ceil(e.width),n=Math.ceil(e.height),s=document.createElement("canvas");s.width=t,s.height=n;let o=s.getContext("2d",{willReadFrequently:!0});if(!o)return;o.strokeStyle="#000",o.lineWidth=L,o.lineCap="butt",o.lineJoin="round";for(let l of e.pipes){o.beginPath(),o.moveTo(l[0],l[1]);for(let d=2;d<l.length;d+=2)o.lineTo(l[d],l[d+1]);o.stroke()}let r=o.getImageData(0,0,t,n).data,a=new Uint8Array(t*n);for(let l=0;l<a.length;l++)a[l]=r[l*4+3]>127?1:0;this.mask=a,this.maskW=t,this.maskH=n}draw(e){let{ctx:t,particles:n}=this;t.clearRect(0,0,400,240),t.save(),t.font="bold 46px monospace",t.fillStyle="rgba(0, 0, 0, 0.035)",t.textAlign="center",t.textBaseline="middle",t.fillText(this.watermark,400/2,240/2),t.restore();let s=this.geometry,o=0,r=0;if(s){this.drift+=G*e;let l=460,d=-s.width-60;o=l-this.drift%(l-d),r=(240-s.height)/2}t.fillStyle=se,t.beginPath();let a=this.mask;for(let l=0;l<T;l++){let d=l*4,h=n[d]+(n[d+2]-G)*e,c=n[d+1]+n[d+3]*e;if(h<0?h+=400:h>=400&&(h-=400),(c<0||c>240)&&(n[d+3]=-n[d+3],c=Math.min(240,Math.max(0,c))),n[d]=h,n[d+1]=c,a){let b=Math.floor(h-o),v=Math.floor(c-r);if(b>=0&&b<this.maskW&&v>=0&&v<this.maskH&&a[v*this.maskW+b])continue}t.moveTo(h+E,c),t.arc(h,c,E,0,S)}if(s)for(let l of this.dots){let d=l.pipe.total;l.s=(l.s+l.v*e)%d,l.s<0&&(l.s+=d),he(l.pipe,l.s);let h=o+g.x+g.nx*l.u,c=r+g.y+g.ny*l.u;h>0&&h<400&&c>0&&c<240&&(t.moveTo(h+E,c),t.arc(h,c,E,0,S))}t.fill()}};var H=`
2
+ *, *::before, *::after { box-sizing: border-box; }
3
+
4
+ :host {
5
+ display: inline-block;
6
+ max-width: 100%;
7
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
8
+ }
9
+
10
+ .root { width: 100%; height: 100%; }
11
+
12
+ /* ---------- checkbox button (customisable: width, height, colour) ---------- */
13
+ .trigger {
14
+ display: flex;
15
+ align-items: center;
16
+ gap: 12px;
17
+ width: 100%;
18
+ height: 100%;
19
+ margin: 0;
20
+ padding: 0 14px;
21
+ background: #f8fafc;
22
+ color: #1f2328;
23
+ border: 1px solid #d0d7de;
24
+ border-radius: 6px;
25
+ font: inherit;
26
+ font-size: 15px;
27
+ text-align: left;
28
+ cursor: pointer;
29
+ transition: border-color 0.15s;
30
+ }
31
+ .pause { display: none; }
32
+
33
+ .trigger:hover { border-color: var(--kc-accent); }
34
+ .trigger:focus-visible { outline: 2px solid var(--kc-accent); outline-offset: 2px; }
35
+ .trigger[aria-disabled="true"] { cursor: default; }
36
+
37
+ .check {
38
+ flex: none;
39
+ display: grid;
40
+ place-items: center;
41
+ width: 24px;
42
+ height: 24px;
43
+ background: #fff;
44
+ border: 2px solid #8c959f;
45
+ border-radius: 4px;
46
+ transition: background 0.15s, border-color 0.15s;
47
+ }
48
+ .check svg {
49
+ width: 16px;
50
+ height: 16px;
51
+ fill: none;
52
+ stroke: #fff;
53
+ stroke-width: 3;
54
+ stroke-linecap: round;
55
+ stroke-linejoin: round;
56
+ opacity: 0;
57
+ }
58
+ .trigger[data-state="verified"] .check { background: var(--kc-accent); border-color: var(--kc-accent); }
59
+ .trigger[data-state="verified"] .check svg { opacity: 1; }
60
+
61
+ .label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
62
+ .brand {
63
+ flex: none;
64
+ color: #6e7781;
65
+ font: 11px ui-monospace, "SF Mono", Menlo, Consolas, monospace;
66
+ letter-spacing: 0.4px;
67
+ }
68
+
69
+ /* ---------- small popup dialog (anchored under the checkbox, no backdrop) ---------- */
70
+ .overlay {
71
+ position: fixed;
72
+ top: 0;
73
+ left: 0;
74
+ z-index: 2147483647;
75
+ width: 320px; /* the script sets the real width and position */
76
+ }
77
+ .overlay[hidden] { display: none; }
78
+
79
+ .dialog {
80
+ padding: 12px;
81
+ background: #0d1117;
82
+ color: #e6edf3;
83
+ border: 1px solid #30363d;
84
+ border-radius: 10px;
85
+ box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
86
+ }
87
+
88
+ .head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
89
+ .head h2 { margin: 0; font-size: 14px; font-weight: 600; }
90
+ .help { margin: 4px 0 0; color: #8b949e; font-size: 12px; line-height: 1.4; }
91
+
92
+ .stage {
93
+ position: relative;
94
+ margin-top: 10px;
95
+ padding: 6px;
96
+ background: #ffffff;
97
+ border: 1px solid #30363d;
98
+ border-radius: 8px;
99
+ box-shadow: 0 0 12px rgba(0, 0, 0, 0.5);
100
+ }
101
+ canvas { display: block; width: 100%; height: auto; aspect-ratio: 400 / 240; }
102
+ .loading {
103
+ position: absolute;
104
+ inset: 0;
105
+ display: none;
106
+ align-items: center;
107
+ justify-content: center;
108
+ background: rgba(255, 255, 255, 0.85);
109
+ border-radius: 8px;
110
+ color: #000000;
111
+ font-size: 13px;
112
+ }
113
+ .stage.busy .loading { display: flex; }
114
+
115
+ .answer {
116
+ display: block;
117
+ width: 100%;
118
+ margin: 10px 0 0;
119
+ padding: 8px 10px;
120
+ background: #161b22;
121
+ color: #e6edf3;
122
+ border: 1px solid #30363d;
123
+ border-radius: 6px;
124
+ outline: none;
125
+ font: 15px ui-monospace, "SF Mono", Menlo, Consolas, monospace;
126
+ letter-spacing: 3px;
127
+ text-transform: uppercase;
128
+ }
129
+ .answer::placeholder { color: #6e7681; letter-spacing: 1px; }
130
+ .answer:focus { border-color: #58a6ff; }
131
+
132
+ .actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; }
133
+
134
+ .dialog button {
135
+ padding: 8px 10px;
136
+ background: #21262d;
137
+ color: #e6edf3;
138
+ border: 1px solid #30363d;
139
+ border-radius: 6px;
140
+ font: inherit;
141
+ font-size: 13px;
142
+ cursor: pointer;
143
+ transition: border-color 0.15s, background 0.15s;
144
+ }
145
+ .dialog button:hover:not(:disabled) { border-color: #58a6ff; }
146
+ .dialog button:focus-visible { outline: 2px solid #58a6ff; outline-offset: 2px; }
147
+ .dialog button:disabled { opacity: 0.5; cursor: not-allowed; }
148
+ .dialog button.primary { background: #238636; border-color: #2ea043; }
149
+ .dialog button.primary:hover:not(:disabled) { background: #2ea043; border-color: #3fb950; }
150
+ .dialog button.close { padding: 0 8px; font-size: 18px; line-height: 1.4; }
151
+
152
+ .status { min-height: 16px; margin-top: 8px; font-size: 12px; letter-spacing: 0.3px; color: #8b949e; }
153
+ .status.ok { color: #3fb950; }
154
+ .status.bad { color: #f85149; }
155
+ `;var ce=`
156
+ <div class="root">
157
+ <button type="button" class="trigger" data-state="idle" aria-haspopup="dialog" aria-expanded="false">
158
+ <span class="check" aria-hidden="true">
159
+ <svg viewBox="0 0 24 24"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>
160
+ </span>
161
+ <span class="label">Verify you are human</span>
162
+ <span class="brand" aria-hidden="true">kCAPTCHA</span>
163
+ </button>
164
+
165
+ <div class="overlay" hidden>
166
+ <div class="dialog" role="dialog" aria-modal="true" aria-labelledby="kc-title" aria-describedby="kc-help">
167
+ <div class="head">
168
+ <h2 id="kc-title">Verify you are human</h2>
169
+ <button type="button" class="close" aria-label="Close">&times;</button>
170
+ </div>
171
+ <p id="kc-help" class="help">Read the moving letters in the box and enter them below.</p>
172
+
173
+ <div class="stage">
174
+ <canvas></canvas>
175
+ <div class="loading">Loading&hellip;</div>
176
+ </div>
177
+
178
+ <input class="answer" type="text" placeholder="CAPTCHA" aria-label="CAPTCHA"
179
+ maxlength="8" autocomplete="off" autocapitalize="characters" spellcheck="false">
180
+
181
+ <div class="actions">
182
+ <button type="button" class="primary submit">Submit</button>
183
+ <button type="button" class="refresh">New code</button>
184
+ <button type="button" class="pause" aria-pressed="false">Pause</button>
185
+ </div>
186
+
187
+ <div class="status" role="status" aria-live="polite"></div>
188
+ </div>
189
+ </div>
190
+ </div>
191
+ `,pe="Verify you are human",ue="Verified",me=440;function p(i,e){let t=i.querySelector(e);if(!t)throw new Error(`[kCAPTCHA] Internal error: missing element ${e}`);return t}function fe(i){try{if(typeof CSSStyleSheet!="undefined"&&"replaceSync"in CSSStyleSheet.prototype){let t=new CSSStyleSheet;t.replaceSync(H),i.adoptedStyleSheets=[t];return}}catch{}let e=document.createElement("style");e.textContent=H,i.appendChild(e)}function ge(i){return i instanceof f?i:new f("network_error",0)}var M=class{constructor(e,t){this.host=e;this.opts=t;this.state="idle";this.token=null;this.challenge=null;this.dialogOpen=!1;this.busy=!1;this.cooling=!1;this.paused=!1;this.seq=0;this.challengeTimer=0;this.passTimer=0;this.cooldownTimer=0;this.reposition=()=>{this.dialogOpen&&this.position()};this.onOutsideClick=e=>{e.composedPath().includes(this.host)||this.close(!1)};this.onOverlayKeyDown=e=>{var o;if(e.key==="Escape"){e.preventDefault(),e.stopPropagation(),this.close();return}if(e.key!=="Tab")return;let t=[this.closeBtn,this.input,this.submitBtn,this.refreshBtn,this.pauseBtn].filter(r=>!r.disabled);if(e.preventDefault(),t.length===0)return;let n=t.findIndex(r=>r===this.shadow.activeElement),s=e.shiftKey?n<=0?t.length-1:n-1:n===-1||n===t.length-1?0:n+1;(o=t[s])==null||o.focus()};var o;this.shadow=(o=e.shadowRoot)!=null?o:e.attachShadow({mode:"open"}),this.shadow.replaceChildren(),fe(this.shadow);let n=document.createElement("template");n.innerHTML=ce,this.shadow.appendChild(n.content.cloneNode(!0)),this.root=p(this.shadow,".root"),this.trigger=p(this.shadow,".trigger"),this.label=p(this.shadow,".label"),this.overlay=p(this.shadow,".overlay"),this.closeBtn=p(this.shadow,".close"),this.stage=p(this.shadow,".stage"),this.loadingEl=p(this.shadow,".loading"),this.input=p(this.shadow,".answer"),this.submitBtn=p(this.shadow,".submit"),this.refreshBtn=p(this.shadow,".refresh"),this.pauseBtn=p(this.shadow,".pause"),this.statusEl=p(this.shadow,".status");let s=p(this.shadow,"canvas");e.style.width=`${t.width}px`,e.style.height=`${t.height}px`,this.root.style.setProperty("--kc-accent",t.color),this.hidden=document.createElement("input"),this.hidden.type="hidden",this.hidden.name=t.name,e.appendChild(this.hidden),this.api=new k(t.endpoint),this.renderer=new C(s),this.trigger.addEventListener("click",()=>{this.state!=="verified"&&(this.dialogOpen?this.close():this.open())}),this.closeBtn.addEventListener("click",()=>this.close()),this.overlay.addEventListener("keydown",this.onOverlayKeyDown),this.submitBtn.addEventListener("click",()=>void this.submit()),this.refreshBtn.addEventListener("click",()=>void this.loadChallenge()),this.pauseBtn.addEventListener("click",()=>this.setPaused(!this.paused)),this.input.addEventListener("input",()=>{this.input.value=this.input.value.toUpperCase().replace(/[^A-Z0-9]/g,"")}),this.input.addEventListener("keydown",r=>{r.key==="Enter"&&(r.preventDefault(),this.submit())}),this.updateTrigger()}getToken(){return this.state==="verified"?this.token:null}reset(){this.resetState()}destroy(){this.resetState(),this.renderer.destroy(),this.hidden.remove(),this.shadow.replaceChildren(),this.shadow.adoptedStyleSheets=[],this.host.style.removeProperty("width"),this.host.style.removeProperty("height")}open(){this.overlay.hidden=!1,this.dialogOpen=!0,this.trigger.setAttribute("aria-expanded","true"),this.position(),window.addEventListener("resize",this.reposition),window.addEventListener("scroll",this.reposition,!0),document.addEventListener("mousedown",this.onOutsideClick,!0),this.renderer.start(),this.input.focus({preventScroll:!0}),!this.challenge&&!this.busy&&!this.cooling&&this.loadChallenge()}close(e=!0){this.dialogOpen&&(this.overlay.hidden=!0,this.dialogOpen=!1,this.setPaused(!1),this.trigger.setAttribute("aria-expanded","false"),window.removeEventListener("resize",this.reposition),window.removeEventListener("scroll",this.reposition,!0),document.removeEventListener("mousedown",this.onOutsideClick,!0),this.renderer.stop(),e&&this.trigger.focus({preventScroll:!0}))}position(){let n=document.documentElement.clientWidth,s=window.innerHeight,o=Math.min(me,n-8*2);this.overlay.style.width=`${o}px`;let r=this.trigger.getBoundingClientRect(),a=this.overlay.offsetHeight,l=Math.min(Math.max(r.left,8),n-o-8),d=r.bottom+8;if(d+a>s-8){let h=r.top-8-a;d=h>=8?h:Math.max(8,s-a-8)}this.overlay.style.left=`${l}px`,this.overlay.style.top=`${d}px`}async loadChallenge(e){let t=++this.seq;this.setPaused(!1),this.clearTimer("challenge"),this.challenge=null,this.renderer.setGeometry(null),this.setStatus(e!=null?e:{text:"",kind:""}),this.setBusy(!0,"Loading\u2026");try{let n=await this.api.newCode();if(t!==this.seq)return;this.challenge=n,this.renderer.setGeometry(n.geometry),this.input.value="",this.challengeTimer=window.setTimeout(()=>this.onChallengeExpired(),n.expiresIn*1e3),this.setBusy(!1)}catch(n){if(t!==this.seq)return;this.setBusy(!1),this.handleFailure(n)}this.dialogOpen&&this.input.focus({preventScroll:!0})}onChallengeExpired(){this.challengeTimer=0,this.challenge=null,this.renderer.setGeometry(null),this.dialogOpen&&!this.busy&&!this.cooling&&this.loadChallenge({text:"Code expired \u2014 here is a new one.",kind:"bad"})}async submit(){if(this.busy||this.cooling)return;let e=this.input.value.trim();if(!e){this.setStatus({text:"Type the moving code first.",kind:"bad"}),this.input.focus({preventScroll:!0});return}if(!this.challenge){this.loadChallenge();return}let t=++this.seq,{token:n}=this.challenge;this.setStatus({text:"",kind:""}),this.setBusy(!0,"Verifying\u2026");try{let s=await this.api.verify(n,e);if(t!==this.seq)return;this.setBusy(!1),this.onVerified(s.token,s.expiresIn)}catch(s){if(t!==this.seq)return;this.setBusy(!1),this.handleFailure(s),this.dialogOpen&&this.input.focus({preventScroll:!0})}}onVerified(e,t){var n,s;this.clearTimer("challenge"),this.challenge=null,this.renderer.setGeometry(null),this.token=e,this.state="verified",this.hidden.value=e,this.input.value="",this.setStatus({text:"",kind:""}),this.close(),this.updateTrigger(),this.passTimer=window.setTimeout(()=>this.onPassExpired(),Math.max(1e3,(t-2)*1e3)),this.dispatch("kcaptcha:verify",{token:e}),(s=(n=this.opts).onVerify)==null||s.call(n,e)}onPassExpired(){var e,t;this.passTimer=0,this.resetState(),this.dispatch("kcaptcha:expire"),(t=(e=this.opts).onExpire)==null||t.call(e)}handleFailure(e){var n;let t=ge(e);switch(t.code){case"incorrect":this.loadChallenge({text:"Incorrect \u2014 try again.",kind:"bad"});return;case"expired":this.loadChallenge({text:"Code expired \u2014 here is a new one.",kind:"bad"});return;case"replayed":case"invalid":case"malformed":this.loadChallenge({text:"Something went wrong \u2014 here is a new code.",kind:"bad"});return;case"rate_limited":this.startCooldown((n=t.retryAfter)!=null?n:30);return;case"network_error":this.setStatus({text:"Network error \u2014 check your connection and try again.",kind:"bad"});return;default:(t.status===404||t.code.startsWith("http_")||t.code==="bad_response")&&console.error(`[kCAPTCHA] Request failed (${t.code}). Is the kCAPTCHA router mounted at "${this.opts.endpoint}"?`),this.setStatus({text:"Service unavailable \u2014 please try again later.",kind:"bad"})}}startCooldown(e){this.stopCooldown(),this.cooling=!0,this.updateButtons();let t=Math.max(1,Math.ceil(e)),n=()=>{if(t<=0){this.stopCooldown(),this.setStatus({text:"You can try again now.",kind:""}),!this.challenge&&this.dialogOpen&&this.loadChallenge();return}this.setStatus({text:`Too many attempts. Try again in ${t}s.`,kind:"bad"}),t-=1};n(),this.cooldownTimer=window.setInterval(n,1e3)}stopCooldown(){this.cooldownTimer&&(window.clearInterval(this.cooldownTimer),this.cooldownTimer=0),this.cooling=!1,this.updateButtons()}resetState(){this.seq+=1,this.clearTimer("challenge"),this.clearTimer("pass"),this.stopCooldown(),this.close(!1),this.state="idle",this.token=null,this.challenge=null,this.busy=!1,this.stage.classList.remove("busy"),this.renderer.setGeometry(null),this.hidden.value="",this.input.value="",this.setStatus({text:"",kind:""}),this.updateButtons(),this.updateTrigger()}clearTimer(e){e==="challenge"&&this.challengeTimer&&(window.clearTimeout(this.challengeTimer),this.challengeTimer=0),e==="pass"&&this.passTimer&&(window.clearTimeout(this.passTimer),this.passTimer=0)}setBusy(e,t="Loading\u2026"){this.busy=e,this.loadingEl.textContent=t,this.stage.classList.toggle("busy",e),this.updateButtons()}setPaused(e){this.paused!==e&&(this.paused=e,this.pauseBtn.textContent=e?"Resume":"Pause",this.pauseBtn.setAttribute("aria-pressed",String(e)),e?this.renderer.stop():this.dialogOpen&&this.renderer.start())}updateButtons(){let e=this.busy||this.cooling;this.submitBtn.disabled=e,this.refreshBtn.disabled=e,this.pauseBtn.disabled=this.busy||this.challenge===null}updateTrigger(){let e=this.state==="verified";this.trigger.dataset.state=this.state,this.trigger.setAttribute("aria-disabled",String(e)),this.label.textContent=e?ue:pe}setStatus(e){this.statusEl.textContent=e.text,this.statusEl.className=`status ${e.kind}`.trim()}dispatch(e,t){this.host.dispatchEvent(new CustomEvent(e,{bubbles:!0,composed:!0,detail:t}))}};var U="0.1.0",A=new WeakMap;function q(i){return A.has(i)}function F(i,e){let t=document.createElement("div");t.setAttribute("role","alert"),t.textContent=`kCAPTCHA error: ${e}`,t.style.cssText="padding:10px 12px;border:1px solid #f85149;border-radius:6px;background:#2d0f12;color:#ff7b72;font:13px/1.4 ui-monospace,Menlo,Consolas,monospace;max-width:100%",i.replaceChildren(t)}function P(i,e){let t=typeof i=="string"?document.querySelector(i):i;if(!t)return console.error(`[kCAPTCHA] Target element not found: ${String(i)}`),null;let n=A.get(t);if(n)return console.warn("[kCAPTCHA] This element already has a kCAPTCHA widget; returning the existing one."),n;let s;try{s=B(e)}catch(a){if(a instanceof y)return console.error(a.message),F(t,a.message.replace(/^\[kCAPTCHA\]\s*/,"")),null;throw a}let o;try{o=new M(t,s)}catch(a){return console.error("[kCAPTCHA] Could not create the widget:",a),F(t,"could not create the widget (see the browser console)."),null}let r={element:t,getToken:()=>o.getToken(),reset:()=>o.reset(),destroy:()=>{o.destroy(),A.delete(t)}};return A.set(t,r),r}var V="[data-kcaptcha]";function ve(i){let e=i.dataset;return{endpoint:e.endpoint,width:e.width,height:e.height,color:e.color,name:e.name,expire:e.expire,rateLimit:e.rateLimit}}function z(i){q(i)||P(i,ve(i))}function O(i=document){i.querySelectorAll(V).forEach(z)}function ye(){new MutationObserver(i=>{for(let e of i)e.addedNodes.forEach(t=>{t instanceof HTMLElement&&(t.matches(V)&&z(t),O(t))})}).observe(document.documentElement,{childList:!0,subtree:!0})}if(window.KCaptcha)console.warn("[kCAPTCHA] kcaptcha.js was loaded twice; ignoring the second copy.");else{window.KCaptcha={render:P,scan:O,version:U};let i=()=>{O(),ye()};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",i,{once:!0}):i()}})();
@@ -0,0 +1,36 @@
1
+ export interface KCaptchaOptions {
2
+ /** REQUIRED. Base path of the kCAPTCHA router on your server, e.g. "/login/kcaptcha". */
3
+ endpoint: string;
4
+ /** Width of the checkbox button in px (160-600). Default 300. */
5
+ width?: number;
6
+ /** Height of the checkbox button in px (40-120). Default 74. */
7
+ height?: number;
8
+ /** Accent colour of the checkbox button (any CSS colour). Default "#3fb950". */
9
+ color?: string;
10
+ /** Name of the hidden form field that receives the pass token. Default "kcaptcha_token". */
11
+ name?: string;
12
+ /** Called with the pass token once the user solves the CAPTCHA. */
13
+ onVerify?: (token: string) => void;
14
+ /** Called when a solved CAPTCHA's pass token expires unused. */
15
+ onExpire?: () => void;
16
+ }
17
+ export interface ResolvedOptions {
18
+ readonly endpoint: string;
19
+ readonly width: number;
20
+ readonly height: number;
21
+ readonly color: string;
22
+ readonly name: string;
23
+ readonly onVerify?: (token: string) => void;
24
+ readonly onExpire?: () => void;
25
+ }
26
+ export declare const DEFAULTS: {
27
+ readonly width: 300;
28
+ readonly height: 74;
29
+ readonly color: "#3fb950";
30
+ readonly name: "kcaptcha_token";
31
+ };
32
+ export declare class KCaptchaOptionError extends Error {
33
+ constructor(message: string);
34
+ }
35
+ /** Validate raw options (from JS, React props or data-* attributes). Throws only for a bad endpoint. */
36
+ export declare function resolveOptions(raw: Record<string, unknown>): ResolvedOptions;
@@ -0,0 +1,23 @@
1
+ import type { Geometry } from './api';
2
+ export declare class Renderer {
3
+ private readonly ctx;
4
+ private readonly particles;
5
+ private geometry;
6
+ private dots;
7
+ private mask;
8
+ private maskW;
9
+ private maskH;
10
+ private watermark;
11
+ private running;
12
+ private raf;
13
+ private last;
14
+ private drift;
15
+ constructor(canvas: HTMLCanvasElement);
16
+ setGeometry(geometry: Geometry | null): void;
17
+ start(): void;
18
+ stop(): void;
19
+ destroy(): void;
20
+ private buildMask;
21
+ private readonly tick;
22
+ private draw;
23
+ }
@@ -0,0 +1 @@
1
+ export declare const STYLES = "\n*, *::before, *::after { box-sizing: border-box; }\n\n:host {\n display: inline-block;\n max-width: 100%;\n font-family: system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif;\n}\n\n.root { width: 100%; height: 100%; }\n\n/* ---------- checkbox button (customisable: width, height, colour) ---------- */\n.trigger {\n display: flex;\n align-items: center;\n gap: 12px;\n width: 100%;\n height: 100%;\n margin: 0;\n padding: 0 14px;\n background: #f8fafc;\n color: #1f2328;\n border: 1px solid #d0d7de;\n border-radius: 6px;\n font: inherit;\n font-size: 15px;\n text-align: left;\n cursor: pointer;\n transition: border-color 0.15s;\n}\n.pause { display: none; }\n\n.trigger:hover { border-color: var(--kc-accent); }\n.trigger:focus-visible { outline: 2px solid var(--kc-accent); outline-offset: 2px; }\n.trigger[aria-disabled=\"true\"] { cursor: default; }\n\n.check {\n flex: none;\n display: grid;\n place-items: center;\n width: 24px;\n height: 24px;\n background: #fff;\n border: 2px solid #8c959f;\n border-radius: 4px;\n transition: background 0.15s, border-color 0.15s;\n}\n.check svg {\n width: 16px;\n height: 16px;\n fill: none;\n stroke: #fff;\n stroke-width: 3;\n stroke-linecap: round;\n stroke-linejoin: round;\n opacity: 0;\n}\n.trigger[data-state=\"verified\"] .check { background: var(--kc-accent); border-color: var(--kc-accent); }\n.trigger[data-state=\"verified\"] .check svg { opacity: 1; }\n\n.label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.brand {\n flex: none;\n color: #6e7781;\n font: 11px ui-monospace, \"SF Mono\", Menlo, Consolas, monospace;\n letter-spacing: 0.4px;\n}\n\n/* ---------- small popup dialog (anchored under the checkbox, no backdrop) ---------- */\n.overlay {\n position: fixed;\n top: 0;\n left: 0;\n z-index: 2147483647;\n width: 320px; /* the script sets the real width and position */\n}\n.overlay[hidden] { display: none; }\n\n.dialog {\n padding: 12px;\n background: #0d1117;\n color: #e6edf3;\n border: 1px solid #30363d;\n border-radius: 10px;\n box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);\n}\n\n.head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }\n.head h2 { margin: 0; font-size: 14px; font-weight: 600; }\n.help { margin: 4px 0 0; color: #8b949e; font-size: 12px; line-height: 1.4; }\n\n.stage {\n position: relative;\n margin-top: 10px;\n padding: 6px;\n background: #ffffff;\n border: 1px solid #30363d;\n border-radius: 8px;\n box-shadow: 0 0 12px rgba(0, 0, 0, 0.5);\n}\ncanvas { display: block; width: 100%; height: auto; aspect-ratio: 400 / 240; }\n.loading {\n position: absolute;\n inset: 0;\n display: none;\n align-items: center;\n justify-content: center;\n background: rgba(255, 255, 255, 0.85);\n border-radius: 8px;\n color: #000000;\n font-size: 13px;\n}\n.stage.busy .loading { display: flex; }\n\n.answer {\n display: block;\n width: 100%;\n margin: 10px 0 0;\n padding: 8px 10px;\n background: #161b22;\n color: #e6edf3;\n border: 1px solid #30363d;\n border-radius: 6px;\n outline: none;\n font: 15px ui-monospace, \"SF Mono\", Menlo, Consolas, monospace;\n letter-spacing: 3px;\n text-transform: uppercase;\n}\n.answer::placeholder { color: #6e7681; letter-spacing: 1px; }\n.answer:focus { border-color: #58a6ff; }\n\n.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; }\n\n.dialog button {\n padding: 8px 10px;\n background: #21262d;\n color: #e6edf3;\n border: 1px solid #30363d;\n border-radius: 6px;\n font: inherit;\n font-size: 13px;\n cursor: pointer;\n transition: border-color 0.15s, background 0.15s;\n}\n.dialog button:hover:not(:disabled) { border-color: #58a6ff; }\n.dialog button:focus-visible { outline: 2px solid #58a6ff; outline-offset: 2px; }\n.dialog button:disabled { opacity: 0.5; cursor: not-allowed; }\n.dialog button.primary { background: #238636; border-color: #2ea043; }\n.dialog button.primary:hover:not(:disabled) { background: #2ea043; border-color: #3fb950; }\n.dialog button.close { padding: 0 8px; font-size: 18px; line-height: 1.4; }\n\n.status { min-height: 16px; margin-top: 8px; font-size: 12px; letter-spacing: 0.3px; color: #8b949e; }\n.status.ok { color: #3fb950; }\n.status.bad { color: #f85149; }\n";
@@ -0,0 +1,59 @@
1
+ import type { ResolvedOptions } from './options';
2
+ export declare class Widget {
3
+ private readonly host;
4
+ private readonly opts;
5
+ private readonly shadow;
6
+ private readonly api;
7
+ private readonly renderer;
8
+ private readonly hidden;
9
+ private readonly root;
10
+ private readonly trigger;
11
+ private readonly label;
12
+ private readonly overlay;
13
+ private readonly closeBtn;
14
+ private readonly stage;
15
+ private readonly loadingEl;
16
+ private readonly input;
17
+ private readonly submitBtn;
18
+ private readonly refreshBtn;
19
+ private readonly pauseBtn;
20
+ private readonly statusEl;
21
+ private state;
22
+ private token;
23
+ private challenge;
24
+ private dialogOpen;
25
+ private busy;
26
+ private cooling;
27
+ private paused;
28
+ private seq;
29
+ private challengeTimer;
30
+ private passTimer;
31
+ private cooldownTimer;
32
+ constructor(host: HTMLElement, opts: ResolvedOptions);
33
+ getToken(): string | null;
34
+ /** Back to the unchecked state. Call after a failed form submit: a pass token works only once. */
35
+ reset(): void;
36
+ destroy(): void;
37
+ private open;
38
+ private close;
39
+ private readonly reposition;
40
+ private readonly onOutsideClick;
41
+ private position;
42
+ private readonly onOverlayKeyDown;
43
+ private loadChallenge;
44
+ private onChallengeExpired;
45
+ private submit;
46
+ private onVerified;
47
+ private onPassExpired;
48
+ private handleFailure;
49
+ private startCooldown;
50
+ private stopCooldown;
51
+ private resetState;
52
+ private clearTimer;
53
+ private setBusy;
54
+ private setPaused;
55
+ private updateButtons;
56
+ private updateTrigger;
57
+ private setStatus;
58
+ private dispatch;
59
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@kcaptcha/client",
3
+ "version": "0.1.0",
4
+ "description": "Browser widget for kCAPTCHA (served as kcaptcha.js)",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ },
14
+ "./kcaptcha.js": {
15
+ "default": "./dist/kcaptcha.js"
16
+ },
17
+ "./package.json": "./package.json"
18
+ },
19
+ "files": ["dist"],
20
+ "scripts": {
21
+ "build": "node build.mjs && tsc -p tsconfig.build.json",
22
+ "typecheck": "tsc -p tsconfig.json --noEmit"
23
+ },
24
+ "devDependencies": {
25
+ "esbuild": "^0.24.0"
26
+ }
27
+ }