719032b19f
Update Schema / Update configuration json schema (push) Has been cancelled
Memory Benchmark / Memory Test (Full Analysis) (push) Has been cancelled
CI Quality / Lint GitHub Actions (actionlint) (push) Has been cancelled
CI Quality / Lint GitHub Actions (zizmor) (push) Has been cancelled
CI Quality / Check typos (push) Has been cancelled
CI / Test coverage (push) Has been cancelled
CI / Test (22.x, windows-latest) (push) Has been cancelled
CI / Test (24.x, macos-latest) (push) Has been cancelled
CI / Test (24.x, ubuntu-latest) (push) Has been cancelled
CI / Test (24.x, windows-latest) (push) Has been cancelled
CI / Test (26.x, macos-latest) (push) Has been cancelled
CI / Test (26.x, ubuntu-latest) (push) Has been cancelled
CI / Test (26.x, windows-latest) (push) Has been cancelled
CI / Test with Bun (latest, macos-latest) (push) Has been cancelled
CI / Test with Bun (latest, ubuntu-latest) (push) Has been cancelled
CI / Test with Bun (latest, windows-latest) (push) Has been cancelled
CI / Build and run (22.x, macos-latest) (push) Has been cancelled
CI / Build and run (22.x, ubuntu-latest) (push) Has been cancelled
CI / Build and run (22.x, windows-latest) (push) Has been cancelled
autofix.ci / autofix (push) Has been cancelled
CI Browser Extension / Lint Browser Extension (push) Has been cancelled
CI Browser Extension / Test Browser Extension (push) Has been cancelled
Memory Benchmark / Memory Test (push) Has been cancelled
CI Website / Lint Website Client (push) Has been cancelled
CI Website / Lint Website Server (push) Has been cancelled
CI Website / Test Website Server (push) Has been cancelled
CI Website / Bundle Website Server (push) Has been cancelled
CI / Lint Biome (push) Has been cancelled
CI / Lint oxlint (push) Has been cancelled
CI / Lint TypeScript (push) Has been cancelled
CI / Lint Secretlint (push) Has been cancelled
CI / Test (22.x, macos-latest) (push) Has been cancelled
CI / Test (22.x, ubuntu-latest) (push) Has been cancelled
CI / Build and run (24.x, macos-latest) (push) Has been cancelled
CI / Build and run (24.x, ubuntu-latest) (push) Has been cancelled
CI / Build and run (24.x, windows-latest) (push) Has been cancelled
CI / Build and run (26.x, macos-latest) (push) Has been cancelled
CI / Build and run (26.x, ubuntu-latest) (push) Has been cancelled
CI / Build and run (26.x, windows-latest) (push) Has been cancelled
CI / Build and run with Bun (latest, macos-latest) (push) Has been cancelled
CI / Build and run with Bun (latest, ubuntu-latest) (push) Has been cancelled
CI / Build and run with Bun (latest, windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Docker / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Docker / build (linux/arm/v7, ubuntu-24.04-arm) (push) Has been cancelled
Docker / build (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Docker / merge (push) Has been cancelled
Pack repository with Repomix / pack-repo (push) Has been cancelled
Performance Benchmark History / Benchmark (macos-latest) (push) Has been cancelled
Performance Benchmark History / Benchmark (ubuntu-latest) (push) Has been cancelled
Performance Benchmark History / Benchmark (windows-latest) (push) Has been cancelled
Performance Benchmark History / Store Results (push) Has been cancelled
Test Repomix Action / Test Node.js 22 (push) Has been cancelled
Test Repomix Action / Test Node.js 24 (push) Has been cancelled
Test Repomix Action / Test Node.js 26 (push) Has been cancelled
245 lines
11 KiB
TypeScript
245 lines
11 KiB
TypeScript
import { onBeforeUnmount, ref } from 'vue';
|
|
import { loadTurnstileScript, type TurnstileGlobal } from './useTurnstileScript';
|
|
import { createTurnstileTokenCache } from './useTurnstileTokenCache';
|
|
|
|
// Cloudflare Turnstile integration. Used by usePackRequest to obtain a 1-shot
|
|
// verification token that the server-side turnstileMiddleware verifies before
|
|
// running /api/pack.
|
|
//
|
|
// Layering:
|
|
// - `useTurnstileScript.ts` — script tag injection / READY_CALLBACK / retry.
|
|
// - `useTurnstileTokenCache.ts` — token cache, single-flight mint,
|
|
// atomic one-shot consumption.
|
|
// - this file — widget lifecycle (render/execute/reset), abort propagation
|
|
// into the underlying iframe, supersede / generation-counter logic.
|
|
//
|
|
// Site key resolution:
|
|
// - Build-time env var `VITE_TURNSTILE_SITE_KEY` overrides the default
|
|
// (used for production / staging deploys via VitePress build env).
|
|
// - The fall-through is Cloudflare's "always-passes" test key
|
|
// (`1x00000000000000000000AA`) so local dev and contributor builds work
|
|
// without any setup. Using the test key in production would silently let all
|
|
// tokens through — pair the deploy with the matching test secret on the
|
|
// server, or set both to real values together.
|
|
const FALLBACK_TEST_SITE_KEY = '1x00000000000000000000AA';
|
|
|
|
// Upper bound on how long the widget callback can take. Cloudflare's
|
|
// `timeout-callback` only fires for interactive challenges, so an invisible
|
|
// widget that hangs (CDN stall, iframe never resolves) would otherwise leave
|
|
// the caller's promise pending forever and freeze the loading spinner.
|
|
const MINT_TIMEOUT_MS = 15_000;
|
|
|
|
export function useTurnstile() {
|
|
const widgetId = ref<string | null>(null);
|
|
const containerEl = ref<HTMLElement | null>(null);
|
|
|
|
// Resolved when the next widget callback produces a token. Reassigned on
|
|
// every mint so back-to-back submits don't share state.
|
|
let pendingResolve: ((token: string) => void) | null = null;
|
|
let pendingReject: ((error: Error) => void) | null = null;
|
|
// Monotonic generation counter. Each mintToken() call captures a local
|
|
// copy and the timeout/callback closures verify it before mutating shared
|
|
// state. This neutralises three otherwise-leaky scenarios:
|
|
// - a stale timeout from a previous mint clearing the next call's pending
|
|
// handlers,
|
|
// - a delayed widget callback resolving a later request with a stale
|
|
// token,
|
|
// - back-to-back mints reusing handlers before the previous timeout has
|
|
// fired.
|
|
let currentGen = 0;
|
|
|
|
// Site key resolution. The production-only safety net lives in
|
|
// `.vitepress/config.ts` (it throws at build time when the Cloudflare Pages
|
|
// production deploy is missing VITE_TURNSTILE_SITE_KEY). We deliberately do
|
|
// *not* duplicate that check here with `import.meta.env.PROD`, because PROD
|
|
// is true for all `vitepress build` outputs — CF Pages preview deploys,
|
|
// local `docs:build`, and CI builds all set PROD=true and are documented to
|
|
// fall through to the test sitekey. Adding a runtime throw scoped to PROD
|
|
// would crash the form in those non-production environments.
|
|
//
|
|
// Defense in depth: the server-side middleware fail-closes when it has a
|
|
// real TURNSTILE_SECRET_KEY but receives a token issued by the test
|
|
// sitekey (action/hostname mismatch), so an actual production deploy that
|
|
// somehow shipped the test sitekey would still 403 every pack.
|
|
const siteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY ?? FALLBACK_TEST_SITE_KEY;
|
|
|
|
// Single-flight cache for the in-flight ensureWidget promise. Shared by
|
|
// every code path that needs the widget (preMintToken, click-time mint),
|
|
// so concurrent calls can't both pass the `widgetId.value` null check
|
|
// after `await loadTurnstileScript()` resolves and call `turnstile.render()`
|
|
// twice — the first widget id would be overwritten and leak.
|
|
let ensureWidgetPromise: Promise<TurnstileGlobal> | null = null;
|
|
|
|
// Forward declaration — set after cache is created below.
|
|
let resetCache: () => void = () => {};
|
|
|
|
async function ensureWidget(el: HTMLElement): Promise<TurnstileGlobal> {
|
|
if (ensureWidgetPromise) return ensureWidgetPromise;
|
|
ensureWidgetPromise = (async () => {
|
|
const turnstile = await loadTurnstileScript();
|
|
// The component may have unmounted (or the user may have switched away
|
|
// from the form) while the script was loading. Detached DOM elements
|
|
// accept render() but the corresponding remove() in onBeforeUnmount has
|
|
// already run, so the widget would leak. Bail out instead.
|
|
if (containerEl.value !== el) {
|
|
throw new Error('Turnstile container detached during script load');
|
|
}
|
|
if (!widgetId.value) {
|
|
widgetId.value = turnstile.render(el, {
|
|
sitekey: siteKey,
|
|
action: 'pack',
|
|
execution: 'execute',
|
|
callback: (token: string) => {
|
|
if (pendingResolve) {
|
|
pendingResolve(token);
|
|
pendingResolve = null;
|
|
pendingReject = null;
|
|
}
|
|
},
|
|
'error-callback': (errorCode: string) => {
|
|
if (pendingReject) {
|
|
pendingReject(new Error(`Turnstile error: ${errorCode}`));
|
|
pendingResolve = null;
|
|
pendingReject = null;
|
|
}
|
|
},
|
|
'expired-callback': () => {
|
|
// Token expired before being used. Drop the cache so the next
|
|
// takeToken() refreshes; the widget will issue a fresh token on
|
|
// the next execute() call.
|
|
//
|
|
// Intentionally do NOT auto-rearm pre-mint here. A user who
|
|
// fills the form and then leaves the tab idle would otherwise
|
|
// burn a challenge every TOKEN_TTL_MS (~4 minutes) for the
|
|
// entire lifetime of the page, re-creating dashboard counter
|
|
// inflation. The trade-off is that an idle-then-return user
|
|
// pays the cold mint latency on their next click; for a tab
|
|
// left open for hours, that's the right call.
|
|
resetCache();
|
|
if (widgetId.value) turnstile.reset(widgetId.value);
|
|
},
|
|
'timeout-callback': () => {
|
|
if (pendingReject) {
|
|
pendingReject(new Error('Turnstile challenge timed out'));
|
|
pendingResolve = null;
|
|
pendingReject = null;
|
|
}
|
|
},
|
|
});
|
|
}
|
|
return turnstile;
|
|
})();
|
|
try {
|
|
return await ensureWidgetPromise;
|
|
} catch (err) {
|
|
// Drop the cached promise on rejection so a retry (e.g. after a CDN
|
|
// blip cleared by useTurnstileScript's resetForRetry) can re-enter the
|
|
// render path. On success we keep the resolved promise cached: the
|
|
// widgetId guard above turns subsequent calls into a no-op anyway, but
|
|
// returning the same promise avoids a duplicate `loadTurnstileScript()`
|
|
// round-trip in the cached-success case.
|
|
ensureWidgetPromise = null;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// Run the widget challenge and return a fresh token. Internal primitive
|
|
// wrapped by the token cache's preMintToken / takeToken.
|
|
async function mintToken(): Promise<string> {
|
|
if (!containerEl.value) {
|
|
throw new Error('Turnstile container element not registered');
|
|
}
|
|
const turnstile = await ensureWidget(containerEl.value);
|
|
const renderedWidgetId = widgetId.value;
|
|
if (!renderedWidgetId) {
|
|
throw new Error('Turnstile widget failed to render');
|
|
}
|
|
|
|
// Supersede any in-flight request: reject the previous caller before we
|
|
// overwrite pendingResolve/pendingReject below.
|
|
if (pendingReject) {
|
|
pendingReject(new Error('Superseded by new Turnstile request'));
|
|
pendingResolve = null;
|
|
pendingReject = null;
|
|
}
|
|
|
|
const myGen = ++currentGen;
|
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
|
|
const tokenPromise = new Promise<string>((resolve, reject) => {
|
|
// Wrap in gen-checked closures so a delayed widget callback can't
|
|
// resolve a later request with a stale token, and the timeout below
|
|
// clears handlers only if no fresher request has taken over.
|
|
pendingResolve = (token) => {
|
|
if (myGen !== currentGen) return;
|
|
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
|
pendingResolve = null;
|
|
pendingReject = null;
|
|
resolve(token);
|
|
};
|
|
pendingReject = (err) => {
|
|
if (myGen !== currentGen) return;
|
|
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
|
pendingResolve = null;
|
|
pendingReject = null;
|
|
reject(err);
|
|
};
|
|
// Tokens are 1-shot, so reset() before each execute() to clear any
|
|
// stale challenge state inside the widget itself.
|
|
turnstile.reset(renderedWidgetId);
|
|
turnstile.execute(renderedWidgetId);
|
|
});
|
|
|
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
timeoutId = setTimeout(() => {
|
|
if (myGen !== currentGen) return;
|
|
pendingResolve = null;
|
|
pendingReject = null;
|
|
reject(new Error('Turnstile challenge timed out'));
|
|
}, MINT_TIMEOUT_MS);
|
|
});
|
|
return Promise.race([tokenPromise, timeoutPromise]);
|
|
}
|
|
|
|
const cache = createTurnstileTokenCache(mintToken);
|
|
resetCache = cache.reset;
|
|
|
|
function setContainer(el: HTMLElement | null) {
|
|
containerEl.value = el;
|
|
// Intentionally do NOT pre-warm the script here. Production telemetry
|
|
// (PR #1541 follow-up) showed that simply loading api.js inflates the
|
|
// Cloudflare dashboard's "challenge issued" counter to roughly the
|
|
// page-view count, regardless of whether `render()` is ever called.
|
|
// Pre-warm now happens only when usePackRequest sees a real intent
|
|
// signal (valid input + user interaction), which gates both the script
|
|
// load and the challenge to visitors who actually plan to submit.
|
|
}
|
|
|
|
onBeforeUnmount(() => {
|
|
// Drop the container ref first so any in-flight pre-warm `ensureWidget()`
|
|
// call that resolves AFTER unmount sees `containerEl.value !== el` and
|
|
// skips render(). Without this, a slow script load could complete after
|
|
// the form was unmounted and bind a new widget to a detached DOM node
|
|
// with no remove() left to clean it up.
|
|
containerEl.value = null;
|
|
// Reject any in-flight mint so the awaiting caller doesn't hang forever
|
|
// after the form unmounts (e.g. user navigates away mid-challenge).
|
|
if (pendingReject) {
|
|
pendingReject(new Error('Turnstile widget unmounted'));
|
|
pendingResolve = null;
|
|
pendingReject = null;
|
|
}
|
|
cache.reset();
|
|
if (widgetId.value && window.turnstile) {
|
|
window.turnstile.remove(widgetId.value);
|
|
widgetId.value = null;
|
|
}
|
|
});
|
|
|
|
return {
|
|
setContainer,
|
|
preMintToken: cache.preMintToken,
|
|
takeToken: cache.takeToken,
|
|
};
|
|
}
|