New Captcha Package added
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
# Changelog
|
||||
|
||||
## Fixed 9
|
||||
|
||||
- Reduced compact mode from `max-w-sm` to `max-w-xs` with smaller padding and tighter section spacing.
|
||||
- Reduced compact challenge images, loading state, answer input, action buttons, labels, icons, and status typography.
|
||||
- Hid the descriptive subtitle and provider credit only in compact mode to remove nonessential vertical height.
|
||||
- Kept normal and big modes unchanged and preserved the legacy `compact=true` alias.
|
||||
|
||||
## Fixed 8
|
||||
|
||||
- Added 18 generated CAPTCHA image renderers: classic, collision, snow, corrosion, spiderweb, cross-shadow, split, split2, cut, darts, distortion, stitch, striped, wave, grid-noise, scribble, pixel, and broken-lines.
|
||||
- Added `imageStyle="random"`, allowed/excluded renderer pools, and forced randomization.
|
||||
- Added resolved renderer metadata, client-runtime forwarding, managed API schema support, and a full renderer showcase page.
|
||||
- Fixed floating-point line endpoints in the PNG renderer and replaced the slow bitwise CRC loop with a lookup-table implementation.
|
||||
- Added renderer catalog, PNG integrity, style-pool, component, runtime, engine, and showcase tests.
|
||||
|
||||
## 0.3.6-fixed7
|
||||
|
||||
- Added `compact`, `normal`, and `big` component size modes with legacy size aliases.
|
||||
- Added the `showListen` prop while preserving the existing `showAudio` switch.
|
||||
- Added the self-hosted `not-robot` checkbox challenge with timing, honeypot, expiry, attempt, action, binding, and one-time token checks.
|
||||
- Added compact not-robot rendering and browser-runtime verification behavior.
|
||||
- Added showcase examples and regression coverage for size modes, listen visibility, and not-robot verification.
|
||||
|
||||
## 0.3.6-fixed6
|
||||
|
||||
- Fixed the page-gate showcase action URL and safe return-path handling.
|
||||
- Added visual disturbance percentages from 25 through 75 for text and image-selection challenges.
|
||||
- Added disturbance metadata, component/runtime support, managed API documentation, and range validation.
|
||||
- Added a complete browser-and-server validated contact form protected by CAPTCHA.
|
||||
- Prevented schema validators from submitting required CAPTCHA forms before verification.
|
||||
- Added automatic CAPTCHA refresh after a successful validated form submission.
|
||||
- Added page-gate, disturbance, runtime, component, and showcase regression tests.
|
||||
|
||||
## 0.3.6
|
||||
|
||||
- Fixed DOM `BodyInit` compatibility for audio responses by converting `Uint8Array<ArrayBufferLike>` to a copied `ArrayBuffer`.
|
||||
- Added self-hosted number, alphabet, alphanumeric, calculation, generated-image, honeypot, and timing challenges.
|
||||
- Added visual, audio, and invisible presentations.
|
||||
- Added one-use challenge and response-token verification with action and optional binding controls.
|
||||
- Added memory, SQLite-compatible, Redis-compatible, and custom store contracts.
|
||||
- Added WRNexus-managed, Turnstile, reCAPTCHA, hCaptcha, and custom provider adapters.
|
||||
- Added form guard, page gate, adaptive risk policy, validation integration, HTTP handlers, and DevToolbar audits.
|
||||
- Added Tailwind-only, theme-aware `Captcha.wrn` component.
|
||||
- Added managed-service starter, example application, tests, installer, and migration documentation.
|
||||
@@ -0,0 +1,324 @@
|
||||
# @wrnexus/captcha
|
||||
|
||||
A first-class CAPTCHA and anti-automation package for WRNexusJS. It supports self-hosted challenges, a managed WRNexus service, external providers, form submission guards, page gates, accessible audio, adaptive risk checks, and a Tailwind-only `.wrn` component.
|
||||
|
||||
## Included challenge modes
|
||||
|
||||
- Number, alphabet, and alphanumeric image challenges
|
||||
- Addition, subtraction, multiplication, and exact-division calculations
|
||||
- Generated shape-selection image challenges
|
||||
- Audio alternatives for text, numbers, and calculations
|
||||
- Honeypot and minimum-completion-time invisible checks
|
||||
- Self-hosted “I’m not a robot” checkbox challenge with one-time server verification
|
||||
- Always, once-per-session, and adaptive page gates
|
||||
- Cloudflare Turnstile, Google reCAPTCHA, hCaptcha, managed, and custom providers
|
||||
|
||||
## Create the self-hosted engine
|
||||
|
||||
```ts
|
||||
import {
|
||||
createCaptchaEngine,
|
||||
createCaptchaHttpHandlers,
|
||||
RedisCaptchaStore,
|
||||
} from "@wrnexus/captcha/server";
|
||||
|
||||
const engine = createCaptchaEngine({
|
||||
secret: process.env.CAPTCHA_SECRET!,
|
||||
store: new RedisCaptchaStore(redis),
|
||||
basePath: "/api/captcha",
|
||||
challengeTtlMs: 2 * 60_000,
|
||||
responseTokenTtlMs: 5 * 60_000,
|
||||
maxAttempts: 3,
|
||||
minCompletionMs: 800,
|
||||
});
|
||||
|
||||
export const handlers = createCaptchaHttpHandlers(engine);
|
||||
```
|
||||
|
||||
Mount the handlers from an API catch-all route:
|
||||
|
||||
```ts
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import { handlers } from "../../lib/captcha.ts";
|
||||
|
||||
export async function POST(ctx: Context) {
|
||||
return (await handlers.handle(ctx.req, ctx)) ?? new Response("Not Found", { status: 404 });
|
||||
}
|
||||
export const GET = POST;
|
||||
export const HEAD = POST;
|
||||
```
|
||||
|
||||
## Use the component
|
||||
|
||||
```wrn
|
||||
<Captcha
|
||||
type="alphanumeric"
|
||||
action="signup"
|
||||
endpoint="/api/captcha/challenge"
|
||||
verifyEndpoint="/api/captcha/verify"
|
||||
difficulty="normal"
|
||||
disturbance="50"
|
||||
imageStyle="random"
|
||||
allowedStyles="classic,snow,distortion,wave"
|
||||
size="normal"
|
||||
showAudio="true"
|
||||
showListen="true"
|
||||
@success='captchaToken = event.detail.responseToken'
|
||||
@failure='formError = event.detail.extra.message'
|
||||
/>
|
||||
```
|
||||
|
||||
The component uses Tailwind utilities and `--wire-*` theme variables. It has no companion component CSS file.
|
||||
|
||||
### Main props
|
||||
|
||||
`provider`, `siteKey`, `type`, `action`, `presentation`, `difficulty`, `disturbance`, `imageStyle`, `allowedStyles`, `excludedStyles`, `randomizeStyle`, `locale`, `size`, `color`, `class`, `name`, `endpoint`, `verifyEndpoint`, `responseField`, labels/messages, `autoLoad`, `autoVerify`, `showVerify`, `showRefresh`, `showAudio`, `showListen`, `showStatus`, `disabled`, `required`, and the backward-compatible `compact` alias.
|
||||
|
||||
### Component sizes
|
||||
|
||||
Use one of the three supported display modes:
|
||||
|
||||
```wrn
|
||||
<Captcha size="compact" action="small-form" />
|
||||
<Captcha size="normal" action="standard-form" />
|
||||
<Captcha size="big" action="security-page" />
|
||||
```
|
||||
|
||||
`small`/`sm` are accepted as aliases for `compact`, while `large`/`lg` are accepted as aliases for `big`. The old `compact="true"` prop still forces compact mode.
|
||||
|
||||
### Listen button visibility
|
||||
|
||||
Audio remains available by default. Hide the Listen and Use audio controls with either of these props:
|
||||
|
||||
```wrn
|
||||
<Captcha showListen="false" action="without-listen-button" />
|
||||
<Captcha showAudio="false" action="without-audio-alternative" />
|
||||
```
|
||||
|
||||
`showListen` is the direct UI switch. `showAudio` remains the broader backward-compatible audio switch.
|
||||
|
||||
### I’m not a robot checkbox
|
||||
|
||||
```wrn
|
||||
<Captcha
|
||||
type="not-robot"
|
||||
action="contact-submit"
|
||||
size="compact"
|
||||
showListen="false"
|
||||
/>
|
||||
```
|
||||
|
||||
The checkbox is not a client-only boolean. Clicking it completes a self-hosted invisible challenge that is time-limited, attempt-limited, one-time-use, action-bound, optionally session/hostname/IP-bound, and verified on the server. It is a low-friction anti-automation layer; use adaptive escalation to a visual or external provider for high-risk traffic.
|
||||
|
||||
### Visual disturbance
|
||||
|
||||
Use `disturbance` for visual and image-selection challenges. It accepts an integer from `25` through `75`:
|
||||
|
||||
- `25`: light disturbance and easiest readability
|
||||
- `50`: balanced default
|
||||
- `75`: maximum supported dots, line crossings, glyph movement, and image-tile noise
|
||||
|
||||
The browser sends this value to the challenge API, and the server validates the range before generating the challenge. It is also returned in challenge metadata.
|
||||
|
||||
### Generated image renderer styles
|
||||
|
||||
Text, number, alphanumeric, and calculation CAPTCHA images support 18 concrete renderers plus a random mode:
|
||||
|
||||
`classic`, `collision`, `snow`, `corrosion`, `spiderweb`, `cross-shadow`, `split`, `split2`, `cut`, `darts`, `distortion`, `stitch`, `striped`, `wave`, `grid-noise`, `scribble`, `pixel`, and `broken-lines`.
|
||||
|
||||
Use a fixed style:
|
||||
|
||||
```wrn
|
||||
<Captcha
|
||||
type="alphanumeric"
|
||||
action="signup"
|
||||
imageStyle="spiderweb"
|
||||
disturbance="55"
|
||||
/>
|
||||
```
|
||||
|
||||
Use a new random style whenever the challenge is refreshed:
|
||||
|
||||
```wrn
|
||||
<Captcha
|
||||
type="number"
|
||||
action="login"
|
||||
imageStyle="random"
|
||||
difficulty="normal"
|
||||
/>
|
||||
```
|
||||
|
||||
Control the random pool with comma-separated component props or arrays in the TypeScript API:
|
||||
|
||||
```wrn
|
||||
<Captcha
|
||||
type="alphanumeric"
|
||||
action="checkout"
|
||||
imageStyle="random"
|
||||
allowedStyles="classic,snow,distortion,wave"
|
||||
excludedStyles="collision"
|
||||
/>
|
||||
```
|
||||
|
||||
```ts
|
||||
const challenge = await engine.create({
|
||||
action: "checkout",
|
||||
type: "alphanumeric",
|
||||
imageStyle: "random",
|
||||
allowedStyles: ["classic", "snow", "distortion", "wave"],
|
||||
excludedStyles: ["collision"],
|
||||
});
|
||||
```
|
||||
|
||||
Set `randomizeStyle: true` to force random selection even when `imageStyle` names a concrete renderer. The resolved style, requested style, and active pool are returned in challenge metadata. The answer is never embedded in metadata or browser JavaScript.
|
||||
|
||||
### Events
|
||||
|
||||
`@ready`, `@challenge`, `@input`, `@verify`, `@success`, `@failure`, `@expired`, `@refresh`, `@audioStart`, `@audioEnd`, and `@error`.
|
||||
|
||||
## Protect a validated form API
|
||||
|
||||
Validate a cloned request first, then consume the CAPTCHA response token. This prevents a valid token from being consumed when ordinary field validation fails.
|
||||
|
||||
```ts
|
||||
import { captchaGuard } from "@wrnexus/captcha/server";
|
||||
import { parseBody } from "@wrnexus/validation";
|
||||
import contactSchema from "../schemas/contact.ts";
|
||||
import { engine } from "../lib/captcha.ts";
|
||||
|
||||
const guard = captchaGuard({
|
||||
action: "contact-submit",
|
||||
engine,
|
||||
bindHostname: true,
|
||||
bindSession: true,
|
||||
});
|
||||
|
||||
export async function POST(ctx) {
|
||||
const validation = await parseBody(contactSchema, ctx.req.clone());
|
||||
if (!validation.ok) return validation.response;
|
||||
|
||||
return guard(ctx, async () =>
|
||||
Response.json({ ok: true, submission: validation.value }),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The CAPTCHA runtime binds its required-form check in the capture phase, so a `data-schema` validator cannot submit the form before CAPTCHA verification. After a successful form request, the component automatically creates a fresh challenge.
|
||||
|
||||
## Validate a schema and CAPTCHA together
|
||||
|
||||
```ts
|
||||
const result = await parseWithCaptcha(signupSchema, body, ctx, {
|
||||
action: "signup",
|
||||
engine,
|
||||
});
|
||||
|
||||
if (!result.ok) return Response.json({ ok: false, errors: result.errors }, { status: 400 });
|
||||
```
|
||||
|
||||
## Page gate
|
||||
|
||||
```ts
|
||||
export default captchaPageGate({
|
||||
action: "reports-access",
|
||||
engine,
|
||||
challengePath: "/captcha",
|
||||
policy: {
|
||||
mode: "session",
|
||||
verifiedForMs: 15 * 60_000,
|
||||
routeGroups: ["/reports"],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Use `mode: "always"` for every visit, `mode: "session"` for a temporary grant, or `mode: "adaptive"` with `signals(ctx)`.
|
||||
|
||||
The challenge page should post the return path as a normal hidden field instead of constructing JavaScript inside the HTML `action` attribute:
|
||||
|
||||
```wrn
|
||||
<form method="post" action="/api/page-grant">
|
||||
<input type="hidden" name="returnTo" value='{returnTo}' />
|
||||
<Captcha action="reports-access" />
|
||||
<button type="submit">Continue</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
The `/api/page-grant` route reads `returnTo`, restricts it to the current origin, and redirects only after `captchaPageGate()` has verified and stored the temporary session grant.
|
||||
|
||||
## External providers
|
||||
|
||||
```ts
|
||||
const turnstile = turnstileProvider({
|
||||
secretKey: process.env.TURNSTILE_SECRET!,
|
||||
siteKey: process.env.PUBLIC_TURNSTILE_SITE_KEY!,
|
||||
expectedHostnames: ["example.com"],
|
||||
expectedAction: "signup",
|
||||
});
|
||||
```
|
||||
|
||||
```wrn
|
||||
<Captcha
|
||||
provider="turnstile"
|
||||
siteKey="PUBLIC_SITE_KEY"
|
||||
action="signup"
|
||||
/>
|
||||
```
|
||||
|
||||
Use the matching provider in `captchaGuard({ provider: turnstile })`. reCAPTCHA and hCaptcha adapters follow the same pattern.
|
||||
|
||||
## Managed provider
|
||||
|
||||
```ts
|
||||
const managed = managedCaptchaProvider({
|
||||
baseUrl: "https://captcha.example.com",
|
||||
siteKey: process.env.PUBLIC_CAPTCHA_SITE_KEY!,
|
||||
secretKey: process.env.CAPTCHA_SECRET_KEY!,
|
||||
});
|
||||
```
|
||||
|
||||
For direct browser challenge creation, configure the component’s `endpoint` as the managed `/v1/challenges` URL and its `verifyEndpoint` as `/v1/solve`. Keep the secret key only in the server provider.
|
||||
|
||||
## Stores
|
||||
|
||||
- `MemoryCaptchaStore`: development and one-process applications
|
||||
- `SqliteCaptchaStore`: adapter for SQLite-like `prepare().run/get/all()` clients
|
||||
- `RedisCaptchaStore`: shared TTL storage with Lua-backed atomic consumption when `eval` is available
|
||||
- `CaptchaStore`: implement this interface for PostgreSQL, MySQL, MongoDB, or another backend
|
||||
|
||||
## Audio
|
||||
|
||||
`AssetAudioRenderer` concatenates bundled English PCM WAV clips without calling an external service. Supply a custom `CaptchaAudioRenderer` for recorded voices, Hindi or other languages, or managed text-to-speech.
|
||||
|
||||
## DevToolbar
|
||||
|
||||
Register `captchaPlugin()` in the WRNexusJS config. The audit panel checks for likely client-side secrets, missing action bindings, missing provider site keys, optional CAPTCHA fields, accessible alternatives, and server-verification reminders.
|
||||
|
||||
## Testing
|
||||
|
||||
Use deterministic custom generators in unit tests. Never require users or CI to solve random CAPTCHA images. The package includes engine, provider, policy, HTTP, storage, replay, expiry, binding, and audio authorization tests.
|
||||
|
||||
## Custom challenge generator
|
||||
|
||||
```ts
|
||||
import { defineCaptchaGenerator, createCaptchaEngine } from "@wrnexus/captcha";
|
||||
|
||||
const wordChallenge = defineCaptchaGenerator({
|
||||
type: "word" as const,
|
||||
generate(context) {
|
||||
const answer = "NEXUS";
|
||||
return {
|
||||
type: "word",
|
||||
presentation: "visual",
|
||||
prompt: "Enter the displayed word",
|
||||
answer,
|
||||
answerKind: "text",
|
||||
image: renderYourImage(answer),
|
||||
inputMode: "text",
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const engine = createCaptchaEngine({ secret, generators: [wordChallenge] });
|
||||
```
|
||||
|
||||
Applications may also implement `CaptchaStore`, `CaptchaAudioRenderer`, or use `defineCaptchaProvider()` for a completely custom service.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Security model
|
||||
|
||||
CAPTCHA is an anti-automation signal, not authentication or authorization. Continue using CSRF protection, rate limiting, validation, secure sessions, and access control.
|
||||
|
||||
## Enforced by the self-hosted engine
|
||||
|
||||
- Answers are stored as HMAC-SHA-256 digests with a per-challenge salt.
|
||||
- Challenge IDs and response tokens use cryptographically secure random bytes.
|
||||
- Challenges expire, have attempt limits, and are consumed after a correct answer.
|
||||
- Response tokens are opaque, hashed at rest, action-bound, and single-use by default.
|
||||
- Optional hostname, session, and IP bindings are validated with constant-time comparisons.
|
||||
- Audio URLs contain a random access key and are private/no-store.
|
||||
- Public challenge payloads never include the answer. Renderer names and disturbance metadata are safe to expose because they do not reveal the answer.
|
||||
- Same-origin HTTP handlers and independent create/verify rate limits are enabled by default.
|
||||
- The `not-robot` checkbox uses timing, honeypot, expiry, attempt limits, bindings, and one-use verification; it is intentionally low-friction and should escalate to a stronger challenge when risk is high.
|
||||
|
||||
## Required deployment controls
|
||||
|
||||
1. Set `CAPTCHA_SECRET` to at least 32 unpredictable characters and rotate it through your secret manager.
|
||||
2. Use Redis, SQL, or another shared atomic store in multi-instance production deployments.
|
||||
3. Always verify the response token on the server. Never trust only a client event or hidden input.
|
||||
4. Bind every CAPTCHA to a stable action such as `signup`, `login`, or `contact-submit`.
|
||||
5. Restrict managed and third-party keys to expected hostnames.
|
||||
6. Never pass a secret key to `<Captcha />` or serialize it into browser output.
|
||||
7. Apply route-level rate limits before expensive image/audio generation.
|
||||
8. Use TLS in production and avoid logging answers, raw tokens, secret keys, or unnecessary raw IP addresses.
|
||||
9. Offer an accessible alternative to visual challenges. Hard renderers and disturbance near 75 should never be the only available path.
|
||||
10. Prefer `imageStyle="random"` or a controlled pool to reduce static segmentation patterns, but do not treat renderer randomness as a replacement for server verification, expiry, attempt limits, or rate limiting.
|
||||
11. Keep provider SDK behavior and verification rules current before release.
|
||||
|
||||
## Reporting
|
||||
|
||||
Report suspected vulnerabilities privately to the WorkRoot/WRNexusJS maintainers. Do not open a public issue containing active keys, tokens, or exploit details.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Bundled English audio clips
|
||||
|
||||
These PCM WAV clips were generated locally with eSpeak for the initial English CAPTCHA vocabulary. They cover digits, letters, and calculation words. Applications may replace the renderer or asset directory to use recorded voices, additional languages, or an external text-to-speech service.
|
||||
|
||||
The package does not transmit text to an external speech provider. Keep an accessible non-audio alternative available and rate-limit audio playback endpoints.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,878 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var RUNTIME_KEY = "__wrnexusCaptchaRuntime";
|
||||
var existingRuntime = window[RUNTIME_KEY];
|
||||
|
||||
if (existingRuntime && typeof existingRuntime.scan === "function") {
|
||||
existingRuntime.scan(document);
|
||||
return;
|
||||
}
|
||||
|
||||
var states = new WeakMap();
|
||||
var scriptPromises = new Map();
|
||||
var instanceCounter = 0;
|
||||
|
||||
function bool(value, fallback) {
|
||||
if (value === undefined || value === null || value === "") return fallback;
|
||||
return value === true || value === "true" || value === "1";
|
||||
}
|
||||
|
||||
function numberInRange(value, fallback, minimum, maximum) {
|
||||
var parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return Math.max(minimum, Math.min(maximum, Math.round(parsed)));
|
||||
}
|
||||
|
||||
function normalizeSize(value, compact) {
|
||||
if (compact) return "compact";
|
||||
var normalized = String(value || "normal").trim().toLowerCase();
|
||||
if (normalized === "compact" || normalized === "small" || normalized === "sm") return "compact";
|
||||
if (normalized === "big" || normalized === "large" || normalized === "lg") return "big";
|
||||
return "normal";
|
||||
}
|
||||
|
||||
function commaList(value) {
|
||||
return String(value || "")
|
||||
.split(",")
|
||||
.map(function (item) { return item.trim().toLowerCase(); })
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function text(root, selector, value) {
|
||||
var element = root.querySelector(selector);
|
||||
if (element) element.textContent = value == null ? "" : String(value);
|
||||
}
|
||||
|
||||
function show(element, visible) {
|
||||
if (!element) return;
|
||||
element.hidden = !visible;
|
||||
}
|
||||
|
||||
function setBusy(root, busy) {
|
||||
root.setAttribute("aria-busy", busy ? "true" : "false");
|
||||
}
|
||||
|
||||
function config(root) {
|
||||
var data = root.dataset;
|
||||
var responseField = data.captchaResponseField || data.captchaName || "wrn-captcha-response";
|
||||
var compact = bool(data.captchaCompact, false);
|
||||
var size = normalizeSize(data.captchaSize, compact);
|
||||
|
||||
return {
|
||||
provider: data.captchaProvider || "self-hosted",
|
||||
siteKey: data.captchaSiteKey || "",
|
||||
type: data.captchaType || "alphanumeric",
|
||||
action: data.captchaAction || "form-submit",
|
||||
presentation: data.captchaPresentation || "visual",
|
||||
difficulty: data.captchaDifficulty || "normal",
|
||||
disturbance: numberInRange(data.captchaDisturbance, 50, 25, 75),
|
||||
imageStyle: data.captchaImageStyle || "random",
|
||||
allowedStyles: commaList(data.captchaAllowedStyles),
|
||||
excludedStyles: commaList(data.captchaExcludedStyles),
|
||||
randomizeStyle: bool(data.captchaRandomizeStyle, false),
|
||||
locale: data.captchaLocale || "en",
|
||||
size: size,
|
||||
endpoint: data.captchaEndpoint || "/__wrnexus/captcha/challenge",
|
||||
verifyEndpoint: data.captchaVerifyEndpoint || "/__wrnexus/captcha/verify",
|
||||
responseField: responseField,
|
||||
autoLoad: bool(data.captchaAutoLoad, true),
|
||||
autoVerify: bool(data.captchaAutoVerify, false),
|
||||
showVerify: bool(data.captchaShowVerify, true),
|
||||
showRefresh: bool(data.captchaShowRefresh, true),
|
||||
showAudio: bool(data.captchaShowAudio, true),
|
||||
showListen: bool(data.captchaShowListen, true),
|
||||
showStatus: bool(data.captchaShowStatus, true),
|
||||
disabled: bool(data.captchaDisabled, false),
|
||||
required: bool(data.captchaRequired, true),
|
||||
compact: compact,
|
||||
requiredMessage: data.captchaRequiredMessage || "Please complete the security check.",
|
||||
incorrectMessage: data.captchaIncorrectMessage || "That answer was not correct. Try again.",
|
||||
expiredMessage: data.captchaExpiredMessage || "This challenge expired. Load a new one.",
|
||||
networkMessage: data.captchaNetworkMessage || "The verification service is unavailable. Try again.",
|
||||
};
|
||||
}
|
||||
|
||||
function eventDetail(state, extra) {
|
||||
var challenge = state.challenge;
|
||||
return {
|
||||
component: "Captcha",
|
||||
provider: state.config.provider,
|
||||
type: challenge && challenge.type ? challenge.type : state.config.type,
|
||||
action: state.config.action,
|
||||
disturbance: state.config.disturbance,
|
||||
imageStyle: challenge && challenge.metadata && challenge.metadata.imageStyle
|
||||
? challenge.metadata.imageStyle
|
||||
: state.config.imageStyle,
|
||||
requestedImageStyle: challenge && challenge.metadata && challenge.metadata.requestedImageStyle
|
||||
? challenge.metadata.requestedImageStyle
|
||||
: state.config.imageStyle,
|
||||
imageStylePool: challenge && challenge.metadata && challenge.metadata.imageStylePool
|
||||
? challenge.metadata.imageStylePool
|
||||
: state.config.allowedStyles,
|
||||
size: state.config.size,
|
||||
status: state.status,
|
||||
challengeId: challenge && challenge.id ? challenge.id : "",
|
||||
responseToken: state.responseToken,
|
||||
expiresAt: challenge && challenge.expiresAt ? challenge.expiresAt : null,
|
||||
extra: extra || null,
|
||||
};
|
||||
}
|
||||
|
||||
function emit(state, name, extra) {
|
||||
state.root.dispatchEvent(
|
||||
new CustomEvent(name, {
|
||||
bubbles: true,
|
||||
detail: eventDetail(state, extra),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function setResponseToken(state, token) {
|
||||
state.responseToken = token || "";
|
||||
var input = state.root.querySelector("[data-captcha-response]");
|
||||
if (input) {
|
||||
input.name = state.config.responseField;
|
||||
input.value = state.responseToken;
|
||||
}
|
||||
}
|
||||
|
||||
function isNotRobot(state) {
|
||||
return Boolean(
|
||||
(state.challenge && state.challenge.type === "not-robot") ||
|
||||
(!state.challenge && state.config.type === "not-robot"),
|
||||
);
|
||||
}
|
||||
|
||||
function updateNotRobotState(state) {
|
||||
var panel = state.root.querySelector("[data-captcha-not-robot]");
|
||||
var button = state.root.querySelector("[data-captcha-not-robot-button]");
|
||||
var empty = state.root.querySelector("[data-captcha-not-robot-empty]");
|
||||
var spinner = state.root.querySelector("[data-captcha-not-robot-spinner]");
|
||||
var check = state.root.querySelector("[data-captcha-not-robot-check]");
|
||||
var control = state.root.querySelector("[data-captcha-not-robot-control]");
|
||||
var label = state.root.querySelector("[data-captcha-not-robot-label]");
|
||||
var active = isNotRobot(state);
|
||||
var pending = state.notRobotPending || state.status === "verifying";
|
||||
var verified = state.status === "verified";
|
||||
|
||||
show(panel, active && state.status !== "loading" && state.status !== "idle");
|
||||
if (!active) return;
|
||||
|
||||
if (button) {
|
||||
button.disabled = state.config.disabled || pending || verified || state.status === "expired";
|
||||
button.setAttribute("aria-pressed", verified ? "true" : "false");
|
||||
}
|
||||
show(empty, !pending && !verified);
|
||||
show(spinner, pending && !verified);
|
||||
show(check, verified);
|
||||
|
||||
if (control) control.dataset.verified = verified ? "true" : "false";
|
||||
if (label) {
|
||||
label.textContent = verified ? "Verified" : pending ? "Checking…" : "I'm not a robot";
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(state, status, message) {
|
||||
state.status = status;
|
||||
state.root.dataset.captchaStatus = status;
|
||||
setBusy(state.root, status === "loading" || status === "verifying");
|
||||
|
||||
var loading = state.root.querySelector("[data-captcha-loading]");
|
||||
var challenge = state.root.querySelector("[data-captcha-challenge]");
|
||||
var success = state.root.querySelector("[data-captcha-success]");
|
||||
var error = state.root.querySelector("[data-captcha-error]");
|
||||
var badge = state.root.querySelector("[data-captcha-verified-badge]");
|
||||
var verifyButton = state.root.querySelector("[data-captcha-verify]");
|
||||
var verifyIcon = state.root.querySelector("[data-captcha-verify-icon]");
|
||||
var verifySpinner = state.root.querySelector("[data-captcha-verify-spinner]");
|
||||
var verifyLabel = state.root.querySelector("[data-captcha-verify-label]");
|
||||
var answer = state.root.querySelector("[data-captcha-answer]");
|
||||
|
||||
show(loading, status === "loading");
|
||||
show(challenge, status !== "loading" && status !== "idle" && state.config.provider !== "turnstile" && state.config.provider !== "recaptcha" && state.config.provider !== "hcaptcha");
|
||||
show(badge, status === "verified");
|
||||
|
||||
if (state.config.showStatus) {
|
||||
show(success, status === "verified");
|
||||
show(error, Boolean(message) && status !== "verified");
|
||||
} else {
|
||||
show(success, false);
|
||||
show(error, false);
|
||||
}
|
||||
|
||||
if (message && status === "verified") text(state.root, "[data-captcha-success-message]", message);
|
||||
if (message && status !== "verified") text(state.root, "[data-captcha-error-message]", message);
|
||||
|
||||
if (answer) {
|
||||
answer.disabled = state.config.disabled || status === "verified" || status === "expired" || status === "loading" || status === "verifying";
|
||||
answer.setAttribute("aria-invalid", status === "incorrect" ? "true" : "false");
|
||||
}
|
||||
|
||||
if (verifyButton) {
|
||||
verifyButton.disabled = state.config.disabled || status === "loading" || status === "verifying" || status === "verified" || status === "expired";
|
||||
}
|
||||
|
||||
show(verifyIcon, status !== "verifying");
|
||||
show(verifySpinner, status === "verifying");
|
||||
if (verifyLabel) verifyLabel.textContent = status === "verifying" ? "Verifying…" : "Verify";
|
||||
|
||||
updateNotRobotState(state);
|
||||
updateControls(state);
|
||||
}
|
||||
|
||||
function clearTimer(state) {
|
||||
if (state.timer) {
|
||||
window.clearInterval(state.timer);
|
||||
state.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateCountdown(state) {
|
||||
var countdown = state.root.querySelector("[data-captcha-countdown]");
|
||||
if (!countdown || !state.challenge || !state.challenge.expiresAt) {
|
||||
show(countdown, false);
|
||||
return;
|
||||
}
|
||||
|
||||
var remaining = Math.max(0, Math.ceil((Number(state.challenge.expiresAt) - Date.now()) / 1000));
|
||||
countdown.textContent = remaining + "s";
|
||||
show(countdown, remaining > 0 && state.status !== "verified");
|
||||
|
||||
if (remaining <= 0 && state.status !== "verified" && state.status !== "expired") {
|
||||
clearTimer(state);
|
||||
setResponseToken(state, "");
|
||||
setStatus(state, "expired", state.config.expiredMessage);
|
||||
emit(state, "expired");
|
||||
}
|
||||
}
|
||||
|
||||
function startTimer(state) {
|
||||
clearTimer(state);
|
||||
if (!state.challenge || !state.challenge.expiresAt) return;
|
||||
updateCountdown(state);
|
||||
state.timer = window.setInterval(function () {
|
||||
updateCountdown(state);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function resetUi(state) {
|
||||
if (state.audioPlayer) {
|
||||
state.audioPlayer.pause();
|
||||
state.audioPlayer.removeAttribute("src");
|
||||
state.audioPlayer.load();
|
||||
state.audioPlayer = null;
|
||||
}
|
||||
if (state.notRobotTimer) {
|
||||
window.clearTimeout(state.notRobotTimer);
|
||||
state.notRobotTimer = null;
|
||||
}
|
||||
|
||||
state.notRobotPending = false;
|
||||
state.challengeLoadedAt = 0;
|
||||
state.answer = "";
|
||||
state.selections = [];
|
||||
state.challenge = null;
|
||||
setResponseToken(state, "");
|
||||
|
||||
var answer = state.root.querySelector("[data-captcha-answer]");
|
||||
var items = state.root.querySelector("[data-captcha-items]");
|
||||
var imageWrap = state.root.querySelector("[data-captcha-image-wrap]");
|
||||
var providerMount = state.root.querySelector("[data-captcha-provider-mount]");
|
||||
var honeypot = state.root.querySelector("[data-captcha-honeypot]");
|
||||
var notRobot = state.root.querySelector("[data-captcha-not-robot]");
|
||||
|
||||
if (answer) answer.value = "";
|
||||
if (items) items.replaceChildren();
|
||||
show(items, false);
|
||||
show(imageWrap, false);
|
||||
show(notRobot, false);
|
||||
if (honeypot) {
|
||||
honeypot.value = "";
|
||||
honeypot.name = "";
|
||||
}
|
||||
if (providerMount && state.config.provider !== "turnstile" && state.config.provider !== "recaptcha" && state.config.provider !== "hcaptcha") {
|
||||
providerMount.replaceChildren();
|
||||
show(providerMount, false);
|
||||
}
|
||||
}
|
||||
|
||||
function updateControls(state) {
|
||||
var challenge = state.challenge;
|
||||
var audio = state.root.querySelector("[data-captcha-audio]");
|
||||
var audioAlternative = state.root.querySelector("[data-captcha-audio-alternative]");
|
||||
var refresh = state.root.querySelector("[data-captcha-refresh]");
|
||||
var verify = state.root.querySelector("[data-captcha-verify]");
|
||||
var footer = state.root.querySelector("[data-captcha-footer]");
|
||||
var isExternal = state.config.provider === "turnstile" || state.config.provider === "recaptcha" || state.config.provider === "hcaptcha";
|
||||
var notRobot = isNotRobot(state);
|
||||
var locked = state.config.disabled || state.status === "loading" || state.status === "verifying" || state.notRobotPending;
|
||||
|
||||
show(footer, !notRobot);
|
||||
show(audio, !notRobot && state.config.showAudio && state.config.showListen && Boolean(challenge && challenge.audioUrl) && state.status !== "verified");
|
||||
show(audioAlternative, !notRobot && state.config.showAudio && Boolean(challenge && challenge.type === "image") && state.status !== "verified");
|
||||
show(refresh, !notRobot && state.config.showRefresh && state.status !== "verified");
|
||||
show(verify, !notRobot && state.config.showVerify && !isExternal);
|
||||
|
||||
if (audio) audio.disabled = locked;
|
||||
if (audioAlternative) audioAlternative.disabled = locked;
|
||||
if (refresh) refresh.disabled = locked;
|
||||
if (verify) verify.disabled = locked || state.status === "verified" || state.status === "expired";
|
||||
}
|
||||
|
||||
function renderItems(state, challenge) {
|
||||
var container = state.root.querySelector("[data-captcha-items]");
|
||||
var template = state.root.querySelector("[data-captcha-item-template]");
|
||||
if (!container || !template || !Array.isArray(challenge.items) || challenge.items.length === 0) {
|
||||
show(container, false);
|
||||
return;
|
||||
}
|
||||
|
||||
container.replaceChildren();
|
||||
container.setAttribute("aria-label", challenge.prompt || "Select matching images");
|
||||
|
||||
challenge.items.forEach(function (item, index) {
|
||||
var fragment = template.content.cloneNode(true);
|
||||
var button = fragment.querySelector("button");
|
||||
var image = fragment.querySelector("[data-captcha-item-image]");
|
||||
if (!button || !image) return;
|
||||
|
||||
button.dataset.captchaItemId = item.id;
|
||||
button.setAttribute("aria-label", item.alt || "Challenge tile " + (index + 1));
|
||||
image.src = item.image;
|
||||
image.alt = "";
|
||||
button.addEventListener("click", function () {
|
||||
toggleItem(state, item.id, button);
|
||||
});
|
||||
container.appendChild(fragment);
|
||||
});
|
||||
|
||||
show(container, true);
|
||||
}
|
||||
|
||||
function toggleItem(state, itemId, button) {
|
||||
if (state.config.disabled || state.status !== "ready") return;
|
||||
|
||||
var index = state.selections.indexOf(itemId);
|
||||
var selected = index >= 0;
|
||||
|
||||
if (selected) {
|
||||
state.selections.splice(index, 1);
|
||||
} else {
|
||||
var maximum = Number(state.challenge && state.challenge.maxSelections ? state.challenge.maxSelections : 0);
|
||||
if (maximum > 0 && state.selections.length >= maximum) {
|
||||
var removed = state.selections.shift();
|
||||
var previousButton = state.root.querySelector('[data-captcha-item-id="' + CSS.escape(removed) + '"]');
|
||||
if (previousButton) {
|
||||
previousButton.setAttribute("aria-pressed", "false");
|
||||
show(previousButton.querySelector("[data-captcha-item-check]"), false);
|
||||
}
|
||||
}
|
||||
state.selections.push(itemId);
|
||||
}
|
||||
|
||||
var nowSelected = state.selections.indexOf(itemId) >= 0;
|
||||
button.setAttribute("aria-pressed", nowSelected ? "true" : "false");
|
||||
show(button.querySelector("[data-captcha-item-check]"), nowSelected);
|
||||
emit(state, "input", { selections: state.selections.slice() });
|
||||
|
||||
var minimum = Number(state.challenge && state.challenge.minSelections ? state.challenge.minSelections : 0);
|
||||
if (state.config.autoVerify && minimum > 0 && state.selections.length >= minimum) verify(state);
|
||||
}
|
||||
|
||||
function renderChallenge(state, challenge) {
|
||||
state.challenge = challenge;
|
||||
state.challengeLoadedAt = Date.now();
|
||||
state.notRobotPending = false;
|
||||
state.answer = "";
|
||||
state.selections = [];
|
||||
|
||||
var notRobot = challenge.type === "not-robot";
|
||||
state.root.dataset.captchaType = challenge.type || state.config.type;
|
||||
state.root.dataset.captchaResolvedImageStyle = challenge.metadata && challenge.metadata.imageStyle
|
||||
? String(challenge.metadata.imageStyle)
|
||||
: state.config.imageStyle;
|
||||
text(state.root, "[data-captcha-prompt]", challenge.prompt || "Complete the security challenge.");
|
||||
|
||||
var promptRow = state.root.querySelector("[data-captcha-prompt-row]");
|
||||
var notRobotPanel = state.root.querySelector("[data-captcha-not-robot]");
|
||||
show(promptRow, !notRobot);
|
||||
show(notRobotPanel, notRobot);
|
||||
|
||||
var imageWrap = state.root.querySelector("[data-captcha-image-wrap]");
|
||||
var image = state.root.querySelector("[data-captcha-image]");
|
||||
if (!notRobot && challenge.image && image) {
|
||||
image.src = challenge.image;
|
||||
show(imageWrap, true);
|
||||
} else {
|
||||
if (image) image.removeAttribute("src");
|
||||
show(imageWrap, false);
|
||||
}
|
||||
|
||||
if (notRobot) {
|
||||
var items = state.root.querySelector("[data-captcha-items]");
|
||||
if (items) items.replaceChildren();
|
||||
show(items, false);
|
||||
} else {
|
||||
renderItems(state, challenge);
|
||||
}
|
||||
|
||||
var answerWrap = state.root.querySelector("[data-captcha-answer-wrap]");
|
||||
var answer = state.root.querySelector("[data-captcha-answer]");
|
||||
var needsAnswer = !notRobot && !challenge.items && challenge.inputMode !== "none";
|
||||
show(answerWrap, needsAnswer);
|
||||
if (answer) {
|
||||
answer.value = "";
|
||||
answer.inputMode = challenge.inputMode === "numeric" ? "numeric" : "text";
|
||||
answer.setAttribute("aria-invalid", "false");
|
||||
}
|
||||
|
||||
var honeypot = state.root.querySelector("[data-captcha-honeypot]");
|
||||
if (honeypot) honeypot.name = challenge.honeypotField || "";
|
||||
|
||||
setStatus(state, "ready", "");
|
||||
startTimer(state);
|
||||
emit(state, "challenge", challenge);
|
||||
emit(state, "ready");
|
||||
|
||||
if (state.config.autoVerify && !notRobot && challenge.inputMode === "none" && !challenge.items) {
|
||||
window.setTimeout(function () {
|
||||
verify(state);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
async function createChallenge(state, requestedPresentation) {
|
||||
if (state.config.disabled) return;
|
||||
|
||||
if (state.config.provider === "turnstile" || state.config.provider === "recaptcha" || state.config.provider === "hcaptcha") {
|
||||
await mountExternal(state);
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimer(state);
|
||||
resetUi(state);
|
||||
setStatus(state, "loading", "");
|
||||
|
||||
try {
|
||||
var response = await fetch(state.config.endpoint, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
siteKey: state.config.siteKey,
|
||||
action: state.config.action,
|
||||
type: state.config.type,
|
||||
presentation: requestedPresentation || state.config.presentation,
|
||||
difficulty: state.config.difficulty,
|
||||
disturbance: state.config.disturbance,
|
||||
imageStyle: state.config.imageStyle,
|
||||
allowedStyles: state.config.allowedStyles,
|
||||
excludedStyles: state.config.excludedStyles,
|
||||
randomizeStyle: state.config.randomizeStyle,
|
||||
locale: state.config.locale,
|
||||
responseField: state.config.responseField,
|
||||
}),
|
||||
});
|
||||
|
||||
var result = await response.json().catch(function () {
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!response.ok || !result || !result.id) {
|
||||
throw new Error(result && result.message ? result.message : "Challenge request failed");
|
||||
}
|
||||
|
||||
renderChallenge(state, result);
|
||||
} catch (error) {
|
||||
setStatus(state, "network-error", state.config.networkMessage);
|
||||
emit(state, "error", { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
async function verify(state) {
|
||||
state.notRobotPending = false;
|
||||
if (state.config.disabled || state.status === "loading" || state.status === "verifying" || state.status === "expired" || state.status === "verified") {
|
||||
updateNotRobotState(state);
|
||||
updateControls(state);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.challenge) {
|
||||
await createChallenge(state);
|
||||
return;
|
||||
}
|
||||
|
||||
var answerInput = state.root.querySelector("[data-captcha-answer]");
|
||||
var honeypotInput = state.root.querySelector("[data-captcha-honeypot]");
|
||||
state.answer = answerInput ? answerInput.value : "";
|
||||
|
||||
setStatus(state, "verifying", "");
|
||||
emit(state, "verify");
|
||||
|
||||
try {
|
||||
var response = await fetch(state.challenge.verifyUrl || state.config.verifyEndpoint, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
challengeId: state.challenge.id,
|
||||
action: state.config.action,
|
||||
answer: state.answer,
|
||||
selections: state.selections,
|
||||
honeypot: honeypotInput ? honeypotInput.value : "",
|
||||
timingToken: state.challenge.timingToken || "",
|
||||
}),
|
||||
});
|
||||
|
||||
var result = await response.json().catch(function () {
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!response.ok || !result || !result.success) {
|
||||
setResponseToken(state, "");
|
||||
var expired = Boolean(result && result.code === "expired");
|
||||
setStatus(
|
||||
state,
|
||||
expired ? "expired" : "incorrect",
|
||||
expired
|
||||
? state.config.expiredMessage
|
||||
: result && result.message
|
||||
? result.message
|
||||
: state.config.incorrectMessage,
|
||||
);
|
||||
emit(state, "failure", result || null);
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimer(state);
|
||||
setResponseToken(state, result.responseToken || "");
|
||||
setStatus(state, "verified", "Verification completed.");
|
||||
emit(state, "success", result);
|
||||
} catch (error) {
|
||||
setStatus(state, "network-error", state.config.networkMessage);
|
||||
emit(state, "error", { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
function verifyNotRobot(state) {
|
||||
if (state.config.disabled || state.notRobotPending || state.status !== "ready") return;
|
||||
if (!state.challenge || state.challenge.type !== "not-robot") {
|
||||
createChallenge(state);
|
||||
return;
|
||||
}
|
||||
|
||||
var configuredMinimum = Number(
|
||||
state.challenge.metadata && state.challenge.metadata.minCompletionMs !== undefined
|
||||
? state.challenge.metadata.minCompletionMs
|
||||
: 800,
|
||||
);
|
||||
var minimum = Number.isFinite(configuredMinimum) ? Math.max(0, configuredMinimum) : 800;
|
||||
var elapsed = Math.max(0, Date.now() - state.challengeLoadedAt);
|
||||
var remaining = Math.max(0, minimum - elapsed);
|
||||
|
||||
state.notRobotPending = true;
|
||||
updateNotRobotState(state);
|
||||
updateControls(state);
|
||||
emit(state, "input", { checked: true });
|
||||
|
||||
var complete = function () {
|
||||
state.notRobotTimer = null;
|
||||
state.notRobotPending = false;
|
||||
verify(state);
|
||||
};
|
||||
|
||||
if (remaining > 0) state.notRobotTimer = window.setTimeout(complete, remaining);
|
||||
else complete();
|
||||
}
|
||||
|
||||
function providerDefinition(provider) {
|
||||
if (provider === "turnstile") {
|
||||
return {
|
||||
url: "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit",
|
||||
globalName: "turnstile",
|
||||
};
|
||||
}
|
||||
if (provider === "recaptcha") {
|
||||
return {
|
||||
url: "https://www.google.com/recaptcha/api.js?render=explicit",
|
||||
globalName: "grecaptcha",
|
||||
};
|
||||
}
|
||||
if (provider === "hcaptcha") {
|
||||
return {
|
||||
url: "https://js.hcaptcha.com/1/api.js?render=explicit",
|
||||
globalName: "hcaptcha",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadProviderScript(definition) {
|
||||
if (window[definition.globalName]) return Promise.resolve(window[definition.globalName]);
|
||||
if (scriptPromises.has(definition.url)) return scriptPromises.get(definition.url);
|
||||
|
||||
var promise = new Promise(function (resolve, reject) {
|
||||
var script = document.querySelector('script[src="' + definition.url + '"]');
|
||||
if (!script) {
|
||||
script = document.createElement("script");
|
||||
script.src = definition.url;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
script.addEventListener(
|
||||
"load",
|
||||
function () {
|
||||
if (window[definition.globalName]) resolve(window[definition.globalName]);
|
||||
else reject(new Error("CAPTCHA provider did not initialize"));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
script.addEventListener("error", function () { reject(new Error("CAPTCHA provider script failed")); }, { once: true });
|
||||
});
|
||||
|
||||
scriptPromises.set(definition.url, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function mountExternal(state) {
|
||||
var definition = providerDefinition(state.config.provider);
|
||||
if (!definition) return;
|
||||
|
||||
if (!state.config.siteKey) {
|
||||
setStatus(state, "provider-error", "A public site key is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
var mount = state.root.querySelector("[data-captcha-provider-mount]");
|
||||
if (!mount) return;
|
||||
|
||||
show(mount, true);
|
||||
setStatus(state, "loading", "");
|
||||
|
||||
try {
|
||||
var api = await loadProviderScript(definition);
|
||||
|
||||
if (state.externalWidgetId !== null && typeof api.reset === "function") {
|
||||
setResponseToken(state, "");
|
||||
api.reset(state.externalWidgetId);
|
||||
setStatus(state, "ready", "");
|
||||
emit(state, "ready");
|
||||
return;
|
||||
}
|
||||
|
||||
mount.replaceChildren();
|
||||
state.externalApi = api;
|
||||
state.externalWidgetId = api.render(mount, {
|
||||
sitekey: state.config.siteKey,
|
||||
theme: "auto",
|
||||
size: state.config.size === "compact" ? "compact" : state.config.provider === "turnstile" ? "flexible" : "normal",
|
||||
action: state.config.action,
|
||||
callback: function (token) {
|
||||
setResponseToken(state, token);
|
||||
setStatus(state, "verified", "Verification completed.");
|
||||
emit(state, "success");
|
||||
},
|
||||
"expired-callback": function () {
|
||||
setResponseToken(state, "");
|
||||
setStatus(state, "expired", state.config.expiredMessage);
|
||||
emit(state, "expired");
|
||||
},
|
||||
"error-callback": function (providerError) {
|
||||
setResponseToken(state, "");
|
||||
setStatus(state, "provider-error", state.config.networkMessage);
|
||||
emit(state, "error", { error: providerError || "provider-error" });
|
||||
},
|
||||
});
|
||||
|
||||
setStatus(state, "ready", "");
|
||||
emit(state, "ready");
|
||||
} catch (error) {
|
||||
setStatus(state, "provider-error", state.config.networkMessage);
|
||||
emit(state, "error", { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
function playAudio(state) {
|
||||
if (!state.challenge || !state.challenge.audioUrl || state.config.disabled) return;
|
||||
|
||||
if (state.audioPlayer) {
|
||||
state.audioPlayer.pause();
|
||||
state.audioPlayer.currentTime = 0;
|
||||
}
|
||||
|
||||
var audio = new Audio();
|
||||
state.audioPlayer = audio;
|
||||
audio.preload = "auto";
|
||||
audio.volume = 1;
|
||||
audio.src = new URL(state.challenge.audioUrl, window.location.href).href;
|
||||
|
||||
audio.addEventListener("ended", function () {
|
||||
if (state.audioPlayer === audio) state.audioPlayer = null;
|
||||
emit(state, "audioEnd");
|
||||
emit(state, "audio-end");
|
||||
}, { once: true });
|
||||
|
||||
audio.addEventListener("error", function () {
|
||||
if (state.audioPlayer === audio) state.audioPlayer = null;
|
||||
var mediaError = audio.error;
|
||||
setStatus(state, "network-error", "The audio challenge could not be played. Load a new challenge and try again.");
|
||||
emit(state, "error", {
|
||||
code: "audio-playback-failed",
|
||||
audioUrl: audio.src,
|
||||
mediaErrorCode: mediaError ? mediaError.code : null,
|
||||
});
|
||||
}, { once: true });
|
||||
|
||||
audio.load();
|
||||
audio.play().then(function () {
|
||||
emit(state, "audioStart");
|
||||
emit(state, "audio-start");
|
||||
}).catch(function (error) {
|
||||
if (state.audioPlayer === audio) state.audioPlayer = null;
|
||||
setStatus(state, "network-error", "The audio challenge could not be played. Check the audio endpoint and try again.");
|
||||
emit(state, "error", {
|
||||
code: "audio-playback-rejected",
|
||||
audioUrl: audio.src,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function onFormSubmit(state, event) {
|
||||
if (!state.config.required || state.responseToken) return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
setStatus(state, "incorrect", state.config.requiredMessage);
|
||||
emit(state, "failure", { code: "missing-input" });
|
||||
var target = state.root.querySelector("[data-captcha-answer]") || state.root.querySelector("[data-captcha-not-robot-button]") || state.root.querySelector("[data-captcha-verify]");
|
||||
if (target && typeof target.focus === "function") target.focus();
|
||||
}
|
||||
|
||||
function initialize(root) {
|
||||
if (!(root instanceof HTMLElement) || states.has(root)) return;
|
||||
|
||||
if (!root.id) {
|
||||
instanceCounter += 1;
|
||||
root.id = "wrn-captcha-" + instanceCounter;
|
||||
}
|
||||
|
||||
var state = {
|
||||
root: root,
|
||||
config: config(root),
|
||||
challenge: null,
|
||||
answer: "",
|
||||
selections: [],
|
||||
responseToken: "",
|
||||
status: "idle",
|
||||
timer: null,
|
||||
notRobotTimer: null,
|
||||
notRobotPending: false,
|
||||
challengeLoadedAt: 0,
|
||||
externalApi: null,
|
||||
externalWidgetId: null,
|
||||
audioPlayer: null,
|
||||
form: root.closest("form"),
|
||||
};
|
||||
states.set(root, state);
|
||||
root.dataset.captchaSize = state.config.size;
|
||||
|
||||
var response = root.querySelector("[data-captcha-response]");
|
||||
if (response) response.name = state.config.responseField;
|
||||
|
||||
var answer = root.querySelector("[data-captcha-answer]");
|
||||
var verifyButton = root.querySelector("[data-captcha-verify]");
|
||||
var refreshButton = root.querySelector("[data-captcha-refresh]");
|
||||
var audioButton = root.querySelector("[data-captcha-audio]");
|
||||
var audioAlternative = root.querySelector("[data-captcha-audio-alternative]");
|
||||
var notRobotButton = root.querySelector("[data-captcha-not-robot-button]");
|
||||
|
||||
if (answer) {
|
||||
answer.addEventListener("input", function () {
|
||||
state.answer = answer.value;
|
||||
emit(state, "input", { answerLength: state.answer.length });
|
||||
});
|
||||
answer.addEventListener("keydown", function (event) {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
verify(state);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (verifyButton) verifyButton.addEventListener("click", function () { verify(state); });
|
||||
if (notRobotButton) notRobotButton.addEventListener("click", function () { verifyNotRobot(state); });
|
||||
if (refreshButton) refreshButton.addEventListener("click", function () {
|
||||
emit(state, "refresh");
|
||||
createChallenge(state, state.config.presentation);
|
||||
});
|
||||
if (audioButton) audioButton.addEventListener("click", function () { playAudio(state); });
|
||||
if (audioAlternative) audioAlternative.addEventListener("click", function () {
|
||||
emit(state, "refresh", { presentation: "audio" });
|
||||
createChallenge(state, "audio");
|
||||
});
|
||||
|
||||
root.addEventListener("captcha-reset", function () {
|
||||
emit(state, "refresh");
|
||||
createChallenge(state, state.config.presentation);
|
||||
});
|
||||
|
||||
if (state.form) {
|
||||
state.form.addEventListener("submit", function (event) {
|
||||
onFormSubmit(state, event);
|
||||
}, true);
|
||||
state.form.addEventListener("wire:success", function () {
|
||||
createChallenge(state, state.config.presentation);
|
||||
});
|
||||
}
|
||||
|
||||
updateNotRobotState(state);
|
||||
updateControls(state);
|
||||
if (state.config.disabled) setStatus(state, "disabled", "");
|
||||
else if (state.config.autoLoad) createChallenge(state, state.config.presentation);
|
||||
}
|
||||
|
||||
function scan(scope) {
|
||||
var host = scope || document;
|
||||
if (host instanceof Element && host.matches("[data-wrn-captcha]")) initialize(host);
|
||||
host.querySelectorAll("[data-wrn-captcha]").forEach(initialize);
|
||||
}
|
||||
|
||||
var runtime = {
|
||||
scan: scan,
|
||||
reset: function (element) {
|
||||
var root = typeof element === "string" ? document.querySelector(element) : element;
|
||||
var state = root ? states.get(root) : null;
|
||||
if (state) createChallenge(state, state.config.presentation);
|
||||
},
|
||||
verify: function (element) {
|
||||
var root = typeof element === "string" ? document.querySelector(element) : element;
|
||||
var state = root ? states.get(root) : null;
|
||||
return state ? verify(state) : Promise.resolve();
|
||||
},
|
||||
};
|
||||
|
||||
window[RUNTIME_KEY] = runtime;
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", function () { scan(document); }, { once: true });
|
||||
} else {
|
||||
scan(document);
|
||||
}
|
||||
|
||||
new MutationObserver(function (records) {
|
||||
records.forEach(function (record) {
|
||||
record.addedNodes.forEach(function (node) {
|
||||
if (node.nodeType === 1) scan(node);
|
||||
});
|
||||
});
|
||||
}).observe(document.documentElement, { childList: true, subtree: true });
|
||||
})();
|
||||
@@ -0,0 +1,320 @@
|
||||
component Captcha {
|
||||
props {
|
||||
provider = "self-hosted"
|
||||
siteKey = ""
|
||||
type = "alphanumeric"
|
||||
action = "form-submit"
|
||||
presentation = "visual"
|
||||
difficulty = "normal"
|
||||
disturbance = 50
|
||||
imageStyle = "random"
|
||||
allowedStyles = ""
|
||||
excludedStyles = ""
|
||||
randomizeStyle = false
|
||||
locale = "en"
|
||||
size = "normal"
|
||||
color = "primary"
|
||||
class = ""
|
||||
|
||||
id = ""
|
||||
name = "wrn-captcha-response"
|
||||
endpoint = "/__wrnexus/captcha/challenge"
|
||||
verifyEndpoint = "/__wrnexus/captcha/verify"
|
||||
responseField = "wrn-captcha-response"
|
||||
|
||||
label = "Security verification"
|
||||
description = "Complete the challenge to continue."
|
||||
helpText = ""
|
||||
requiredMessage = "Please complete the security check."
|
||||
incorrectMessage = "That answer was not correct. Try again."
|
||||
expiredMessage = "This challenge expired. Load a new one."
|
||||
networkMessage = "The verification service is unavailable. Try again."
|
||||
|
||||
autoLoad = true
|
||||
autoVerify = false
|
||||
showVerify = true
|
||||
showRefresh = true
|
||||
showAudio = true
|
||||
showListen = true
|
||||
showStatus = true
|
||||
disabled = false
|
||||
required = true
|
||||
compact = false
|
||||
|
||||
@event ready = function
|
||||
@event challenge = function
|
||||
@event input = function
|
||||
@event verify = function
|
||||
@event success = function
|
||||
@event failure = function
|
||||
@event expired = function
|
||||
@event refresh = function
|
||||
@event audioStart = function
|
||||
@event audioEnd = function
|
||||
@event error = function
|
||||
}
|
||||
|
||||
view {
|
||||
<section
|
||||
{...attrs}
|
||||
id='{id}'
|
||||
data-wrn-captcha
|
||||
data-captcha-provider='{provider}'
|
||||
data-captcha-site-key='{siteKey}'
|
||||
data-captcha-type='{type}'
|
||||
data-captcha-action='{action}'
|
||||
data-captcha-presentation='{presentation}'
|
||||
data-captcha-difficulty='{difficulty}'
|
||||
data-captcha-disturbance='{disturbance}'
|
||||
data-captcha-image-style='{imageStyle}'
|
||||
data-captcha-allowed-styles='{allowedStyles}'
|
||||
data-captcha-excluded-styles='{excludedStyles}'
|
||||
data-captcha-randomize-style='{randomizeStyle}'
|
||||
data-captcha-locale='{locale}'
|
||||
data-captcha-size='{size}'
|
||||
data-captcha-color='{color}'
|
||||
data-captcha-endpoint='{endpoint}'
|
||||
data-captcha-verify-endpoint='{verifyEndpoint}'
|
||||
data-captcha-response-field='{responseField}'
|
||||
data-captcha-name='{name}'
|
||||
data-captcha-auto-load='{autoLoad}'
|
||||
data-captcha-auto-verify='{autoVerify}'
|
||||
data-captcha-show-verify='{showVerify}'
|
||||
data-captcha-show-refresh='{showRefresh}'
|
||||
data-captcha-show-audio='{showAudio}'
|
||||
data-captcha-show-listen='{showListen}'
|
||||
data-captcha-show-status='{showStatus}'
|
||||
data-captcha-disabled='{disabled}'
|
||||
data-captcha-required='{required}'
|
||||
data-captcha-compact='{compact}'
|
||||
data-captcha-required-message='{requiredMessage}'
|
||||
data-captcha-incorrect-message='{incorrectMessage}'
|
||||
data-captcha-expired-message='{expiredMessage}'
|
||||
data-captcha-network-message='{networkMessage}'
|
||||
data-captcha-status="idle"
|
||||
data-server-verification-required="true"
|
||||
aria-busy="false"
|
||||
class='group/captcha relative flex w-full max-w-lg flex-col gap-3 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] p-4 text-[var(--wire-color-text)] shadow-[var(--wire-shadow-1)] [--captcha-accent:var(--wire-color-primary)] data-[captcha-color=secondary]:[--captcha-accent:var(--wire-color-secondary)] data-[captcha-color=success]:[--captcha-accent:var(--wire-color-success)] data-[captcha-color=warning]:[--captcha-accent:var(--wire-color-warning)] data-[captcha-color=danger]:[--captcha-accent:var(--wire-color-danger)] data-[captcha-color=info]:[--captcha-accent:var(--wire-color-info)] data-[captcha-size=compact]:max-w-xs data-[captcha-size=compact]:gap-1.5 data-[captcha-size=compact]:p-2 data-[captcha-size=big]:max-w-2xl data-[captcha-size=big]:gap-4 data-[captcha-size=big]:p-5 data-[captcha-compact=true]:max-w-xs data-[captcha-compact=true]:gap-1.5 data-[captcha-compact=true]:p-2 data-[captcha-type=not-robot]:max-w-sm data-[captcha-type=not-robot]:gap-0 data-[captcha-type=not-robot]:p-0 data-[captcha-disabled=true]:pointer-events-none data-[captcha-disabled=true]:opacity-60 data-[captcha-status=verified]:border-[var(--wire-color-success)] data-[captcha-status=incorrect]:border-[var(--wire-color-danger)] data-[captcha-status=expired]:border-[var(--wire-color-danger)] data-[captcha-status=network-error]:border-[var(--wire-color-danger)] data-[captcha-status=provider-error]:border-[var(--wire-color-danger)] {class}'
|
||||
>
|
||||
<script
|
||||
src="/assets/wrnexus/captcha.js"
|
||||
defer
|
||||
data-wrn-captcha-runtime
|
||||
></script>
|
||||
|
||||
<header data-captcha-header class='flex items-start justify-between gap-3 group-data-[captcha-size=compact]/captcha:gap-1.5 group-data-[captcha-type=not-robot]/captcha:hidden'>
|
||||
<div class='min-w-0'>
|
||||
<div class='flex items-center gap-2 group-data-[captcha-size=compact]/captcha:gap-1.5'>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class='icon-[lucide--shield-check] size-5 shrink-0 text-[var(--captcha-accent)] group-data-[captcha-size=compact]/captcha:size-4'
|
||||
></span>
|
||||
<h3 class='m-0 text-sm font-semibold leading-5 text-[var(--wire-color-text)] group-data-[captcha-size=compact]/captcha:text-[11px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-size=big]/captcha:text-base'>
|
||||
{label}
|
||||
</h3>
|
||||
</div>
|
||||
<p class='m-0 mt-1 text-xs leading-5 text-[var(--wire-color-muted)] group-data-[captcha-size=compact]/captcha:hidden group-data-[captcha-size=big]/captcha:text-sm'>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span
|
||||
data-captcha-verified-badge
|
||||
hidden
|
||||
class='shrink-0 items-center gap-1 rounded-full bg-[color-mix(in_srgb,var(--wire-color-success)_12%,transparent)] px-2 py-1 text-xs font-semibold text-[var(--wire-color-success)] group-data-[captcha-size=compact]/captcha:gap-0.5 group-data-[captcha-size=compact]/captcha:px-1.5 group-data-[captcha-size=compact]/captcha:py-0.5 group-data-[captcha-size=compact]/captcha:text-[10px]'
|
||||
>
|
||||
<span aria-hidden="true" class='icon-[lucide--circle-check] size-3.5 group-data-[captcha-size=compact]/captcha:size-3'></span>
|
||||
Verified
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div
|
||||
data-captcha-provider-mount
|
||||
hidden
|
||||
class='flex min-h-16 w-full items-center justify-center overflow-hidden rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-surface-2)] p-2 group-data-[captcha-size=compact]/captcha:min-h-12 group-data-[captcha-size=compact]/captcha:p-1.5'
|
||||
></div>
|
||||
|
||||
<div
|
||||
data-captcha-loading
|
||||
hidden
|
||||
class='flex min-h-28 items-center justify-center gap-2 rounded-[var(--wire-radius-sm)] bg-[var(--wire-color-surface-2)] text-sm text-[var(--wire-color-muted)] group-data-[captcha-size=compact]/captcha:min-h-14 group-data-[captcha-size=compact]/captcha:gap-1.5 group-data-[captcha-size=compact]/captcha:text-[11px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-size=big]/captcha:min-h-36 group-data-[captcha-type=not-robot]/captcha:m-3 group-data-[captcha-type=not-robot]/captcha:min-h-16'
|
||||
>
|
||||
<span aria-hidden="true" class='icon-[lucide--loader-circle] size-5 animate-spin group-data-[captcha-size=compact]/captcha:size-4'></span>
|
||||
Loading challenge…
|
||||
</div>
|
||||
|
||||
<div data-captcha-challenge hidden class='flex flex-col gap-3 group-data-[captcha-size=compact]/captcha:gap-1.5 group-data-[captcha-size=big]/captcha:gap-4 group-data-[captcha-type=not-robot]/captcha:p-3'>
|
||||
<div data-captcha-prompt-row class='flex items-start justify-between gap-3 group-data-[captcha-size=compact]/captcha:gap-2'>
|
||||
<p data-captcha-prompt class='m-0 text-sm font-medium text-[var(--wire-color-text)] group-data-[captcha-size=compact]/captcha:text-[11px] group-data-[captcha-size=compact]/captcha:leading-4'></p>
|
||||
<span data-captcha-countdown hidden class='shrink-0 text-xs tabular-nums text-[var(--wire-color-muted)] group-data-[captcha-size=compact]/captcha:text-[10px]'></span>
|
||||
</div>
|
||||
|
||||
<div data-captcha-not-robot hidden class='w-full'>
|
||||
<button
|
||||
data-captcha-not-robot-button
|
||||
type="button"
|
||||
aria-pressed="false"
|
||||
class='flex min-h-20 w-full items-center gap-3 rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface-2)] px-4 py-3 text-left transition-[border-color,box-shadow,background-color] hover:border-[var(--captcha-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] disabled:cursor-wait disabled:opacity-80 group-data-[captcha-size=compact]/captcha:min-h-16 group-data-[captcha-size=compact]/captcha:gap-2.5 group-data-[captcha-size=compact]/captcha:px-3 group-data-[captcha-size=compact]/captcha:py-2.5 group-data-[captcha-size=big]/captcha:min-h-24 group-data-[captcha-size=big]/captcha:px-5 group-data-[captcha-size=big]/captcha:py-4'
|
||||
>
|
||||
<span
|
||||
data-captcha-not-robot-control
|
||||
aria-hidden="true"
|
||||
class='inline-flex size-7 shrink-0 items-center justify-center rounded-md border-2 border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] text-white transition-colors data-[verified=true]:border-[var(--wire-color-success)] data-[verified=true]:bg-[var(--wire-color-success)] group-data-[captcha-size=compact]/captcha:size-6 group-data-[captcha-size=big]/captcha:size-8'
|
||||
>
|
||||
<span data-captcha-not-robot-empty class='size-full'></span>
|
||||
<span data-captcha-not-robot-spinner hidden class='icon-[lucide--loader-circle] size-4 animate-spin text-[var(--captcha-accent)]'></span>
|
||||
<span data-captcha-not-robot-check hidden class='icon-[lucide--check] size-5'></span>
|
||||
</span>
|
||||
|
||||
<span data-captcha-not-robot-label class='min-w-0 flex-1 text-sm font-semibold text-[var(--wire-color-text)] group-data-[captcha-size=compact]/captcha:text-[11px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-size=big]/captcha:text-base'>
|
||||
I'm not a robot
|
||||
</span>
|
||||
|
||||
<span class='flex shrink-0 flex-col items-center gap-0.5 text-[10px] leading-none text-[var(--wire-color-muted)]'>
|
||||
<span aria-hidden="true" class='icon-[lucide--shield-check] size-6 text-[var(--captcha-accent)] group-data-[captcha-size=compact]/captcha:size-5 group-data-[captcha-size=big]/captcha:size-7'></span>
|
||||
<span>WRNexus</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-captcha-image-wrap
|
||||
hidden
|
||||
class='overflow-hidden rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-white'
|
||||
>
|
||||
<img
|
||||
data-captcha-image
|
||||
src=""
|
||||
alt="CAPTCHA challenge"
|
||||
draggable="false"
|
||||
class='block h-auto min-h-20 w-full select-none object-contain group-data-[captcha-size=compact]/captcha:max-h-24 group-data-[captcha-size=compact]/captcha:min-h-12 group-data-[captcha-size=big]/captcha:min-h-28'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-captcha-items
|
||||
hidden
|
||||
role="group"
|
||||
class='grid grid-cols-2 gap-2 group-data-[captcha-size=compact]/captcha:gap-1 sm:grid-cols-3 group-data-[captcha-size=big]/captcha:gap-3'
|
||||
></div>
|
||||
|
||||
<template data-captcha-item-template>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed="false"
|
||||
class='group relative overflow-hidden rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-white p-1 group-data-[captcha-size=compact]/captcha:p-0.5 transition-[border-color,box-shadow,transform] duration-[var(--wire-motion-fast)] hover:border-[var(--captcha-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] aria-[pressed=true]:border-[var(--captcha-accent)] aria-[pressed=true]:ring-2 aria-[pressed=true]:ring-[var(--captcha-accent)]'
|
||||
>
|
||||
<img
|
||||
data-captcha-item-image
|
||||
src=""
|
||||
alt=""
|
||||
draggable="false"
|
||||
class='aspect-[4/3] w-full select-none object-cover'
|
||||
/>
|
||||
<span
|
||||
data-captcha-item-check
|
||||
hidden
|
||||
aria-hidden="true"
|
||||
class='absolute right-1.5 top-1.5 inline-flex size-5 group-data-[captcha-size=compact]/captcha:right-1 group-data-[captcha-size=compact]/captcha:top-1 group-data-[captcha-size=compact]/captcha:size-4 items-center justify-center rounded-full bg-[var(--captcha-accent)] text-white shadow-sm'
|
||||
>
|
||||
<span class='icon-[lucide--check] size-3.5'></span>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<label data-captcha-answer-wrap hidden class='flex flex-col gap-1.5 group-data-[captcha-size=compact]/captcha:gap-1'>
|
||||
<span class='text-xs font-semibold text-[var(--wire-color-text)] group-data-[captcha-size=compact]/captcha:text-[10px]'>Your answer</span>
|
||||
<input
|
||||
data-captcha-answer
|
||||
type="text"
|
||||
inputmode="text"
|
||||
autocomplete="off"
|
||||
autocapitalize="characters"
|
||||
spellcheck="false"
|
||||
class='h-11 w-full rounded-[var(--wire-radius-sm)] border border-[var(--wire-color-border)] bg-[var(--wire-color-surface)] px-3 text-base font-semibold tracking-[0.18em] text-[var(--wire-color-text)] outline-none transition-[border-color,box-shadow] placeholder:tracking-normal focus:border-[var(--captcha-accent)] focus:ring-2 focus:ring-[var(--captcha-accent)] disabled:cursor-not-allowed disabled:bg-[var(--wire-color-surface-2)] aria-[invalid=true]:border-[var(--wire-color-danger)] aria-[invalid=true]:ring-2 aria-[invalid=true]:ring-[var(--wire-color-danger)] group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:px-2 group-data-[captcha-size=compact]/captcha:text-xs group-data-[captcha-size=compact]/captcha:tracking-[0.12em] group-data-[captcha-size=big]/captcha:h-12 group-data-[captcha-size=big]/captcha:px-4 group-data-[captcha-size=big]/captcha:text-lg'
|
||||
placeholder="Enter the answer"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<input
|
||||
data-captcha-honeypot
|
||||
type="text"
|
||||
name=""
|
||||
value=""
|
||||
tabindex="-1"
|
||||
autocomplete="off"
|
||||
aria-hidden="true"
|
||||
class='pointer-events-none absolute -left-[10000px] top-auto h-px w-px overflow-hidden opacity-0'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div data-captcha-success hidden role="status" aria-live="polite" class='flex items-start gap-1.5 text-xs leading-5 text-[var(--wire-color-success)] group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:text-[10px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-type=not-robot]/captcha:hidden'>
|
||||
<span aria-hidden="true" class='icon-[lucide--circle-check] mt-0.5 size-3.5 shrink-0'></span>
|
||||
<span data-captcha-success-message>Verification completed.</span>
|
||||
</div>
|
||||
|
||||
<div data-captcha-error hidden role="alert" aria-live="polite" class='flex items-start gap-1.5 text-xs leading-5 text-[var(--wire-color-danger)] group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:text-[10px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-type=not-robot]/captcha:mx-3 group-data-[captcha-type=not-robot]/captcha:mb-3'>
|
||||
<span aria-hidden="true" class='icon-[lucide--circle-alert] mt-0.5 size-3.5 shrink-0'></span>
|
||||
<span data-captcha-error-message></span>
|
||||
</div>
|
||||
|
||||
<p data-captcha-help class='m-0 text-xs leading-5 text-[var(--wire-color-muted)] group-data-[captcha-size=compact]/captcha:text-[10px] group-data-[captcha-size=compact]/captcha:leading-4 group-data-[captcha-type=not-robot]/captcha:hidden'>
|
||||
{helpText}
|
||||
</p>
|
||||
|
||||
<footer data-captcha-footer class='flex flex-wrap items-center justify-between gap-2 group-data-[captcha-size=compact]/captcha:flex-nowrap group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-type=not-robot]/captcha:hidden'>
|
||||
<div class='flex flex-wrap items-center gap-1.5 group-data-[captcha-size=compact]/captcha:flex-nowrap group-data-[captcha-size=compact]/captcha:gap-0.5'>
|
||||
<button
|
||||
data-captcha-audio
|
||||
hidden
|
||||
type="button"
|
||||
class='inline-flex h-9 items-center justify-center gap-1.5 rounded-[var(--wire-radius-sm)] px-2.5 text-xs group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:px-1.5 group-data-[captcha-size=compact]/captcha:text-[10px] font-semibold text-[var(--wire-color-muted)] transition-colors hover:bg-[var(--wire-color-surface-2)] hover:text-[var(--captcha-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
>
|
||||
<span aria-hidden="true" class='icon-[lucide--volume-2] size-4 group-data-[captcha-size=compact]/captcha:size-3.5'></span>
|
||||
Listen
|
||||
</button>
|
||||
|
||||
<button
|
||||
data-captcha-audio-alternative
|
||||
hidden
|
||||
type="button"
|
||||
class='inline-flex h-9 items-center justify-center gap-1.5 rounded-[var(--wire-radius-sm)] px-2.5 text-xs group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:px-1.5 group-data-[captcha-size=compact]/captcha:text-[10px] font-semibold text-[var(--wire-color-muted)] transition-colors hover:bg-[var(--wire-color-surface-2)] hover:text-[var(--captcha-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
>
|
||||
<span aria-hidden="true" class='icon-[lucide--ear] size-4 group-data-[captcha-size=compact]/captcha:size-3.5'></span>
|
||||
Use audio
|
||||
</button>
|
||||
|
||||
<button
|
||||
data-captcha-refresh
|
||||
type="button"
|
||||
class='inline-flex h-9 items-center justify-center gap-1.5 rounded-[var(--wire-radius-sm)] px-2.5 text-xs group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:px-1.5 group-data-[captcha-size=compact]/captcha:text-[10px] font-semibold text-[var(--wire-color-muted)] transition-colors hover:bg-[var(--wire-color-surface-2)] hover:text-[var(--captcha-accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
>
|
||||
<span aria-hidden="true" class='icon-[lucide--refresh-cw] size-4 group-data-[captcha-size=compact]/captcha:size-3.5'></span>
|
||||
New challenge
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
data-captcha-verify
|
||||
type="button"
|
||||
class='inline-flex h-9 items-center justify-center gap-1.5 rounded-[var(--wire-radius-sm)] bg-[var(--captcha-accent)] px-3 text-xs group-data-[captcha-size=compact]/captcha:h-8 group-data-[captcha-size=compact]/captcha:gap-1 group-data-[captcha-size=compact]/captcha:px-2.5 group-data-[captcha-size=compact]/captcha:text-[10px] font-semibold text-white shadow-sm transition-[filter,transform] hover:brightness-95 active:translate-y-px focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--captcha-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--wire-color-surface)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
>
|
||||
<span data-captcha-verify-icon aria-hidden="true" class='icon-[lucide--shield-check] size-4 group-data-[captcha-size=compact]/captcha:size-3.5'></span>
|
||||
<span data-captcha-verify-spinner hidden aria-hidden="true" class='icon-[lucide--loader-circle] size-4 animate-spin group-data-[captcha-size=compact]/captcha:size-3.5'></span>
|
||||
<span data-captcha-verify-label>Verify</span>
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
<input
|
||||
data-captcha-response
|
||||
type="hidden"
|
||||
name='{responseField}'
|
||||
value=""
|
||||
/>
|
||||
|
||||
<p data-captcha-credit class='m-0 text-[11px] leading-4 text-[var(--wire-color-muted)] group-data-[captcha-size=compact]/captcha:hidden group-data-[captcha-type=not-robot]/captcha:hidden'>
|
||||
Protected by WRNexus CAPTCHA. Server-side verification is required.
|
||||
</p>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@wrnexus/captcha",
|
||||
"version": "0.3.6",
|
||||
"description": "First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI.",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"files": [
|
||||
"src",
|
||||
"components",
|
||||
"assets",
|
||||
"README.md",
|
||||
"SECURITY.md",
|
||||
"CHANGELOG.md"
|
||||
],
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./server": "./src/server/index.ts",
|
||||
"./client": "./src/client/index.ts",
|
||||
"./plugin": "./src/plugin.ts",
|
||||
"./types": "./src/types.ts",
|
||||
"./stores/memory": "./src/stores/memory.ts",
|
||||
"./stores/sqlite": "./src/stores/sqlite.ts",
|
||||
"./stores/redis": "./src/stores/redis.ts",
|
||||
"./providers/self-hosted": "./src/providers/self-hosted.ts",
|
||||
"./providers/managed": "./src/providers/managed.ts",
|
||||
"./providers/turnstile": "./src/providers/turnstile.ts",
|
||||
"./providers/recaptcha": "./src/providers/recaptcha.ts",
|
||||
"./providers/hcaptcha": "./src/providers/hcaptcha.ts",
|
||||
"./components/*": "./components/*",
|
||||
"./providers/custom": "./src/providers/custom.ts",
|
||||
"./providers": "./src/providers/index.ts",
|
||||
"./stores": "./src/stores/memory.ts",
|
||||
"./challenges": "./src/challenges/index.ts",
|
||||
"./audio": "./src/audio/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check": "bun run typecheck && bun run test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/core": "workspace:*",
|
||||
"@wrnexus/plugin": "workspace:*",
|
||||
"@wrnexus/validation": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5.9.2",
|
||||
"@wrnexus/syntax": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
createAssetAudioRenderer,
|
||||
resolveCaptchaAudioAssetsDir,
|
||||
} from "../src/audio/renderer.ts";
|
||||
|
||||
const packageRoot = join(import.meta.dir, "..");
|
||||
const expectedAssets = join(packageRoot, "assets", "audio");
|
||||
|
||||
describe("CAPTCHA audio renderer", () => {
|
||||
test("resolves the packaged audio directory", () => {
|
||||
expect(resolveCaptchaAudioAssetsDir(expectedAssets)).toBe(expectedAssets);
|
||||
});
|
||||
|
||||
test("renders a playable PCM WAV challenge", async () => {
|
||||
const renderer = createAssetAudioRenderer({ assetsDir: expectedAssets, gapMs: 100 });
|
||||
const bytes = await renderer.render(["one", "two", "three"], "en-IN");
|
||||
|
||||
expect(bytes.byteLength).toBeGreaterThan(44);
|
||||
expect(new TextDecoder().decode(bytes.slice(0, 4))).toBe("RIFF");
|
||||
expect(new TextDecoder().decode(bytes.slice(8, 12))).toBe("WAVE");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
const packageRoot = join(import.meta.dir, "..");
|
||||
|
||||
test("Captcha browser runtime is valid JavaScript and exposes the expected lifecycle", async () => {
|
||||
const source = await readFile(join(packageRoot, "assets/client/captcha.js"), "utf8");
|
||||
|
||||
expect(() => new Function(source)).not.toThrow();
|
||||
expect(source).toContain("__wrnexusCaptchaRuntime");
|
||||
expect(source).toContain("createChallenge");
|
||||
expect(source).toContain("verify(state)");
|
||||
expect(source).toContain("mountExternal");
|
||||
expect(source).toContain("captchaDisturbance");
|
||||
expect(source).toContain("captchaImageStyle");
|
||||
expect(source).toContain("captchaAllowedStyles");
|
||||
expect(source).toContain("captchaExcludedStyles");
|
||||
expect(source).toContain("captchaRandomizeStyle");
|
||||
expect(source).toContain("captchaResolvedImageStyle");
|
||||
expect(source).toContain("captchaShowListen");
|
||||
expect(source).toContain("normalizeSize");
|
||||
expect(source).toContain("verifyNotRobot");
|
||||
expect(source).toContain("stopImmediatePropagation");
|
||||
expect(source).toContain('addEventListener("submit"');
|
||||
expect(source).toContain("MutationObserver");
|
||||
expect(source).toContain('new CustomEvent(name');
|
||||
});
|
||||
|
||||
test("Captcha component delegates native browser work to the packaged runtime", async () => {
|
||||
const source = await readFile(join(packageRoot, "components/Captcha.wrn"), "utf8");
|
||||
|
||||
expect(source).toContain('src="/assets/wrnexus/captcha.js"');
|
||||
expect(source).not.toContain('src="/__wrnexus/captcha.js"');
|
||||
expect(source).toContain("data-wrn-captcha");
|
||||
expect(source).toContain("data-captcha-response");
|
||||
expect(source).toContain("data-captcha-disturbance");
|
||||
expect(source).toContain("data-captcha-image-style");
|
||||
expect(source).toContain("data-captcha-allowed-styles");
|
||||
expect(source).toContain("data-captcha-excluded-styles");
|
||||
expect(source).toContain("data-captcha-randomize-style");
|
||||
expect(source).toContain("data-captcha-show-listen");
|
||||
expect(source).toContain("data-captcha-not-robot-button");
|
||||
expect(source).not.toContain("lifecycle {");
|
||||
expect(source).not.toContain("async function");
|
||||
expect(source).not.toContain("await ");
|
||||
expect(source).not.toContain("try {");
|
||||
expect(source).not.toContain("setTimeout(function");
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { parse } from "@wrnexus/syntax";
|
||||
|
||||
const componentPath = join(import.meta.dir, "../components/Captcha.wrn");
|
||||
|
||||
test("Captcha.wrn parses and exposes the full public contract", async () => {
|
||||
const source = await readFile(componentPath, "utf8");
|
||||
const ast = parse(source);
|
||||
expect(ast.kind).toBe("component");
|
||||
expect(ast.name).toBe("Captcha");
|
||||
for (const prop of ["provider", "siteKey", "type", "action", "presentation", "difficulty", "disturbance", "imageStyle", "allowedStyles", "excludedStyles", "randomizeStyle", "size", "color", "class", "showListen"]) {
|
||||
expect(source).toContain(`${prop} =`);
|
||||
}
|
||||
for (const event of ["ready", "challenge", "input", "verify", "success", "failure", "expired", "refresh", "audioStart", "audioEnd", "error"]) {
|
||||
expect(source).toContain(`@event ${event} = function`);
|
||||
}
|
||||
expect(source).toContain("data-captcha-disturbance='{disturbance}'");
|
||||
expect(source).toContain("data-captcha-image-style='{imageStyle}'");
|
||||
expect(source).toContain("data-captcha-allowed-styles='{allowedStyles}'");
|
||||
expect(source).toContain("data-captcha-excluded-styles='{excludedStyles}'");
|
||||
expect(source).toContain("data-captcha-randomize-style='{randomizeStyle}'");
|
||||
expect(source).toContain("data-captcha-show-listen='{showListen}'");
|
||||
expect(source).toContain("data-captcha-not-robot-button");
|
||||
expect(source).toContain("data-[captcha-size=compact]");
|
||||
expect(source).toContain("data-[captcha-size=compact]:max-w-xs");
|
||||
expect(source).toContain("group-data-[captcha-size=compact]/captcha:max-h-24");
|
||||
expect(source).toContain("group-data-[captcha-size=compact]/captcha:h-8");
|
||||
expect(source).toContain("group-data-[captcha-size=compact]/captcha:hidden");
|
||||
expect(source).toContain("data-[captcha-size=big]");
|
||||
expect(source).toContain("{...attrs}");
|
||||
expect(source).toContain("--wire-");
|
||||
expect(source).not.toMatch(/=\{/);
|
||||
expect(source).toContain('src="/assets/wrnexus/captcha.js"');
|
||||
expect(source).not.toContain('src="/__wrnexus/captcha.js"');
|
||||
expect(source).not.toContain("lifecycle {");
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createCaptchaEngine } from "../src/engine.ts";
|
||||
import { MemoryCaptchaStore } from "../src/stores/memory.ts";
|
||||
import type { CaptchaChallengeGenerator } from "../src/types.ts";
|
||||
|
||||
function fixture() {
|
||||
let now = 1_700_000_000_000;
|
||||
let seed = 7;
|
||||
const generator: CaptchaChallengeGenerator = {
|
||||
type: "number",
|
||||
generate: () => ({
|
||||
type: "number",
|
||||
presentation: "visual",
|
||||
prompt: "Enter 42",
|
||||
answer: "42",
|
||||
answerKind: "text",
|
||||
inputMode: "numeric",
|
||||
audioSequence: ["four", "two"],
|
||||
}),
|
||||
};
|
||||
const engine = createCaptchaEngine({
|
||||
secret: "captcha-test-secret-with-at-least-thirty-two-characters",
|
||||
store: new MemoryCaptchaStore(),
|
||||
generators: [generator],
|
||||
defaultType: "number",
|
||||
minCompletionMs: 0,
|
||||
now: () => now,
|
||||
randomBytes(length) {
|
||||
const bytes = new Uint8Array(length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
seed = (seed * 1664525 + 1013904223) >>> 0;
|
||||
bytes[index] = seed & 255;
|
||||
}
|
||||
return bytes;
|
||||
},
|
||||
audioRenderer: { contentType: "audio/wav", async render() { return new Uint8Array([82, 73, 70, 70]); } },
|
||||
});
|
||||
return { engine, advance: (milliseconds: number) => { now += milliseconds; } };
|
||||
}
|
||||
|
||||
describe("self-hosted CAPTCHA engine", () => {
|
||||
test("creates, solves, and consumes a challenge and response token", async () => {
|
||||
const { engine } = fixture();
|
||||
const challenge = await engine.create({ action: "signup", hostname: "example.test", sessionId: "s1" });
|
||||
const solved = await engine.verify({ challengeId: challenge.id, action: "signup", answer: "42", hostname: "example.test", sessionId: "s1" });
|
||||
expect(solved.success).toBe(true);
|
||||
expect(solved.responseToken).toBeString();
|
||||
|
||||
const accepted = await engine.verifyResponseToken({ responseToken: solved.responseToken, action: "signup", hostname: "example.test", sessionId: "s1" });
|
||||
expect(accepted.success).toBe(true);
|
||||
|
||||
const replay = await engine.verifyResponseToken({ responseToken: solved.responseToken, action: "signup", hostname: "example.test", sessionId: "s1" });
|
||||
expect(replay).toMatchObject({ success: false, code: "already-used" });
|
||||
});
|
||||
|
||||
test("rejects wrong answers and enforces attempt limits", async () => {
|
||||
const { engine } = fixture();
|
||||
const challenge = await engine.create({ action: "login", maxAttempts: 2 });
|
||||
expect(await engine.verify({ challengeId: challenge.id, action: "login", answer: "1" })).toMatchObject({ success: false, code: "incorrect-answer" });
|
||||
expect(await engine.verify({ challengeId: challenge.id, action: "login", answer: "2" })).toMatchObject({ success: false, code: "attempts-exhausted" });
|
||||
});
|
||||
|
||||
test("binds challenges to actions, hosts, and sessions", async () => {
|
||||
const { engine } = fixture();
|
||||
const challenge = await engine.create({ action: "checkout", hostname: "shop.test", sessionId: "abc" });
|
||||
expect(await engine.verify({ challengeId: challenge.id, action: "login", answer: "42", hostname: "shop.test", sessionId: "abc" })).toMatchObject({ code: "action-mismatch" });
|
||||
expect(await engine.verify({ challengeId: challenge.id, action: "checkout", answer: "42", hostname: "other.test", sessionId: "abc" })).toMatchObject({ code: "hostname-mismatch" });
|
||||
expect(await engine.verify({ challengeId: challenge.id, action: "checkout", answer: "42", hostname: "shop.test", sessionId: "wrong" })).toMatchObject({ code: "session-mismatch" });
|
||||
});
|
||||
|
||||
test("expires challenges", async () => {
|
||||
const { engine, advance } = fixture();
|
||||
const challenge = await engine.create({ action: "contact", expiresInMs: 1000 });
|
||||
advance(1001);
|
||||
expect(await engine.verify({ challengeId: challenge.id, action: "contact", answer: "42" })).toMatchObject({ success: false, code: "expired" });
|
||||
});
|
||||
|
||||
test("protects audio with an unguessable challenge key", async () => {
|
||||
const { engine } = fixture();
|
||||
const challenge = await engine.create({ action: "contact", presentation: "audio" });
|
||||
expect(challenge.audioUrl).toContain("/audio/");
|
||||
const url = new URL(challenge.audioUrl!, "https://example.test");
|
||||
expect(await engine.renderAudio(challenge.id, "wrong")).toBeUndefined();
|
||||
const rendered = await engine.renderAudio(challenge.id, url.searchParams.get("key")!);
|
||||
expect(rendered?.contentType).toBe("audio/wav");
|
||||
});
|
||||
test("creates and verifies the not-robot checkbox challenge after the minimum completion time", async () => {
|
||||
let now = 1_700_000_000_000;
|
||||
let seed = 19;
|
||||
const engine = createCaptchaEngine({
|
||||
secret: "not-robot-test-secret-with-at-least-thirty-two-characters",
|
||||
store: new MemoryCaptchaStore(),
|
||||
minCompletionMs: 800,
|
||||
now: () => now,
|
||||
randomBytes(length) {
|
||||
const bytes = new Uint8Array(length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
seed = (seed * 1664525 + 1013904223) >>> 0;
|
||||
bytes[index] = seed & 255;
|
||||
}
|
||||
return bytes;
|
||||
},
|
||||
});
|
||||
|
||||
const challenge = await engine.create({ action: "not-robot-demo", type: "not-robot" });
|
||||
expect(challenge).toMatchObject({
|
||||
type: "not-robot",
|
||||
presentation: "invisible",
|
||||
inputMode: "none",
|
||||
});
|
||||
expect(challenge.metadata).toMatchObject({
|
||||
interaction: "checkbox",
|
||||
minCompletionMs: 800,
|
||||
});
|
||||
|
||||
const tooFast = await engine.verify({
|
||||
challengeId: challenge.id,
|
||||
action: "not-robot-demo",
|
||||
honeypot: "",
|
||||
timingToken: challenge.timingToken,
|
||||
});
|
||||
expect(tooFast).toMatchObject({ success: false, code: "risk-rejected" });
|
||||
|
||||
now += 800;
|
||||
const solved = await engine.verify({
|
||||
challengeId: challenge.id,
|
||||
action: "not-robot-demo",
|
||||
honeypot: "",
|
||||
timingToken: challenge.timingToken,
|
||||
});
|
||||
expect(solved.success).toBe(true);
|
||||
expect(solved.responseToken).toBeString();
|
||||
});
|
||||
|
||||
test("normalizes and validates visual disturbance percentages", async () => {
|
||||
const { engine } = fixture();
|
||||
|
||||
const easy = await engine.create({ action: "image-easy", disturbance: 25 });
|
||||
expect(easy.metadata?.disturbance).toBe(25);
|
||||
|
||||
const hard = await engine.create({ action: "image-hard", disturbance: 75 });
|
||||
expect(hard.metadata?.disturbance).toBe(75);
|
||||
|
||||
await expect(engine.create({ action: "too-easy", disturbance: 24 })).rejects.toThrow(
|
||||
"disturbance must be between 25 and 75",
|
||||
);
|
||||
await expect(engine.create({ action: "too-hard", disturbance: 76 })).rejects.toThrow(
|
||||
"disturbance must be between 25 and 75",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolves explicit and random image renderer styles", async () => {
|
||||
const { engine } = fixture();
|
||||
|
||||
const explicit = await engine.create({
|
||||
action: "styled-explicit",
|
||||
imageStyle: "spiderweb",
|
||||
});
|
||||
expect(explicit.metadata).toMatchObject({
|
||||
requestedImageStyle: "spiderweb",
|
||||
imageStyle: "spiderweb",
|
||||
});
|
||||
|
||||
const pooled = await engine.create({
|
||||
action: "styled-random",
|
||||
imageStyle: "random",
|
||||
allowedStyles: ["snow", "wave"],
|
||||
});
|
||||
expect(["snow", "wave"]).toContain(pooled.metadata?.imageStyle);
|
||||
expect(pooled.metadata?.imageStylePool).toEqual(["snow", "wave"]);
|
||||
|
||||
const forced = await engine.create({
|
||||
action: "styled-forced-random",
|
||||
imageStyle: "classic",
|
||||
randomizeStyle: true,
|
||||
allowedStyles: "cut,striped",
|
||||
});
|
||||
expect(["cut", "striped"]).toContain(forced.metadata?.imageStyle);
|
||||
expect(forced.metadata?.requestedImageStyle).toBe("classic");
|
||||
});
|
||||
|
||||
test("validates image renderer style pools", async () => {
|
||||
const { engine } = fixture();
|
||||
|
||||
await expect(engine.create({
|
||||
action: "unknown-style",
|
||||
imageStyle: "unknown" as never,
|
||||
})).rejects.toThrow("imageStyle must be one of");
|
||||
|
||||
await expect(engine.create({
|
||||
action: "empty-style-pool",
|
||||
allowedStyles: ["snow"],
|
||||
excludedStyles: ["snow"],
|
||||
})).rejects.toThrow("No CAPTCHA image styles remain");
|
||||
|
||||
await expect(engine.create({
|
||||
action: "excluded-explicit-style",
|
||||
imageStyle: "classic",
|
||||
excludedStyles: ["classic"],
|
||||
})).rejects.toThrow("is not available in the configured style pool");
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createCaptchaHttpHandlers } from "../src/http.ts";
|
||||
import type { CaptchaEngine } from "../src/types.ts";
|
||||
|
||||
const engine: CaptchaEngine = {
|
||||
provider: "self-hosted",
|
||||
basePath: "/api/captcha",
|
||||
async create(options) { return { id: "c1", provider: "self-hosted", type: "number", presentation: "visual", action: options.action, prompt: "Enter 1", createdAt: 1, expiresAt: 2, responseField: "wrn-captcha-response" }; },
|
||||
async verify(input) { return { success: input.answer === "1", provider: "self-hosted", action: input.action, code: input.answer === "1" ? undefined : "incorrect-answer" }; },
|
||||
async verifyResponseToken(input) { return { success: true, provider: "self-hosted", action: input.action }; },
|
||||
async renderAudio() { return { bytes: new Uint8Array([1, 2, 3]), contentType: "audio/wav" }; },
|
||||
async gc() {},
|
||||
};
|
||||
|
||||
describe("CAPTCHA HTTP handlers", () => {
|
||||
test("creates and verifies same-origin challenges", async () => {
|
||||
const handlers = createCaptchaHttpHandlers(engine);
|
||||
const created = await handlers.handle(new Request("https://example.test/api/captcha/challenge", { method: "POST", headers: { origin: "https://example.test", "content-type": "application/json" }, body: JSON.stringify({ action: "signup" }) }));
|
||||
expect(created?.status).toBe(201);
|
||||
const verified = await handlers.handle(new Request("https://example.test/api/captcha/verify", { method: "POST", headers: { origin: "https://example.test", "content-type": "application/json" }, body: JSON.stringify({ action: "signup", answer: "1" }) }));
|
||||
expect(verified?.status).toBe(200);
|
||||
});
|
||||
|
||||
test("rejects cross-origin requests", async () => {
|
||||
const handlers = createCaptchaHttpHandlers(engine);
|
||||
const response = await handlers.create(new Request("https://example.test/api/captcha/challenge", { method: "POST", headers: { origin: "https://evil.test", "content-type": "application/json" }, body: "{}" }));
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { captchaPageGate } from "../src/middleware.ts";
|
||||
import type { CaptchaEngine } from "../src/types.ts";
|
||||
|
||||
function fakeEngine(): CaptchaEngine {
|
||||
return {
|
||||
provider: "self-hosted",
|
||||
basePath: "/api/captcha",
|
||||
async create() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
async verify() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
async verifyResponseToken(input) {
|
||||
return input.responseToken === "verified-token"
|
||||
? { success: true, provider: "self-hosted", action: input.action }
|
||||
: { success: false, provider: "self-hosted", action: input.action, code: "invalid-input" };
|
||||
},
|
||||
async renderAudio() {
|
||||
return undefined;
|
||||
},
|
||||
async gc() {},
|
||||
};
|
||||
}
|
||||
|
||||
function sessionFixture() {
|
||||
const values = new Map<string, unknown>();
|
||||
return {
|
||||
id: () => "session-1",
|
||||
get<T>(key: string): T | undefined {
|
||||
return values.get(key) as T | undefined;
|
||||
},
|
||||
set<T>(key: string, value: T): void {
|
||||
values.set(key, value);
|
||||
},
|
||||
delete(key: string): void {
|
||||
values.delete(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("CAPTCHA page gate", () => {
|
||||
test("accepts a verified form token and grants the protected route", async () => {
|
||||
const session = sessionFixture();
|
||||
const gate = captchaPageGate({
|
||||
action: "protected-page-access",
|
||||
engine: fakeEngine(),
|
||||
challengePath: "/captcha",
|
||||
policy: {
|
||||
mode: "session",
|
||||
verifiedForMs: 15 * 60_000,
|
||||
routeGroups: ["/protected"],
|
||||
},
|
||||
});
|
||||
|
||||
const body = new URLSearchParams({
|
||||
returnTo: "/protected",
|
||||
"wrn-captcha-response": "verified-token",
|
||||
});
|
||||
const request = new Request("https://example.test/api/page-grant", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
});
|
||||
const context = {
|
||||
req: request,
|
||||
url: new URL(request.url),
|
||||
session,
|
||||
ip: "127.0.0.1",
|
||||
locals: {},
|
||||
};
|
||||
|
||||
const granted = await gate(context, () =>
|
||||
Response.redirect("https://example.test/protected", 303),
|
||||
);
|
||||
expect(granted.status).toBe(303);
|
||||
|
||||
const protectedRequest = new Request("https://example.test/protected");
|
||||
const protectedContext = {
|
||||
...context,
|
||||
req: protectedRequest,
|
||||
url: new URL(protectedRequest.url),
|
||||
locals: {},
|
||||
};
|
||||
const allowed = await gate(protectedContext, () => new Response("unlocked"));
|
||||
expect(await allowed.text()).toBe("unlocked");
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user