From 1b1da652d3d26964171e214d1dc884269f19f186 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 4 Oct 2026 15:13:52 +0200 Subject: [PATCH] fix(monitoring): route Umami events to the endpoint the tracker was built with The tracker posts to `${data-host-url}${collect path}`, and the collect path is compiled into the operator's Umami build: /api/send by default, anything else when the instance sets COLLECT_API_ENDPOINT. The tag pointed data-host-url at the app origin and the proxy listened only on /api/send, so a renamed endpoint sent every page view and event to an app path that does not exist and got a full 404 page back each time. Point data-host-url at /api/monitoring/umami and answer every path under it with one proxy route. The route reads the collect path from the configured tracker (cached for an hour), forwards only that path to the Umami host, and answers anything else, or an unreadable tracker, with an empty response. The admin test event posts to the same learnt path. The send-URL resolver lives in one module instead of two copies. --- .../openapi-route-coverage-guard.test.ts | 2 +- .../api/admin/monitoring/umami-test/route.ts | 36 ++-- src/app/api/monitoring/umami-script/route.ts | 47 +---- .../umami/[...path]/__tests__/route.test.ts | 166 ++++++++++++++++++ .../api/monitoring/umami/[...path]/route.ts | 107 +++++++++++ src/app/api/send/__tests__/route.test.ts | 103 ----------- src/app/api/send/route.ts | 104 ----------- src/components/monitoring/umami-script.tsx | 12 +- src/lib/monitoring/__tests__/umami.test.ts | 59 +++++++ src/lib/monitoring/umami-paths.ts | 19 ++ src/lib/monitoring/umami.ts | 113 ++++++++++++ src/proxy.ts | 1 - 12 files changed, 494 insertions(+), 275 deletions(-) create mode 100644 src/app/api/monitoring/umami/[...path]/__tests__/route.test.ts create mode 100644 src/app/api/monitoring/umami/[...path]/route.ts delete mode 100644 src/app/api/send/__tests__/route.test.ts delete mode 100644 src/app/api/send/route.ts create mode 100644 src/lib/monitoring/__tests__/umami.test.ts create mode 100644 src/lib/monitoring/umami-paths.ts create mode 100644 src/lib/monitoring/umami.ts diff --git a/src/__tests__/openapi-route-coverage-guard.test.ts b/src/__tests__/openapi-route-coverage-guard.test.ts index d46bb5b8c..3b23a8804 100644 --- a/src/__tests__/openapi-route-coverage-guard.test.ts +++ b/src/__tests__/openapi-route-coverage-guard.test.ts @@ -320,7 +320,7 @@ const UNPUBLISHED: Readonly> = { "/api/monitoring/glitchtip": { kind: "internalOps", methods: ["POST"] }, "/api/monitoring/settings": { kind: "internalOps", methods: ["GET"] }, "/api/monitoring/umami-script": { kind: "internalOps", methods: ["GET"] }, - "/api/send": { kind: "internalOps", methods: ["POST"] }, + "/api/monitoring/umami/{path}": { kind: "internalOps", methods: ["POST"] }, "/api/mcp/oauth/authorize": { kind: "mcpTransport", methods: ["GET", "POST"], diff --git a/src/app/api/admin/monitoring/umami-test/route.ts b/src/app/api/admin/monitoring/umami-test/route.ts index e3a9d6fc1..52db57a1c 100644 --- a/src/app/api/admin/monitoring/umami-test/route.ts +++ b/src/app/api/admin/monitoring/umami-test/route.ts @@ -4,6 +4,10 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler"; import { annotate, getEvent } from "@/lib/logging/context"; import { apiError, apiSuccess } from "@/lib/api-response"; import { getPublicMonitoringSettings } from "@/lib/monitoring-settings"; +import { + resolveUmamiCollectPath, + resolveUmamiSendUrls, +} from "@/lib/monitoring/umami"; import { safeFetch } from "@/lib/safe-fetch"; export const dynamic = "force-dynamic"; @@ -12,30 +16,6 @@ function redact(text: string): string { return text.replace(/https?:\/\/\S+/gi, "[url]"); } -function resolveUmamiSendUrls(scriptUrl: string | null): string[] { - if (!scriptUrl) return []; - try { - const parsed = new URL(scriptUrl); - const origin = parsed.origin; - const pathSegments = parsed.pathname.split("/").filter(Boolean); - const segments = [...pathSegments]; - - if (segments.length > 0 && segments[segments.length - 1]?.includes(".")) { - segments.pop(); - } - - const prefix = segments.length > 0 ? `/${segments.join("/")}` : ""; - const candidates = [ - `${origin}${prefix}/api/send`, - `${origin}/api/send`, - `${origin}/umami/api/send`, - ]; - return Array.from(new Set(candidates)); - } catch { - return []; - } -} - function resolveAppUrl(request: NextRequest): URL { const configured = envValue("APP_URL") ?? @@ -63,7 +43,13 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Umami script URL and website ID must be configured", 422); } - const targetUrls = resolveUmamiSendUrls(settings.umamiScriptUrl); + // The collect path is compiled into the operator's tracker build + // (`COLLECT_API_ENDPOINT`), so the test posts where the tracker would. + const collectPath = await resolveUmamiCollectPath(settings.umamiScriptUrl); + if (collectPath === null) { + return apiError("Umami script could not be fetched", 502); + } + const targetUrls = resolveUmamiSendUrls(settings.umamiScriptUrl, collectPath); if (targetUrls.length === 0) { return apiError("Umami script URL is invalid", 422); } diff --git a/src/app/api/monitoring/umami-script/route.ts b/src/app/api/monitoring/umami-script/route.ts index 4482c9c71..d625bb995 100644 --- a/src/app/api/monitoring/umami-script/route.ts +++ b/src/app/api/monitoring/umami-script/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { getPublicMonitoringSettings } from "@/lib/monitoring-settings"; -import { safeFetch } from "@/lib/safe-fetch"; +import { fetchUmamiScript } from "@/lib/monitoring/umami"; export const dynamic = "force-dynamic"; @@ -26,35 +26,14 @@ export const GET = apiHandler(async () => { }); } - // Fail-soft: an unreachable / slow / rebinding-refused upstream must - // degrade to the noop script, not 500 the proxy route — `safeFetch` - // THROWS on its 15 s timeout and on the connect-time public-host - // refusal, and an uncaught throw here surfaced as a 500 on every - // mount whenever the operator's Umami host was down or unresolvable. - let response: Response; - try { - response = await safeFetch( - settings.umamiScriptUrl, - { - next: { revalidate: 3600 }, - }, - // Operator-configured host — pin the connect-time IP so a low-TTL - // DNS record cannot rebind the fetch at an internal range between - // the input-time `isPublicUrl` accept and the socket connect. - { requirePublicHost: true }, - ); - } catch (err) { - // Fail-soft stays a 200, but the failure must stay observable: - // a proper action name keeps the degraded state visible on the - // wide-event dashboards instead of hiding behind a bare meta flag. + // Fail-soft: an unreachable / slow / rebinding-refused upstream degrades + // to the noop script, never a 500 on every mount. The failure stays + // observable under its own action name on the wide-event dashboards. + const fetched = await fetchUmamiScript(settings.umamiScriptUrl); + if (!fetched.ok) { annotate({ action: { name: "monitoring.umami_script.fetch_failed" }, - meta: { - reason: - err instanceof Error - ? err.message.slice(0, 200) - : String(err).slice(0, 200), - }, + meta: { reason: fetched.reason }, }); return new NextResponse(NOOP_SCRIPT, { status: 200, @@ -64,18 +43,8 @@ export const GET = apiHandler(async () => { }, }); } - if (!response.ok) { - return new NextResponse(NOOP_SCRIPT, { - status: 200, - headers: { - "Content-Type": "application/javascript; charset=utf-8", - "Cache-Control": "public, max-age=60", - }, - }); - } - const script = await response.text(); - return new NextResponse(script, { + return new NextResponse(fetched.script, { status: 200, headers: { "Content-Type": "application/javascript; charset=utf-8", diff --git a/src/app/api/monitoring/umami/[...path]/__tests__/route.test.ts b/src/app/api/monitoring/umami/[...path]/__tests__/route.test.ts new file mode 100644 index 000000000..8093f0fe0 --- /dev/null +++ b/src/app/api/monitoring/umami/[...path]/__tests__/route.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +// safeFetch's requirePublicHost path runs through undici's own `fetch` +// (version-locked with its dispatcher). Delegate it to the global `fetch` +// stub these tests install so the interception still applies. +vi.mock("undici", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetch: (input: unknown, init?: unknown) => + (globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)( + input, + init, + ), + }; +}); + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); + +vi.mock("@/lib/monitoring-settings", () => ({ + getPublicMonitoringSettings: vi.fn(), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), +})); + +import { POST } from "../route"; +import { getPublicMonitoringSettings } from "@/lib/monitoring-settings"; +import { resetUmamiCollectPathCache } from "@/lib/monitoring/umami"; +import { checkRateLimit } from "@/lib/rate-limit"; + +const SCRIPT_URL = "https://analytics.example.com/script.js"; + +/** A built tracker's endpoint template, as Umami compiles it. */ +function tracker(collectPath: string): string { + return `!function(){const R=\`\${(x||""||u.src.split("/").slice(0,-1).join("/")).replace(/\\/$/,"")}${collectPath}\`;}();`; +} + +function post(path: string[], ip = "203.0.113.11") { + return POST( + new NextRequest(`http://localhost/api/monitoring/umami/${path.join("/")}`, { + method: "POST", + body: JSON.stringify({ type: "event" }), + headers: { "content-type": "application/json", "x-real-ip": ip }, + }), + { params: Promise.resolve({ path }) }, + ); +} + +/** Upstream: the tracker script on GET, `status` on the event POST. */ +function upstream(collectPath: string, status = 202) { + vi.mocked(fetch).mockImplementation(async (input, init) => { + const url = String(input instanceof Request ? input.url : input); + if (url === SCRIPT_URL && (!init?.method || init.method === "GET")) { + return new Response(tracker(collectPath), { status: 200 }); + } + return new Response(null, { status }); + }); +} + +function eventPosts(): string[] { + return vi + .mocked(fetch) + .mock.calls.filter(([, init]) => init?.method === "POST") + .map(([input]) => String(input)); +} + +beforeEach(() => { + vi.resetAllMocks(); + resetUmamiCollectPathCache(); + vi.stubGlobal("fetch", vi.fn()); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 119, + resetAt: Date.now() + 60_000, + } as never); + vi.mocked(getPublicMonitoringSettings).mockResolvedValue({ + umamiEnabled: true, + umamiScriptUrl: SCRIPT_URL, + umamiWebsiteId: "site-1", + } as never); +}); + +describe("POST /api/monitoring/umami/[...path]", () => { + it("rate-limits the public Umami proxy by client IP before anything else", async () => { + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + } as never); + + const response = await post(["api", "send"], "203.0.113.10"); + + expect(response.status).toBe(429); + expect(checkRateLimit).toHaveBeenCalledWith( + "umami-proxy:203.0.113.10", + 120, + 60 * 1000, + ); + expect(getPublicMonitoringSettings).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("forwards a default build's /api/send to the Umami host", async () => { + upstream("/api/send"); + const response = await post(["api", "send"]); + expect(response.status).toBe(202); + expect(eventPosts()).toEqual(["https://analytics.example.com/api/send"]); + }); + + it("forwards a renamed collect endpoint to the same path on the Umami host", async () => { + // A build with COLLECT_API_ENDPOINT=/api/insight posts to + // `/api/insight`. Before the proxy prefix, that landed on + // the app's own origin and 404'd with a full page on every page view. + upstream("/api/insight"); + const response = await post(["api", "insight"]); + expect(response.status).toBe(202); + expect(eventPosts()).toEqual(["https://analytics.example.com/api/insight"]); + }); + + it("refuses any path that is not the tracker's collect path, with an empty body", async () => { + upstream("/api/insight"); + const response = await post(["api", "websites"]); + expect(response.status).toBe(404); + expect(await response.text()).toBe(""); + expect(eventPosts()).toEqual([]); + }); + + it("learns the collect path once, not on every event", async () => { + upstream("/api/insight"); + await post(["api", "insight"]); + await post(["api", "insight"]); + const scriptFetches = vi + .mocked(fetch) + .mock.calls.filter(([input]) => String(input) === SCRIPT_URL); + expect(scriptFetches).toHaveLength(1); + expect(eventPosts()).toHaveLength(2); + }); + + it("answers an empty 502 when the tracker cannot be read", async () => { + vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 500 })); + const response = await post(["api", "send"]); + expect(response.status).toBe(502); + expect(await response.text()).toBe(""); + expect(eventPosts()).toEqual([]); + }); + + it("answers 204 without touching the network when Umami is off", async () => { + vi.mocked(getPublicMonitoringSettings).mockResolvedValue({ + umamiEnabled: false, + umamiScriptUrl: SCRIPT_URL, + umamiWebsiteId: "site-1", + } as never); + const response = await post(["api", "send"]); + expect(response.status).toBe(204); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/monitoring/umami/[...path]/route.ts b/src/app/api/monitoring/umami/[...path]/route.ts new file mode 100644 index 000000000..70e83ad3b --- /dev/null +++ b/src/app/api/monitoring/umami/[...path]/route.ts @@ -0,0 +1,107 @@ +import { NextRequest, NextResponse } from "next/server"; +import { apiHandler } from "@/lib/api-handler"; +import { apiError, getClientIp } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { getPublicMonitoringSettings } from "@/lib/monitoring-settings"; +import { + resolveUmamiCollectPath, + resolveUmamiSendUrls, +} from "@/lib/monitoring/umami"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { safeFetch } from "@/lib/safe-fetch"; + +export const dynamic = "force-dynamic"; + +/** An empty answer: a refused event must not cost the tracker a 404 page. */ +function empty(status: number): NextResponse { + return new NextResponse(null, { status }); +} + +/** + * Same-origin proxy for the browser's Umami tracker. + * + * The tracker is loaded with `data-host-url` set to `UMAMI_PROXY_PREFIX`, so + * it posts to ``, where the collect path is whatever + * the operator's Umami build calls its endpoint (`/api/send` by default). + * The path after the prefix must be exactly the collect path compiled into + * the configured tracker; anything else is refused, so the proxy forwards + * events and nothing else to the operator's host. + */ +export const POST = apiHandler( + async ( + request: NextRequest, + context: { params: Promise<{ path: string[] }> }, + ) => { + annotate({ action: { name: "umami.proxy" } }); + + const ip = getClientIp(request) ?? "unknown"; + const rl = await checkRateLimit(`umami-proxy:${ip}`, 120, 60 * 1000); + if (!rl.allowed) return apiError("Rate limit exceeded", 429); + + const settings = await getPublicMonitoringSettings(); + if ( + !settings.umamiEnabled || + !settings.umamiScriptUrl || + !settings.umamiWebsiteId + ) { + return empty(204); + } + + const { path } = await context.params; + const requested = `/${path.join("/")}`; + const collectPath = await resolveUmamiCollectPath(settings.umamiScriptUrl); + if (collectPath === null) { + annotate({ + action: { name: "umami.proxy.script_unavailable" }, + }); + return empty(502); + } + if (requested !== collectPath) { + annotate({ + action: { name: "umami.proxy.path_refused" }, + meta: { requested: requested.slice(0, 128), collectPath }, + }); + return empty(404); + } + + const targetUrls = resolveUmamiSendUrls( + settings.umamiScriptUrl, + collectPath, + ); + if (targetUrls.length === 0) return empty(204); + + const body = await request.arrayBuffer(); + // Cap the proxied body at 64 KB. + if (body.byteLength > 65536) return empty(413); + + let lastStatus = 404; + for (const targetUrl of targetUrls) { + let upstream: Response; + try { + upstream = await safeFetch( + targetUrl, + { + method: "POST", + headers: { + "content-type": + request.headers.get("content-type") || "application/json", + "user-agent": + request.headers.get("user-agent") || "healthlog-proxy", + }, + body, + cache: "no-store", + }, + // Operator-configured Umami host: pin the connect-time IP. + { requirePublicHost: true }, + ); + } catch { + annotate({ action: { name: "umami.proxy.upstream_failed" } }); + return empty(502); + } + lastStatus = upstream.status; + // Only a path miss moves on to the next candidate. + if (upstream.status !== 404) return empty(upstream.status); + } + return empty(lastStatus); + }, +); diff --git a/src/app/api/send/__tests__/route.test.ts b/src/app/api/send/__tests__/route.test.ts deleted file mode 100644 index 874cca183..000000000 --- a/src/app/api/send/__tests__/route.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest } from "next/server"; - -// safeFetch's requirePublicHost path runs through undici's own `fetch` -// (version-locked with its dispatcher). Delegate it to the global `fetch` -// stub these tests install so the existing interception still applies. -vi.mock("undici", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - fetch: (input: unknown, init?: unknown) => - (globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)( - input, - init, - ), - }; -}); - -vi.mock("@/lib/api-handler", () => ({ - apiHandler: unknown>(fn: T) => fn, -})); - -vi.mock("@/lib/logging/context", () => ({ - annotate: vi.fn(), -})); - -vi.mock("@/lib/monitoring-settings", () => ({ - getPublicMonitoringSettings: vi.fn(), -})); - -vi.mock("@/lib/rate-limit", () => ({ - checkRateLimit: vi.fn(), -})); - -import { POST } from "../route"; -import { getPublicMonitoringSettings } from "@/lib/monitoring-settings"; -import { checkRateLimit } from "@/lib/rate-limit"; - -beforeEach(() => { - vi.resetAllMocks(); - vi.stubGlobal("fetch", vi.fn()); -}); - -describe("POST /api/send", () => { - it("rate-limits the public Umami proxy by client IP", async () => { - vi.mocked(checkRateLimit).mockResolvedValue({ - allowed: false, - remaining: 0, - resetAt: Date.now() + 60_000, - } as never); - - const response = await POST( - new NextRequest("http://localhost/api/send", { - method: "POST", - body: JSON.stringify({ type: "event" }), - headers: { - "content-type": "application/json", - "x-forwarded-for": "203.0.113.10", - }, - }), - ); - - expect(response.status).toBe(429); - expect(checkRateLimit).toHaveBeenCalledWith( - "umami-proxy:203.0.113.10", - 120, - 60 * 1000, - ); - expect(getPublicMonitoringSettings).not.toHaveBeenCalled(); - expect(fetch).not.toHaveBeenCalled(); - }); - - it("continues to proxy when the request is within quota", async () => { - vi.mocked(checkRateLimit).mockResolvedValue({ - allowed: true, - remaining: 119, - resetAt: Date.now() + 60_000, - } as never); - vi.mocked(getPublicMonitoringSettings).mockResolvedValue({ - umamiEnabled: true, - umamiScriptUrl: "https://analytics.example.com/script.js", - umamiWebsiteId: "site-1", - } as never); - vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 202 })); - - const response = await POST( - new NextRequest("http://localhost/api/send", { - method: "POST", - body: JSON.stringify({ type: "event" }), - headers: { - "content-type": "application/json", - "x-real-ip": "203.0.113.11", - }, - }), - ); - - expect(response.status).toBe(202); - expect(fetch).toHaveBeenCalledWith( - "https://analytics.example.com/api/send", - expect.objectContaining({ method: "POST" }), - ); - }); -}); diff --git a/src/app/api/send/route.ts b/src/app/api/send/route.ts deleted file mode 100644 index e8f8e13af..000000000 --- a/src/app/api/send/route.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { apiHandler } from "@/lib/api-handler"; -import { apiError, getClientIp } from "@/lib/api-response"; -import { annotate } from "@/lib/logging/context"; -import { getPublicMonitoringSettings } from "@/lib/monitoring-settings"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { isPublicUrl } from "@/lib/validations/notifications"; -import { safeFetch } from "@/lib/safe-fetch"; - -export const dynamic = "force-dynamic"; - -// Use the canonical `isPublicUrl` guard rather than a hand-rolled -// allowlist. The previous local implementation predated the central -// helper and was missing CGNAT (100.64/10), IPv6 ULA (fc00::/7), the -// octal/hex/decimal IPv4 alt-notations, and the IPv4-mapped IPv6 -// shapes — every one of which the shared helper catches. Dev mode is -// no longer special-cased here: a localhost-or-RFC1918 Umami URL was -// never a valid configuration anyway (the proxy ships the request to -// the configured host with the visitor's user-agent + body, which -// only makes sense against an externally-reachable analytics target). -function isAllowedUmamiUrl(url: string): boolean { - return isPublicUrl(url); -} - -function resolveUmamiSendUrls(scriptUrl: string | null): string[] { - if (!scriptUrl) return []; - try { - const parsed = new URL(scriptUrl); - const origin = parsed.origin; - const pathSegments = parsed.pathname.split("/").filter(Boolean); - const segments = [...pathSegments]; - - // Remove script file segment (e.g. script.js, umami.js) if present. - if (segments.length > 0 && segments[segments.length - 1]?.includes(".")) { - segments.pop(); - } - - const prefix = segments.length > 0 ? `/${segments.join("/")}` : ""; - const candidates = [ - `${origin}${prefix}/api/send`, - `${origin}/api/send`, - `${origin}/umami/api/send`, - ]; - - return Array.from(new Set(candidates.filter(isAllowedUmamiUrl))); - } catch { - return []; - } -} - -export const POST = apiHandler(async (request: NextRequest) => { - annotate({ action: { name: "umami.proxy" } }); - - const ip = getClientIp(request) ?? "unknown"; - const rl = await checkRateLimit(`umami-proxy:${ip}`, 120, 60 * 1000); - if (!rl.allowed) return apiError("Rate limit exceeded", 429); - - const settings = await getPublicMonitoringSettings(); - if (!settings.umamiEnabled) { - return NextResponse.json({}, { status: 204 }); - } - - const targetUrls = resolveUmamiSendUrls(settings.umamiScriptUrl); - if (targetUrls.length === 0 || !settings.umamiWebsiteId) { - return NextResponse.json({}, { status: 204 }); - } - - const body = await request.arrayBuffer(); - // Limit proxy body size to 64 KB to prevent abuse - if (body.byteLength > 65536) { - return NextResponse.json({}, { status: 413 }); - } - - let lastResponseStatus = 404; - - for (const targetUrl of targetUrls) { - const upstream = await safeFetch( - targetUrl, - { - method: "POST", - headers: { - "content-type": - request.headers.get("content-type") || "application/json", - "user-agent": request.headers.get("user-agent") || "healthlog-proxy", - }, - body, - cache: "no-store", - }, - // Operator-configured Umami host — pin the connect-time IP. - { requirePublicHost: true }, - ); - - lastResponseStatus = upstream.status; - - // Try next candidate only for path misses. - if (upstream.status === 404) { - continue; - } - - return new NextResponse(null, { status: upstream.status }); - } - - return new NextResponse(null, { status: lastResponseStatus }); -}); diff --git a/src/components/monitoring/umami-script.tsx b/src/components/monitoring/umami-script.tsx index 00265cbd6..264c65d69 100644 --- a/src/components/monitoring/umami-script.tsx +++ b/src/components/monitoring/umami-script.tsx @@ -2,6 +2,8 @@ import { useEffect } from "react"; +import { UMAMI_PROXY_PREFIX } from "@/lib/monitoring/umami-paths"; + interface UmamiScriptProps { enabled: boolean; websiteId: string | null; @@ -22,8 +24,14 @@ export function UmamiScript({ enabled, websiteId }: UmamiScriptProps) { script.defer = true; script.src = "/api/monitoring/umami-script"; script.setAttribute("data-website-id", websiteId); - // Force same-origin tracking endpoint so CSP stays strict. - script.setAttribute("data-host-url", window.location.origin); + // Send events to the same-origin proxy so CSP stays strict. The tracker + // appends its own collect path (`/api/send`, or whatever the operator's + // Umami build renamed it to), and every path under the prefix reaches + // the proxy route. + script.setAttribute( + "data-host-url", + `${window.location.origin}${UMAMI_PROXY_PREFIX}`, + ); if (existing) { existing.replaceWith(script); diff --git a/src/lib/monitoring/__tests__/umami.test.ts b/src/lib/monitoring/__tests__/umami.test.ts new file mode 100644 index 000000000..bf9827bac --- /dev/null +++ b/src/lib/monitoring/__tests__/umami.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; + +import { + extractUmamiCollectPath, + resolveUmamiSendUrls, +} from "@/lib/monitoring/umami"; +import { UMAMI_PROXY_PREFIX } from "@/lib/monitoring/umami-paths"; + +/** The endpoint template of a built Umami tracker. */ +function tracker(collectPath: string): string { + return `const{currentScript:u}=c;const R=\`\${(x||""||u.src.split("/").slice(0,-1).join("/")).replace(/\\/$/,"")}${collectPath}\`,_=\`\${e}x\${a}\`;`; +} + +describe("extractUmamiCollectPath", () => { + it("reads Umami's default endpoint", () => { + expect(extractUmamiCollectPath(tracker("/api/send"))).toBe("/api/send"); + }); + + it("reads an endpoint renamed through COLLECT_API_ENDPOINT", () => { + expect(extractUmamiCollectPath(tracker("/api/insight"))).toBe( + "/api/insight", + ); + expect(extractUmamiCollectPath(tracker("/collect"))).toBe("/collect"); + }); + + it("falls back to the default for an unrecognised build", () => { + expect(extractUmamiCollectPath("/* umami disabled */")).toBe("/api/send"); + }); + + it("never accepts a traversal as the collect path", () => { + expect(extractUmamiCollectPath(tracker("/../admin"))).toBe("/api/send"); + }); +}); + +describe("resolveUmamiSendUrls", () => { + it("tries the script directory, the host root and the /umami mount", () => { + expect( + resolveUmamiSendUrls( + "https://stats.example.com/umami/script.js", + "/api/insight", + ), + ).toEqual([ + "https://stats.example.com/umami/api/insight", + "https://stats.example.com/api/insight", + ]); + }); + + it("refuses a private host", () => { + expect( + resolveUmamiSendUrls("http://10.0.0.5/script.js", "/api/send"), + ).toEqual([]); + }); +}); + +describe("UMAMI_PROXY_PREFIX", () => { + it("sits under the public monitoring family the proxy gate admits", () => { + expect(UMAMI_PROXY_PREFIX.startsWith("/api/monitoring/")).toBe(true); + }); +}); diff --git a/src/lib/monitoring/umami-paths.ts b/src/lib/monitoring/umami-paths.ts new file mode 100644 index 000000000..edd6a6f44 --- /dev/null +++ b/src/lib/monitoring/umami-paths.ts @@ -0,0 +1,19 @@ +// Client-safe: imported by the tracker tag, so it carries no server code. + +/** + * Where the browser's Umami tracker sends its events, relative to the app. + * + * The tracker posts to `${data-host-url}${collectPath}`, and the collect + * path is compiled into the operator's tracker build: `/api/send` by + * default, anything at all when the Umami instance sets + * `COLLECT_API_ENDPOINT` (a common ad-blocker workaround). Pointing + * `data-host-url` at the app origin therefore only worked for default + * builds; a renamed endpoint posted every page view to an app path that did + * not exist. Every path under this prefix is answered by the one proxy route + * (`src/app/api/monitoring/umami/[...path]/route.ts`), so whatever the build + * calls its endpoint, the request lands somewhere that forwards it. + */ +export const UMAMI_PROXY_PREFIX = "/api/monitoring/umami"; + +/** Umami's own default collect endpoint. */ +export const UMAMI_DEFAULT_COLLECT_PATH = "/api/send"; diff --git a/src/lib/monitoring/umami.ts b/src/lib/monitoring/umami.ts new file mode 100644 index 000000000..a973dde5a --- /dev/null +++ b/src/lib/monitoring/umami.ts @@ -0,0 +1,113 @@ +import { safeFetch } from "@/lib/safe-fetch"; +import { isPublicUrl } from "@/lib/validations/notifications"; + +import { UMAMI_DEFAULT_COLLECT_PATH } from "./umami-paths"; + +/** + * The endpoint template in a built tracker: + * `` `${(hostUrl || …).replace(/\/$/,"")}/api/send` ``. The capture is the + * path the build appends to the host. + */ +const COLLECT_PATH_PATTERN = + /\.replace\(\/\\\/\$\/,\s*(?:""|'')\)\}(\/[\w\-./~]{1,128})`/; + +/** A collect path is a plain absolute path: no traversal, no query. */ +function isPlainPath(path: string): boolean { + return /^\/[\w\-./~]{1,128}$/.test(path) && !path.split("/").includes(".."); +} + +/** + * The collect path compiled into a tracker build, or Umami's default when + * the build does not carry a recognisable endpoint template. + */ +export function extractUmamiCollectPath(script: string): string { + const match = COLLECT_PATH_PATTERN.exec(script)?.[1]; + return match && isPlainPath(match) ? match : UMAMI_DEFAULT_COLLECT_PATH; +} + +/** + * Upstream URLs to try for an event, in order: the collect path under the + * script's own directory (Umami served below a path prefix), at the host + * root, and under the conventional `/umami` mount. Only public URLs survive. + */ +export function resolveUmamiSendUrls( + scriptUrl: string | null, + collectPath: string = UMAMI_DEFAULT_COLLECT_PATH, +): string[] { + if (!scriptUrl || !isPlainPath(collectPath)) return []; + try { + const parsed = new URL(scriptUrl); + const origin = parsed.origin; + const segments = parsed.pathname.split("/").filter(Boolean); + // Drop the script file itself (script.js, umami.js, …). + if (segments.length > 0 && segments[segments.length - 1]?.includes(".")) { + segments.pop(); + } + const prefix = segments.length > 0 ? `/${segments.join("/")}` : ""; + const candidates = [ + `${origin}${prefix}${collectPath}`, + `${origin}${collectPath}`, + `${origin}/umami${collectPath}`, + ]; + return Array.from(new Set(candidates.filter((url) => isPublicUrl(url)))); + } catch { + return []; + } +} + +const COLLECT_PATH_TTL_MS = 60 * 60 * 1000; +const collectPathCache = new Map(); + +export type UmamiScriptFetch = + { ok: true; script: string } | { ok: false; reason: string }; + +/** + * Fetch the operator's tracker. Fail-soft: an unreachable, slow, refused or + * non-2xx upstream comes back as `{ ok: false, reason }`, never a throw. A + * successful fetch also records the build's collect path, so the event proxy + * learns it without a fetch of its own. + */ +export async function fetchUmamiScript( + scriptUrl: string, +): Promise { + try { + const response = await safeFetch( + scriptUrl, + { next: { revalidate: 3600 } }, + // Operator-configured host: pin the connect-time IP so a low-TTL DNS + // record cannot rebind the fetch at an internal range. + { requirePublicHost: true }, + ); + if (!response.ok) return { ok: false, reason: `HTTP ${response.status}` }; + const script = await response.text(); + collectPathCache.set(scriptUrl, { + path: extractUmamiCollectPath(script), + expiresAt: Date.now() + COLLECT_PATH_TTL_MS, + }); + return { ok: true, script }; + } catch (err) { + return { + ok: false, + reason: (err instanceof Error ? err.message : String(err)).slice(0, 200), + }; + } +} + +/** + * The collect path of the configured tracker, from the cache when fresh, + * otherwise by fetching the script. `null` when the script cannot be read, + * so the caller can refuse rather than guess. + */ +export async function resolveUmamiCollectPath( + scriptUrl: string, +): Promise { + const cached = collectPathCache.get(scriptUrl); + if (cached && cached.expiresAt > Date.now()) return cached.path; + const fetched = await fetchUmamiScript(scriptUrl); + return fetched.ok ? extractUmamiCollectPath(fetched.script) : null; +} + +/** Test seam: forget every learnt collect path. */ +export function resetUmamiCollectPathCache(): void { + collectPathCache.clear(); +} diff --git a/src/proxy.ts b/src/proxy.ts index b514ab5a4..d2bd8b033 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -41,7 +41,6 @@ export const PUBLIC_PATHS = [ "/api/version", "/api/notifications/vapid", "/api/monitoring/", - "/api/send", "/api/withings/webhook", // v1.11.0 — WHOOP webhook (`recovery.updated` / `sleep.updated` / // `workout.updated`, + `*.deleted`). Authenticated by the path-segment