99 lines
3.4 KiB
TypeScript
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 });
|
|
}
|
|
}
|