Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions app/(frontend)/stats/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
const umamiUrl = readRuntimeConfigFromEnv().umamiUrl
if (!umamiUrl) {
return new NextResponse(null, { status: 204 })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Silent 204 response hides missing Umami configuration

When NEXT_PUBLIC_UMAMI_URL is not set, the route returns 204 (No Content) instead of 404 or 500. This makes the client think the analytics request succeeded when it was actually dropped. The previous rewrite approach simply didn't create the route (returning []), so clients would get a 404. Consider returning 503 or 404 to surface configuration issues, or at minimum log a warning server-side.

}

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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Filter sensitive headers from upstream response

Passing all upstream headers (upstream.headers) back to the client could leak headers like set-cookie, content-encoding, transfer-encoding, etc. For an analytics proxy, consider filtering to only safe headers (e.g., content-type, cache-control) or explicitly removing sensitive ones.

})
}
4 changes: 2 additions & 2 deletions src/lib/umami/sendUmamiPayload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
// 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
Expand Down
Loading