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(resolve => server.once("listening", resolve)); + try { await use({ url: `http://127.0.0.1:${server.address().port}` }); } + finally { await new Promise(resolve => server.close(resolve)); await db.destroy(); } + }, + packets: async ({ page }, use) => { + const packets: any[] = []; + await page.route("https://analytics.example.test/**", async route => { + if (route.request().url().endsWith("/script.js")) { + await route.fulfill({ contentType: "application/javascript", body: tracker }); + } else { + packets.push(route.request().postDataJSON().payload); + await route.fulfill({ contentType: "application/json", headers: { "Access-Control-Allow-Origin": "*" }, body: "{}" }); + } + }); + await use(packets); + }, +}); + +test("runtime configuration tracks SPA navigation, back/forward and sanitized attribution once", async ({ page, portal, packets }) => { + await page.goto(`${portal.url}/?token=PRIVATE&utm_source=newsletter&utm_campaign=launch#PRIVATE`, { + referer: "https://source.example.test/private?token=PRIVATE", + }); + await expect.poll(() => packets.length).toBe(1); + expect(packets[0]).toMatchObject({ website: websiteId, url: "/?utm_source=newsletter&utm_campaign=launch", referrer: "https://source.example.test" }); + for (const path of ["/requests", "/transparency"]) { + await page.locator(`header nav a[href="${path}"]`).click(); + await expect.poll(() => packets.at(-1)?.url).toBe(path); + } + await page.goBack(); + await expect.poll(() => packets.at(-1)?.url).toBe("/requests"); + await page.goForward(); + await expect.poll(() => packets.at(-1)?.url).toBe("/transparency"); + expect(packets).toHaveLength(5); + expect(await page.locator("#picpeak-umami").count()).toBe(1); + expect(JSON.stringify(packets)).not.toContain("PRIVATE"); +}); + +test("private links contribute a page name without any access or feedback identifiers", async ({ page, portal, packets }) => { + await page.goto(`${portal.url}/maintainer?feedback=PRIVATE&lookup=PRIVATE#connect=PRIVATE`); + await expect.poll(() => packets.length).toBe(1); + expect(packets[0].url).toBe("/maintainer"); + expect(JSON.stringify(packets)).not.toContain("PRIVATE"); +}); + +for (const setting of ["dnt", "gpc", "optout", "domain"]) { + test(`does not load Umami with ${setting} excluded`, async ({ page, portal, packets }) => { + await page.addInitScript(setting => { + if (setting === "dnt") Object.defineProperty(navigator, "doNotTrack", { value: "1" }); + if (setting === "gpc") Object.defineProperty(navigator, "globalPrivacyControl", { value: true }); + if (setting === "optout") localStorage.setItem("umami.disabled", "1"); + }, setting); + if (setting === "domain") await page.route("**/api/public/analytics-config", route => route.fulfill({ json: { ...config, domains: ["usage.picpeak.app"] } })); + await page.goto(portal.url); + await page.waitForFunction(() => typeof (window as any).picpeakAnalyticsAllowed === "function"); + await expect(page.locator("#picpeak-umami")).toHaveCount(0); + expect(packets).toHaveLength(0); + await page.locator('header nav a[href="/requests"]').click(); + await expect(page).toHaveURL(`${portal.url}/requests`); + }); +} + +test("an unavailable tracker does not break navigation", async ({ page, portal }) => { + await page.route("https://analytics.example.test/**", route => route.abort()); + await page.goto(portal.url); + await page.locator('header nav a[href="/transparency"]').click(); + await expect(page.getByRole("heading", { name: "Website visitor statistics" })).toBeVisible(); +}); + +test("click tracking records the action without waiting on the analytics server", async ({ page, portal, packets }) => { + await page.goto(portal.url); + await expect.poll(() => packets.length).toBe(1); + let event; + await page.route("https://analytics.example.test/api/send", async route => { + event = route.request().postDataJSON().payload; + await new Promise(resolve => setTimeout(resolve, 1500)); + await route.fulfill({ contentType: "application/json", headers: { "Access-Control-Allow-Origin": "*" }, body: "{}" }).catch(() => {}); + }); + await page.route("https://www.picpeak.app/", route => route.fulfill({ contentType: "text/html", body: "

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 = {}) { + return new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.id = id; + script.src = src; + script.defer = true; + script.referrerPolicy = "no-referrer"; + for (const [name, value] of Object.entries(attributes)) script.setAttribute(name, value); + script.onload = () => resolve(); + script.onerror = () => reject(new Error("Analytics unavailable")); + document.head.append(script); + }); +} + +export async function initializeAnalytics() { + try { + const response = await fetch("/api/public/analytics-config", { credentials: "omit" }); + if (!response.ok) return; + const config: AnalyticsConfig | null = await response.json(); + if (!config || document.getElementById("picpeak-analytics-privacy")) return; + await loadScript("picpeak-analytics-privacy", "/analytics-privacy.js", { "data-site": "usage" }); + if (!window.picpeakAnalyticsAllowed?.(config.domains)) return; + await loadScript("picpeak-umami", config.scriptUrl, { + "data-website-id": config.websiteId, + "data-domains": config.domains.join(","), + "data-do-not-track": "true", + "data-exclude-hash": "true", + "data-before-send": "picpeakUmamiBeforeSend", + }); + } catch { + // Analytics must never block reading the portal, even when filtered offline. + } +} diff --git a/web/locales/de.json b/web/locales/de.json index 5e3b621..c9bb5c6 100644 --- a/web/locales/de.json +++ b/web/locales/de.json @@ -307,5 +307,8 @@ "weeklyEmailDisclosure": "Betreiber können einen wöchentlichen E-Mail-Bericht für berechtigte Maintainer aktivieren. Er enthält zusammengefasste Nutzungsdaten und alle neuen Rückmeldungen, auch private Nachrichten. Links erfordern die Maintainer-Anmeldung. Vor der Erstellung gelöschte Nachrichten werden ausgelassen; zugestellte E-Mail-Kopien können nicht zurückgerufen werden. SMTP- und Postfach-Aufbewahrung liegen in der Verantwortung des Betreibers.", "weeklyCounterDisclosure": "Während der Aktivierung speichert der Collector höchstens 90 Tage lang nur tägliche Gesamtzahlen neuer Registrierungen und abgeschlossener Austritte, ohne Installations-IDs, Hashes, Adressen oder Nachrichteninhalte. Diese betrieblichen Gesamtzahlen bleiben nach einzelnen Austritten bestehen. Bestätigungen ausstehender E-Mails verweisen auf vorhandenes Feedback und werden beim Austritt oder Abschluss des jeweiligen Wochenberichts entfernt. Das Abschalten der Wochenberichte löscht die Zähler und den Versandstatus.", "retentionWeeklyData": "Wochenbericht-Zähler und Versandkoordination", - "retentionWeeklyDuration": "Gesamtzahlen der Ein- und Austritte: 90 Tage, ohne Identität. Minimaler Zeitplan- und Wiederholungsstatus je Empfänger während der Aktivierung; Feedback-Teilbestätigungen nur bis zum Abschluss des Berichts oder Austritt des Verfassers. Der Zeitplaner speichert keine E-Mail-Inhalte. Das Abschalten entfernt diesen Zustand vollständig. Zugestellte Postfach-Kopien verbleiben beim Maintainer." + "retentionWeeklyDuration": "Gesamtzahlen der Ein- und Austritte: 90 Tage, ohne Identität. Minimaler Zeitplan- und Wiederholungsstatus je Empfänger während der Aktivierung; Feedback-Teilbestätigungen nur bis zum Abschluss des Berichts oder Austritt des Verfassers. Der Zeitplaner speichert keine E-Mail-Inhalte. Das Abschalten entfernt diesen Zustand vollständig. Zugestellte Postfach-Kopien verbleiben beim Maintainer.", + "websiteAnalyticsTitle": "Besuchsstatistik dieser Webseite", + "websiteAnalyticsIntro": "Wenn aktiviert, erfasst Umami Besuche dieses Portals und seiner Unterseiten, verweisende Webseiten, Kampagnenbezeichnungen, Browser, Gerät, Sprache und den ungefähren Standort. Diese Webseitenstatistik ist unabhängig von den Produktberichten, die du freiwillig aus PicPeak übermittelst.", + "websiteAnalyticsPrivacy": "Verbindungstoken, private Abfrageschlüssel, Feedbacktexte, Berichtsinhalte und Nutzeridentitäten werden nicht an Umami übermittelt. Aus URLs werden nur bekannte Seitenpfade und UTM-Kampagnenbezeichnungen gesendet; verweisende Webseiten werden auf ihren Ursprung reduziert. Do Not Track, Global Privacy Control und das Browser-Opt-out von Umami werden berücksichtigt." } diff --git a/web/locales/en.json b/web/locales/en.json index dc11cb2..151ee47 100644 --- a/web/locales/en.json +++ b/web/locales/en.json @@ -307,5 +307,8 @@ "weeklyEmailDisclosure": "Operators can enable a weekly email for trusted maintainers containing aggregate usage insights and all new feedback, including private messages. Links require maintainer sign-in. Messages deleted before email preparation are excluded, but delivered email copies cannot be recalled. SMTP and mailbox retention are the operator’s responsibility.", "weeklyCounterDisclosure": "While enabled, the collector keeps only daily totals of new registrations and completed opt-outs for at most 90 days, without installation IDs, hashes, addresses or message contents. These aggregate operational counts survive individual opt-outs. Pending email receipts reference live feedback and are removed on opt-out or completion of that recipient’s weekly report. Disabling weekly reports clears the counters and delivery state.", "retentionWeeklyData": "Weekly email counts and delivery coordination", - "retentionWeeklyDuration": "Aggregate joins/opt-outs: 90 days, without identity. Minimal per-recipient scheduling/retry state while enabled; feedback part receipts only until report completion or author opt-out. No email bodies stored by the scheduler. Disabling removes all of this state. Delivered mailbox copies remain with the maintainer." + "retentionWeeklyDuration": "Aggregate joins/opt-outs: 90 days, without identity. Minimal per-recipient scheduling/retry state while enabled; feedback part receipts only until report completion or author opt-out. No email bodies stored by the scheduler. Disabling removes all of this state. Delivered mailbox copies remain with the maintainer.", + "websiteAnalyticsTitle": "Website visitor statistics", + "websiteAnalyticsIntro": "When enabled, Umami measures visits to this portal and its subpages, referral websites, campaign labels, browser, device, language and approximate location. These website statistics are separate from the product reports you choose to share from PicPeak.", + "websiteAnalyticsPrivacy": "Tracking excludes connection tokens, private lookup hashes, feedback text, report contents and user identities. Only known page paths and UTM campaign labels are sent from URLs; referring websites are reduced to their origin. Do Not Track, Global Privacy Control and Umami’s browser opt-out are respected." } diff --git a/web/main.tsx b/web/main.tsx index 40e1a88..82bcd7d 100644 --- a/web/main.tsx +++ b/web/main.tsx @@ -1,4 +1,5 @@ import { LocaleProvider, useLocale } from "./Locale"; +import { initializeAnalytics } from "./analytics"; import { linkedFeedbackId } from "./maintainerLinks"; import React, { useEffect, useRef, useState } from "react"; import { useRequestScope } from "./useRequestScope"; @@ -705,19 +706,19 @@ function Transparency() {

{t.signalMeaning}

{t.schemaCompatibility}

{t.layoutValues}

- + {t.schemaV5} {" "} - + {t.schemaV4} {" "} - + {t.schemaV3} {" "} - + {t.schemaV2} {" "} - + {t.schemaV1} @@ -739,6 +740,11 @@ function Transparency() { +
+

{t.websiteAnalyticsTitle}

+

{t.websiteAnalyticsIntro}

+

{t.websiteAnalyticsPrivacy}

+

{t.weeklyEmailTitle}

@@ -765,7 +771,7 @@ function Transparency() { {t.sourceContents}

- + {t.downloadSource} @@ -1166,7 +1172,7 @@ function App() { {t[label]} ))} - + picpeak.app {credential && ( @@ -1229,7 +1235,7 @@ function App() { {t.proposalLink} - + picpeak.app , ); +void initializeAnalytics();