diff --git a/website/client/composables/usePackRequest.ts b/website/client/composables/usePackRequest.ts index 613f0823..ba8b5b51 100644 --- a/website/client/composables/usePackRequest.ts +++ b/website/client/composables/usePackRequest.ts @@ -32,10 +32,16 @@ export function usePackRequest() { 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. + // gate the Turnstile pre-mint so URL-parameter hydration (`?repo=...`) + // and form restoration don't trigger background challenges. Set-only — + // once true, it stays true for the session. + // + // Caveat: modern Chromium / Firefox DO fire `input` events on browser + // autofill, which would flip this flag through TryItUrlInput's handler + // and trigger a wasted pre-mint for JS-executing crawlers that have + // autofill-like behaviour. The `isBot()` check at the pre-mint trigger + // sites covers that gap for well-behaved crawler UAs; sophisticated + // bots that spoof UA still get filtered server-side by siteverify. const userTouched = ref(false); // Request states @@ -93,12 +99,15 @@ export function usePackRequest() { // Skip background pre-mint for known crawlers. These visitors can't // solve the Turnstile challenge anyway (the JS challenge requires // real browser fingerprints), so issuing one only inflates the CF - // dashboard "提示チャレンジ" / "未解決" counters without producing a - // usable token. The actual security gate is the server-side - // siteverify in turnstileMiddleware — that stays unchanged, so a - // crawler that spoofs UA past `isBot()` still gets blocked there. - // Submit-path takeToken is intentionally NOT gated to avoid - // false-positive lockouts of legit users with unusual UAs. + // dashboard "提示チャレンジ" (issued challenges) / "未解決" + // (unsolved) counters without producing a usable token. The actual + // security gate is the server-side siteverify in + // turnstileMiddleware — that stays unchanged, so a crawler that + // spoofs UA past `isBot()` still gets blocked there. The click-path + // `acquireTurnstileToken()` (cold-mint at submit time) is + // intentionally NOT gated to avoid false-positive lockouts of legit + // users with unusual UAs; only the warm-up paths short-circuit here + // and at the post-submit re-mint below. if (isBot()) return; turnstile.preMintToken().catch(() => { /* errors surface on the actual submit path */ diff --git a/website/client/utils/botDetect.ts b/website/client/utils/botDetect.ts index 0a5a1c1c..12f17975 100644 --- a/website/client/utils/botDetect.ts +++ b/website/client/utils/botDetect.ts @@ -1,5 +1,14 @@ import { isbot } from 'isbot'; +// Cache the per-session result. `navigator.userAgent` is immutable for the +// life of the page, and `isbot()` runs a non-trivial regex match — caching +// avoids re-parsing the UA on every Turnstile pre-mint debounce or +// post-submit re-mint check. The SSR fallback (`navigator === undefined`) +// is intentionally NOT cached so that if the same module instance is +// somehow reused between SSR and CSR, the CSR path still reaches the real +// UA check on first call. +let cached: boolean | undefined; + /** * Detects whether the current user agent is a bot/crawler. * Used to prevent automatic API calls when bots render pages with JavaScript. @@ -8,5 +17,8 @@ export function isBot(): boolean { if (typeof navigator === 'undefined') { return false; } - return isbot(navigator.userAgent); + if (cached === undefined) { + cached = isbot(navigator.userAgent); + } + return cached; }