Files
WRNexusJS/packages/captcha/src/challenges/styles.ts
T
2026-07-27 12:42:18 +05:30

118 lines
3.4 KiB
TypeScript

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,
};
}