k9guard 1.0.2 → 1.0.4
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/README.md +202 -14
- package/dist/index.cjs +1555 -0
- package/dist/index.d.cts +141 -0
- package/dist/index.d.ts +141 -0
- package/dist/index.js +1526 -0
- package/docs/tr/README.md +203 -15
- package/package.json +35 -13
- package/index.ts +0 -4
- package/src/K9Guard.ts +0 -91
- package/src/core/captchaGenerator.ts +0 -298
- package/src/core/captchaValidator.ts +0 -182
- package/src/types/index.ts +0 -84
- package/src/utils/crypto.ts +0 -193
- package/src/utils/customQuestionGenerator.ts +0 -40
- package/src/utils/emojiGenerator.ts +0 -88
- package/src/utils/imageGenerator.ts +0 -152
- package/src/utils/random.ts +0 -43
- package/src/utils/reverseGenerator.ts +0 -54
- package/src/utils/scrambleGenerator.ts +0 -49
- package/src/utils/sequenceGenerator.ts +0 -30
- package/src/validators/customQuestionValidator.ts +0 -88
- package/tsconfig.json +0 -29
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1555 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
AdaptiveTracker: () => AdaptiveTracker,
|
|
24
|
+
CustomQuestionGenerator: () => CustomQuestionGenerator,
|
|
25
|
+
CustomQuestionValidator: () => CustomQuestionValidator,
|
|
26
|
+
default: () => K9Guard
|
|
27
|
+
});
|
|
28
|
+
module.exports = __toCommonJS(index_exports);
|
|
29
|
+
|
|
30
|
+
// src/utils/crypto.ts
|
|
31
|
+
var CryptoBuffer = class {
|
|
32
|
+
buffer;
|
|
33
|
+
constructor(buffer) {
|
|
34
|
+
this.buffer = buffer;
|
|
35
|
+
const proxy = new Proxy(this, {
|
|
36
|
+
get(target, prop) {
|
|
37
|
+
if (typeof prop === "string" && !isNaN(Number(prop))) {
|
|
38
|
+
return target.buffer[Number(prop)];
|
|
39
|
+
}
|
|
40
|
+
return target[prop];
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
return proxy;
|
|
44
|
+
}
|
|
45
|
+
readUInt32LE(offset) {
|
|
46
|
+
if (offset < 0 || offset + 4 > this.buffer.length) {
|
|
47
|
+
throw new RangeError("Offset out of bounds");
|
|
48
|
+
}
|
|
49
|
+
const b0 = this.buffer[offset];
|
|
50
|
+
const b1 = this.buffer[offset + 1];
|
|
51
|
+
const b2 = this.buffer[offset + 2];
|
|
52
|
+
const b3 = this.buffer[offset + 3];
|
|
53
|
+
if (b0 === void 0 || b1 === void 0 || b2 === void 0 || b3 === void 0) {
|
|
54
|
+
throw new RangeError("Buffer read error");
|
|
55
|
+
}
|
|
56
|
+
return (b0 | b1 << 8 | b2 << 16 | b3 << 24) >>> 0;
|
|
57
|
+
}
|
|
58
|
+
toString(encoding) {
|
|
59
|
+
if (encoding === "hex") {
|
|
60
|
+
return Array.from(this.buffer).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
61
|
+
}
|
|
62
|
+
if (encoding === "base64") {
|
|
63
|
+
const binString = Array.from(this.buffer, (byte) => String.fromCodePoint(byte)).join("");
|
|
64
|
+
return btoa(binString);
|
|
65
|
+
}
|
|
66
|
+
return new TextDecoder().decode(this.buffer);
|
|
67
|
+
}
|
|
68
|
+
get length() {
|
|
69
|
+
return this.buffer.length;
|
|
70
|
+
}
|
|
71
|
+
[Symbol.iterator]() {
|
|
72
|
+
let index = 0;
|
|
73
|
+
const buffer = this.buffer;
|
|
74
|
+
return {
|
|
75
|
+
next() {
|
|
76
|
+
if (index < buffer.length) {
|
|
77
|
+
const val = buffer[index++];
|
|
78
|
+
if (val === void 0) {
|
|
79
|
+
return { value: 0, done: true };
|
|
80
|
+
}
|
|
81
|
+
return { value: val, done: false };
|
|
82
|
+
}
|
|
83
|
+
return { value: 0, done: true };
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
var CryptoHash = class {
|
|
89
|
+
chunks = [];
|
|
90
|
+
update(input) {
|
|
91
|
+
if (typeof input === "string") {
|
|
92
|
+
this.chunks.push(new TextEncoder().encode(input));
|
|
93
|
+
} else {
|
|
94
|
+
this.chunks.push(input);
|
|
95
|
+
}
|
|
96
|
+
return this;
|
|
97
|
+
}
|
|
98
|
+
// Pure-JS SHA-256 — used for the synchronous digest path.
|
|
99
|
+
// Identical output to SubtleCrypto; no external dependency.
|
|
100
|
+
digest(encoding = "hex") {
|
|
101
|
+
const combined = this.mergeChunks();
|
|
102
|
+
const hash = sha256(combined);
|
|
103
|
+
if (encoding === "hex") {
|
|
104
|
+
return Array.from(hash).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
105
|
+
}
|
|
106
|
+
if (encoding === "base64") {
|
|
107
|
+
const binString = Array.from(hash, (b) => String.fromCodePoint(b)).join("");
|
|
108
|
+
return btoa(binString);
|
|
109
|
+
}
|
|
110
|
+
return String.fromCodePoint(...hash);
|
|
111
|
+
}
|
|
112
|
+
mergeChunks() {
|
|
113
|
+
const total = this.chunks.reduce((acc, c) => acc + c.length, 0);
|
|
114
|
+
const out = new Uint8Array(total);
|
|
115
|
+
let offset = 0;
|
|
116
|
+
for (const chunk of this.chunks) {
|
|
117
|
+
out.set(chunk, offset);
|
|
118
|
+
offset += chunk.length;
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
var CryptoUtils = class {
|
|
124
|
+
static randomBytes(size) {
|
|
125
|
+
if (size <= 0 || size > 65536) {
|
|
126
|
+
throw new RangeError("Size must be between 1 and 65536");
|
|
127
|
+
}
|
|
128
|
+
const buf = new Uint8Array(size);
|
|
129
|
+
globalThis.crypto.getRandomValues(buf);
|
|
130
|
+
return new CryptoBuffer(buf);
|
|
131
|
+
}
|
|
132
|
+
static createHash(algorithm) {
|
|
133
|
+
if (algorithm.toLowerCase() !== "sha256") {
|
|
134
|
+
throw new Error("Only SHA-256 is supported");
|
|
135
|
+
}
|
|
136
|
+
return new CryptoHash();
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
var randomBytes = (size) => CryptoUtils.randomBytes(size);
|
|
140
|
+
var createHash = (algorithm) => CryptoUtils.createHash(algorithm);
|
|
141
|
+
function timingSafeEqual(a, b) {
|
|
142
|
+
if (a.length !== b.length) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
let diff = 0;
|
|
146
|
+
for (let i = 0; i < a.length; i++) {
|
|
147
|
+
diff |= a[i] ^ b[i];
|
|
148
|
+
}
|
|
149
|
+
return diff === 0;
|
|
150
|
+
}
|
|
151
|
+
var RandomPool = class {
|
|
152
|
+
buffer;
|
|
153
|
+
offset;
|
|
154
|
+
chunkSize;
|
|
155
|
+
constructor(chunkSize = 2048) {
|
|
156
|
+
this.chunkSize = chunkSize;
|
|
157
|
+
this.buffer = new Uint8Array(chunkSize);
|
|
158
|
+
globalThis.crypto.getRandomValues(this.buffer);
|
|
159
|
+
this.offset = 0;
|
|
160
|
+
}
|
|
161
|
+
refill() {
|
|
162
|
+
globalThis.crypto.getRandomValues(this.buffer);
|
|
163
|
+
this.offset = 0;
|
|
164
|
+
}
|
|
165
|
+
uint32() {
|
|
166
|
+
if (this.offset + 4 > this.buffer.length) {
|
|
167
|
+
this.refill();
|
|
168
|
+
}
|
|
169
|
+
const b0 = this.buffer[this.offset];
|
|
170
|
+
const b1 = this.buffer[this.offset + 1];
|
|
171
|
+
const b2 = this.buffer[this.offset + 2];
|
|
172
|
+
const b3 = this.buffer[this.offset + 3];
|
|
173
|
+
this.offset += 4;
|
|
174
|
+
return (b0 | b1 << 8 | b2 << 16 | b3 << 24) >>> 0;
|
|
175
|
+
}
|
|
176
|
+
byte() {
|
|
177
|
+
if (this.offset >= this.buffer.length) {
|
|
178
|
+
this.refill();
|
|
179
|
+
}
|
|
180
|
+
const val = this.buffer[this.offset];
|
|
181
|
+
this.offset += 1;
|
|
182
|
+
return val;
|
|
183
|
+
}
|
|
184
|
+
// uniform float in [0, 1)
|
|
185
|
+
float() {
|
|
186
|
+
return this.uint32() / 4294967295;
|
|
187
|
+
}
|
|
188
|
+
// uniform integer in [min, max)
|
|
189
|
+
int(min, max) {
|
|
190
|
+
return Math.floor(this.float() * (max - min)) + min;
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
var K = [
|
|
194
|
+
1116352408,
|
|
195
|
+
1899447441,
|
|
196
|
+
3049323471,
|
|
197
|
+
3921009573,
|
|
198
|
+
961987163,
|
|
199
|
+
1508970993,
|
|
200
|
+
2453635748,
|
|
201
|
+
2870763221,
|
|
202
|
+
3624381080,
|
|
203
|
+
310598401,
|
|
204
|
+
607225278,
|
|
205
|
+
1426881987,
|
|
206
|
+
1925078388,
|
|
207
|
+
2162078206,
|
|
208
|
+
2614888103,
|
|
209
|
+
3248222580,
|
|
210
|
+
3835390401,
|
|
211
|
+
4022224774,
|
|
212
|
+
264347078,
|
|
213
|
+
604807628,
|
|
214
|
+
770255983,
|
|
215
|
+
1249150122,
|
|
216
|
+
1555081692,
|
|
217
|
+
1996064986,
|
|
218
|
+
2554220882,
|
|
219
|
+
2821834349,
|
|
220
|
+
2952996808,
|
|
221
|
+
3210313671,
|
|
222
|
+
3336571891,
|
|
223
|
+
3584528711,
|
|
224
|
+
113926993,
|
|
225
|
+
338241895,
|
|
226
|
+
666307205,
|
|
227
|
+
773529912,
|
|
228
|
+
1294757372,
|
|
229
|
+
1396182291,
|
|
230
|
+
1695183700,
|
|
231
|
+
1986661051,
|
|
232
|
+
2177026350,
|
|
233
|
+
2456956037,
|
|
234
|
+
2730485921,
|
|
235
|
+
2820302411,
|
|
236
|
+
3259730800,
|
|
237
|
+
3345764771,
|
|
238
|
+
3516065817,
|
|
239
|
+
3600352804,
|
|
240
|
+
4094571909,
|
|
241
|
+
275423344,
|
|
242
|
+
430227734,
|
|
243
|
+
506948616,
|
|
244
|
+
659060556,
|
|
245
|
+
883997877,
|
|
246
|
+
958139571,
|
|
247
|
+
1322822218,
|
|
248
|
+
1537002063,
|
|
249
|
+
1747873779,
|
|
250
|
+
1955562222,
|
|
251
|
+
2024104815,
|
|
252
|
+
2227730452,
|
|
253
|
+
2361852424,
|
|
254
|
+
2428436474,
|
|
255
|
+
2756734187,
|
|
256
|
+
3204031479,
|
|
257
|
+
3329325298
|
|
258
|
+
];
|
|
259
|
+
function rotr32(x, n) {
|
|
260
|
+
return x >>> n | x << 32 - n;
|
|
261
|
+
}
|
|
262
|
+
function sha256(data) {
|
|
263
|
+
let h0 = 1779033703;
|
|
264
|
+
let h1 = 3144134277;
|
|
265
|
+
let h2 = 1013904242;
|
|
266
|
+
let h3 = 2773480762;
|
|
267
|
+
let h4 = 1359893119;
|
|
268
|
+
let h5 = 2600822924;
|
|
269
|
+
let h6 = 528734635;
|
|
270
|
+
let h7 = 1541459225;
|
|
271
|
+
const msgLen = data.length;
|
|
272
|
+
const bitLen = msgLen * 8;
|
|
273
|
+
const padLen = msgLen + 9 + 63 & ~63;
|
|
274
|
+
const padded = new Uint8Array(padLen);
|
|
275
|
+
padded.set(data);
|
|
276
|
+
padded[msgLen] = 128;
|
|
277
|
+
const view = new DataView(padded.buffer);
|
|
278
|
+
view.setUint32(padLen - 4, bitLen >>> 0, false);
|
|
279
|
+
view.setUint32(padLen - 8, Math.floor(bitLen / 4294967296) >>> 0, false);
|
|
280
|
+
const w = new Int32Array(64);
|
|
281
|
+
for (let offset = 0; offset < padLen; offset += 64) {
|
|
282
|
+
for (let i = 0; i < 16; i++) {
|
|
283
|
+
w[i] = view.getInt32(offset + i * 4, false);
|
|
284
|
+
}
|
|
285
|
+
for (let i = 16; i < 64; i++) {
|
|
286
|
+
const s0 = rotr32(w[i - 15], 7) ^ rotr32(w[i - 15], 18) ^ w[i - 15] >>> 3;
|
|
287
|
+
const s1 = rotr32(w[i - 2], 17) ^ rotr32(w[i - 2], 19) ^ w[i - 2] >>> 10;
|
|
288
|
+
w[i] = w[i - 16] + s0 + w[i - 7] + s1 | 0;
|
|
289
|
+
}
|
|
290
|
+
let a = h0, b = h1, c = h2, d = h3;
|
|
291
|
+
let e = h4, f = h5, g = h6, h = h7;
|
|
292
|
+
for (let i = 0; i < 64; i++) {
|
|
293
|
+
const S1 = rotr32(e, 6) ^ rotr32(e, 11) ^ rotr32(e, 25);
|
|
294
|
+
const ch = e & f ^ ~e & g;
|
|
295
|
+
const tmp1 = h + S1 + ch + K[i] + w[i] | 0;
|
|
296
|
+
const S0 = rotr32(a, 2) ^ rotr32(a, 13) ^ rotr32(a, 22);
|
|
297
|
+
const maj = a & b ^ a & c ^ b & c;
|
|
298
|
+
const tmp2 = S0 + maj | 0;
|
|
299
|
+
h = g;
|
|
300
|
+
g = f;
|
|
301
|
+
f = e;
|
|
302
|
+
e = d + tmp1 | 0;
|
|
303
|
+
d = c;
|
|
304
|
+
c = b;
|
|
305
|
+
b = a;
|
|
306
|
+
a = tmp1 + tmp2 | 0;
|
|
307
|
+
}
|
|
308
|
+
h0 = h0 + a | 0;
|
|
309
|
+
h1 = h1 + b | 0;
|
|
310
|
+
h2 = h2 + c | 0;
|
|
311
|
+
h3 = h3 + d | 0;
|
|
312
|
+
h4 = h4 + e | 0;
|
|
313
|
+
h5 = h5 + f | 0;
|
|
314
|
+
h6 = h6 + g | 0;
|
|
315
|
+
h7 = h7 + h | 0;
|
|
316
|
+
}
|
|
317
|
+
const result = new Uint8Array(32);
|
|
318
|
+
const rv = new DataView(result.buffer);
|
|
319
|
+
rv.setUint32(0, h0 >>> 0, false);
|
|
320
|
+
rv.setUint32(4, h1 >>> 0, false);
|
|
321
|
+
rv.setUint32(8, h2 >>> 0, false);
|
|
322
|
+
rv.setUint32(12, h3 >>> 0, false);
|
|
323
|
+
rv.setUint32(16, h4 >>> 0, false);
|
|
324
|
+
rv.setUint32(20, h5 >>> 0, false);
|
|
325
|
+
rv.setUint32(24, h6 >>> 0, false);
|
|
326
|
+
rv.setUint32(28, h7 >>> 0, false);
|
|
327
|
+
return result;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// src/utils/random.ts
|
|
331
|
+
var Random = class {
|
|
332
|
+
static getRandomNumber(difficulty) {
|
|
333
|
+
const buffer = randomBytes(4);
|
|
334
|
+
const rand = buffer.readUInt32LE(0) / 4294967296;
|
|
335
|
+
if (difficulty === "easy") {
|
|
336
|
+
return Math.floor(rand * 10) + 1;
|
|
337
|
+
}
|
|
338
|
+
if (difficulty === "medium") {
|
|
339
|
+
return Math.floor(rand * 50) + 1;
|
|
340
|
+
}
|
|
341
|
+
return Math.floor(rand * 100) + 1;
|
|
342
|
+
}
|
|
343
|
+
static getRandomOperator() {
|
|
344
|
+
const operators = ["+", "-", "*", "/"];
|
|
345
|
+
const buffer = randomBytes(1);
|
|
346
|
+
const index = buffer[0] % operators.length;
|
|
347
|
+
return operators[index];
|
|
348
|
+
}
|
|
349
|
+
static generateRandomString(difficulty) {
|
|
350
|
+
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
351
|
+
let result = "";
|
|
352
|
+
const length = difficulty === "easy" ? 4 : difficulty === "medium" ? 6 : 8;
|
|
353
|
+
const buffer = randomBytes(length);
|
|
354
|
+
for (let i = 0; i < length; i++) {
|
|
355
|
+
result += chars.charAt(buffer[i] % chars.length);
|
|
356
|
+
}
|
|
357
|
+
return result;
|
|
358
|
+
}
|
|
359
|
+
static generateNonce() {
|
|
360
|
+
return randomBytes(16).toString("hex");
|
|
361
|
+
}
|
|
362
|
+
static generateSalt() {
|
|
363
|
+
return randomBytes(8).toString("hex");
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
// src/utils/sequenceGenerator.ts
|
|
368
|
+
var SequenceGenerator = class {
|
|
369
|
+
static generate(difficulty) {
|
|
370
|
+
if (difficulty === "easy") {
|
|
371
|
+
const buffer2 = randomBytes(8);
|
|
372
|
+
const start2 = buffer2.readUInt32LE(0) % 5 + 1;
|
|
373
|
+
const step = buffer2.readUInt32LE(4) % 3 + 1;
|
|
374
|
+
const sequence2 = [start2, start2 + step, start2 + 2 * step];
|
|
375
|
+
const answer2 = start2 + 3 * step;
|
|
376
|
+
return { question: `${sequence2.join(", ")}, ?`, answer: answer2 };
|
|
377
|
+
}
|
|
378
|
+
if (difficulty === "medium") {
|
|
379
|
+
const letters = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"];
|
|
380
|
+
const step = 2;
|
|
381
|
+
const maxStart2 = letters.length - step * 3 - 1;
|
|
382
|
+
const buffer2 = randomBytes(4);
|
|
383
|
+
const start2 = buffer2.readUInt32LE(0) % (maxStart2 + 1);
|
|
384
|
+
const sequence2 = [letters[start2], letters[start2 + step], letters[start2 + 2 * step]];
|
|
385
|
+
const answer2 = letters[start2 + 3 * step];
|
|
386
|
+
return { question: `${sequence2.join(", ")}, ?`, answer: answer2 };
|
|
387
|
+
}
|
|
388
|
+
const fibs = [1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144];
|
|
389
|
+
const buffer = randomBytes(4);
|
|
390
|
+
const maxStart = fibs.length - 5;
|
|
391
|
+
const start = buffer.readUInt32LE(0) % (maxStart + 1);
|
|
392
|
+
const sequence = fibs.slice(start, start + 4);
|
|
393
|
+
const answer = fibs[start + 4];
|
|
394
|
+
return { question: `${sequence.join(", ")}, ?`, answer };
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
// src/utils/scrambleGenerator.ts
|
|
399
|
+
var ScrambleGenerator = class {
|
|
400
|
+
static easyWords = [
|
|
401
|
+
"apple",
|
|
402
|
+
"cat",
|
|
403
|
+
"dog",
|
|
404
|
+
"house",
|
|
405
|
+
"sun",
|
|
406
|
+
"moon",
|
|
407
|
+
"car",
|
|
408
|
+
"tree",
|
|
409
|
+
"book",
|
|
410
|
+
"water"
|
|
411
|
+
];
|
|
412
|
+
static mediumWords = [
|
|
413
|
+
"apple",
|
|
414
|
+
"cat",
|
|
415
|
+
"dog",
|
|
416
|
+
"house",
|
|
417
|
+
"sun",
|
|
418
|
+
"moon",
|
|
419
|
+
"car",
|
|
420
|
+
"tree",
|
|
421
|
+
"book",
|
|
422
|
+
"water",
|
|
423
|
+
"bread",
|
|
424
|
+
"milk",
|
|
425
|
+
"fish",
|
|
426
|
+
"bird",
|
|
427
|
+
"flower",
|
|
428
|
+
"star",
|
|
429
|
+
"hand",
|
|
430
|
+
"eye",
|
|
431
|
+
"nose",
|
|
432
|
+
"mouth"
|
|
433
|
+
];
|
|
434
|
+
// hard pool: longer words with uncommon letter patterns to resist guessing
|
|
435
|
+
static hardWords = [
|
|
436
|
+
"typescript",
|
|
437
|
+
"javascript",
|
|
438
|
+
"algorithm",
|
|
439
|
+
"encryption",
|
|
440
|
+
"blockchain",
|
|
441
|
+
"metamorphosis",
|
|
442
|
+
"cryptography",
|
|
443
|
+
"synchronize",
|
|
444
|
+
"parallelism",
|
|
445
|
+
"obfuscation",
|
|
446
|
+
"infrastructure",
|
|
447
|
+
"authentication",
|
|
448
|
+
"authorization",
|
|
449
|
+
"vulnerability",
|
|
450
|
+
"orchestration"
|
|
451
|
+
];
|
|
452
|
+
static generate(difficulty) {
|
|
453
|
+
let pool;
|
|
454
|
+
if (difficulty === "easy") {
|
|
455
|
+
pool = this.easyWords;
|
|
456
|
+
} else if (difficulty === "medium") {
|
|
457
|
+
pool = this.mediumWords;
|
|
458
|
+
} else {
|
|
459
|
+
pool = this.hardWords;
|
|
460
|
+
}
|
|
461
|
+
const buffer = randomBytes(4);
|
|
462
|
+
const rand = buffer.readUInt32LE(0) / 4294967295;
|
|
463
|
+
const word = pool[Math.floor(rand * pool.length)];
|
|
464
|
+
const scrambled = this.scramble(word);
|
|
465
|
+
return { question: scrambled, answer: word };
|
|
466
|
+
}
|
|
467
|
+
static scramble(word) {
|
|
468
|
+
const arr = word.split("");
|
|
469
|
+
const n = arr.length;
|
|
470
|
+
const batchBuf = randomBytes(n * 4);
|
|
471
|
+
for (let i = n - 1; i > 0; i--) {
|
|
472
|
+
const j = batchBuf.readUInt32LE((n - 1 - i) * 4) % (i + 1);
|
|
473
|
+
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
474
|
+
}
|
|
475
|
+
return arr.join("");
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
// src/utils/reverseGenerator.ts
|
|
480
|
+
var ReverseGenerator = class {
|
|
481
|
+
static easyWords = [
|
|
482
|
+
"cat",
|
|
483
|
+
"dog",
|
|
484
|
+
"sun",
|
|
485
|
+
"moon",
|
|
486
|
+
"star",
|
|
487
|
+
"fish",
|
|
488
|
+
"bird",
|
|
489
|
+
"tree",
|
|
490
|
+
"ball",
|
|
491
|
+
"book",
|
|
492
|
+
"door",
|
|
493
|
+
"lamp",
|
|
494
|
+
"rock",
|
|
495
|
+
"leaf",
|
|
496
|
+
"wind",
|
|
497
|
+
"fire",
|
|
498
|
+
"ice",
|
|
499
|
+
"sky",
|
|
500
|
+
"sea",
|
|
501
|
+
"fog",
|
|
502
|
+
"rain",
|
|
503
|
+
"snow",
|
|
504
|
+
"bear",
|
|
505
|
+
"wolf",
|
|
506
|
+
"coin",
|
|
507
|
+
"key",
|
|
508
|
+
"cup",
|
|
509
|
+
"pen",
|
|
510
|
+
"box",
|
|
511
|
+
"hat",
|
|
512
|
+
"map",
|
|
513
|
+
"web"
|
|
514
|
+
];
|
|
515
|
+
static mediumWords = [
|
|
516
|
+
"apple",
|
|
517
|
+
"house",
|
|
518
|
+
"water",
|
|
519
|
+
"bread",
|
|
520
|
+
"ocean",
|
|
521
|
+
"river",
|
|
522
|
+
"tiger",
|
|
523
|
+
"eagle",
|
|
524
|
+
"storm",
|
|
525
|
+
"cloud",
|
|
526
|
+
"flame",
|
|
527
|
+
"frost",
|
|
528
|
+
"stone",
|
|
529
|
+
"metal",
|
|
530
|
+
"glass",
|
|
531
|
+
"paper",
|
|
532
|
+
"forest",
|
|
533
|
+
"desert",
|
|
534
|
+
"valley",
|
|
535
|
+
"castle",
|
|
536
|
+
"bridge",
|
|
537
|
+
"garden",
|
|
538
|
+
"market",
|
|
539
|
+
"temple",
|
|
540
|
+
"dragon",
|
|
541
|
+
"wizard",
|
|
542
|
+
"knight",
|
|
543
|
+
"shield",
|
|
544
|
+
"sword",
|
|
545
|
+
"crown",
|
|
546
|
+
"jewel",
|
|
547
|
+
"crystal",
|
|
548
|
+
"planet",
|
|
549
|
+
"galaxy",
|
|
550
|
+
"comet",
|
|
551
|
+
"nebula",
|
|
552
|
+
"cipher",
|
|
553
|
+
"enigma",
|
|
554
|
+
"puzzle",
|
|
555
|
+
"riddle",
|
|
556
|
+
"shadow",
|
|
557
|
+
"mirror",
|
|
558
|
+
"portal",
|
|
559
|
+
"beacon",
|
|
560
|
+
"anchor",
|
|
561
|
+
"compass",
|
|
562
|
+
"lantern",
|
|
563
|
+
"prism"
|
|
564
|
+
];
|
|
565
|
+
static hardWords = [
|
|
566
|
+
"typescript",
|
|
567
|
+
"javascript",
|
|
568
|
+
"encryption",
|
|
569
|
+
"cryptography",
|
|
570
|
+
"algorithm",
|
|
571
|
+
"infrastructure",
|
|
572
|
+
"architecture",
|
|
573
|
+
"authentication",
|
|
574
|
+
"authorization",
|
|
575
|
+
"vulnerability",
|
|
576
|
+
"cybersecurity",
|
|
577
|
+
"blockchain",
|
|
578
|
+
"metamorphosis",
|
|
579
|
+
"constellation",
|
|
580
|
+
"synchronization",
|
|
581
|
+
"transformation",
|
|
582
|
+
"illumination",
|
|
583
|
+
"orchestration",
|
|
584
|
+
"denomination",
|
|
585
|
+
"comprehension",
|
|
586
|
+
"manifestation",
|
|
587
|
+
"extraordinary",
|
|
588
|
+
"revolutionary",
|
|
589
|
+
"sophisticated",
|
|
590
|
+
"kaleidoscope",
|
|
591
|
+
"optimization",
|
|
592
|
+
"crystallization",
|
|
593
|
+
"configuration",
|
|
594
|
+
"implementation",
|
|
595
|
+
"parallelization",
|
|
596
|
+
"serialization",
|
|
597
|
+
"decentralization",
|
|
598
|
+
"internationalization",
|
|
599
|
+
"containerization",
|
|
600
|
+
"virtualization",
|
|
601
|
+
"obfuscation",
|
|
602
|
+
"triangulation",
|
|
603
|
+
"interpolation",
|
|
604
|
+
"extrapolation",
|
|
605
|
+
"approximation",
|
|
606
|
+
"segmentation",
|
|
607
|
+
"fragmentation",
|
|
608
|
+
"concatenation",
|
|
609
|
+
"regeneration",
|
|
610
|
+
"degeneration"
|
|
611
|
+
];
|
|
612
|
+
static generate(difficulty) {
|
|
613
|
+
let wordPool;
|
|
614
|
+
if (difficulty === "hard") {
|
|
615
|
+
wordPool = this.hardWords;
|
|
616
|
+
} else if (difficulty === "medium") {
|
|
617
|
+
wordPool = this.mediumWords;
|
|
618
|
+
} else {
|
|
619
|
+
wordPool = this.easyWords;
|
|
620
|
+
}
|
|
621
|
+
const buf = randomBytes(4);
|
|
622
|
+
const rand = buf.readUInt32LE(0) / 4294967295;
|
|
623
|
+
const text = wordPool[Math.floor(rand * wordPool.length)];
|
|
624
|
+
const reversed = text.split("").reverse().join("");
|
|
625
|
+
return { question: reversed, answer: text };
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
// src/utils/customQuestionGenerator.ts
|
|
630
|
+
var CustomQuestionGenerator = class {
|
|
631
|
+
questions;
|
|
632
|
+
constructor(questions) {
|
|
633
|
+
this.questions = questions;
|
|
634
|
+
}
|
|
635
|
+
generate(difficulty) {
|
|
636
|
+
const candidates = difficulty ? this.questions.filter((q) => q.difficulty === difficulty) : this.questions;
|
|
637
|
+
if (candidates.length === 0) {
|
|
638
|
+
return this.selectRandom(this.questions);
|
|
639
|
+
}
|
|
640
|
+
return this.selectRandom(candidates);
|
|
641
|
+
}
|
|
642
|
+
selectRandom(questions) {
|
|
643
|
+
if (questions.length === 0) {
|
|
644
|
+
return { question: "", answer: "" };
|
|
645
|
+
}
|
|
646
|
+
const buffer = randomBytes(4);
|
|
647
|
+
const index = buffer.readUInt32LE(0) % questions.length;
|
|
648
|
+
const selected = questions[index];
|
|
649
|
+
return {
|
|
650
|
+
question: selected.question,
|
|
651
|
+
answer: selected.answer
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
// src/validators/customQuestionValidator.ts
|
|
657
|
+
var CustomQuestionValidator = class {
|
|
658
|
+
static MAX_QUESTIONS = 100;
|
|
659
|
+
static MAX_QUESTION_LENGTH = 500;
|
|
660
|
+
static MAX_ANSWER_LENGTH = 200;
|
|
661
|
+
static MIN_QUESTION_LENGTH = 5;
|
|
662
|
+
static MIN_ANSWER_LENGTH = 1;
|
|
663
|
+
static VALID_DIFFICULTY = ["easy", "medium", "hard"];
|
|
664
|
+
// validates the entire questions array structure and content
|
|
665
|
+
static validate(questions) {
|
|
666
|
+
if (!Array.isArray(questions)) {
|
|
667
|
+
return { valid: false, error: "Questions must be an array" };
|
|
668
|
+
}
|
|
669
|
+
if (questions.length === 0) {
|
|
670
|
+
return { valid: false, error: "At least one question is required" };
|
|
671
|
+
}
|
|
672
|
+
if (questions.length > this.MAX_QUESTIONS) {
|
|
673
|
+
return { valid: false, error: `Maximum ${this.MAX_QUESTIONS} questions allowed` };
|
|
674
|
+
}
|
|
675
|
+
for (let i = 0; i < questions.length; i++) {
|
|
676
|
+
const questionItem = questions[i];
|
|
677
|
+
const singleValidation = this.validateSingle(questionItem);
|
|
678
|
+
if (!singleValidation.valid) {
|
|
679
|
+
return { valid: false, error: `Question ${i + 1}: ${singleValidation.error}` };
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return { valid: true };
|
|
683
|
+
}
|
|
684
|
+
static validateSingle(question) {
|
|
685
|
+
if (typeof question !== "object" || question === null) {
|
|
686
|
+
return { valid: false, error: "Each question must be an object" };
|
|
687
|
+
}
|
|
688
|
+
const q = question;
|
|
689
|
+
if (typeof q.question !== "string") {
|
|
690
|
+
return { valid: false, error: "Question text must be a string" };
|
|
691
|
+
}
|
|
692
|
+
if (typeof q.answer !== "string") {
|
|
693
|
+
return { valid: false, error: "Answer must be a string" };
|
|
694
|
+
}
|
|
695
|
+
if (typeof q.difficulty !== "string") {
|
|
696
|
+
return { valid: false, error: "Difficulty must be specified" };
|
|
697
|
+
}
|
|
698
|
+
if (!this.VALID_DIFFICULTY.includes(q.difficulty)) {
|
|
699
|
+
return { valid: false, error: `Difficulty must be one of: ${this.VALID_DIFFICULTY.join(", ")}` };
|
|
700
|
+
}
|
|
701
|
+
if (q.question.length < this.MIN_QUESTION_LENGTH) {
|
|
702
|
+
return { valid: false, error: `Question must be at least ${this.MIN_QUESTION_LENGTH} characters` };
|
|
703
|
+
}
|
|
704
|
+
if (q.question.length > this.MAX_QUESTION_LENGTH) {
|
|
705
|
+
return { valid: false, error: `Question must not exceed ${this.MAX_QUESTION_LENGTH} characters` };
|
|
706
|
+
}
|
|
707
|
+
if (q.answer.length < this.MIN_ANSWER_LENGTH) {
|
|
708
|
+
return { valid: false, error: "Answer cannot be empty" };
|
|
709
|
+
}
|
|
710
|
+
if (q.answer.length > this.MAX_ANSWER_LENGTH) {
|
|
711
|
+
return { valid: false, error: `Answer must not exceed ${this.MAX_ANSWER_LENGTH} characters` };
|
|
712
|
+
}
|
|
713
|
+
return { valid: true };
|
|
714
|
+
}
|
|
715
|
+
// clean up whitespace from questions and answers
|
|
716
|
+
static sanitize(questions) {
|
|
717
|
+
return questions.map((q) => ({
|
|
718
|
+
question: q.question.trim(),
|
|
719
|
+
answer: q.answer.trim(),
|
|
720
|
+
difficulty: q.difficulty
|
|
721
|
+
}));
|
|
722
|
+
}
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
// src/utils/imageGenerator.ts
|
|
726
|
+
var CHAR_POOL_EASY = "ABCDEFGHJKLMNPQRSTUVWXYZ";
|
|
727
|
+
var CHAR_POOL_MEDIUM = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
728
|
+
var CHAR_POOL_HARD = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
|
|
729
|
+
var SVG_WIDTH = 200;
|
|
730
|
+
var SVG_HEIGHT = 70;
|
|
731
|
+
function escapeXml(str) {
|
|
732
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
733
|
+
}
|
|
734
|
+
function generateText(pool, charPool, length) {
|
|
735
|
+
let result = "";
|
|
736
|
+
for (let i = 0; i < length; i++) {
|
|
737
|
+
result += charPool[pool.byte() % charPool.length];
|
|
738
|
+
}
|
|
739
|
+
return result;
|
|
740
|
+
}
|
|
741
|
+
function renderChar(pool, char, x, baseY, colorHex) {
|
|
742
|
+
const rotate = pool.int(-25, 25);
|
|
743
|
+
const offsetY = pool.int(-6, 6);
|
|
744
|
+
const fontSize = pool.int(22, 30);
|
|
745
|
+
const opacity = (pool.float() * 0.25 + 0.75).toFixed(2);
|
|
746
|
+
return `<text x="${x}" y="${baseY + offsetY}" transform="rotate(${rotate},${x},${baseY + offsetY})" font-size="${fontSize}" font-family="monospace" font-weight="bold" fill="${colorHex}" opacity="${opacity}" letter-spacing="2">${escapeXml(char)}</text>`;
|
|
747
|
+
}
|
|
748
|
+
function grayColor(pool) {
|
|
749
|
+
const v = pool.int(100, 200);
|
|
750
|
+
return `rgb(${v},${v},${v})`;
|
|
751
|
+
}
|
|
752
|
+
function darkColor(pool) {
|
|
753
|
+
const r = pool.int(20, 150);
|
|
754
|
+
const g = pool.int(20, 150);
|
|
755
|
+
const b = pool.int(20, 150);
|
|
756
|
+
return `rgb(${r},${g},${b})`;
|
|
757
|
+
}
|
|
758
|
+
function renderNoiseLines(pool, count) {
|
|
759
|
+
let lines = "";
|
|
760
|
+
for (let i = 0; i < count; i++) {
|
|
761
|
+
const x1 = pool.int(0, SVG_WIDTH);
|
|
762
|
+
const y1 = pool.int(0, SVG_HEIGHT);
|
|
763
|
+
const x2 = pool.int(0, SVG_WIDTH);
|
|
764
|
+
const y2 = pool.int(0, SVG_HEIGHT);
|
|
765
|
+
const strokeWidth = (pool.float() * 1.5 + 0.5).toFixed(1);
|
|
766
|
+
const color = grayColor(pool);
|
|
767
|
+
const opacity = (pool.float() * 0.4 + 0.2).toFixed(2);
|
|
768
|
+
lines += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${color}" stroke-width="${strokeWidth}" opacity="${opacity}"/>`;
|
|
769
|
+
}
|
|
770
|
+
return lines;
|
|
771
|
+
}
|
|
772
|
+
function renderNoiseDots(pool, count) {
|
|
773
|
+
let dots = "";
|
|
774
|
+
for (let i = 0; i < count; i++) {
|
|
775
|
+
const cx = pool.int(0, SVG_WIDTH);
|
|
776
|
+
const cy = pool.int(0, SVG_HEIGHT);
|
|
777
|
+
const r = pool.int(1, 3);
|
|
778
|
+
const color = grayColor(pool);
|
|
779
|
+
const opacity = (pool.float() * 0.5 + 0.1).toFixed(2);
|
|
780
|
+
dots += `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${color}" opacity="${opacity}"/>`;
|
|
781
|
+
}
|
|
782
|
+
return dots;
|
|
783
|
+
}
|
|
784
|
+
function renderWavePath(pool) {
|
|
785
|
+
const amplitude = pool.int(3, 8);
|
|
786
|
+
const frequency = pool.float() * 0.08 + 0.04;
|
|
787
|
+
const phaseShift = pool.float() * Math.PI * 2;
|
|
788
|
+
const strokeColor = grayColor(pool);
|
|
789
|
+
let d = `M 0 ${SVG_HEIGHT / 2}`;
|
|
790
|
+
for (let x = 1; x <= SVG_WIDTH; x += 2) {
|
|
791
|
+
const y = SVG_HEIGHT / 2 + amplitude * Math.sin(frequency * x + phaseShift);
|
|
792
|
+
d += ` L ${x} ${y.toFixed(2)}`;
|
|
793
|
+
}
|
|
794
|
+
return `<path d="${d}" stroke="${strokeColor}" stroke-width="1.5" fill="none" opacity="0.35"/>`;
|
|
795
|
+
}
|
|
796
|
+
function buildSvg(pool, text, difficulty) {
|
|
797
|
+
const charCount = text.length;
|
|
798
|
+
const spacing = SVG_WIDTH / (charCount + 1);
|
|
799
|
+
const baseY = SVG_HEIGHT / 2 + 8;
|
|
800
|
+
const noiseLineCount = difficulty === "easy" ? 4 : difficulty === "medium" ? 7 : 10;
|
|
801
|
+
const noiseDotCount = difficulty === "easy" ? 20 : difficulty === "medium" ? 40 : 60;
|
|
802
|
+
const waveCount = difficulty === "easy" ? 1 : difficulty === "medium" ? 2 : 3;
|
|
803
|
+
const bgGray = pool.int(235, 250);
|
|
804
|
+
const background = `<rect width="${SVG_WIDTH}" height="${SVG_HEIGHT}" fill="rgb(${bgGray},${bgGray},${bgGray})" rx="6"/>`;
|
|
805
|
+
let chars = "";
|
|
806
|
+
for (let i = 0; i < charCount; i++) {
|
|
807
|
+
const x = Math.round(spacing * (i + 1));
|
|
808
|
+
const color = darkColor(pool);
|
|
809
|
+
chars += renderChar(pool, text[i], x, baseY, color);
|
|
810
|
+
}
|
|
811
|
+
let waves = "";
|
|
812
|
+
for (let i = 0; i < waveCount; i++) {
|
|
813
|
+
waves += renderWavePath(pool);
|
|
814
|
+
}
|
|
815
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${SVG_WIDTH}" height="${SVG_HEIGHT}" viewBox="0 0 ${SVG_WIDTH} ${SVG_HEIGHT}">${background}${renderNoiseDots(pool, noiseDotCount)}${renderNoiseLines(pool, noiseLineCount)}${waves}${chars}</svg>`;
|
|
816
|
+
}
|
|
817
|
+
function svgToDataUri(svg) {
|
|
818
|
+
const bytes = new TextEncoder().encode(svg);
|
|
819
|
+
const binString = Array.from(bytes, (b) => String.fromCodePoint(b)).join("");
|
|
820
|
+
return `data:image/svg+xml;base64,${btoa(binString)}`;
|
|
821
|
+
}
|
|
822
|
+
var ImageGenerator = class {
|
|
823
|
+
static generate(difficulty) {
|
|
824
|
+
const pool = new RandomPool(2048);
|
|
825
|
+
const charPool = difficulty === "easy" ? CHAR_POOL_EASY : difficulty === "medium" ? CHAR_POOL_MEDIUM : CHAR_POOL_HARD;
|
|
826
|
+
const length = difficulty === "easy" ? 4 : difficulty === "medium" ? 5 : 6;
|
|
827
|
+
const answer = generateText(pool, charPool, length);
|
|
828
|
+
const svg = buildSvg(pool, answer, difficulty);
|
|
829
|
+
const image = svgToDataUri(svg);
|
|
830
|
+
return {
|
|
831
|
+
image,
|
|
832
|
+
answer: answer.toLowerCase(),
|
|
833
|
+
question: "Type the characters shown in the image"
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
};
|
|
837
|
+
|
|
838
|
+
// src/utils/emojiGenerator.ts
|
|
839
|
+
var CATEGORIES = {
|
|
840
|
+
animals: ["\u{1F436}", "\u{1F431}", "\u{1F42D}", "\u{1F439}", "\u{1F430}", "\u{1F98A}", "\u{1F43B}", "\u{1F43C}", "\u{1F428}", "\u{1F42F}", "\u{1F981}", "\u{1F42E}", "\u{1F437}", "\u{1F438}", "\u{1F435}", "\u{1F992}", "\u{1F993}", "\u{1F418}", "\u{1F427}", "\u{1F985}"],
|
|
841
|
+
food: ["\u{1F34E}", "\u{1F34A}", "\u{1F34B}", "\u{1F347}", "\u{1F353}", "\u{1F354}", "\u{1F355}", "\u{1F35C}", "\u{1F363}", "\u{1F369}", "\u{1F382}", "\u{1F966}", "\u{1F955}", "\u{1F951}", "\u{1F9C0}", "\u{1F366}", "\u{1F36B}", "\u{1F950}", "\u{1F32E}", "\u{1F371}"],
|
|
842
|
+
vehicles: ["\u{1F697}", "\u{1F695}", "\u{1F699}", "\u{1F68C}", "\u{1F691}", "\u{1F692}", "\u{1F693}", "\u{1F69C}", "\u{1F3CE}", "\u{1F682}", "\u{1F681}", "\u{1F6E9}", "\u{1F680}", "\u{1F6F8}", "\u{1F6A2}", "\u26F5", "\u{1F6B2}", "\u{1F6F5}", "\u{1F3CD}", "\u{1F69B}"],
|
|
843
|
+
nature: ["\u{1F338}", "\u{1F33A}", "\u{1F33B}", "\u{1F339}", "\u{1F337}", "\u{1F340}", "\u{1F33F}", "\u{1F331}", "\u{1F332}", "\u{1F333}", "\u{1F341}", "\u{1F342}", "\u{1F30A}", "\u{1F319}", "\u2B50", "\u{1F308}", "\u{1F31E}", "\u{1F30B}", "\u{1F3D4}", "\u{1F33E}"],
|
|
844
|
+
sports: ["\u26BD", "\u{1F3C0}", "\u{1F3C8}", "\u26BE", "\u{1F3BE}", "\u{1F3D0}", "\u{1F3C9}", "\u{1F3B1}", "\u{1F3D3}", "\u{1F3F8}", "\u{1F94A}", "\u{1F3BF}", "\u{1F6F7}", "\u26F7", "\u{1F938}", "\u{1F6B4}", "\u{1F3CA}", "\u{1F93A}", "\u{1F94B}", "\u{1F3AF}"]
|
|
845
|
+
};
|
|
846
|
+
var CATEGORY_KEYS = Object.keys(CATEGORIES);
|
|
847
|
+
function secureIndex(max) {
|
|
848
|
+
const buf = randomBytes(4);
|
|
849
|
+
return buf.readUInt32LE(0) % max;
|
|
850
|
+
}
|
|
851
|
+
function pickUnique(pool, count) {
|
|
852
|
+
const available = pool.slice();
|
|
853
|
+
const result = [];
|
|
854
|
+
for (let i = 0; i < count && available.length > 0; i++) {
|
|
855
|
+
const idx = secureIndex(available.length);
|
|
856
|
+
result.push(available.splice(idx, 1)[0]);
|
|
857
|
+
}
|
|
858
|
+
return result;
|
|
859
|
+
}
|
|
860
|
+
function shuffleInPlace(arr) {
|
|
861
|
+
const batchBuf = randomBytes(arr.length * 4);
|
|
862
|
+
for (let i = arr.length - 1; i > 0; i--) {
|
|
863
|
+
const j = batchBuf.readUInt32LE((arr.length - 1 - i) * 4) % (i + 1);
|
|
864
|
+
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
var EmojiGenerator = class {
|
|
868
|
+
static generate(difficulty) {
|
|
869
|
+
const targetCount = difficulty === "easy" ? 2 : difficulty === "medium" ? 3 : 4;
|
|
870
|
+
const distractorCount = difficulty === "easy" ? 2 : difficulty === "medium" ? 3 : 4;
|
|
871
|
+
const totalCount = targetCount + distractorCount;
|
|
872
|
+
const categoryKey = CATEGORY_KEYS[secureIndex(CATEGORY_KEYS.length)];
|
|
873
|
+
const targetPool = CATEGORIES[categoryKey];
|
|
874
|
+
const distractorPool = [];
|
|
875
|
+
for (const key of CATEGORY_KEYS) {
|
|
876
|
+
if (key !== categoryKey) {
|
|
877
|
+
distractorPool.push(...CATEGORIES[key]);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
const targets = pickUnique(targetPool, targetCount);
|
|
881
|
+
const distractors = pickUnique(distractorPool, distractorCount);
|
|
882
|
+
const combined = [...targets, ...distractors];
|
|
883
|
+
shuffleInPlace(combined);
|
|
884
|
+
const targetSet = new Set(targets);
|
|
885
|
+
const targetIndices = combined.map((e, i) => targetSet.has(e) ? i : -1).filter((i) => i !== -1).sort((a, b) => a - b);
|
|
886
|
+
const answer = targetIndices.join(",");
|
|
887
|
+
return {
|
|
888
|
+
emojis: combined,
|
|
889
|
+
category: categoryKey,
|
|
890
|
+
answer,
|
|
891
|
+
question: `Select all ${categoryKey} from the list (${totalCount} emojis, ${targetCount} correct)`
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
};
|
|
895
|
+
|
|
896
|
+
// src/core/captchaGenerator.ts
|
|
897
|
+
var NONCE_STORE_MAX = 1e4;
|
|
898
|
+
var CaptchaGenerator = class {
|
|
899
|
+
standardOptions = null;
|
|
900
|
+
customOptions = null;
|
|
901
|
+
customGenerator = null;
|
|
902
|
+
// Keyed by nonce; stores the full server-side record including answer hash and salt.
|
|
903
|
+
// answer, hashedAnswer and salt are never included in the public CaptchaChallenge.
|
|
904
|
+
store = /* @__PURE__ */ new Map();
|
|
905
|
+
// set up the generator and check custom questions if they exist
|
|
906
|
+
constructor(options) {
|
|
907
|
+
if (this.isCustomOptions(options)) {
|
|
908
|
+
this.customOptions = options;
|
|
909
|
+
const validation = CustomQuestionValidator.validate(options.questions);
|
|
910
|
+
if (!validation.valid) {
|
|
911
|
+
throw new Error(`Invalid custom questions: ${validation.error}`);
|
|
912
|
+
}
|
|
913
|
+
const sanitized = CustomQuestionValidator.sanitize(options.questions);
|
|
914
|
+
this.customGenerator = new CustomQuestionGenerator(sanitized);
|
|
915
|
+
} else {
|
|
916
|
+
this.standardOptions = options;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
// check if we got custom captcha options
|
|
920
|
+
isCustomOptions(options) {
|
|
921
|
+
if (typeof options !== "object" || options === null) {
|
|
922
|
+
return false;
|
|
923
|
+
}
|
|
924
|
+
const opt = options;
|
|
925
|
+
return opt.type === "custom" && Array.isArray(opt.questions);
|
|
926
|
+
}
|
|
927
|
+
getDifficulty(override) {
|
|
928
|
+
if (override) {
|
|
929
|
+
return override;
|
|
930
|
+
}
|
|
931
|
+
if (!this.standardOptions) {
|
|
932
|
+
return "easy";
|
|
933
|
+
}
|
|
934
|
+
return this.standardOptions.difficulty;
|
|
935
|
+
}
|
|
936
|
+
// Atomically fetches and removes the stored challenge by nonce.
|
|
937
|
+
// Single-use semantics: the challenge is invalidated on the first attempt (success or failure).
|
|
938
|
+
// Callers must re-generate a new challenge after any validation attempt.
|
|
939
|
+
consume(nonce) {
|
|
940
|
+
const record = this.store.get(nonce);
|
|
941
|
+
if (record !== void 0) {
|
|
942
|
+
this.store.delete(nonce);
|
|
943
|
+
}
|
|
944
|
+
return record;
|
|
945
|
+
}
|
|
946
|
+
// Removes all entries whose expiry has passed to free memory proactively.
|
|
947
|
+
// Called on every generate() to prevent the store from filling with stale records.
|
|
948
|
+
pruneExpired() {
|
|
949
|
+
const now = Date.now();
|
|
950
|
+
for (const [key, record] of this.store) {
|
|
951
|
+
if (now > record.expiry) {
|
|
952
|
+
this.store.delete(key);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
// Stores the full record server-side; returns a public CaptchaChallenge
|
|
957
|
+
// that is safe to send to the client (no answer, hashedAnswer or salt).
|
|
958
|
+
createChallenge(base) {
|
|
959
|
+
this.pruneExpired();
|
|
960
|
+
let nonce;
|
|
961
|
+
do {
|
|
962
|
+
nonce = Random.generateNonce();
|
|
963
|
+
} while (this.store.has(nonce));
|
|
964
|
+
if (this.store.size >= NONCE_STORE_MAX) {
|
|
965
|
+
const oldest = this.store.keys().next().value;
|
|
966
|
+
if (oldest !== void 0) {
|
|
967
|
+
this.store.delete(oldest);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
const expiry = Date.now() + 5 * 60 * 1e3;
|
|
971
|
+
const salt = Random.generateSalt();
|
|
972
|
+
const answerStr = typeof base.answer === "number" ? Number.isInteger(base.answer) ? base.answer.toString() : base.answer.toFixed(2) : base.answer.toString();
|
|
973
|
+
const hashedAnswer = createHash("sha256").update(answerStr + salt).digest("hex");
|
|
974
|
+
const stored = { ...base, nonce, expiry, hashedAnswer, salt };
|
|
975
|
+
this.store.set(nonce, stored);
|
|
976
|
+
const { answer: _answer, hashedAnswer: _ha, salt: _salt, steps: rawSteps, ...rest } = stored;
|
|
977
|
+
const publicChallenge = { ...rest };
|
|
978
|
+
if (rawSteps && rawSteps.length > 0) {
|
|
979
|
+
publicChallenge.steps = rawSteps.map(({ answer: _a, hashedAnswer: _h, salt: _s, steps: _nested, ...pub }) => pub);
|
|
980
|
+
}
|
|
981
|
+
return publicChallenge;
|
|
982
|
+
}
|
|
983
|
+
// evaluate arithmetic expression for the math captcha type
|
|
984
|
+
calculateMath(a, op, b) {
|
|
985
|
+
if (op === "+") {
|
|
986
|
+
return a + b;
|
|
987
|
+
}
|
|
988
|
+
if (op === "-") {
|
|
989
|
+
return a - b;
|
|
990
|
+
}
|
|
991
|
+
if (op === "*") {
|
|
992
|
+
return a * b;
|
|
993
|
+
}
|
|
994
|
+
if (op === "/") {
|
|
995
|
+
if (b === 0) {
|
|
996
|
+
return Number.NaN;
|
|
997
|
+
}
|
|
998
|
+
const raw = a / b;
|
|
999
|
+
return parseFloat(raw.toFixed(2));
|
|
1000
|
+
}
|
|
1001
|
+
return 0;
|
|
1002
|
+
}
|
|
1003
|
+
// dispatch to the correct generator based on configured captcha type
|
|
1004
|
+
generate(difficulty) {
|
|
1005
|
+
if (this.customOptions) {
|
|
1006
|
+
return this.generateCustom();
|
|
1007
|
+
}
|
|
1008
|
+
if (!this.standardOptions) {
|
|
1009
|
+
throw new Error("Generator not properly initialized");
|
|
1010
|
+
}
|
|
1011
|
+
const captchaType = this.standardOptions.type;
|
|
1012
|
+
if (captchaType === "math") {
|
|
1013
|
+
return this.generateMath(difficulty);
|
|
1014
|
+
}
|
|
1015
|
+
if (captchaType === "text") {
|
|
1016
|
+
return this.generateText(difficulty);
|
|
1017
|
+
}
|
|
1018
|
+
if (captchaType === "sequence") {
|
|
1019
|
+
return this.generateSequence(difficulty);
|
|
1020
|
+
}
|
|
1021
|
+
if (captchaType === "scramble") {
|
|
1022
|
+
return this.generateScramble(difficulty);
|
|
1023
|
+
}
|
|
1024
|
+
if (captchaType === "reverse") {
|
|
1025
|
+
return this.generateReverse(difficulty);
|
|
1026
|
+
}
|
|
1027
|
+
if (captchaType === "multi") {
|
|
1028
|
+
return this.generateMulti(difficulty);
|
|
1029
|
+
}
|
|
1030
|
+
if (captchaType === "image") {
|
|
1031
|
+
return this.generateImage(difficulty);
|
|
1032
|
+
}
|
|
1033
|
+
if (captchaType === "emoji") {
|
|
1034
|
+
return this.generateEmoji(difficulty);
|
|
1035
|
+
}
|
|
1036
|
+
return this.generateMixed(difficulty);
|
|
1037
|
+
}
|
|
1038
|
+
generateCustom() {
|
|
1039
|
+
if (!this.customGenerator) {
|
|
1040
|
+
throw new Error("Custom generator not initialized");
|
|
1041
|
+
}
|
|
1042
|
+
const custom = this.customGenerator.generate();
|
|
1043
|
+
if (!custom.question || !custom.answer) {
|
|
1044
|
+
throw new Error("Custom question pool returned an empty question or answer");
|
|
1045
|
+
}
|
|
1046
|
+
return this.createChallenge({ type: "custom", question: custom.question, answer: custom.answer });
|
|
1047
|
+
}
|
|
1048
|
+
generateMath(diffOverride) {
|
|
1049
|
+
const difficulty = this.getDifficulty(diffOverride);
|
|
1050
|
+
let num1 = Random.getRandomNumber(difficulty);
|
|
1051
|
+
let num2 = Random.getRandomNumber(difficulty);
|
|
1052
|
+
const operator = Random.getRandomOperator();
|
|
1053
|
+
if (operator === "/" && num2 === 0) {
|
|
1054
|
+
num2 = Random.getRandomNumber(difficulty) + 1;
|
|
1055
|
+
}
|
|
1056
|
+
const answer = this.calculateMath(num1, operator, num2);
|
|
1057
|
+
if (isNaN(answer) || !isFinite(answer)) {
|
|
1058
|
+
num1 = Random.getRandomNumber(difficulty);
|
|
1059
|
+
num2 = Random.getRandomNumber(difficulty) + 1;
|
|
1060
|
+
return this.createChallenge({
|
|
1061
|
+
type: "math",
|
|
1062
|
+
question: `${num1} + ${num2}`,
|
|
1063
|
+
answer: num1 + num2
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
return this.createChallenge({ type: "math", question: `${num1} ${operator} ${num2}`, answer });
|
|
1067
|
+
}
|
|
1068
|
+
generateText(diffOverride) {
|
|
1069
|
+
const text = Random.generateRandomString(this.getDifficulty(diffOverride));
|
|
1070
|
+
return this.createChallenge({ type: "text", question: text, answer: text });
|
|
1071
|
+
}
|
|
1072
|
+
generateSequence(diffOverride) {
|
|
1073
|
+
const seq = SequenceGenerator.generate(this.getDifficulty(diffOverride));
|
|
1074
|
+
return this.createChallenge({ type: "sequence", question: seq.question, answer: seq.answer });
|
|
1075
|
+
}
|
|
1076
|
+
generateScramble(diffOverride) {
|
|
1077
|
+
const scr = ScrambleGenerator.generate(this.getDifficulty(diffOverride));
|
|
1078
|
+
return this.createChallenge({ type: "scramble", question: scr.question, answer: scr.answer });
|
|
1079
|
+
}
|
|
1080
|
+
generateReverse(diffOverride) {
|
|
1081
|
+
const rev = ReverseGenerator.generate(this.getDifficulty(diffOverride));
|
|
1082
|
+
return this.createChallenge({ type: "reverse", question: rev.question, answer: rev.answer });
|
|
1083
|
+
}
|
|
1084
|
+
// randomly selects one of the available non-compound types
|
|
1085
|
+
generateMixed(diffOverride) {
|
|
1086
|
+
const types = ["math", "text", "sequence", "scramble", "reverse"];
|
|
1087
|
+
const buffer = randomBytes(1);
|
|
1088
|
+
const randomType = types[buffer[0] % types.length];
|
|
1089
|
+
let question;
|
|
1090
|
+
let answer;
|
|
1091
|
+
if (randomType === "math") {
|
|
1092
|
+
const difficulty = this.getDifficulty(diffOverride);
|
|
1093
|
+
let num1 = Random.getRandomNumber(difficulty);
|
|
1094
|
+
let num2 = Random.getRandomNumber(difficulty);
|
|
1095
|
+
const operator = Random.getRandomOperator();
|
|
1096
|
+
if (operator === "/" && num2 === 0) {
|
|
1097
|
+
num2 = Random.getRandomNumber(difficulty) + 1;
|
|
1098
|
+
}
|
|
1099
|
+
const result = this.calculateMath(num1, operator, num2);
|
|
1100
|
+
if (isNaN(result) || !isFinite(result)) {
|
|
1101
|
+
num1 = Random.getRandomNumber(difficulty);
|
|
1102
|
+
num2 = Random.getRandomNumber(difficulty) + 1;
|
|
1103
|
+
question = `${num1} + ${num2}`;
|
|
1104
|
+
answer = num1 + num2;
|
|
1105
|
+
} else {
|
|
1106
|
+
question = `${num1} ${operator} ${num2}`;
|
|
1107
|
+
answer = result;
|
|
1108
|
+
}
|
|
1109
|
+
} else if (randomType === "text") {
|
|
1110
|
+
const text = Random.generateRandomString(this.getDifficulty(diffOverride));
|
|
1111
|
+
question = text;
|
|
1112
|
+
answer = text;
|
|
1113
|
+
} else if (randomType === "sequence") {
|
|
1114
|
+
const seq = SequenceGenerator.generate(this.getDifficulty(diffOverride));
|
|
1115
|
+
question = seq.question;
|
|
1116
|
+
answer = seq.answer;
|
|
1117
|
+
} else if (randomType === "scramble") {
|
|
1118
|
+
const scr = ScrambleGenerator.generate(this.getDifficulty(diffOverride));
|
|
1119
|
+
question = scr.question;
|
|
1120
|
+
answer = scr.answer;
|
|
1121
|
+
} else {
|
|
1122
|
+
const rev = ReverseGenerator.generate(this.getDifficulty(diffOverride));
|
|
1123
|
+
question = rev.question;
|
|
1124
|
+
answer = rev.answer;
|
|
1125
|
+
}
|
|
1126
|
+
return this.createChallenge({ type: "mixed", question, answer });
|
|
1127
|
+
}
|
|
1128
|
+
// two-step captcha: math + scramble, both must be solved correctly.
|
|
1129
|
+
// Child challenges are built via the same salt/hash pipeline but are NOT inserted into the
|
|
1130
|
+
// nonce store independently, so they cannot be validated as standalone challenges.
|
|
1131
|
+
generateMulti(diffOverride) {
|
|
1132
|
+
const mathRaw = this.buildMathRecord(diffOverride);
|
|
1133
|
+
const scrambleRaw = this.buildScrambleRecord(diffOverride);
|
|
1134
|
+
return this.createChallenge({
|
|
1135
|
+
type: "multi",
|
|
1136
|
+
question: "Complete two steps",
|
|
1137
|
+
answer: "",
|
|
1138
|
+
steps: [mathRaw, scrambleRaw]
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
// Builds a StoredChallenge for a math question WITHOUT registering it in the nonce store.
|
|
1142
|
+
buildMathRecord(diffOverride) {
|
|
1143
|
+
const difficulty = this.getDifficulty(diffOverride);
|
|
1144
|
+
let num1 = Random.getRandomNumber(difficulty);
|
|
1145
|
+
let num2 = Random.getRandomNumber(difficulty);
|
|
1146
|
+
const operator = Random.getRandomOperator();
|
|
1147
|
+
if (operator === "/" && num2 === 0) {
|
|
1148
|
+
num2 = Random.getRandomNumber(difficulty) + 1;
|
|
1149
|
+
}
|
|
1150
|
+
let answer = this.calculateMath(num1, operator, num2);
|
|
1151
|
+
if (isNaN(answer) || !isFinite(answer)) {
|
|
1152
|
+
num1 = Random.getRandomNumber(difficulty);
|
|
1153
|
+
num2 = Random.getRandomNumber(difficulty) + 1;
|
|
1154
|
+
answer = num1 + num2;
|
|
1155
|
+
}
|
|
1156
|
+
return this.buildStoredRecord({ type: "math", question: `${num1} ${operator} ${num2}`, answer });
|
|
1157
|
+
}
|
|
1158
|
+
// Builds a StoredChallenge for a scramble question WITHOUT registering it in the nonce store.
|
|
1159
|
+
buildScrambleRecord(diffOverride) {
|
|
1160
|
+
const scr = ScrambleGenerator.generate(this.getDifficulty(diffOverride));
|
|
1161
|
+
return this.buildStoredRecord({ type: "scramble", question: scr.question, answer: scr.answer });
|
|
1162
|
+
}
|
|
1163
|
+
// Hashes and packages a challenge record but does NOT add it to the nonce store.
|
|
1164
|
+
// Used for child steps that must not be independently redeemable.
|
|
1165
|
+
buildStoredRecord(base) {
|
|
1166
|
+
const nonce = Random.generateNonce();
|
|
1167
|
+
const expiry = Date.now() + 5 * 60 * 1e3;
|
|
1168
|
+
const salt = Random.generateSalt();
|
|
1169
|
+
const answerStr = typeof base.answer === "number" ? Number.isInteger(base.answer) ? base.answer.toString() : base.answer.toFixed(2) : base.answer.toString();
|
|
1170
|
+
const hashedAnswer = createHash("sha256").update(answerStr + salt).digest("hex");
|
|
1171
|
+
return { ...base, nonce, expiry, hashedAnswer, salt };
|
|
1172
|
+
}
|
|
1173
|
+
// generates an SVG-based visual CAPTCHA immune to trivial OCR attacks
|
|
1174
|
+
generateImage(diffOverride) {
|
|
1175
|
+
const result = ImageGenerator.generate(this.getDifficulty(diffOverride));
|
|
1176
|
+
return this.createChallenge({
|
|
1177
|
+
type: "image",
|
|
1178
|
+
question: result.question,
|
|
1179
|
+
answer: result.answer,
|
|
1180
|
+
image: result.image
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
// generates an emoji selection CAPTCHA: user picks all emojis from a given category
|
|
1184
|
+
generateEmoji(diffOverride) {
|
|
1185
|
+
const result = EmojiGenerator.generate(this.getDifficulty(diffOverride));
|
|
1186
|
+
return this.createChallenge({
|
|
1187
|
+
type: "emoji",
|
|
1188
|
+
question: result.question,
|
|
1189
|
+
answer: result.answer,
|
|
1190
|
+
emojis: result.emojis,
|
|
1191
|
+
category: result.category
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
};
|
|
1195
|
+
|
|
1196
|
+
// src/core/captchaValidator.ts
|
|
1197
|
+
function hexToBytes(hex) {
|
|
1198
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
1199
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
1200
|
+
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
1201
|
+
}
|
|
1202
|
+
return bytes;
|
|
1203
|
+
}
|
|
1204
|
+
var CaptchaValidator = class {
|
|
1205
|
+
static MAX_INPUT_LENGTH = 1e3;
|
|
1206
|
+
static VALID_CHAR_REGEX = /^[a-zA-Z0-9\s\-çÇğĞıİöÖşŞüÜ.,'!?]*$/;
|
|
1207
|
+
// main validation entry point, routes to the correct validator based on challenge type
|
|
1208
|
+
static validate(challenge, userInput) {
|
|
1209
|
+
if (!this.isValidInput(userInput)) {
|
|
1210
|
+
return false;
|
|
1211
|
+
}
|
|
1212
|
+
if (challenge.type === "multi") {
|
|
1213
|
+
return this.validateMulti(challenge, userInput);
|
|
1214
|
+
}
|
|
1215
|
+
if (challenge.type === "custom") {
|
|
1216
|
+
return this.validateCustom(challenge, userInput);
|
|
1217
|
+
}
|
|
1218
|
+
if (challenge.type === "image") {
|
|
1219
|
+
return this.validateImage(challenge, userInput);
|
|
1220
|
+
}
|
|
1221
|
+
if (challenge.type === "emoji") {
|
|
1222
|
+
return this.validateEmoji(challenge, userInput);
|
|
1223
|
+
}
|
|
1224
|
+
if (this.isNumericChallenge(challenge)) {
|
|
1225
|
+
return this.validateNumeric(challenge, userInput);
|
|
1226
|
+
}
|
|
1227
|
+
return this.validateText(challenge, userInput);
|
|
1228
|
+
}
|
|
1229
|
+
static isValidInput(input) {
|
|
1230
|
+
if (typeof input !== "string") {
|
|
1231
|
+
return false;
|
|
1232
|
+
}
|
|
1233
|
+
if (input.length === 0 || input.length > this.MAX_INPUT_LENGTH) {
|
|
1234
|
+
return false;
|
|
1235
|
+
}
|
|
1236
|
+
return true;
|
|
1237
|
+
}
|
|
1238
|
+
// validates multi step captchas by checking each step individually
|
|
1239
|
+
static validateMulti(challenge, userInput) {
|
|
1240
|
+
if (!challenge.steps || challenge.steps.length === 0) {
|
|
1241
|
+
return false;
|
|
1242
|
+
}
|
|
1243
|
+
let parsed;
|
|
1244
|
+
try {
|
|
1245
|
+
parsed = JSON.parse(userInput);
|
|
1246
|
+
} catch {
|
|
1247
|
+
return false;
|
|
1248
|
+
}
|
|
1249
|
+
if (!Array.isArray(parsed) || parsed.length !== challenge.steps.length) {
|
|
1250
|
+
return false;
|
|
1251
|
+
}
|
|
1252
|
+
for (let i = 0; i < challenge.steps.length; i++) {
|
|
1253
|
+
const step = challenge.steps[i];
|
|
1254
|
+
const input = parsed[i];
|
|
1255
|
+
if (!step || typeof input !== "string") {
|
|
1256
|
+
return false;
|
|
1257
|
+
}
|
|
1258
|
+
if (!this.validate(step, input)) {
|
|
1259
|
+
return false;
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
return true;
|
|
1263
|
+
}
|
|
1264
|
+
static isNumericChallenge(challenge) {
|
|
1265
|
+
return challenge.type === "math" || challenge.type === "sequence" && typeof challenge.answer === "number" || challenge.type === "mixed" && typeof challenge.answer === "number";
|
|
1266
|
+
}
|
|
1267
|
+
static validateNumeric(challenge, userInput) {
|
|
1268
|
+
if (!/^-?(\d+(\.\d+)?|\.\d+)$/.test(userInput.trim())) {
|
|
1269
|
+
return false;
|
|
1270
|
+
}
|
|
1271
|
+
const inputNum = parseFloat(userInput.trim());
|
|
1272
|
+
if (isNaN(inputNum) || !isFinite(inputNum)) {
|
|
1273
|
+
return false;
|
|
1274
|
+
}
|
|
1275
|
+
const canonical = Number.isInteger(inputNum) ? inputNum.toString() : inputNum.toFixed(2);
|
|
1276
|
+
return this.verifyAnswer(challenge, canonical);
|
|
1277
|
+
}
|
|
1278
|
+
static validateText(challenge, userInput) {
|
|
1279
|
+
const sanitized = userInput.trim();
|
|
1280
|
+
if (!this.VALID_CHAR_REGEX.test(sanitized)) {
|
|
1281
|
+
return false;
|
|
1282
|
+
}
|
|
1283
|
+
return this.verifyAnswer(challenge, sanitized);
|
|
1284
|
+
}
|
|
1285
|
+
// hash the user input with the same salt and compare using a constant-time
|
|
1286
|
+
// equality check to eliminate timing side-channels
|
|
1287
|
+
static verifyAnswer(challenge, userInput) {
|
|
1288
|
+
const userHash = createHash("sha256").update(userInput + challenge.salt).digest("hex");
|
|
1289
|
+
if (userHash.length !== challenge.hashedAnswer.length) {
|
|
1290
|
+
return false;
|
|
1291
|
+
}
|
|
1292
|
+
return timingSafeEqual(
|
|
1293
|
+
hexToBytes(userHash),
|
|
1294
|
+
hexToBytes(challenge.hashedAnswer)
|
|
1295
|
+
);
|
|
1296
|
+
}
|
|
1297
|
+
static validateCustom(challenge, userInput) {
|
|
1298
|
+
const sanitized = userInput.trim();
|
|
1299
|
+
if (!this.VALID_CHAR_REGEX.test(sanitized)) {
|
|
1300
|
+
return false;
|
|
1301
|
+
}
|
|
1302
|
+
return this.verifyAnswer(challenge, sanitized);
|
|
1303
|
+
}
|
|
1304
|
+
// image answers are case-insensitive; only alphanumeric chars are accepted
|
|
1305
|
+
static validateImage(challenge, userInput) {
|
|
1306
|
+
const sanitized = userInput.trim().toLowerCase();
|
|
1307
|
+
if (!/^[a-z0-9]+$/.test(sanitized)) {
|
|
1308
|
+
return false;
|
|
1309
|
+
}
|
|
1310
|
+
if (sanitized.length < 1 || sanitized.length > 20) {
|
|
1311
|
+
return false;
|
|
1312
|
+
}
|
|
1313
|
+
return this.verifyAnswer(challenge, sanitized);
|
|
1314
|
+
}
|
|
1315
|
+
// emoji answers: comma-separated zero-based indices e.g. "0,2,4"
|
|
1316
|
+
// parsed, deduplicated, sorted numerically then re-joined to produce the canonical form
|
|
1317
|
+
static validateEmoji(challenge, userInput) {
|
|
1318
|
+
const trimmed = userInput.trim();
|
|
1319
|
+
if (!/^[0-9,]+$/.test(trimmed)) {
|
|
1320
|
+
return false;
|
|
1321
|
+
}
|
|
1322
|
+
const parts = trimmed.split(",").filter((s) => s.length > 0);
|
|
1323
|
+
if (parts.length === 0 || parts.length > 20) {
|
|
1324
|
+
return false;
|
|
1325
|
+
}
|
|
1326
|
+
const indices = parts.map(Number);
|
|
1327
|
+
if (indices.some((n) => isNaN(n) || n < 0 || !Number.isInteger(n))) {
|
|
1328
|
+
return false;
|
|
1329
|
+
}
|
|
1330
|
+
const normalized = [...new Set(indices)].sort((a, b) => a - b).join(",");
|
|
1331
|
+
return this.verifyAnswer(challenge, normalized);
|
|
1332
|
+
}
|
|
1333
|
+
};
|
|
1334
|
+
|
|
1335
|
+
// src/core/adaptiveTracker.ts
|
|
1336
|
+
var WINDOW_SIZE = 10;
|
|
1337
|
+
var MIN_ATTEMPTS_BEFORE_ADJUST = 3;
|
|
1338
|
+
var SESSION_TTL_MS = 30 * 60 * 1e3;
|
|
1339
|
+
var MAX_SESSIONS = 1e4;
|
|
1340
|
+
var DIFFICULTY_ORDER = ["easy", "medium", "hard"];
|
|
1341
|
+
var AdaptiveTracker = class {
|
|
1342
|
+
sessions = /* @__PURE__ */ new Map();
|
|
1343
|
+
getDifficulty(sessionId) {
|
|
1344
|
+
this.pruneExpired();
|
|
1345
|
+
const session = this.sessions.get(sessionId);
|
|
1346
|
+
if (!session) {
|
|
1347
|
+
return "medium";
|
|
1348
|
+
}
|
|
1349
|
+
return session.currentDifficulty;
|
|
1350
|
+
}
|
|
1351
|
+
recordAttempt(sessionId, success) {
|
|
1352
|
+
this.pruneExpired();
|
|
1353
|
+
let session = this.sessions.get(sessionId);
|
|
1354
|
+
if (!session) {
|
|
1355
|
+
session = this.createSession();
|
|
1356
|
+
if (this.sessions.size >= MAX_SESSIONS) {
|
|
1357
|
+
this.evictOldest();
|
|
1358
|
+
}
|
|
1359
|
+
this.sessions.set(sessionId, session);
|
|
1360
|
+
}
|
|
1361
|
+
const attempt = {
|
|
1362
|
+
timestamp: Date.now(),
|
|
1363
|
+
success
|
|
1364
|
+
};
|
|
1365
|
+
session.attempts.push(attempt);
|
|
1366
|
+
if (session.attempts.length > WINDOW_SIZE) {
|
|
1367
|
+
session.attempts = session.attempts.slice(-WINDOW_SIZE);
|
|
1368
|
+
}
|
|
1369
|
+
session.attemptsSinceAdjustment++;
|
|
1370
|
+
if (session.attemptsSinceAdjustment >= MIN_ATTEMPTS_BEFORE_ADJUST) {
|
|
1371
|
+
this.adjustDifficulty(session);
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
getSession(sessionId) {
|
|
1375
|
+
return this.sessions.get(sessionId);
|
|
1376
|
+
}
|
|
1377
|
+
clearSession(sessionId) {
|
|
1378
|
+
return this.sessions.delete(sessionId);
|
|
1379
|
+
}
|
|
1380
|
+
clearAll() {
|
|
1381
|
+
this.sessions.clear();
|
|
1382
|
+
}
|
|
1383
|
+
get sessionCount() {
|
|
1384
|
+
return this.sessions.size;
|
|
1385
|
+
}
|
|
1386
|
+
createSession() {
|
|
1387
|
+
return {
|
|
1388
|
+
attempts: [],
|
|
1389
|
+
currentDifficulty: "medium",
|
|
1390
|
+
lastAdjustment: Date.now(),
|
|
1391
|
+
attemptsSinceAdjustment: 0
|
|
1392
|
+
};
|
|
1393
|
+
}
|
|
1394
|
+
adjustDifficulty(session) {
|
|
1395
|
+
const recentAttempts = session.attempts.slice(-WINDOW_SIZE);
|
|
1396
|
+
if (recentAttempts.length < MIN_ATTEMPTS_BEFORE_ADJUST) {
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
const successCount = recentAttempts.filter((a) => a.success).length;
|
|
1400
|
+
const successRate = successCount / recentAttempts.length;
|
|
1401
|
+
const currentIndex = DIFFICULTY_ORDER.indexOf(session.currentDifficulty);
|
|
1402
|
+
let newIndex = currentIndex;
|
|
1403
|
+
if (successRate >= 0.8 && currentIndex < DIFFICULTY_ORDER.length - 1) {
|
|
1404
|
+
newIndex = currentIndex + 1;
|
|
1405
|
+
} else if (successRate <= 0.4 && currentIndex > 0) {
|
|
1406
|
+
newIndex = currentIndex - 1;
|
|
1407
|
+
}
|
|
1408
|
+
if (newIndex !== currentIndex) {
|
|
1409
|
+
session.currentDifficulty = DIFFICULTY_ORDER[newIndex];
|
|
1410
|
+
session.lastAdjustment = Date.now();
|
|
1411
|
+
session.attemptsSinceAdjustment = 0;
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
pruneExpired() {
|
|
1415
|
+
const now = Date.now();
|
|
1416
|
+
for (const [key, session] of this.sessions) {
|
|
1417
|
+
const lastAttempt = session.attempts.length > 0 ? session.attempts[session.attempts.length - 1].timestamp : session.lastAdjustment;
|
|
1418
|
+
if (now - lastAttempt > SESSION_TTL_MS) {
|
|
1419
|
+
this.sessions.delete(key);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
evictOldest() {
|
|
1424
|
+
let oldestKey;
|
|
1425
|
+
let oldestTime = Infinity;
|
|
1426
|
+
for (const [key, session] of this.sessions) {
|
|
1427
|
+
const lastActivity = session.attempts.length > 0 ? session.attempts[session.attempts.length - 1].timestamp : session.lastAdjustment;
|
|
1428
|
+
if (lastActivity < oldestTime) {
|
|
1429
|
+
oldestTime = lastActivity;
|
|
1430
|
+
oldestKey = key;
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
if (oldestKey !== void 0) {
|
|
1434
|
+
this.sessions.delete(oldestKey);
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
};
|
|
1438
|
+
|
|
1439
|
+
// src/K9Guard.ts
|
|
1440
|
+
var K9Guard = class {
|
|
1441
|
+
options;
|
|
1442
|
+
generator;
|
|
1443
|
+
adaptiveTracker = null;
|
|
1444
|
+
defaultSessionId = null;
|
|
1445
|
+
constructor(options) {
|
|
1446
|
+
const processedOptions = this.processOptions(options);
|
|
1447
|
+
this.generator = new CaptchaGenerator(processedOptions);
|
|
1448
|
+
this.options = processedOptions;
|
|
1449
|
+
if (this.isAdaptive()) {
|
|
1450
|
+
this.adaptiveTracker = new AdaptiveTracker();
|
|
1451
|
+
this.defaultSessionId = options.sessionId ?? null;
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
isAdaptive() {
|
|
1455
|
+
return "difficulty" in this.options && this.options.difficulty === "adaptive";
|
|
1456
|
+
}
|
|
1457
|
+
resolveSessionId(sessionId) {
|
|
1458
|
+
const id = sessionId ?? this.defaultSessionId;
|
|
1459
|
+
if (!id) {
|
|
1460
|
+
throw new Error("sessionId is required for adaptive difficulty. Provide it in constructor or as parameter.");
|
|
1461
|
+
}
|
|
1462
|
+
return id;
|
|
1463
|
+
}
|
|
1464
|
+
processOptions(options) {
|
|
1465
|
+
if (typeof options !== "object" || options === null) {
|
|
1466
|
+
throw new Error("Options must be an object");
|
|
1467
|
+
}
|
|
1468
|
+
const opt = options;
|
|
1469
|
+
if (opt.type === "custom") {
|
|
1470
|
+
if (!Array.isArray(opt.questions)) {
|
|
1471
|
+
throw new Error("Custom type requires questions array");
|
|
1472
|
+
}
|
|
1473
|
+
const validation = CustomQuestionValidator.validate(opt.questions);
|
|
1474
|
+
if (!validation.valid) {
|
|
1475
|
+
throw new Error(`Invalid custom questions: ${validation.error}`);
|
|
1476
|
+
}
|
|
1477
|
+
return {
|
|
1478
|
+
type: "custom",
|
|
1479
|
+
questions: CustomQuestionValidator.sanitize(opt.questions),
|
|
1480
|
+
sessionId: opt.sessionId
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
const validTypes = ["math", "text", "sequence", "scramble", "reverse", "mixed", "multi", "image", "emoji"];
|
|
1484
|
+
if (!validTypes.includes(opt.type)) {
|
|
1485
|
+
throw new Error(`Invalid type. Must be one of: ${validTypes.join(", ")}`);
|
|
1486
|
+
}
|
|
1487
|
+
const validDifficulties = ["easy", "medium", "hard", "adaptive"];
|
|
1488
|
+
if (!validDifficulties.includes(opt.difficulty)) {
|
|
1489
|
+
throw new Error(`Invalid difficulty. Must be one of: ${validDifficulties.join(", ")}`);
|
|
1490
|
+
}
|
|
1491
|
+
return {
|
|
1492
|
+
type: opt.type,
|
|
1493
|
+
difficulty: opt.difficulty,
|
|
1494
|
+
sessionId: opt.sessionId
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
generate(sessionId) {
|
|
1498
|
+
if (!this.isAdaptive()) {
|
|
1499
|
+
return this.generator.generate();
|
|
1500
|
+
}
|
|
1501
|
+
const id = this.resolveSessionId(sessionId);
|
|
1502
|
+
const difficulty = this.adaptiveTracker.getDifficulty(id);
|
|
1503
|
+
return this.generator.generate(difficulty);
|
|
1504
|
+
}
|
|
1505
|
+
validate(challenge, userInput, sessionId) {
|
|
1506
|
+
if (!this.isValidChallenge(challenge)) {
|
|
1507
|
+
return false;
|
|
1508
|
+
}
|
|
1509
|
+
if (typeof userInput !== "string") {
|
|
1510
|
+
return false;
|
|
1511
|
+
}
|
|
1512
|
+
const stored = this.generator.consume(challenge.nonce);
|
|
1513
|
+
if (!stored) {
|
|
1514
|
+
return false;
|
|
1515
|
+
}
|
|
1516
|
+
if (Date.now() > stored.expiry) {
|
|
1517
|
+
return false;
|
|
1518
|
+
}
|
|
1519
|
+
const isValid = CaptchaValidator.validate(stored, userInput);
|
|
1520
|
+
if (this.isAdaptive()) {
|
|
1521
|
+
const id = this.resolveSessionId(sessionId);
|
|
1522
|
+
this.adaptiveTracker.recordAttempt(id, isValid);
|
|
1523
|
+
}
|
|
1524
|
+
return isValid;
|
|
1525
|
+
}
|
|
1526
|
+
clearSession(sessionId) {
|
|
1527
|
+
if (!this.adaptiveTracker) {
|
|
1528
|
+
return false;
|
|
1529
|
+
}
|
|
1530
|
+
return this.adaptiveTracker.clearSession(sessionId);
|
|
1531
|
+
}
|
|
1532
|
+
clearAllSessions() {
|
|
1533
|
+
var _a;
|
|
1534
|
+
(_a = this.adaptiveTracker) == null ? void 0 : _a.clearAll();
|
|
1535
|
+
}
|
|
1536
|
+
getSessionDifficulty(sessionId) {
|
|
1537
|
+
if (!this.adaptiveTracker) {
|
|
1538
|
+
return null;
|
|
1539
|
+
}
|
|
1540
|
+
return this.adaptiveTracker.getDifficulty(sessionId);
|
|
1541
|
+
}
|
|
1542
|
+
isValidChallenge(challenge) {
|
|
1543
|
+
if (typeof challenge !== "object" || challenge === null) {
|
|
1544
|
+
return false;
|
|
1545
|
+
}
|
|
1546
|
+
const c = challenge;
|
|
1547
|
+
return typeof c.type === "string" && typeof c.question === "string" && typeof c.nonce === "string" && c.nonce.length > 0 && typeof c.expiry === "number";
|
|
1548
|
+
}
|
|
1549
|
+
};
|
|
1550
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1551
|
+
0 && (module.exports = {
|
|
1552
|
+
AdaptiveTracker,
|
|
1553
|
+
CustomQuestionGenerator,
|
|
1554
|
+
CustomQuestionValidator
|
|
1555
|
+
});
|