mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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.
This commit is contained in:
1 parent
2e150e97cf
commit
1b1da652d3
12 files changed
+494
-275
No files matched your search
@@ -320,7 +320,7 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
|
||||
"/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"],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof import("undici")>();
|
||||
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: <T extends (...args: unknown[]) => 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
|
||||
// `<data-host-url>/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();
|
||||
});
|
||||
});
|
||||
@@ -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 `<prefix><collect path>`, 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);
|
||||
},
|
||||
);
|
||||
@@ -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<typeof import("undici")>();
|
||||
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: <T extends (...args: unknown[]) => 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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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<string, { path: string; expiresAt: number }>();
|
||||
|
||||
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<UmamiScriptFetch> {
|
||||
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<string | null> {
|
||||
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();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user