@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.
- package/dist/api.d.ts +32 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +1044 -0
- package/dist/kcaptcha.js +191 -0
- package/dist/options.d.ts +36 -0
- package/dist/renderer.d.ts +23 -0
- package/dist/styles.d.ts +1 -0
- package/dist/widget.d.ts +59 -0
- package/package.json +27 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1044 @@
|
|
|
1
|
+
// src/options.ts
|
|
2
|
+
var DEFAULTS = {
|
|
3
|
+
width: 300,
|
|
4
|
+
height: 74,
|
|
5
|
+
color: "#3fb950",
|
|
6
|
+
name: "kcaptcha_token"
|
|
7
|
+
};
|
|
8
|
+
var WIDTH_RANGE = [160, 600];
|
|
9
|
+
var HEIGHT_RANGE = [40, 120];
|
|
10
|
+
var KCaptchaOptionError = class extends Error {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(`[kCAPTCHA] ${message}`);
|
|
13
|
+
this.name = "KCaptchaOptionError";
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
var warn = (message) => console.warn(`[kCAPTCHA] ${message}`);
|
|
17
|
+
function endpointOption(raw) {
|
|
18
|
+
if (typeof raw !== "string" || raw.trim() === "") {
|
|
19
|
+
throw new KCaptchaOptionError(
|
|
20
|
+
'Missing required "endpoint" option, so the widget cannot work. Example: <div data-kcaptcha data-endpoint="/login/kcaptcha"></div>'
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
const value = raw.trim().replace(/\/+$/, "");
|
|
24
|
+
const isPath = /^\/(?!\/)/.test(value);
|
|
25
|
+
const isUrl = /^https?:\/\/[^\s/]+/i.test(value);
|
|
26
|
+
if (!isPath && !isUrl) {
|
|
27
|
+
throw new KCaptchaOptionError(
|
|
28
|
+
`Invalid "endpoint": ${JSON.stringify(raw)}. It must start with "/" (e.g. "/login/kcaptcha") or be a full http(s) URL.`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
function numberOption(name, raw, fallback, [min, max]) {
|
|
34
|
+
if (raw === void 0 || raw === null || raw === "") return fallback;
|
|
35
|
+
const n = typeof raw === "number" ? raw : Number(raw);
|
|
36
|
+
if (!Number.isFinite(n)) {
|
|
37
|
+
warn(`"${name}" must be a number (got ${JSON.stringify(raw)}). Using ${fallback}.`);
|
|
38
|
+
return fallback;
|
|
39
|
+
}
|
|
40
|
+
const rounded = Math.round(n);
|
|
41
|
+
const clamped = Math.min(max, Math.max(min, rounded));
|
|
42
|
+
if (clamped !== rounded) {
|
|
43
|
+
warn(`"${name}" must be between ${min} and ${max} (got ${rounded}). Using ${clamped}.`);
|
|
44
|
+
}
|
|
45
|
+
return clamped;
|
|
46
|
+
}
|
|
47
|
+
function colorOption(raw) {
|
|
48
|
+
if (raw === void 0 || raw === null || raw === "") return DEFAULTS.color;
|
|
49
|
+
if (typeof raw === "string" && typeof CSS !== "undefined" && CSS.supports("color", raw)) {
|
|
50
|
+
return raw.trim();
|
|
51
|
+
}
|
|
52
|
+
warn(`"color" is not a valid CSS colour (got ${JSON.stringify(raw)}). Using ${DEFAULTS.color}.`);
|
|
53
|
+
return DEFAULTS.color;
|
|
54
|
+
}
|
|
55
|
+
function nameOption(raw) {
|
|
56
|
+
if (raw === void 0 || raw === null || raw === "") return DEFAULTS.name;
|
|
57
|
+
if (typeof raw === "string" && /^[A-Za-z_][\w.-]{0,63}$/.test(raw)) return raw;
|
|
58
|
+
warn(`"name" is not a valid field name (got ${JSON.stringify(raw)}). Using "${DEFAULTS.name}".`);
|
|
59
|
+
return DEFAULTS.name;
|
|
60
|
+
}
|
|
61
|
+
function resolveOptions(raw) {
|
|
62
|
+
const endpoint = endpointOption(raw.endpoint);
|
|
63
|
+
if (raw.expire !== void 0) {
|
|
64
|
+
warn('"expire" cannot be set in the browser. Set it on the server: createKCaptcha({ expire }). Ignored.');
|
|
65
|
+
}
|
|
66
|
+
if (raw.rateLimit !== void 0) {
|
|
67
|
+
warn('"rateLimit" cannot be set in the browser. Set it on the server: createKCaptcha({ rateLimit }). Ignored.');
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
endpoint,
|
|
71
|
+
width: numberOption("width", raw.width, DEFAULTS.width, WIDTH_RANGE),
|
|
72
|
+
height: numberOption("height", raw.height, DEFAULTS.height, HEIGHT_RANGE),
|
|
73
|
+
color: colorOption(raw.color),
|
|
74
|
+
name: nameOption(raw.name),
|
|
75
|
+
onVerify: typeof raw.onVerify === "function" ? raw.onVerify : void 0,
|
|
76
|
+
onExpire: typeof raw.onExpire === "function" ? raw.onExpire : void 0
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// src/api.ts
|
|
81
|
+
var ApiError = class extends Error {
|
|
82
|
+
constructor(code, status, retryAfter) {
|
|
83
|
+
super(code);
|
|
84
|
+
this.code = code;
|
|
85
|
+
this.status = status;
|
|
86
|
+
this.retryAfter = retryAfter;
|
|
87
|
+
this.name = "ApiError";
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
var TIMEOUT_MS = 1e4;
|
|
91
|
+
var MAX_PIPES = 200;
|
|
92
|
+
var MAX_NUMBERS_PER_PIPE = 2e3;
|
|
93
|
+
var MAX_DIMENSION = 2e3;
|
|
94
|
+
var MAX_TOKEN_LENGTH = 1024;
|
|
95
|
+
var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
96
|
+
var isPositive = (v, max) => typeof v === "number" && Number.isFinite(v) && v > 0 && v <= max;
|
|
97
|
+
function bad() {
|
|
98
|
+
throw new ApiError("bad_response", 200);
|
|
99
|
+
}
|
|
100
|
+
function parseGeometry(value) {
|
|
101
|
+
if (!isRecord(value)) return bad();
|
|
102
|
+
const { width, height, spacing, pipes } = value;
|
|
103
|
+
if (!isPositive(width, MAX_DIMENSION) || !isPositive(height, MAX_DIMENSION)) return bad();
|
|
104
|
+
if (!isPositive(spacing, 100)) return bad();
|
|
105
|
+
if (!Array.isArray(pipes) || pipes.length === 0 || pipes.length > MAX_PIPES) return bad();
|
|
106
|
+
for (const pipe of pipes) {
|
|
107
|
+
if (!Array.isArray(pipe)) return bad();
|
|
108
|
+
if (pipe.length < 4 || pipe.length % 2 !== 0 || pipe.length > MAX_NUMBERS_PER_PIPE) return bad();
|
|
109
|
+
for (const n of pipe) {
|
|
110
|
+
if (typeof n !== "number" || !Number.isFinite(n)) return bad();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { width, height, spacing, pipes };
|
|
114
|
+
}
|
|
115
|
+
function parseChallenge(data) {
|
|
116
|
+
const { token, expiresIn } = data;
|
|
117
|
+
if (typeof token !== "string" || token.length === 0 || token.length > MAX_TOKEN_LENGTH) return bad();
|
|
118
|
+
if (!isPositive(expiresIn, 3600)) return bad();
|
|
119
|
+
return { token, expiresIn, geometry: parseGeometry(data.geometry) };
|
|
120
|
+
}
|
|
121
|
+
var KCaptchaApi = class {
|
|
122
|
+
constructor(endpoint) {
|
|
123
|
+
this.endpoint = endpoint;
|
|
124
|
+
}
|
|
125
|
+
/** POST <endpoint>/new-code */
|
|
126
|
+
async newCode() {
|
|
127
|
+
return parseChallenge(await this.post("/new-code"));
|
|
128
|
+
}
|
|
129
|
+
/** POST <endpoint>/verify-captcha */
|
|
130
|
+
async verify(token, answer) {
|
|
131
|
+
const data = await this.post("/verify-captcha", { token, answer });
|
|
132
|
+
const { token: pass, expiresIn } = data;
|
|
133
|
+
if (typeof pass !== "string" || pass.length === 0 || pass.length > MAX_TOKEN_LENGTH) return bad();
|
|
134
|
+
if (!isPositive(expiresIn, 86400)) return bad();
|
|
135
|
+
return { token: pass, expiresIn };
|
|
136
|
+
}
|
|
137
|
+
async post(path, body) {
|
|
138
|
+
const controller = new AbortController();
|
|
139
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
140
|
+
let res;
|
|
141
|
+
try {
|
|
142
|
+
res = await fetch(`${this.endpoint}${path}`, {
|
|
143
|
+
method: "POST",
|
|
144
|
+
headers: body === void 0 ? { Accept: "application/json" } : { Accept: "application/json", "Content-Type": "application/json" },
|
|
145
|
+
body: body === void 0 ? void 0 : JSON.stringify(body),
|
|
146
|
+
credentials: "same-origin",
|
|
147
|
+
cache: "no-store",
|
|
148
|
+
signal: controller.signal
|
|
149
|
+
});
|
|
150
|
+
} catch {
|
|
151
|
+
throw new ApiError("network_error", 0);
|
|
152
|
+
} finally {
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
}
|
|
155
|
+
let data = null;
|
|
156
|
+
try {
|
|
157
|
+
data = await res.json();
|
|
158
|
+
} catch {
|
|
159
|
+
}
|
|
160
|
+
const record = isRecord(data) ? data : null;
|
|
161
|
+
if (!res.ok || !record || record.ok !== true) {
|
|
162
|
+
const code = record && typeof record.error === "string" ? record.error : `http_${res.status}`;
|
|
163
|
+
const header = Number(res.headers.get("Retry-After"));
|
|
164
|
+
const retryAfter = record && typeof record.retryAfter === "number" ? record.retryAfter : Number.isFinite(header) && header > 0 ? header : void 0;
|
|
165
|
+
throw new ApiError(code, res.status, retryAfter);
|
|
166
|
+
}
|
|
167
|
+
return record;
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// src/renderer.ts
|
|
172
|
+
var W = 400;
|
|
173
|
+
var H = 240;
|
|
174
|
+
var TAU = Math.PI * 2;
|
|
175
|
+
var FRAME_MS = 1e3 / 60;
|
|
176
|
+
var DOT_RADIUS = 1.2;
|
|
177
|
+
var DOT_COLOR = "#000000";
|
|
178
|
+
var PARTICLE_COUNT = 3e3;
|
|
179
|
+
var DENSITY = PARTICLE_COUNT / (W * H);
|
|
180
|
+
var BAND_WIDTH = 15;
|
|
181
|
+
var LETTER_SPEED_MIN = 20;
|
|
182
|
+
var LETTER_SPEED_MAX = 20;
|
|
183
|
+
var NOISE_SPEED_MIN = 0.1;
|
|
184
|
+
var NOISE_SPEED_MAX = 0.1;
|
|
185
|
+
var DRIFT_SPEED = 0.5;
|
|
186
|
+
var WATERMARK_CHARS = "QWERTYUIPASDFGHJKLZXCVBNM123456789";
|
|
187
|
+
function randomWatermark() {
|
|
188
|
+
let out2 = "";
|
|
189
|
+
for (let i = 0; i < 5; i++) out2 += WATERMARK_CHARS[Math.floor(Math.random() * WATERMARK_CHARS.length)];
|
|
190
|
+
return out2;
|
|
191
|
+
}
|
|
192
|
+
function preparePipe(flat) {
|
|
193
|
+
const n = flat.length / 2;
|
|
194
|
+
const xs = [];
|
|
195
|
+
const ys = [];
|
|
196
|
+
const cum = [0];
|
|
197
|
+
for (let i = 0; i < n; i++) {
|
|
198
|
+
xs.push(flat[i * 2]);
|
|
199
|
+
ys.push(flat[i * 2 + 1]);
|
|
200
|
+
if (i > 0) {
|
|
201
|
+
cum.push(cum[i - 1] + Math.hypot(xs[i] - xs[i - 1], ys[i] - ys[i - 1]));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return { xs, ys, cum, total: cum[n - 1] };
|
|
205
|
+
}
|
|
206
|
+
var out = { x: 0, y: 0, nx: 0, ny: 0 };
|
|
207
|
+
function pointAt(pipe, s) {
|
|
208
|
+
const { xs, ys, cum } = pipe;
|
|
209
|
+
let lo = 1;
|
|
210
|
+
let hi = cum.length - 1;
|
|
211
|
+
while (lo < hi) {
|
|
212
|
+
const mid = lo + hi >> 1;
|
|
213
|
+
if (cum[mid] < s) lo = mid + 1;
|
|
214
|
+
else hi = mid;
|
|
215
|
+
}
|
|
216
|
+
const c0 = cum[lo - 1];
|
|
217
|
+
const c1 = cum[lo];
|
|
218
|
+
const f = c1 > c0 ? (s - c0) / (c1 - c0) : 0;
|
|
219
|
+
const dx = xs[lo] - xs[lo - 1];
|
|
220
|
+
const dy = ys[lo] - ys[lo - 1];
|
|
221
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
222
|
+
out.x = xs[lo - 1] + dx * f;
|
|
223
|
+
out.y = ys[lo - 1] + dy * f;
|
|
224
|
+
out.nx = -dy / len;
|
|
225
|
+
out.ny = dx / len;
|
|
226
|
+
}
|
|
227
|
+
var Renderer = class {
|
|
228
|
+
constructor(canvas) {
|
|
229
|
+
this.particles = new Float32Array(PARTICLE_COUNT * 4);
|
|
230
|
+
// x, y, vx, vy
|
|
231
|
+
this.geometry = null;
|
|
232
|
+
this.dots = [];
|
|
233
|
+
this.mask = null;
|
|
234
|
+
// 1 = inside a stroke band (string space)
|
|
235
|
+
this.maskW = 0;
|
|
236
|
+
this.maskH = 0;
|
|
237
|
+
this.watermark = randomWatermark();
|
|
238
|
+
this.running = false;
|
|
239
|
+
this.raf = 0;
|
|
240
|
+
this.last = 0;
|
|
241
|
+
this.drift = 0;
|
|
242
|
+
this.tick = (now) => {
|
|
243
|
+
if (!this.running) return;
|
|
244
|
+
const dt = Math.min(Math.max((now - this.last) / FRAME_MS, 0), 3);
|
|
245
|
+
this.last = now;
|
|
246
|
+
this.draw(dt);
|
|
247
|
+
this.raf = requestAnimationFrame(this.tick);
|
|
248
|
+
};
|
|
249
|
+
const ctx = canvas.getContext("2d");
|
|
250
|
+
if (!ctx) throw new Error("[kCAPTCHA] Canvas 2D is not supported in this browser.");
|
|
251
|
+
this.ctx = ctx;
|
|
252
|
+
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
253
|
+
canvas.width = Math.round(W * dpr);
|
|
254
|
+
canvas.height = Math.round(H * dpr);
|
|
255
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
256
|
+
for (let i = 0; i < PARTICLE_COUNT; i++) {
|
|
257
|
+
const o = i * 4;
|
|
258
|
+
const angle = Math.random() * TAU;
|
|
259
|
+
const speed = NOISE_SPEED_MIN + Math.random() * (NOISE_SPEED_MAX - NOISE_SPEED_MIN);
|
|
260
|
+
this.particles[o] = Math.random() * W;
|
|
261
|
+
this.particles[o + 1] = Math.random() * H;
|
|
262
|
+
this.particles[o + 2] = Math.cos(angle) * speed;
|
|
263
|
+
this.particles[o + 3] = Math.sin(angle) * speed;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
setGeometry(geometry) {
|
|
267
|
+
this.geometry = geometry;
|
|
268
|
+
this.dots = [];
|
|
269
|
+
this.mask = null;
|
|
270
|
+
this.drift = 0;
|
|
271
|
+
this.watermark = randomWatermark();
|
|
272
|
+
if (!geometry) return;
|
|
273
|
+
for (const flat of geometry.pipes) {
|
|
274
|
+
const pipe = preparePipe(flat);
|
|
275
|
+
const expected = pipe.total * BAND_WIDTH * DENSITY;
|
|
276
|
+
const count = Math.floor(expected + Math.random());
|
|
277
|
+
for (let k = 0; k < count; k++) {
|
|
278
|
+
const dir = Math.random() < 0.5 ? -1 : 1;
|
|
279
|
+
const speed = LETTER_SPEED_MIN + Math.random() * (LETTER_SPEED_MAX - LETTER_SPEED_MIN);
|
|
280
|
+
this.dots.push({
|
|
281
|
+
pipe,
|
|
282
|
+
s: Math.random() * pipe.total,
|
|
283
|
+
v: dir * speed,
|
|
284
|
+
u: (Math.random() - 0.5) * BAND_WIDTH
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
this.buildMask(geometry);
|
|
289
|
+
}
|
|
290
|
+
start() {
|
|
291
|
+
if (this.running) return;
|
|
292
|
+
this.running = true;
|
|
293
|
+
this.last = performance.now();
|
|
294
|
+
this.raf = requestAnimationFrame(this.tick);
|
|
295
|
+
}
|
|
296
|
+
stop() {
|
|
297
|
+
this.running = false;
|
|
298
|
+
cancelAnimationFrame(this.raf);
|
|
299
|
+
}
|
|
300
|
+
destroy() {
|
|
301
|
+
this.stop();
|
|
302
|
+
this.geometry = null;
|
|
303
|
+
this.dots = [];
|
|
304
|
+
this.mask = null;
|
|
305
|
+
}
|
|
306
|
+
// Rasterise the stroke bands once per challenge. Background dots that fall
|
|
307
|
+
// inside a band are not drawn, so the band's total density equals the density outside it.
|
|
308
|
+
buildMask(geometry) {
|
|
309
|
+
const w = Math.ceil(geometry.width);
|
|
310
|
+
const h = Math.ceil(geometry.height);
|
|
311
|
+
const canvas = document.createElement("canvas");
|
|
312
|
+
canvas.width = w;
|
|
313
|
+
canvas.height = h;
|
|
314
|
+
const mctx = canvas.getContext("2d", { willReadFrequently: true });
|
|
315
|
+
if (!mctx) return;
|
|
316
|
+
mctx.strokeStyle = "#000";
|
|
317
|
+
mctx.lineWidth = BAND_WIDTH;
|
|
318
|
+
mctx.lineCap = "butt";
|
|
319
|
+
mctx.lineJoin = "round";
|
|
320
|
+
for (const flat of geometry.pipes) {
|
|
321
|
+
mctx.beginPath();
|
|
322
|
+
mctx.moveTo(flat[0], flat[1]);
|
|
323
|
+
for (let i = 2; i < flat.length; i += 2) mctx.lineTo(flat[i], flat[i + 1]);
|
|
324
|
+
mctx.stroke();
|
|
325
|
+
}
|
|
326
|
+
const data = mctx.getImageData(0, 0, w, h).data;
|
|
327
|
+
const mask = new Uint8Array(w * h);
|
|
328
|
+
for (let i = 0; i < mask.length; i++) mask[i] = data[i * 4 + 3] > 127 ? 1 : 0;
|
|
329
|
+
this.mask = mask;
|
|
330
|
+
this.maskW = w;
|
|
331
|
+
this.maskH = h;
|
|
332
|
+
}
|
|
333
|
+
draw(dt) {
|
|
334
|
+
const { ctx, particles } = this;
|
|
335
|
+
ctx.clearRect(0, 0, W, H);
|
|
336
|
+
ctx.save();
|
|
337
|
+
ctx.font = "bold 46px monospace";
|
|
338
|
+
ctx.fillStyle = "rgba(0, 0, 0, 0.035)";
|
|
339
|
+
ctx.textAlign = "center";
|
|
340
|
+
ctx.textBaseline = "middle";
|
|
341
|
+
ctx.fillText(this.watermark, W / 2, H / 2);
|
|
342
|
+
ctx.restore();
|
|
343
|
+
const g = this.geometry;
|
|
344
|
+
let x0 = 0;
|
|
345
|
+
let y0 = 0;
|
|
346
|
+
if (g) {
|
|
347
|
+
this.drift += DRIFT_SPEED * dt;
|
|
348
|
+
const startX = W + 60;
|
|
349
|
+
const endX = -g.width - 60;
|
|
350
|
+
x0 = startX - this.drift % (startX - endX);
|
|
351
|
+
y0 = (H - g.height) / 2;
|
|
352
|
+
}
|
|
353
|
+
ctx.fillStyle = DOT_COLOR;
|
|
354
|
+
ctx.beginPath();
|
|
355
|
+
const mask = this.mask;
|
|
356
|
+
for (let i = 0; i < PARTICLE_COUNT; i++) {
|
|
357
|
+
const o = i * 4;
|
|
358
|
+
let x = particles[o] + (particles[o + 2] - DRIFT_SPEED) * dt;
|
|
359
|
+
let y = particles[o + 1] + particles[o + 3] * dt;
|
|
360
|
+
if (x < 0) x += W;
|
|
361
|
+
else if (x >= W) x -= W;
|
|
362
|
+
if (y < 0 || y > H) {
|
|
363
|
+
particles[o + 3] = -particles[o + 3];
|
|
364
|
+
y = Math.min(H, Math.max(0, y));
|
|
365
|
+
}
|
|
366
|
+
particles[o] = x;
|
|
367
|
+
particles[o + 1] = y;
|
|
368
|
+
if (mask) {
|
|
369
|
+
const mx = Math.floor(x - x0);
|
|
370
|
+
const my = Math.floor(y - y0);
|
|
371
|
+
if (mx >= 0 && mx < this.maskW && my >= 0 && my < this.maskH && mask[my * this.maskW + mx]) continue;
|
|
372
|
+
}
|
|
373
|
+
ctx.moveTo(x + DOT_RADIUS, y);
|
|
374
|
+
ctx.arc(x, y, DOT_RADIUS, 0, TAU);
|
|
375
|
+
}
|
|
376
|
+
if (g) {
|
|
377
|
+
for (const dot of this.dots) {
|
|
378
|
+
const length = dot.pipe.total;
|
|
379
|
+
dot.s = (dot.s + dot.v * dt) % length;
|
|
380
|
+
if (dot.s < 0) dot.s += length;
|
|
381
|
+
pointAt(dot.pipe, dot.s);
|
|
382
|
+
const x = x0 + out.x + out.nx * dot.u;
|
|
383
|
+
const y = y0 + out.y + out.ny * dot.u;
|
|
384
|
+
if (x > 0 && x < W && y > 0 && y < H) {
|
|
385
|
+
ctx.moveTo(x + DOT_RADIUS, y);
|
|
386
|
+
ctx.arc(x, y, DOT_RADIUS, 0, TAU);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
ctx.fill();
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
// src/styles.ts
|
|
395
|
+
var STYLES = `
|
|
396
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
397
|
+
|
|
398
|
+
:host {
|
|
399
|
+
display: inline-block;
|
|
400
|
+
max-width: 100%;
|
|
401
|
+
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
.root { width: 100%; height: 100%; }
|
|
405
|
+
|
|
406
|
+
/* ---------- checkbox button (customisable: width, height, colour) ---------- */
|
|
407
|
+
.trigger {
|
|
408
|
+
display: flex;
|
|
409
|
+
align-items: center;
|
|
410
|
+
gap: 12px;
|
|
411
|
+
width: 100%;
|
|
412
|
+
height: 100%;
|
|
413
|
+
margin: 0;
|
|
414
|
+
padding: 0 14px;
|
|
415
|
+
background: #f8fafc;
|
|
416
|
+
color: #1f2328;
|
|
417
|
+
border: 1px solid #d0d7de;
|
|
418
|
+
border-radius: 6px;
|
|
419
|
+
font: inherit;
|
|
420
|
+
font-size: 15px;
|
|
421
|
+
text-align: left;
|
|
422
|
+
cursor: pointer;
|
|
423
|
+
transition: border-color 0.15s;
|
|
424
|
+
}
|
|
425
|
+
.pause { display: none; }
|
|
426
|
+
|
|
427
|
+
.trigger:hover { border-color: var(--kc-accent); }
|
|
428
|
+
.trigger:focus-visible { outline: 2px solid var(--kc-accent); outline-offset: 2px; }
|
|
429
|
+
.trigger[aria-disabled="true"] { cursor: default; }
|
|
430
|
+
|
|
431
|
+
.check {
|
|
432
|
+
flex: none;
|
|
433
|
+
display: grid;
|
|
434
|
+
place-items: center;
|
|
435
|
+
width: 24px;
|
|
436
|
+
height: 24px;
|
|
437
|
+
background: #fff;
|
|
438
|
+
border: 2px solid #8c959f;
|
|
439
|
+
border-radius: 4px;
|
|
440
|
+
transition: background 0.15s, border-color 0.15s;
|
|
441
|
+
}
|
|
442
|
+
.check svg {
|
|
443
|
+
width: 16px;
|
|
444
|
+
height: 16px;
|
|
445
|
+
fill: none;
|
|
446
|
+
stroke: #fff;
|
|
447
|
+
stroke-width: 3;
|
|
448
|
+
stroke-linecap: round;
|
|
449
|
+
stroke-linejoin: round;
|
|
450
|
+
opacity: 0;
|
|
451
|
+
}
|
|
452
|
+
.trigger[data-state="verified"] .check { background: var(--kc-accent); border-color: var(--kc-accent); }
|
|
453
|
+
.trigger[data-state="verified"] .check svg { opacity: 1; }
|
|
454
|
+
|
|
455
|
+
.label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
456
|
+
.brand {
|
|
457
|
+
flex: none;
|
|
458
|
+
color: #6e7781;
|
|
459
|
+
font: 11px ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
|
460
|
+
letter-spacing: 0.4px;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/* ---------- small popup dialog (anchored under the checkbox, no backdrop) ---------- */
|
|
464
|
+
.overlay {
|
|
465
|
+
position: fixed;
|
|
466
|
+
top: 0;
|
|
467
|
+
left: 0;
|
|
468
|
+
z-index: 2147483647;
|
|
469
|
+
width: 320px; /* the script sets the real width and position */
|
|
470
|
+
}
|
|
471
|
+
.overlay[hidden] { display: none; }
|
|
472
|
+
|
|
473
|
+
.dialog {
|
|
474
|
+
padding: 12px;
|
|
475
|
+
background: #0d1117;
|
|
476
|
+
color: #e6edf3;
|
|
477
|
+
border: 1px solid #30363d;
|
|
478
|
+
border-radius: 10px;
|
|
479
|
+
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
.head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
483
|
+
.head h2 { margin: 0; font-size: 14px; font-weight: 600; }
|
|
484
|
+
.help { margin: 4px 0 0; color: #8b949e; font-size: 12px; line-height: 1.4; }
|
|
485
|
+
|
|
486
|
+
.stage {
|
|
487
|
+
position: relative;
|
|
488
|
+
margin-top: 10px;
|
|
489
|
+
padding: 6px;
|
|
490
|
+
background: #ffffff;
|
|
491
|
+
border: 1px solid #30363d;
|
|
492
|
+
border-radius: 8px;
|
|
493
|
+
box-shadow: 0 0 12px rgba(0, 0, 0, 0.5);
|
|
494
|
+
}
|
|
495
|
+
canvas { display: block; width: 100%; height: auto; aspect-ratio: 400 / 240; }
|
|
496
|
+
.loading {
|
|
497
|
+
position: absolute;
|
|
498
|
+
inset: 0;
|
|
499
|
+
display: none;
|
|
500
|
+
align-items: center;
|
|
501
|
+
justify-content: center;
|
|
502
|
+
background: rgba(255, 255, 255, 0.85);
|
|
503
|
+
border-radius: 8px;
|
|
504
|
+
color: #000000;
|
|
505
|
+
font-size: 13px;
|
|
506
|
+
}
|
|
507
|
+
.stage.busy .loading { display: flex; }
|
|
508
|
+
|
|
509
|
+
.answer {
|
|
510
|
+
display: block;
|
|
511
|
+
width: 100%;
|
|
512
|
+
margin: 10px 0 0;
|
|
513
|
+
padding: 8px 10px;
|
|
514
|
+
background: #161b22;
|
|
515
|
+
color: #e6edf3;
|
|
516
|
+
border: 1px solid #30363d;
|
|
517
|
+
border-radius: 6px;
|
|
518
|
+
outline: none;
|
|
519
|
+
font: 15px ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
|
520
|
+
letter-spacing: 3px;
|
|
521
|
+
text-transform: uppercase;
|
|
522
|
+
}
|
|
523
|
+
.answer::placeholder { color: #6e7681; letter-spacing: 1px; }
|
|
524
|
+
.answer:focus { border-color: #58a6ff; }
|
|
525
|
+
|
|
526
|
+
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; }
|
|
527
|
+
|
|
528
|
+
.dialog button {
|
|
529
|
+
padding: 8px 10px;
|
|
530
|
+
background: #21262d;
|
|
531
|
+
color: #e6edf3;
|
|
532
|
+
border: 1px solid #30363d;
|
|
533
|
+
border-radius: 6px;
|
|
534
|
+
font: inherit;
|
|
535
|
+
font-size: 13px;
|
|
536
|
+
cursor: pointer;
|
|
537
|
+
transition: border-color 0.15s, background 0.15s;
|
|
538
|
+
}
|
|
539
|
+
.dialog button:hover:not(:disabled) { border-color: #58a6ff; }
|
|
540
|
+
.dialog button:focus-visible { outline: 2px solid #58a6ff; outline-offset: 2px; }
|
|
541
|
+
.dialog button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
542
|
+
.dialog button.primary { background: #238636; border-color: #2ea043; }
|
|
543
|
+
.dialog button.primary:hover:not(:disabled) { background: #2ea043; border-color: #3fb950; }
|
|
544
|
+
.dialog button.close { padding: 0 8px; font-size: 18px; line-height: 1.4; }
|
|
545
|
+
|
|
546
|
+
.status { min-height: 16px; margin-top: 8px; font-size: 12px; letter-spacing: 0.3px; color: #8b949e; }
|
|
547
|
+
.status.ok { color: #3fb950; }
|
|
548
|
+
.status.bad { color: #f85149; }
|
|
549
|
+
`;
|
|
550
|
+
|
|
551
|
+
// src/widget.ts
|
|
552
|
+
var TEMPLATE = `
|
|
553
|
+
<div class="root">
|
|
554
|
+
<button type="button" class="trigger" data-state="idle" aria-haspopup="dialog" aria-expanded="false">
|
|
555
|
+
<span class="check" aria-hidden="true">
|
|
556
|
+
<svg viewBox="0 0 24 24"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>
|
|
557
|
+
</span>
|
|
558
|
+
<span class="label">Verify you are human</span>
|
|
559
|
+
<span class="brand" aria-hidden="true">kCAPTCHA</span>
|
|
560
|
+
</button>
|
|
561
|
+
|
|
562
|
+
<div class="overlay" hidden>
|
|
563
|
+
<div class="dialog" role="dialog" aria-modal="true" aria-labelledby="kc-title" aria-describedby="kc-help">
|
|
564
|
+
<div class="head">
|
|
565
|
+
<h2 id="kc-title">Verify you are human</h2>
|
|
566
|
+
<button type="button" class="close" aria-label="Close">×</button>
|
|
567
|
+
</div>
|
|
568
|
+
<p id="kc-help" class="help">Read the moving letters in the box and enter them below.</p>
|
|
569
|
+
|
|
570
|
+
<div class="stage">
|
|
571
|
+
<canvas></canvas>
|
|
572
|
+
<div class="loading">Loading…</div>
|
|
573
|
+
</div>
|
|
574
|
+
|
|
575
|
+
<input class="answer" type="text" placeholder="CAPTCHA" aria-label="CAPTCHA"
|
|
576
|
+
maxlength="8" autocomplete="off" autocapitalize="characters" spellcheck="false">
|
|
577
|
+
|
|
578
|
+
<div class="actions">
|
|
579
|
+
<button type="button" class="primary submit">Submit</button>
|
|
580
|
+
<button type="button" class="refresh">New code</button>
|
|
581
|
+
<button type="button" class="pause" aria-pressed="false">Pause</button>
|
|
582
|
+
</div>
|
|
583
|
+
|
|
584
|
+
<div class="status" role="status" aria-live="polite"></div>
|
|
585
|
+
</div>
|
|
586
|
+
</div>
|
|
587
|
+
</div>
|
|
588
|
+
`;
|
|
589
|
+
var LABEL_IDLE = "Verify you are human";
|
|
590
|
+
var LABEL_VERIFIED = "Verified";
|
|
591
|
+
var POPUP_WIDTH = 440;
|
|
592
|
+
function query(root, selector) {
|
|
593
|
+
const el = root.querySelector(selector);
|
|
594
|
+
if (!el) throw new Error(`[kCAPTCHA] Internal error: missing element ${selector}`);
|
|
595
|
+
return el;
|
|
596
|
+
}
|
|
597
|
+
function applyStyles(shadow) {
|
|
598
|
+
try {
|
|
599
|
+
if (typeof CSSStyleSheet !== "undefined" && "replaceSync" in CSSStyleSheet.prototype) {
|
|
600
|
+
const sheet = new CSSStyleSheet();
|
|
601
|
+
sheet.replaceSync(STYLES);
|
|
602
|
+
shadow.adoptedStyleSheets = [sheet];
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
} catch {
|
|
606
|
+
}
|
|
607
|
+
const style = document.createElement("style");
|
|
608
|
+
style.textContent = STYLES;
|
|
609
|
+
shadow.appendChild(style);
|
|
610
|
+
}
|
|
611
|
+
function toApiError(err) {
|
|
612
|
+
return err instanceof ApiError ? err : new ApiError("network_error", 0);
|
|
613
|
+
}
|
|
614
|
+
var Widget = class {
|
|
615
|
+
constructor(host, opts) {
|
|
616
|
+
this.host = host;
|
|
617
|
+
this.opts = opts;
|
|
618
|
+
this.state = "idle";
|
|
619
|
+
this.token = null;
|
|
620
|
+
this.challenge = null;
|
|
621
|
+
this.dialogOpen = false;
|
|
622
|
+
this.busy = false;
|
|
623
|
+
this.cooling = false;
|
|
624
|
+
this.paused = false;
|
|
625
|
+
this.seq = 0;
|
|
626
|
+
// bumped on every request/reset so stale responses are ignored
|
|
627
|
+
this.challengeTimer = 0;
|
|
628
|
+
this.passTimer = 0;
|
|
629
|
+
this.cooldownTimer = 0;
|
|
630
|
+
// Keep the popup attached to the checkbox while the page scrolls or resizes.
|
|
631
|
+
this.reposition = () => {
|
|
632
|
+
if (this.dialogOpen) this.position();
|
|
633
|
+
};
|
|
634
|
+
// A click anywhere outside the widget closes the popup.
|
|
635
|
+
this.onOutsideClick = (e) => {
|
|
636
|
+
if (!e.composedPath().includes(this.host)) this.close(false);
|
|
637
|
+
};
|
|
638
|
+
// Escape closes; Tab is cycled by hand so focus can never leave the dialog.
|
|
639
|
+
this.onOverlayKeyDown = (e) => {
|
|
640
|
+
var _a;
|
|
641
|
+
if (e.key === "Escape") {
|
|
642
|
+
e.preventDefault();
|
|
643
|
+
e.stopPropagation();
|
|
644
|
+
this.close();
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
if (e.key !== "Tab") return;
|
|
648
|
+
const items = [this.closeBtn, this.input, this.submitBtn, this.refreshBtn, this.pauseBtn].filter(
|
|
649
|
+
(el) => !el.disabled
|
|
650
|
+
);
|
|
651
|
+
e.preventDefault();
|
|
652
|
+
if (items.length === 0) return;
|
|
653
|
+
const idx = items.findIndex((el) => el === this.shadow.activeElement);
|
|
654
|
+
const next = e.shiftKey ? idx <= 0 ? items.length - 1 : idx - 1 : idx === -1 || idx === items.length - 1 ? 0 : idx + 1;
|
|
655
|
+
(_a = items[next]) == null ? void 0 : _a.focus();
|
|
656
|
+
};
|
|
657
|
+
var _a;
|
|
658
|
+
this.shadow = (_a = host.shadowRoot) != null ? _a : host.attachShadow({ mode: "open" });
|
|
659
|
+
this.shadow.replaceChildren();
|
|
660
|
+
applyStyles(this.shadow);
|
|
661
|
+
const template = document.createElement("template");
|
|
662
|
+
template.innerHTML = TEMPLATE;
|
|
663
|
+
this.shadow.appendChild(template.content.cloneNode(true));
|
|
664
|
+
this.root = query(this.shadow, ".root");
|
|
665
|
+
this.trigger = query(this.shadow, ".trigger");
|
|
666
|
+
this.label = query(this.shadow, ".label");
|
|
667
|
+
this.overlay = query(this.shadow, ".overlay");
|
|
668
|
+
this.closeBtn = query(this.shadow, ".close");
|
|
669
|
+
this.stage = query(this.shadow, ".stage");
|
|
670
|
+
this.loadingEl = query(this.shadow, ".loading");
|
|
671
|
+
this.input = query(this.shadow, ".answer");
|
|
672
|
+
this.submitBtn = query(this.shadow, ".submit");
|
|
673
|
+
this.refreshBtn = query(this.shadow, ".refresh");
|
|
674
|
+
this.pauseBtn = query(this.shadow, ".pause");
|
|
675
|
+
this.statusEl = query(this.shadow, ".status");
|
|
676
|
+
const canvas = query(this.shadow, "canvas");
|
|
677
|
+
host.style.width = `${opts.width}px`;
|
|
678
|
+
host.style.height = `${opts.height}px`;
|
|
679
|
+
this.root.style.setProperty("--kc-accent", opts.color);
|
|
680
|
+
this.hidden = document.createElement("input");
|
|
681
|
+
this.hidden.type = "hidden";
|
|
682
|
+
this.hidden.name = opts.name;
|
|
683
|
+
host.appendChild(this.hidden);
|
|
684
|
+
this.api = new KCaptchaApi(opts.endpoint);
|
|
685
|
+
this.renderer = new Renderer(canvas);
|
|
686
|
+
this.trigger.addEventListener("click", () => {
|
|
687
|
+
if (this.state === "verified") return;
|
|
688
|
+
if (this.dialogOpen) this.close();
|
|
689
|
+
else this.open();
|
|
690
|
+
});
|
|
691
|
+
this.closeBtn.addEventListener("click", () => this.close());
|
|
692
|
+
this.overlay.addEventListener("keydown", this.onOverlayKeyDown);
|
|
693
|
+
this.submitBtn.addEventListener("click", () => void this.submit());
|
|
694
|
+
this.refreshBtn.addEventListener("click", () => void this.loadChallenge());
|
|
695
|
+
this.pauseBtn.addEventListener("click", () => this.setPaused(!this.paused));
|
|
696
|
+
this.input.addEventListener("input", () => {
|
|
697
|
+
this.input.value = this.input.value.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
|
698
|
+
});
|
|
699
|
+
this.input.addEventListener("keydown", (e) => {
|
|
700
|
+
if (e.key === "Enter") {
|
|
701
|
+
e.preventDefault();
|
|
702
|
+
void this.submit();
|
|
703
|
+
}
|
|
704
|
+
});
|
|
705
|
+
this.updateTrigger();
|
|
706
|
+
}
|
|
707
|
+
// -------------------------------------------------------------------------
|
|
708
|
+
// Public API
|
|
709
|
+
// -------------------------------------------------------------------------
|
|
710
|
+
getToken() {
|
|
711
|
+
return this.state === "verified" ? this.token : null;
|
|
712
|
+
}
|
|
713
|
+
/** Back to the unchecked state. Call after a failed form submit: a pass token works only once. */
|
|
714
|
+
reset() {
|
|
715
|
+
this.resetState();
|
|
716
|
+
}
|
|
717
|
+
destroy() {
|
|
718
|
+
this.resetState();
|
|
719
|
+
this.renderer.destroy();
|
|
720
|
+
this.hidden.remove();
|
|
721
|
+
this.shadow.replaceChildren();
|
|
722
|
+
this.shadow.adoptedStyleSheets = [];
|
|
723
|
+
this.host.style.removeProperty("width");
|
|
724
|
+
this.host.style.removeProperty("height");
|
|
725
|
+
}
|
|
726
|
+
// -------------------------------------------------------------------------
|
|
727
|
+
// Dialog
|
|
728
|
+
// -------------------------------------------------------------------------
|
|
729
|
+
open() {
|
|
730
|
+
this.overlay.hidden = false;
|
|
731
|
+
this.dialogOpen = true;
|
|
732
|
+
this.trigger.setAttribute("aria-expanded", "true");
|
|
733
|
+
this.position();
|
|
734
|
+
window.addEventListener("resize", this.reposition);
|
|
735
|
+
window.addEventListener("scroll", this.reposition, true);
|
|
736
|
+
document.addEventListener("mousedown", this.onOutsideClick, true);
|
|
737
|
+
this.renderer.start();
|
|
738
|
+
this.input.focus({ preventScroll: true });
|
|
739
|
+
if (!this.challenge && !this.busy && !this.cooling) void this.loadChallenge();
|
|
740
|
+
}
|
|
741
|
+
close(returnFocus = true) {
|
|
742
|
+
if (!this.dialogOpen) return;
|
|
743
|
+
this.overlay.hidden = true;
|
|
744
|
+
this.dialogOpen = false;
|
|
745
|
+
this.setPaused(false);
|
|
746
|
+
this.trigger.setAttribute("aria-expanded", "false");
|
|
747
|
+
window.removeEventListener("resize", this.reposition);
|
|
748
|
+
window.removeEventListener("scroll", this.reposition, true);
|
|
749
|
+
document.removeEventListener("mousedown", this.onOutsideClick, true);
|
|
750
|
+
this.renderer.stop();
|
|
751
|
+
if (returnFocus) this.trigger.focus({ preventScroll: true });
|
|
752
|
+
}
|
|
753
|
+
// Place the popup under the checkbox, or above it if there is no room below,
|
|
754
|
+
// and always keep it fully inside the screen.
|
|
755
|
+
position() {
|
|
756
|
+
const GAP = 8;
|
|
757
|
+
const MARGIN = 8;
|
|
758
|
+
const vw = document.documentElement.clientWidth;
|
|
759
|
+
const vh = window.innerHeight;
|
|
760
|
+
const width = Math.min(POPUP_WIDTH, vw - MARGIN * 2);
|
|
761
|
+
this.overlay.style.width = `${width}px`;
|
|
762
|
+
const rect = this.trigger.getBoundingClientRect();
|
|
763
|
+
const height = this.overlay.offsetHeight;
|
|
764
|
+
const left = Math.min(Math.max(rect.left, MARGIN), vw - width - MARGIN);
|
|
765
|
+
let top = rect.bottom + GAP;
|
|
766
|
+
if (top + height > vh - MARGIN) {
|
|
767
|
+
const above = rect.top - GAP - height;
|
|
768
|
+
top = above >= MARGIN ? above : Math.max(MARGIN, vh - height - MARGIN);
|
|
769
|
+
}
|
|
770
|
+
this.overlay.style.left = `${left}px`;
|
|
771
|
+
this.overlay.style.top = `${top}px`;
|
|
772
|
+
}
|
|
773
|
+
// -------------------------------------------------------------------------
|
|
774
|
+
// Challenge lifecycle
|
|
775
|
+
// -------------------------------------------------------------------------
|
|
776
|
+
async loadChallenge(notice) {
|
|
777
|
+
const mine = ++this.seq;
|
|
778
|
+
this.setPaused(false);
|
|
779
|
+
this.clearTimer("challenge");
|
|
780
|
+
this.challenge = null;
|
|
781
|
+
this.renderer.setGeometry(null);
|
|
782
|
+
this.setStatus(notice != null ? notice : { text: "", kind: "" });
|
|
783
|
+
this.setBusy(true, "Loading\u2026");
|
|
784
|
+
try {
|
|
785
|
+
const challenge = await this.api.newCode();
|
|
786
|
+
if (mine !== this.seq) return;
|
|
787
|
+
this.challenge = challenge;
|
|
788
|
+
this.renderer.setGeometry(challenge.geometry);
|
|
789
|
+
this.input.value = "";
|
|
790
|
+
this.challengeTimer = window.setTimeout(() => this.onChallengeExpired(), challenge.expiresIn * 1e3);
|
|
791
|
+
this.setBusy(false);
|
|
792
|
+
} catch (err) {
|
|
793
|
+
if (mine !== this.seq) return;
|
|
794
|
+
this.setBusy(false);
|
|
795
|
+
this.handleFailure(err);
|
|
796
|
+
}
|
|
797
|
+
if (this.dialogOpen) this.input.focus({ preventScroll: true });
|
|
798
|
+
}
|
|
799
|
+
onChallengeExpired() {
|
|
800
|
+
this.challengeTimer = 0;
|
|
801
|
+
this.challenge = null;
|
|
802
|
+
this.renderer.setGeometry(null);
|
|
803
|
+
if (this.dialogOpen && !this.busy && !this.cooling) {
|
|
804
|
+
void this.loadChallenge({ text: "Code expired \u2014 here is a new one.", kind: "bad" });
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
async submit() {
|
|
808
|
+
if (this.busy || this.cooling) return;
|
|
809
|
+
const answer = this.input.value.trim();
|
|
810
|
+
if (!answer) {
|
|
811
|
+
this.setStatus({ text: "Type the moving code first.", kind: "bad" });
|
|
812
|
+
this.input.focus({ preventScroll: true });
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
if (!this.challenge) {
|
|
816
|
+
void this.loadChallenge();
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
const mine = ++this.seq;
|
|
820
|
+
const { token } = this.challenge;
|
|
821
|
+
this.setStatus({ text: "", kind: "" });
|
|
822
|
+
this.setBusy(true, "Verifying\u2026");
|
|
823
|
+
try {
|
|
824
|
+
const result = await this.api.verify(token, answer);
|
|
825
|
+
if (mine !== this.seq) return;
|
|
826
|
+
this.setBusy(false);
|
|
827
|
+
this.onVerified(result.token, result.expiresIn);
|
|
828
|
+
} catch (err) {
|
|
829
|
+
if (mine !== this.seq) return;
|
|
830
|
+
this.setBusy(false);
|
|
831
|
+
this.handleFailure(err);
|
|
832
|
+
if (this.dialogOpen) this.input.focus({ preventScroll: true });
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
onVerified(token, expiresIn) {
|
|
836
|
+
var _a, _b;
|
|
837
|
+
this.clearTimer("challenge");
|
|
838
|
+
this.challenge = null;
|
|
839
|
+
this.renderer.setGeometry(null);
|
|
840
|
+
this.token = token;
|
|
841
|
+
this.state = "verified";
|
|
842
|
+
this.hidden.value = token;
|
|
843
|
+
this.input.value = "";
|
|
844
|
+
this.setStatus({ text: "", kind: "" });
|
|
845
|
+
this.close();
|
|
846
|
+
this.updateTrigger();
|
|
847
|
+
this.passTimer = window.setTimeout(() => this.onPassExpired(), Math.max(1e3, (expiresIn - 2) * 1e3));
|
|
848
|
+
this.dispatch("kcaptcha:verify", { token });
|
|
849
|
+
(_b = (_a = this.opts).onVerify) == null ? void 0 : _b.call(_a, token);
|
|
850
|
+
}
|
|
851
|
+
onPassExpired() {
|
|
852
|
+
var _a, _b;
|
|
853
|
+
this.passTimer = 0;
|
|
854
|
+
this.resetState();
|
|
855
|
+
this.dispatch("kcaptcha:expire");
|
|
856
|
+
(_b = (_a = this.opts).onExpire) == null ? void 0 : _b.call(_a);
|
|
857
|
+
}
|
|
858
|
+
// -------------------------------------------------------------------------
|
|
859
|
+
// Errors
|
|
860
|
+
// -------------------------------------------------------------------------
|
|
861
|
+
handleFailure(err) {
|
|
862
|
+
var _a;
|
|
863
|
+
const e = toApiError(err);
|
|
864
|
+
switch (e.code) {
|
|
865
|
+
case "incorrect":
|
|
866
|
+
void this.loadChallenge({ text: "Incorrect \u2014 try again.", kind: "bad" });
|
|
867
|
+
return;
|
|
868
|
+
case "expired":
|
|
869
|
+
void this.loadChallenge({ text: "Code expired \u2014 here is a new one.", kind: "bad" });
|
|
870
|
+
return;
|
|
871
|
+
case "replayed":
|
|
872
|
+
case "invalid":
|
|
873
|
+
case "malformed":
|
|
874
|
+
void this.loadChallenge({ text: "Something went wrong \u2014 here is a new code.", kind: "bad" });
|
|
875
|
+
return;
|
|
876
|
+
case "rate_limited":
|
|
877
|
+
this.startCooldown((_a = e.retryAfter) != null ? _a : 30);
|
|
878
|
+
return;
|
|
879
|
+
case "network_error":
|
|
880
|
+
this.setStatus({ text: "Network error \u2014 check your connection and try again.", kind: "bad" });
|
|
881
|
+
return;
|
|
882
|
+
default:
|
|
883
|
+
if (e.status === 404 || e.code.startsWith("http_") || e.code === "bad_response") {
|
|
884
|
+
console.error(
|
|
885
|
+
`[kCAPTCHA] Request failed (${e.code}). Is the kCAPTCHA router mounted at "${this.opts.endpoint}"?`
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
this.setStatus({ text: "Service unavailable \u2014 please try again later.", kind: "bad" });
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
startCooldown(seconds) {
|
|
892
|
+
this.stopCooldown();
|
|
893
|
+
this.cooling = true;
|
|
894
|
+
this.updateButtons();
|
|
895
|
+
let left = Math.max(1, Math.ceil(seconds));
|
|
896
|
+
const tick = () => {
|
|
897
|
+
if (left <= 0) {
|
|
898
|
+
this.stopCooldown();
|
|
899
|
+
this.setStatus({ text: "You can try again now.", kind: "" });
|
|
900
|
+
if (!this.challenge && this.dialogOpen) void this.loadChallenge();
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
this.setStatus({ text: `Too many attempts. Try again in ${left}s.`, kind: "bad" });
|
|
904
|
+
left -= 1;
|
|
905
|
+
};
|
|
906
|
+
tick();
|
|
907
|
+
this.cooldownTimer = window.setInterval(tick, 1e3);
|
|
908
|
+
}
|
|
909
|
+
stopCooldown() {
|
|
910
|
+
if (this.cooldownTimer) {
|
|
911
|
+
window.clearInterval(this.cooldownTimer);
|
|
912
|
+
this.cooldownTimer = 0;
|
|
913
|
+
}
|
|
914
|
+
this.cooling = false;
|
|
915
|
+
this.updateButtons();
|
|
916
|
+
}
|
|
917
|
+
// -------------------------------------------------------------------------
|
|
918
|
+
// State + UI helpers
|
|
919
|
+
// -------------------------------------------------------------------------
|
|
920
|
+
resetState() {
|
|
921
|
+
this.seq += 1;
|
|
922
|
+
this.clearTimer("challenge");
|
|
923
|
+
this.clearTimer("pass");
|
|
924
|
+
this.stopCooldown();
|
|
925
|
+
this.close(false);
|
|
926
|
+
this.state = "idle";
|
|
927
|
+
this.token = null;
|
|
928
|
+
this.challenge = null;
|
|
929
|
+
this.busy = false;
|
|
930
|
+
this.stage.classList.remove("busy");
|
|
931
|
+
this.renderer.setGeometry(null);
|
|
932
|
+
this.hidden.value = "";
|
|
933
|
+
this.input.value = "";
|
|
934
|
+
this.setStatus({ text: "", kind: "" });
|
|
935
|
+
this.updateButtons();
|
|
936
|
+
this.updateTrigger();
|
|
937
|
+
}
|
|
938
|
+
clearTimer(which) {
|
|
939
|
+
if (which === "challenge" && this.challengeTimer) {
|
|
940
|
+
window.clearTimeout(this.challengeTimer);
|
|
941
|
+
this.challengeTimer = 0;
|
|
942
|
+
}
|
|
943
|
+
if (which === "pass" && this.passTimer) {
|
|
944
|
+
window.clearTimeout(this.passTimer);
|
|
945
|
+
this.passTimer = 0;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
setBusy(on, text = "Loading\u2026") {
|
|
949
|
+
this.busy = on;
|
|
950
|
+
this.loadingEl.textContent = text;
|
|
951
|
+
this.stage.classList.toggle("busy", on);
|
|
952
|
+
this.updateButtons();
|
|
953
|
+
}
|
|
954
|
+
// TEMPORARY testing aid: freeze / resume the animation.
|
|
955
|
+
setPaused(on) {
|
|
956
|
+
if (this.paused === on) return;
|
|
957
|
+
this.paused = on;
|
|
958
|
+
this.pauseBtn.textContent = on ? "Resume" : "Pause";
|
|
959
|
+
this.pauseBtn.setAttribute("aria-pressed", String(on));
|
|
960
|
+
if (on) this.renderer.stop();
|
|
961
|
+
else if (this.dialogOpen) this.renderer.start();
|
|
962
|
+
}
|
|
963
|
+
updateButtons() {
|
|
964
|
+
const disabled = this.busy || this.cooling;
|
|
965
|
+
this.submitBtn.disabled = disabled;
|
|
966
|
+
this.refreshBtn.disabled = disabled;
|
|
967
|
+
this.pauseBtn.disabled = this.busy || this.challenge === null;
|
|
968
|
+
}
|
|
969
|
+
updateTrigger() {
|
|
970
|
+
const verified = this.state === "verified";
|
|
971
|
+
this.trigger.dataset.state = this.state;
|
|
972
|
+
this.trigger.setAttribute("aria-disabled", String(verified));
|
|
973
|
+
this.label.textContent = verified ? LABEL_VERIFIED : LABEL_IDLE;
|
|
974
|
+
}
|
|
975
|
+
setStatus(notice) {
|
|
976
|
+
this.statusEl.textContent = notice.text;
|
|
977
|
+
this.statusEl.className = `status ${notice.kind}`.trim();
|
|
978
|
+
}
|
|
979
|
+
dispatch(name, detail) {
|
|
980
|
+
this.host.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true, detail }));
|
|
981
|
+
}
|
|
982
|
+
};
|
|
983
|
+
|
|
984
|
+
// src/index.ts
|
|
985
|
+
var version = "0.1.0";
|
|
986
|
+
var registry = /* @__PURE__ */ new WeakMap();
|
|
987
|
+
function isMounted(el) {
|
|
988
|
+
return registry.has(el);
|
|
989
|
+
}
|
|
990
|
+
function showConfigError(el, message) {
|
|
991
|
+
const box = document.createElement("div");
|
|
992
|
+
box.setAttribute("role", "alert");
|
|
993
|
+
box.textContent = `kCAPTCHA error: ${message}`;
|
|
994
|
+
box.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%";
|
|
995
|
+
el.replaceChildren(box);
|
|
996
|
+
}
|
|
997
|
+
function mount(target, options) {
|
|
998
|
+
const el = typeof target === "string" ? document.querySelector(target) : target;
|
|
999
|
+
if (!el) {
|
|
1000
|
+
console.error(`[kCAPTCHA] Target element not found: ${String(target)}`);
|
|
1001
|
+
return null;
|
|
1002
|
+
}
|
|
1003
|
+
const existing = registry.get(el);
|
|
1004
|
+
if (existing) {
|
|
1005
|
+
console.warn("[kCAPTCHA] This element already has a kCAPTCHA widget; returning the existing one.");
|
|
1006
|
+
return existing;
|
|
1007
|
+
}
|
|
1008
|
+
let resolved;
|
|
1009
|
+
try {
|
|
1010
|
+
resolved = resolveOptions(options);
|
|
1011
|
+
} catch (err) {
|
|
1012
|
+
if (err instanceof KCaptchaOptionError) {
|
|
1013
|
+
console.error(err.message);
|
|
1014
|
+
showConfigError(el, err.message.replace(/^\[kCAPTCHA\]\s*/, ""));
|
|
1015
|
+
return null;
|
|
1016
|
+
}
|
|
1017
|
+
throw err;
|
|
1018
|
+
}
|
|
1019
|
+
let widget;
|
|
1020
|
+
try {
|
|
1021
|
+
widget = new Widget(el, resolved);
|
|
1022
|
+
} catch (err) {
|
|
1023
|
+
console.error("[kCAPTCHA] Could not create the widget:", err);
|
|
1024
|
+
showConfigError(el, "could not create the widget (see the browser console).");
|
|
1025
|
+
return null;
|
|
1026
|
+
}
|
|
1027
|
+
const handle = {
|
|
1028
|
+
element: el,
|
|
1029
|
+
getToken: () => widget.getToken(),
|
|
1030
|
+
reset: () => widget.reset(),
|
|
1031
|
+
destroy: () => {
|
|
1032
|
+
widget.destroy();
|
|
1033
|
+
registry.delete(el);
|
|
1034
|
+
}
|
|
1035
|
+
};
|
|
1036
|
+
registry.set(el, handle);
|
|
1037
|
+
return handle;
|
|
1038
|
+
}
|
|
1039
|
+
export {
|
|
1040
|
+
KCaptchaOptionError,
|
|
1041
|
+
isMounted,
|
|
1042
|
+
mount,
|
|
1043
|
+
version
|
|
1044
|
+
};
|