New Captcha Package added
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export * from "./renderer.ts";
|
||||
@@ -0,0 +1,239 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { CaptchaAudioRenderer } from "../types.ts";
|
||||
|
||||
interface ParsedWav {
|
||||
sampleRate: number;
|
||||
channels: number;
|
||||
bitsPerSample: number;
|
||||
data: Uint8Array;
|
||||
}
|
||||
|
||||
function u16(view: DataView, offset: number): number {
|
||||
return view.getUint16(offset, true);
|
||||
}
|
||||
|
||||
function u32(view: DataView, offset: number): number {
|
||||
return view.getUint32(offset, true);
|
||||
}
|
||||
|
||||
function parseWav(bytes: Uint8Array): ParsedWav {
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
if (bytes.length < 44 || String.fromCharCode(...bytes.slice(0, 4)) !== "RIFF") {
|
||||
throw new Error("CAPTCHA audio asset is not a WAV file");
|
||||
}
|
||||
|
||||
let offset = 12;
|
||||
let sampleRate = 0;
|
||||
let channels = 0;
|
||||
let bitsPerSample = 0;
|
||||
let data: Uint8Array | undefined;
|
||||
|
||||
while (offset + 8 <= bytes.length) {
|
||||
const id = String.fromCharCode(...bytes.slice(offset, offset + 4));
|
||||
const size = u32(view, offset + 4);
|
||||
const start = offset + 8;
|
||||
|
||||
if (id === "fmt ") {
|
||||
const format = u16(view, start);
|
||||
if (format !== 1) throw new Error("CAPTCHA audio assets must use PCM WAV");
|
||||
channels = u16(view, start + 2);
|
||||
sampleRate = u32(view, start + 4);
|
||||
bitsPerSample = u16(view, start + 14);
|
||||
} else if (id === "data") {
|
||||
data = bytes.slice(start, start + size);
|
||||
}
|
||||
|
||||
offset = start + size + (size % 2);
|
||||
}
|
||||
|
||||
if (!sampleRate || !channels || !bitsPerSample || !data) {
|
||||
throw new Error("Invalid CAPTCHA WAV asset");
|
||||
}
|
||||
|
||||
return { sampleRate, channels, bitsPerSample, data };
|
||||
}
|
||||
|
||||
function writeAscii(target: Uint8Array, offset: number, value: string): void {
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
target[offset + index] = value.charCodeAt(index);
|
||||
}
|
||||
}
|
||||
|
||||
function createWav(
|
||||
parts: Uint8Array[],
|
||||
sampleRate: number,
|
||||
channels: number,
|
||||
bitsPerSample: number,
|
||||
): Uint8Array {
|
||||
const dataLength = parts.reduce((sum, part) => sum + part.length, 0);
|
||||
const out = new Uint8Array(44 + dataLength);
|
||||
const view = new DataView(out.buffer);
|
||||
const blockAlign = channels * (bitsPerSample / 8);
|
||||
const byteRate = sampleRate * blockAlign;
|
||||
|
||||
writeAscii(out, 0, "RIFF");
|
||||
view.setUint32(4, 36 + dataLength, true);
|
||||
writeAscii(out, 8, "WAVE");
|
||||
writeAscii(out, 12, "fmt ");
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, channels, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, byteRate, true);
|
||||
view.setUint16(32, blockAlign, true);
|
||||
view.setUint16(34, bitsPerSample, true);
|
||||
writeAscii(out, 36, "data");
|
||||
view.setUint32(40, dataLength, true);
|
||||
|
||||
let cursor = 44;
|
||||
for (const part of parts) {
|
||||
out.set(part, cursor);
|
||||
cursor += part.length;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function safeToken(value: string): string {
|
||||
const token = value.toLowerCase().trim();
|
||||
if (!/^[a-z0-9]+$/.test(token)) {
|
||||
throw new Error(`Unsupported audio token: ${value}`);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
function addCandidate(candidates: string[], value: string | undefined): void {
|
||||
if (!value) return;
|
||||
const normalized = value.trim();
|
||||
if (normalized && !candidates.includes(normalized)) candidates.push(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the bundled audio directory in source, workspace, installed-package,
|
||||
* and bundled-server layouts. A production server bundle changes import.meta.url,
|
||||
* so package resolution and cwd fallbacks are required in addition to the
|
||||
* source-relative path.
|
||||
*/
|
||||
export function resolveCaptchaAudioAssetsDir(explicitDir?: string): string {
|
||||
const candidates: string[] = [];
|
||||
|
||||
addCandidate(candidates, explicitDir);
|
||||
addCandidate(candidates, process.env.WRNEXUS_CAPTCHA_AUDIO_DIR);
|
||||
|
||||
try {
|
||||
const packageEntry = createRequire(import.meta.url).resolve("@wrnexus/captcha/audio");
|
||||
addCandidate(
|
||||
candidates,
|
||||
join(dirname(dirname(dirname(packageEntry))), "assets", "audio"),
|
||||
);
|
||||
} catch {
|
||||
// The source-relative and cwd fallbacks below still support direct source use.
|
||||
}
|
||||
|
||||
const sourcePackageRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
|
||||
addCandidate(candidates, join(sourcePackageRoot, "assets", "audio"));
|
||||
|
||||
let current = process.cwd();
|
||||
for (let depth = 0; depth < 8; depth += 1) {
|
||||
addCandidate(candidates, join(current, "packages", "captcha", "assets", "audio"));
|
||||
addCandidate(candidates, join(current, "node_modules", "@wrnexus", "captcha", "assets", "audio"));
|
||||
|
||||
const parent = dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(join(candidate, "en"))) return candidate;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
[
|
||||
"Unable to locate WRNexusJS CAPTCHA audio assets.",
|
||||
"Set WRNEXUS_CAPTCHA_AUDIO_DIR or pass assetsDir to AssetAudioRenderer.",
|
||||
`Checked: ${candidates.join(", ")}`,
|
||||
].join(" "),
|
||||
);
|
||||
}
|
||||
|
||||
export interface AssetAudioRendererOptions {
|
||||
assetsDir?: string;
|
||||
gapMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenates bundled English PCM word clips. Applications can replace this
|
||||
* renderer with cloud TTS or their own localized renderer without changing the
|
||||
* challenge engine.
|
||||
*/
|
||||
export class AssetAudioRenderer implements CaptchaAudioRenderer {
|
||||
readonly contentType = "audio/wav";
|
||||
private readonly assetsDir: string;
|
||||
private readonly gapMs: number;
|
||||
private readonly cache = new Map<string, ParsedWav>();
|
||||
|
||||
constructor(options: AssetAudioRendererOptions = {}) {
|
||||
this.assetsDir = resolveCaptchaAudioAssetsDir(options.assetsDir);
|
||||
this.gapMs = options.gapMs ?? 180;
|
||||
}
|
||||
|
||||
async render(sequence: string[], locale: string): Promise<Uint8Array> {
|
||||
const language = locale.toLowerCase().split("-")[0] || "en";
|
||||
if (language !== "en") {
|
||||
throw new Error(
|
||||
`No bundled CAPTCHA audio assets for locale '${locale}'. Supply a custom CaptchaAudioRenderer.`,
|
||||
);
|
||||
}
|
||||
if (!sequence.length) throw new Error("Cannot render an empty CAPTCHA audio sequence");
|
||||
|
||||
const clips = await Promise.all(
|
||||
sequence.map((token) => this.load(language, safeToken(token))),
|
||||
);
|
||||
const first = clips[0]!;
|
||||
|
||||
for (const clip of clips) {
|
||||
if (
|
||||
clip.sampleRate !== first.sampleRate ||
|
||||
clip.channels !== first.channels ||
|
||||
clip.bitsPerSample !== first.bitsPerSample
|
||||
) {
|
||||
throw new Error("CAPTCHA audio assets must share one PCM format");
|
||||
}
|
||||
}
|
||||
|
||||
const bytesPerSample = first.channels * (first.bitsPerSample / 8);
|
||||
const silenceBytes = Math.floor((first.sampleRate * this.gapMs) / 1000) * bytesPerSample;
|
||||
const silence = new Uint8Array(silenceBytes);
|
||||
const parts: Uint8Array[] = [];
|
||||
|
||||
for (let index = 0; index < clips.length; index += 1) {
|
||||
if (index) parts.push(silence);
|
||||
parts.push(clips[index]!.data);
|
||||
}
|
||||
|
||||
return createWav(parts, first.sampleRate, first.channels, first.bitsPerSample);
|
||||
}
|
||||
|
||||
private async load(language: string, token: string): Promise<ParsedWav> {
|
||||
const key = `${language}/${token}`;
|
||||
const cached = this.cache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const bytes = new Uint8Array(
|
||||
await readFile(join(this.assetsDir, language, `${token}.wav`)),
|
||||
);
|
||||
const parsed = parseWav(bytes);
|
||||
this.cache.set(key, parsed);
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
export function createAssetAudioRenderer(
|
||||
options?: AssetAudioRendererOptions,
|
||||
): AssetAudioRenderer {
|
||||
return new AssetAudioRenderer(options);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export const BITMAP_FONT: Record<string, readonly string[]> = {
|
||||
"0": ["01110", "10001", "10011", "10101", "11001", "10001", "01110"],
|
||||
"1": ["00100", "01100", "00100", "00100", "00100", "00100", "01110"],
|
||||
"2": ["01110", "10001", "00001", "00010", "00100", "01000", "11111"],
|
||||
"3": ["11110", "00001", "00001", "01110", "00001", "00001", "11110"],
|
||||
"4": ["00010", "00110", "01010", "10010", "11111", "00010", "00010"],
|
||||
"5": ["11111", "10000", "10000", "11110", "00001", "00001", "11110"],
|
||||
"6": ["01110", "10000", "10000", "11110", "10001", "10001", "01110"],
|
||||
"7": ["11111", "00001", "00010", "00100", "01000", "01000", "01000"],
|
||||
"8": ["01110", "10001", "10001", "01110", "10001", "10001", "01110"],
|
||||
"9": ["01110", "10001", "10001", "01111", "00001", "00001", "01110"],
|
||||
A: ["01110", "10001", "10001", "11111", "10001", "10001", "10001"],
|
||||
B: ["11110", "10001", "10001", "11110", "10001", "10001", "11110"],
|
||||
C: ["01111", "10000", "10000", "10000", "10000", "10000", "01111"],
|
||||
D: ["11110", "10001", "10001", "10001", "10001", "10001", "11110"],
|
||||
E: ["11111", "10000", "10000", "11110", "10000", "10000", "11111"],
|
||||
F: ["11111", "10000", "10000", "11110", "10000", "10000", "10000"],
|
||||
G: ["01111", "10000", "10000", "10111", "10001", "10001", "01110"],
|
||||
H: ["10001", "10001", "10001", "11111", "10001", "10001", "10001"],
|
||||
I: ["01110", "00100", "00100", "00100", "00100", "00100", "01110"],
|
||||
J: ["00111", "00010", "00010", "00010", "10010", "10010", "01100"],
|
||||
K: ["10001", "10010", "10100", "11000", "10100", "10010", "10001"],
|
||||
L: ["10000", "10000", "10000", "10000", "10000", "10000", "11111"],
|
||||
M: ["10001", "11011", "10101", "10101", "10001", "10001", "10001"],
|
||||
N: ["10001", "11001", "10101", "10011", "10001", "10001", "10001"],
|
||||
O: ["01110", "10001", "10001", "10001", "10001", "10001", "01110"],
|
||||
P: ["11110", "10001", "10001", "11110", "10000", "10000", "10000"],
|
||||
Q: ["01110", "10001", "10001", "10001", "10101", "10010", "01101"],
|
||||
R: ["11110", "10001", "10001", "11110", "10100", "10010", "10001"],
|
||||
S: ["01111", "10000", "10000", "01110", "00001", "00001", "11110"],
|
||||
T: ["11111", "00100", "00100", "00100", "00100", "00100", "00100"],
|
||||
U: ["10001", "10001", "10001", "10001", "10001", "10001", "01110"],
|
||||
V: ["10001", "10001", "10001", "10001", "10001", "01010", "00100"],
|
||||
W: ["10001", "10001", "10001", "10101", "10101", "10101", "01010"],
|
||||
X: ["10001", "10001", "01010", "00100", "01010", "10001", "10001"],
|
||||
Y: ["10001", "10001", "01010", "00100", "00100", "00100", "00100"],
|
||||
Z: ["11111", "00001", "00010", "00100", "01000", "10000", "11111"],
|
||||
"+": ["00000", "00100", "00100", "11111", "00100", "00100", "00000"],
|
||||
"-": ["00000", "00000", "00000", "11111", "00000", "00000", "00000"],
|
||||
"*": ["00000", "10001", "01010", "00100", "01010", "10001", "00000"],
|
||||
"/": ["00001", "00010", "00010", "00100", "01000", "01000", "10000"],
|
||||
"=": ["00000", "11111", "00000", "11111", "00000", "00000", "00000"],
|
||||
"?": ["01110", "10001", "00001", "00010", "00100", "00000", "00100"],
|
||||
" ": ["00000", "00000", "00000", "00000", "00000", "00000", "00000"],
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import type {
|
||||
CaptchaChallengeGenerator,
|
||||
CaptchaGeneratorContext,
|
||||
GeneratedCaptchaChallenge,
|
||||
} from "../types.ts";
|
||||
import { renderTextChallenge } from "./visual.ts";
|
||||
|
||||
const OPERATOR_WORDS: Record<string, string[]> = {
|
||||
"+": ["plus"],
|
||||
"-": ["minus"],
|
||||
"*": ["times"],
|
||||
"/": ["divided", "by"],
|
||||
};
|
||||
|
||||
function numberTokens(value: number): string[] {
|
||||
const text = String(Math.abs(value));
|
||||
return value < 0 ? ["minus", ...text] : [...text];
|
||||
}
|
||||
|
||||
export class CalculationCaptchaGenerator implements CaptchaChallengeGenerator {
|
||||
readonly type = "calculation" as const;
|
||||
|
||||
generate(context: CaptchaGeneratorContext): GeneratedCaptchaChallenge {
|
||||
const max = context.difficulty === "easy" ? 9 : context.difficulty === "hard" ? 30 : 15;
|
||||
const operations = context.difficulty === "easy" ? ["+", "-"] : ["+", "-", "*", "/"];
|
||||
const operator = operations[context.randomInt(0, operations.length - 1)]!;
|
||||
let left = context.randomInt(2, max);
|
||||
let right = context.randomInt(1, max);
|
||||
let answer: number;
|
||||
|
||||
if (operator === "+") answer = left + right;
|
||||
else if (operator === "-") {
|
||||
if (context.difficulty !== "hard" && right > left) [left, right] = [right, left];
|
||||
answer = left - right;
|
||||
} else if (operator === "*") {
|
||||
right = context.randomInt(2, context.difficulty === "hard" ? 12 : 9);
|
||||
left = context.randomInt(2, context.difficulty === "hard" ? 12 : 9);
|
||||
answer = left * right;
|
||||
} else {
|
||||
right = context.randomInt(2, context.difficulty === "hard" ? 12 : 9);
|
||||
answer = context.randomInt(2, context.difficulty === "hard" ? 12 : 9);
|
||||
left = right * answer;
|
||||
}
|
||||
|
||||
const expression = `${left} ${operator} ${right} = ?`;
|
||||
return {
|
||||
type: "calculation",
|
||||
presentation: "visual",
|
||||
prompt: "Solve the calculation",
|
||||
answer: String(answer),
|
||||
answerKind: "text",
|
||||
image: renderTextChallenge(expression, context),
|
||||
inputMode: "numeric",
|
||||
audioSequence: ["what", "is", ...numberTokens(left), ...(OPERATOR_WORDS[operator] ?? []), ...numberTokens(right)],
|
||||
metadata: { operator, imageStyle: context.imageStyle },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const calculationCaptchaGenerator = new CalculationCaptchaGenerator();
|
||||
@@ -0,0 +1,130 @@
|
||||
import type {
|
||||
CaptchaChallengeGenerator,
|
||||
CaptchaGeneratorContext,
|
||||
CaptchaImageItem,
|
||||
GeneratedCaptchaChallenge,
|
||||
} from "../types.ts";
|
||||
import { createImage, drawLine, fillCircle, fillPolygon, fillRect, pngDataUri, setPixel } from "./png.ts";
|
||||
|
||||
const SHAPES = ["circle", "square", "triangle", "diamond", "star"] as const;
|
||||
type Shape = (typeof SHAPES)[number];
|
||||
const COLORS = [
|
||||
[37, 99, 235, 255],
|
||||
[22, 163, 74, 255],
|
||||
[220, 38, 38, 255],
|
||||
[147, 51, 234, 255],
|
||||
[234, 88, 12, 255],
|
||||
] as const;
|
||||
|
||||
function starPoints(cx: number, cy: number, outer: number, inner: number): Array<[number, number]> {
|
||||
const points: Array<[number, number]> = [];
|
||||
for (let index = 0; index < 10; index++) {
|
||||
const radius = index % 2 === 0 ? outer : inner;
|
||||
const angle = -Math.PI / 2 + (index * Math.PI) / 5;
|
||||
points.push([cx + Math.cos(angle) * radius, cy + Math.sin(angle) * radius]);
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
function disturbanceRatio(context: CaptchaGeneratorContext): number {
|
||||
return Math.max(0, Math.min(1, (context.disturbance - 25) / 50));
|
||||
}
|
||||
|
||||
function shapeImage(shape: Shape, context: CaptchaGeneratorContext): string {
|
||||
const image = createImage(96, 72, [248, 250, 252, 255]);
|
||||
const color = COLORS[context.randomInt(0, COLORS.length - 1)]!;
|
||||
const cx = 48 + context.randomInt(-5, 5);
|
||||
const cy = 36 + context.randomInt(-4, 4);
|
||||
const size = context.randomInt(19, 25);
|
||||
if (shape === "circle") fillCircle(image, cx, cy, size, color);
|
||||
else if (shape === "square") fillRect(image, cx - size, cy - size, size * 2, size * 2, color);
|
||||
else if (shape === "triangle") {
|
||||
fillPolygon(image, [[cx, cy - size], [cx - size, cy + size], [cx + size, cy + size]], color);
|
||||
} else if (shape === "diamond") {
|
||||
fillPolygon(image, [[cx, cy - size], [cx - size, cy], [cx, cy + size], [cx + size, cy]], color);
|
||||
} else {
|
||||
fillPolygon(image, starPoints(cx, cy, size, size * 0.45), color);
|
||||
}
|
||||
|
||||
const ratio = disturbanceRatio(context);
|
||||
const dots = Math.round(35 + ratio * 150);
|
||||
for (let index = 0; index < dots; index++) {
|
||||
setPixel(
|
||||
image,
|
||||
context.randomInt(0, 95),
|
||||
context.randomInt(0, 71),
|
||||
[context.randomInt(105, 225), context.randomInt(105, 225), context.randomInt(105, 225), Math.round(55 + ratio * 65)],
|
||||
);
|
||||
}
|
||||
|
||||
const lines = Math.round(1 + ratio * 4);
|
||||
for (let index = 0; index < lines; index++) {
|
||||
drawLine(
|
||||
image,
|
||||
context.randomInt(0, 95),
|
||||
context.randomInt(0, 71),
|
||||
context.randomInt(0, 95),
|
||||
context.randomInt(0, 71),
|
||||
[context.randomInt(100, 210), context.randomInt(100, 210), context.randomInt(100, 210), Math.round(45 + ratio * 55)],
|
||||
ratio > 0.75 ? 2 : 1,
|
||||
);
|
||||
}
|
||||
return pngDataUri(image);
|
||||
}
|
||||
|
||||
function shuffle<T>(items: T[], context: CaptchaGeneratorContext): T[] {
|
||||
for (let index = items.length - 1; index > 0; index--) {
|
||||
const other = context.randomInt(0, index);
|
||||
[items[index], items[other]] = [items[other]!, items[index]!];
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export class ImageCaptchaGenerator implements CaptchaChallengeGenerator {
|
||||
readonly type = "image" as const;
|
||||
|
||||
generate(context: CaptchaGeneratorContext): GeneratedCaptchaChallenge {
|
||||
const target = SHAPES[context.randomInt(0, SHAPES.length - 1)]!;
|
||||
const count = context.difficulty === "easy" ? 6 : 9;
|
||||
const matches = context.difficulty === "hard" ? 2 : 3;
|
||||
const entries: Array<{ id: string; shape: Shape; item: CaptchaImageItem }> = [];
|
||||
|
||||
for (let index = 0; index < count; index++) {
|
||||
let shape: Shape;
|
||||
if (index < matches) shape = target;
|
||||
else {
|
||||
do shape = SHAPES[context.randomInt(0, SHAPES.length - 1)]!;
|
||||
while (shape === target);
|
||||
}
|
||||
const id = context.randomId(9);
|
||||
entries.push({
|
||||
id,
|
||||
shape,
|
||||
item: { id, image: shapeImage(shape, context), alt: `Challenge tile ${index + 1}` },
|
||||
});
|
||||
}
|
||||
|
||||
shuffle(entries, context);
|
||||
const answer = entries.filter((entry) => entry.shape === target).map((entry) => entry.id).sort().join(",");
|
||||
return {
|
||||
type: "image",
|
||||
presentation: "visual",
|
||||
prompt: `Select every ${target}`,
|
||||
answer,
|
||||
answerKind: "selections",
|
||||
items: entries.map((entry) => entry.item),
|
||||
minSelections: matches,
|
||||
maxSelections: matches,
|
||||
inputMode: "none",
|
||||
metadata: {
|
||||
target,
|
||||
count,
|
||||
matches,
|
||||
disturbance: context.disturbance,
|
||||
accessibleAlternative: "Request an audio challenge",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const imageCaptchaGenerator = new ImageCaptchaGenerator();
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { CaptchaChallengeGenerator } from "../types.ts";
|
||||
import { alphaCaptchaGenerator, alphanumericCaptchaGenerator, numberCaptchaGenerator } from "./text.ts";
|
||||
import { calculationCaptchaGenerator } from "./calculation.ts";
|
||||
import { imageCaptchaGenerator } from "./image.ts";
|
||||
import { honeypotCaptchaGenerator, notRobotCaptchaGenerator, timingCaptchaGenerator } from "./invisible.ts";
|
||||
|
||||
export * from "./text.ts";
|
||||
export * from "./calculation.ts";
|
||||
export * from "./image.ts";
|
||||
export * from "./invisible.ts";
|
||||
export * from "./png.ts";
|
||||
export * from "./styles.ts";
|
||||
|
||||
export function defineCaptchaGenerator<T extends CaptchaChallengeGenerator>(generator: T): T {
|
||||
if (!generator.type) throw new TypeError("CAPTCHA generator requires a stable type");
|
||||
if (typeof generator.generate !== "function") throw new TypeError("CAPTCHA generator requires generate()");
|
||||
return generator;
|
||||
}
|
||||
|
||||
export function defaultCaptchaGenerators(): CaptchaChallengeGenerator[] {
|
||||
return [
|
||||
numberCaptchaGenerator,
|
||||
alphaCaptchaGenerator,
|
||||
alphanumericCaptchaGenerator,
|
||||
calculationCaptchaGenerator,
|
||||
imageCaptchaGenerator,
|
||||
honeypotCaptchaGenerator,
|
||||
timingCaptchaGenerator,
|
||||
notRobotCaptchaGenerator,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type {
|
||||
CaptchaChallengeGenerator,
|
||||
CaptchaGeneratorContext,
|
||||
GeneratedCaptchaChallenge,
|
||||
} from "../types.ts";
|
||||
|
||||
type InvisibleCaptchaType = "honeypot" | "timing" | "not-robot";
|
||||
|
||||
export class InvisibleCaptchaGenerator implements CaptchaChallengeGenerator {
|
||||
readonly type: InvisibleCaptchaType;
|
||||
|
||||
constructor(type: InvisibleCaptchaType) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
generate(context: CaptchaGeneratorContext): GeneratedCaptchaChallenge {
|
||||
const honeypotField = `website_${context.randomId(5)}`;
|
||||
const timingToken = context.randomId(18);
|
||||
return {
|
||||
type: this.type,
|
||||
presentation: "invisible",
|
||||
prompt: this.type === "not-robot" ? "Confirm that you are not a robot" : "Automated abuse check",
|
||||
answer: JSON.stringify({ honeypot: "", timingToken }),
|
||||
answerKind: "invisible",
|
||||
inputMode: "none",
|
||||
metadata: {
|
||||
honeypotField,
|
||||
timingToken,
|
||||
minCompletionMs: context.minCompletionMs,
|
||||
interaction: this.type === "not-robot" ? "checkbox" : "automatic",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const honeypotCaptchaGenerator = new InvisibleCaptchaGenerator("honeypot");
|
||||
export const timingCaptchaGenerator = new InvisibleCaptchaGenerator("timing");
|
||||
export const notRobotCaptchaGenerator = new InvisibleCaptchaGenerator("not-robot");
|
||||
@@ -0,0 +1,240 @@
|
||||
import { BITMAP_FONT } from "./bitmap.ts";
|
||||
|
||||
export interface RgbaImage {
|
||||
width: number;
|
||||
height: number;
|
||||
data: Uint8Array;
|
||||
}
|
||||
|
||||
export type Rgba = readonly [number, number, number, number?];
|
||||
|
||||
export function createImage(width: number, height: number, background: Rgba = [255, 255, 255, 255]): RgbaImage {
|
||||
const data = new Uint8Array(width * height * 4);
|
||||
const alpha = background[3] ?? 255;
|
||||
for (let index = 0; index < data.length; index += 4) {
|
||||
data[index] = background[0];
|
||||
data[index + 1] = background[1];
|
||||
data[index + 2] = background[2];
|
||||
data[index + 3] = alpha;
|
||||
}
|
||||
return { width, height, data };
|
||||
}
|
||||
|
||||
export function setPixel(image: RgbaImage, x: number, y: number, color: Rgba): void {
|
||||
const px = Math.round(x);
|
||||
const py = Math.round(y);
|
||||
if (px < 0 || py < 0 || px >= image.width || py >= image.height) return;
|
||||
const index = (py * image.width + px) * 4;
|
||||
const alpha = (color[3] ?? 255) / 255;
|
||||
const inverse = 1 - alpha;
|
||||
image.data[index] = Math.round(color[0] * alpha + image.data[index] * inverse);
|
||||
image.data[index + 1] = Math.round(color[1] * alpha + image.data[index + 1] * inverse);
|
||||
image.data[index + 2] = Math.round(color[2] * alpha + image.data[index + 2] * inverse);
|
||||
image.data[index + 3] = 255;
|
||||
}
|
||||
|
||||
export function fillRect(image: RgbaImage, x: number, y: number, width: number, height: number, color: Rgba): void {
|
||||
for (let py = Math.floor(y); py < Math.ceil(y + height); py++) {
|
||||
for (let px = Math.floor(x); px < Math.ceil(x + width); px++) setPixel(image, px, py, color);
|
||||
}
|
||||
}
|
||||
|
||||
export function drawLine(image: RgbaImage, x0: number, y0: number, x1: number, y1: number, color: Rgba, thickness = 1): void {
|
||||
let x = Math.round(x0);
|
||||
let y = Math.round(y0);
|
||||
const targetX = Math.round(x1);
|
||||
const targetY = Math.round(y1);
|
||||
const dx = Math.abs(targetX - x);
|
||||
const dy = -Math.abs(targetY - y);
|
||||
const sx = x < targetX ? 1 : -1;
|
||||
const sy = y < targetY ? 1 : -1;
|
||||
let error = dx + dy;
|
||||
while (true) {
|
||||
fillRect(image, x - Math.floor(thickness / 2), y - Math.floor(thickness / 2), thickness, thickness, color);
|
||||
if (x === targetX && y === targetY) break;
|
||||
const twice = 2 * error;
|
||||
if (twice >= dy) {
|
||||
error += dy;
|
||||
x += sx;
|
||||
}
|
||||
if (twice <= dx) {
|
||||
error += dx;
|
||||
y += sy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function fillCircle(image: RgbaImage, centerX: number, centerY: number, radius: number, color: Rgba): void {
|
||||
const r2 = radius * radius;
|
||||
for (let y = Math.floor(centerY - radius); y <= Math.ceil(centerY + radius); y++) {
|
||||
for (let x = Math.floor(centerX - radius); x <= Math.ceil(centerX + radius); x++) {
|
||||
const dx = x - centerX;
|
||||
const dy = y - centerY;
|
||||
if (dx * dx + dy * dy <= r2) setPixel(image, x, y, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pointInPolygon(x: number, y: number, points: readonly (readonly [number, number])[]): boolean {
|
||||
let inside = false;
|
||||
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
|
||||
const xi = points[i]![0];
|
||||
const yi = points[i]![1];
|
||||
const xj = points[j]![0];
|
||||
const yj = points[j]![1];
|
||||
const intersects = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi || 1) + xi;
|
||||
if (intersects) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
export function fillPolygon(image: RgbaImage, points: readonly (readonly [number, number])[], color: Rgba): void {
|
||||
const minX = Math.floor(Math.min(...points.map((point) => point[0])));
|
||||
const maxX = Math.ceil(Math.max(...points.map((point) => point[0])));
|
||||
const minY = Math.floor(Math.min(...points.map((point) => point[1])));
|
||||
const maxY = Math.ceil(Math.max(...points.map((point) => point[1])));
|
||||
for (let y = minY; y <= maxY; y++) {
|
||||
for (let x = minX; x <= maxX; x++) if (pointInPolygon(x + 0.5, y + 0.5, points)) setPixel(image, x, y, color);
|
||||
}
|
||||
}
|
||||
|
||||
export function drawGlyph(
|
||||
image: RgbaImage,
|
||||
character: string,
|
||||
x: number,
|
||||
y: number,
|
||||
scale: number,
|
||||
color: Rgba,
|
||||
shear = 0,
|
||||
): void {
|
||||
const glyph = BITMAP_FONT[character.toUpperCase()] ?? BITMAP_FONT["?"]!;
|
||||
for (let row = 0; row < glyph.length; row++) {
|
||||
const line = glyph[row]!;
|
||||
for (let column = 0; column < line.length; column++) {
|
||||
if (line[column] !== "1") continue;
|
||||
const offset = Math.round((glyph.length - row) * shear);
|
||||
fillRect(image, x + column * scale + offset, y + row * scale, scale, scale, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function drawText(
|
||||
image: RgbaImage,
|
||||
text: string,
|
||||
options: {
|
||||
x: number;
|
||||
y: number;
|
||||
scale: number;
|
||||
color: Rgba;
|
||||
spacing?: number;
|
||||
jitter?: (index: number) => { x: number; y: number; shear: number };
|
||||
},
|
||||
): void {
|
||||
const spacing = options.spacing ?? options.scale * 2;
|
||||
let cursor = options.x;
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
const jitter = options.jitter?.(index) ?? { x: 0, y: 0, shear: 0 };
|
||||
drawGlyph(image, text[index]!, cursor + jitter.x, options.y + jitter.y, options.scale, options.color, jitter.shear);
|
||||
cursor += options.scale * 5 + spacing;
|
||||
}
|
||||
}
|
||||
|
||||
const CRC32_TABLE = (() => {
|
||||
const table = new Uint32Array(256);
|
||||
for (let index = 0; index < table.length; index++) {
|
||||
let value = index;
|
||||
for (let bit = 0; bit < 8; bit++) {
|
||||
value = (value >>> 1) ^ (value & 1 ? 0xedb88320 : 0);
|
||||
}
|
||||
table[index] = value >>> 0;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
function crc32(bytes: Uint8Array): number {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of bytes) {
|
||||
crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ byte) & 255]!;
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function adler32(bytes: Uint8Array): number {
|
||||
let a = 1;
|
||||
let b = 0;
|
||||
for (const byte of bytes) {
|
||||
a = (a + byte) % 65521;
|
||||
b = (b + a) % 65521;
|
||||
}
|
||||
return ((b << 16) | a) >>> 0;
|
||||
}
|
||||
|
||||
function u32(value: number): Uint8Array {
|
||||
return Uint8Array.of((value >>> 24) & 255, (value >>> 16) & 255, (value >>> 8) & 255, value & 255);
|
||||
}
|
||||
|
||||
function concat(parts: readonly Uint8Array[]): Uint8Array {
|
||||
const length = parts.reduce((sum, part) => sum + part.length, 0);
|
||||
const out = new Uint8Array(length);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
out.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function chunk(type: string, data: Uint8Array): Uint8Array {
|
||||
const typeBytes = new TextEncoder().encode(type);
|
||||
return concat([u32(data.length), typeBytes, data, u32(crc32(concat([typeBytes, data])))]);
|
||||
}
|
||||
|
||||
function deflateStored(data: Uint8Array): Uint8Array {
|
||||
const blocks: Uint8Array[] = [Uint8Array.of(0x78, 0x01)];
|
||||
for (let offset = 0; offset < data.length; offset += 65535) {
|
||||
const size = Math.min(65535, data.length - offset);
|
||||
const final = offset + size >= data.length;
|
||||
const length = size;
|
||||
const inverse = (~length) & 0xffff;
|
||||
blocks.push(
|
||||
Uint8Array.of(final ? 1 : 0, length & 255, (length >>> 8) & 255, inverse & 255, (inverse >>> 8) & 255),
|
||||
data.slice(offset, offset + size),
|
||||
);
|
||||
}
|
||||
blocks.push(u32(adler32(data)));
|
||||
return concat(blocks);
|
||||
}
|
||||
|
||||
export function encodePng(image: RgbaImage): Uint8Array {
|
||||
const stride = image.width * 4;
|
||||
const scanlines = new Uint8Array((stride + 1) * image.height);
|
||||
for (let y = 0; y < image.height; y++) {
|
||||
const target = y * (stride + 1);
|
||||
scanlines[target] = 0;
|
||||
scanlines.set(image.data.slice(y * stride, (y + 1) * stride), target + 1);
|
||||
}
|
||||
const header = new Uint8Array(13);
|
||||
header.set(u32(image.width), 0);
|
||||
header.set(u32(image.height), 4);
|
||||
header[8] = 8;
|
||||
header[9] = 6;
|
||||
return concat([
|
||||
Uint8Array.of(137, 80, 78, 71, 13, 10, 26, 10),
|
||||
chunk("IHDR", header),
|
||||
chunk("IDAT", deflateStored(scanlines)),
|
||||
chunk("IEND", new Uint8Array()),
|
||||
]);
|
||||
}
|
||||
|
||||
export function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
const size = 0x8000;
|
||||
for (let offset = 0; offset < bytes.length; offset += size) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, Math.min(bytes.length, offset + size)));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
export function pngDataUri(image: RgbaImage): string {
|
||||
return `data:image/png;base64,${bytesToBase64(encodePng(image))}`;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type {
|
||||
CaptchaConcreteImageStyle,
|
||||
CaptchaImageStyle,
|
||||
} from "../types.ts";
|
||||
|
||||
export const CAPTCHA_CONCRETE_IMAGE_STYLES = [
|
||||
"classic",
|
||||
"collision",
|
||||
"snow",
|
||||
"corrosion",
|
||||
"spiderweb",
|
||||
"cross-shadow",
|
||||
"split",
|
||||
"split2",
|
||||
"cut",
|
||||
"darts",
|
||||
"distortion",
|
||||
"stitch",
|
||||
"striped",
|
||||
"wave",
|
||||
"grid-noise",
|
||||
"scribble",
|
||||
"pixel",
|
||||
"broken-lines",
|
||||
] as const satisfies readonly CaptchaConcreteImageStyle[];
|
||||
|
||||
export const CAPTCHA_IMAGE_STYLES = [
|
||||
"random",
|
||||
...CAPTCHA_CONCRETE_IMAGE_STYLES,
|
||||
] as const satisfies readonly CaptchaImageStyle[];
|
||||
|
||||
const CONCRETE_STYLE_SET = new Set<string>(CAPTCHA_CONCRETE_IMAGE_STYLES);
|
||||
const STYLE_SET = new Set<string>(CAPTCHA_IMAGE_STYLES);
|
||||
|
||||
function styleValues(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.flatMap((item) => styleValues(item));
|
||||
if (typeof value !== "string") return [];
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function isCaptchaImageStyle(value: unknown): value is CaptchaImageStyle {
|
||||
return typeof value === "string" && STYLE_SET.has(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function normalizeCaptchaImageStyle(
|
||||
value: unknown,
|
||||
fallback: CaptchaImageStyle = "random",
|
||||
): CaptchaImageStyle {
|
||||
if (value === undefined || value === null || value === "") return fallback;
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
if (!STYLE_SET.has(normalized)) {
|
||||
throw new RangeError(
|
||||
`imageStyle must be one of: ${CAPTCHA_IMAGE_STYLES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return normalized as CaptchaImageStyle;
|
||||
}
|
||||
|
||||
export function normalizeCaptchaImageStyleList(
|
||||
value: unknown,
|
||||
name: "allowedStyles" | "excludedStyles",
|
||||
): CaptchaConcreteImageStyle[] {
|
||||
const styles: CaptchaConcreteImageStyle[] = [];
|
||||
for (const entry of styleValues(value)) {
|
||||
if (entry === "random") continue;
|
||||
if (!CONCRETE_STYLE_SET.has(entry)) {
|
||||
throw new RangeError(
|
||||
`${name} contains an unknown image style: ${entry}`,
|
||||
);
|
||||
}
|
||||
const style = entry as CaptchaConcreteImageStyle;
|
||||
if (!styles.includes(style)) styles.push(style);
|
||||
}
|
||||
return styles;
|
||||
}
|
||||
|
||||
export interface ResolveCaptchaImageStyleOptions {
|
||||
imageStyle?: unknown;
|
||||
allowedStyles?: unknown;
|
||||
excludedStyles?: unknown;
|
||||
randomizeStyle?: boolean;
|
||||
randomInt(min: number, max: number): number;
|
||||
}
|
||||
|
||||
export interface ResolvedCaptchaImageStyle {
|
||||
requested: CaptchaImageStyle;
|
||||
resolved: CaptchaConcreteImageStyle;
|
||||
pool: CaptchaConcreteImageStyle[];
|
||||
}
|
||||
|
||||
export function resolveCaptchaImageStyle(
|
||||
options: ResolveCaptchaImageStyleOptions,
|
||||
): ResolvedCaptchaImageStyle {
|
||||
const requested = normalizeCaptchaImageStyle(options.imageStyle, "random");
|
||||
const allowed = normalizeCaptchaImageStyleList(
|
||||
options.allowedStyles,
|
||||
"allowedStyles",
|
||||
);
|
||||
const excluded = new Set(
|
||||
normalizeCaptchaImageStyleList(options.excludedStyles, "excludedStyles"),
|
||||
);
|
||||
|
||||
const source = allowed.length
|
||||
? allowed
|
||||
: [...CAPTCHA_CONCRETE_IMAGE_STYLES];
|
||||
const pool = source.filter((style) => !excluded.has(style));
|
||||
|
||||
if (!pool.length) {
|
||||
throw new RangeError(
|
||||
"No CAPTCHA image styles remain after applying allowedStyles and excludedStyles",
|
||||
);
|
||||
}
|
||||
|
||||
if (!options.randomizeStyle && requested !== "random") {
|
||||
if (!pool.includes(requested)) {
|
||||
throw new RangeError(
|
||||
`imageStyle ${requested} is not available in the configured style pool`,
|
||||
);
|
||||
}
|
||||
return { requested, resolved: requested, pool };
|
||||
}
|
||||
|
||||
return {
|
||||
requested,
|
||||
resolved: pool[options.randomInt(0, pool.length - 1)]!,
|
||||
pool,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type {
|
||||
CaptchaChallengeGenerator,
|
||||
CaptchaChallengeType,
|
||||
CaptchaGeneratorContext,
|
||||
GeneratedCaptchaChallenge,
|
||||
} from "../types.ts";
|
||||
import { renderTextChallenge } from "./visual.ts";
|
||||
|
||||
const NUMBERS = "23456789";
|
||||
const ALPHA = "ABCDEFGHJKMNPQRSTUVWXYZ";
|
||||
const ALPHANUMERIC = `${ALPHA}${NUMBERS}`;
|
||||
|
||||
function defaultLength(context: CaptchaGeneratorContext): number {
|
||||
return context.length ?? (context.difficulty === "easy" ? 4 : context.difficulty === "hard" ? 7 : 6);
|
||||
}
|
||||
|
||||
function charset(type: CaptchaChallengeType): string {
|
||||
if (type === "number") return NUMBERS;
|
||||
if (type === "alpha") return ALPHA;
|
||||
return ALPHANUMERIC;
|
||||
}
|
||||
|
||||
function generateText(type: "number" | "alpha" | "alphanumeric", context: CaptchaGeneratorContext): GeneratedCaptchaChallenge {
|
||||
const source = charset(type);
|
||||
const length = Math.max(3, Math.min(10, defaultLength(context)));
|
||||
let answer = "";
|
||||
for (let index = 0; index < length; index++) answer += source[context.randomInt(0, source.length - 1)];
|
||||
return {
|
||||
type,
|
||||
presentation: "visual",
|
||||
prompt: type === "number" ? "Enter the numbers shown" : "Enter the characters shown",
|
||||
answer,
|
||||
answerKind: "text",
|
||||
image: renderTextChallenge(answer, context),
|
||||
inputMode: type === "number" ? "numeric" : "text",
|
||||
audioSequence: [...answer.toLowerCase()],
|
||||
metadata: { length, excludedAmbiguousCharacters: true, imageStyle: context.imageStyle },
|
||||
};
|
||||
}
|
||||
|
||||
export class TextCaptchaGenerator implements CaptchaChallengeGenerator {
|
||||
readonly type: "number" | "alpha" | "alphanumeric";
|
||||
|
||||
constructor(type: "number" | "alpha" | "alphanumeric") {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
generate(context: CaptchaGeneratorContext): GeneratedCaptchaChallenge {
|
||||
return generateText(this.type, context);
|
||||
}
|
||||
}
|
||||
|
||||
export const numberCaptchaGenerator = new TextCaptchaGenerator("number");
|
||||
export const alphaCaptchaGenerator = new TextCaptchaGenerator("alpha");
|
||||
export const alphanumericCaptchaGenerator = new TextCaptchaGenerator("alphanumeric");
|
||||
@@ -0,0 +1,690 @@
|
||||
import type {
|
||||
CaptchaConcreteImageStyle,
|
||||
CaptchaGeneratorContext,
|
||||
} from "../types.ts";
|
||||
import {
|
||||
createImage,
|
||||
drawGlyph,
|
||||
drawLine,
|
||||
fillCircle,
|
||||
fillRect,
|
||||
pngDataUri,
|
||||
setPixel,
|
||||
type Rgba,
|
||||
type RgbaImage,
|
||||
} from "./png.ts";
|
||||
|
||||
const LIGHT_BACKGROUND: Rgba = [248, 250, 252, 255];
|
||||
const ALT_BACKGROUND: Rgba = [241, 245, 249, 255];
|
||||
const INK: Rgba = [15, 23, 42, 255];
|
||||
const ACCENTS: readonly Rgba[] = [
|
||||
[30, 64, 175, 235],
|
||||
[126, 34, 206, 235],
|
||||
[190, 24, 93, 230],
|
||||
[15, 118, 110, 230],
|
||||
[194, 65, 12, 230],
|
||||
];
|
||||
|
||||
interface TextLayout {
|
||||
scale: number;
|
||||
spacing: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
textWidth: number;
|
||||
textHeight: number;
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
|
||||
function disturbanceRatio(context: CaptchaGeneratorContext): number {
|
||||
return clamp((context.disturbance - 25) / 50, 0, 1);
|
||||
}
|
||||
|
||||
function randomColor(
|
||||
context: CaptchaGeneratorContext,
|
||||
minimum = 70,
|
||||
maximum = 205,
|
||||
alpha = 100,
|
||||
): Rgba {
|
||||
return [
|
||||
context.randomInt(minimum, maximum),
|
||||
context.randomInt(minimum, maximum),
|
||||
context.randomInt(minimum, maximum),
|
||||
alpha,
|
||||
];
|
||||
}
|
||||
|
||||
function rawPixel(image: RgbaImage, x: number, y: number): readonly [number, number, number, number] {
|
||||
const px = clamp(Math.round(x), 0, image.width - 1);
|
||||
const py = clamp(Math.round(y), 0, image.height - 1);
|
||||
const index = (py * image.width + px) * 4;
|
||||
return [
|
||||
image.data[index]!,
|
||||
image.data[index + 1]!,
|
||||
image.data[index + 2]!,
|
||||
image.data[index + 3]!,
|
||||
];
|
||||
}
|
||||
|
||||
function putRawPixel(
|
||||
image: RgbaImage,
|
||||
x: number,
|
||||
y: number,
|
||||
color: readonly [number, number, number, number],
|
||||
): void {
|
||||
const px = Math.round(x);
|
||||
const py = Math.round(y);
|
||||
if (px < 0 || py < 0 || px >= image.width || py >= image.height) return;
|
||||
const index = (py * image.width + px) * 4;
|
||||
image.data[index] = color[0];
|
||||
image.data[index + 1] = color[1];
|
||||
image.data[index + 2] = color[2];
|
||||
image.data[index + 3] = color[3];
|
||||
}
|
||||
|
||||
function cloneImage(image: RgbaImage): RgbaImage {
|
||||
return { width: image.width, height: image.height, data: image.data.slice() };
|
||||
}
|
||||
|
||||
function replaceImage(target: RgbaImage, source: RgbaImage): void {
|
||||
target.data.set(source.data);
|
||||
}
|
||||
|
||||
function layoutFor(text: string, style: CaptchaConcreteImageStyle): TextLayout {
|
||||
const width = 300;
|
||||
const height = 104;
|
||||
const scale = text.length > 9 ? 3 : text.length > 7 ? 4 : 5;
|
||||
const glyphWidth = scale * 5;
|
||||
const normalSpacing = scale + 3;
|
||||
const spacing = style === "collision"
|
||||
? Math.max(-Math.floor(scale * 0.45), -2)
|
||||
: style === "cross-shadow"
|
||||
? scale
|
||||
: normalSpacing;
|
||||
const textWidth = text.length * glyphWidth + Math.max(0, text.length - 1) * spacing;
|
||||
const textHeight = scale * 7;
|
||||
return {
|
||||
scale,
|
||||
spacing,
|
||||
startX: Math.max(12, Math.floor((width - textWidth) / 2)),
|
||||
startY: Math.floor((height - textHeight) / 2),
|
||||
textWidth,
|
||||
textHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function drawCircleOutline(
|
||||
image: RgbaImage,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
radius: number,
|
||||
color: Rgba,
|
||||
thickness = 1,
|
||||
): void {
|
||||
const steps = Math.max(24, Math.round(radius * 8));
|
||||
for (let index = 0; index < steps; index++) {
|
||||
const angle = (index / steps) * Math.PI * 2;
|
||||
const x = centerX + Math.cos(angle) * radius;
|
||||
const y = centerY + Math.sin(angle) * radius;
|
||||
fillCircle(image, x, y, Math.max(0.75, thickness / 2), color);
|
||||
}
|
||||
}
|
||||
|
||||
function drawDashedLine(
|
||||
image: RgbaImage,
|
||||
x0: number,
|
||||
y0: number,
|
||||
x1: number,
|
||||
y1: number,
|
||||
color: Rgba,
|
||||
dash = 5,
|
||||
gap = 4,
|
||||
thickness = 1,
|
||||
): void {
|
||||
const distance = Math.hypot(x1 - x0, y1 - y0);
|
||||
if (distance <= 0) return;
|
||||
const dx = (x1 - x0) / distance;
|
||||
const dy = (y1 - y0) / distance;
|
||||
for (let offset = 0; offset < distance; offset += dash + gap) {
|
||||
const end = Math.min(distance, offset + dash);
|
||||
drawLine(
|
||||
image,
|
||||
x0 + dx * offset,
|
||||
y0 + dy * offset,
|
||||
x0 + dx * end,
|
||||
y0 + dy * end,
|
||||
color,
|
||||
thickness,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function addDots(
|
||||
image: RgbaImage,
|
||||
context: CaptchaGeneratorContext,
|
||||
count: number,
|
||||
alpha: number,
|
||||
minimum = 75,
|
||||
maximum = 220,
|
||||
radius = 0,
|
||||
): void {
|
||||
for (let index = 0; index < count; index++) {
|
||||
const x = context.randomInt(0, image.width - 1);
|
||||
const y = context.randomInt(0, image.height - 1);
|
||||
const color = randomColor(context, minimum, maximum, alpha);
|
||||
if (radius > 0) fillCircle(image, x, y, context.randomInt(1, radius), color);
|
||||
else setPixel(image, x, y, color);
|
||||
}
|
||||
}
|
||||
|
||||
function addCrossingLines(
|
||||
image: RgbaImage,
|
||||
context: CaptchaGeneratorContext,
|
||||
count: number,
|
||||
alpha: number,
|
||||
thickness = 1,
|
||||
): void {
|
||||
for (let index = 0; index < count; index++) {
|
||||
drawLine(
|
||||
image,
|
||||
context.randomInt(0, image.width - 1),
|
||||
context.randomInt(0, image.height - 1),
|
||||
context.randomInt(0, image.width - 1),
|
||||
context.randomInt(0, image.height - 1),
|
||||
randomColor(context, 65, 195, alpha),
|
||||
thickness,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function drawCharacters(
|
||||
image: RgbaImage,
|
||||
text: string,
|
||||
context: CaptchaGeneratorContext,
|
||||
layout: TextLayout,
|
||||
style: CaptchaConcreteImageStyle,
|
||||
): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const jitterX = Math.round(1 + ratio * 3);
|
||||
const jitterY = Math.round(2 + ratio * 4);
|
||||
const shear = 0.12 + ratio * 0.42;
|
||||
let cursor = layout.startX;
|
||||
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
const character = text[index]!;
|
||||
const x = cursor + context.randomInt(-jitterX, jitterX);
|
||||
const y = layout.startY + context.randomInt(-jitterY, jitterY);
|
||||
const glyphShear = (context.randomFloat() - 0.5) * shear;
|
||||
const accent = ACCENTS[context.randomInt(0, ACCENTS.length - 1)]!;
|
||||
const color = style === "collision" || style === "cross-shadow" || style === "pixel"
|
||||
? accent
|
||||
: INK;
|
||||
|
||||
if (style === "cross-shadow") {
|
||||
drawGlyph(image, character, x - 3, y + 3, layout.scale, [37, 99, 235, 95], glyphShear);
|
||||
drawGlyph(image, character, x + 3, y - 2, layout.scale, [220, 38, 38, 85], glyphShear);
|
||||
} else if (style === "collision") {
|
||||
drawGlyph(image, character, x - 2, y + 2, layout.scale, [15, 23, 42, 75], glyphShear);
|
||||
if (index > 0 && context.randomFloat() < 0.65) {
|
||||
drawGlyph(image, character, x + context.randomInt(-4, 1), y, layout.scale, [2, 6, 23, 60], -glyphShear);
|
||||
}
|
||||
} else if (context.randomFloat() < 0.28 + ratio * 0.35) {
|
||||
drawGlyph(image, character, x + 1, y + 1, layout.scale, [15, 23, 42, 70], glyphShear);
|
||||
}
|
||||
|
||||
drawGlyph(image, character, x, y, layout.scale, color, glyphShear);
|
||||
if (style === "pixel" && context.randomFloat() < 0.45) {
|
||||
drawGlyph(image, character, x + 1, y, layout.scale, [15, 23, 42, 90], glyphShear);
|
||||
}
|
||||
|
||||
cursor += layout.scale * 5 + layout.spacing;
|
||||
}
|
||||
}
|
||||
|
||||
function shiftRows(
|
||||
image: RgbaImage,
|
||||
shiftForY: (y: number) => number,
|
||||
background: Rgba = LIGHT_BACKGROUND,
|
||||
): void {
|
||||
const source = cloneImage(image);
|
||||
const output = createImage(image.width, image.height, background);
|
||||
for (let y = 0; y < image.height; y++) {
|
||||
const shift = Math.round(shiftForY(y));
|
||||
for (let x = 0; x < image.width; x++) {
|
||||
const sourceX = x - shift;
|
||||
if (sourceX >= 0 && sourceX < image.width) {
|
||||
putRawPixel(output, x, y, rawPixel(source, sourceX, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
replaceImage(image, output);
|
||||
}
|
||||
|
||||
function shiftColumns(
|
||||
image: RgbaImage,
|
||||
shiftForX: (x: number) => number,
|
||||
background: Rgba = LIGHT_BACKGROUND,
|
||||
): void {
|
||||
const source = cloneImage(image);
|
||||
const output = createImage(image.width, image.height, background);
|
||||
for (let x = 0; x < image.width; x++) {
|
||||
const shift = Math.round(shiftForX(x));
|
||||
for (let y = 0; y < image.height; y++) {
|
||||
const sourceY = y - shift;
|
||||
if (sourceY >= 0 && sourceY < image.height) {
|
||||
putRawPixel(output, x, y, rawPixel(source, x, sourceY));
|
||||
}
|
||||
}
|
||||
}
|
||||
replaceImage(image, output);
|
||||
}
|
||||
|
||||
function applyClassic(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
addCrossingLines(image, context, Math.round(3 + ratio * 5), Math.round(55 + ratio * 55), ratio > 0.7 ? 2 : 1);
|
||||
addDots(image, context, Math.round(140 + ratio * 470), Math.round(45 + ratio * 50));
|
||||
}
|
||||
|
||||
function applyCollision(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
addCrossingLines(image, context, Math.round(5 + ratio * 7), Math.round(60 + ratio * 60), ratio > 0.55 ? 2 : 1);
|
||||
addDots(image, context, Math.round(120 + ratio * 320), 70);
|
||||
const bars = Math.round(2 + ratio * 5);
|
||||
for (let index = 0; index < bars; index++) {
|
||||
fillRect(
|
||||
image,
|
||||
context.randomInt(0, image.width - 35),
|
||||
context.randomInt(18, image.height - 20),
|
||||
context.randomInt(18, 50),
|
||||
context.randomInt(1, ratio > 0.65 ? 3 : 2),
|
||||
randomColor(context, 35, 160, 75),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function applySnow(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
addDots(image, context, Math.round(420 + ratio * 950), Math.round(85 + ratio * 75), 120, 245, ratio > 0.5 ? 2 : 1);
|
||||
for (let index = 0; index < Math.round(35 + ratio * 90); index++) {
|
||||
const x = context.randomInt(0, image.width - 1);
|
||||
const y = context.randomInt(0, image.height - 1);
|
||||
drawLine(image, x - 2, y, x + 2, y, [255, 255, 255, 185], 1);
|
||||
drawLine(image, x, y - 2, x, y + 2, [255, 255, 255, 185], 1);
|
||||
}
|
||||
addCrossingLines(image, context, Math.round(2 + ratio * 4), 55, 1);
|
||||
}
|
||||
|
||||
function applyCorrosion(
|
||||
image: RgbaImage,
|
||||
context: CaptchaGeneratorContext,
|
||||
layout: TextLayout,
|
||||
): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const holes = Math.round(70 + ratio * 230);
|
||||
for (let index = 0; index < holes; index++) {
|
||||
const x = context.randomInt(layout.startX - 5, layout.startX + layout.textWidth + 5);
|
||||
const y = context.randomInt(layout.startY - 5, layout.startY + layout.textHeight + 5);
|
||||
const size = context.randomInt(1, ratio > 0.65 ? 4 : 3);
|
||||
fillRect(image, x, y, size, size, context.randomFloat() < 0.6 ? LIGHT_BACKGROUND : [203, 213, 225, 210]);
|
||||
}
|
||||
addDots(image, context, Math.round(170 + ratio * 430), 85, 90, 190, 2);
|
||||
addCrossingLines(image, context, Math.round(2 + ratio * 4), 60, 1);
|
||||
}
|
||||
|
||||
function applySpiderweb(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const nodes = Array.from({ length: Math.round(8 + ratio * 12) }, () => ({
|
||||
x: context.randomInt(0, image.width - 1),
|
||||
y: context.randomInt(0, image.height - 1),
|
||||
}));
|
||||
for (let index = 0; index < nodes.length; index++) {
|
||||
const current = nodes[index]!;
|
||||
const next = nodes[(index + context.randomInt(1, Math.max(1, nodes.length - 1))) % nodes.length]!;
|
||||
drawLine(image, current.x, current.y, next.x, next.y, [71, 85, 105, Math.round(65 + ratio * 65)], 1);
|
||||
if (index % 2 === 0) drawCircleOutline(image, current.x, current.y, context.randomInt(2, 5), [100, 116, 139, 70], 1);
|
||||
}
|
||||
const anchorX = context.randomInt(Math.floor(image.width * 0.25), Math.floor(image.width * 0.75));
|
||||
const anchorY = context.randomInt(Math.floor(image.height * 0.25), Math.floor(image.height * 0.75));
|
||||
for (let index = 0; index < Math.round(7 + ratio * 7); index++) {
|
||||
const angle = (index / Math.round(7 + ratio * 7)) * Math.PI * 2;
|
||||
drawLine(
|
||||
image,
|
||||
anchorX,
|
||||
anchorY,
|
||||
anchorX + Math.cos(angle) * image.width,
|
||||
anchorY + Math.sin(angle) * image.height,
|
||||
[71, 85, 105, 65],
|
||||
1,
|
||||
);
|
||||
}
|
||||
addDots(image, context, Math.round(80 + ratio * 200), 60);
|
||||
}
|
||||
|
||||
function applyCrossShadow(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const centerY = Math.floor(image.height / 2);
|
||||
drawLine(image, 0, centerY - 6, image.width - 1, centerY + 6, [37, 99, 235, 75], ratio > 0.55 ? 2 : 1);
|
||||
drawLine(image, 0, centerY + 7, image.width - 1, centerY - 8, [220, 38, 38, 65], ratio > 0.55 ? 2 : 1);
|
||||
addCrossingLines(image, context, Math.round(2 + ratio * 5), 55, 1);
|
||||
addDots(image, context, Math.round(120 + ratio * 300), 60);
|
||||
}
|
||||
|
||||
function applySplit(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const bandHeight = context.randomInt(7, 13);
|
||||
const amplitude = Math.round(4 + ratio * 13);
|
||||
shiftRows(image, (y) => {
|
||||
const band = Math.floor(y / bandHeight);
|
||||
return band % 2 === 0 ? amplitude : -amplitude;
|
||||
});
|
||||
for (let y = bandHeight; y < image.height; y += bandHeight) {
|
||||
drawLine(image, 0, y, image.width - 1, y, [100, 116, 139, 75], 1);
|
||||
}
|
||||
addDots(image, context, Math.round(90 + ratio * 250), 65);
|
||||
}
|
||||
|
||||
function applySplit2(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const stripWidth = context.randomInt(10, 18);
|
||||
const amplitude = Math.round(3 + ratio * 10);
|
||||
shiftColumns(image, (x) => {
|
||||
const strip = Math.floor(x / stripWidth);
|
||||
return strip % 2 === 0 ? amplitude : -amplitude;
|
||||
});
|
||||
for (let x = stripWidth; x < image.width; x += stripWidth) {
|
||||
drawLine(image, x, 0, x, image.height - 1, [100, 116, 139, 65], 1);
|
||||
}
|
||||
addCrossingLines(image, context, Math.round(2 + ratio * 4), 55, 1);
|
||||
}
|
||||
|
||||
function applyCut(
|
||||
image: RgbaImage,
|
||||
context: CaptchaGeneratorContext,
|
||||
layout: TextLayout,
|
||||
): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const cuts = Math.round(4 + ratio * 8);
|
||||
for (let index = 0; index < cuts; index++) {
|
||||
const x0 = context.randomInt(layout.startX - 10, layout.startX + layout.textWidth);
|
||||
const y0 = context.randomInt(layout.startY - 5, layout.startY + layout.textHeight + 5);
|
||||
const x1 = x0 + context.randomInt(24, 72);
|
||||
const y1 = y0 + context.randomInt(-18, 18);
|
||||
drawLine(image, x0, y0, x1, y1, [248, 250, 252, 245], ratio > 0.6 ? 3 : 2);
|
||||
drawLine(image, x0, y0 + 2, x1, y1 + 2, randomColor(context, 75, 170, 80), 1);
|
||||
}
|
||||
addDots(image, context, Math.round(90 + ratio * 260), 70);
|
||||
}
|
||||
|
||||
function applyDarts(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const targets = Math.round(2 + ratio * 3);
|
||||
for (let index = 0; index < targets; index++) {
|
||||
const x = context.randomInt(25, image.width - 25);
|
||||
const y = context.randomInt(16, image.height - 16);
|
||||
const maxRadius = context.randomInt(9, Math.round(14 + ratio * 11));
|
||||
for (let radius = maxRadius; radius >= 4; radius -= 5) {
|
||||
drawCircleOutline(image, x, y, radius, randomColor(context, 55, 175, 70), 1);
|
||||
}
|
||||
for (let ray = 0; ray < Math.round(3 + ratio * 4); ray++) {
|
||||
const angle = context.randomFloat() * Math.PI * 2;
|
||||
drawLine(
|
||||
image,
|
||||
x,
|
||||
y,
|
||||
x + Math.cos(angle) * context.randomInt(25, 70),
|
||||
y + Math.sin(angle) * context.randomInt(18, 55),
|
||||
randomColor(context, 55, 175, 70),
|
||||
1,
|
||||
);
|
||||
}
|
||||
}
|
||||
addDots(image, context, Math.round(100 + ratio * 240), 60);
|
||||
}
|
||||
|
||||
function applyDistortion(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const amplitudeX = 3 + ratio * 9;
|
||||
const amplitudeY = 2 + ratio * 5;
|
||||
const phase = context.randomFloat() * Math.PI * 2;
|
||||
shiftRows(image, (y) => Math.sin(y / (7 + ratio * 4) + phase) * amplitudeX);
|
||||
shiftColumns(image, (x) => Math.sin(x / (20 - ratio * 6) + phase) * amplitudeY);
|
||||
addCrossingLines(image, context, Math.round(3 + ratio * 5), 60, ratio > 0.7 ? 2 : 1);
|
||||
addDots(image, context, Math.round(100 + ratio * 280), 60);
|
||||
}
|
||||
|
||||
function applyStitch(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const rows = Math.round(4 + ratio * 5);
|
||||
for (let index = 0; index < rows; index++) {
|
||||
const y = Math.round(((index + 1) / (rows + 1)) * image.height);
|
||||
drawDashedLine(image, 0, y, image.width - 1, y + context.randomInt(-3, 3), [71, 85, 105, 75], 4, 4, 1);
|
||||
}
|
||||
const columns = Math.round(2 + ratio * 4);
|
||||
for (let index = 0; index < columns; index++) {
|
||||
const x = context.randomInt(10, image.width - 10);
|
||||
drawDashedLine(image, x, 0, x + context.randomInt(-5, 5), image.height - 1, [100, 116, 139, 65], 3, 5, 1);
|
||||
}
|
||||
for (let index = 0; index < Math.round(18 + ratio * 35); index++) {
|
||||
const x = context.randomInt(0, image.width - 1);
|
||||
const y = context.randomInt(0, image.height - 1);
|
||||
drawLine(image, x - 2, y - 2, x + 2, y + 2, [100, 116, 139, 65], 1);
|
||||
drawLine(image, x + 2, y - 2, x - 2, y + 2, [100, 116, 139, 65], 1);
|
||||
}
|
||||
}
|
||||
|
||||
function applyStriped(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const spacing = Math.round(10 - ratio * 4);
|
||||
const offset = context.randomInt(-image.height, image.width);
|
||||
for (let x = offset; x < image.width + image.height; x += spacing) {
|
||||
drawLine(image, x, 0, x - image.height, image.height - 1, [71, 85, 105, Math.round(45 + ratio * 45)], ratio > 0.7 ? 2 : 1);
|
||||
}
|
||||
for (let y = context.randomInt(3, 9); y < image.height; y += context.randomInt(8, 14)) {
|
||||
drawLine(image, 0, y, image.width - 1, y, [148, 163, 184, 45], 1);
|
||||
}
|
||||
addDots(image, context, Math.round(70 + ratio * 180), 55);
|
||||
}
|
||||
|
||||
function applyWave(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const phase = context.randomFloat() * Math.PI * 2;
|
||||
const amplitude = 5 + ratio * 13;
|
||||
shiftRows(image, (y) => Math.sin(y / (5.5 + ratio * 3) + phase) * amplitude);
|
||||
shiftColumns(image, (x) => Math.cos(x / (24 - ratio * 7) + phase) * (2 + ratio * 6));
|
||||
for (let index = 0; index < Math.round(2 + ratio * 3); index++) {
|
||||
const baseY = context.randomInt(10, image.height - 10);
|
||||
let previousX = 0;
|
||||
let previousY = baseY;
|
||||
for (let x = 8; x < image.width; x += 8) {
|
||||
const y = baseY + Math.sin(x / 17 + phase + index) * (5 + ratio * 5);
|
||||
drawLine(image, previousX, previousY, x, y, randomColor(context, 75, 180, 55), 1);
|
||||
previousX = x;
|
||||
previousY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyGridNoise(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const cell = Math.round(16 - ratio * 6);
|
||||
for (let x = 0; x < image.width; x += cell) {
|
||||
drawLine(image, x, 0, x, image.height - 1, [100, 116, 139, 55], 1);
|
||||
}
|
||||
for (let y = 0; y < image.height; y += cell) {
|
||||
drawLine(image, 0, y, image.width - 1, y, [100, 116, 139, 55], 1);
|
||||
}
|
||||
for (let index = 0; index < Math.round(10 + ratio * 26); index++) {
|
||||
const x = context.randomInt(0, Math.floor(image.width / cell)) * cell;
|
||||
const y = context.randomInt(0, Math.floor(image.height / cell)) * cell;
|
||||
fillRect(image, x, y, cell, cell, randomColor(context, 100, 220, Math.round(25 + ratio * 35)));
|
||||
}
|
||||
}
|
||||
|
||||
function applyScribble(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const scribbles = Math.round(3 + ratio * 6);
|
||||
for (let index = 0; index < scribbles; index++) {
|
||||
let x = context.randomInt(0, image.width - 1);
|
||||
let y = context.randomInt(0, image.height - 1);
|
||||
const segments = context.randomInt(7, Math.round(12 + ratio * 13));
|
||||
const color = randomColor(context, 45, 180, Math.round(55 + ratio * 45));
|
||||
for (let segment = 0; segment < segments; segment++) {
|
||||
const nextX = clamp(x + context.randomInt(-28, 28), 0, image.width - 1);
|
||||
const nextY = clamp(y + context.randomInt(-18, 18), 0, image.height - 1);
|
||||
drawLine(image, x, y, nextX, nextY, color, ratio > 0.7 && segment % 3 === 0 ? 2 : 1);
|
||||
x = nextX;
|
||||
y = nextY;
|
||||
}
|
||||
}
|
||||
addDots(image, context, Math.round(80 + ratio * 220), 55);
|
||||
}
|
||||
|
||||
function applyPixel(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const blocks = Math.round(28 + ratio * 75);
|
||||
for (let index = 0; index < blocks; index++) {
|
||||
const size = context.randomInt(2, Math.round(4 + ratio * 5));
|
||||
fillRect(
|
||||
image,
|
||||
context.randomInt(0, image.width - size),
|
||||
context.randomInt(0, image.height - size),
|
||||
size,
|
||||
size,
|
||||
randomColor(context, 65, 220, Math.round(45 + ratio * 45)),
|
||||
);
|
||||
}
|
||||
const source = cloneImage(image);
|
||||
for (let index = 0; index < Math.round(4 + ratio * 8); index++) {
|
||||
const blockWidth = context.randomInt(12, 28);
|
||||
const blockHeight = context.randomInt(6, 16);
|
||||
const x = context.randomInt(0, image.width - blockWidth);
|
||||
const y = context.randomInt(0, image.height - blockHeight);
|
||||
const shift = context.randomInt(-8, 8);
|
||||
for (let py = 0; py < blockHeight; py++) {
|
||||
for (let px = 0; px < blockWidth; px++) {
|
||||
const sourceX = clamp(x + px, 0, image.width - 1);
|
||||
const targetX = x + px + shift;
|
||||
putRawPixel(image, targetX, y + py, rawPixel(source, sourceX, y + py));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyBrokenLines(
|
||||
image: RgbaImage,
|
||||
context: CaptchaGeneratorContext,
|
||||
layout: TextLayout,
|
||||
): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const gaps = Math.round(12 + ratio * 35);
|
||||
for (let index = 0; index < gaps; index++) {
|
||||
const horizontal = context.randomFloat() < 0.7;
|
||||
const width = horizontal ? context.randomInt(6, 24) : context.randomInt(1, 4);
|
||||
const height = horizontal ? context.randomInt(1, 3) : context.randomInt(7, 18);
|
||||
fillRect(
|
||||
image,
|
||||
context.randomInt(layout.startX - 3, layout.startX + layout.textWidth),
|
||||
context.randomInt(layout.startY - 3, layout.startY + layout.textHeight),
|
||||
width,
|
||||
height,
|
||||
LIGHT_BACKGROUND,
|
||||
);
|
||||
}
|
||||
for (let index = 0; index < Math.round(12 + ratio * 28); index++) {
|
||||
const x = context.randomInt(0, image.width - 12);
|
||||
const y = context.randomInt(0, image.height - 1);
|
||||
drawLine(image, x, y, x + context.randomInt(4, 18), y + context.randomInt(-2, 2), randomColor(context, 45, 175, 75), 1);
|
||||
}
|
||||
addDots(image, context, Math.round(70 + ratio * 180), 60);
|
||||
}
|
||||
|
||||
function applyStyle(
|
||||
image: RgbaImage,
|
||||
context: CaptchaGeneratorContext,
|
||||
layout: TextLayout,
|
||||
): void {
|
||||
switch (context.imageStyle) {
|
||||
case "collision":
|
||||
applyCollision(image, context);
|
||||
break;
|
||||
case "snow":
|
||||
applySnow(image, context);
|
||||
break;
|
||||
case "corrosion":
|
||||
applyCorrosion(image, context, layout);
|
||||
break;
|
||||
case "spiderweb":
|
||||
applySpiderweb(image, context);
|
||||
break;
|
||||
case "cross-shadow":
|
||||
applyCrossShadow(image, context);
|
||||
break;
|
||||
case "split":
|
||||
applySplit(image, context);
|
||||
break;
|
||||
case "split2":
|
||||
applySplit2(image, context);
|
||||
break;
|
||||
case "cut":
|
||||
applyCut(image, context, layout);
|
||||
break;
|
||||
case "darts":
|
||||
applyDarts(image, context);
|
||||
break;
|
||||
case "distortion":
|
||||
applyDistortion(image, context);
|
||||
break;
|
||||
case "stitch":
|
||||
applyStitch(image, context);
|
||||
break;
|
||||
case "striped":
|
||||
applyStriped(image, context);
|
||||
break;
|
||||
case "wave":
|
||||
applyWave(image, context);
|
||||
break;
|
||||
case "grid-noise":
|
||||
applyGridNoise(image, context);
|
||||
break;
|
||||
case "scribble":
|
||||
applyScribble(image, context);
|
||||
break;
|
||||
case "pixel":
|
||||
applyPixel(image, context);
|
||||
break;
|
||||
case "broken-lines":
|
||||
applyBrokenLines(image, context, layout);
|
||||
break;
|
||||
default:
|
||||
applyClassic(image, context);
|
||||
}
|
||||
}
|
||||
|
||||
function backgroundFor(style: CaptchaConcreteImageStyle): Rgba {
|
||||
if (style === "snow" || style === "grid-noise" || style === "stitch") return ALT_BACKGROUND;
|
||||
return LIGHT_BACKGROUND;
|
||||
}
|
||||
|
||||
export function renderTextChallenge(
|
||||
text: string,
|
||||
context: CaptchaGeneratorContext,
|
||||
): string {
|
||||
const layout = layoutFor(text, context.imageStyle);
|
||||
const image = createImage(300, 104, backgroundFor(context.imageStyle));
|
||||
|
||||
if (context.imageStyle === "grid-noise") applyGridNoise(image, context);
|
||||
else if (context.imageStyle === "snow") addDots(image, context, 130, 45, 160, 235, 1);
|
||||
else addDots(image, context, Math.round(35 + disturbanceRatio(context) * 80), 30);
|
||||
|
||||
drawCharacters(image, text, context, layout, context.imageStyle);
|
||||
applyStyle(image, context, layout);
|
||||
|
||||
if (context.imageStyle !== "snow" && context.imageStyle !== "grid-noise") {
|
||||
const ratio = disturbanceRatio(context);
|
||||
addDots(image, context, Math.round(25 + ratio * 100), 35, 100, 220);
|
||||
}
|
||||
|
||||
return pngDataUri(image);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export interface CaptchaClientProviderDefinition {
|
||||
name: "turnstile" | "recaptcha" | "hcaptcha";
|
||||
scriptUrl: string;
|
||||
responseField: string;
|
||||
globalName: string;
|
||||
}
|
||||
|
||||
export const captchaClientProviders: Record<string, CaptchaClientProviderDefinition> = {
|
||||
turnstile: {
|
||||
name: "turnstile",
|
||||
scriptUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit",
|
||||
responseField: "cf-turnstile-response",
|
||||
globalName: "turnstile",
|
||||
},
|
||||
recaptcha: {
|
||||
name: "recaptcha",
|
||||
scriptUrl: "https://www.google.com/recaptcha/api.js?render=explicit",
|
||||
responseField: "g-recaptcha-response",
|
||||
globalName: "grecaptcha",
|
||||
},
|
||||
hcaptcha: {
|
||||
name: "hcaptcha",
|
||||
scriptUrl: "https://js.hcaptcha.com/1/api.js?render=explicit",
|
||||
responseField: "h-captcha-response",
|
||||
globalName: "hcaptcha",
|
||||
},
|
||||
};
|
||||
|
||||
export function getCaptchaResponse(form: HTMLFormElement, field = "wrn-captcha-response"): string {
|
||||
const value = new FormData(form).get(field);
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
export function resetCaptchaElement(element: Element): void {
|
||||
element.dispatchEvent(new CustomEvent("captcha-reset", { bubbles: true }));
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
export function defaultRandomBytes(length: number): Uint8Array {
|
||||
if (!Number.isInteger(length) || length < 1) throw new RangeError("random byte length must be positive");
|
||||
const bytes = new Uint8Array(length);
|
||||
crypto.getRandomValues(bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function bytesToBase64Url(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
export function randomId(randomBytes = defaultRandomBytes, length = 24): string {
|
||||
return bytesToBase64Url(randomBytes(length));
|
||||
}
|
||||
|
||||
function bytesToHex(bytes: Uint8Array): string {
|
||||
let out = "";
|
||||
for (const byte of bytes) out += byte.toString(16).padStart(2, "0");
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function sha256(value: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value));
|
||||
return bytesToHex(new Uint8Array(digest));
|
||||
}
|
||||
|
||||
export async function hmacSha256(secret: string, value: string): Promise<string> {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(value));
|
||||
return bytesToHex(new Uint8Array(signature));
|
||||
}
|
||||
|
||||
export function constantTimeEqual(left: string, right: string): boolean {
|
||||
if (left.length !== right.length) return false;
|
||||
let difference = 0;
|
||||
for (let index = 0; index < left.length; index++) {
|
||||
difference |= left.charCodeAt(index) ^ right.charCodeAt(index);
|
||||
}
|
||||
return difference === 0;
|
||||
}
|
||||
|
||||
export async function bindingHash(value: string | undefined): Promise<string | undefined> {
|
||||
if (!value) return undefined;
|
||||
return sha256(value);
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
import { defaultCaptchaGenerators } from "./challenges/index.ts";
|
||||
import { resolveCaptchaImageStyle } from "./challenges/styles.ts";
|
||||
import { AssetAudioRenderer } from "./audio/renderer.ts";
|
||||
import { bindingHash, constantTimeEqual, defaultRandomBytes, hmacSha256, randomId, sha256 } from "./crypto.ts";
|
||||
import { normalizeSelections, normalizeTextAnswer, normalizedSubmittedAnswer } from "./normalize.ts";
|
||||
import { MemoryCaptchaStore } from "./stores/memory.ts";
|
||||
import type {
|
||||
CaptchaBinding,
|
||||
CaptchaChallenge,
|
||||
CaptchaChallengeGenerator,
|
||||
CaptchaChallengeRecord,
|
||||
CaptchaChallengeType,
|
||||
CaptchaDifficulty,
|
||||
CaptchaEngine,
|
||||
CaptchaEngineOptions,
|
||||
CaptchaGeneratorContext,
|
||||
CaptchaResponseTokenRecord,
|
||||
CaptchaStore,
|
||||
CaptchaAudioRenderer,
|
||||
CaptchaVerificationResult,
|
||||
CreateCaptchaOptions,
|
||||
VerifyCaptchaInput,
|
||||
} from "./types.ts";
|
||||
|
||||
const DEFAULT_CHALLENGE_TTL_MS = 120_000;
|
||||
const DEFAULT_TOKEN_TTL_MS = 300_000;
|
||||
const DEFAULT_MAX_ATTEMPTS = 3;
|
||||
const DEFAULT_MIN_COMPLETION_MS = 800;
|
||||
const DEFAULT_RESPONSE_FIELD = "wrn-captcha-response";
|
||||
|
||||
function failure(
|
||||
action: string,
|
||||
code: string,
|
||||
message: string,
|
||||
challengeId?: string,
|
||||
): CaptchaVerificationResult {
|
||||
return { success: false, provider: "self-hosted", action, code, message, challengeId };
|
||||
}
|
||||
|
||||
function assertAction(action: string): string {
|
||||
const value = action.trim();
|
||||
if (!value || value.length > 128 || !/^[a-z0-9][a-z0-9:._/-]*$/i.test(value)) {
|
||||
throw new TypeError("CAPTCHA action must be a non-empty stable identifier up to 128 characters");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertPositiveInteger(value: number, name: string): number {
|
||||
if (!Number.isInteger(value) || value < 1) throw new RangeError(`${name} must be a positive integer`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeDisturbance(value: number | undefined, difficulty: CaptchaDifficulty): number {
|
||||
const fallback = difficulty === "easy" ? 25 : difficulty === "hard" ? 75 : 50;
|
||||
if (value === undefined) return fallback;
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) throw new RangeError("disturbance must be a finite number between 25 and 75");
|
||||
const normalized = Math.round(numeric);
|
||||
if (normalized < 25 || normalized > 75) {
|
||||
throw new RangeError("disturbance must be between 25 and 75");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function randomInteger(randomBytes: (length: number) => Uint8Array, min: number, max: number): number {
|
||||
if (!Number.isInteger(min) || !Number.isInteger(max) || max < min) throw new RangeError("invalid random range");
|
||||
const span = max - min + 1;
|
||||
if (span === 1) return min;
|
||||
const limit = Math.floor(0x1_0000_0000 / span) * span;
|
||||
while (true) {
|
||||
const bytes = randomBytes(4);
|
||||
const value = ((bytes[0]! << 24) | (bytes[1]! << 16) | (bytes[2]! << 8) | bytes[3]!) >>> 0;
|
||||
if (value < limit) return min + (value % span);
|
||||
}
|
||||
}
|
||||
|
||||
async function matchesHash(expected: string | undefined, raw: string | undefined): Promise<boolean> {
|
||||
if (!expected) return true;
|
||||
if (!raw) return false;
|
||||
return constantTimeEqual(expected, await bindingHash(raw) ?? "");
|
||||
}
|
||||
|
||||
export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
readonly provider = "self-hosted" as const;
|
||||
readonly basePath: string;
|
||||
private readonly secret: string;
|
||||
private readonly store: CaptchaStore;
|
||||
private readonly generators = new Map<CaptchaChallengeType, CaptchaChallengeGenerator>();
|
||||
private readonly audioRenderer: CaptchaAudioRenderer;
|
||||
private readonly challengeTtlMs: number;
|
||||
private readonly responseTokenTtlMs: number;
|
||||
private readonly maxAttempts: number;
|
||||
private readonly minCompletionMs: number;
|
||||
private readonly responseField: string;
|
||||
private readonly defaultType: CaptchaChallengeType;
|
||||
private readonly defaultDifficulty: CaptchaDifficulty;
|
||||
private readonly bindIp: boolean;
|
||||
private readonly now: () => number;
|
||||
private readonly randomBytes: (length: number) => Uint8Array;
|
||||
|
||||
constructor(options: CaptchaEngineOptions) {
|
||||
if (!options.secret || options.secret.length < 32) {
|
||||
throw new TypeError("CAPTCHA secret must contain at least 32 characters");
|
||||
}
|
||||
this.secret = options.secret;
|
||||
this.store = options.store ?? new MemoryCaptchaStore();
|
||||
this.audioRenderer = options.audioRenderer ?? new AssetAudioRenderer();
|
||||
this.basePath = `/${(options.basePath ?? "/__wrnexus/captcha").replace(/^\/+|\/+$/g, "")}`;
|
||||
this.challengeTtlMs = assertPositiveInteger(options.challengeTtlMs ?? DEFAULT_CHALLENGE_TTL_MS, "challengeTtlMs");
|
||||
this.responseTokenTtlMs = assertPositiveInteger(options.responseTokenTtlMs ?? DEFAULT_TOKEN_TTL_MS, "responseTokenTtlMs");
|
||||
this.maxAttempts = assertPositiveInteger(options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS, "maxAttempts");
|
||||
this.minCompletionMs = Math.max(0, options.minCompletionMs ?? DEFAULT_MIN_COMPLETION_MS);
|
||||
this.responseField = options.responseField ?? DEFAULT_RESPONSE_FIELD;
|
||||
this.defaultType = options.defaultType ?? "alphanumeric";
|
||||
this.defaultDifficulty = options.defaultDifficulty ?? "normal";
|
||||
this.bindIp = options.bindIp ?? false;
|
||||
this.now = options.now ?? Date.now;
|
||||
this.randomBytes = options.randomBytes ?? defaultRandomBytes;
|
||||
for (const generator of options.generators ?? defaultCaptchaGenerators()) this.generators.set(generator.type, generator);
|
||||
if (!this.generators.has(this.defaultType)) throw new Error(`No CAPTCHA generator registered for ${this.defaultType}`);
|
||||
}
|
||||
|
||||
async create(options: CreateCaptchaOptions): Promise<CaptchaChallenge> {
|
||||
const action = assertAction(options.action);
|
||||
const requestedType = options.type ?? this.defaultType;
|
||||
const requestedPresentation = options.presentation ?? (
|
||||
requestedType === "honeypot" || requestedType === "timing" || requestedType === "not-robot"
|
||||
? "invisible"
|
||||
: "visual"
|
||||
);
|
||||
const actualType = requestedPresentation === "audio" && requestedType === "image" ? "number" : requestedType;
|
||||
const generator = this.generators.get(actualType);
|
||||
if (!generator) throw new Error(`No CAPTCHA generator registered for ${actualType}`);
|
||||
const now = this.now();
|
||||
const difficulty = options.difficulty ?? this.defaultDifficulty;
|
||||
const disturbance = normalizeDisturbance(options.disturbance, difficulty);
|
||||
const randomInt = (min: number, max: number): number =>
|
||||
randomInteger(this.randomBytes, min, max);
|
||||
const imageStyle = resolveCaptchaImageStyle({
|
||||
imageStyle: options.imageStyle,
|
||||
allowedStyles: options.allowedStyles,
|
||||
excludedStyles: options.excludedStyles,
|
||||
randomizeStyle: options.randomizeStyle ?? false,
|
||||
randomInt,
|
||||
});
|
||||
const context: CaptchaGeneratorContext = {
|
||||
difficulty,
|
||||
disturbance,
|
||||
imageStyle: imageStyle.resolved,
|
||||
requestedImageStyle: imageStyle.requested,
|
||||
imageStylePool: imageStyle.pool,
|
||||
locale: options.locale ?? "en",
|
||||
length: options.length,
|
||||
caseSensitive: options.caseSensitive ?? false,
|
||||
minCompletionMs: Math.max(0, options.minCompletionMs ?? this.minCompletionMs),
|
||||
randomInt,
|
||||
randomFloat: () => randomInteger(this.randomBytes, 0, 0xffff_ffff) / 0xffff_ffff,
|
||||
randomId: (bytes = 18) => randomId(this.randomBytes, bytes),
|
||||
};
|
||||
const generated = await generator.generate(context);
|
||||
const id = randomId(this.randomBytes, 24);
|
||||
const answerSalt = randomId(this.randomBytes, 16);
|
||||
const caseSensitive = options.caseSensitive ?? false;
|
||||
const normalizedAnswer = generated.answerKind === "selections"
|
||||
? normalizeSelections(generated.answer.split(","))
|
||||
: generated.answerKind === "text"
|
||||
? normalizeTextAnswer(generated.answer, caseSensitive)
|
||||
: generated.answer;
|
||||
const answerDigest = await hmacSha256(this.secret, `${id}:${answerSalt}:${normalizedAnswer}`);
|
||||
const expiresAt = now + assertPositiveInteger(options.expiresInMs ?? this.challengeTtlMs, "expiresInMs");
|
||||
const maxAttempts = assertPositiveInteger(options.maxAttempts ?? this.maxAttempts, "maxAttempts");
|
||||
const audioKey = generated.audioSequence?.length ? randomId(this.randomBytes, 18) : undefined;
|
||||
const responseField = options.responseField ?? this.responseField;
|
||||
const presentation = requestedPresentation === "audio" && generated.audioSequence?.length
|
||||
? "audio"
|
||||
: generated.presentation;
|
||||
|
||||
const publicChallenge: CaptchaChallenge = {
|
||||
id,
|
||||
provider: "self-hosted",
|
||||
type: generated.type,
|
||||
presentation,
|
||||
action,
|
||||
prompt: presentation === "audio" ? "Listen and enter the spoken answer" : generated.prompt,
|
||||
createdAt: now,
|
||||
expiresAt,
|
||||
responseField,
|
||||
inputMode: generated.inputMode,
|
||||
image: presentation === "audio" ? undefined : generated.image,
|
||||
items: presentation === "audio" ? undefined : generated.items,
|
||||
minSelections: generated.minSelections,
|
||||
maxSelections: generated.maxSelections,
|
||||
audioUrl: audioKey ? `${this.basePath}/audio/${encodeURIComponent(id)}?key=${encodeURIComponent(audioKey)}` : undefined,
|
||||
refreshUrl: `${this.basePath}/challenge`,
|
||||
verifyUrl: `${this.basePath}/verify`,
|
||||
honeypotField: String(generated.metadata?.honeypotField ?? "") || undefined,
|
||||
timingToken: String(generated.metadata?.timingToken ?? "") || undefined,
|
||||
metadata: {
|
||||
difficulty,
|
||||
disturbance,
|
||||
imageStyle: context.imageStyle,
|
||||
requestedImageStyle: context.requestedImageStyle,
|
||||
imageStylePool: [...context.imageStylePool],
|
||||
locale: context.locale,
|
||||
...(generated.answerKind === "invisible" ? { minCompletionMs: context.minCompletionMs } : {}),
|
||||
...(generated.metadata?.interaction ? { interaction: generated.metadata.interaction } : {}),
|
||||
...(requestedType === "image" && actualType !== requestedType ? { alternativeFor: requestedType } : {}),
|
||||
...options.metadata,
|
||||
},
|
||||
};
|
||||
|
||||
const record: CaptchaChallengeRecord = {
|
||||
id,
|
||||
provider: "self-hosted",
|
||||
type: generated.type,
|
||||
presentation,
|
||||
action,
|
||||
publicChallenge,
|
||||
answerDigest,
|
||||
answerSalt,
|
||||
answerKind: generated.answerKind,
|
||||
caseSensitive,
|
||||
createdAt: now,
|
||||
expiresAt,
|
||||
attempts: 0,
|
||||
maxAttempts,
|
||||
hostnameHash: await bindingHash(options.hostname),
|
||||
sessionHash: await bindingHash(options.sessionId),
|
||||
ipHash: this.bindIp ? await bindingHash(options.ip) : undefined,
|
||||
metadata: {
|
||||
...generated.metadata,
|
||||
audioKey,
|
||||
audioSequence: generated.audioSequence,
|
||||
locale: context.locale,
|
||||
imageStyle: context.imageStyle,
|
||||
requestedImageStyle: context.requestedImageStyle,
|
||||
imageStylePool: [...context.imageStylePool],
|
||||
minCompletionMs: context.minCompletionMs,
|
||||
},
|
||||
};
|
||||
await this.store.createChallenge(record);
|
||||
return structuredClone(publicChallenge);
|
||||
}
|
||||
|
||||
async verify(input: VerifyCaptchaInput): Promise<CaptchaVerificationResult> {
|
||||
if (input.responseToken) return this.verifyResponseToken(input);
|
||||
const action = assertAction(input.action);
|
||||
if (!input.challengeId) return failure(action, "missing-input", "Missing CAPTCHA challenge id");
|
||||
const now = this.now();
|
||||
const record = await this.store.getChallenge(input.challengeId);
|
||||
if (!record) return failure(action, "invalid-input", "Unknown CAPTCHA challenge", input.challengeId);
|
||||
if (record.expiresAt <= now) return failure(action, "expired", "The CAPTCHA challenge expired", record.id);
|
||||
if (record.consumedAt) return failure(action, "already-used", "The CAPTCHA challenge was already used", record.id);
|
||||
const bindingFailure = await this.checkChallengeBinding(record, input, action);
|
||||
if (bindingFailure) return bindingFailure;
|
||||
|
||||
const attempted = await this.store.incrementAttempts(record.id, now);
|
||||
if (!attempted) return failure(action, "already-used", "The CAPTCHA challenge is no longer available", record.id);
|
||||
if (attempted.attempts > attempted.maxAttempts) {
|
||||
await this.store.consumeChallenge(attempted.id, now);
|
||||
return failure(action, "attempts-exhausted", "Too many CAPTCHA attempts", record.id);
|
||||
}
|
||||
|
||||
if (attempted.answerKind === "invisible") {
|
||||
const minimum = Number(attempted.metadata.minCompletionMs ?? this.minCompletionMs);
|
||||
if (now - attempted.createdAt < minimum) {
|
||||
return failure(action, "risk-rejected", "The form was completed too quickly", record.id);
|
||||
}
|
||||
}
|
||||
|
||||
const submitted = normalizedSubmittedAnswer(attempted, input);
|
||||
const digest = await hmacSha256(this.secret, `${attempted.id}:${attempted.answerSalt}:${submitted}`);
|
||||
if (!constantTimeEqual(attempted.answerDigest, digest)) {
|
||||
const exhausted = attempted.attempts >= attempted.maxAttempts;
|
||||
if (exhausted) await this.store.consumeChallenge(attempted.id, now);
|
||||
return failure(
|
||||
action,
|
||||
exhausted ? "attempts-exhausted" : "incorrect-answer",
|
||||
exhausted ? "Too many CAPTCHA attempts" : "The CAPTCHA answer is incorrect",
|
||||
attempted.id,
|
||||
);
|
||||
}
|
||||
|
||||
const consumed = await this.store.consumeChallenge(attempted.id, now);
|
||||
if (!consumed) return failure(action, "already-used", "The CAPTCHA challenge was already used", attempted.id);
|
||||
const plainToken = randomId(this.randomBytes, 32);
|
||||
const tokenHash = await sha256(plainToken);
|
||||
const expiresAt = now + this.responseTokenTtlMs;
|
||||
const tokenRecord: CaptchaResponseTokenRecord = {
|
||||
tokenHash,
|
||||
provider: "self-hosted",
|
||||
challengeId: consumed.id,
|
||||
action,
|
||||
createdAt: now,
|
||||
expiresAt,
|
||||
hostnameHash: consumed.hostnameHash,
|
||||
sessionHash: consumed.sessionHash,
|
||||
ipHash: consumed.ipHash,
|
||||
metadata: optionsMetadata(consumed),
|
||||
};
|
||||
await this.store.createToken(tokenRecord);
|
||||
return {
|
||||
success: true,
|
||||
provider: "self-hosted",
|
||||
action,
|
||||
responseToken: plainToken,
|
||||
expiresAt,
|
||||
hostname: input.hostname,
|
||||
challengeId: consumed.id,
|
||||
};
|
||||
}
|
||||
|
||||
async verifyResponseToken(input: VerifyCaptchaInput): Promise<CaptchaVerificationResult> {
|
||||
const action = assertAction(input.action);
|
||||
const token = input.responseToken ?? input.providerToken;
|
||||
if (!token) return failure(action, "missing-input", "Missing CAPTCHA response token");
|
||||
if (token.length > 4096) return failure(action, "invalid-input", "CAPTCHA token is too long");
|
||||
const now = this.now();
|
||||
const tokenHash = await sha256(token);
|
||||
const existing = await this.store.getToken(tokenHash);
|
||||
if (!existing) return failure(action, "invalid-input", "Unknown CAPTCHA response token");
|
||||
if (existing.expiresAt <= now) return failure(action, "expired", "The CAPTCHA response token expired", existing.challengeId);
|
||||
if (existing.consumedAt) return failure(action, "already-used", "The CAPTCHA response token was already used", existing.challengeId);
|
||||
if (existing.action !== action) return failure(action, "action-mismatch", "The CAPTCHA action does not match", existing.challengeId);
|
||||
const bindingFailure = await this.checkTokenBinding(existing, input, action);
|
||||
if (bindingFailure) return bindingFailure;
|
||||
const record = input.consume === false ? existing : await this.store.consumeToken(tokenHash, now);
|
||||
if (!record) return failure(action, "already-used", "The CAPTCHA response token was already used", existing.challengeId);
|
||||
return {
|
||||
success: true,
|
||||
provider: "self-hosted",
|
||||
action,
|
||||
expiresAt: record.expiresAt,
|
||||
hostname: input.hostname,
|
||||
challengeId: record.challengeId,
|
||||
score: record.score,
|
||||
metadata: record.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
async renderAudio(challengeId: string, key: string): Promise<{ bytes: Uint8Array; contentType: string } | undefined> {
|
||||
const record = await this.store.getChallenge(challengeId);
|
||||
if (!record || record.expiresAt <= this.now() || record.consumedAt) return undefined;
|
||||
const expectedKey = String(record.metadata.audioKey ?? "");
|
||||
if (!expectedKey || !constantTimeEqual(expectedKey, key)) return undefined;
|
||||
const sequence = record.metadata.audioSequence;
|
||||
if (!Array.isArray(sequence) || !sequence.every((value) => typeof value === "string")) return undefined;
|
||||
const bytes = await this.audioRenderer.render(sequence, String(record.metadata.locale ?? "en"));
|
||||
return { bytes, contentType: this.audioRenderer.contentType ?? "audio/wav" };
|
||||
}
|
||||
|
||||
async gc(): Promise<void> {
|
||||
await this.store.gc?.(this.now());
|
||||
}
|
||||
|
||||
private async checkChallengeBinding(
|
||||
record: CaptchaChallengeRecord,
|
||||
input: CaptchaBinding,
|
||||
action: string,
|
||||
): Promise<CaptchaVerificationResult | undefined> {
|
||||
if (record.action !== action) return failure(action, "action-mismatch", "The CAPTCHA action does not match", record.id);
|
||||
if (!(await matchesHash(record.hostnameHash, input.hostname))) {
|
||||
return failure(action, "hostname-mismatch", "The CAPTCHA hostname does not match", record.id);
|
||||
}
|
||||
if (!(await matchesHash(record.sessionHash, input.sessionId))) {
|
||||
return failure(action, "session-mismatch", "The CAPTCHA session does not match", record.id);
|
||||
}
|
||||
if (!(await matchesHash(record.ipHash, input.ip))) {
|
||||
return failure(action, "ip-mismatch", "The CAPTCHA network binding does not match", record.id);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async checkTokenBinding(
|
||||
record: CaptchaResponseTokenRecord,
|
||||
input: CaptchaBinding,
|
||||
action: string,
|
||||
): Promise<CaptchaVerificationResult | undefined> {
|
||||
if (!(await matchesHash(record.hostnameHash, input.hostname))) {
|
||||
return failure(action, "hostname-mismatch", "The CAPTCHA hostname does not match", record.challengeId);
|
||||
}
|
||||
if (!(await matchesHash(record.sessionHash, input.sessionId))) {
|
||||
return failure(action, "session-mismatch", "The CAPTCHA session does not match", record.challengeId);
|
||||
}
|
||||
if (!(await matchesHash(record.ipHash, input.ip))) {
|
||||
return failure(action, "ip-mismatch", "The CAPTCHA network binding does not match", record.challengeId);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function optionsMetadata(record: CaptchaChallengeRecord): Record<string, unknown> {
|
||||
const metadata = { ...record.publicChallenge.metadata };
|
||||
delete metadata.secret;
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function createCaptchaEngine(options: CaptchaEngineOptions): CaptchaEngine {
|
||||
return new DefaultCaptchaEngine(options);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import type {
|
||||
CaptchaEngine,
|
||||
CaptchaHttpHandlers,
|
||||
CreateCaptchaOptions,
|
||||
VerifyCaptchaInput,
|
||||
} from "./types.ts";
|
||||
|
||||
export interface CaptchaHttpOptions {
|
||||
allowedOrigins?: string[];
|
||||
trustProxy?: boolean;
|
||||
createLimit?: number;
|
||||
verifyLimit?: number;
|
||||
windowMs?: number;
|
||||
}
|
||||
|
||||
interface Counter {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
function json(body: unknown, status = 200, headers: HeadersInit = {}): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"cache-control": "no-store, max-age=0",
|
||||
pragma: "no-cache",
|
||||
"x-content-type-options": "nosniff",
|
||||
...headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function readPayload(request: Request): Promise<Record<string, unknown>> {
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) return (await request.json()) as Record<string, unknown>;
|
||||
if (contentType.includes("form")) {
|
||||
const form = await request.formData();
|
||||
const payload: Record<string, unknown> = {};
|
||||
for (const [key, value] of form) {
|
||||
if (key === "selections") {
|
||||
const current = payload[key];
|
||||
payload[key] = Array.isArray(current) ? [...current, String(value)] : [String(value)];
|
||||
} else payload[key] = typeof value === "string" ? value : value.name;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function clientIp(request: Request, ctx: Context | undefined, trustProxy: boolean): string {
|
||||
if (trustProxy) {
|
||||
const forwarded = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
|
||||
if (forwarded) return forwarded;
|
||||
const real = request.headers.get("x-real-ip");
|
||||
if (real) return real;
|
||||
}
|
||||
return ctx?.ip ?? "global";
|
||||
}
|
||||
|
||||
function requestBinding(request: Request, ctx: Context | undefined, trustProxy: boolean) {
|
||||
const url = new URL(request.url);
|
||||
return {
|
||||
hostname: url.hostname,
|
||||
sessionId: ctx?.session.id(),
|
||||
ip: clientIp(request, ctx, trustProxy),
|
||||
};
|
||||
}
|
||||
|
||||
export function createCaptchaHttpHandlers(
|
||||
engine: CaptchaEngine,
|
||||
options: CaptchaHttpOptions = {},
|
||||
): CaptchaHttpHandlers {
|
||||
const counters = new Map<string, Counter>();
|
||||
const windowMs = options.windowMs ?? 60_000;
|
||||
const originAllowed = (request: Request): boolean => {
|
||||
const origin = request.headers.get("origin");
|
||||
if (!origin) return true;
|
||||
const own = new URL(request.url).origin;
|
||||
return origin === own || options.allowedOrigins?.includes(origin) === true;
|
||||
};
|
||||
const withinLimit = (key: string, maximum: number): { allowed: boolean; retryAfter: number } => {
|
||||
const now = Date.now();
|
||||
let counter = counters.get(key);
|
||||
if (!counter || counter.resetAt <= now) {
|
||||
counter = { count: 0, resetAt: now + windowMs };
|
||||
counters.set(key, counter);
|
||||
}
|
||||
counter.count += 1;
|
||||
return { allowed: counter.count <= maximum, retryAfter: Math.max(1, Math.ceil((counter.resetAt - now) / 1000)) };
|
||||
};
|
||||
|
||||
const create = async (request: Request, ctx?: Context): Promise<Response> => {
|
||||
if (!originAllowed(request)) return json({ success: false, code: "origin-rejected" }, 403);
|
||||
if (request.method !== "POST") return json({ success: false, code: "method-not-allowed" }, 405, { allow: "POST" });
|
||||
const ip = clientIp(request, ctx, options.trustProxy ?? false);
|
||||
const limit = withinLimit(`create:${ip}`, options.createLimit ?? 30);
|
||||
if (!limit.allowed) return json({ success: false, code: "rate-limited" }, 429, { "retry-after": String(limit.retryAfter) });
|
||||
try {
|
||||
const payload = await readPayload(request);
|
||||
const challenge = await engine.create({
|
||||
...(payload as unknown as CreateCaptchaOptions),
|
||||
...requestBinding(request, ctx, options.trustProxy ?? false),
|
||||
});
|
||||
return json(challenge, 201);
|
||||
} catch (error) {
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
code: "invalid-input",
|
||||
message: error instanceof Error ? error.message : "Unable to create CAPTCHA challenge",
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const verify = async (request: Request, ctx?: Context): Promise<Response> => {
|
||||
if (!originAllowed(request)) return json({ success: false, code: "origin-rejected" }, 403);
|
||||
if (request.method !== "POST") return json({ success: false, code: "method-not-allowed" }, 405, { allow: "POST" });
|
||||
const ip = clientIp(request, ctx, options.trustProxy ?? false);
|
||||
const limit = withinLimit(`verify:${ip}`, options.verifyLimit ?? 60);
|
||||
if (!limit.allowed) return json({ success: false, code: "rate-limited" }, 429, { "retry-after": String(limit.retryAfter) });
|
||||
try {
|
||||
const payload = await readPayload(request);
|
||||
const result = await engine.verify({
|
||||
...(payload as unknown as VerifyCaptchaInput),
|
||||
...requestBinding(request, ctx, options.trustProxy ?? false),
|
||||
});
|
||||
return json(result, result.success ? 200 : 400);
|
||||
} catch (error) {
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
code: "invalid-input",
|
||||
message: error instanceof Error ? error.message : "Unable to verify CAPTCHA",
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const audio = async (request: Request): Promise<Response> => {
|
||||
if (!originAllowed(request)) return new Response("Forbidden", { status: 403 });
|
||||
if (request.method !== "GET" && request.method !== "HEAD") return new Response("Method Not Allowed", { status: 405 });
|
||||
const url = new URL(request.url);
|
||||
const prefix = `${engine.basePath}/audio/`;
|
||||
const id = decodeURIComponent(url.pathname.slice(prefix.length));
|
||||
const key = url.searchParams.get("key") ?? "";
|
||||
try {
|
||||
const audio = await engine.renderAudio(id, key);
|
||||
if (!audio) return new Response("Not Found", { status: 404 });
|
||||
|
||||
const body: BodyInit | null =
|
||||
request.method === "HEAD" ? null : Uint8Array.from(audio.bytes).buffer;
|
||||
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"content-type": audio.contentType,
|
||||
"content-length": String(audio.bytes.byteLength),
|
||||
"cache-control": "private, no-store, max-age=0",
|
||||
"x-content-type-options": "nosniff",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[wrnexus/captcha] Unable to render CAPTCHA audio:",
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
return new Response("CAPTCHA audio is unavailable", {
|
||||
status: 503,
|
||||
headers: {
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
"cache-control": "no-store, max-age=0",
|
||||
"x-content-type-options": "nosniff",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
create,
|
||||
verify,
|
||||
audio,
|
||||
async handle(request, ctx) {
|
||||
const pathname = new URL(request.url).pathname;
|
||||
if (pathname === `${engine.basePath}/challenge`) return create(request, ctx);
|
||||
if (pathname === `${engine.basePath}/verify`) return verify(request, ctx);
|
||||
if (pathname.startsWith(`${engine.basePath}/audio/`)) return audio(request);
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export * from "./types.ts";
|
||||
export * from "./engine.ts";
|
||||
export * from "./http.ts";
|
||||
export * from "./middleware.ts";
|
||||
export * from "./policy.ts";
|
||||
export * from "./validation.ts";
|
||||
export * from "./plugin.ts";
|
||||
export * from "./stores/memory.ts";
|
||||
export * from "./stores/sqlite.ts";
|
||||
export * from "./stores/redis.ts";
|
||||
export * from "./providers/index.ts";
|
||||
export * from "./challenges/index.ts";
|
||||
export * from "./audio/index.ts";
|
||||
export * from "./crypto.ts";
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import { shouldRequireCaptcha, validCaptchaGrant, type CaptchaSessionGrant } from "./policy.ts";
|
||||
import { selfHostedProvider } from "./providers/self-hosted.ts";
|
||||
import type {
|
||||
CaptchaGuardOptions,
|
||||
CaptchaPageGateOptions,
|
||||
CaptchaProvider,
|
||||
CaptchaVerificationResult,
|
||||
} from "./types.ts";
|
||||
|
||||
const DEFAULT_FIELD = "wrn-captcha-response";
|
||||
|
||||
function resolveAction(value: CaptchaGuardOptions["action"], ctx: Context): string {
|
||||
return typeof value === "function" ? value(ctx) : value;
|
||||
}
|
||||
|
||||
async function bodyValue(request: Request, field: string): Promise<string | undefined> {
|
||||
const header = request.headers.get("x-wrn-captcha-token");
|
||||
if (header) return header;
|
||||
if (request.method === "GET" || request.method === "HEAD") return undefined;
|
||||
const clone = request.clone();
|
||||
const contentType = clone.headers.get("content-type") ?? "";
|
||||
try {
|
||||
if (contentType.includes("application/json")) {
|
||||
const body = (await clone.json()) as Record<string, unknown>;
|
||||
const value = body[field] ?? body.captchaToken ?? body.responseToken;
|
||||
return value === undefined ? undefined : String(value);
|
||||
}
|
||||
if (contentType.includes("form")) {
|
||||
const form = await clone.formData();
|
||||
const value = form.get(field);
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function providerFor(options: CaptchaGuardOptions): CaptchaProvider {
|
||||
if (options.provider) return options.provider;
|
||||
if (options.engine) return selfHostedProvider(options.engine);
|
||||
throw new TypeError("captchaGuard requires provider or engine");
|
||||
}
|
||||
|
||||
async function verifyRequest(
|
||||
ctx: Context,
|
||||
options: CaptchaGuardOptions,
|
||||
): Promise<CaptchaVerificationResult> {
|
||||
const provider = providerFor(options);
|
||||
const action = resolveAction(options.action, ctx);
|
||||
const field = options.responseField ?? provider.client.responseField ?? DEFAULT_FIELD;
|
||||
const token = await bodyValue(ctx.req, field);
|
||||
return provider.verify({
|
||||
action,
|
||||
providerToken: token,
|
||||
responseToken: token,
|
||||
hostname: options.bindHostname === false ? undefined : ctx.url.hostname,
|
||||
sessionId: options.bindSession === false ? undefined : ctx.session.id(),
|
||||
ip: options.bindIp ? ctx.ip : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function defaultFailure(options: CaptchaGuardOptions, result: CaptchaVerificationResult): Response {
|
||||
return new Response(options.failureMessage ?? result.message ?? "CAPTCHA verification failed", {
|
||||
status: options.failureStatus ?? 403,
|
||||
headers: {
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
"cache-control": "no-store",
|
||||
"x-wrn-captcha-error": result.code ?? "verification-failed",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function captchaGuard(options: CaptchaGuardOptions) {
|
||||
return async (ctx: Context, next: () => Promise<Response> | Response): Promise<Response> => {
|
||||
const result = await verifyRequest(ctx, options);
|
||||
ctx.locals.captcha = result;
|
||||
if (!result.success) return options.onFailure ? options.onFailure(ctx, result) : defaultFailure(options, result);
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
export function captchaPageGate(options: CaptchaPageGateOptions) {
|
||||
const sessionKey = options.sessionKey ?? "wrnexus.captcha.grants";
|
||||
const returnToParam = options.returnToParam ?? "returnTo";
|
||||
const challengePath = options.challengePath ?? "/captcha";
|
||||
const policy = options.policy ?? { mode: "session" as const };
|
||||
return async (ctx: Context, next: () => Promise<Response> | Response): Promise<Response> => {
|
||||
const action = resolveAction(options.action, ctx);
|
||||
const now = Date.now();
|
||||
const routeGroup = policy.routeGroups?.find((group) => ctx.url.pathname.startsWith(group));
|
||||
const grants = ctx.session.get<CaptchaSessionGrant[]>(sessionKey) ?? [];
|
||||
if (validCaptchaGrant(grants, action, now, routeGroup)) return next();
|
||||
|
||||
const signals = await options.signals?.(ctx) ?? {};
|
||||
const decision = shouldRequireCaptcha(action, policy, signals);
|
||||
ctx.locals.captchaRisk = decision;
|
||||
if (!decision.challenge) return next();
|
||||
|
||||
const token = await bodyValue(ctx.req, options.responseField ?? DEFAULT_FIELD);
|
||||
if (token) {
|
||||
const result = await verifyRequest(ctx, options);
|
||||
ctx.locals.captcha = result;
|
||||
if (result.success) {
|
||||
const grant: CaptchaSessionGrant = {
|
||||
action,
|
||||
routeGroup,
|
||||
provider: result.provider,
|
||||
expiresAt: now + (policy.verifiedForMs ?? 15 * 60_000),
|
||||
};
|
||||
ctx.session.set(sessionKey, [...grants.filter((item) => item.expiresAt > now), grant]);
|
||||
return next();
|
||||
}
|
||||
if (ctx.req.method !== "GET" && ctx.req.method !== "HEAD") {
|
||||
return options.onFailure ? options.onFailure(ctx, result) : defaultFailure(options, result);
|
||||
}
|
||||
}
|
||||
|
||||
const redirect = new URL(challengePath, ctx.url);
|
||||
redirect.searchParams.set(returnToParam, `${ctx.url.pathname}${ctx.url.search}`);
|
||||
redirect.searchParams.set("action", action);
|
||||
return Response.redirect(redirect, 302);
|
||||
};
|
||||
}
|
||||
|
||||
export function clearCaptchaGrants(ctx: Context, sessionKey = "wrnexus.captcha.grants"): void {
|
||||
ctx.session.delete(sessionKey);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { CaptchaChallengeRecord, VerifyCaptchaInput } from "./types.ts";
|
||||
|
||||
export function normalizeTextAnswer(value: unknown, caseSensitive: boolean): string {
|
||||
const normalized = String(value ?? "")
|
||||
.normalize("NFKC")
|
||||
.trim()
|
||||
.replace(/\s+/g, " ");
|
||||
return caseSensitive ? normalized : normalized.toUpperCase();
|
||||
}
|
||||
|
||||
export function normalizeSelections(values: unknown): string {
|
||||
if (!Array.isArray(values)) return "";
|
||||
return [...new Set(values.map((value) => String(value).trim()).filter(Boolean))].sort().join(",");
|
||||
}
|
||||
|
||||
export function normalizedSubmittedAnswer(record: CaptchaChallengeRecord, input: VerifyCaptchaInput): string {
|
||||
if (record.answerKind === "selections") return normalizeSelections(input.selections);
|
||||
if (record.answerKind === "invisible") {
|
||||
return JSON.stringify({
|
||||
honeypot: String(input.honeypot ?? ""),
|
||||
timingToken: String(input.timingToken ?? ""),
|
||||
});
|
||||
}
|
||||
return normalizeTextAnswer(input.answer, record.caseSensitive);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { copyFile, mkdir } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { definePlugin } from "@wrnexus/plugin";
|
||||
import { CAPTCHA_IMAGE_STYLES } from "./challenges/styles.ts";
|
||||
|
||||
export interface CaptchaPluginOptions {
|
||||
componentDir?: string;
|
||||
exposeComponentDirectory?: boolean;
|
||||
enableDevToolbar?: boolean;
|
||||
auditExternalProviders?: boolean;
|
||||
}
|
||||
|
||||
export interface CaptchaAuditIssue {
|
||||
id: string;
|
||||
severity: "error" | "warning" | "suggestion";
|
||||
title: string;
|
||||
message: string;
|
||||
file: string;
|
||||
}
|
||||
|
||||
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const captchaClientRuntime = join(packageRoot, "assets", "client", "captcha.js");
|
||||
|
||||
async function copyCaptchaClientRuntime(destinationDir: string): Promise<void> {
|
||||
await mkdir(destinationDir, { recursive: true });
|
||||
await copyFile(captchaClientRuntime, join(destinationDir, "captcha.js"));
|
||||
}
|
||||
|
||||
async function installCaptchaClientRuntime(root: string, includeBuildOutput: boolean): Promise<void> {
|
||||
await copyCaptchaClientRuntime(join(root, "public", "assets", "wrnexus"));
|
||||
if (includeBuildOutput) {
|
||||
await copyCaptchaClientRuntime(join(root, "dist", "public", "assets", "wrnexus"));
|
||||
}
|
||||
}
|
||||
export function captchaComponentsDir(): string {
|
||||
return join(packageRoot, "components");
|
||||
}
|
||||
|
||||
function auditCaptchaSource(code: string, file: string, external: boolean): CaptchaAuditIssue[] {
|
||||
if (!code.includes("<Captcha") && !code.includes('data-component="Captcha"')) return [];
|
||||
const issues: CaptchaAuditIssue[] = [];
|
||||
const push = (id: string, severity: CaptchaAuditIssue["severity"], title: string, message: string) =>
|
||||
issues.push({ id: `${id}:${file}`, severity, title, message, file });
|
||||
|
||||
if (/secret(Key)?\s*=|providerSecret\s*=|captchaSecret\s*=/i.test(code)) {
|
||||
push("client-secret", "error", "CAPTCHA secret exposed", "Never pass a provider secret or verification secret to a .wrn component.");
|
||||
}
|
||||
if (!/action\s*=/.test(code)) {
|
||||
push("missing-action", "warning", "CAPTCHA action is missing", "Bind each challenge to a stable action such as signup, login, or contact-submit.");
|
||||
}
|
||||
if (/required\s*=\s*["']?false/i.test(code)) {
|
||||
push("optional-captcha", "warning", "CAPTCHA is optional", "Protected forms should require a CAPTCHA response and verify it on the server.");
|
||||
}
|
||||
if (!/showAudio\s*=|presentation\s*=\s*["']audio/i.test(code)) {
|
||||
push("audio-alternative", "suggestion", "Confirm an accessible alternative", "Visual challenges should offer an audio or non-visual alternative.");
|
||||
}
|
||||
|
||||
const imageStyle = code.match(/imageStyle\s*=\s*["']([^"']+)["']/i)?.[1]?.trim().toLowerCase();
|
||||
if (imageStyle && !CAPTCHA_IMAGE_STYLES.includes(imageStyle as (typeof CAPTCHA_IMAGE_STYLES)[number])) {
|
||||
push(
|
||||
"unknown-image-style",
|
||||
"error",
|
||||
"Unknown CAPTCHA image style",
|
||||
`Use one of: ${CAPTCHA_IMAGE_STYLES.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const disturbance = Number(code.match(/disturbance\s*=\s*["']?(\d+)/i)?.[1] ?? "");
|
||||
if (Number.isFinite(disturbance) && disturbance >= 65 && /showAudio\s*=\s*["']?false/i.test(code)) {
|
||||
push(
|
||||
"hard-without-audio",
|
||||
"warning",
|
||||
"Hard CAPTCHA has no audio alternative",
|
||||
"High disturbance should include audio or another non-visual challenge path.",
|
||||
);
|
||||
}
|
||||
if (external && /provider\s*=\s*["'](?:turnstile|recaptcha|hcaptcha)/i.test(code) && !/siteKey\s*=/.test(code)) {
|
||||
push("missing-site-key", "error", "Provider site key is missing", "External CAPTCHA providers require a public site key in the browser.");
|
||||
}
|
||||
push("server-verification", "suggestion", "Server verification required", "Confirm the receiving API uses captchaGuard(), parseWithCaptcha(), or provider.verify().");
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function captchaPlugin(options: CaptchaPluginOptions = {}) {
|
||||
const metadataKey = "@wrnexus/captcha:audit";
|
||||
return definePlugin({
|
||||
name: "@wrnexus/captcha",
|
||||
version: "0.3.6",
|
||||
enforce: "post",
|
||||
async configure(config, context) {
|
||||
const current = (config.captcha ?? {}) as Record<string, unknown>;
|
||||
config.captcha = {
|
||||
componentDir: options.componentDir ?? captchaComponentsDir(),
|
||||
...current,
|
||||
};
|
||||
context.metadata.set("@wrnexus/captcha:component-dir", options.componentDir ?? captchaComponentsDir());
|
||||
await installCaptchaClientRuntime(context.root, context.command === "build");
|
||||
},
|
||||
transformCode(code, context) {
|
||||
if (context.mode !== "development") return;
|
||||
const previous = (context.metadata.get(metadataKey) as CaptchaAuditIssue[] | undefined) ?? [];
|
||||
const withoutFile = previous.filter((issue) => issue.file !== context.file);
|
||||
context.metadata.set(
|
||||
metadataKey,
|
||||
[...withoutFile, ...auditCaptchaSource(code, context.file, options.auditExternalProviders ?? true)],
|
||||
);
|
||||
},
|
||||
devToolbarPanels(context) {
|
||||
if (options.enableDevToolbar === false) return [];
|
||||
const issues = (context.metadata.get(metadataKey) as CaptchaAuditIssue[] | undefined) ?? [];
|
||||
return [{
|
||||
id: "wrnexus-captcha",
|
||||
title: "CAPTCHA",
|
||||
icon: "shield-check",
|
||||
badge: issues.length,
|
||||
description: "CAPTCHA security, accessibility, and integration checks",
|
||||
issues,
|
||||
}];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default captchaPlugin;
|
||||
@@ -0,0 +1,63 @@
|
||||
import type {
|
||||
CaptchaPolicyOptions,
|
||||
CaptchaRiskResult,
|
||||
CaptchaRiskSignals,
|
||||
} from "./types.ts";
|
||||
|
||||
export function evaluateCaptchaRisk(
|
||||
signals: CaptchaRiskSignals,
|
||||
threshold = 50,
|
||||
): CaptchaRiskResult {
|
||||
let score = Math.max(0, Math.min(100, signals.customScore ?? 0));
|
||||
const reasons: string[] = [];
|
||||
const add = (points: number, reason: string): void => {
|
||||
score = Math.min(100, score + points);
|
||||
reasons.push(reason);
|
||||
};
|
||||
if ((signals.failedAttempts ?? 0) > 0) add(Math.min(35, (signals.failedAttempts ?? 0) * 10), "failed-attempts");
|
||||
if ((signals.requestsInWindow ?? 0) > 20) add(Math.min(35, ((signals.requestsInWindow ?? 0) - 20) * 2), "request-rate");
|
||||
if (signals.completionMs !== undefined && signals.completionMs < 700) add(25, "too-fast");
|
||||
if (signals.missingBrowserSignals) add(20, "missing-browser-signals");
|
||||
if (signals.suspiciousHeaders) add(20, "suspicious-headers");
|
||||
if (signals.tokenReuse) add(70, "token-reuse");
|
||||
if (signals.knownBadIp) add(60, "known-bad-ip");
|
||||
return { score, challenge: score >= threshold, reasons };
|
||||
}
|
||||
|
||||
export function shouldRequireCaptcha(
|
||||
action: string,
|
||||
options: CaptchaPolicyOptions = {},
|
||||
signals: CaptchaRiskSignals = {},
|
||||
): CaptchaRiskResult {
|
||||
if (options.neverForActions?.includes(action) || options.mode === "never") {
|
||||
return { score: 0, challenge: false, reasons: ["policy-never"] };
|
||||
}
|
||||
if (options.alwaysForActions?.includes(action) || options.mode === "always") {
|
||||
return { score: 100, challenge: true, reasons: ["policy-always"] };
|
||||
}
|
||||
if ((options.mode ?? "adaptive") === "session") {
|
||||
return { score: 100, challenge: true, reasons: ["session-unverified"] };
|
||||
}
|
||||
return evaluateCaptchaRisk(signals, options.threshold ?? 50);
|
||||
}
|
||||
|
||||
export interface CaptchaSessionGrant {
|
||||
action: string;
|
||||
routeGroup?: string;
|
||||
expiresAt: number;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
export function validCaptchaGrant(
|
||||
grants: CaptchaSessionGrant[] | undefined,
|
||||
action: string,
|
||||
now: number,
|
||||
routeGroup?: string,
|
||||
): CaptchaSessionGrant | undefined {
|
||||
return grants?.find(
|
||||
(grant) =>
|
||||
grant.expiresAt > now &&
|
||||
(grant.action === action || grant.action === "*") &&
|
||||
(!routeGroup || !grant.routeGroup || grant.routeGroup === routeGroup),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { CaptchaProvider } from "../types.ts";
|
||||
|
||||
export function defineCaptchaProvider<T extends CaptchaProvider>(provider: T): T {
|
||||
if (!provider.name) throw new TypeError("Custom CAPTCHA provider requires a stable name");
|
||||
if (!provider.client?.responseField) throw new TypeError("Custom CAPTCHA provider requires client.responseField");
|
||||
if (typeof provider.verify !== "function") throw new TypeError("Custom CAPTCHA provider requires verify()");
|
||||
return provider;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { SiteverifyCaptchaProvider, type SiteverifyProviderOptions } from "./siteverify.ts";
|
||||
|
||||
export class HcaptchaProvider extends SiteverifyCaptchaProvider {
|
||||
constructor(options: SiteverifyProviderOptions) {
|
||||
super(
|
||||
{
|
||||
name: "hcaptcha",
|
||||
endpoint: "https://api.hcaptcha.com/siteverify",
|
||||
client: {
|
||||
responseField: "h-captcha-response",
|
||||
scriptUrl: "https://js.hcaptcha.com/1/api.js?render=explicit",
|
||||
widgetClass: "h-captcha",
|
||||
},
|
||||
sendSiteKey: true,
|
||||
scoreDirection: "higher-is-risk",
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function hcaptchaProvider(options: SiteverifyProviderOptions): HcaptchaProvider {
|
||||
return new HcaptchaProvider(options);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from "./self-hosted.ts";
|
||||
export * from "./siteverify.ts";
|
||||
export * from "./turnstile.ts";
|
||||
export * from "./recaptcha.ts";
|
||||
export * from "./hcaptcha.ts";
|
||||
export * from "./managed.ts";
|
||||
export * from "./custom.ts";
|
||||
@@ -0,0 +1,69 @@
|
||||
import type {
|
||||
CaptchaChallenge,
|
||||
CaptchaProvider,
|
||||
CaptchaVerificationResult,
|
||||
CreateCaptchaOptions,
|
||||
VerifyCaptchaInput,
|
||||
} from "../types.ts";
|
||||
|
||||
export interface ManagedCaptchaProviderOptions {
|
||||
baseUrl: string;
|
||||
siteKey: string;
|
||||
secretKey: string;
|
||||
timeoutMs?: number;
|
||||
fetch?: typeof fetch;
|
||||
}
|
||||
|
||||
export class ManagedCaptchaProvider implements CaptchaProvider {
|
||||
readonly name = "wrnexus-managed" as const;
|
||||
readonly client;
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(private readonly options: ManagedCaptchaProviderOptions) {
|
||||
if (!options.baseUrl) throw new TypeError("Managed CAPTCHA baseUrl is required");
|
||||
if (!options.siteKey) throw new TypeError("Managed CAPTCHA siteKey is required");
|
||||
if (!options.secretKey) throw new TypeError("Managed CAPTCHA secretKey is required");
|
||||
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
||||
this.client = {
|
||||
responseField: "wrn-captcha-response",
|
||||
siteKey: options.siteKey,
|
||||
managedCreateUrl: `${this.baseUrl}/v1/challenges`,
|
||||
};
|
||||
}
|
||||
|
||||
async createChallenge(options: CreateCaptchaOptions): Promise<CaptchaChallenge> {
|
||||
return this.request<CaptchaChallenge>("/v1/challenges", {
|
||||
siteKey: this.options.siteKey,
|
||||
...options,
|
||||
}, false);
|
||||
}
|
||||
|
||||
async verify(input: VerifyCaptchaInput): Promise<CaptchaVerificationResult> {
|
||||
return this.request<CaptchaVerificationResult>("/v1/verify", input, true);
|
||||
}
|
||||
|
||||
private async request<T>(path: string, payload: unknown, secret: boolean): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), this.options.timeoutMs ?? 10_000);
|
||||
try {
|
||||
const response = await (this.options.fetch ?? fetch)(`${this.baseUrl}${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
accept: "application/json",
|
||||
...(secret ? { authorization: `Bearer ${this.options.secretKey}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(`Managed CAPTCHA returned HTTP ${response.status}`);
|
||||
return (await response.json()) as T;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function managedCaptchaProvider(options: ManagedCaptchaProviderOptions): ManagedCaptchaProvider {
|
||||
return new ManagedCaptchaProvider(options);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { SiteverifyCaptchaProvider, type SiteverifyProviderOptions } from "./siteverify.ts";
|
||||
|
||||
export class RecaptchaProvider extends SiteverifyCaptchaProvider {
|
||||
constructor(options: SiteverifyProviderOptions) {
|
||||
super(
|
||||
{
|
||||
name: "recaptcha",
|
||||
endpoint: "https://www.google.com/recaptcha/api/siteverify",
|
||||
client: {
|
||||
responseField: "g-recaptcha-response",
|
||||
scriptUrl: "https://www.google.com/recaptcha/api.js?render=explicit",
|
||||
widgetClass: "g-recaptcha",
|
||||
},
|
||||
scoreDirection: "higher-is-human",
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function recaptchaProvider(options: SiteverifyProviderOptions): RecaptchaProvider {
|
||||
return new RecaptchaProvider(options);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type {
|
||||
CaptchaEngine,
|
||||
CaptchaProvider,
|
||||
CreateCaptchaOptions,
|
||||
VerifyCaptchaInput,
|
||||
} from "../types.ts";
|
||||
|
||||
export class SelfHostedCaptchaProvider implements CaptchaProvider {
|
||||
readonly name = "self-hosted" as const;
|
||||
readonly client;
|
||||
|
||||
constructor(readonly engine: CaptchaEngine) {
|
||||
this.client = {
|
||||
responseField: "wrn-captcha-response",
|
||||
managedCreateUrl: `${engine.basePath}/challenge`,
|
||||
};
|
||||
}
|
||||
|
||||
createChallenge(options: CreateCaptchaOptions) {
|
||||
return this.engine.create(options);
|
||||
}
|
||||
|
||||
verify(input: VerifyCaptchaInput) {
|
||||
return input.responseToken || input.providerToken
|
||||
? this.engine.verifyResponseToken({ ...input, responseToken: input.responseToken ?? input.providerToken })
|
||||
: this.engine.verify(input);
|
||||
}
|
||||
}
|
||||
|
||||
export function selfHostedProvider(engine: CaptchaEngine): SelfHostedCaptchaProvider {
|
||||
return new SelfHostedCaptchaProvider(engine);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import type {
|
||||
CaptchaProvider,
|
||||
CaptchaProviderClientConfig,
|
||||
CaptchaProviderName,
|
||||
CaptchaVerificationResult,
|
||||
VerifyCaptchaInput,
|
||||
} from "../types.ts";
|
||||
|
||||
export interface SiteverifyProviderOptions {
|
||||
secretKey: string;
|
||||
siteKey?: string;
|
||||
expectedHostnames?: string[];
|
||||
expectedAction?: string;
|
||||
minScore?: number;
|
||||
timeoutMs?: number;
|
||||
fetch?: typeof fetch;
|
||||
}
|
||||
|
||||
export interface SiteverifyPreset {
|
||||
name: CaptchaProviderName;
|
||||
endpoint: string;
|
||||
client: CaptchaProviderClientConfig;
|
||||
sendSiteKey?: boolean;
|
||||
scoreDirection?: "higher-is-human" | "higher-is-risk";
|
||||
}
|
||||
|
||||
interface SiteverifyPayload {
|
||||
success?: boolean;
|
||||
hostname?: string;
|
||||
action?: string;
|
||||
score?: number;
|
||||
challenge_ts?: string;
|
||||
"error-codes"?: string[];
|
||||
error_codes?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function providerFailure(
|
||||
name: CaptchaProviderName,
|
||||
action: string,
|
||||
code: string,
|
||||
message: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
): CaptchaVerificationResult {
|
||||
return { success: false, provider: name, action, code, message, metadata };
|
||||
}
|
||||
|
||||
export class SiteverifyCaptchaProvider implements CaptchaProvider {
|
||||
readonly name: CaptchaProviderName;
|
||||
readonly client: CaptchaProviderClientConfig;
|
||||
|
||||
constructor(
|
||||
private readonly preset: SiteverifyPreset,
|
||||
private readonly options: SiteverifyProviderOptions,
|
||||
) {
|
||||
if (!options.secretKey) throw new TypeError(`${preset.name} secretKey is required`);
|
||||
this.name = preset.name;
|
||||
this.client = { ...preset.client, siteKey: options.siteKey };
|
||||
}
|
||||
|
||||
async verify(input: VerifyCaptchaInput): Promise<CaptchaVerificationResult> {
|
||||
const token = input.providerToken ?? input.responseToken;
|
||||
const action = input.action;
|
||||
if (!token) return providerFailure(this.name, action, "missing-input", "Missing provider response token");
|
||||
if (token.length > 4096) return providerFailure(this.name, action, "invalid-input", "Provider token is too long");
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), this.options.timeoutMs ?? 10_000);
|
||||
const body = new URLSearchParams({ secret: this.options.secretKey, response: token });
|
||||
if (input.ip) body.set("remoteip", input.ip);
|
||||
if (this.preset.sendSiteKey && this.options.siteKey) body.set("sitekey", this.options.siteKey);
|
||||
try {
|
||||
const response = await (this.options.fetch ?? fetch)(this.preset.endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
accept: "application/json",
|
||||
},
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
return providerFailure(this.name, action, "provider-error", `${this.name} verification returned HTTP ${response.status}`);
|
||||
}
|
||||
const data = (await response.json()) as SiteverifyPayload;
|
||||
if (!data.success) {
|
||||
const codes = data["error-codes"] ?? data.error_codes ?? [];
|
||||
return providerFailure(
|
||||
this.name,
|
||||
action,
|
||||
codes.includes("timeout-or-duplicate") || codes.includes("already-seen-response")
|
||||
? "already-used"
|
||||
: codes.includes("expired-input-response")
|
||||
? "expired"
|
||||
: "invalid-input",
|
||||
"Provider verification failed",
|
||||
{ errorCodes: codes },
|
||||
);
|
||||
}
|
||||
const expectedAction = this.options.expectedAction ?? action;
|
||||
if (data.action && expectedAction && data.action !== expectedAction) {
|
||||
return providerFailure(this.name, action, "action-mismatch", "Provider action does not match", {
|
||||
receivedAction: data.action,
|
||||
});
|
||||
}
|
||||
if (
|
||||
this.options.expectedHostnames?.length &&
|
||||
(!data.hostname || !this.options.expectedHostnames.includes(data.hostname))
|
||||
) {
|
||||
return providerFailure(this.name, action, "hostname-mismatch", "Provider hostname does not match", {
|
||||
hostname: data.hostname,
|
||||
});
|
||||
}
|
||||
if (this.options.minScore !== undefined && typeof data.score === "number") {
|
||||
const rejected = this.preset.scoreDirection === "higher-is-risk"
|
||||
? data.score >= this.options.minScore
|
||||
: data.score < this.options.minScore;
|
||||
if (rejected) {
|
||||
return providerFailure(this.name, action, "risk-rejected", "Provider risk score did not pass", {
|
||||
score: data.score,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
provider: this.name,
|
||||
action,
|
||||
score: data.score,
|
||||
hostname: data.hostname,
|
||||
metadata: {
|
||||
challengeTimestamp: data.challenge_ts,
|
||||
raw: data,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return providerFailure(
|
||||
this.name,
|
||||
action,
|
||||
error instanceof DOMException && error.name === "AbortError" ? "network-error" : "provider-error",
|
||||
error instanceof DOMException && error.name === "AbortError"
|
||||
? `${this.name} verification timed out`
|
||||
: `${this.name} verification failed`,
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { SiteverifyCaptchaProvider, type SiteverifyProviderOptions } from "./siteverify.ts";
|
||||
|
||||
export class TurnstileCaptchaProvider extends SiteverifyCaptchaProvider {
|
||||
constructor(options: SiteverifyProviderOptions) {
|
||||
super(
|
||||
{
|
||||
name: "turnstile",
|
||||
endpoint: "https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||
client: {
|
||||
responseField: "cf-turnstile-response",
|
||||
scriptUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit",
|
||||
widgetClass: "cf-turnstile",
|
||||
},
|
||||
scoreDirection: "higher-is-human",
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function turnstileProvider(options: SiteverifyProviderOptions): TurnstileCaptchaProvider {
|
||||
return new TurnstileCaptchaProvider(options);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export * from "../types.ts";
|
||||
export * from "../engine.ts";
|
||||
export * from "../http.ts";
|
||||
export * from "../middleware.ts";
|
||||
export * from "../policy.ts";
|
||||
export * from "../validation.ts";
|
||||
export * from "../stores/memory.ts";
|
||||
export * from "../stores/sqlite.ts";
|
||||
export * from "../stores/redis.ts";
|
||||
export * from "../providers/index.ts";
|
||||
export * from "../audio/index.ts";
|
||||
export * from "../crypto.ts";
|
||||
@@ -0,0 +1,107 @@
|
||||
import type {
|
||||
CaptchaChallengeRecord,
|
||||
CaptchaResponseTokenRecord,
|
||||
CaptchaStore,
|
||||
} from "../types.ts";
|
||||
|
||||
function cloneChallenge(record: CaptchaChallengeRecord): CaptchaChallengeRecord {
|
||||
return structuredClone(record);
|
||||
}
|
||||
|
||||
function cloneToken(record: CaptchaResponseTokenRecord): CaptchaResponseTokenRecord {
|
||||
return structuredClone(record);
|
||||
}
|
||||
|
||||
export interface MemoryCaptchaStoreOptions {
|
||||
maxChallenges?: number;
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
export class MemoryCaptchaStore implements CaptchaStore {
|
||||
private readonly challenges = new Map<string, CaptchaChallengeRecord>();
|
||||
private readonly tokens = new Map<string, CaptchaResponseTokenRecord>();
|
||||
private readonly maxChallenges: number;
|
||||
private readonly maxTokens: number;
|
||||
|
||||
constructor(options: MemoryCaptchaStoreOptions = {}) {
|
||||
this.maxChallenges = options.maxChallenges ?? 25_000;
|
||||
this.maxTokens = options.maxTokens ?? 50_000;
|
||||
if (!Number.isInteger(this.maxChallenges) || this.maxChallenges < 1) {
|
||||
throw new RangeError("maxChallenges must be a positive integer");
|
||||
}
|
||||
if (!Number.isInteger(this.maxTokens) || this.maxTokens < 1) {
|
||||
throw new RangeError("maxTokens must be a positive integer");
|
||||
}
|
||||
}
|
||||
|
||||
async createChallenge(record: CaptchaChallengeRecord): Promise<void> {
|
||||
this.evict(this.challenges, this.maxChallenges, record.createdAt);
|
||||
this.challenges.set(record.id, cloneChallenge(record));
|
||||
}
|
||||
|
||||
async getChallenge(id: string): Promise<CaptchaChallengeRecord | undefined> {
|
||||
const record = this.challenges.get(id);
|
||||
return record ? cloneChallenge(record) : undefined;
|
||||
}
|
||||
|
||||
async incrementAttempts(id: string, now: number): Promise<CaptchaChallengeRecord | undefined> {
|
||||
const record = this.challenges.get(id);
|
||||
if (!record || record.expiresAt <= now || record.consumedAt) return undefined;
|
||||
record.attempts += 1;
|
||||
return cloneChallenge(record);
|
||||
}
|
||||
|
||||
async consumeChallenge(id: string, now: number): Promise<CaptchaChallengeRecord | undefined> {
|
||||
const record = this.challenges.get(id);
|
||||
if (!record || record.expiresAt <= now || record.consumedAt) return undefined;
|
||||
record.consumedAt = now;
|
||||
return cloneChallenge(record);
|
||||
}
|
||||
|
||||
async deleteChallenge(id: string): Promise<void> {
|
||||
this.challenges.delete(id);
|
||||
}
|
||||
|
||||
async createToken(record: CaptchaResponseTokenRecord): Promise<void> {
|
||||
this.evict(this.tokens, this.maxTokens, record.createdAt);
|
||||
this.tokens.set(record.tokenHash, cloneToken(record));
|
||||
}
|
||||
|
||||
async getToken(tokenHash: string): Promise<CaptchaResponseTokenRecord | undefined> {
|
||||
const record = this.tokens.get(tokenHash);
|
||||
return record ? cloneToken(record) : undefined;
|
||||
}
|
||||
|
||||
async consumeToken(tokenHash: string, now: number): Promise<CaptchaResponseTokenRecord | undefined> {
|
||||
const record = this.tokens.get(tokenHash);
|
||||
if (!record || record.expiresAt <= now || record.consumedAt) return undefined;
|
||||
record.consumedAt = now;
|
||||
return cloneToken(record);
|
||||
}
|
||||
|
||||
async deleteToken(tokenHash: string): Promise<void> {
|
||||
this.tokens.delete(tokenHash);
|
||||
}
|
||||
|
||||
async gc(now: number): Promise<void> {
|
||||
for (const [id, record] of this.challenges) {
|
||||
if (record.expiresAt <= now || (record.consumedAt && record.consumedAt + 60_000 <= now)) {
|
||||
this.challenges.delete(id);
|
||||
}
|
||||
}
|
||||
for (const [hash, record] of this.tokens) {
|
||||
if (record.expiresAt <= now || (record.consumedAt && record.consumedAt + 60_000 <= now)) {
|
||||
this.tokens.delete(hash);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private evict<T extends { expiresAt: number }>(map: Map<string, T>, max: number, now: number): void {
|
||||
for (const [key, record] of map) if (record.expiresAt <= now) map.delete(key);
|
||||
while (map.size >= max) map.delete(map.keys().next().value!);
|
||||
}
|
||||
}
|
||||
|
||||
export function createMemoryCaptchaStore(options?: MemoryCaptchaStoreOptions): MemoryCaptchaStore {
|
||||
return new MemoryCaptchaStore(options);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import type {
|
||||
CaptchaChallengeRecord,
|
||||
CaptchaResponseTokenRecord,
|
||||
CaptchaStore,
|
||||
} from "../types.ts";
|
||||
|
||||
export interface RedisCaptchaClient {
|
||||
get(key: string): Promise<string | null> | string | null;
|
||||
set(
|
||||
key: string,
|
||||
value: string,
|
||||
options?: { px?: number; nx?: boolean },
|
||||
): Promise<unknown> | unknown;
|
||||
del(key: string): Promise<number> | number;
|
||||
eval?(
|
||||
script: string,
|
||||
options: { keys: string[]; arguments: string[] },
|
||||
): Promise<unknown> | unknown;
|
||||
scanIterator?(options?: { match?: string; count?: number }): AsyncIterable<string>;
|
||||
}
|
||||
|
||||
export interface RedisCaptchaStoreOptions {
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
const MUTATE_CHALLENGE = `
|
||||
local raw = redis.call('GET', KEYS[1])
|
||||
if not raw then return nil end
|
||||
local value = cjson.decode(raw)
|
||||
local now = tonumber(ARGV[1])
|
||||
if tonumber(value.expiresAt) <= now or value.consumedAt ~= nil then return nil end
|
||||
if ARGV[2] == 'attempt' then
|
||||
value.attempts = tonumber(value.attempts) + 1
|
||||
else
|
||||
value.consumedAt = now
|
||||
end
|
||||
local encoded = cjson.encode(value)
|
||||
local ttl = math.max(1, tonumber(value.expiresAt) - now)
|
||||
redis.call('SET', KEYS[1], encoded, 'PX', ttl)
|
||||
return encoded
|
||||
`;
|
||||
|
||||
const CONSUME_TOKEN = `
|
||||
local raw = redis.call('GET', KEYS[1])
|
||||
if not raw then return nil end
|
||||
local value = cjson.decode(raw)
|
||||
local now = tonumber(ARGV[1])
|
||||
if tonumber(value.expiresAt) <= now or value.consumedAt ~= nil then return nil end
|
||||
value.consumedAt = now
|
||||
local encoded = cjson.encode(value)
|
||||
local ttl = math.max(1, tonumber(value.expiresAt) - now)
|
||||
redis.call('SET', KEYS[1], encoded, 'PX', ttl)
|
||||
return encoded
|
||||
`;
|
||||
|
||||
export class RedisCaptchaStore implements CaptchaStore {
|
||||
private readonly prefix: string;
|
||||
private readonly locks = new Map<string, Promise<void>>();
|
||||
|
||||
constructor(
|
||||
private readonly redis: RedisCaptchaClient,
|
||||
options: RedisCaptchaStoreOptions = {},
|
||||
) {
|
||||
this.prefix = options.prefix ?? "wrn:captcha:";
|
||||
}
|
||||
|
||||
async createChallenge(record: CaptchaChallengeRecord): Promise<void> {
|
||||
const ttl = Math.max(1, record.expiresAt - Date.now());
|
||||
await this.redis.set(this.challengeKey(record.id), JSON.stringify(record), { px: ttl });
|
||||
}
|
||||
|
||||
async getChallenge(id: string): Promise<CaptchaChallengeRecord | undefined> {
|
||||
return this.read<CaptchaChallengeRecord>(this.challengeKey(id));
|
||||
}
|
||||
|
||||
async incrementAttempts(id: string, now: number): Promise<CaptchaChallengeRecord | undefined> {
|
||||
return this.mutateChallenge(id, now, "attempt");
|
||||
}
|
||||
|
||||
async consumeChallenge(id: string, now: number): Promise<CaptchaChallengeRecord | undefined> {
|
||||
return this.mutateChallenge(id, now, "consume");
|
||||
}
|
||||
|
||||
async deleteChallenge(id: string): Promise<void> {
|
||||
await this.redis.del(this.challengeKey(id));
|
||||
}
|
||||
|
||||
async createToken(record: CaptchaResponseTokenRecord): Promise<void> {
|
||||
const ttl = Math.max(1, record.expiresAt - Date.now());
|
||||
await this.redis.set(this.tokenKey(record.tokenHash), JSON.stringify(record), { px: ttl });
|
||||
}
|
||||
|
||||
async getToken(tokenHash: string): Promise<CaptchaResponseTokenRecord | undefined> {
|
||||
return this.read<CaptchaResponseTokenRecord>(this.tokenKey(tokenHash));
|
||||
}
|
||||
|
||||
async consumeToken(tokenHash: string, now: number): Promise<CaptchaResponseTokenRecord | undefined> {
|
||||
const key = this.tokenKey(tokenHash);
|
||||
if (this.redis.eval) {
|
||||
const raw = await this.redis.eval(CONSUME_TOKEN, {
|
||||
keys: [key],
|
||||
arguments: [String(now)],
|
||||
});
|
||||
return typeof raw === "string" ? (JSON.parse(raw) as CaptchaResponseTokenRecord) : undefined;
|
||||
}
|
||||
return this.withLock(key, async () => {
|
||||
const current = await this.read<CaptchaResponseTokenRecord>(key);
|
||||
if (!current || current.expiresAt <= now || current.consumedAt) return undefined;
|
||||
current.consumedAt = now;
|
||||
await this.redis.set(key, JSON.stringify(current), { px: Math.max(1, current.expiresAt - now) });
|
||||
return current;
|
||||
});
|
||||
}
|
||||
|
||||
async deleteToken(tokenHash: string): Promise<void> {
|
||||
await this.redis.del(this.tokenKey(tokenHash));
|
||||
}
|
||||
|
||||
async gc(): Promise<void> {
|
||||
// Redis TTLs are authoritative, so no explicit sweep is required.
|
||||
}
|
||||
|
||||
private async mutateChallenge(
|
||||
id: string,
|
||||
now: number,
|
||||
operation: "attempt" | "consume",
|
||||
): Promise<CaptchaChallengeRecord | undefined> {
|
||||
const key = this.challengeKey(id);
|
||||
if (this.redis.eval) {
|
||||
const raw = await this.redis.eval(MUTATE_CHALLENGE, {
|
||||
keys: [key],
|
||||
arguments: [String(now), operation],
|
||||
});
|
||||
return typeof raw === "string" ? (JSON.parse(raw) as CaptchaChallengeRecord) : undefined;
|
||||
}
|
||||
return this.withLock(key, async () => {
|
||||
const current = await this.read<CaptchaChallengeRecord>(key);
|
||||
if (!current || current.expiresAt <= now || current.consumedAt) return undefined;
|
||||
if (operation === "attempt") current.attempts += 1;
|
||||
else current.consumedAt = now;
|
||||
await this.redis.set(key, JSON.stringify(current), { px: Math.max(1, current.expiresAt - now) });
|
||||
return current;
|
||||
});
|
||||
}
|
||||
|
||||
private async read<T>(key: string): Promise<T | undefined> {
|
||||
const value = await this.redis.get(key);
|
||||
return value ? (JSON.parse(value) as T) : undefined;
|
||||
}
|
||||
|
||||
private challengeKey(id: string): string {
|
||||
return `${this.prefix}challenge:${id}`;
|
||||
}
|
||||
|
||||
private tokenKey(hash: string): string {
|
||||
return `${this.prefix}token:${hash}`;
|
||||
}
|
||||
|
||||
private async withLock<T>(key: string, task: () => Promise<T>): Promise<T> {
|
||||
const previous = this.locks.get(key) ?? Promise.resolve();
|
||||
let release!: () => void;
|
||||
const current = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const chain = previous.then(() => current);
|
||||
this.locks.set(key, chain);
|
||||
await previous;
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
release();
|
||||
if (this.locks.get(key) === chain) this.locks.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createRedisCaptchaStore(
|
||||
redis: RedisCaptchaClient,
|
||||
options?: RedisCaptchaStoreOptions,
|
||||
): RedisCaptchaStore {
|
||||
return new RedisCaptchaStore(redis, options);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import type {
|
||||
CaptchaChallengeRecord,
|
||||
CaptchaResponseTokenRecord,
|
||||
CaptchaStore,
|
||||
} from "../types.ts";
|
||||
|
||||
export interface SqliteStatementLike {
|
||||
run(...params: unknown[]): unknown;
|
||||
get(...params: unknown[]): Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
export interface SqliteDatabaseLike {
|
||||
exec(sql: string): unknown;
|
||||
prepare(sql: string): SqliteStatementLike;
|
||||
}
|
||||
|
||||
export interface SqliteCaptchaStoreOptions {
|
||||
challengeTable?: string;
|
||||
tokenTable?: string;
|
||||
initialize?: boolean;
|
||||
}
|
||||
|
||||
function safeIdentifier(value: string): string {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) throw new TypeError(`Unsafe SQL identifier: ${value}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseChallenge(row: Record<string, unknown> | undefined): CaptchaChallengeRecord | undefined {
|
||||
if (!row) return undefined;
|
||||
return JSON.parse(String(row.payload)) as CaptchaChallengeRecord;
|
||||
}
|
||||
|
||||
function parseToken(row: Record<string, unknown> | undefined): CaptchaResponseTokenRecord | undefined {
|
||||
if (!row) return undefined;
|
||||
return JSON.parse(String(row.payload)) as CaptchaResponseTokenRecord;
|
||||
}
|
||||
|
||||
export class SqliteCaptchaStore implements CaptchaStore {
|
||||
private readonly challengeTable: string;
|
||||
private readonly tokenTable: string;
|
||||
|
||||
constructor(
|
||||
private readonly db: SqliteDatabaseLike,
|
||||
options: SqliteCaptchaStoreOptions = {},
|
||||
) {
|
||||
this.challengeTable = safeIdentifier(options.challengeTable ?? "wrn_captcha_challenges");
|
||||
this.tokenTable = safeIdentifier(options.tokenTable ?? "wrn_captcha_tokens");
|
||||
if (options.initialize ?? true) this.initialize();
|
||||
}
|
||||
|
||||
initialize(): void {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ${this.challengeTable} (
|
||||
id TEXT PRIMARY KEY,
|
||||
payload TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
consumed_at INTEGER,
|
||||
attempts INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ${this.challengeTable}_expires_idx
|
||||
ON ${this.challengeTable}(expires_at);
|
||||
CREATE TABLE IF NOT EXISTS ${this.tokenTable} (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
payload TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
consumed_at INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ${this.tokenTable}_expires_idx
|
||||
ON ${this.tokenTable}(expires_at);
|
||||
`);
|
||||
}
|
||||
|
||||
async createChallenge(record: CaptchaChallengeRecord): Promise<void> {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR REPLACE INTO ${this.challengeTable}
|
||||
(id, payload, expires_at, consumed_at, attempts)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(record.id, JSON.stringify(record), record.expiresAt, record.consumedAt ?? null, record.attempts);
|
||||
}
|
||||
|
||||
async getChallenge(id: string): Promise<CaptchaChallengeRecord | undefined> {
|
||||
return parseChallenge(
|
||||
this.db.prepare(`SELECT payload FROM ${this.challengeTable} WHERE id = ?`).get(id),
|
||||
);
|
||||
}
|
||||
|
||||
async incrementAttempts(id: string, now: number): Promise<CaptchaChallengeRecord | undefined> {
|
||||
const current = await this.getChallenge(id);
|
||||
if (!current || current.expiresAt <= now || current.consumedAt) return undefined;
|
||||
current.attempts += 1;
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`UPDATE ${this.challengeTable}
|
||||
SET payload = ?, attempts = ?
|
||||
WHERE id = ? AND expires_at > ? AND consumed_at IS NULL AND attempts = ?`,
|
||||
)
|
||||
.run(JSON.stringify(current), current.attempts, id, now, current.attempts - 1) as { changes?: number };
|
||||
return result?.changes === 0 ? undefined : current;
|
||||
}
|
||||
|
||||
async consumeChallenge(id: string, now: number): Promise<CaptchaChallengeRecord | undefined> {
|
||||
const current = await this.getChallenge(id);
|
||||
if (!current || current.expiresAt <= now || current.consumedAt) return undefined;
|
||||
current.consumedAt = now;
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`UPDATE ${this.challengeTable}
|
||||
SET payload = ?, consumed_at = ?
|
||||
WHERE id = ? AND expires_at > ? AND consumed_at IS NULL`,
|
||||
)
|
||||
.run(JSON.stringify(current), now, id, now) as { changes?: number };
|
||||
return result?.changes === 0 ? undefined : current;
|
||||
}
|
||||
|
||||
async deleteChallenge(id: string): Promise<void> {
|
||||
this.db.prepare(`DELETE FROM ${this.challengeTable} WHERE id = ?`).run(id);
|
||||
}
|
||||
|
||||
async createToken(record: CaptchaResponseTokenRecord): Promise<void> {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR REPLACE INTO ${this.tokenTable}
|
||||
(token_hash, payload, expires_at, consumed_at)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
)
|
||||
.run(record.tokenHash, JSON.stringify(record), record.expiresAt, record.consumedAt ?? null);
|
||||
}
|
||||
|
||||
async getToken(tokenHash: string): Promise<CaptchaResponseTokenRecord | undefined> {
|
||||
return parseToken(
|
||||
this.db.prepare(`SELECT payload FROM ${this.tokenTable} WHERE token_hash = ?`).get(tokenHash),
|
||||
);
|
||||
}
|
||||
|
||||
async consumeToken(tokenHash: string, now: number): Promise<CaptchaResponseTokenRecord | undefined> {
|
||||
const current = await this.getToken(tokenHash);
|
||||
if (!current || current.expiresAt <= now || current.consumedAt) return undefined;
|
||||
current.consumedAt = now;
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`UPDATE ${this.tokenTable}
|
||||
SET payload = ?, consumed_at = ?
|
||||
WHERE token_hash = ? AND expires_at > ? AND consumed_at IS NULL`,
|
||||
)
|
||||
.run(JSON.stringify(current), now, tokenHash, now) as { changes?: number };
|
||||
return result?.changes === 0 ? undefined : current;
|
||||
}
|
||||
|
||||
async deleteToken(tokenHash: string): Promise<void> {
|
||||
this.db.prepare(`DELETE FROM ${this.tokenTable} WHERE token_hash = ?`).run(tokenHash);
|
||||
}
|
||||
|
||||
async gc(now: number): Promise<void> {
|
||||
this.db
|
||||
.prepare(`DELETE FROM ${this.challengeTable} WHERE expires_at <= ? OR consumed_at <= ?`)
|
||||
.run(now, now - 60_000);
|
||||
this.db
|
||||
.prepare(`DELETE FROM ${this.tokenTable} WHERE expires_at <= ? OR consumed_at <= ?`)
|
||||
.run(now, now - 60_000);
|
||||
}
|
||||
}
|
||||
|
||||
export function createSqliteCaptchaStore(
|
||||
db: SqliteDatabaseLike,
|
||||
options?: SqliteCaptchaStoreOptions,
|
||||
): SqliteCaptchaStore {
|
||||
return new SqliteCaptchaStore(db, options);
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import type { Context, Middleware } from "@wrnexus/core";
|
||||
|
||||
export type CaptchaProviderName =
|
||||
| "self-hosted"
|
||||
| "wrnexus-managed"
|
||||
| "turnstile"
|
||||
| "recaptcha"
|
||||
| "hcaptcha"
|
||||
| (string & {});
|
||||
|
||||
export type CaptchaChallengeType =
|
||||
| "number"
|
||||
| "alpha"
|
||||
| "alphanumeric"
|
||||
| "calculation"
|
||||
| "image"
|
||||
| "honeypot"
|
||||
| "timing"
|
||||
| "not-robot"
|
||||
| (string & {});
|
||||
|
||||
export type CaptchaDifficulty = "easy" | "normal" | "hard";
|
||||
export type CaptchaConcreteImageStyle =
|
||||
| "classic"
|
||||
| "collision"
|
||||
| "snow"
|
||||
| "corrosion"
|
||||
| "spiderweb"
|
||||
| "cross-shadow"
|
||||
| "split"
|
||||
| "split2"
|
||||
| "cut"
|
||||
| "darts"
|
||||
| "distortion"
|
||||
| "stitch"
|
||||
| "striped"
|
||||
| "wave"
|
||||
| "grid-noise"
|
||||
| "scribble"
|
||||
| "pixel"
|
||||
| "broken-lines";
|
||||
export type CaptchaImageStyle = "random" | CaptchaConcreteImageStyle;
|
||||
export type CaptchaPresentation = "visual" | "audio" | "invisible";
|
||||
export type CaptchaPolicyMode = "always" | "session" | "adaptive" | "never";
|
||||
|
||||
export type CaptchaFailureCode =
|
||||
| "missing-input"
|
||||
| "invalid-input"
|
||||
| "incorrect-answer"
|
||||
| "expired"
|
||||
| "already-used"
|
||||
| "attempts-exhausted"
|
||||
| "action-mismatch"
|
||||
| "hostname-mismatch"
|
||||
| "session-mismatch"
|
||||
| "ip-mismatch"
|
||||
| "provider-error"
|
||||
| "network-error"
|
||||
| "risk-rejected"
|
||||
| "internal-error";
|
||||
|
||||
export interface CaptchaBinding {
|
||||
hostname?: string;
|
||||
sessionId?: string;
|
||||
ip?: string;
|
||||
}
|
||||
|
||||
export interface CaptchaImageItem {
|
||||
id: string;
|
||||
image: string;
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
export interface CaptchaChallenge {
|
||||
id: string;
|
||||
provider: CaptchaProviderName;
|
||||
type: CaptchaChallengeType;
|
||||
presentation: CaptchaPresentation;
|
||||
action: string;
|
||||
prompt: string;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
responseField: string;
|
||||
inputMode?: "text" | "numeric" | "none";
|
||||
image?: string;
|
||||
audioUrl?: string;
|
||||
refreshUrl?: string;
|
||||
verifyUrl?: string;
|
||||
items?: CaptchaImageItem[];
|
||||
minSelections?: number;
|
||||
maxSelections?: number;
|
||||
honeypotField?: string;
|
||||
timingToken?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CreateCaptchaOptions extends CaptchaBinding {
|
||||
action: string;
|
||||
type?: CaptchaChallengeType;
|
||||
presentation?: CaptchaPresentation;
|
||||
difficulty?: CaptchaDifficulty;
|
||||
/** Visual disturbance percentage. 25 is easiest and 75 is hardest. */
|
||||
disturbance?: number;
|
||||
/** Renderer used for generated text and calculation CAPTCHA images. */
|
||||
imageStyle?: CaptchaImageStyle;
|
||||
/** Optional renderer pool, supplied as an array or comma-separated string. */
|
||||
allowedStyles?: CaptchaConcreteImageStyle[] | string;
|
||||
/** Renderers removed from the active pool. */
|
||||
excludedStyles?: CaptchaConcreteImageStyle[] | string;
|
||||
/** Force a new random renderer even when imageStyle names a concrete style. */
|
||||
randomizeStyle?: boolean;
|
||||
locale?: string;
|
||||
length?: number;
|
||||
caseSensitive?: boolean;
|
||||
expiresInMs?: number;
|
||||
maxAttempts?: number;
|
||||
minCompletionMs?: number;
|
||||
responseField?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface VerifyCaptchaInput extends CaptchaBinding {
|
||||
challengeId?: string;
|
||||
responseToken?: string;
|
||||
providerToken?: string;
|
||||
answer?: string | number;
|
||||
selections?: string[];
|
||||
action: string;
|
||||
honeypot?: string;
|
||||
timingToken?: string;
|
||||
consume?: boolean;
|
||||
}
|
||||
|
||||
export interface CaptchaVerificationResult {
|
||||
success: boolean;
|
||||
provider: CaptchaProviderName;
|
||||
action: string;
|
||||
code?: CaptchaFailureCode | string;
|
||||
message?: string;
|
||||
responseToken?: string;
|
||||
expiresAt?: number;
|
||||
score?: number;
|
||||
hostname?: string;
|
||||
challengeId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CaptchaChallengeRecord {
|
||||
id: string;
|
||||
provider: CaptchaProviderName;
|
||||
type: CaptchaChallengeType;
|
||||
presentation: CaptchaPresentation;
|
||||
action: string;
|
||||
publicChallenge: CaptchaChallenge;
|
||||
answerDigest: string;
|
||||
answerSalt: string;
|
||||
answerKind: "text" | "selections" | "invisible";
|
||||
caseSensitive: boolean;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
consumedAt?: number;
|
||||
hostnameHash?: string;
|
||||
sessionHash?: string;
|
||||
ipHash?: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CaptchaResponseTokenRecord {
|
||||
tokenHash: string;
|
||||
provider: CaptchaProviderName;
|
||||
challengeId?: string;
|
||||
action: string;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
consumedAt?: number;
|
||||
hostnameHash?: string;
|
||||
sessionHash?: string;
|
||||
ipHash?: string;
|
||||
score?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CaptchaStore {
|
||||
createChallenge(record: CaptchaChallengeRecord): Promise<void>;
|
||||
getChallenge(id: string): Promise<CaptchaChallengeRecord | undefined>;
|
||||
incrementAttempts(id: string, now: number): Promise<CaptchaChallengeRecord | undefined>;
|
||||
consumeChallenge(id: string, now: number): Promise<CaptchaChallengeRecord | undefined>;
|
||||
deleteChallenge(id: string): Promise<void>;
|
||||
createToken(record: CaptchaResponseTokenRecord): Promise<void>;
|
||||
getToken(tokenHash: string): Promise<CaptchaResponseTokenRecord | undefined>;
|
||||
consumeToken(tokenHash: string, now: number): Promise<CaptchaResponseTokenRecord | undefined>;
|
||||
deleteToken(tokenHash: string): Promise<void>;
|
||||
gc?(now: number): Promise<void>;
|
||||
}
|
||||
|
||||
export interface GeneratedCaptchaChallenge {
|
||||
type: CaptchaChallengeType;
|
||||
presentation: CaptchaPresentation;
|
||||
prompt: string;
|
||||
answer: string;
|
||||
answerKind: CaptchaChallengeRecord["answerKind"];
|
||||
image?: string;
|
||||
items?: CaptchaImageItem[];
|
||||
minSelections?: number;
|
||||
maxSelections?: number;
|
||||
inputMode?: CaptchaChallenge["inputMode"];
|
||||
audioSequence?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CaptchaGeneratorContext {
|
||||
difficulty: CaptchaDifficulty;
|
||||
/** Normalized visual disturbance percentage in the inclusive range 25..75. */
|
||||
disturbance: number;
|
||||
/** Concrete renderer selected for this challenge. */
|
||||
imageStyle: CaptchaConcreteImageStyle;
|
||||
/** Renderer requested by the caller before random resolution. */
|
||||
requestedImageStyle: CaptchaImageStyle;
|
||||
/** Concrete renderer pool available to random selection. */
|
||||
imageStylePool: readonly CaptchaConcreteImageStyle[];
|
||||
locale: string;
|
||||
length?: number;
|
||||
caseSensitive: boolean;
|
||||
minCompletionMs: number;
|
||||
randomInt(min: number, max: number): number;
|
||||
randomFloat(): number;
|
||||
randomId(bytes?: number): string;
|
||||
}
|
||||
|
||||
export interface CaptchaChallengeGenerator {
|
||||
readonly type: CaptchaChallengeType;
|
||||
generate(context: CaptchaGeneratorContext): GeneratedCaptchaChallenge | Promise<GeneratedCaptchaChallenge>;
|
||||
}
|
||||
|
||||
export interface CaptchaAudioRenderer {
|
||||
render(sequence: string[], locale: string): Promise<Uint8Array>;
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
export interface CaptchaEngineOptions {
|
||||
secret: string;
|
||||
store?: CaptchaStore;
|
||||
generators?: CaptchaChallengeGenerator[];
|
||||
audioRenderer?: CaptchaAudioRenderer;
|
||||
basePath?: string;
|
||||
challengeTtlMs?: number;
|
||||
responseTokenTtlMs?: number;
|
||||
maxAttempts?: number;
|
||||
minCompletionMs?: number;
|
||||
responseField?: string;
|
||||
defaultType?: CaptchaChallengeType;
|
||||
defaultDifficulty?: CaptchaDifficulty;
|
||||
bindIp?: boolean;
|
||||
now?: () => number;
|
||||
randomBytes?: (length: number) => Uint8Array;
|
||||
}
|
||||
|
||||
export interface CaptchaEngine {
|
||||
readonly provider: "self-hosted";
|
||||
readonly basePath: string;
|
||||
create(options: CreateCaptchaOptions): Promise<CaptchaChallenge>;
|
||||
verify(input: VerifyCaptchaInput): Promise<CaptchaVerificationResult>;
|
||||
verifyResponseToken(input: VerifyCaptchaInput): Promise<CaptchaVerificationResult>;
|
||||
renderAudio(challengeId: string, key: string): Promise<{ bytes: Uint8Array; contentType: string } | undefined>;
|
||||
gc(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface CaptchaProviderClientConfig {
|
||||
responseField: string;
|
||||
siteKey?: string;
|
||||
scriptUrl?: string;
|
||||
widgetClass?: string;
|
||||
managedCreateUrl?: string;
|
||||
}
|
||||
|
||||
export interface CaptchaProvider {
|
||||
readonly name: CaptchaProviderName;
|
||||
readonly client: CaptchaProviderClientConfig;
|
||||
createChallenge?(options: CreateCaptchaOptions): Promise<CaptchaChallenge>;
|
||||
verify(input: VerifyCaptchaInput): Promise<CaptchaVerificationResult>;
|
||||
}
|
||||
|
||||
export interface CaptchaRiskSignals {
|
||||
failedAttempts?: number;
|
||||
requestsInWindow?: number;
|
||||
completionMs?: number;
|
||||
missingBrowserSignals?: boolean;
|
||||
suspiciousHeaders?: boolean;
|
||||
tokenReuse?: boolean;
|
||||
knownBadIp?: boolean;
|
||||
customScore?: number;
|
||||
}
|
||||
|
||||
export interface CaptchaRiskResult {
|
||||
score: number;
|
||||
challenge: boolean;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export interface CaptchaPolicyOptions {
|
||||
mode?: CaptchaPolicyMode;
|
||||
threshold?: number;
|
||||
verifiedForMs?: number;
|
||||
alwaysForActions?: string[];
|
||||
neverForActions?: string[];
|
||||
routeGroups?: string[];
|
||||
}
|
||||
|
||||
export interface CaptchaGuardOptions {
|
||||
action: string | ((ctx: Context) => string);
|
||||
responseField?: string;
|
||||
provider?: CaptchaProvider;
|
||||
engine?: CaptchaEngine;
|
||||
failureStatus?: number;
|
||||
failureMessage?: string;
|
||||
bindHostname?: boolean;
|
||||
bindSession?: boolean;
|
||||
bindIp?: boolean;
|
||||
onFailure?: (ctx: Context, result: CaptchaVerificationResult) => Response | Promise<Response>;
|
||||
}
|
||||
|
||||
export interface CaptchaPageGateOptions extends CaptchaGuardOptions {
|
||||
policy?: CaptchaPolicyOptions;
|
||||
challengePath?: string;
|
||||
returnToParam?: string;
|
||||
sessionKey?: string;
|
||||
signals?: (ctx: Context) => CaptchaRiskSignals | Promise<CaptchaRiskSignals>;
|
||||
}
|
||||
|
||||
export type CaptchaMiddleware = Middleware;
|
||||
|
||||
export interface CaptchaHttpHandlers {
|
||||
handle(request: Request, ctx?: Context): Promise<Response | undefined>;
|
||||
create(request: Request, ctx?: Context): Promise<Response>;
|
||||
verify(request: Request, ctx?: Context): Promise<Response>;
|
||||
audio(request: Request, ctx?: Context): Promise<Response>;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import type { ObjectSchema, ParseResult } from "@wrnexus/validation";
|
||||
import type {
|
||||
CaptchaEngine,
|
||||
CaptchaProvider,
|
||||
CaptchaVerificationResult,
|
||||
} from "./types.ts";
|
||||
import { selfHostedProvider } from "./providers/self-hosted.ts";
|
||||
|
||||
export interface ParseWithCaptchaOptions {
|
||||
action: string;
|
||||
provider?: CaptchaProvider;
|
||||
engine?: CaptchaEngine;
|
||||
responseField?: string;
|
||||
bindHostname?: boolean;
|
||||
bindSession?: boolean;
|
||||
bindIp?: boolean;
|
||||
}
|
||||
|
||||
export interface CaptchaParseResult<T = Record<string, unknown>> extends ParseResult<T> {
|
||||
captcha: CaptchaVerificationResult;
|
||||
}
|
||||
|
||||
export async function parseWithCaptcha<T = Record<string, unknown>>(
|
||||
schema: ObjectSchema,
|
||||
input: Record<string, unknown>,
|
||||
ctx: Context,
|
||||
options: ParseWithCaptchaOptions,
|
||||
): Promise<CaptchaParseResult<T>> {
|
||||
const parsed = schema.parse(input) as ParseResult<T>;
|
||||
const provider = options.provider ?? (options.engine ? selfHostedProvider(options.engine) : undefined);
|
||||
if (!provider) throw new TypeError("parseWithCaptcha requires provider or engine");
|
||||
const field = options.responseField ?? provider.client.responseField;
|
||||
const token = input[field] ?? input.captchaToken ?? input.responseToken;
|
||||
const captcha = await provider.verify({
|
||||
action: options.action,
|
||||
providerToken: token === undefined ? undefined : String(token),
|
||||
responseToken: token === undefined ? undefined : String(token),
|
||||
hostname: options.bindHostname === false ? undefined : ctx.url.hostname,
|
||||
sessionId: options.bindSession === false ? undefined : ctx.session.id(),
|
||||
ip: options.bindIp ? ctx.ip : undefined,
|
||||
});
|
||||
const errors = { ...parsed.errors };
|
||||
if (!captcha.success) errors[field] = captcha.message ?? "CAPTCHA verification failed";
|
||||
return {
|
||||
...parsed,
|
||||
ok: parsed.ok && captcha.success,
|
||||
errors,
|
||||
captcha,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user