@usequark/quark-create-app 1.27.1 → 1.27.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/templates/base-project/apps/web/.env.example +20 -2
- package/templates/base-project/apps/web/next.config.js +10 -5
- package/templates/base-project/apps/web/src/app/_components/UmamiReplayRecorder.js +3 -3
- package/templates/base-project/apps/web/src/app/_components/UmamiWebVitals.js +2 -2
- package/templates/base-project/apps/web/src/app/layout.js +11 -15
- package/templates/base-project/apps/web/src/app/s/api/[endpoint]/route.js +328 -0
- package/templates/base-project/apps/web/src/app/s/script.js/route.js +128 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-before-send.js +7 -2
- package/templates/base-project/apps/web/src/lib/analytics/umami-config.js +101 -11
- package/templates/base-project/apps/web/src/lib/analytics/umami-csp.js +2 -1
- package/templates/base-project/apps/web/src/lib/analytics/umami-replay.js +172 -28
- package/templates/base-project/apps/web/src/lib/analytics/umami-web-vitals.js +30 -13
- package/templates/base-project/apps/web/src/lib/analytics/umami.js +7 -4
- package/templates/base-project/apps/web/src/lib/proxy-auth.js +63 -0
- package/templates/base-project/apps/web/src/proxy.js +10 -38
- package/templates/config/src/validate-env.js +25 -6
|
@@ -48,6 +48,61 @@ export function getUmamiScriptUrl(value) {
|
|
|
48
48
|
return `${normalizedUrl}/script.js`;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
// ─── First-party proxy ───────────────────────────────────────────────────────
|
|
52
|
+
// The tracker, web-vitals beacons, and replay batches are served through
|
|
53
|
+
// same-origin URLs so blocklists never see the upstream analytics host.
|
|
54
|
+
// Base `/s` carries no `umami|analytics|track|stats|collect|send` token;
|
|
55
|
+
// the `/api/*` suffix under it preserves the tracker's `data-host-url`
|
|
56
|
+
// convention (`data-host-url="/s"` → POST `/s/api/send` with zero forks).
|
|
57
|
+
export const UMAMI_PROXY_BASE = "/s";
|
|
58
|
+
export const UMAMI_PROXY_SCRIPT_PATH = "/s/script.js";
|
|
59
|
+
export const UMAMI_PROXY_HOST_URL = "/s";
|
|
60
|
+
export const UMAMI_PROXY_SEND_PATH = "/s/api/send";
|
|
61
|
+
// Legacy alias for pre-v2 Umami (whose ingest lived at /api/collect). Nothing
|
|
62
|
+
// in the scaffold posts here anymore — web-vitals uses the send schema — but
|
|
63
|
+
// the pinned path costs nothing to keep forwarding. Do not remove without
|
|
64
|
+
// checking supported upstream versions.
|
|
65
|
+
export const UMAMI_PROXY_COLLECT_PATH = "/s/api/collect";
|
|
66
|
+
export const UMAMI_PROXY_RECORD_PATH = "/s/api/record";
|
|
67
|
+
|
|
68
|
+
// Server-side upstream preference: an operator that wants the analytics host
|
|
69
|
+
// out of the client bundle entirely sets UMAMI_UPSTREAM_URL (or UMAMI_URL)
|
|
70
|
+
// and the rewrite/handler destination follows it. NEXT_PUBLIC_UMAMI_URL
|
|
71
|
+
// remains the default so existing deployments keep working with no new vars.
|
|
72
|
+
export function getUmamiUpstreamBase(env = process.env) {
|
|
73
|
+
const candidates = [
|
|
74
|
+
env.UMAMI_UPSTREAM_URL,
|
|
75
|
+
env.UMAMI_URL,
|
|
76
|
+
env.NEXT_PUBLIC_UMAMI_URL,
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
for (const candidate of candidates) {
|
|
80
|
+
const normalized = normalizeUmamiUrl(candidate);
|
|
81
|
+
if (normalized) return normalized;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return "";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Default env for browser-safe readers. Each NEXT_PUBLIC_* access MUST stay
|
|
88
|
+
// a literal member expression: Next.js statically inlines those literals in
|
|
89
|
+
// client bundles, where bare `process` does not exist — a whole-`process.env`
|
|
90
|
+
// default throws ReferenceError in every browser, which previously killed
|
|
91
|
+
// track(), web-vitals, and replay client-side. Do not refactor this into a
|
|
92
|
+
// `process.env` passthrough. Exported for tests (the no-process fallback).
|
|
93
|
+
export function readPublicEnv() {
|
|
94
|
+
try {
|
|
95
|
+
return {
|
|
96
|
+
NEXT_PUBLIC_UMAMI_URL: process.env.NEXT_PUBLIC_UMAMI_URL,
|
|
97
|
+
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
|
98
|
+
NEXT_PUBLIC_UMAMI_REPLAY_ENABLED:
|
|
99
|
+
process.env.NEXT_PUBLIC_UMAMI_REPLAY_ENABLED,
|
|
100
|
+
};
|
|
101
|
+
} catch {
|
|
102
|
+
return {};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
51
106
|
export function parseUmamiBooleanFlag(value) {
|
|
52
107
|
if (typeof value !== "string") return false;
|
|
53
108
|
return TRUE_VALUES.has(value.trim().toLowerCase());
|
|
@@ -59,7 +114,7 @@ export function isUmamiWebsiteId(value) {
|
|
|
59
114
|
}
|
|
60
115
|
|
|
61
116
|
export function getUmamiConfig(env = process.env) {
|
|
62
|
-
const url =
|
|
117
|
+
const url = getUmamiUpstreamBase(env);
|
|
63
118
|
const rawWebsiteId =
|
|
64
119
|
typeof env.NEXT_PUBLIC_UMAMI_WEBSITE_ID === "string"
|
|
65
120
|
? env.NEXT_PUBLIC_UMAMI_WEBSITE_ID.trim()
|
|
@@ -76,20 +131,55 @@ export function getUmamiConfig(env = process.env) {
|
|
|
76
131
|
enabled,
|
|
77
132
|
replayEnabled:
|
|
78
133
|
enabled && parseUmamiBooleanFlag(env.NEXT_PUBLIC_UMAMI_REPLAY_ENABLED),
|
|
134
|
+
// First-party proxy URLs. Empty when analytics is disabled so
|
|
135
|
+
// callers can gate on `enabled` alone.
|
|
136
|
+
proxyBase: enabled ? UMAMI_PROXY_BASE : "",
|
|
137
|
+
proxyScriptUrl: enabled ? UMAMI_PROXY_SCRIPT_PATH : "",
|
|
138
|
+
proxyHostUrl: enabled ? UMAMI_PROXY_HOST_URL : "",
|
|
139
|
+
proxySendUrl: enabled ? UMAMI_PROXY_SEND_PATH : "",
|
|
140
|
+
proxyCollectUrl: enabled ? UMAMI_PROXY_COLLECT_PATH : "",
|
|
141
|
+
proxyRecordUrl: enabled ? UMAMI_PROXY_RECORD_PATH : "",
|
|
79
142
|
};
|
|
80
143
|
}
|
|
81
144
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
145
|
+
// Client-safe config. Contains no upstream URL, origin, or script URL — only
|
|
146
|
+
// the website ID, gates, and static first-party proxy paths. Browser code
|
|
147
|
+
// must use this (never getUmamiConfig) so the analytics host cannot end up
|
|
148
|
+
// in client bundles. `enabled` needs only a valid website ID because every
|
|
149
|
+
// client surface is same-origin; the server layout keeps the upstream-aware
|
|
150
|
+
// gate so a missing upstream never emits a 404 script tag.
|
|
151
|
+
//
|
|
152
|
+
// The default env is readPublicEnv(), not process.env: bare `process` does
|
|
153
|
+
// not exist in browsers, so a process.env default throws on every no-arg
|
|
154
|
+
// browser call.
|
|
155
|
+
export function getUmamiClientConfig(env = readPublicEnv()) {
|
|
156
|
+
const rawWebsiteId =
|
|
157
|
+
typeof env.NEXT_PUBLIC_UMAMI_WEBSITE_ID === "string"
|
|
158
|
+
? env.NEXT_PUBLIC_UMAMI_WEBSITE_ID.trim()
|
|
159
|
+
: "";
|
|
160
|
+
const websiteId = isUmamiWebsiteId(rawWebsiteId) ? rawWebsiteId : "";
|
|
161
|
+
const enabled = Boolean(websiteId);
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
websiteId,
|
|
165
|
+
enabled,
|
|
166
|
+
replayEnabled:
|
|
167
|
+
enabled && parseUmamiBooleanFlag(env.NEXT_PUBLIC_UMAMI_REPLAY_ENABLED),
|
|
168
|
+
proxyBase: enabled ? UMAMI_PROXY_BASE : "",
|
|
169
|
+
proxyScriptUrl: enabled ? UMAMI_PROXY_SCRIPT_PATH : "",
|
|
170
|
+
proxyHostUrl: enabled ? UMAMI_PROXY_HOST_URL : "",
|
|
171
|
+
proxySendUrl: enabled ? UMAMI_PROXY_SEND_PATH : "",
|
|
172
|
+
proxyCollectUrl: enabled ? UMAMI_PROXY_COLLECT_PATH : "",
|
|
173
|
+
proxyRecordUrl: enabled ? UMAMI_PROXY_RECORD_PATH : "",
|
|
174
|
+
};
|
|
175
|
+
}
|
|
90
176
|
|
|
177
|
+
export function getUmamiCspOrigins() {
|
|
178
|
+
// Proxy mode is the default path when analytics is enabled: tracker and
|
|
179
|
+
// beacons are same-origin, so CSP stays `'self'`-only and never names
|
|
180
|
+
// the upstream analytics host.
|
|
91
181
|
return {
|
|
92
|
-
scriptSrc: [
|
|
93
|
-
connectSrc: [
|
|
182
|
+
scriptSrc: [],
|
|
183
|
+
connectSrc: [],
|
|
94
184
|
};
|
|
95
185
|
}
|
|
@@ -2,8 +2,9 @@ import { getUmamiCspOrigins } from "./umami-config.js";
|
|
|
2
2
|
|
|
3
3
|
export function buildContentSecurityPolicy(env = process.env) {
|
|
4
4
|
const isProduction = env.NODE_ENV === "production";
|
|
5
|
+
// Same-origin proxy: analytics needs no allowlist entries beyond 'self'.
|
|
5
6
|
const { connectSrc: umamiConnectSrc, scriptSrc: umamiScriptSrc } =
|
|
6
|
-
getUmamiCspOrigins(
|
|
7
|
+
getUmamiCspOrigins();
|
|
7
8
|
|
|
8
9
|
const scriptSrc = [
|
|
9
10
|
"script-src",
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { createLogger } from "@usequark/quark-core/logger";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
getUmamiClientConfig,
|
|
4
|
+
normalizeUmamiUrl,
|
|
5
|
+
readPublicEnv,
|
|
6
|
+
} from "./umami-config.js";
|
|
3
7
|
|
|
4
8
|
const log = createLogger("umami-replay");
|
|
5
9
|
|
|
@@ -18,6 +22,12 @@ export const UMAMI_REPLAY_DEFAULTS = Object.freeze({
|
|
|
18
22
|
blockSelector: "[data-umami-block]",
|
|
19
23
|
});
|
|
20
24
|
|
|
25
|
+
// Max serialized bytes per replay POST. Stays under the first-party proxy's
|
|
26
|
+
// 1 MB cap with headroom for headers and JSON overhead, so a large DOM
|
|
27
|
+
// snapshot cannot turn into a 413-restore-retry loop that grows the buffer
|
|
28
|
+
// forever (each 413 restores the whole batch alongside new events).
|
|
29
|
+
export const MAX_REPLAY_REQUEST_BYTES = 900_000;
|
|
30
|
+
|
|
21
31
|
export function shouldSampleReplay(
|
|
22
32
|
randomValue = Math.random(),
|
|
23
33
|
sampleRate = UMAMI_REPLAY_DEFAULTS.sampleRate,
|
|
@@ -67,9 +77,17 @@ export function createReplayBuffer() {
|
|
|
67
77
|
};
|
|
68
78
|
}
|
|
69
79
|
|
|
70
|
-
export function buildReplayEndpoint(env =
|
|
71
|
-
|
|
72
|
-
|
|
80
|
+
export function buildReplayEndpoint(env = readPublicEnv()) {
|
|
81
|
+
// First-party ingest is the default: replay batches POST same-origin so
|
|
82
|
+
// blocklists never see the upstream analytics host.
|
|
83
|
+
if (typeof env !== "string") {
|
|
84
|
+
const config = getUmamiClientConfig(env);
|
|
85
|
+
return config.enabled ? config.proxyRecordUrl : "";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Explicit upstream URL string (legacy direct mode, e.g. server-side
|
|
89
|
+
// callers that already resolved the upstream base).
|
|
90
|
+
const url = normalizeUmamiUrl(env);
|
|
73
91
|
|
|
74
92
|
return url ? `${url}/api/record` : "";
|
|
75
93
|
}
|
|
@@ -144,28 +162,22 @@ export function buildReplayBody({ websiteId, events, timestamp }) {
|
|
|
144
162
|
});
|
|
145
163
|
}
|
|
146
164
|
|
|
147
|
-
|
|
165
|
+
function replayBodyBytes(body) {
|
|
166
|
+
if (typeof TextEncoder !== "undefined") {
|
|
167
|
+
return new TextEncoder().encode(body).length;
|
|
168
|
+
}
|
|
169
|
+
return body.length;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function postReplayChunk({
|
|
148
173
|
endpoint,
|
|
149
|
-
|
|
174
|
+
sessionCache,
|
|
150
175
|
websiteId,
|
|
151
176
|
events,
|
|
152
|
-
timestamp
|
|
153
|
-
useKeepalive
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
} = {}) {
|
|
157
|
-
const sessionCache = resolveUmamiSessionCache({ cache, getUmami });
|
|
158
|
-
|
|
159
|
-
if (
|
|
160
|
-
!endpoint ||
|
|
161
|
-
!sessionCache ||
|
|
162
|
-
!websiteId ||
|
|
163
|
-
!events?.length ||
|
|
164
|
-
!fetchImpl
|
|
165
|
-
) {
|
|
166
|
-
return false;
|
|
167
|
-
}
|
|
168
|
-
|
|
177
|
+
timestamp,
|
|
178
|
+
useKeepalive,
|
|
179
|
+
fetchImpl,
|
|
180
|
+
}) {
|
|
169
181
|
const body = buildReplayBody({ websiteId, events, timestamp });
|
|
170
182
|
const keepalive = useKeepalive && body.length < 60000;
|
|
171
183
|
|
|
@@ -181,11 +193,24 @@ export async function postReplayBatch({
|
|
|
181
193
|
credentials: "omit",
|
|
182
194
|
});
|
|
183
195
|
|
|
184
|
-
if (!response
|
|
196
|
+
if (!response) return "retry";
|
|
197
|
+
|
|
198
|
+
// A 413 means the bytes exceed the proxy's cap. Multi-event chunks
|
|
199
|
+
// split and retry halves; a single event that 413s can never be
|
|
200
|
+
// delivered, so drop it with a warning instead of restoring it into
|
|
201
|
+
// an infinite retry loop.
|
|
202
|
+
if (response.status === 413) {
|
|
203
|
+
if (events.length > 1) return "split";
|
|
204
|
+
log.warn("dropping replay event rejected as oversized (413)");
|
|
205
|
+
return "drop";
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!response.ok) return "retry";
|
|
185
209
|
|
|
186
210
|
// Umami answers 200 with { ok: false, reason } when replay is disabled
|
|
187
|
-
// server-side
|
|
188
|
-
//
|
|
211
|
+
// server-side — and the first-party proxy answers 202 with
|
|
212
|
+
// { ok: false } when the upstream is unavailable. Treat both as a
|
|
213
|
+
// failure so events are restored instead of silently dropped.
|
|
189
214
|
const responseBody =
|
|
190
215
|
typeof response.json === "function"
|
|
191
216
|
? await response.json().catch(() => null)
|
|
@@ -194,11 +219,130 @@ export async function postReplayBatch({
|
|
|
194
219
|
log.warn(
|
|
195
220
|
`batch rejected by server: ${responseBody.reason ?? "unknown reason"}`,
|
|
196
221
|
);
|
|
197
|
-
return
|
|
222
|
+
return "retry";
|
|
198
223
|
}
|
|
199
224
|
|
|
200
|
-
return
|
|
225
|
+
return "sent";
|
|
201
226
|
} catch {
|
|
227
|
+
return "retry";
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function postReplayChunks({
|
|
232
|
+
endpoint,
|
|
233
|
+
sessionCache,
|
|
234
|
+
websiteId,
|
|
235
|
+
events,
|
|
236
|
+
timestamp,
|
|
237
|
+
useKeepalive,
|
|
238
|
+
fetchImpl,
|
|
239
|
+
}) {
|
|
240
|
+
if (!events.length) return true;
|
|
241
|
+
|
|
242
|
+
const probe = buildReplayBody({ websiteId, events, timestamp });
|
|
243
|
+
if (replayBodyBytes(probe) <= MAX_REPLAY_REQUEST_BYTES) {
|
|
244
|
+
const outcome = await postReplayChunk({
|
|
245
|
+
endpoint,
|
|
246
|
+
sessionCache,
|
|
247
|
+
websiteId,
|
|
248
|
+
events,
|
|
249
|
+
timestamp,
|
|
250
|
+
useKeepalive,
|
|
251
|
+
fetchImpl,
|
|
252
|
+
});
|
|
253
|
+
if (outcome === "sent" || outcome === "drop") return true;
|
|
254
|
+
if (outcome === "split") {
|
|
255
|
+
const mid = Math.max(1, Math.ceil(events.length / 2));
|
|
256
|
+
return (
|
|
257
|
+
(await postReplayChunks({
|
|
258
|
+
endpoint,
|
|
259
|
+
sessionCache,
|
|
260
|
+
websiteId,
|
|
261
|
+
events: events.slice(0, mid),
|
|
262
|
+
timestamp,
|
|
263
|
+
useKeepalive,
|
|
264
|
+
fetchImpl,
|
|
265
|
+
})) &&
|
|
266
|
+
(await postReplayChunks({
|
|
267
|
+
endpoint,
|
|
268
|
+
sessionCache,
|
|
269
|
+
websiteId,
|
|
270
|
+
events: events.slice(mid),
|
|
271
|
+
timestamp,
|
|
272
|
+
useKeepalive,
|
|
273
|
+
fetchImpl,
|
|
274
|
+
}))
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Oversized batch: split before sending. A single event that alone
|
|
281
|
+
// exceeds the cap can never be delivered, so drop it with a warning
|
|
282
|
+
// instead of restoring it into an infinite 413 loop.
|
|
283
|
+
if (events.length === 1) {
|
|
284
|
+
log.warn(
|
|
285
|
+
`dropping oversized replay event (${replayBodyBytes(probe)} bytes exceeds cap)`,
|
|
286
|
+
);
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const mid = Math.max(1, Math.ceil(events.length / 2));
|
|
291
|
+
// Sequential halves: on partial failure the caller restores the whole
|
|
292
|
+
// batch, so an already-sent half may duplicate on retry. Accepted as
|
|
293
|
+
// rare best-effort duplication (upstream outage mid-oversized-batch);
|
|
294
|
+
// the alternative is silent loss.
|
|
295
|
+
return (
|
|
296
|
+
(await postReplayChunks({
|
|
297
|
+
endpoint,
|
|
298
|
+
sessionCache,
|
|
299
|
+
websiteId,
|
|
300
|
+
events: events.slice(0, mid),
|
|
301
|
+
timestamp,
|
|
302
|
+
useKeepalive,
|
|
303
|
+
fetchImpl,
|
|
304
|
+
})) &&
|
|
305
|
+
(await postReplayChunks({
|
|
306
|
+
endpoint,
|
|
307
|
+
sessionCache,
|
|
308
|
+
websiteId,
|
|
309
|
+
events: events.slice(mid),
|
|
310
|
+
timestamp,
|
|
311
|
+
useKeepalive,
|
|
312
|
+
fetchImpl,
|
|
313
|
+
}))
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export async function postReplayBatch({
|
|
318
|
+
endpoint,
|
|
319
|
+
cache,
|
|
320
|
+
websiteId,
|
|
321
|
+
events,
|
|
322
|
+
timestamp = Math.floor(Date.now() / 1000),
|
|
323
|
+
useKeepalive = false,
|
|
324
|
+
getUmami = () => globalThis.window?.umami,
|
|
325
|
+
fetchImpl = globalThis.fetch?.bind(globalThis),
|
|
326
|
+
} = {}) {
|
|
327
|
+
const sessionCache = resolveUmamiSessionCache({ cache, getUmami });
|
|
328
|
+
|
|
329
|
+
if (
|
|
330
|
+
!endpoint ||
|
|
331
|
+
!sessionCache ||
|
|
332
|
+
!websiteId ||
|
|
333
|
+
!events?.length ||
|
|
334
|
+
!fetchImpl
|
|
335
|
+
) {
|
|
202
336
|
return false;
|
|
203
337
|
}
|
|
338
|
+
|
|
339
|
+
return postReplayChunks({
|
|
340
|
+
endpoint,
|
|
341
|
+
sessionCache,
|
|
342
|
+
websiteId,
|
|
343
|
+
events,
|
|
344
|
+
timestamp,
|
|
345
|
+
useKeepalive,
|
|
346
|
+
fetchImpl,
|
|
347
|
+
});
|
|
204
348
|
}
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
* Privacy guarantees:
|
|
10
10
|
* - Zero user data: no email, name, ID, token, or cookie values.
|
|
11
11
|
* - Layout timings only: metric name, numeric value, rating string,
|
|
12
|
-
*
|
|
12
|
+
* a sanitised URL path (origin stripped, never full URL with query),
|
|
13
|
+
* and the bare hostname (as the stock tracker sends; keeps these rows
|
|
14
|
+
* out of the NULL-hostname group in Umami's sessions list).
|
|
13
15
|
* - No referrer, no user-agent, no IP collected by this script.
|
|
14
16
|
* - Uses sendBeacon for reliable delivery without blocking the page.
|
|
15
17
|
*
|
|
@@ -18,11 +20,12 @@
|
|
|
18
20
|
* (e.g. a "use client" wrapper in the root layout).
|
|
19
21
|
*
|
|
20
22
|
* Environment:
|
|
21
|
-
* Controlled by
|
|
23
|
+
* Controlled by NEXT_PUBLIC_UMAMI_WEBSITE_ID (first-party proxy paths are
|
|
24
|
+
* static, so no upstream URL is needed in the browser).
|
|
22
25
|
* When Umami analytics is not configured, the observer is a no-op.
|
|
23
26
|
*/
|
|
24
27
|
|
|
25
|
-
import {
|
|
28
|
+
import { getUmamiClientConfig } from "./umami-config.js";
|
|
26
29
|
|
|
27
30
|
// ── Initialization guard ──────────────────────────────────────
|
|
28
31
|
|
|
@@ -56,40 +59,54 @@ function sanitiseUrl(url) {
|
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
/**
|
|
59
|
-
* Dispatches a single CWV observation to
|
|
60
|
-
*
|
|
62
|
+
* Dispatches a single CWV observation to Umami's send endpoint as a custom
|
|
63
|
+
* event (`name: "cwv"`, metrics in `data`). The payload is fully anonymous.
|
|
64
|
+
*
|
|
65
|
+
* Shape matters: Umami derives the event type server-side (`name` present →
|
|
66
|
+
* custom event) and persists `payload.name`/`payload.data` — `event_name`,
|
|
67
|
+
* `event_type`, and `event_data` keys are ignored, and posting anywhere
|
|
68
|
+
* other than `/api/send` (there is no `/api/collect` in Umami v2+) silently
|
|
69
|
+
* drops the beacon while recording nothing. Both mistakes shipped here
|
|
70
|
+
* before and were caught by reading Umami's send route, not by any test.
|
|
71
|
+
*
|
|
72
|
+
* Exported for tests: the browser-only shape (window/navigator) cannot be
|
|
73
|
+
* covered by observation in unit tests.
|
|
61
74
|
*
|
|
62
75
|
* @param {{ name: string, value: number, rating: string, navigationType?: string }} metric
|
|
63
76
|
*/
|
|
64
|
-
function dispatchMetric(metric) {
|
|
65
|
-
const config =
|
|
77
|
+
export function dispatchMetric(metric) {
|
|
78
|
+
const config = getUmamiClientConfig();
|
|
66
79
|
if (!config.enabled) return;
|
|
67
80
|
|
|
68
81
|
const payload = {
|
|
69
82
|
type: "event",
|
|
70
83
|
payload: {
|
|
71
84
|
website: config.websiteId,
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
url: sanitiseUrl(window.location.href),
|
|
75
|
-
event_data: {
|
|
85
|
+
name: "cwv",
|
|
86
|
+
data: {
|
|
76
87
|
metric_name: metric.name,
|
|
77
88
|
metric_value: Math.round(metric.value * 1000) / 1000, // round to 3dp
|
|
78
89
|
metric_rating: metric.rating,
|
|
79
90
|
navigation_type: metric.navigationType || "navigate",
|
|
80
91
|
},
|
|
92
|
+
url: sanitiseUrl(window.location.href),
|
|
93
|
+
hostname: window.location.hostname,
|
|
81
94
|
},
|
|
82
95
|
};
|
|
83
96
|
|
|
84
97
|
const body = JSON.stringify(payload);
|
|
85
98
|
|
|
99
|
+
// First-party ingest: same-origin POSTs survive blocklists that match the
|
|
100
|
+
// upstream analytics host as third-party.
|
|
101
|
+
const endpoint = config.proxySendUrl || `${config.url}/api/send`;
|
|
102
|
+
|
|
86
103
|
// Use sendBeacon for reliability during page lifecycle transitions.
|
|
87
104
|
if (navigator.sendBeacon) {
|
|
88
105
|
const blob = new Blob([body], { type: "application/json" });
|
|
89
|
-
navigator.sendBeacon(
|
|
106
|
+
navigator.sendBeacon(endpoint, blob);
|
|
90
107
|
} else {
|
|
91
108
|
// Fallback to fetch with keepalive.
|
|
92
|
-
fetch(
|
|
109
|
+
fetch(endpoint, {
|
|
93
110
|
method: "POST",
|
|
94
111
|
body,
|
|
95
112
|
keepalive: true,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getUmamiClientConfig, readPublicEnv } from "./umami-config.js";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Read the Umami user role cookie set by the admin layout.
|
|
@@ -18,12 +18,15 @@ export function isUmamiStaff() {
|
|
|
18
18
|
|
|
19
19
|
function getUmamiClient() {
|
|
20
20
|
if (typeof window === "undefined") return null;
|
|
21
|
-
|
|
21
|
+
// Client config deliberately: the server-only upstream URL is invisible
|
|
22
|
+
// here, so gating on getUmamiConfig would wrongly disable track() and
|
|
23
|
+
// identify() in server-only-upstream deployments.
|
|
24
|
+
if (!getUmamiClientConfig().enabled) return null;
|
|
22
25
|
return window.umami ?? null;
|
|
23
26
|
}
|
|
24
27
|
|
|
25
|
-
export function isUmamiEnabled(env =
|
|
26
|
-
return
|
|
28
|
+
export function isUmamiEnabled(env = readPublicEnv()) {
|
|
29
|
+
return getUmamiClientConfig(env).enabled;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
export function track(name, data) {
|
|
@@ -107,3 +107,66 @@ export function getRateLimitBucket(pathname, method = "GET") {
|
|
|
107
107
|
export function isRateLimitExempt(pathname) {
|
|
108
108
|
return RATE_LIMIT_EXEMPT_ROUTES.has(pathname);
|
|
109
109
|
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Boolean-like values that disable trusting proxy headers. Mirrors the
|
|
113
|
+
* "explicit false wins" convention used by `AUTH_ALLOW_SIGNUP` and
|
|
114
|
+
* `DB_INSTRUMENTATION`, so operators don't have to learn a new dialect.
|
|
115
|
+
*/
|
|
116
|
+
const PROXY_HEADERS_FALSY_VALUES = new Set(["false", "0", "no", "off"]);
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Whether the client-supplied proxy headers (`x-forwarded-for`, `x-real-ip`)
|
|
120
|
+
* may be trusted to identify the client.
|
|
121
|
+
*
|
|
122
|
+
* Default `true`: the shipped behaviour. The app assumes it sits behind a
|
|
123
|
+
* reverse proxy (Railway, Docker, nginx) that overwrites these headers on
|
|
124
|
+
* every request, so the left-most `x-forwarded-for` entry is treated as the
|
|
125
|
+
* client IP.
|
|
126
|
+
*
|
|
127
|
+
* Set `TRUST_PROXY_HEADERS=false` when the app is reachable directly (no
|
|
128
|
+
* proxy rewriting the header on the way in). Callers must then ignore both
|
|
129
|
+
* headers: proxy.js keys every client on the literal "unknown", and the
|
|
130
|
+
* Umami ingest proxy forwards "unknown" instead of attacker bytes. A header
|
|
131
|
+
* a client can set is not an identity.
|
|
132
|
+
*
|
|
133
|
+
* Read lazily (per request) rather than at module load: tests flip it and a
|
|
134
|
+
* value set after import still takes effect. Only explicit false-like values
|
|
135
|
+
* disable trusting; unset, empty, "true", or a typo all keep the default.
|
|
136
|
+
*
|
|
137
|
+
* @returns {boolean}
|
|
138
|
+
*/
|
|
139
|
+
export function shouldTrustProxyHeaders() {
|
|
140
|
+
const raw = process.env.TRUST_PROXY_HEADERS?.trim().toLowerCase();
|
|
141
|
+
return !(raw && PROXY_HEADERS_FALSY_VALUES.has(raw));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Boolean-like values that opt into trusting `CF-Connecting-IP`. Deliberately
|
|
146
|
+
* the mirror of the proxy-headers convention: "explicit true wins", default
|
|
147
|
+
* off. `CF-Connecting-IP` is only overwritten by Cloudflare itself — unlike
|
|
148
|
+
* `X-Forwarded-For`, most proxies (Railway, nginx, Docker) pass it through
|
|
149
|
+
* untouched. Trusting it unconditionally would let any client override an
|
|
150
|
+
* otherwise-legitimate `X-Forwarded-For` with an arbitrary IP, so it stays
|
|
151
|
+
* ignored unless the operator confirms Cloudflare is in the path.
|
|
152
|
+
*/
|
|
153
|
+
const CF_CONNECTING_IP_TRUTHY_VALUES = new Set(["true", "1", "yes", "on"]);
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Whether `CF-Connecting-IP` may identify the client in the Umami ingest
|
|
157
|
+
* proxy (resolution preference and upstream passthrough).
|
|
158
|
+
*
|
|
159
|
+
* Default `false`: the header is ignored entirely. Set
|
|
160
|
+
* `TRUST_CF_CONNECTING_IP=true` only when Cloudflare sits in front of the
|
|
161
|
+
* app (it overwrites the header with the true client IP on every request).
|
|
162
|
+
* Independently of this flag, `TRUST_PROXY_HEADERS=false` still forces
|
|
163
|
+
* `"unknown"` — distrust always wins over trust.
|
|
164
|
+
*
|
|
165
|
+
* Read lazily (per request) like `shouldTrustProxyHeaders`.
|
|
166
|
+
*
|
|
167
|
+
* @returns {boolean}
|
|
168
|
+
*/
|
|
169
|
+
export function shouldTrustCfConnectingIp() {
|
|
170
|
+
const raw = process.env.TRUST_CF_CONNECTING_IP?.trim().toLowerCase();
|
|
171
|
+
return !!raw && CF_CONNECTING_IP_TRUTHY_VALUES.has(raw);
|
|
172
|
+
}
|
|
@@ -14,7 +14,16 @@ import { createLogger, createRateLimiter } from "@usequark/quark-core/core";
|
|
|
14
14
|
import { NextResponse } from "next/server";
|
|
15
15
|
|
|
16
16
|
import { buildContentSecurityPolicy } from "./lib/analytics/umami-csp.js";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
getRateLimitBucket,
|
|
19
|
+
isRateLimitExempt,
|
|
20
|
+
shouldTrustProxyHeaders,
|
|
21
|
+
} from "./lib/proxy-auth";
|
|
22
|
+
|
|
23
|
+
// Re-exported: proxy.test.js and external callers resolve it from here.
|
|
24
|
+
// The definition lives in lib/proxy-auth.js, shared with the Umami ingest
|
|
25
|
+
// proxy so both honor TRUST_PROXY_HEADERS identically.
|
|
26
|
+
export { shouldTrustProxyHeaders };
|
|
18
27
|
|
|
19
28
|
const logger = createLogger("proxy");
|
|
20
29
|
|
|
@@ -93,43 +102,6 @@ export async function getRateLimiter() {
|
|
|
93
102
|
return rateLimiterPromise;
|
|
94
103
|
}
|
|
95
104
|
|
|
96
|
-
/**
|
|
97
|
-
* Boolean-like values that disable trusting proxy headers. Mirrors the
|
|
98
|
-
* "explicit false wins" convention used by `AUTH_ALLOW_SIGNUP` and
|
|
99
|
-
* `DB_INSTRUMENTATION`, so operators don't have to learn a new dialect.
|
|
100
|
-
*/
|
|
101
|
-
const PROXY_HEADERS_FALSY_VALUES = new Set(["false", "0", "no", "off"]);
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Whether the client-supplied proxy headers (`x-forwarded-for`, `x-real-ip`)
|
|
105
|
-
* may be trusted to identify the client.
|
|
106
|
-
*
|
|
107
|
-
* Default `true`: the shipped behaviour. The app assumes it sits behind a
|
|
108
|
-
* reverse proxy (Railway, Docker, nginx) that overwrites these headers on
|
|
109
|
-
* every request, so the left-most `x-forwarded-for` entry is treated as the
|
|
110
|
-
* client IP.
|
|
111
|
-
*
|
|
112
|
-
* Set `TRUST_PROXY_HEADERS=false` when the app is reachable directly (no
|
|
113
|
-
* proxy rewriting the header on the way in). The headers are then ignored and
|
|
114
|
-
* every client keys on the literal "unknown". Because the bucket key is
|
|
115
|
-
* `${ip}:${path}`, that means one bucket shared by every client of a route —
|
|
116
|
-
* not one bucket across all routes. A header a client can set is not an
|
|
117
|
-
* identity, so this is what stops a spoofed `x-forwarded-for` from buying a
|
|
118
|
-
* fresh bucket per request. The trade-off is the reverse of the default: one
|
|
119
|
-
* client can now exhaust the shared bucket for the route it floods, which is
|
|
120
|
-
* why the default keeps trusting the headers.
|
|
121
|
-
*
|
|
122
|
-
* Read lazily (per request) rather than at module load: tests flip it and a
|
|
123
|
-
* value set after import still takes effect. Only explicit false-like values
|
|
124
|
-
* disable trusting; unset, empty, "true", or a typo all keep the default.
|
|
125
|
-
*
|
|
126
|
-
* @returns {boolean}
|
|
127
|
-
*/
|
|
128
|
-
export function shouldTrustProxyHeaders() {
|
|
129
|
-
const raw = process.env.TRUST_PROXY_HEADERS?.trim().toLowerCase();
|
|
130
|
-
return !(raw && PROXY_HEADERS_FALSY_VALUES.has(raw));
|
|
131
|
-
}
|
|
132
|
-
|
|
133
105
|
/**
|
|
134
106
|
* Resolve the client IP for rate-limit keying.
|
|
135
107
|
*
|