import { NextResponse } from "next/server"; import { getPayload } from "payload"; import config from "@payload-config"; import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit"; import { visitorHash } from "@/lib/analytics/salt"; import { classifyBrowser, classifyDevice } from "@/lib/analytics/userAgent"; import { resolveSessionId } from "@/lib/analytics/session"; export const dynamic = "force-dynamic"; const MAX_PATH_LENGTH = 512; const MAX_EVENT_NAME_LENGTH = 100; function normalizePath(value: unknown): string | null { if (typeof value !== "string" || !value.startsWith("/") || value.length > MAX_PATH_LENGTH) return null; return value; } function normalizeEventName(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); if (!trimmed || trimmed.length > MAX_EVENT_NAME_LENGTH) return null; return trimmed; } /** Hostname only (never path/query) - see collections/AnalyticsEvents.ts. */ function referrerHostFrom(referrer: unknown): string | undefined { if (typeof referrer !== "string" || !referrer) return undefined; try { return new URL(referrer).hostname || undefined; } catch { return undefined; } } /** * Public tracking beacon, called by lib/analytics/client.ts only after the * visitor has granted statistics consent - but the client's own claim is * never trusted alone: this route re-checks the CookieSettings global's * `statistics.enabled` flag server-side before writing anything, so a * global kill-switch (or a stale/tampered client) can never bypass it. * * navigator.sendBeacon() can't reliably set a JSON Content-Type across * browsers, so this accepts both application/json and a raw text body. */ export async function POST(request: Request) { const ip = getClientIp(request); if (!checkRateLimit(`analytics:${ip}`, { max: 120, windowMs: 60_000 })) { return new NextResponse(null, { status: 204 }); } let body: { eventName?: unknown; path?: unknown; referrer?: unknown }; try { const raw = await request.text(); body = JSON.parse(raw); if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("Invalid body"); } catch { return new NextResponse(null, { status: 204 }); } const path = normalizePath(body.path); const eventName = normalizeEventName(body.eventName); if (!path || !eventName) { return new NextResponse(null, { status: 204 }); } try { const payload = await getPayload({ config }); const cookieSettings = await payload.findGlobal({ slug: "cookie-settings" }); if (cookieSettings.statistics?.enabled === false) { return new NextResponse(null, { status: 204 }); } const userAgent = request.headers.get("user-agent") ?? ""; const host = request.headers.get("host") ?? ""; const hash = visitorHash({ ip, userAgent, host }); const sessionId = await resolveSessionId(payload, hash); await payload.create({ collection: "analytics-events", data: { sessionId, visitorHash: hash, path, eventName, referrerHost: referrerHostFrom(body.referrer), device: classifyDevice(userAgent), browser: classifyBrowser(userAgent), }, }); return new NextResponse(null, { status: 204 }); } catch (error) { console.error("analytics track failed", error); return new NextResponse(null, { status: 204 }); } }