Three operator decisions change v1's premises, so the design is now two tiers with separate flags: Tier A (THERMOGRAPH_EVENTS) is v1 unchanged -- anonymous hourly aggregates, no identifier, nothing stored on the device, no consent needed, runs for everyone. It stays the primary signal precisely so a poor consent rate cannot take the numbers down with it, and so Tier B's rates can be calibrated against it to measure the consent bias rather than ignoring it. Tier B (THERMOGRAPH_EVENT_SESSIONS) adds an ephemeral per-tab id -- 16 random bytes in sessionStorage, rotating on a 30-minute idle and a 2-hour absolute cap, capped at 200 events, never linked to another session, device, or to the account (api_event does not read the auth cookie and there is no user column). Rows land in a 30-day hypertable with minute-granularity timestamps and an in-session sequence number instead of precise clock times. Storing an identifier engages ePrivacy Art. 5(3) and analytics is not strictly necessary, so it is gated on opt-in consent: an equal-weight banner that mints nothing before "Allow", one-click withdrawal in every footer that drops the live id immediately, and GPC/DNT treated as a refusal already given. THERMOGRAPH_SEARCH_MISS captures zero-result search text server-side in api_suggest only -- normalised, rejected outright on any personal-data smell, stored as a per-day count, and pruned below a three-person floor after a week. The privacy page is rewritten rather than deferred: "no per-visitor identifier" becomes false the moment Tier B ships. Tests assert the load-bearing promises so the copy and the code cannot drift apart silently. Raw-IP truncation is NOT implemented here (the logging pipeline owns it) but UI-EVENTS.md states what the app side must do, and records that Caddy's default JSON log already stores full request URIs -- so every search query is in Loki with the client IP today, which the search-miss mitigations depend on fixing. UI-EVENTS.md carries the v1-to-v2 diff, the consent reasoning including why legitimate interest is not available, and an explicit argument that the session identifier is the wrong trade at this traffic volume.
145 lines
6.1 KiB
JavaScript
145 lines
6.1 KiB
JavaScript
// Analytics consent: the opt-in gate in front of the session-scoped tier.
|
|
//
|
|
// WHY THIS EXISTS. The anonymous tier (hourly counts, no identifier, nothing
|
|
// written to your device) needs no permission. The session tier does: it mints
|
|
// an ephemeral id and stores it in sessionStorage, and storing an identifier on
|
|
// someone's device engages ePrivacy Art. 5(3). Product analytics is not
|
|
// "strictly necessary to provide a service the user explicitly requested", so
|
|
// the exemption does not apply and the lawful basis is opt-in consent.
|
|
//
|
|
// RULES THIS FILE ENFORCES, all of them load-bearing:
|
|
// * Nothing is stored and no id is minted before an explicit "Allow". Not on
|
|
// first paint, not "pending a choice" — refusing by ignoring the banner must
|
|
// leave exactly as little behind as refusing explicitly.
|
|
// * Allow and No are the same size, same weight, same prominence. No pre-tick,
|
|
// no "legitimate interest" second layer, no cookie-wall.
|
|
// * Withdrawal is as easy as granting, reachable at any time from the footer
|
|
// and the privacy page, and it deletes the live session id immediately.
|
|
// * GPC / DNT are treated as a refusal already given: no banner is shown and
|
|
// nothing is stored. Stricter than the letter of the law, and it means the
|
|
// most privacy-conscious visitors are never nagged.
|
|
//
|
|
// The consent RECORD itself lives in localStorage. Storing a user's own choice
|
|
// is strictly necessary to honour it, so it is exempt from the consent it
|
|
// records — but it is deliberately the only thing kept across sessions.
|
|
|
|
const KEY = "thermograph:analytics-consent";
|
|
//: Bump to re-ask everyone — only when what is collected materially changes.
|
|
const POLICY_VERSION = 1;
|
|
//: Re-ask a granter after this long. Never re-ask a refuser inside it: a banner
|
|
//: that keeps reappearing after "no" is a dark pattern, not a question.
|
|
const GRANT_TTL_MS = 183 * 24 * 3600 * 1000; // ~6 months
|
|
const DENY_TTL_MS = 365 * 24 * 3600 * 1000; // ~12 months
|
|
|
|
export const signalledOptOut =
|
|
navigator.globalPrivacyControl === true ||
|
|
navigator.doNotTrack === "1" ||
|
|
window.doNotTrack === "1";
|
|
|
|
const listeners = [];
|
|
let state = null; // "granted" | "denied" | null (not asked)
|
|
|
|
function read() {
|
|
try {
|
|
const raw = localStorage.getItem(KEY);
|
|
if (!raw) return null;
|
|
const rec = JSON.parse(raw);
|
|
if (rec.v !== POLICY_VERSION) return null;
|
|
const ttl = rec.choice === "granted" ? GRANT_TTL_MS : DENY_TTL_MS;
|
|
if (!rec.ts || Date.now() - rec.ts > ttl) return null;
|
|
return rec.choice === "granted" ? "granted" : "denied";
|
|
} catch { return null; }
|
|
}
|
|
|
|
function write(choice) {
|
|
try {
|
|
localStorage.setItem(KEY, JSON.stringify({ v: POLICY_VERSION, choice, ts: Date.now() }));
|
|
} catch { /* private mode: the choice just won't persist */ }
|
|
}
|
|
|
|
/** "granted" | "denied" | null. GPC/DNT short-circuits to "denied". */
|
|
export function consentState() {
|
|
if (signalledOptOut) return "denied";
|
|
if (state === null) state = read();
|
|
return state;
|
|
}
|
|
|
|
export const hasConsent = () => consentState() === "granted";
|
|
|
|
/** Called with the new state whenever it changes — track.js uses this to drop
|
|
* the live session id the instant consent is withdrawn. */
|
|
export function onConsentChange(cb) { listeners.push(cb); }
|
|
|
|
export function setConsent(choice) {
|
|
state = choice === "granted" ? "granted" : "denied";
|
|
write(state);
|
|
hideBanner();
|
|
paintControls();
|
|
for (const cb of listeners) { try { cb(state); } catch { /* never break */ } }
|
|
}
|
|
|
|
// --- the banner ---------------------------------------------------------------
|
|
// Rendered only when instrumentation is enabled AND no choice has been recorded
|
|
// AND no opt-out signal is present. It is a bar, not a modal: it never blocks
|
|
// reading the page, which is the whole product.
|
|
|
|
let banner = null;
|
|
|
|
function hideBanner() {
|
|
if (banner) { banner.remove(); banner = null; }
|
|
}
|
|
|
|
function showBanner() {
|
|
if (banner) return;
|
|
banner = document.createElement("div");
|
|
banner.className = "consent-bar";
|
|
banner.setAttribute("role", "dialog");
|
|
banner.setAttribute("aria-label", "Analytics");
|
|
banner.innerHTML = `
|
|
<p>Can we count how this page gets used? Anonymous, kept for 30 days, never
|
|
shared, and never linked to you or to an account.
|
|
<a class="consent-more" href="">What this collects</a></p>
|
|
<div class="consent-actions">
|
|
<button type="button" class="consent-no">No thanks</button>
|
|
<button type="button" class="consent-yes">Allow</button>
|
|
</div>`;
|
|
// The privacy link is resolved against the page, not hardcoded, so it is right
|
|
// under both the root-served and /thermograph-prefixed deployments.
|
|
const more = banner.querySelector(".consent-more");
|
|
more.href = new URL("privacy", document.baseURI).pathname;
|
|
banner.querySelector(".consent-yes").addEventListener("click", () => setConsent("granted"));
|
|
banner.querySelector(".consent-no").addEventListener("click", () => setConsent("denied"));
|
|
document.body.appendChild(banner);
|
|
}
|
|
|
|
// --- the withdrawal control ---------------------------------------------------
|
|
// Any [data-consent-control] element becomes a live "Analytics: on/off — change"
|
|
// toggle. The footer carries one on every page, so withdrawing is never more
|
|
// than one click from wherever the visitor is.
|
|
|
|
function paintControls() {
|
|
const s = consentState();
|
|
for (const el of document.querySelectorAll("[data-consent-control]")) {
|
|
if (signalledOptOut) {
|
|
el.textContent = "Analytics: off (your browser asked us not to)";
|
|
continue;
|
|
}
|
|
const on = s === "granted";
|
|
el.innerHTML =
|
|
`Analytics: ${on ? "on" : "off"} <button type="button" class="consent-toggle">`
|
|
+ `${on ? "Turn off" : "Turn on"}</button>`;
|
|
el.querySelector(".consent-toggle").addEventListener(
|
|
"click", () => setConsent(on ? "denied" : "granted"));
|
|
}
|
|
}
|
|
|
|
export function initConsent() {
|
|
// Instrumentation disabled for this environment: no banner, no control, no
|
|
// storage. The flag is the outermost gate on everything.
|
|
if (document.documentElement.dataset.tgEvents !== "1") return;
|
|
paintControls();
|
|
if (signalledOptOut) return; // already refused, at the browser level
|
|
if (consentState() === null) showBanner();
|
|
}
|
|
|
|
initConsent();
|