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..0e80bdae 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.NEXT_PUBLIC_UMAMI_WEBSITE_ID || 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/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*`,