From 555e71c0a3feb7100909417ca84f7b0b646edc17 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Fri, 20 Feb 2026 15:10:10 +0100 Subject: [PATCH] fix(analytics): relay umami events via nextjs proxy without exposing website id to client --- .gitea/workflows/deploy.yml | 6 +-- app/stats/api/send/route.ts | 92 +++++++++++++++++++++++++++++++++++++ lib/config.ts | 4 +- lib/env.ts | 2 +- next.config.mjs | 4 -- 5 files changed, 98 insertions(+), 10 deletions(-) create mode 100644 app/stats/api/send/route.ts diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 270eaaa2..c4e8cff1 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -202,7 +202,7 @@ jobs: NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }} NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }} DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }} - NEXT_PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }} + UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }} UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }} NPM_TOKEN=${{ secrets.REGISTRY_PASS }} tags: registry.infra.mintel.me/mintel/klz-cables.com:${{ needs.prepare.outputs.image_tag }} @@ -254,7 +254,7 @@ jobs: GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }} # Analytics - NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }} + UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }} UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }} steps: - name: Checkout repository @@ -321,7 +321,7 @@ jobs: echo "COOKIE_DOMAIN=$COOKIE_DOMAIN" echo "" echo "# Analytics" - echo "NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID" + echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID" echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT" echo "" echo "TARGET=$TARGET" diff --git a/app/stats/api/send/route.ts b/app/stats/api/send/route.ts new file mode 100644 index 00000000..5555522b --- /dev/null +++ b/app/stats/api/send/route.ts @@ -0,0 +1,92 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerAppServices } from '@/lib/services/create-services.server'; +import { config } from '@/lib/config'; + +/** + * Smart Proxy for Umami Analytics. + * + * This Route Handler receives tracking events from the browser, + * injects the secret UMAMI_WEBSITE_ID, and forwards them to the + * internal Umami API endpoint. + * + * This ensures: + * 1. The Website ID is NOT leaked to the client bundle. + * 2. The Umami API endpoint is hidden behind our domain. + * 3. We have full control over the tracking data. + */ +export async function POST(request: NextRequest) { + const services = getServerAppServices(); + const logger = services.logger.child({ component: 'umami-smart-proxy' }); + + try { + const body = await request.json(); + const { type, payload } = body; + + // Inject the secret websiteId from server config + const websiteId = config.analytics.umami.websiteId; + if (!websiteId) { + logger.warn('Umami tracking received but no Website ID configured on server'); + return NextResponse.json({ status: 'ignored' }, { status: 200 }); + } + + // Prepare the enhanced payload with the secret ID + const enhancedPayload = { + ...payload, + website: websiteId, + }; + + const umamiEndpoint = config.analytics.umami.apiEndpoint; + + // Log the event (internal only) + logger.debug('Forwarding analytics event', { + type, + url: payload.url, + website: websiteId.slice(0, 8) + '...', + }); + + const response = await fetch(`${umamiEndpoint}/api/send`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': request.headers.get('user-agent') || 'KLZ-Smart-Proxy', + 'X-Forwarded-For': request.headers.get('x-forwarded-for') || '', + }, + body: JSON.stringify({ type, payload: enhancedPayload }), + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.error('Umami API responded with error', { + status: response.status, + error: errorText.slice(0, 100), + }); + return new NextResponse(errorText, { status: response.status }); + } + + return NextResponse.json({ status: 'ok' }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error ? error.stack : undefined; + + // Console error to ensure it appears in logs even if logger fails + console.error('CRITICAL PROXY ERROR:', { + message: errorMessage, + stack: errorStack, + endpoint: config.analytics.umami.apiEndpoint, + }); + + logger.error('Failed to proxy analytics request', { + error: errorMessage, + stack: errorStack, + }); + + return NextResponse.json( + { + error: 'Internal Server Error', + details: errorMessage, // Expose error for debugging + endpoint: config.analytics.umami.apiEndpoint ? 'configured' : 'missing', + }, + { status: 500 }, + ); + } +} diff --git a/lib/config.ts b/lib/config.ts index cdc3eb75..2b2ee18c 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -35,9 +35,9 @@ function createConfig() { analytics: { umami: { - websiteId: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID, + websiteId: env.UMAMI_WEBSITE_ID, apiEndpoint: env.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me', - enabled: Boolean(env.NEXT_PUBLIC_UMAMI_WEBSITE_ID), + enabled: typeof window !== 'undefined' || Boolean(env.UMAMI_WEBSITE_ID), }, }, diff --git a/lib/env.ts b/lib/env.ts index 441836d3..47567130 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -31,7 +31,7 @@ const envExtension = { INFRA_DIRECTUS_TOKEN: z.string().optional(), // Analytics - NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.string().optional(), + UMAMI_WEBSITE_ID: z.string().optional(), UMAMI_API_ENDPOINT: z.string().optional(), // Mail Configuration diff --git a/next.config.mjs b/next.config.mjs index 6e7054c7..f0b00fc3 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -348,10 +348,6 @@ const nextConfig = { } return [ - { - source: '/stats/:path*', - destination: `${umamiUrl}/:path*`, - }, { source: '/cms/:path*', destination: `${directusUrl}/:path*`,