Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
81 changes: 81 additions & 0 deletions public/analytics-privacy.js
Original file line number Diff line number Diff line change
@@ -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; }
};
})();
1 change: 1 addition & 0 deletions scripts/archive-source.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const files = [
"public/fonts",
"public/picpeak-mark.svg",
"public/favicon.ico",
"public/analytics-privacy.js",
"protocol",
"server",
"web",
Expand Down
28 changes: 28 additions & 0 deletions server/analyticsConfig.js
Original file line number Diff line number Diff line change
@@ -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 };
9 changes: 6 additions & 3 deletions server/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -28,6 +29,7 @@ function createApp({
now,
disableRateLimit = false,
exportOptions,
analyticsConfig = readAnalyticsConfig(),
...options
}) {
const app = express();
Expand All @@ -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'"],
Expand All @@ -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);
Expand Down
6 changes: 4 additions & 2 deletions server/index.js
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -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);
});
37 changes: 37 additions & 0 deletions test/analytics-config.test.js
Original file line number Diff line number Diff line change
@@ -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'(;|$)/);
});
79 changes: 79 additions & 0 deletions test/analytics-privacy.test.js
Original file line number Diff line number Diff line change
@@ -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" }) } }));
});
Loading
Loading