From 216c78651487aa0a59d5dacc74aa24f6e96efc8d Mon Sep 17 00:00:00 2001
From: Paul Nothaft <53005142+the-luap@users.noreply.github.com>
Date: Tue, 8 Sep 2026 16:34:56 +0200
Subject: [PATCH] feat: add configurable Umami visitor analytics
---
.env.example | 8 +++
compose.yaml | 3 ++
public/analytics-privacy.js | 81 ++++++++++++++++++++++++++++
scripts/archive-source.js | 1 +
server/analyticsConfig.js | 28 ++++++++++
server/app.js | 9 ++--
server/index.js | 6 ++-
test/analytics-config.test.js | 37 +++++++++++++
test/analytics-privacy.test.js | 79 +++++++++++++++++++++++++++
test/browser/analytics.spec.ts | 98 ++++++++++++++++++++++++++++++++++
test/helpers/umamiTracker.cjs | 27 ++++++++++
web/analytics.ts | 40 ++++++++++++++
web/locales/de.json | 5 +-
web/locales/en.json | 5 +-
web/main.tsx | 23 +++++---
15 files changed, 435 insertions(+), 15 deletions(-)
create mode 100644 public/analytics-privacy.js
create mode 100644 server/analyticsConfig.js
create mode 100644 test/analytics-config.test.js
create mode 100644 test/analytics-privacy.test.js
create mode 100644 test/browser/analytics.spec.ts
create mode 100644 test/helpers/umamiTracker.cjs
create mode 100644 web/analytics.ts
diff --git a/.env.example b/.env.example
index 9892a23..b392a53 100644
--- a/.env.example
+++ b/.env.example
@@ -17,6 +17,14 @@ SESSION_SECRET=
# Exact trusted proxy hops (0-3), never true. Behind one reverse proxy: 1.
TRUST_PROXY_HOPS=0
+# Optional website analytics, separate from opt-in PicPeak product reports.
+# Configure these at container startup; no frontend rebuild is needed.
+# Requires Umami >= 2.18 (before-send filtering). Both values empty disables it.
+UMAMI_SCRIPT_URL=
+UMAMI_WEBSITE_ID=
+# Exact hostnames prevent development/preview visits reaching production stats.
+UMAMI_DOMAINS=usage.picpeak.app
+
# Optional confidential weekly email to trusted maintainers. Disabled by default.
WEEKLY_REPORT_ENABLED=false
# Comma-separated plain addresses. Each recipient receives a separate copy.
diff --git a/compose.yaml b/compose.yaml
index ead3669..930a0ce 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -21,6 +21,9 @@ services:
# The container is meant to run behind exactly one TLS-terminating reverse
# proxy on this host, so one X-Forwarded-For hop is trusted by default.
TRUST_PROXY_HOPS: ${TRUST_PROXY_HOPS:-1}
+ UMAMI_SCRIPT_URL: ${UMAMI_SCRIPT_URL:-}
+ UMAMI_WEBSITE_ID: ${UMAMI_WEBSITE_ID:-}
+ UMAMI_DOMAINS: ${UMAMI_DOMAINS:-usage.picpeak.app}
WEEKLY_REPORT_ENABLED: ${WEEKLY_REPORT_ENABLED:-false}
WEEKLY_REPORT_TO: ${WEEKLY_REPORT_TO:-}
WEEKLY_REPORT_BASE_URL: ${WEEKLY_REPORT_BASE_URL:-https://usage.picpeak.app}
diff --git a/public/analytics-privacy.js b/public/analytics-privacy.js
new file mode 100644
index 0000000..461c5e0
--- /dev/null
+++ b/public/analytics-privacy.js
@@ -0,0 +1,81 @@
+/* Umami's before-send hook keeps visitor statistics separate from private data. */
+(() => {
+ const usage = document.currentScript?.getAttribute("data-site") === "usage";
+ const publicPaths = usage
+ ? ["/", "/packets", "/requests", "/transparency", "/maintainer"]
+ : ["", "features", "compare", "faq", "whats-new", "impressum", "privacy",
+ "vs-picdrop", "vs-pikd", "vs-piwigo", "vs-pixieset"]
+ .flatMap(page => ["de", "en"].map(locale => `/${locale}/${page}`.replace(/\/$/, "")));
+ const campaigns = ["utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term"];
+ const events = ["Open website", "Open GitHub", "Open demo", "Read documentation", "Download source", "Read schema"];
+ let previousPageview;
+
+ function blocked() {
+ if (navigator.globalPrivacyControl || ["1", "yes"].includes(String(navigator.doNotTrack || window.doNotTrack))) return true;
+ try { return Boolean(localStorage.getItem("umami.disabled")); } catch { return false; }
+ }
+
+ window.picpeakAnalyticsAllowed = domains => !blocked() && domains.includes(location.hostname);
+
+ function trackCurrent(name) {
+ if (blocked() || !window.umami?.track) return;
+ try {
+ Promise.resolve(window.umami.track(props => ({
+ ...props, url: location.href,
+ ...(name ? { name } : { referrer: previousPageview || document.referrer }),
+ }))).catch(() => {});
+ } catch { /* optional analytics */ }
+ }
+
+ // Never delay links or downloads while waiting for the analytics server.
+ document.addEventListener("click", event => {
+ const name = event.target?.closest?.("[data-analytics-event]")?.getAttribute("data-analytics-event");
+ if (events.includes(name)) trackCurrent(name);
+ }, true);
+ // Umami observes pushState/replaceState. Browser back/forward also needs a
+ // current-URL payload; its internal URL can still refer to the page we left.
+ window.addEventListener("popstate", () => setTimeout(() => trackCurrent(), 0));
+
+ function pagePath(value) {
+ const path = value.replace(/\/$/, "") || "/";
+ return publicPaths.includes(path) ? path : null;
+ }
+
+ function referrer(value) {
+ if (!value) return "";
+ try {
+ const url = new URL(value, location.origin);
+ if (!["https:", "http:"].includes(url.protocol)) return "";
+ return url.origin === location.origin ? pagePath(url.pathname) || "" : url.origin;
+ } catch { return ""; }
+ }
+
+ window.picpeakUmamiBeforeSend = (type, payload) => {
+ if (blocked() || type !== "event" || !payload || (payload.name && !events.includes(payload.name))) return false;
+ try {
+ const url = new URL(payload.url, location.origin);
+ const path = pagePath(url.pathname);
+ if (!path || url.origin !== location.origin) return false;
+ const query = new URLSearchParams();
+ for (const key of campaigns) {
+ const value = url.searchParams.get(key);
+ if (value) query.set(key, value.slice(0, 100));
+ }
+ const cleanUrl = path + (query.size ? `?${query}` : "");
+ // Auth handoffs, filters and anchor changes must not duplicate a pageview.
+ if (!payload.name && previousPageview === cleanUrl) return false;
+ const previous = previousPageview;
+ if (!payload.name) previousPageview = cleanUrl;
+ return {
+ website: payload.website,
+ hostname: payload.hostname,
+ language: payload.language,
+ screen: payload.screen,
+ title: payload.title,
+ url: cleanUrl,
+ referrer: referrer(!payload.name && previous ? previous : payload.referrer),
+ ...(payload.name ? { name: payload.name } : {}),
+ };
+ } catch { return false; }
+ };
+})();
diff --git a/scripts/archive-source.js b/scripts/archive-source.js
index 8eb3db7..385e95a 100644
--- a/scripts/archive-source.js
+++ b/scripts/archive-source.js
@@ -23,6 +23,7 @@ const files = [
"public/fonts",
"public/picpeak-mark.svg",
"public/favicon.ico",
+ "public/analytics-privacy.js",
"protocol",
"server",
"web",
diff --git a/server/analyticsConfig.js b/server/analyticsConfig.js
new file mode 100644
index 0000000..40545ca
--- /dev/null
+++ b/server/analyticsConfig.js
@@ -0,0 +1,28 @@
+"use strict";
+
+function parseAnalyticsConfig(env) {
+ const scriptUrl = env.UMAMI_SCRIPT_URL?.trim();
+ const websiteId = env.UMAMI_WEBSITE_ID?.trim();
+ if (!scriptUrl && !websiteId) return null;
+ if (!scriptUrl || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(websiteId || ""))
+ throw new Error("Set UMAMI_SCRIPT_URL and a valid UMAMI_WEBSITE_ID together");
+ const script = new URL(scriptUrl);
+ const local = ["localhost", "127.0.0.1", "[::1]"].includes(script.hostname);
+ if ((script.protocol !== "https:" && !(local && script.protocol === "http:")) ||
+ script.username || script.password || script.search || script.hash)
+ throw new Error("UMAMI_SCRIPT_URL must be an HTTPS script URL without credentials, query or fragment");
+ const domains = (env.UMAMI_DOMAINS || "usage.picpeak.app").split(",").map(value => value.trim().toLowerCase());
+ if (domains.some(domain => !/^[a-z0-9.-]+$/.test(domain) || domain.length > 253 ||
+ domain.split(".").some(label => !label || label.length > 63 || label.startsWith("-") || label.endsWith("-"))))
+ throw new Error("UMAMI_DOMAINS must contain comma-separated hostnames");
+ return { scriptUrl: script.href, websiteId, domains };
+}
+
+function readAnalyticsConfig(env = process.env) {
+ try { return parseAnalyticsConfig(env); }
+ catch (error) {
+ throw Object.assign(new Error(`Invalid Umami configuration: ${error.message}`), { code: "ANALYTICS_CONFIG" });
+ }
+}
+
+module.exports = { readAnalyticsConfig };
diff --git a/server/app.js b/server/app.js
index 54047a2..62d81a2 100644
--- a/server/app.js
+++ b/server/app.js
@@ -7,6 +7,7 @@ const path = require("node:path");
const { Collector } = require("./collector");
const { createExportStreamer } = require("./exports");
const { history } = require("./history");
+const { readAnalyticsConfig } = require("./analyticsConfig");
const { reporters, pageOptions } = require("./maintainer");
const {
envelopeSchemas,
@@ -28,6 +29,7 @@ function createApp({
now,
disableRateLimit = false,
exportOptions,
+ analyticsConfig = readAnalyticsConfig(),
...options
}) {
const app = express();
@@ -47,15 +49,15 @@ function createApp({
app.use(
helmet({
referrerPolicy: { policy: "no-referrer" },
- // The portal loads nothing from third parties: no https: wildcards.
+ // Only the explicitly configured analytics origin may load/send data.
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
- scriptSrc: ["'self'"],
+ scriptSrc: ["'self'", ...(analyticsConfig ? [new URL(analyticsConfig.scriptUrl).origin] : [])],
styleSrc: ["'self'", "'unsafe-inline'"],
fontSrc: ["'self'"],
imgSrc: ["'self'", "data:"],
- connectSrc: ["'self'"],
+ connectSrc: ["'self'", ...(analyticsConfig ? [new URL(analyticsConfig.scriptUrl).origin] : [])],
formAction: ["'self'"],
frameAncestors: ["'none'"],
objectSrc: ["'none'"],
@@ -76,6 +78,7 @@ function createApp({
res.set("Cache-Control", "no-store");
next();
});
+ app.get("/api/public/analytics-config", (_req, res) => res.json(analyticsConfig));
// No request/access logger. Rate limiting retains only an ephemeral HMAC of
// the transport address, in memory for one window; it is never analytics.
const limiterKey = crypto.randomBytes(32);
diff --git a/server/index.js b/server/index.js
index 4bd5b79..640b1bc 100644
--- a/server/index.js
+++ b/server/index.js
@@ -1,16 +1,18 @@
"use strict";
const { createDatabase, migrate } = require("./database");
const { createApp } = require("./app");
+const { readAnalyticsConfig } = require("./analyticsConfig");
const { readWeeklyConfig } = require("./weeklyConfig");
const { prepareWeeklyReporting } = require("./weeklyActivity");
const { WeeklyReporter } = require("./weeklyReporter");
async function start() {
const weeklyConfig = readWeeklyConfig();
+ const analyticsConfig = readAnalyticsConfig();
const db = createDatabase();
await migrate(db);
await prepareWeeklyReporting(db, weeklyConfig);
- const app = createApp({ db });
+ const app = createApp({ db, analyticsConfig });
await app.locals.collector.pruneExpired();
let maintaining = false;
const maintenance = setInterval(async () => {
@@ -53,7 +55,7 @@ async function start() {
}
start().catch((error) => {
process.stderr.write(
- error.code === "WEEKLY_CONFIG" ? `${error.message}\n` : "picpeak-usage startup failed; check database configuration\n",
+ ["WEEKLY_CONFIG", "ANALYTICS_CONFIG"].includes(error.code) ? `${error.message}\n` : "picpeak-usage startup failed; check database configuration\n",
);
process.exit(1);
});
diff --git a/test/analytics-config.test.js b/test/analytics-config.test.js
new file mode 100644
index 0000000..9debc5a
--- /dev/null
+++ b/test/analytics-config.test.js
@@ -0,0 +1,37 @@
+const { test } = require("node:test");
+const assert = require("node:assert/strict");
+const request = require("supertest");
+const { readAnalyticsConfig } = require("../server/analyticsConfig");
+const { createApp } = require("../server/app");
+const valid = { UMAMI_SCRIPT_URL: "https://stats.example.test/script.js", UMAMI_WEBSITE_ID: "11111111-1111-4111-8111-111111111111" };
+
+test("analytics is opt-in and rejects incomplete or unsafe configuration", () => {
+ assert.equal(readAnalyticsConfig({}), null);
+ for (const env of [{ UMAMI_SCRIPT_URL: valid.UMAMI_SCRIPT_URL }, { UMAMI_WEBSITE_ID: valid.UMAMI_WEBSITE_ID },
+ { ...valid, UMAMI_SCRIPT_URL: "javascript:alert(1)" }, { ...valid, UMAMI_SCRIPT_URL: "http://stats.example.test/script.js" },
+ { ...valid, UMAMI_SCRIPT_URL: "https://user:password@stats.example.test/script.js" },
+ { ...valid, UMAMI_SCRIPT_URL: "https://stats.example.test/script.js?secret=x" },
+ { ...valid, UMAMI_DOMAINS: "example.test/path" }, { ...valid, UMAMI_DOMAINS: "*.example.test" }]) {
+ assert.throws(() => readAnalyticsConfig(env));
+ }
+});
+
+test("runtime config exposes only public fields and permits only the configured CSP origin", async () => {
+ const config = readAnalyticsConfig({ ...valid, MAINTAINER_TOKEN: "PRIVATE", SESSION_SECRET: "PRIVATE" });
+ const app = createApp({ db: {}, analyticsConfig: config, disableRateLimit: true });
+ const response = await request(app).get("/api/public/analytics-config").expect(200);
+ assert.deepEqual(response.body, { scriptUrl: valid.UMAMI_SCRIPT_URL, websiteId: valid.UMAMI_WEBSITE_ID, domains: ["usage.picpeak.app"] });
+ assert.equal(response.headers["cache-control"], "no-store");
+ for (const directive of ["script-src", "connect-src"]) {
+ assert.ok(response.headers["content-security-policy"].includes(directive + " 'self' https://stats.example.test"));
+ }
+ assert.ok(!response.text.includes("PRIVATE"));
+});
+
+test("disabled analytics leaves the original self-only CSP intact", async () => {
+ const app = createApp({ db: {}, analyticsConfig: null, disableRateLimit: true });
+ const response = await request(app).get("/api/public/analytics-config").expect(200);
+ assert.equal(response.body, null);
+ assert.match(response.headers["content-security-policy"], /script-src 'self';/);
+ assert.match(response.headers["content-security-policy"], /connect-src 'self'(;|$)/);
+});
diff --git a/test/analytics-privacy.test.js b/test/analytics-privacy.test.js
new file mode 100644
index 0000000..dcef43c
--- /dev/null
+++ b/test/analytics-privacy.test.js
@@ -0,0 +1,79 @@
+const { test } = require("node:test");
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const path = require("node:path");
+const vm = require("node:vm");
+
+function tracker(site = "usage") {
+ const location = new URL(site === "usage" ? "https://usage.picpeak.app/" : "https://www.picpeak.app/en/");
+ const handlers = {};
+ const context = { URL, URLSearchParams, Promise, setTimeout, location, navigator: {}, localStorage: { getItem: () => null },
+ document: { currentScript: { getAttribute: () => site }, addEventListener: (name, handler) => { handlers[name] = handler; } },
+ window: { addEventListener: (name, handler) => { handlers[name] = handler; } } };
+ vm.runInNewContext(fs.readFileSync(path.join(__dirname, "../public/analytics-privacy.js"), "utf8"), context);
+ const send = (overrides = {}, type = "event") => context.window.picpeakUmamiBeforeSend(type, {
+ website: "11111111-1111-4111-8111-111111111111", hostname: location.hostname,
+ language: "en-US", screen: "1440x900", title: "PicPeak", url: location.pathname, ...overrides,
+ });
+ return { context, handlers, send };
+}
+
+test("keeps page and campaign metrics but strips credentials, fragments and arbitrary data", () => {
+ const { send } = tracker();
+ const page = "usage" === "usage" ? "/requests" : "/en/features";
+ const result = send({ url: page + "?connect=SECRET&token=SECRET&feedback=PRIVATE&utm_source=newsletter&utm_campaign=launch#connect=SECRET",
+ referrer: "https://example.test/private/SECRET?token=SECRET#SECRET", id: "PRIVATE", data: { email: "PRIVATE" } });
+ assert.equal(result.url, page + "?utm_source=newsletter&utm_campaign=launch");
+ assert.equal(result.referrer, "https://example.test");
+ assert.equal(result.screen, "1440x900");
+ assert.equal(result.language, "en-US");
+ assert.ok(!JSON.stringify(result).includes("SECRET"));
+ assert.ok(!JSON.stringify(result).includes("PRIVATE"));
+});
+
+test("counts real navigation and back visits, without duplicating filtered URL changes", () => {
+ const { send } = tracker();
+ const a = "usage" === "usage" ? "/" : "/en";
+ const b = "usage" === "usage" ? "/transparency" : "/de/faq";
+ assert.ok(send({ url: a }));
+ assert.equal(send({ url: a + "?token=SECRET#anything" }), false);
+ assert.ok(send({ url: b }));
+ assert.ok(send({ url: a }));
+});
+
+test("drops unknown page paths and session identification", () => {
+ const { send } = tracker();
+ assert.equal(send({ url: "/PRIVATE-LOOKUP-HASH" }), false);
+ assert.equal(send({ id: "PRIVATE" }, "identify"), false);
+ assert.equal(send({ name: "Private feedback text" }), false);
+ assert.equal(send({ url: "https://foreign.test/" }), false);
+});
+
+test("honors browser privacy choices and exact production domain restrictions", () => {
+ for (const privacy of ["dnt", "gpc", "optout"]) {
+ const { context, send } = tracker();
+ if (privacy === "dnt") context.navigator.doNotTrack = "1";
+ if (privacy === "gpc") context.navigator.globalPrivacyControl = true;
+ if (privacy === "optout") context.localStorage.getItem = () => "1";
+ assert.equal(context.window.picpeakAnalyticsAllowed([context.location.hostname]), false);
+ assert.equal(send(), false);
+ }
+ const { context } = tracker();
+ assert.equal(context.window.picpeakAnalyticsAllowed([context.location.hostname]), true);
+ assert.equal(context.window.picpeakAnalyticsAllowed(["localhost"]), false);
+ context.localStorage.getItem = () => { throw new Error("storage unavailable"); };
+ assert.equal(context.window.picpeakAnalyticsAllowed([context.location.hostname]), true);
+});
+
+test("click analytics never waits for or prevents navigation", () => {
+ const { context, handlers, send } = tracker();
+ let tracked;
+ context.window.umami = { track: build => { tracked = build({}).name; return new Promise(() => {}); } };
+ let prevented = false;
+ handlers.click({ target: { closest: () => ({ getAttribute: () => "Open GitHub" }) }, preventDefault: () => { prevented = true; } });
+ assert.equal(tracked, "Open GitHub");
+ assert.equal(prevented, false);
+ assert.equal(send({ name: "Open GitHub", data: { token: "SECRET" } }).name, "Open GitHub");
+ context.window.umami.track = () => { throw new Error("blocked"); };
+ assert.doesNotThrow(() => handlers.click({ target: { closest: () => ({ getAttribute: () => "Open GitHub" }) } }));
+});
diff --git a/test/browser/analytics.spec.ts b/test/browser/analytics.spec.ts
new file mode 100644
index 0000000..0a6799d
--- /dev/null
+++ b/test/browser/analytics.spec.ts
@@ -0,0 +1,98 @@
+import { test as base, expect } from "@playwright/test";
+import fs from "node:fs";
+const { createDatabase, migrate } = require("../../server/database");
+const { createApp } = require("../../server/app");
+const tracker = process.env.UMAMI_TRACKER_FIXTURE
+ ? fs.readFileSync(process.env.UMAMI_TRACKER_FIXTURE, "utf8")
+ : require("../helpers/umamiTracker.cjs");
+const websiteId = "11111111-1111-4111-8111-111111111111";
+const config = { scriptUrl: "https://analytics.example.test/script.js", websiteId, domains: ["127.0.0.1"] };
+
+const test = base.extend<{ portal: { url: string }; packets: any[] }>({
+ portal: async ({}, use) => {
+ const db = createDatabase({ DATABASE_PATH: ":memory:" });
+ await migrate(db);
+ const app = createApp({ db, analyticsConfig: config, disableRateLimit: true });
+ const server = app.listen(0, "127.0.0.1");
+ await new Promise {t.signalMeaning} {t.schemaCompatibility} {t.layoutValues} {t.websiteAnalyticsIntro} {t.websiteAnalyticsPrivacy}Website fixture
" }));
+ await page.locator('header a[href="https://www.picpeak.app"]').click({ timeout: 1000 });
+ await expect(page).toHaveURL("https://www.picpeak.app/");
+ await expect.poll(() => event?.name).toBe("Open website");
+});
diff --git a/test/helpers/umamiTracker.cjs b/test/helpers/umamiTracker.cjs
new file mode 100644
index 0000000..0e3c8df
--- /dev/null
+++ b/test/helpers/umamiTracker.cjs
@@ -0,0 +1,27 @@
+// Contract fixture for script loading and browser integration. Local QA can
+// also run the same tests with UMAMI_TRACKER_FIXTURE pointing at Umami's tracker.
+module.exports = `(() => {
+ const script = document.currentScript;
+ const originalReferrer = document.referrer;
+ const track = input => {
+ let payload = { website: script.dataset.websiteId, hostname: location.hostname,
+ language: navigator.language, screen: screen.width + "x" + screen.height,
+ title: document.title, url: location.href, referrer: originalReferrer };
+ if (typeof input === "function") payload = input(payload);
+ else if (typeof input === "string") payload.name = input;
+ payload = window[script.dataset.beforeSend]("event", payload);
+ if (!payload) return Promise.resolve();
+ return fetch(new URL("/api/send", script.src), { method: "POST", keepalive: true,
+ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: "event", payload }) });
+ };
+ window.umami = { track };
+ for (const method of ["pushState", "replaceState"]) {
+ const original = history[method];
+ history[method] = function(...args) {
+ const result = original.apply(this, args);
+ setTimeout(() => track(), 0);
+ return result;
+ };
+ }
+ track();
+})();`;
diff --git a/web/analytics.ts b/web/analytics.ts
new file mode 100644
index 0000000..c43e64a
--- /dev/null
+++ b/web/analytics.ts
@@ -0,0 +1,40 @@
+type AnalyticsConfig = { scriptUrl: string; websiteId: string; domains: string[] };
+declare global {
+ interface Window {
+ picpeakAnalyticsAllowed?: (domains: string[]) => boolean;
+ }
+}
+
+function loadScript(id: string, src: string, attributes: Record{t.websiteAnalyticsTitle}
+ {t.weeklyEmailTitle}
@@ -765,7 +771,7 @@ function Transparency() {
{t.sourceContents}