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