New Captcha Package added

This commit is contained in:
2026-07-25 13:38:18 +05:30
parent d0aded0392
commit 8b728a3e5d
157 changed files with 12092 additions and 1913 deletions
+1
View File
@@ -0,0 +1 @@
export * from "./renderer.ts";
+239
View File
@@ -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);
}