@usequark/quark-create-app 1.27.0 → 1.27.2
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 +8 -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 +286 -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 +97 -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 +10 -5
- package/templates/base-project/apps/web/src/lib/analytics/umami.js +7 -4
- package/templates/base-project/apps/web/src/lib/auth.js +7 -0
- package/templates/base-project/apps/web/src/lib/proxy-auth.js +33 -0
- package/templates/base-project/apps/web/src/proxy.js +10 -38
- package/templates/base-project/packages/db/src/queries.js +6 -0
- package/templates/config/src/validate-env.js +25 -6
package/package.json
CHANGED
|
@@ -61,15 +61,21 @@ NEXTAUTH_SECRET=replace_with_a_long_random_secret
|
|
|
61
61
|
# Staging and preview environments should leave this unset so they are not indexed.
|
|
62
62
|
# ALLOW_INDEXING=true
|
|
63
63
|
|
|
64
|
-
# --- Optional: Umami Analytics ---
|
|
64
|
+
# --- Optional: Umami Analytics (first-party proxy) ---
|
|
65
65
|
# Leave all three unset to disable analytics cleanly.
|
|
66
|
-
# Set URL + website ID to load
|
|
66
|
+
# Set URL + website ID to load the tracker from the first-party proxy
|
|
67
|
+
# (GET /s/script.js) in the root layout head. Beacons and replay batches
|
|
68
|
+
# POST same-origin to /s/api/send, /s/api/collect, and /s/api/record, so
|
|
69
|
+
# blocklists never see the upstream host and CSP stays 'self'-only.
|
|
67
70
|
# Replay stays off unless NEXT_PUBLIC_UMAMI_REPLAY_ENABLED=true.
|
|
68
71
|
# Umami Links and Pixels are dashboard-generated URLs, so the scaffold does not add extra env vars for them.
|
|
69
72
|
# Use tracked links in campaigns/emails and pixel URLs only in HTML emails or external pages.
|
|
70
73
|
# NEXT_PUBLIC_UMAMI_URL=https://stats.example.com
|
|
71
74
|
# NEXT_PUBLIC_UMAMI_WEBSITE_ID=00000000-0000-0000-0000-000000000000
|
|
72
75
|
# NEXT_PUBLIC_UMAMI_REPLAY_ENABLED=false
|
|
76
|
+
# Optional server-only override for the proxy destination (takes precedence
|
|
77
|
+
# over NEXT_PUBLIC_UMAMI_URL; never exposed to the browser):
|
|
78
|
+
# UMAMI_UPSTREAM_URL=https://stats.example.com
|
|
73
79
|
|
|
74
80
|
# --- Web App Configuration ---
|
|
75
81
|
PORT=3005
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { hostname, networkInterfaces } from "node:os";
|
|
2
|
-
|
|
3
2
|
import { buildContentSecurityPolicy } from "./src/lib/analytics/umami-csp.js";
|
|
4
3
|
|
|
5
4
|
function getLocalNetworkHosts() {
|
|
@@ -127,10 +126,16 @@ const nextConfig = {
|
|
|
127
126
|
},
|
|
128
127
|
},
|
|
129
128
|
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
129
|
+
// First-party Umami proxy: GET /s/script.js and POST
|
|
130
|
+
// /s/api/send|collect|record are served by Route Handlers under
|
|
131
|
+
// src/app/s/*, not rewrites(). A handler is required in both cases:
|
|
132
|
+
// rewrites proxy incoming request headers unchanged, which would forward
|
|
133
|
+
// app session cookies to the analytics host (script) and cannot strip or
|
|
134
|
+
// append the ingest headers (cookie strip, X-Forwarded-For for geo,
|
|
135
|
+
// x-umami-cache for replay).
|
|
136
|
+
// NOTE: the tracker destination is resolved at request time from
|
|
137
|
+
// UMAMI_UPSTREAM_URL (preferred) or NEXT_PUBLIC_UMAMI_URL, so unlike
|
|
138
|
+
// rewrites() it needs no rebuild to take effect — only a restart.
|
|
134
139
|
|
|
135
140
|
// Compiler options for production optimization
|
|
136
141
|
compiler: {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { usePathname } from "next/navigation";
|
|
4
4
|
import { useEffect, useEffectEvent, useRef } from "react";
|
|
5
5
|
import { isUmamiStaff } from "../../lib/analytics/umami.js";
|
|
6
|
-
import {
|
|
6
|
+
import { getUmamiClientConfig } from "../../lib/analytics/umami-config.js";
|
|
7
7
|
import {
|
|
8
8
|
buildReplayEndpoint,
|
|
9
9
|
createReplayBuffer,
|
|
@@ -65,7 +65,7 @@ export default function UmamiReplayRecorder() {
|
|
|
65
65
|
);
|
|
66
66
|
|
|
67
67
|
useEffect(() => {
|
|
68
|
-
const umamiConfig =
|
|
68
|
+
const umamiConfig = getUmamiClientConfig();
|
|
69
69
|
if (!umamiConfig.replayEnabled || !shouldSampleReplay()) {
|
|
70
70
|
return undefined;
|
|
71
71
|
}
|
|
@@ -92,7 +92,7 @@ export default function UmamiReplayRecorder() {
|
|
|
92
92
|
if (cancelled) return;
|
|
93
93
|
|
|
94
94
|
cacheRef.current = cache;
|
|
95
|
-
endpointRef.current = buildReplayEndpoint(
|
|
95
|
+
endpointRef.current = umamiConfig.proxyRecordUrl || buildReplayEndpoint();
|
|
96
96
|
websiteIdRef.current = umamiConfig.websiteId;
|
|
97
97
|
startedAtRef.current = Date.now();
|
|
98
98
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect } from "react";
|
|
4
4
|
import { isUmamiStaff } from "../../lib/analytics/umami.js";
|
|
5
|
-
import {
|
|
5
|
+
import { getUmamiClientConfig } from "../../lib/analytics/umami-config.js";
|
|
6
6
|
import { observeWebVitals } from "../../lib/analytics/umami-web-vitals.js";
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -22,7 +22,7 @@ import { observeWebVitals } from "../../lib/analytics/umami-web-vitals.js";
|
|
|
22
22
|
*/
|
|
23
23
|
export default function UmamiWebVitals() {
|
|
24
24
|
useEffect(() => {
|
|
25
|
-
const config =
|
|
25
|
+
const config = getUmamiClientConfig();
|
|
26
26
|
if (!config.enabled) return;
|
|
27
27
|
|
|
28
28
|
// Skip web vitals dispatch for any staff user (identified by the
|
|
@@ -21,6 +21,9 @@ const themeScript = `(function(){try{var t=localStorage.getItem("${THEME_STORAGE
|
|
|
21
21
|
const umamiBeforeSendScript = buildUmamiBeforeSendScript();
|
|
22
22
|
|
|
23
23
|
export default function RootLayout({ children }) {
|
|
24
|
+
// Server config deliberately: the gate stays upstream-aware so a missing
|
|
25
|
+
// upstream never emits a 404 script tag, while only websiteId + proxy
|
|
26
|
+
// paths reach the HTML. Browser code uses getUmamiClientConfig instead.
|
|
24
27
|
const umamiConfig = getUmamiConfig();
|
|
25
28
|
|
|
26
29
|
return (
|
|
@@ -31,21 +34,14 @@ export default function RootLayout({ children }) {
|
|
|
31
34
|
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: static inline before-send handler */}
|
|
32
35
|
<script dangerouslySetInnerHTML={{ __html: umamiBeforeSendScript }} />
|
|
33
36
|
{umamiConfig.enabled ? (
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
defer
|
|
43
|
-
data-performance="true"
|
|
44
|
-
data-website-id={umamiConfig.websiteId}
|
|
45
|
-
src={umamiConfig.scriptUrl}
|
|
46
|
-
data-before-send="__umamiBeforeSend"
|
|
47
|
-
/>
|
|
48
|
-
</>
|
|
37
|
+
<script
|
|
38
|
+
defer
|
|
39
|
+
data-performance="true"
|
|
40
|
+
data-website-id={umamiConfig.websiteId}
|
|
41
|
+
data-host-url={umamiConfig.proxyHostUrl}
|
|
42
|
+
src={umamiConfig.proxyScriptUrl}
|
|
43
|
+
data-before-send="__umamiBeforeSend"
|
|
44
|
+
/>
|
|
49
45
|
) : null}
|
|
50
46
|
</head>
|
|
51
47
|
<body>
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Umami First-Party Ingest Proxy
|
|
3
|
+
*
|
|
4
|
+
* POST /s/api/send|collect|record → upstream Umami /api/*.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists: the tracker, web-vitals beacons, and replay batches would
|
|
7
|
+
* otherwise POST directly to the upstream analytics host, which blocklists
|
|
8
|
+
* (EasyPrivacy / uBlock / Brave) match as third-party. Same-origin ingest
|
|
9
|
+
* survives those lists while keeping geo, browser stats, and CSP tightness.
|
|
10
|
+
*
|
|
11
|
+
* Security shape (load-bearing, do not loosen without review):
|
|
12
|
+
* - Endpoint whitelist — never interpolate the raw route param into the
|
|
13
|
+
* upstream URL, or this becomes an open proxy to the analytics host.
|
|
14
|
+
* - POST-only — every other method answers 405.
|
|
15
|
+
* - Header hygiene — `cookie`, `authorization`, and `x-metrics-token` are
|
|
16
|
+
* never forwarded; only the analytics-relevant headers cross the boundary.
|
|
17
|
+
* - `X-Forwarded-For` / `X-Real-IP` are preserved so Umami geolocates the
|
|
18
|
+
* visitor, not the Railway egress IP — unless TRUST_PROXY_HEADERS=false,
|
|
19
|
+
* in which case "unknown" crosses instead (same rule as proxy.js).
|
|
20
|
+
* - 1 MB body cap enforced while streaming (a missing/lying Content-Length
|
|
21
|
+
* cannot force full buffering) + 5s upstream timeout bound abuse cost.
|
|
22
|
+
* - `Cache-Control: no-store` on every response; upstream `Set-Cookie` is
|
|
23
|
+
* stripped and never reaches the browser.
|
|
24
|
+
* - Fail-open 202 on upstream 5xx/timeout so analytics never fails the
|
|
25
|
+
* product flow — and never retry-storms a struggling upstream. The body
|
|
26
|
+
* is endpoint-aware: `record` answers `{ ok: false }` so replay batches
|
|
27
|
+
* are retained and retried, while `send`/`collect` answer `{ ok: true }`
|
|
28
|
+
* because those beacons have no retry path.
|
|
29
|
+
*
|
|
30
|
+
* Deliberately NOT rate-limited by proxy.js: that limiter keys on `/api/*`
|
|
31
|
+
* paths and analytics beacons must never 429. The body cap, content-type
|
|
32
|
+
* gate, and timeout are the abuse bounds here.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { createLogger } from "@usequark/quark-core/core";
|
|
36
|
+
import { NextResponse } from "next/server";
|
|
37
|
+
import { getUmamiUpstreamBase } from "@/lib/analytics/umami-config.js";
|
|
38
|
+
import { shouldTrustProxyHeaders } from "@/lib/proxy-auth.js";
|
|
39
|
+
|
|
40
|
+
const logger = createLogger("umami-proxy");
|
|
41
|
+
|
|
42
|
+
const ALLOWED_ENDPOINTS = new Set(["send", "collect", "record"]);
|
|
43
|
+
const MAX_BODY_BYTES = 1_000_000;
|
|
44
|
+
const UPSTREAM_TIMEOUT_MS = 5000;
|
|
45
|
+
|
|
46
|
+
function noStoreHeaders(extra = {}) {
|
|
47
|
+
return { "Cache-Control": "no-store", ...extra };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function methodNotAllowed() {
|
|
51
|
+
return NextResponse.json(
|
|
52
|
+
{ error: "Method not allowed" },
|
|
53
|
+
{ status: 405, headers: noStoreHeaders() },
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Fail-open without losing replay data. Tracker (`send`) and web-vitals
|
|
58
|
+
// (`collect`) beacons are fire-and-forget with no retry path, so they keep
|
|
59
|
+
// `{ ok: true }` to avoid any client-side retry storm against a struggling
|
|
60
|
+
// upstream. Replay (`record`) has a restore/retry path keyed on
|
|
61
|
+
// `body.ok === false` in postReplayBatch: answering `ok: true` there would
|
|
62
|
+
// discard the batch, so record answers `ok: false` and the events are
|
|
63
|
+
// retained and retried.
|
|
64
|
+
function failOpenResponse(endpoint) {
|
|
65
|
+
const body =
|
|
66
|
+
endpoint === "record"
|
|
67
|
+
? { ok: false, proxied: false, reason: "upstream_unavailable" }
|
|
68
|
+
: { ok: true, proxied: false };
|
|
69
|
+
return NextResponse.json(body, {
|
|
70
|
+
status: 202,
|
|
71
|
+
headers: noStoreHeaders(),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function resolveClientIp(request) {
|
|
76
|
+
const forwardedFor = request.headers.get("x-forwarded-for");
|
|
77
|
+
if (forwardedFor) {
|
|
78
|
+
const client = forwardedFor
|
|
79
|
+
.split(",")
|
|
80
|
+
.map((value) => value.trim())
|
|
81
|
+
.filter(Boolean)[0];
|
|
82
|
+
if (client) return client;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return request.headers.get("x-real-ip")?.trim() || "unknown";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Streams the request body under a running byte budget. The Content-Length
|
|
89
|
+
// pre-check above is only an early exit: a missing or lying header must not
|
|
90
|
+
// force full buffering of an unbounded body before the 413 fires.
|
|
91
|
+
async function readCappedBody(request) {
|
|
92
|
+
if (!request.body) return { status: "empty" };
|
|
93
|
+
|
|
94
|
+
const reader = request.body.getReader();
|
|
95
|
+
const chunks = [];
|
|
96
|
+
let total = 0;
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
for (;;) {
|
|
100
|
+
const { done, value } = await reader.read();
|
|
101
|
+
if (done) break;
|
|
102
|
+
total += value.byteLength;
|
|
103
|
+
if (total > MAX_BODY_BYTES) {
|
|
104
|
+
await reader.cancel().catch(() => {});
|
|
105
|
+
return { status: "too-large" };
|
|
106
|
+
}
|
|
107
|
+
chunks.push(value);
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
return { status: "unreadable" };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!total) return { status: "empty" };
|
|
114
|
+
|
|
115
|
+
const bytes = new Uint8Array(total);
|
|
116
|
+
let offset = 0;
|
|
117
|
+
for (const chunk of chunks) {
|
|
118
|
+
bytes.set(chunk, offset);
|
|
119
|
+
offset += chunk.byteLength;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { status: "ok", bytes };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function POST(request, { params } = {}) {
|
|
126
|
+
const resolvedParams = params ? await params : {};
|
|
127
|
+
const endpoint = resolvedParams?.endpoint;
|
|
128
|
+
|
|
129
|
+
if (!ALLOWED_ENDPOINTS.has(endpoint)) {
|
|
130
|
+
return NextResponse.json(
|
|
131
|
+
{ error: "Not found" },
|
|
132
|
+
{ status: 404, headers: noStoreHeaders() },
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const contentType =
|
|
137
|
+
request.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() ??
|
|
138
|
+
"";
|
|
139
|
+
if (contentType !== "application/json") {
|
|
140
|
+
return NextResponse.json(
|
|
141
|
+
{ error: "Unsupported media type" },
|
|
142
|
+
{ status: 415, headers: noStoreHeaders() },
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const contentLength = request.headers.get("content-length");
|
|
147
|
+
if (contentLength && Number(contentLength) > MAX_BODY_BYTES) {
|
|
148
|
+
return NextResponse.json(
|
|
149
|
+
{ error: "Payload too large" },
|
|
150
|
+
{ status: 413, headers: noStoreHeaders() },
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let body;
|
|
155
|
+
try {
|
|
156
|
+
body = await readCappedBody(request);
|
|
157
|
+
} catch {
|
|
158
|
+
return NextResponse.json(
|
|
159
|
+
{ error: "Unreadable request body" },
|
|
160
|
+
{ status: 400, headers: noStoreHeaders() },
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (body.status === "too-large") {
|
|
165
|
+
return NextResponse.json(
|
|
166
|
+
{ error: "Payload too large" },
|
|
167
|
+
{ status: 413, headers: noStoreHeaders() },
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (body.status !== "ok") {
|
|
172
|
+
return NextResponse.json(
|
|
173
|
+
{
|
|
174
|
+
error:
|
|
175
|
+
body.status === "empty" ? "Empty body" : "Unreadable request body",
|
|
176
|
+
},
|
|
177
|
+
{ status: 400, headers: noStoreHeaders() },
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const upstreamBase = getUmamiUpstreamBase(process.env);
|
|
182
|
+
if (!upstreamBase) {
|
|
183
|
+
return failOpenResponse(endpoint);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Honor the same proxy-header trust setting as proxy.js: with
|
|
187
|
+
// TRUST_PROXY_HEADERS=false the app is directly reachable, the headers
|
|
188
|
+
// are attacker-controlled, and forwarding them would poison Umami geo.
|
|
189
|
+
// "unknown" (never client bytes) crosses the boundary instead.
|
|
190
|
+
const trustProxyHeaders = shouldTrustProxyHeaders();
|
|
191
|
+
const incomingForwardedFor = trustProxyHeaders
|
|
192
|
+
? request.headers.get("x-forwarded-for")
|
|
193
|
+
: null;
|
|
194
|
+
const clientIp = trustProxyHeaders ? resolveClientIp(request) : "unknown";
|
|
195
|
+
const forwardedFor = incomingForwardedFor?.trim()
|
|
196
|
+
? incomingForwardedFor
|
|
197
|
+
: clientIp;
|
|
198
|
+
|
|
199
|
+
const forwardHeaders = {
|
|
200
|
+
"Content-Type": "application/json",
|
|
201
|
+
"X-Forwarded-For": forwardedFor,
|
|
202
|
+
"X-Real-IP": trustProxyHeaders
|
|
203
|
+
? request.headers.get("x-real-ip")?.trim() || clientIp
|
|
204
|
+
: clientIp,
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const userAgent = request.headers.get("user-agent");
|
|
208
|
+
if (userAgent) forwardHeaders["User-Agent"] = userAgent;
|
|
209
|
+
|
|
210
|
+
const acceptLanguage = request.headers.get("accept-language");
|
|
211
|
+
if (acceptLanguage) forwardHeaders["Accept-Language"] = acceptLanguage;
|
|
212
|
+
|
|
213
|
+
const umamiCache = request.headers.get("x-umami-cache");
|
|
214
|
+
if (umamiCache) forwardHeaders["x-umami-cache"] = umamiCache;
|
|
215
|
+
|
|
216
|
+
let upstreamResponse;
|
|
217
|
+
try {
|
|
218
|
+
upstreamResponse = await fetch(`${upstreamBase}/api/${endpoint}`, {
|
|
219
|
+
method: "POST",
|
|
220
|
+
body: body.bytes,
|
|
221
|
+
headers: forwardHeaders,
|
|
222
|
+
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
|
223
|
+
});
|
|
224
|
+
} catch (error) {
|
|
225
|
+
logger.warn("Umami ingest proxy upstream unreachable", {
|
|
226
|
+
endpoint,
|
|
227
|
+
message: error?.message ?? String(error),
|
|
228
|
+
});
|
|
229
|
+
return failOpenResponse(endpoint);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (upstreamResponse.status >= 500) {
|
|
233
|
+
logger.warn("Umami ingest proxy upstream error", {
|
|
234
|
+
endpoint,
|
|
235
|
+
status: upstreamResponse.status,
|
|
236
|
+
});
|
|
237
|
+
return failOpenResponse(endpoint);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
let responseBody;
|
|
241
|
+
try {
|
|
242
|
+
responseBody = await upstreamResponse.arrayBuffer();
|
|
243
|
+
} catch (error) {
|
|
244
|
+
logger.warn("Umami ingest proxy upstream body unreadable", {
|
|
245
|
+
endpoint,
|
|
246
|
+
message: error?.message ?? String(error),
|
|
247
|
+
});
|
|
248
|
+
return failOpenResponse(endpoint);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const responseHeaders = noStoreHeaders();
|
|
252
|
+
const upstreamContentType = upstreamResponse.headers.get("content-type");
|
|
253
|
+
if (upstreamContentType) {
|
|
254
|
+
responseHeaders["Content-Type"] = upstreamContentType;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Never pass upstream Set-Cookie through to the browser.
|
|
258
|
+
return new NextResponse(responseBody, {
|
|
259
|
+
status: upstreamResponse.status,
|
|
260
|
+
headers: responseHeaders,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function GET() {
|
|
265
|
+
return methodNotAllowed();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function PUT() {
|
|
269
|
+
return methodNotAllowed();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function PATCH() {
|
|
273
|
+
return methodNotAllowed();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function DELETE() {
|
|
277
|
+
return methodNotAllowed();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function HEAD() {
|
|
281
|
+
return methodNotAllowed();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function OPTIONS() {
|
|
285
|
+
return methodNotAllowed();
|
|
286
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Umami First-Party Tracker Script
|
|
3
|
+
*
|
|
4
|
+
* GET /s/script.js → upstream Umami /script.js.
|
|
5
|
+
*
|
|
6
|
+
* Why a handler and not a `rewrites()` entry: a browser requesting a
|
|
7
|
+
* same-origin script URL sends this app's cookies with it, and Next.js
|
|
8
|
+
* external rewrites proxy incoming request headers unchanged — so a rewrite
|
|
9
|
+
* would forward session cookies to the analytics host. (The previous
|
|
10
|
+
* cross-origin `<script src>` never sent same-origin credentials.) This
|
|
11
|
+
* handler forwards NO client headers at all: the upstream sees an anonymous
|
|
12
|
+
* server-to-server fetch, and its `Set-Cookie` is stripped before the
|
|
13
|
+
* response reaches the browser.
|
|
14
|
+
*
|
|
15
|
+
* Caching: the tracker is static and versioned by the upstream, so the
|
|
16
|
+
* response is publicly cacheable (browser + CDN). The upstream fetch is
|
|
17
|
+
* revalidated hourly so most script loads never touch the analytics host.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createLogger } from "@usequark/quark-core/core";
|
|
21
|
+
import { NextResponse } from "next/server";
|
|
22
|
+
import { getUmamiUpstreamBase } from "@/lib/analytics/umami-config.js";
|
|
23
|
+
|
|
24
|
+
const logger = createLogger("umami-proxy");
|
|
25
|
+
|
|
26
|
+
export const dynamic = "force-dynamic";
|
|
27
|
+
|
|
28
|
+
const UPSTREAM_TIMEOUT_MS = 5000;
|
|
29
|
+
const SCRIPT_CACHE_CONTROL =
|
|
30
|
+
"public, max-age=3600, stale-while-revalidate=86400";
|
|
31
|
+
|
|
32
|
+
function noStoreHeaders(extra = {}) {
|
|
33
|
+
return { "Cache-Control": "no-store", ...extra };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function methodNotAllowed() {
|
|
37
|
+
return NextResponse.json(
|
|
38
|
+
{ error: "Method not allowed" },
|
|
39
|
+
{ status: 405, headers: noStoreHeaders() },
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function GET() {
|
|
44
|
+
const upstreamBase = getUmamiUpstreamBase(process.env);
|
|
45
|
+
if (!upstreamBase) {
|
|
46
|
+
return NextResponse.json(
|
|
47
|
+
{ error: "Not found" },
|
|
48
|
+
{ status: 404, headers: noStoreHeaders() },
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let upstreamResponse;
|
|
53
|
+
try {
|
|
54
|
+
// Deliberately no client headers: no Cookie, no Authorization, no
|
|
55
|
+
// X-Forwarded-For. The tracker script is identical for every visitor.
|
|
56
|
+
upstreamResponse = await fetch(`${upstreamBase}/script.js`, {
|
|
57
|
+
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
|
58
|
+
next: { revalidate: 3600 },
|
|
59
|
+
});
|
|
60
|
+
} catch (error) {
|
|
61
|
+
logger.warn("Umami script proxy upstream unreachable", {
|
|
62
|
+
message: error?.message ?? String(error),
|
|
63
|
+
});
|
|
64
|
+
return NextResponse.json(
|
|
65
|
+
{ error: "Bad gateway" },
|
|
66
|
+
{ status: 502, headers: noStoreHeaders() },
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!upstreamResponse.ok) {
|
|
71
|
+
logger.warn("Umami script proxy upstream error", {
|
|
72
|
+
status: upstreamResponse.status,
|
|
73
|
+
});
|
|
74
|
+
return NextResponse.json(
|
|
75
|
+
{ error: "Bad gateway" },
|
|
76
|
+
{ status: 502, headers: noStoreHeaders() },
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let body;
|
|
81
|
+
try {
|
|
82
|
+
body = await upstreamResponse.arrayBuffer();
|
|
83
|
+
} catch (error) {
|
|
84
|
+
logger.warn("Umami script proxy upstream body unreadable", {
|
|
85
|
+
message: error?.message ?? String(error),
|
|
86
|
+
});
|
|
87
|
+
return NextResponse.json(
|
|
88
|
+
{ error: "Bad gateway" },
|
|
89
|
+
{ status: 502, headers: noStoreHeaders() },
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Fresh headers: upstream Content-Type (or a JS default), public caching,
|
|
94
|
+
// and never an upstream Set-Cookie.
|
|
95
|
+
return new NextResponse(body, {
|
|
96
|
+
status: 200,
|
|
97
|
+
headers: {
|
|
98
|
+
"Content-Type":
|
|
99
|
+
upstreamResponse.headers.get("content-type") ??
|
|
100
|
+
"application/javascript",
|
|
101
|
+
"Cache-Control": SCRIPT_CACHE_CONTROL,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function POST() {
|
|
107
|
+
return methodNotAllowed();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function PUT() {
|
|
111
|
+
return methodNotAllowed();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function PATCH() {
|
|
115
|
+
return methodNotAllowed();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function DELETE() {
|
|
119
|
+
return methodNotAllowed();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function HEAD() {
|
|
123
|
+
return methodNotAllowed();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function OPTIONS() {
|
|
127
|
+
return methodNotAllowed();
|
|
128
|
+
}
|
|
@@ -24,6 +24,11 @@ export function shouldBlockUmamiPayload({ url, roleCookie } = {}) {
|
|
|
24
24
|
* @returns {string}
|
|
25
25
|
*/
|
|
26
26
|
export function buildUmamiBeforeSendScript() {
|
|
27
|
-
//
|
|
28
|
-
|
|
27
|
+
// Assigned via `var`: bundler minification may drop the function name from
|
|
28
|
+
// shouldBlockUmamiPayload.toString() (observed `function({...})` in the
|
|
29
|
+
// production bundle), and a bare anonymous `function(...){...}` statement
|
|
30
|
+
// is a SyntaxError that kills the whole inline tag — including
|
|
31
|
+
// window.__umamiBeforeSend. `var name=<any function form>;` is valid for
|
|
32
|
+
// named, anonymous, and arrow shapes alike.
|
|
33
|
+
return `window.__umamiBeforeSend=function(t,p){var r=(document.cookie.match(/umami_user_role=([^;]+)/)||[])[1];if(shouldBlockUmamiPayload({url:p.url,roleCookie:r}))return false;return p};var shouldBlockUmamiPayload=${shouldBlockUmamiPayload.toString()};`;
|
|
29
34
|
}
|
|
@@ -48,6 +48,57 @@ 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
|
+
export const UMAMI_PROXY_COLLECT_PATH = "/s/api/collect";
|
|
62
|
+
export const UMAMI_PROXY_RECORD_PATH = "/s/api/record";
|
|
63
|
+
|
|
64
|
+
// Server-side upstream preference: an operator that wants the analytics host
|
|
65
|
+
// out of the client bundle entirely sets UMAMI_UPSTREAM_URL (or UMAMI_URL)
|
|
66
|
+
// and the rewrite/handler destination follows it. NEXT_PUBLIC_UMAMI_URL
|
|
67
|
+
// remains the default so existing deployments keep working with no new vars.
|
|
68
|
+
export function getUmamiUpstreamBase(env = process.env) {
|
|
69
|
+
const candidates = [
|
|
70
|
+
env.UMAMI_UPSTREAM_URL,
|
|
71
|
+
env.UMAMI_URL,
|
|
72
|
+
env.NEXT_PUBLIC_UMAMI_URL,
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
for (const candidate of candidates) {
|
|
76
|
+
const normalized = normalizeUmamiUrl(candidate);
|
|
77
|
+
if (normalized) return normalized;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return "";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Default env for browser-safe readers. Each NEXT_PUBLIC_* access MUST stay
|
|
84
|
+
// a literal member expression: Next.js statically inlines those literals in
|
|
85
|
+
// client bundles, where bare `process` does not exist — a whole-`process.env`
|
|
86
|
+
// default throws ReferenceError in every browser, which previously killed
|
|
87
|
+
// track(), web-vitals, and replay client-side. Do not refactor this into a
|
|
88
|
+
// `process.env` passthrough. Exported for tests (the no-process fallback).
|
|
89
|
+
export function readPublicEnv() {
|
|
90
|
+
try {
|
|
91
|
+
return {
|
|
92
|
+
NEXT_PUBLIC_UMAMI_URL: process.env.NEXT_PUBLIC_UMAMI_URL,
|
|
93
|
+
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
|
94
|
+
NEXT_PUBLIC_UMAMI_REPLAY_ENABLED:
|
|
95
|
+
process.env.NEXT_PUBLIC_UMAMI_REPLAY_ENABLED,
|
|
96
|
+
};
|
|
97
|
+
} catch {
|
|
98
|
+
return {};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
51
102
|
export function parseUmamiBooleanFlag(value) {
|
|
52
103
|
if (typeof value !== "string") return false;
|
|
53
104
|
return TRUE_VALUES.has(value.trim().toLowerCase());
|
|
@@ -59,7 +110,7 @@ export function isUmamiWebsiteId(value) {
|
|
|
59
110
|
}
|
|
60
111
|
|
|
61
112
|
export function getUmamiConfig(env = process.env) {
|
|
62
|
-
const url =
|
|
113
|
+
const url = getUmamiUpstreamBase(env);
|
|
63
114
|
const rawWebsiteId =
|
|
64
115
|
typeof env.NEXT_PUBLIC_UMAMI_WEBSITE_ID === "string"
|
|
65
116
|
? env.NEXT_PUBLIC_UMAMI_WEBSITE_ID.trim()
|
|
@@ -76,20 +127,55 @@ export function getUmamiConfig(env = process.env) {
|
|
|
76
127
|
enabled,
|
|
77
128
|
replayEnabled:
|
|
78
129
|
enabled && parseUmamiBooleanFlag(env.NEXT_PUBLIC_UMAMI_REPLAY_ENABLED),
|
|
130
|
+
// First-party proxy URLs. Empty when analytics is disabled so
|
|
131
|
+
// callers can gate on `enabled` alone.
|
|
132
|
+
proxyBase: enabled ? UMAMI_PROXY_BASE : "",
|
|
133
|
+
proxyScriptUrl: enabled ? UMAMI_PROXY_SCRIPT_PATH : "",
|
|
134
|
+
proxyHostUrl: enabled ? UMAMI_PROXY_HOST_URL : "",
|
|
135
|
+
proxySendUrl: enabled ? UMAMI_PROXY_SEND_PATH : "",
|
|
136
|
+
proxyCollectUrl: enabled ? UMAMI_PROXY_COLLECT_PATH : "",
|
|
137
|
+
proxyRecordUrl: enabled ? UMAMI_PROXY_RECORD_PATH : "",
|
|
79
138
|
};
|
|
80
139
|
}
|
|
81
140
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
141
|
+
// Client-safe config. Contains no upstream URL, origin, or script URL — only
|
|
142
|
+
// the website ID, gates, and static first-party proxy paths. Browser code
|
|
143
|
+
// must use this (never getUmamiConfig) so the analytics host cannot end up
|
|
144
|
+
// in client bundles. `enabled` needs only a valid website ID because every
|
|
145
|
+
// client surface is same-origin; the server layout keeps the upstream-aware
|
|
146
|
+
// gate so a missing upstream never emits a 404 script tag.
|
|
147
|
+
//
|
|
148
|
+
// The default env is readPublicEnv(), not process.env: bare `process` does
|
|
149
|
+
// not exist in browsers, so a process.env default throws on every no-arg
|
|
150
|
+
// browser call.
|
|
151
|
+
export function getUmamiClientConfig(env = readPublicEnv()) {
|
|
152
|
+
const rawWebsiteId =
|
|
153
|
+
typeof env.NEXT_PUBLIC_UMAMI_WEBSITE_ID === "string"
|
|
154
|
+
? env.NEXT_PUBLIC_UMAMI_WEBSITE_ID.trim()
|
|
155
|
+
: "";
|
|
156
|
+
const websiteId = isUmamiWebsiteId(rawWebsiteId) ? rawWebsiteId : "";
|
|
157
|
+
const enabled = Boolean(websiteId);
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
websiteId,
|
|
161
|
+
enabled,
|
|
162
|
+
replayEnabled:
|
|
163
|
+
enabled && parseUmamiBooleanFlag(env.NEXT_PUBLIC_UMAMI_REPLAY_ENABLED),
|
|
164
|
+
proxyBase: enabled ? UMAMI_PROXY_BASE : "",
|
|
165
|
+
proxyScriptUrl: enabled ? UMAMI_PROXY_SCRIPT_PATH : "",
|
|
166
|
+
proxyHostUrl: enabled ? UMAMI_PROXY_HOST_URL : "",
|
|
167
|
+
proxySendUrl: enabled ? UMAMI_PROXY_SEND_PATH : "",
|
|
168
|
+
proxyCollectUrl: enabled ? UMAMI_PROXY_COLLECT_PATH : "",
|
|
169
|
+
proxyRecordUrl: enabled ? UMAMI_PROXY_RECORD_PATH : "",
|
|
170
|
+
};
|
|
171
|
+
}
|
|
90
172
|
|
|
173
|
+
export function getUmamiCspOrigins() {
|
|
174
|
+
// Proxy mode is the default path when analytics is enabled: tracker and
|
|
175
|
+
// beacons are same-origin, so CSP stays `'self'`-only and never names
|
|
176
|
+
// the upstream analytics host.
|
|
91
177
|
return {
|
|
92
|
-
scriptSrc: [
|
|
93
|
-
connectSrc: [
|
|
178
|
+
scriptSrc: [],
|
|
179
|
+
connectSrc: [],
|
|
94
180
|
};
|
|
95
181
|
}
|
|
@@ -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
|
}
|
|
@@ -18,11 +18,12 @@
|
|
|
18
18
|
* (e.g. a "use client" wrapper in the root layout).
|
|
19
19
|
*
|
|
20
20
|
* Environment:
|
|
21
|
-
* Controlled by
|
|
21
|
+
* Controlled by NEXT_PUBLIC_UMAMI_WEBSITE_ID (first-party proxy paths are
|
|
22
|
+
* static, so no upstream URL is needed in the browser).
|
|
22
23
|
* When Umami analytics is not configured, the observer is a no-op.
|
|
23
24
|
*/
|
|
24
25
|
|
|
25
|
-
import {
|
|
26
|
+
import { getUmamiClientConfig } from "./umami-config.js";
|
|
26
27
|
|
|
27
28
|
// ── Initialization guard ──────────────────────────────────────
|
|
28
29
|
|
|
@@ -62,7 +63,7 @@ function sanitiseUrl(url) {
|
|
|
62
63
|
* @param {{ name: string, value: number, rating: string, navigationType?: string }} metric
|
|
63
64
|
*/
|
|
64
65
|
function dispatchMetric(metric) {
|
|
65
|
-
const config =
|
|
66
|
+
const config = getUmamiClientConfig();
|
|
66
67
|
if (!config.enabled) return;
|
|
67
68
|
|
|
68
69
|
const payload = {
|
|
@@ -83,13 +84,17 @@ function dispatchMetric(metric) {
|
|
|
83
84
|
|
|
84
85
|
const body = JSON.stringify(payload);
|
|
85
86
|
|
|
87
|
+
// First-party ingest: same-origin POSTs survive blocklists that match the
|
|
88
|
+
// upstream analytics host as third-party.
|
|
89
|
+
const endpoint = config.proxyCollectUrl || `${config.url}/api/collect`;
|
|
90
|
+
|
|
86
91
|
// Use sendBeacon for reliability during page lifecycle transitions.
|
|
87
92
|
if (navigator.sendBeacon) {
|
|
88
93
|
const blob = new Blob([body], { type: "application/json" });
|
|
89
|
-
navigator.sendBeacon(
|
|
94
|
+
navigator.sendBeacon(endpoint, blob);
|
|
90
95
|
} else {
|
|
91
96
|
// Fallback to fetch with keepalive.
|
|
92
|
-
fetch(
|
|
97
|
+
fetch(endpoint, {
|
|
93
98
|
method: "POST",
|
|
94
99
|
body,
|
|
95
100
|
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) {
|
|
@@ -237,6 +237,13 @@ async function validateSessionToken(token) {
|
|
|
237
237
|
|
|
238
238
|
export function getAuthOptions() {
|
|
239
239
|
return createAuthConfig({
|
|
240
|
+
// The adapter is load-bearing even though sessions are JWT (see
|
|
241
|
+
// `session.strategy` below): it persists the `Account` rows that link
|
|
242
|
+
// OAuth identities to users — the adapter reads and writes them, and
|
|
243
|
+
// `account.findByUserIdAndProvider` is covered in `packages/db`. Do
|
|
244
|
+
// not remove it. The `Session` model queries (`session.*` in
|
|
245
|
+
// `packages/db/src/queries.js`) are the opposite: app-owned helpers the
|
|
246
|
+
// JWT strategy never populates.
|
|
240
247
|
adapter: PrismaAdapter(prisma),
|
|
241
248
|
providers: providers,
|
|
242
249
|
logger: {
|
|
@@ -107,3 +107,36 @@ 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
|
+
}
|
|
@@ -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
|
*
|
|
@@ -139,6 +139,12 @@ export const account = {
|
|
|
139
139
|
};
|
|
140
140
|
|
|
141
141
|
// Session queries (NextAuth)
|
|
142
|
+
//
|
|
143
|
+
// App-owned helpers, not Auth.js storage: the scaffold pins
|
|
144
|
+
// `session.strategy: "jwt"` (see `apps/web/src/lib/auth.js`), under which
|
|
145
|
+
// Auth.js neither reads nor writes session rows — revocation is enforced by
|
|
146
|
+
// `validateSessionToken` re-reading the user row instead. Kept for apps that
|
|
147
|
+
// need server-side session bookkeeping of their own.
|
|
142
148
|
export const session = {
|
|
143
149
|
findByToken: (sessionToken) => {
|
|
144
150
|
return prisma.session.findUnique({
|
|
@@ -101,6 +101,10 @@ const ENV_DESCRIPTIONS = {
|
|
|
101
101
|
APP_URL: "Canonical application URL - derives NEXTAUTH_URL and CORS origins",
|
|
102
102
|
NEXT_PUBLIC_UMAMI_URL:
|
|
103
103
|
"Public Umami base URL for script.js and replay uploads",
|
|
104
|
+
UMAMI_UPSTREAM_URL:
|
|
105
|
+
"Server-only Umami base URL for the first-party proxy (rewrites + /s/api/* destination). Takes precedence over NEXT_PUBLIC_UMAMI_URL and keeps the analytics host out of the client bundle",
|
|
106
|
+
UMAMI_URL:
|
|
107
|
+
"Server-only Umami base URL alias for the first-party proxy (same as UMAMI_UPSTREAM_URL)",
|
|
104
108
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID: "Public Umami website UUID",
|
|
105
109
|
NEXT_PUBLIC_UMAMI_REPLAY_ENABLED:
|
|
106
110
|
"Enable local rrweb session replay uploads to Umami",
|
|
@@ -246,6 +250,10 @@ function createEnvSchema(service) {
|
|
|
246
250
|
NEXT_PUBLIC_UMAMI_URL: absoluteHttpUrl(
|
|
247
251
|
"NEXT_PUBLIC_UMAMI_URL must be an absolute http(s) URL",
|
|
248
252
|
),
|
|
253
|
+
UMAMI_UPSTREAM_URL: absoluteHttpUrl(
|
|
254
|
+
"UMAMI_UPSTREAM_URL must be an absolute http(s) URL",
|
|
255
|
+
),
|
|
256
|
+
UMAMI_URL: absoluteHttpUrl("UMAMI_URL must be an absolute http(s) URL"),
|
|
249
257
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z
|
|
250
258
|
.string()
|
|
251
259
|
.refine((value) => isUuid(value), {
|
|
@@ -370,8 +378,13 @@ function createEnvSchema(service) {
|
|
|
370
378
|
}
|
|
371
379
|
}
|
|
372
380
|
|
|
373
|
-
// Umami replay requires full analytics contract
|
|
374
|
-
|
|
381
|
+
// Umami replay requires full analytics contract. A server-only
|
|
382
|
+
// upstream (UMAMI_UPSTREAM_URL / UMAMI_URL) counts as URL-present:
|
|
383
|
+
// the proxy rewrite/handler destination follows it.
|
|
384
|
+
const umamiUrl =
|
|
385
|
+
data.UMAMI_UPSTREAM_URL?.trim() ||
|
|
386
|
+
data.UMAMI_URL?.trim() ||
|
|
387
|
+
data.NEXT_PUBLIC_UMAMI_URL?.trim();
|
|
375
388
|
const umamiWebsiteId = data.NEXT_PUBLIC_UMAMI_WEBSITE_ID?.trim();
|
|
376
389
|
const umamiReplayEnabled = data.NEXT_PUBLIC_UMAMI_REPLAY_ENABLED?.trim();
|
|
377
390
|
const hasUmamiUrl = Boolean(umamiUrl);
|
|
@@ -385,7 +398,7 @@ function createEnvSchema(service) {
|
|
|
385
398
|
ctx.addIssue({
|
|
386
399
|
code: "custom",
|
|
387
400
|
message:
|
|
388
|
-
"NEXT_PUBLIC_UMAMI_REPLAY_ENABLED requires NEXT_PUBLIC_UMAMI_URL and NEXT_PUBLIC_UMAMI_WEBSITE_ID.",
|
|
401
|
+
"NEXT_PUBLIC_UMAMI_REPLAY_ENABLED requires an Umami URL (UMAMI_UPSTREAM_URL, UMAMI_URL, or NEXT_PUBLIC_UMAMI_URL) and NEXT_PUBLIC_UMAMI_WEBSITE_ID.",
|
|
389
402
|
});
|
|
390
403
|
}
|
|
391
404
|
});
|
|
@@ -455,14 +468,20 @@ function collectWarnings() {
|
|
|
455
468
|
}
|
|
456
469
|
}
|
|
457
470
|
|
|
458
|
-
// Umami pair completeness
|
|
459
|
-
|
|
471
|
+
// Umami pair completeness. Server-only upstream vars count as
|
|
472
|
+
// URL-present, matching the proxy destination preference.
|
|
473
|
+
const umamiUrl = (
|
|
474
|
+
process.env.UMAMI_UPSTREAM_URL ||
|
|
475
|
+
process.env.UMAMI_URL ||
|
|
476
|
+
process.env.NEXT_PUBLIC_UMAMI_URL ||
|
|
477
|
+
""
|
|
478
|
+
).trim();
|
|
460
479
|
const umamiWebsiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID?.trim();
|
|
461
480
|
const hasUmamiUrl = Boolean(umamiUrl);
|
|
462
481
|
const hasUmamiWebsiteId = Boolean(umamiWebsiteId);
|
|
463
482
|
if (hasUmamiUrl !== hasUmamiWebsiteId) {
|
|
464
483
|
warnings.push(
|
|
465
|
-
"Umami analytics is incomplete: set
|
|
484
|
+
"Umami analytics is incomplete: set an Umami URL (UMAMI_UPSTREAM_URL, UMAMI_URL, or NEXT_PUBLIC_UMAMI_URL) and NEXT_PUBLIC_UMAMI_WEBSITE_ID to enable tracking.",
|
|
466
485
|
);
|
|
467
486
|
}
|
|
468
487
|
|