Files
anouma/app/(payload)/api/analytics/track/route.ts
T

99 lines
3.4 KiB
TypeScript

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 });
}
}