diff --git a/app/(frontend)/stats/[...path]/route.ts b/app/(frontend)/stats/[...path]/route.ts new file mode 100644 index 00000000..4a58027a --- /dev/null +++ b/app/(frontend)/stats/[...path]/route.ts @@ -0,0 +1,43 @@ +import { type NextRequest, NextResponse } from 'next/server' + +import { readRuntimeConfigFromEnv } from '@/lib/runtimeConfig' + +// Proxy browser-side Umami analytics requests through /stats so the +// outgoing host is the site's own domain rather than the Umami server, +// keeping it out of ad-blocker filter lists that target known analytics +// hosts/paths. +// +// The Umami URL is read via readRuntimeConfigFromEnv() (bracket-notation +// process.env lookup) so the bundler never inlines the value at build time, +// which keeps one compiled output deployable across environments without +// triggering check-env-leak in CI. +// `sendUmamiPayload.ts` routes browser requests to /stats/api/send. + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ path: string[] }> }, +): Promise { + const umamiUrl = readRuntimeConfigFromEnv().umamiUrl + if (!umamiUrl) { + return new NextResponse(null, { status: 204 }) + } + + const { path } = await params + const destination = `${umamiUrl}/${path.join('/')}` + + const headers = new Headers(req.headers) + headers.delete('host') + + const upstream = await fetch(destination, { + method: 'POST', + headers, + body: req.body, + // @ts-ignore -- duplex is required for streaming request bodies in Node fetch + duplex: 'half', + }) + + return new NextResponse(upstream.body, { + status: upstream.status, + headers: upstream.headers, + }) +} diff --git a/src/lib/umami/sendUmamiPayload.ts b/src/lib/umami/sendUmamiPayload.ts index 02ac4b27..c54bb094 100644 --- a/src/lib/umami/sendUmamiPayload.ts +++ b/src/lib/umami/sendUmamiPayload.ts @@ -39,8 +39,8 @@ const sendViaFetch = async (url: string, body: string, keepalive: boolean) => { * `console.debug` so tracking issues never break the calling code. */ export const sendUmamiPayload = async (payload: UmamiSendPayload): Promise => { - // In the browser, route through the same-origin `/stats` rewrite (see - // next.config.ts) so the request doesn't visibly hit the Umami domain, + // In the browser, route through the same-origin `/stats` proxy route (see + // app/(frontend)/stats/[...path]/route.ts) so the request doesn't visibly hit the Umami domain, // which keeps it out of ad-blocker filter lists that target known // analytics hosts/paths. On the server there is no origin to resolve a // relative URL against and no rewrite applies to a raw server fetch, so