Add privacy-friendly analytics and cookie consent, integrated into the CMS
Adds a first-party analytics system with no third-party trackers and no persistent per-visitor identifiers: events are grouped into sessions via a daily-rotating pseudonymous hash (IP+UA+host, HMAC-salted, never stored raw). - New "Analytics" section in the Payload admin, gated by a canViewAnalytics permission on Users, following the existing custom-admin-view pattern used by the calendar feature - Sessions, page views, unique visitors, top/entry/exit pages, events, device/browser breakdown, date range filter with period comparison - Raw events and daily aggregate rollups as their own collections, with configurable retention and a cron endpoint that rolls up before purging - Cookie consent banner (necessary/preferences/statistics) with categories and copy editable from a new CMS global; a track() helper that is a complete no-op without statistics consent, so nothing is ever sent before a visitor opts in - track() calls wired into the booking flow, contact form, and navigation
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
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);
|
||||
} 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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user