forked from yamadashy/repomix
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathuseTurnstile.ts
More file actions
244 lines (227 loc) · 10.6 KB
/
Copy pathuseTurnstile.ts
File metadata and controls
244 lines (227 loc) · 10.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
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,
};
}