@usequark/quark-create-app 1.27.2 → 1.27.4
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 +12 -0
- package/templates/base-project/apps/web/src/app/s/api/[endpoint]/route.js +60 -18
- package/templates/base-project/apps/web/src/lib/analytics/umami-config.js +5 -1
- package/templates/base-project/apps/web/src/lib/analytics/umami-web-vitals.js +25 -10
- package/templates/base-project/apps/web/src/lib/proxy-auth.js +30 -0
package/package.json
CHANGED
|
@@ -118,6 +118,18 @@ ADMIN_PASSWORD=SET_ME
|
|
|
118
118
|
# cannot earn a fresh bucket. Boolean-like: true/false/1/0/yes/no/on/off.
|
|
119
119
|
# TRUST_PROXY_HEADERS=true
|
|
120
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
|
+
|
|
121
133
|
# --- File Storage ---
|
|
122
134
|
# Default: local filesystem (./uploads). No further config needed for development.
|
|
123
135
|
STORAGE_PROVIDER=local
|
|
@@ -14,9 +14,16 @@
|
|
|
14
14
|
* - POST-only — every other method answers 405.
|
|
15
15
|
* - Header hygiene — `cookie`, `authorization`, and `x-metrics-token` are
|
|
16
16
|
* never forwarded; only the analytics-relevant headers cross the boundary.
|
|
17
|
-
* - `X-Forwarded-For` / `X-Real-IP`
|
|
18
|
-
* visitor, not the Railway egress IP — unless
|
|
19
|
-
* in which case "unknown" crosses instead (same
|
|
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.
|
|
20
27
|
* - 1 MB body cap enforced while streaming (a missing/lying Content-Length
|
|
21
28
|
* cannot force full buffering) + 5s upstream timeout bound abuse cost.
|
|
22
29
|
* - `Cache-Control: no-store` on every response; upstream `Set-Cookie` is
|
|
@@ -35,7 +42,10 @@
|
|
|
35
42
|
import { createLogger } from "@usequark/quark-core/core";
|
|
36
43
|
import { NextResponse } from "next/server";
|
|
37
44
|
import { getUmamiUpstreamBase } from "@/lib/analytics/umami-config.js";
|
|
38
|
-
import {
|
|
45
|
+
import {
|
|
46
|
+
shouldTrustCfConnectingIp,
|
|
47
|
+
shouldTrustProxyHeaders,
|
|
48
|
+
} from "@/lib/proxy-auth.js";
|
|
39
49
|
|
|
40
50
|
const logger = createLogger("umami-proxy");
|
|
41
51
|
|
|
@@ -54,8 +64,8 @@ function methodNotAllowed() {
|
|
|
54
64
|
);
|
|
55
65
|
}
|
|
56
66
|
|
|
57
|
-
// Fail-open without losing replay data. Tracker
|
|
58
|
-
//
|
|
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
|
|
59
69
|
// `{ ok: true }` to avoid any client-side retry storm against a struggling
|
|
60
70
|
// upstream. Replay (`record`) has a restore/retry path keyed on
|
|
61
71
|
// `body.ok === false` in postReplayBatch: answering `ok: true` there would
|
|
@@ -72,7 +82,26 @@ function failOpenResponse(endpoint) {
|
|
|
72
82
|
});
|
|
73
83
|
}
|
|
74
84
|
|
|
75
|
-
function resolveClientIp(request) {
|
|
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
|
+
|
|
76
105
|
const forwardedFor = request.headers.get("x-forwarded-for");
|
|
77
106
|
if (forwardedFor) {
|
|
78
107
|
const client = forwardedFor
|
|
@@ -187,23 +216,36 @@ export async function POST(request, { params } = {}) {
|
|
|
187
216
|
// TRUST_PROXY_HEADERS=false the app is directly reachable, the headers
|
|
188
217
|
// are attacker-controlled, and forwarding them would poison Umami geo.
|
|
189
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.
|
|
190
222
|
const trustProxyHeaders = shouldTrustProxyHeaders();
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const forwardedFor = incomingForwardedFor?.trim()
|
|
196
|
-
? incomingForwardedFor
|
|
197
|
-
: clientIp;
|
|
223
|
+
const trustCfConnectingIp = trustProxyHeaders && shouldTrustCfConnectingIp();
|
|
224
|
+
const clientIp = trustProxyHeaders
|
|
225
|
+
? resolveClientIp(request, { trustCfConnectingIp })
|
|
226
|
+
: "unknown";
|
|
198
227
|
|
|
199
228
|
const forwardHeaders = {
|
|
200
229
|
"Content-Type": "application/json",
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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,
|
|
205
237
|
};
|
|
206
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
|
+
|
|
207
249
|
const userAgent = request.headers.get("user-agent");
|
|
208
250
|
if (userAgent) forwardHeaders["User-Agent"] = userAgent;
|
|
209
251
|
|
|
@@ -58,12 +58,16 @@ export const UMAMI_PROXY_BASE = "/s";
|
|
|
58
58
|
export const UMAMI_PROXY_SCRIPT_PATH = "/s/script.js";
|
|
59
59
|
export const UMAMI_PROXY_HOST_URL = "/s";
|
|
60
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.
|
|
61
65
|
export const UMAMI_PROXY_COLLECT_PATH = "/s/api/collect";
|
|
62
66
|
export const UMAMI_PROXY_RECORD_PATH = "/s/api/record";
|
|
63
67
|
|
|
64
68
|
// Server-side upstream preference: an operator that wants the analytics host
|
|
65
69
|
// out of the client bundle entirely sets UMAMI_UPSTREAM_URL (or UMAMI_URL)
|
|
66
|
-
// and the
|
|
70
|
+
// and the handler destination follows it. NEXT_PUBLIC_UMAMI_URL
|
|
67
71
|
// remains the default so existing deployments keep working with no new vars.
|
|
68
72
|
export function getUmamiUpstreamBase(env = process.env) {
|
|
69
73
|
const candidates = [
|
|
@@ -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
|
*
|
|
@@ -57,12 +59,22 @@ function sanitiseUrl(url) {
|
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
/**
|
|
60
|
-
* Dispatches a single CWV observation to
|
|
61
|
-
*
|
|
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.
|
|
62
74
|
*
|
|
63
75
|
* @param {{ name: string, value: number, rating: string, navigationType?: string }} metric
|
|
64
76
|
*/
|
|
65
|
-
function dispatchMetric(metric) {
|
|
77
|
+
export function dispatchMetric(metric) {
|
|
66
78
|
const config = getUmamiClientConfig();
|
|
67
79
|
if (!config.enabled) return;
|
|
68
80
|
|
|
@@ -70,23 +82,26 @@ function dispatchMetric(metric) {
|
|
|
70
82
|
type: "event",
|
|
71
83
|
payload: {
|
|
72
84
|
website: config.websiteId,
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
url: sanitiseUrl(window.location.href),
|
|
76
|
-
event_data: {
|
|
85
|
+
name: "cwv",
|
|
86
|
+
data: {
|
|
77
87
|
metric_name: metric.name,
|
|
78
88
|
metric_value: Math.round(metric.value * 1000) / 1000, // round to 3dp
|
|
79
89
|
metric_rating: metric.rating,
|
|
80
90
|
navigation_type: metric.navigationType || "navigate",
|
|
81
91
|
},
|
|
92
|
+
url: sanitiseUrl(window.location.href),
|
|
93
|
+
hostname: window.location.hostname,
|
|
82
94
|
},
|
|
83
95
|
};
|
|
84
96
|
|
|
85
97
|
const body = JSON.stringify(payload);
|
|
86
98
|
|
|
87
99
|
// First-party ingest: same-origin POSTs survive blocklists that match the
|
|
88
|
-
// upstream analytics host as third-party.
|
|
89
|
-
|
|
100
|
+
// upstream analytics host as third-party. The client config carries no
|
|
101
|
+
// upstream URL, so there is no direct-mode fallback — when disabled the
|
|
102
|
+
// function already returned above.
|
|
103
|
+
const endpoint = config.proxySendUrl;
|
|
104
|
+
if (!endpoint) return;
|
|
90
105
|
|
|
91
106
|
// Use sendBeacon for reliability during page lifecycle transitions.
|
|
92
107
|
if (navigator.sendBeacon) {
|
|
@@ -140,3 +140,33 @@ export function shouldTrustProxyHeaders() {
|
|
|
140
140
|
const raw = process.env.TRUST_PROXY_HEADERS?.trim().toLowerCase();
|
|
141
141
|
return !(raw && PROXY_HEADERS_FALSY_VALUES.has(raw));
|
|
142
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
|
+
}
|