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);
|
||||
}
|
||||
Reference in New Issue
Block a user