diff --git a/website/client/components/Home/TryIt.vue b/website/client/components/Home/TryIt.vue index c9ddb566..6f81187b 100644 --- a/website/client/components/Home/TryIt.vue +++ b/website/client/components/Home/TryIt.vue @@ -45,6 +45,7 @@ :loading="loading" @keydown="handleKeydown" @submit="handleSubmit" + @user-input="markUserTouched" :show-button="false" /> @@ -85,7 +86,7 @@ v-model:show-line-numbers="packOptions.showLineNumbers" v-model:output-parsable="packOptions.outputParsable" v-model:compress="packOptions.compress" - + @user-input="markUserTouched" />
@@ -157,6 +158,7 @@ const { resetOptions, cancelRequest, setTurnstileContainer, + markUserTouched, } = usePackRequest(); // Wire the template ref into useTurnstile so the widget renders into the diff --git a/website/client/components/Home/TryItPackOptions.vue b/website/client/components/Home/TryItPackOptions.vue index dfebb240..48fdf560 100644 --- a/website/client/components/Home/TryItPackOptions.vue +++ b/website/client/components/Home/TryItPackOptions.vue @@ -27,55 +27,70 @@ const emit = defineEmits<{ 'update:showLineNumbers': [value: boolean]; 'update:outputParsable': [value: boolean]; 'update:compress': [value: boolean]; + // Emitted on every real user-driven option change so usePackRequest can + // gate the Turnstile pre-mint. Mirrors TryItUrlInput's `userInput` — + // emitted from DOM-level handlers only, never from URL-parameter + // hydration / form restoration paths. + userInput: []; }>(); function handleFormatChange(newFormat: 'xml' | 'markdown' | 'plain') { emit('update:format', newFormat); + emit('userInput'); handleOptionChange(newFormat, AnalyticsAction.FORMAT_CHANGE); } function handleIncludePatternsUpdate(patterns: string) { emit('update:includePatterns', patterns); + emit('userInput'); handleOptionChange(patterns, AnalyticsAction.UPDATE_INCLUDE_PATTERNS); } function handleIgnorePatternsUpdate(patterns: string) { emit('update:ignorePatterns', patterns); + emit('userInput'); handleOptionChange(patterns, AnalyticsAction.UPDATE_IGNORE_PATTERNS); } function handleFileSummaryToggle(enabled: boolean) { emit('update:fileSummary', enabled); + emit('userInput'); handleOptionChange(enabled, AnalyticsAction.TOGGLE_FILE_SUMMARY); } function handleDirectoryStructureToggle(enabled: boolean) { emit('update:directoryStructure', enabled); + emit('userInput'); handleOptionChange(enabled, AnalyticsAction.TOGGLE_DIRECTORY_STRUCTURE); } function handleRemoveCommentsToggle(enabled: boolean) { emit('update:removeComments', enabled); + emit('userInput'); handleOptionChange(enabled, AnalyticsAction.TOGGLE_REMOVE_COMMENTS); } function handleRemoveEmptyLinesToggle(enabled: boolean) { emit('update:removeEmptyLines', enabled); + emit('userInput'); handleOptionChange(enabled, AnalyticsAction.TOGGLE_REMOVE_EMPTY_LINES); } function handleShowLineNumbersToggle(enabled: boolean) { emit('update:showLineNumbers', enabled); + emit('userInput'); handleOptionChange(enabled, AnalyticsAction.TOGGLE_LINE_NUMBERS); } function handleOutputParsableToggle(enabled: boolean) { emit('update:outputParsable', enabled); + emit('userInput'); handleOptionChange(enabled, AnalyticsAction.TOGGLE_OUTPUT_PARSABLE); } function handleCompressToggle(enabled: boolean) { emit('update:compress', enabled); + emit('userInput'); handleOptionChange(enabled, AnalyticsAction.TOGGLE_COMPRESS); } diff --git a/website/client/components/Home/TryItUrlInput.vue b/website/client/components/Home/TryItUrlInput.vue index c66a95e0..f3597611 100644 --- a/website/client/components/Home/TryItUrlInput.vue +++ b/website/client/components/Home/TryItUrlInput.vue @@ -15,6 +15,11 @@ const emit = defineEmits<{ submit: []; keydown: [event: KeyboardEvent]; cancel: []; + // Emitted on real DOM input — typing, paste, IME compose end, datalist + // selection. Used by usePackRequest to gate the Turnstile pre-mint so + // URL-parameter hydration / form restoration don't trigger background + // challenges. + userInput: []; }>(); const isValidUrl = computed(() => { @@ -68,6 +73,7 @@ function saveUrlToHistory(url: string) { function handleUrlInput(event: Event) { const input = event.target as HTMLInputElement; emit('update:url', input.value); + emit('userInput'); } // Process and save valid URL diff --git a/website/client/composables/turnstileSubmit.ts b/website/client/composables/turnstileSubmit.ts new file mode 100644 index 00000000..8b2fdc39 --- /dev/null +++ b/website/client/composables/turnstileSubmit.ts @@ -0,0 +1,63 @@ +// Helpers for translating Turnstile token-acquisition outcomes into the +// shape usePackRequest's `submitRequest` consumes. Splitting these out keeps +// usePackRequest under the 250-line file-size guideline and centralises the +// user-facing error copy. + +import type { useTurnstile } from './useTurnstile'; + +export type TurnstileTokenResult = + // Token acquired (or dev/preview fallthrough where the server skips + // verification when TURNSTILE_SECRET_KEY is unset). + | { kind: 'token'; token: string | undefined } + // The pack-request controller was aborted while the Turnstile challenge + // was in flight. `reason` mirrors AbortSignal.reason so the caller can + // distinguish user cancel from the 30s timeout. + | { kind: 'aborted'; reason: AbortSignal['reason'] } + // Production verification failure — surface a user-visible error instead + // of calling /api/pack since the server-side middleware would 403 anyway. + | { kind: 'error'; message: string }; + +// Acquire a Turnstile token for the click path. The signal aborts an +// in-flight challenge when the surrounding pack request is cancelled. +export async function acquireTurnstileToken( + turnstile: ReturnType, + signal: AbortSignal, +): Promise { + try { + return { kind: 'token', token: await turnstile.takeToken(signal) }; + } catch (err) { + // Abort is a normal flow (user cancel, 30s timeout). Don't log it as + // a failure — only log genuine challenge / script-load errors. + if (signal.aborted) { + return { kind: 'aborted', reason: signal.reason }; + } + console.warn('Turnstile token acquisition failed:', err); + if (import.meta.env.PROD) { + return { kind: 'error', message: turnstileFailureMessage(err) }; + } + // Dev/preview: continue without a token. The server skips verification + // when TURNSTILE_SECRET_KEY is unset, so contributors without a + // Cloudflare account can still exercise the pack flow. + return { kind: 'token', token: undefined }; + } +} + +// Distinguish "Turnstile script blocked" (likely an extension) from generic +// verification failure so the user has a path to recovery instead of just +// being told "try again". +function turnstileFailureMessage(err: unknown): string { + const msg = err instanceof Error ? err.message : ''; + const isScriptIssue = /script|load|missing/i.test(msg); + return isScriptIssue + ? 'Bot protection failed to load. Please disable ad blockers or privacy extensions blocking challenges.cloudflare.com and reload, or use the CLI: npx repomix --remote owner/repo.' + : 'Verification failed. Please reload the page and try again.'; +} + +// Mirror handlePackRequest's onAbort messaging. Used when the Turnstile +// challenge is aborted before /api/pack is reached, so we short-circuit +// rather than calling handlePackRequest at all. +export function abortMessage(reason: AbortSignal['reason']): string { + return reason === 'timeout' + ? 'Request timed out.\nPlease consider using Include Patterns or Ignore Patterns to reduce the scope.' + : 'Request was cancelled.'; +} diff --git a/website/client/composables/usePackRequest.ts b/website/client/composables/usePackRequest.ts index 326eef0f..32743516 100644 --- a/website/client/composables/usePackRequest.ts +++ b/website/client/composables/usePackRequest.ts @@ -3,9 +3,18 @@ import type { FileInfo, PackProgressStage, PackResult } from '../components/api/ import { handlePackRequest } from '../components/utils/requestHandlers'; import { isValidRemoteValue } from '../components/utils/validation'; import { parseUrlParameters } from '../utils/urlParams'; +import { abortMessage, acquireTurnstileToken } from './turnstileSubmit'; import { usePackOptions } from './usePackOptions'; +import { usePreMintDebounce } from './usePreMintDebounce'; import { useTurnstile } from './useTurnstile'; +// Delay between the user's last interaction and when we kick off the +// background Turnstile pre-mint. Tuned for the typical paste-then-click +// cadence: long enough that single-keystroke typing doesn't burn a token, +// short enough that a paste-and-click within a normal reaction window +// (~500ms+) usually finds a ready token in the cache. +const PRE_MINT_DEBOUNCE_MS = 300; + export type InputMode = 'url' | 'file' | 'folder'; export function usePackRequest() { @@ -20,6 +29,13 @@ export function usePackRequest() { const inputRepositoryUrl = ref(''); const mode = ref('url'); const uploadedFile = ref(null); + // True once the user has signalled real intent: typed/pasted a URL, + // uploaded a file/folder, switched modes, or tweaked options. Used to + // gate the Turnstile pre-mint so URL-parameter hydration (`?repo=...`), + // browser autofill, form restoration, and JS-executing link unfurlers + // don't trigger background challenges. Set-only — once true, it stays + // true for the session. + const userTouched = ref(false); // Request states const loading = ref(false); @@ -49,12 +65,37 @@ export function usePackRequest() { function setMode(newMode: InputMode) { mode.value = newMode; + // Mode tab clicks are unambiguous user interactions, so they're a safe + // intent signal even before any input has been entered. + userTouched.value = true; } function handleFileUpload(file: File) { uploadedFile.value = file; + userTouched.value = true; } + // Wired to DOM-level input events (paste / IME / drop / typing) by + // TryItUrlInput, and to TryItPackOptions option-change handlers. + // Watching `inputUrl` / `packOptions` directly would also fire on URL- + // parameter hydration in onMounted(), which we want to opt into + // explicitly (see `?repo=` handling in onMounted) rather than implicitly. + function markUserTouched() { + userTouched.value = true; + } + + const preMint = usePreMintDebounce({ + isSubmitValid, + userTouched, + loading, + onTrigger: () => { + turnstile.preMintToken().catch(() => { + /* errors surface on the actual submit path */ + }); + }, + delayMs: PRE_MINT_DEBOUNCE_MS, + }); + function resetRequest() { error.value = null; errorType.value = 'error'; @@ -65,6 +106,11 @@ export function usePackRequest() { async function submitRequest() { if (!isSubmitValid.value) return; + // Drop any pending pre-mint debounce. Without an explicit clear here a + // debounce that's about to fire *this microtask* could still mint an + // extra token alongside the click path's mint. + preMint.clear(); + // Cancel any pending request if (requestController) { requestController.abort(); @@ -72,7 +118,7 @@ export function usePackRequest() { requestController = new AbortController(); // Capture the controller in a local const before any await. cancelRequest() // can null out the shared `requestController` while we're awaiting - // turnstile.getToken(); reading `requestController.signal` after that + // turnstile.takeToken(); reading `requestController.signal` after that // would throw TypeError. The local reference still points to the original // (already-aborted) controller, so the downstream signal check in // handlePackRequest still works correctly. @@ -91,15 +137,6 @@ export function usePackRequest() { // Use .bind() to avoid capturing the surrounding scope in the closure const timeoutId = setTimeout(controller.abort.bind(controller, 'timeout'), TIMEOUT_MS); - // Obtain a 1-shot Turnstile token before issuing the pack request. If the - // widget fails (e.g. script blocked by an ad blocker, network error) the - // policy is environment-specific: - // - In production: surface a user-facing error and skip the request. - // The server-side middleware would 403 anyway, so calling /api/pack - // without a token only wastes a server round-trip. - // - In dev/preview: continue without a token. The server skips - // verification when TURNSTILE_SECRET_KEY is unset, so contributors - // without a Cloudflare account can still exercise the pack flow. // All UI mutations from this point forward are guarded by `isCurrent()`. // Without the guard, a slow request whose user hit cancel-and-resubmit // could clobber the new request's `loading` / `result` / `error` state @@ -108,50 +145,31 @@ export function usePackRequest() { // identity is the cleanest way to detect supersession. const isCurrent = () => requestController === controller; - let turnstileToken: string | undefined; - try { - // Pass the controller signal so cancelling the pack request also - // aborts an in-flight Turnstile challenge — otherwise a hung widget - // would delay the cancel response by up to 15s. - turnstileToken = await turnstile.getToken(controller.signal); - } catch (turnstileError) { - console.warn('Turnstile token acquisition failed:', turnstileError); - if (controller.signal.aborted) { - // The user (or the 30s timeout) cancelled while the challenge was - // in flight. Mirror handlePackRequest's onAbort messaging since we - // short-circuit before calling it. - clearTimeout(timeoutId); - if (isCurrent()) { - loading.value = false; - requestController = null; - if (controller.signal.reason === 'timeout') { - error.value = - 'Request timed out.\nPlease consider using Include Patterns or Ignore Patterns to reduce the scope.'; - } else { - error.value = 'Request was cancelled.'; - } - errorType.value = 'warning'; - } - return; - } - if (import.meta.env.PROD) { - clearTimeout(timeoutId); - if (isCurrent()) { - loading.value = false; - requestController = null; - // Distinguish "Turnstile script blocked" (likely an extension) from - // generic verification failure so the user has a path to recovery - // instead of just being told "try again". - const msg = turnstileError instanceof Error ? turnstileError.message : ''; - const isScriptIssue = /script|load|missing/i.test(msg); - error.value = isScriptIssue - ? 'Bot protection failed to load. Please disable ad blockers or privacy extensions blocking challenges.cloudflare.com and reload, or use the CLI: npx repomix --remote owner/repo.' - : 'Verification failed. Please reload the page and try again.'; - errorType.value = 'error'; - } - return; + // Obtain a 1-shot Turnstile token before issuing the pack request. The + // controller signal aborts an in-flight challenge when the pack request + // is cancelled, so a hung widget can't delay the cancel response. + const tokenResult = await acquireTurnstileToken(turnstile, controller.signal); + if (tokenResult.kind === 'aborted') { + clearTimeout(timeoutId); + if (isCurrent()) { + loading.value = false; + requestController = null; + error.value = abortMessage(tokenResult.reason); + errorType.value = 'warning'; } + return; } + if (tokenResult.kind === 'error') { + clearTimeout(timeoutId); + if (isCurrent()) { + loading.value = false; + requestController = null; + error.value = tokenResult.message; + errorType.value = 'error'; + } + return; + } + const turnstileToken = tokenResult.token; try { await handlePackRequest( @@ -192,6 +210,17 @@ export function usePackRequest() { if (requestController === controller) { loading.value = false; requestController = null; + // Repeat-pack convenience: warm the cache for a likely follow-up + // submission (option tweak + repack, or `repackWithSelectedFiles` + // triggered from the result view). Skipped on abort/cancel since + // the user may have given up, and on invalid form (user may have + // cleared the URL mid-request). userTouched is necessarily true + // here — it was a precondition for isSubmitValid to be true at + // submit start. Failures swallow silently — they surface on the + // next click via takeToken's cold path. + if (!controller.signal.aborted && isSubmitValid.value) { + turnstile.preMintToken().catch(() => {}); + } } } } @@ -252,7 +281,15 @@ export function usePackRequest() { // Apply pack options from URL parameters applyUrlParameters(urlParams); - // Apply repo URL from URL parameters + // Apply repo URL from URL parameters. Intentionally do NOT flip + // `userTouched` here even when the value is valid: third-party pages + // driving traffic to `https://repomix.com/?repo=<...>` would otherwise + // amplify dashboard counters via link unfurlers (Slack / Discord / + // Twitter card validators that execute JS) — re-creating the + // page-view-shaped inflation this PR is meant to fix. Permalink + // visitors still pay the cold mint on click; the user's first real + // form interaction (typing, mode click, option tweak, file upload) + // is what gates the pre-mint. if (urlParams.repo) { inputUrl.value = urlParams.repo; } @@ -287,6 +324,7 @@ export function usePackRequest() { submitRequest, repackWithSelectedFiles, cancelRequest, + markUserTouched, // Turnstile widget container (Vue ref callback consumer) setTurnstileContainer: turnstile.setContainer, diff --git a/website/client/composables/usePreMintDebounce.ts b/website/client/composables/usePreMintDebounce.ts new file mode 100644 index 00000000..0ed0e2de --- /dev/null +++ b/website/client/composables/usePreMintDebounce.ts @@ -0,0 +1,56 @@ +import { onBeforeUnmount, type Ref, watch } from 'vue'; + +// Debounced trigger for the Turnstile pre-mint. Watches a (valid + touched) +// gate and fires `onTrigger` after `delayMs` of quiet — short enough that +// the token is usually ready by the time the user reaches for the Pack +// button, long enough that rapid typing or quick mode-switches don't +// trigger multiple mints. +// +// `loading` is intentionally NOT a watch source — only a guard inside the +// callback. Including it in the deps would re-fire the watch on +// `loading: true → false`, scheduling a fresh pre-mint immediately after +// every pack completion even though the user hasn't done anything new, +// re-introducing dashboard counter inflation through a different path. +// The `clear()` callers (e.g. `submitRequest`'s start) are what stop a +// pending debounce from firing mid-submit. +export interface PreMintDebounceOptions { + // Source refs the watch should react to. Pre-mint fires when both are + // truthy AND `loading.value` is false at the time the timer fires. + isSubmitValid: Readonly>; + userTouched: Readonly>; + loading: Readonly>; + // Called when the debounce window elapses. Should kick off the actual + // background mint — errors should be swallowed by the caller since + // failures surface on the explicit submit path. + onTrigger: () => void; + delayMs: number; +} + +export function usePreMintDebounce(opts: PreMintDebounceOptions) { + const { isSubmitValid, userTouched, loading, onTrigger, delayMs } = opts; + let timer: ReturnType | undefined; + + function clear() { + if (timer !== undefined) { + clearTimeout(timer); + timer = undefined; + } + } + + watch( + [isSubmitValid, userTouched], + ([valid, touched]) => { + clear(); + if (!valid || !touched || loading.value) return; + timer = setTimeout(() => { + timer = undefined; + onTrigger(); + }, delayMs); + }, + { flush: 'post' }, + ); + + onBeforeUnmount(() => clear()); + + return { clear }; +} diff --git a/website/client/composables/useTurnstile.ts b/website/client/composables/useTurnstile.ts index 94680841..80d6cc00 100644 --- a/website/client/composables/useTurnstile.ts +++ b/website/client/composables/useTurnstile.ts @@ -1,13 +1,17 @@ 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. // -// The script-loading mechanics (script tag injection, READY_CALLBACK, -// retry-on-failure) live in `useTurnstileScript.ts` so this file stays -// focused on widget lifecycle / token requests / abort propagation. +// 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 @@ -19,29 +23,29 @@ import { loadTurnstileScript, type TurnstileGlobal } from './useTurnstileScript' // server, or set both to real values together. const FALLBACK_TEST_SITE_KEY = '1x00000000000000000000AA'; -// Upper bound on how long getToken() will wait for a callback. Cloudflare's +// 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 GET_TOKEN_TIMEOUT_MS = 15_000; +const MINT_TIMEOUT_MS = 15_000; export function useTurnstile() { const widgetId = ref(null); const containerEl = ref(null); - const error = ref(null); - // Resolved when the next render of the widget produces a token. Reassigned - // on each `getToken()` call so back-to-back submits don't share state. + // 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 getToken() 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 call clearing the next call's pending + // 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 the next call with a stale token, - // - a back-to-back submit reusing handlers before the previous timeout - // has fired. + // - 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 @@ -59,14 +63,16 @@ export function useTurnstile() { // 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. With pack - // pre-warm now restricted to `loadTurnstileScript()`, only back-to-back - // `getToken()` calls can race here — but two concurrent submits would - // still both pass the `widgetId.value` null check after the awaited - // script load resolves and call `turnstile.render()` twice, leaking the - // first widget id (onBeforeUnmount can only remove the surviving one). + // 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 | null = null; + // Forward declaration — set after cache is created below. + let resetCache: () => void = () => {}; + async function ensureWidget(el: HTMLElement): Promise { if (ensureWidgetPromise) return ensureWidgetPromise; ensureWidgetPromise = (async () => { @@ -83,12 +89,6 @@ export function useTurnstile() { sitekey: siteKey, size: 'invisible', action: 'pack', - // Defer the actual challenge until execute() is called below. - // Caveat per PR #1541: render() itself still inflates the - // Cloudflare dashboard's challenge counters even with this - // option, which is why `setContainer()` no longer pre-warms by - // calling render(). We still pass `execute` here so render() at - // least doesn't auto-mint a token before getToken() is ready. execution: 'execute', callback: (token: string) => { if (pendingResolve) { @@ -98,17 +98,25 @@ export function useTurnstile() { } }, 'error-callback': (errorCode: string) => { - const message = `Turnstile error: ${errorCode}`; - error.value = message; if (pendingReject) { - pendingReject(new Error(message)); + pendingReject(new Error(`Turnstile error: ${errorCode}`)); pendingResolve = null; pendingReject = null; } }, 'expired-callback': () => { - // Token expired before being used. The widget will issue a fresh - // one on the next execute() call. + // 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': () => { @@ -136,41 +144,15 @@ export function useTurnstile() { } } - // Ask the (invisible) widget for a fresh verification token. Each call - // resets the widget first because Turnstile tokens are 1-shot. - // - // The optional `signal` lets the caller (usePackRequest's submit flow) - // abort the challenge mid-flight when the user cancels — without it, a - // hung Turnstile iframe would block the cancel response for up to - // GET_TOKEN_TIMEOUT_MS even though the surrounding pack request was - // already aborted. - async function getToken(signal?: AbortSignal): Promise { - error.value = null; - const checkAborted = () => { - if (signal?.aborted) throw new Error('Turnstile challenge aborted'); - }; - checkAborted(); + // Run the widget challenge and return a fresh token. Internal primitive + // wrapped by the token cache's preMintToken / takeToken. + async function mintToken(): Promise { if (!containerEl.value) { throw new Error('Turnstile container element not registered'); } - // Race the script-load step against the caller's abort signal so a - // user-initiated cancel during a slow script load (CDN stall, ad - // blocker, network blip) doesn't have to wait for the surrounding 30s - // pack timeout. The signal is also re-checked before listener setup - // below to cover the race where the abort fired during the await. - const widgetPromise = ensureWidget(containerEl.value); - const turnstile = signal - ? await Promise.race([ - widgetPromise, - new Promise((_, reject) => { - const onPreAbort = () => reject(new Error('Turnstile challenge aborted')); - if (signal.aborted) onPreAbort(); - else signal.addEventListener('abort', onPreAbort, { once: true }); - }), - ]) - : await widgetPromise; - checkAborted(); - if (!widgetId.value) { + const turnstile = await ensureWidget(containerEl.value); + const renderedWidgetId = widgetId.value; + if (!renderedWidgetId) { throw new Error('Turnstile widget failed to render'); } @@ -184,7 +166,6 @@ export function useTurnstile() { const myGen = ++currentGen; let timeoutId: ReturnType | undefined; - let onAbort: (() => void) | undefined; const tokenPromise = new Promise((resolve, reject) => { // Wrap in gen-checked closures so a delayed widget callback can't @@ -193,7 +174,6 @@ export function useTurnstile() { pendingResolve = (token) => { if (myGen !== currentGen) return; if (timeoutId !== undefined) clearTimeout(timeoutId); - if (onAbort && signal) signal.removeEventListener('abort', onAbort); pendingResolve = null; pendingReject = null; resolve(token); @@ -201,60 +181,39 @@ export function useTurnstile() { pendingReject = (err) => { if (myGen !== currentGen) return; if (timeoutId !== undefined) clearTimeout(timeoutId); - if (onAbort && signal) signal.removeEventListener('abort', onAbort); pendingResolve = null; pendingReject = null; reject(err); }; - // The widget retains its previous token until reset(); explicit reset - // forces a new challenge on every getToken() call. - if (widgetId.value) turnstile.reset(widgetId.value); - if (widgetId.value) turnstile.execute(widgetId.value); + // 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); }); - if (signal) { - onAbort = () => { - if (pendingReject) pendingReject(new Error('Turnstile challenge aborted')); - }; - signal.addEventListener('abort', onAbort, { once: true }); - } - - // Bounded race against a hung widget. The gen check ensures a stale timer - // from a previous call (whose tokenPromise already resolved) cannot clear - // the current request's handlers. const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { if (myGen !== currentGen) return; - if (onAbort && signal) signal.removeEventListener('abort', onAbort); pendingResolve = null; pendingReject = null; reject(new Error('Turnstile challenge timed out')); - }, GET_TOKEN_TIMEOUT_MS); + }, MINT_TIMEOUT_MS); }); return Promise.race([tokenPromise, timeoutPromise]); } + const cache = createTurnstileTokenCache(mintToken); + resetCache = cache.reset; + function setContainer(el: HTMLElement | null) { containerEl.value = el; - // Pre-warm scope: ONLY load the Turnstile script, do NOT render the - // widget here. Production telemetry showed that calling - // `turnstile.render()` at form-mount time inflated the Cloudflare - // dashboard's "challenge issued / solved" counters far beyond the GA - // pack_start volume — every visitor (humans, crawlers, ad-blocker - // failures) was being counted into Turnstile analytics even though the - // widget was configured with `execution: 'execute'`. The docs say - // render() shouldn't fire a challenge in that mode, but the analytics - // disagree, so we no longer trust render() to be side-effect free at - // mount time. Render is now deferred to the first `getToken()` call. - // - // Errors are intentionally swallowed: a failed pre-warm doesn't block - // page rendering, and the same `loadTurnstileScript` path will retry - // (with full error propagation) when `getToken()` is eventually called. - if (el) { - loadTurnstileScript().catch(() => { - // pre-warm failures surface on the actual submit path - }); - } + // 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(() => { @@ -264,14 +223,14 @@ export function useTurnstile() { // 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 getToken() promise so the awaiting caller doesn't - // hang forever after the form unmounts (e.g. user navigates away mid- - // challenge). + // 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; @@ -280,7 +239,7 @@ export function useTurnstile() { return { setContainer, - getToken, - error, + preMintToken: cache.preMintToken, + takeToken: cache.takeToken, }; } diff --git a/website/client/composables/useTurnstileScript.ts b/website/client/composables/useTurnstileScript.ts index 33bed5f0..d76236be 100644 --- a/website/client/composables/useTurnstileScript.ts +++ b/website/client/composables/useTurnstileScript.ts @@ -14,7 +14,6 @@ export interface TurnstileGlobal { execute: (widgetId: string) => void; reset: (widgetId: string) => void; remove: (widgetId: string) => void; - getResponse: (widgetId: string) => string | undefined; } export interface TurnstileRenderOptions { @@ -31,9 +30,10 @@ export interface TurnstileRenderOptions { // NOTE: production telemetry (PR #1539 → #1541) showed that even with // `execution: 'execute'` the dashboard still counts every render() call // toward "challenges issued / solved", contradicting the public docs. The - // useTurnstile composable now defers render() to the first getToken() - // call instead of pre-warming at form mount, which is the only reliable - // way to keep the dashboard counters aligned with real submissions. + // useTurnstile composable now defers render() to the first takeToken() / + // preMintToken() call instead of pre-warming at form mount, which is the + // only reliable way to keep the dashboard counters aligned with real + // submissions. execution?: 'render' | 'execute'; callback?: (token: string) => void; 'error-callback'?: (errorCode: string) => void; @@ -58,7 +58,7 @@ export function loadTurnstileScript(): Promise { // Reset state on rejection so a transient CDN failure (ad blocker, network // blip) doesn't permanently lock the page out of Turnstile. Without this, // the rejected promise would be cached forever and every subsequent - // getToken() call would inherit the same stale rejection. + // takeToken() call would inherit the same stale rejection. // // Belt-and-suspenders: also drop the global onload callback so a late- // arriving script load (e.g. extension interference resolving after diff --git a/website/client/composables/useTurnstileTokenCache.ts b/website/client/composables/useTurnstileTokenCache.ts new file mode 100644 index 00000000..ba985cfd --- /dev/null +++ b/website/client/composables/useTurnstileTokenCache.ts @@ -0,0 +1,132 @@ +// Token cache for Cloudflare Turnstile. Decoupled from widget lifecycle so +// useTurnstile.ts stays focused on script loading / widget rendering / abort +// propagation. +// +// Responsibilities: +// - Stash a freshly minted token between preMintToken() and the next +// takeToken() so the click path skips the challenge round-trip. +// - Single-flight the mint: a debounced pre-mint that fires while a click +// is already in flight must NOT call mint() twice on the same widget +// (the supersede logic in mintToken would otherwise reject the older +// call and surface "Verification failed" on a perfectly valid challenge). +// - Make takeToken's cache claim atomic so two concurrent callers awaiting +// the same shared mint promise can't both walk away with the same +// one-shot token (siteverify would reject the second as +// `timeout-or-duplicate`). + +// Cached tokens are treated as expired before Cloudflare's hard 300s ceiling, +// to leave a safety margin for clock skew and network round-trips. A user +// who starts a pack just inside the window won't get a `timeout-or-duplicate` +// from siteverify because they were 1 second from the cliff. +const TOKEN_TTL_MS = 240_000; + +interface CachedToken { + token: string; + mintedAt: number; +} + +export interface TurnstileTokenCache { + preMintToken(): Promise; + takeToken(signal?: AbortSignal): Promise; + reset(): void; +} + +export function createTurnstileTokenCache(mint: () => Promise): TurnstileTokenCache { + let cachedToken: CachedToken | null = null; + let mintPromise: Promise | null = null; + + function isExpired(entry: CachedToken): boolean { + return Date.now() - entry.mintedAt > TOKEN_TTL_MS; + } + + // Single in-flight mint. The signal is intentionally NOT threaded through + // — pre-mint is unaware of any submit lifecycle. takeToken() races the + // shared promise against the caller's signal so a click-then-cancel + // unblocks the awaiter without aborting the underlying mint, leaving + // the resolved token in the cache for the next submit. + function startMint(): Promise { + if (mintPromise) return mintPromise; + mintPromise = mint() + .then((token) => { + cachedToken = { token, mintedAt: Date.now() }; + return token; + }) + .catch((err) => { + // Don't cache failures — let the next takeToken/preMintToken retry. + cachedToken = null; + throw err; + }) + .finally(() => { + mintPromise = null; + }); + // Swallow rejections at the boundary so an unawaited preMintToken() (the + // common case) doesn't trigger an unhandled rejection in the console; + // errors surface on the actual submit path via takeToken. + mintPromise.catch(() => {}); + return mintPromise; + } + + function preMintToken(): Promise { + if (cachedToken && !isExpired(cachedToken)) { + return Promise.resolve(cachedToken.token); + } + return startMint(); + } + + // Tokens are 1-shot, so claim the cache atomically (synchronous read + + // null-out before any await). The shared mint's resolution value is + // intentionally ignored — two concurrent callers awaiting the same + // promise would otherwise both receive the same token. If a concurrent + // caller already drained the cache, loop and start a fresh mint instead + // of returning a duplicate that siteverify would reject with + // `timeout-or-duplicate`. + async function takeToken(signal?: AbortSignal): Promise { + while (true) { + if (cachedToken && !isExpired(cachedToken)) { + const token = cachedToken.token; + cachedToken = null; + return token; + } + const sharedMint = startMint(); + await waitWithAbort(sharedMint, signal); + // Loop back: the mint resolved into the cache via startMint's `.then`, + // but a concurrent takeToken may have claimed it first. The cache + // check at the top of the loop is the single source of truth for + // whether we got the token or need to mint another one. + } + } + + // Drop any cached token. Called from useTurnstile on widget + // `expired-callback` (so the next take re-mints) and on unmount. + // mintPromise stays — if a mint is currently running, its resolution + // will populate the new cache; we just lost the previous unused token. + function reset(): void { + cachedToken = null; + } + + return { preMintToken, takeToken, reset }; +} + +// Race a promise against an AbortSignal. Used by takeToken so a user- +// initiated cancel unblocks the await without cancelling the shared +// mint behind it (which may still cache its token for the next submit). +function waitWithAbort(promise: Promise, signal: AbortSignal | undefined): Promise { + if (!signal) return promise; + if (signal.aborted) { + return Promise.reject(new Error('Turnstile challenge aborted')); + } + return new Promise((resolve, reject) => { + const onAbort = () => reject(new Error('Turnstile challenge aborted')); + signal.addEventListener('abort', onAbort, { once: true }); + promise.then( + (value) => { + signal.removeEventListener('abort', onAbort); + resolve(value); + }, + (err) => { + signal.removeEventListener('abort', onAbort); + reject(err); + }, + ); + }); +}