@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
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
|
|
@@ -112,6 +118,18 @@ ADMIN_PASSWORD=SET_ME
|
|
|
112
118
|
# cannot earn a fresh bucket. Boolean-like: true/false/1/0/yes/no/on/off.
|
|
113
119
|
# TRUST_PROXY_HEADERS=true
|
|
114
120
|
|
|
121
|
+
# --- Umami Ingest Proxy & Cloudflare ---
|
|
122
|
+
# The first-party analytics proxy (/s/*) resolves the visitor IP from proxy
|
|
123
|
+
# headers. Unlike x-forwarded-for, CF-Connecting-IP is NOT sanitized by
|
|
124
|
+
# generic proxies (Railway, nginx, Docker pass it through), so trusting it
|
|
125
|
+
# unconditionally would let any client override a legitimate X-Forwarded-For.
|
|
126
|
+
# Default (unset) ignores it. Set to true ONLY when Cloudflare sits in front
|
|
127
|
+
# of the app (it overwrites the header with the true client IP): correct geo
|
|
128
|
+
# and stable Umami sessions behind Cloudflare → Railway. Boolean-like:
|
|
129
|
+
# true/1/yes/on enable; anything else (including unset) disables.
|
|
130
|
+
# Independent of TRUST_PROXY_HEADERS, which still forces "unknown" when false.
|
|
131
|
+
# TRUST_CF_CONNECTING_IP=true
|
|
132
|
+
|
|
115
133
|
# --- File Storage ---
|
|
116
134
|
# Default: local filesystem (./uploads). No further config needed for development.
|
|
117
135
|
STORAGE_PROVIDER=local
|
|
@@ -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,328 @@
|
|
|
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` carry the single resolved visitor IP so
|
|
18
|
+
* Umami geolocates the visitor, not the Railway egress IP — unless
|
|
19
|
+
* TRUST_PROXY_HEADERS=false, in which case "unknown" crosses instead (same
|
|
20
|
+
* rule as proxy.js). Resolution prefers `CF-Connecting-IP`, but only with
|
|
21
|
+
* explicit opt-in (`TRUST_CF_CONNECTING_IP=true`, for Cloudflare-fronted
|
|
22
|
+
* deployments): the header takes precedence yet generic proxies pass it
|
|
23
|
+
* through unsanitized, so unconditional trust would let any client override
|
|
24
|
+
* a legitimate X-Forwarded-For. `CF-Connecting-IP` passthrough follows the
|
|
25
|
+
* same opt-in — never when untrusted. Tracker metadata (`x-umami-cache`) is forwarded; `cookie`,
|
|
26
|
+
* `authorization`, and `x-metrics-token` never are.
|
|
27
|
+
* - 1 MB body cap enforced while streaming (a missing/lying Content-Length
|
|
28
|
+
* cannot force full buffering) + 5s upstream timeout bound abuse cost.
|
|
29
|
+
* - `Cache-Control: no-store` on every response; upstream `Set-Cookie` is
|
|
30
|
+
* stripped and never reaches the browser.
|
|
31
|
+
* - Fail-open 202 on upstream 5xx/timeout so analytics never fails the
|
|
32
|
+
* product flow — and never retry-storms a struggling upstream. The body
|
|
33
|
+
* is endpoint-aware: `record` answers `{ ok: false }` so replay batches
|
|
34
|
+
* are retained and retried, while `send`/`collect` answer `{ ok: true }`
|
|
35
|
+
* because those beacons have no retry path.
|
|
36
|
+
*
|
|
37
|
+
* Deliberately NOT rate-limited by proxy.js: that limiter keys on `/api/*`
|
|
38
|
+
* paths and analytics beacons must never 429. The body cap, content-type
|
|
39
|
+
* gate, and timeout are the abuse bounds here.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { createLogger } from "@usequark/quark-core/core";
|
|
43
|
+
import { NextResponse } from "next/server";
|
|
44
|
+
import { getUmamiUpstreamBase } from "@/lib/analytics/umami-config.js";
|
|
45
|
+
import {
|
|
46
|
+
shouldTrustCfConnectingIp,
|
|
47
|
+
shouldTrustProxyHeaders,
|
|
48
|
+
} from "@/lib/proxy-auth.js";
|
|
49
|
+
|
|
50
|
+
const logger = createLogger("umami-proxy");
|
|
51
|
+
|
|
52
|
+
const ALLOWED_ENDPOINTS = new Set(["send", "collect", "record"]);
|
|
53
|
+
const MAX_BODY_BYTES = 1_000_000;
|
|
54
|
+
const UPSTREAM_TIMEOUT_MS = 5000;
|
|
55
|
+
|
|
56
|
+
function noStoreHeaders(extra = {}) {
|
|
57
|
+
return { "Cache-Control": "no-store", ...extra };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function methodNotAllowed() {
|
|
61
|
+
return NextResponse.json(
|
|
62
|
+
{ error: "Method not allowed" },
|
|
63
|
+
{ status: 405, headers: noStoreHeaders() },
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Fail-open without losing replay data. Tracker, web-vitals, and legacy
|
|
68
|
+
// `collect` beacons are fire-and-forget with no retry path, so they keep
|
|
69
|
+
// `{ ok: true }` to avoid any client-side retry storm against a struggling
|
|
70
|
+
// upstream. Replay (`record`) has a restore/retry path keyed on
|
|
71
|
+
// `body.ok === false` in postReplayBatch: answering `ok: true` there would
|
|
72
|
+
// discard the batch, so record answers `ok: false` and the events are
|
|
73
|
+
// retained and retried.
|
|
74
|
+
function failOpenResponse(endpoint) {
|
|
75
|
+
const body =
|
|
76
|
+
endpoint === "record"
|
|
77
|
+
? { ok: false, proxied: false, reason: "upstream_unavailable" }
|
|
78
|
+
: { ok: true, proxied: false };
|
|
79
|
+
return NextResponse.json(body, {
|
|
80
|
+
status: 202,
|
|
81
|
+
headers: noStoreHeaders(),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function resolveClientIp(request, { trustCfConnectingIp = false } = {}) {
|
|
86
|
+
// Preference (only when the operator opted in via TRUST_CF_CONNECTING_IP):
|
|
87
|
+
// Cloudflare overwrites CF-Connecting-IP with the true client IP and
|
|
88
|
+
// Railway passes it through, while Railway REPLACES X-Forwarded-For with
|
|
89
|
+
// the Cloudflare edge address. Reading XFF alone therefore misattributes
|
|
90
|
+
// every visitor behind Cloudflare — wrong geo, and rotating edge IPs
|
|
91
|
+
// change Umami's hash(IP, UA, site) session identity between requests of
|
|
92
|
+
// one visit, splitting it into duplicate sessions.
|
|
93
|
+
//
|
|
94
|
+
// The opt-in is load-bearing, not bureaucracy: CF-Connecting-IP takes
|
|
95
|
+
// precedence over X-Forwarded-For, and most proxies pass it through
|
|
96
|
+
// untouched — so trusting it unconditionally would let any client override
|
|
97
|
+
// an otherwise-legitimate XFF with an arbitrary IP. Only Cloudflare
|
|
98
|
+
// itself sanitizes this header, hence explicit opt-in (and
|
|
99
|
+
// TRUST_PROXY_HEADERS=false still forces "unknown" at the call site).
|
|
100
|
+
const cloudflareIp = trustCfConnectingIp
|
|
101
|
+
? request.headers.get("cf-connecting-ip")?.trim()
|
|
102
|
+
: null;
|
|
103
|
+
if (cloudflareIp) return cloudflareIp;
|
|
104
|
+
|
|
105
|
+
const forwardedFor = request.headers.get("x-forwarded-for");
|
|
106
|
+
if (forwardedFor) {
|
|
107
|
+
const client = forwardedFor
|
|
108
|
+
.split(",")
|
|
109
|
+
.map((value) => value.trim())
|
|
110
|
+
.filter(Boolean)[0];
|
|
111
|
+
if (client) return client;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return request.headers.get("x-real-ip")?.trim() || "unknown";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Streams the request body under a running byte budget. The Content-Length
|
|
118
|
+
// pre-check above is only an early exit: a missing or lying header must not
|
|
119
|
+
// force full buffering of an unbounded body before the 413 fires.
|
|
120
|
+
async function readCappedBody(request) {
|
|
121
|
+
if (!request.body) return { status: "empty" };
|
|
122
|
+
|
|
123
|
+
const reader = request.body.getReader();
|
|
124
|
+
const chunks = [];
|
|
125
|
+
let total = 0;
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
for (;;) {
|
|
129
|
+
const { done, value } = await reader.read();
|
|
130
|
+
if (done) break;
|
|
131
|
+
total += value.byteLength;
|
|
132
|
+
if (total > MAX_BODY_BYTES) {
|
|
133
|
+
await reader.cancel().catch(() => {});
|
|
134
|
+
return { status: "too-large" };
|
|
135
|
+
}
|
|
136
|
+
chunks.push(value);
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
return { status: "unreadable" };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!total) return { status: "empty" };
|
|
143
|
+
|
|
144
|
+
const bytes = new Uint8Array(total);
|
|
145
|
+
let offset = 0;
|
|
146
|
+
for (const chunk of chunks) {
|
|
147
|
+
bytes.set(chunk, offset);
|
|
148
|
+
offset += chunk.byteLength;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { status: "ok", bytes };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function POST(request, { params } = {}) {
|
|
155
|
+
const resolvedParams = params ? await params : {};
|
|
156
|
+
const endpoint = resolvedParams?.endpoint;
|
|
157
|
+
|
|
158
|
+
if (!ALLOWED_ENDPOINTS.has(endpoint)) {
|
|
159
|
+
return NextResponse.json(
|
|
160
|
+
{ error: "Not found" },
|
|
161
|
+
{ status: 404, headers: noStoreHeaders() },
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const contentType =
|
|
166
|
+
request.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() ??
|
|
167
|
+
"";
|
|
168
|
+
if (contentType !== "application/json") {
|
|
169
|
+
return NextResponse.json(
|
|
170
|
+
{ error: "Unsupported media type" },
|
|
171
|
+
{ status: 415, headers: noStoreHeaders() },
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const contentLength = request.headers.get("content-length");
|
|
176
|
+
if (contentLength && Number(contentLength) > MAX_BODY_BYTES) {
|
|
177
|
+
return NextResponse.json(
|
|
178
|
+
{ error: "Payload too large" },
|
|
179
|
+
{ status: 413, headers: noStoreHeaders() },
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let body;
|
|
184
|
+
try {
|
|
185
|
+
body = await readCappedBody(request);
|
|
186
|
+
} catch {
|
|
187
|
+
return NextResponse.json(
|
|
188
|
+
{ error: "Unreadable request body" },
|
|
189
|
+
{ status: 400, headers: noStoreHeaders() },
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (body.status === "too-large") {
|
|
194
|
+
return NextResponse.json(
|
|
195
|
+
{ error: "Payload too large" },
|
|
196
|
+
{ status: 413, headers: noStoreHeaders() },
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (body.status !== "ok") {
|
|
201
|
+
return NextResponse.json(
|
|
202
|
+
{
|
|
203
|
+
error:
|
|
204
|
+
body.status === "empty" ? "Empty body" : "Unreadable request body",
|
|
205
|
+
},
|
|
206
|
+
{ status: 400, headers: noStoreHeaders() },
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const upstreamBase = getUmamiUpstreamBase(process.env);
|
|
211
|
+
if (!upstreamBase) {
|
|
212
|
+
return failOpenResponse(endpoint);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Honor the same proxy-header trust setting as proxy.js: with
|
|
216
|
+
// TRUST_PROXY_HEADERS=false the app is directly reachable, the headers
|
|
217
|
+
// are attacker-controlled, and forwarding them would poison Umami geo.
|
|
218
|
+
// "unknown" (never client bytes) crosses the boundary instead.
|
|
219
|
+
// CF-Connecting-IP additionally needs TRUST_CF_CONNECTING_IP=true:
|
|
220
|
+
// unlike XFF it is not sanitized by generic proxies, so trusting it
|
|
221
|
+
// unconditionally would let a client override a legitimate XFF.
|
|
222
|
+
const trustProxyHeaders = shouldTrustProxyHeaders();
|
|
223
|
+
const trustCfConnectingIp = trustProxyHeaders && shouldTrustCfConnectingIp();
|
|
224
|
+
const clientIp = trustProxyHeaders
|
|
225
|
+
? resolveClientIp(request, { trustCfConnectingIp })
|
|
226
|
+
: "unknown";
|
|
227
|
+
|
|
228
|
+
const forwardHeaders = {
|
|
229
|
+
"Content-Type": "application/json",
|
|
230
|
+
// Single stable IP, never the incoming chain: Umami hashes
|
|
231
|
+
// (IP, UA, site) into its session identity, so a rotating edge IP
|
|
232
|
+
// anywhere in a forwarded chain splits one visit into duplicate
|
|
233
|
+
// sessions (and geolocates the edge, not the visitor). Chain entries
|
|
234
|
+
// beyond the client are proxies — no analytics value, only leak.
|
|
235
|
+
"X-Forwarded-For": clientIp,
|
|
236
|
+
"X-Real-IP": clientIp,
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// Umami reads cf-connecting-ip before X-Forwarded-For, and behind
|
|
240
|
+
// Cloudflare it is the only header carrying the true client IP (Railway
|
|
241
|
+
// replaces XFF with the edge address). Pass it through — but only under
|
|
242
|
+
// the same opt-in that allowed it to win resolution above, and never when
|
|
243
|
+
// untrusted, when it is client-set bytes like every other proxy header.
|
|
244
|
+
if (trustCfConnectingIp) {
|
|
245
|
+
const cloudflareIp = request.headers.get("cf-connecting-ip")?.trim();
|
|
246
|
+
if (cloudflareIp) forwardHeaders["CF-Connecting-IP"] = cloudflareIp;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const userAgent = request.headers.get("user-agent");
|
|
250
|
+
if (userAgent) forwardHeaders["User-Agent"] = userAgent;
|
|
251
|
+
|
|
252
|
+
const acceptLanguage = request.headers.get("accept-language");
|
|
253
|
+
if (acceptLanguage) forwardHeaders["Accept-Language"] = acceptLanguage;
|
|
254
|
+
|
|
255
|
+
const umamiCache = request.headers.get("x-umami-cache");
|
|
256
|
+
if (umamiCache) forwardHeaders["x-umami-cache"] = umamiCache;
|
|
257
|
+
|
|
258
|
+
let upstreamResponse;
|
|
259
|
+
try {
|
|
260
|
+
upstreamResponse = await fetch(`${upstreamBase}/api/${endpoint}`, {
|
|
261
|
+
method: "POST",
|
|
262
|
+
body: body.bytes,
|
|
263
|
+
headers: forwardHeaders,
|
|
264
|
+
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
|
265
|
+
});
|
|
266
|
+
} catch (error) {
|
|
267
|
+
logger.warn("Umami ingest proxy upstream unreachable", {
|
|
268
|
+
endpoint,
|
|
269
|
+
message: error?.message ?? String(error),
|
|
270
|
+
});
|
|
271
|
+
return failOpenResponse(endpoint);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (upstreamResponse.status >= 500) {
|
|
275
|
+
logger.warn("Umami ingest proxy upstream error", {
|
|
276
|
+
endpoint,
|
|
277
|
+
status: upstreamResponse.status,
|
|
278
|
+
});
|
|
279
|
+
return failOpenResponse(endpoint);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
let responseBody;
|
|
283
|
+
try {
|
|
284
|
+
responseBody = await upstreamResponse.arrayBuffer();
|
|
285
|
+
} catch (error) {
|
|
286
|
+
logger.warn("Umami ingest proxy upstream body unreadable", {
|
|
287
|
+
endpoint,
|
|
288
|
+
message: error?.message ?? String(error),
|
|
289
|
+
});
|
|
290
|
+
return failOpenResponse(endpoint);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const responseHeaders = noStoreHeaders();
|
|
294
|
+
const upstreamContentType = upstreamResponse.headers.get("content-type");
|
|
295
|
+
if (upstreamContentType) {
|
|
296
|
+
responseHeaders["Content-Type"] = upstreamContentType;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Never pass upstream Set-Cookie through to the browser.
|
|
300
|
+
return new NextResponse(responseBody, {
|
|
301
|
+
status: upstreamResponse.status,
|
|
302
|
+
headers: responseHeaders,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function GET() {
|
|
307
|
+
return methodNotAllowed();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function PUT() {
|
|
311
|
+
return methodNotAllowed();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function PATCH() {
|
|
315
|
+
return methodNotAllowed();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function DELETE() {
|
|
319
|
+
return methodNotAllowed();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function HEAD() {
|
|
323
|
+
return methodNotAllowed();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function OPTIONS() {
|
|
327
|
+
return methodNotAllowed();
|
|
328
|
+
}
|
|
@@ -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
|
}
|