/** * Umami (self-hosted) analytics configuration and payload redaction. * * Two deliberate choices are encoded here: * * 1. The tracker is served same-origin through a rewrite (see `ANALYTICS_PROXY_PATH` * and next.config.mjs) rather than from the Umami host directly. That keeps the * strict CSP in middleware.ts untouched — `script-src`/`connect-src` stay * `'self'` — and stops content blockers from dropping the request. * * 2. Nothing that identifies a user's content ever reaches the analytics database. * See `redactUrl` / `redactTitle`. */ /** Same-origin prefix the tracker and its beacon are proxied through. */ export const ANALYTICS_PROXY_PATH = "/_a"; export interface UmamiConfig { /** Origin of the Umami instance, e.g. "https://analytics.example.com". */ hostUrl: string; websiteId: string; } /** * Resolve the Umami config from the environment. Returns null when either value * is absent, which disables analytics entirely (the default in development). * * Both are read at build time for statically rendered pages, so they must be * present as build args in Docker — see the Dockerfile. */ export function umamiConfig(): UmamiConfig | null { const hostUrl = process.env.UMAMI_HOST_URL?.trim().replace(/\/+$/, ""); const websiteId = process.env.UMAMI_WEBSITE_ID?.trim(); if (!hostUrl || !websiteId) return null; return { hostUrl, websiteId }; } /** * Path segments that are opaque identifiers rather than page names. * * Sending them would put episode IDs, series IDs and — worst — unlisted share * IDs into the analytics database. Share links are secret by design, so leaking * one there effectively publishes it. Collapsing them also makes the reports * useful: one "/episodes/[id]" row instead of thousands of singletons. */ export function redactPath(pathname: string): string { const seg = pathname.split("/").filter(Boolean); // "/episodes/new" is a real page; "/episodes/" is not. if (seg[0] === "episodes" && seg[1] && seg[1] !== "new") seg[1] = "[id]"; else if (seg[0] === "series" && seg[1]) seg[1] = "[id]"; else if (seg[0] === "p" && seg[1]) seg[1] = "[shareId]"; else if (seg[0] === "admin" && seg[1] === "users" && seg[2]) seg[2] = "[id]"; return `/${seg.join("/")}`; } /** * Redact a full URL: collapse identifier segments and drop the query string and * hash outright. * * The query string is the sharper hazard — /reset-password carries a live * password-reset token in `?token=`, and /sign-in carries the visitor's intended * destination in `?redirect=`. `data-exclude-search` already strips it; this is * the second line of defence, and it also covers the referrer field, which the * tracker fills from the previous URL. */ export function redactUrl(raw: string): string { if (!raw) return raw; try { // Bare paths are the common case; the base only matters for absolute URLs. const url = new URL(raw, "http://localhost"); const path = redactPath(url.pathname); return url.origin === "http://localhost" && !raw.startsWith("http") ? path : `${url.origin}${path}`; } catch { // Unparseable — return just the redacted path portion, never the raw value. return redactPath(raw.split(/[?#]/)[0]); } } /** Whether a path carries user content that its would expose. */ function isRedacted(pathname: string): boolean { return redactPath(pathname) !== pathname; } /** * Page titles on detail routes are the user's own content — `generateMetadata` * returns the episode or series title verbatim. Swap those for the route name so * the reports stay readable without storing anyone's content. */ export function redactTitle(title: string, url: string): string { let pathname: string; try { pathname = new URL(url, "http://localhost").pathname; } catch { pathname = url.split(/[?#]/)[0]; } return isRedacted(pathname) ? `${redactPath(pathname)} · Podcast Distribution AI` : title; } /** The Umami event payload we are allowed to inspect and rewrite. */ export interface UmamiPayload { url?: string; referrer?: string; title?: string; [key: string]: unknown; } /** Apply every redaction rule to one outgoing event payload. */ export function redactPayload(payload: UmamiPayload): UmamiPayload { const url = payload.url ? redactUrl(payload.url) : payload.url; return { ...payload, ...(url !== undefined ? { url } : {}), ...(payload.referrer ? { referrer: redactUrl(payload.referrer) } : {}), ...(payload.title && payload.url ? { title: redactTitle(payload.title, payload.url) } : {}), }; }