mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Merge pull request #1145 from MBombeck/perf/dashboard-client
perf: faster charts and insights, no sidebar scrollbar flash, Umami events delivered
This commit is contained in:
33 files changed
+1458
-393
No files matched your search
@@ -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 });
|
||||
});
|
||||
Reference in new issue
Block a user