Merge remote-tracking branch 'origin/beta' into feat/workflow-engine

# Conflicts:
#	frontend/src/components/admin/PublishGalleryDialog.tsx
This commit is contained in:
Luca
2026-06-26 17:01:16 +02:00
26 changed files with 1570 additions and 184 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.69.0-beta.0"
".": "3.71.0-beta.0"
}
+37
View File
@@ -5,6 +5,43 @@ All notable changes to PicPeak will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.71.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.70.0-beta.0...v3.71.0-beta.0) (2026-06-25)
### Features
* admin-configurable workflow engine + dunning/Mahngebühr rework (RFC — feedback welcome) ([15be3b8](https://github.com/the-luap/picpeak/commit/15be3b8d32965eedc08d46ccc525c65a3bf34de6))
* **workflows:** per-quote booking-workflow picker + quote→invoice (no gallery) built-in ([d14f1d8](https://github.com/the-luap/picpeak/commit/d14f1d850cc995b2cb1119ba0424f123feba50ec))
* **workflows:** pre-event reminder picks the template GROUP on the block, type stays automatic ([10d091b](https://github.com/the-luap/picpeak/commit/10d091b55e0c44738b4001a71def6416a8f0aeb0))
* **workflows:** route webhook node through the delivery pipeline (full Option 1) ([675e41a](https://github.com/the-luap/picpeak/commit/675e41a2f72c8c23fa5c36b13bc6b95abcb9d570))
* **workflows:** warn when disabling a built-in (reverts to legacy, not off) ([c5f131c](https://github.com/the-luap/picpeak/commit/c5f131cec32826331722ef3705c5f5422e31726d))
### Bug Fixes
* **crm:** pre-event reminder resolves recipient from the event row, not a non-existent column ([5fbe514](https://github.com/the-luap/picpeak/commit/5fbe514db6e386eee2eeade548bccbb5bbc5b422))
* **event-types:** renaming a type's slug cascades to events, quotes + reminder template ([415c93a](https://github.com/the-luap/picpeak/commit/415c93a512f74898d0225ce2e9298f24cc12f60d))
* **workflows:** close review blockers — prefetch-safe approvals + loud gate-edge failure ([98ab717](https://github.com/the-luap/picpeak/commit/98ab717043e3fdefac0934bf8f4621d523b15e9a))
* **workflows:** harden graph validation + refuse enabling unimplemented flows ([d927464](https://github.com/the-luap/picpeak/commit/d927464778272bd862aa01903179672f4d47368a))
* **workflows:** matchFilter strict equality + accurate comment ([dee8d40](https://github.com/the-luap/picpeak/commit/dee8d40bb3235a908bba514a97a62d3a91a6e131))
* **workflows:** ship built-ins disabled for first beta + enabled-based mutex + admin sentinel ([5893ecb](https://github.com/the-luap/picpeak/commit/5893ecb27a0365a79ec04336c5a122b31d31db0e))
* **workflows:** wire a real, SSRF-guarded webhook action (was a silent no-op) ([af7eea8](https://github.com/the-luap/picpeak/commit/af7eea8b43e37905a79138bcde4b1026dea13050))
## [3.70.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.69.1-beta.0...v3.70.0-beta.0) (2026-06-23)
### Features
* **analytics:** pluggable trackers — Umami + Rybbit + Custom ([#663](https://github.com/the-luap/picpeak/issues/663) Phase 1) ([83461fe](https://github.com/the-luap/picpeak/commit/83461fe5d4d44006482167464d92e70546cf7377))
## [3.69.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.69.0-beta.0...v3.69.1-beta.0) (2026-06-23)
### Bug Fixes
* **analytics:** admin dashboard reads correct fields + Umami device API ([#661](https://github.com/the-luap/picpeak/issues/661)) ([349f566](https://github.com/the-luap/picpeak/commit/349f566e87b33c59f61eb28b8abc5f889e6285d6))
* **analytics:** admin dashboard reads correct fields + Umami device API ([#661](https://github.com/the-luap/picpeak/issues/661)) ([7534447](https://github.com/the-luap/picpeak/commit/7534447b6c0df4290fd8dac12270673097096f1b))
## [3.69.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.68.1-beta.0...v3.69.0-beta.0) (2026-06-22)
@@ -0,0 +1,100 @@
/**
* Tests for the custom-tracker HTML sanitiser (#663 Phase 1).
*
* The field accepts admin-pasted `<head>`-style snippets for arbitrary
* trackers (Plausible / Matomo / Pirsch / GA4 / GoatCounter / Fathom /
* Cloudflare Web Analytics). We sanitise on save with a narrow allowlist
* tuned for tracker scripts — defence-in-depth, even though the field is
* admin-only.
*/
const { sanitizeTrackerSnippet } = require('../../src/services/trackers/customScriptSanitiser');
describe('sanitizeTrackerSnippet (#663)', () => {
test('returns empty string for non-string / empty / whitespace input', () => {
expect(sanitizeTrackerSnippet(null)).toBe('');
expect(sanitizeTrackerSnippet(undefined)).toBe('');
expect(sanitizeTrackerSnippet(42)).toBe('');
expect(sanitizeTrackerSnippet('')).toBe('');
expect(sanitizeTrackerSnippet(' ')).toBe('');
});
test('passes through a Plausible-style script tag with data-domain', () => {
const input = '<script defer data-domain="example.com" src="https://plausible.io/js/script.js"></script>';
const out = sanitizeTrackerSnippet(input);
expect(out).toContain('src="https://plausible.io/js/script.js"');
expect(out).toContain('data-domain="example.com"');
expect(out).toContain('defer');
});
test('passes through a Umami-style script with data-website-id', () => {
const input = '<script async defer src="https://analytics.example.com/script.js" data-website-id="aaa-bbb-ccc"></script>';
const out = sanitizeTrackerSnippet(input);
expect(out).toContain('src="https://analytics.example.com/script.js"');
expect(out).toContain('data-website-id="aaa-bbb-ccc"');
});
test('passes through inline script body unchanged', () => {
const input = '<script>window.GA = "x"; window.tracker = function() { console.log("init"); };</script>';
const out = sanitizeTrackerSnippet(input);
expect(out).toContain('window.GA = "x"');
expect(out).toContain('console.log("init")');
});
test('allows <noscript> fallback', () => {
const input = '<noscript><img src="https://t.example/?nojs=1" /></noscript>';
const out = sanitizeTrackerSnippet(input);
expect(out).toContain('<noscript>');
});
test('allows <link rel="preconnect"> and <link rel="dns-prefetch">', () => {
const out = sanitizeTrackerSnippet(
'<link rel="preconnect" href="https://t.example.com">'
+ '<link rel="dns-prefetch" href="https://t.example.com">',
);
expect(out).toContain('rel="preconnect"');
expect(out).toContain('rel="dns-prefetch"');
expect(out).toContain('href="https://t.example.com"');
});
test('strips <link rel="stylesheet"> (not tracker-related)', () => {
const out = sanitizeTrackerSnippet('<link rel="stylesheet" href="https://evil.example/x.css">');
expect(out).not.toContain('stylesheet');
expect(out).not.toContain('href');
});
test('strips disallowed tags entirely', () => {
const input = '<div><iframe src="https://evil.example/x.html"></iframe><h1>hi</h1></div>';
const out = sanitizeTrackerSnippet(input);
expect(out).not.toContain('iframe');
expect(out).not.toContain('<div');
expect(out).not.toContain('<h1');
});
test('strips javascript: URLs from script src', () => {
const input = '<script src="javascript:alert(1)"></script>';
const out = sanitizeTrackerSnippet(input);
expect(out).not.toContain('javascript:');
});
test('strips data: URLs from script src', () => {
const input = '<script src="data:text/javascript,alert(1)"></script>';
const out = sanitizeTrackerSnippet(input);
expect(out).not.toContain('data:text/javascript');
});
test('strips on* event-handler attributes (defence-in-depth)', () => {
// event-handler attrs are not in our allowlist; sanitize-html strips them.
const input = '<script src="https://t.example/x.js" onload="evil()"></script>';
const out = sanitizeTrackerSnippet(input);
expect(out).not.toContain('onload');
expect(out).toContain('src="https://t.example/x.js"');
});
test('returns empty string on unparseable input rather than throwing', () => {
// sanitize-html is fault-tolerant — pass deliberately malformed and
// confirm we don't blow up.
expect(typeof sanitizeTrackerSnippet('<<<>>>')).toBe('string');
expect(typeof sanitizeTrackerSnippet('<script')).toBe('string');
});
});
@@ -0,0 +1,115 @@
/**
* Tests for the Rybbit metrics-API adapter (#663 Phase 1). Mirrors the
* `umamiAdapter` test contract: missing config / URL shape / encoding /
* normalisation / unknown-bucket drop / failure modes.
*
* Rybbit's documented endpoint is `/api/site/{websiteId}/breakdown` with
* `dimension=device`; we accept both bare-array and `{ data: [...] }`
* envelopes since their docs hint at minor v0 → v1 shape variation.
*/
const { buildAdapter } = require('../../src/services/trackers/rybbitAdapter');
const ORIGINAL_FETCH = global.fetch;
afterEach(() => {
global.fetch = ORIGINAL_FETCH;
});
function mockJson(body, { status = 200 } = {}) {
global.fetch = jest.fn(async () => ({
ok: status >= 200 && status < 300,
status,
json: async () => body,
}));
}
const valid = { baseUrl: 'https://r.example.com', websiteId: 'rsite-789', apiKey: 'rkey' };
describe('rybbitAdapter.fetchDeviceBreakdown (#663)', () => {
test('returns null when config is incomplete', async () => {
expect(await buildAdapter({}).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
expect(global.fetch).toBe(ORIGINAL_FETCH);
});
test('builds the expected URL + sends Bearer auth', async () => {
mockJson([{ device: 'desktop', sessions: 10 }]);
await buildAdapter({ ...valid, baseUrl: 'https://r.example.com/' })
.fetchDeviceBreakdown({ startMs: 1700000000000, endMs: 1700003600000 });
const [calledUrl, init] = global.fetch.mock.calls[0];
expect(calledUrl).toMatch(/^https:\/\/r\.example\.com\/api\/site\/rsite-789\/breakdown\?dimension=device&start=.*&end=.*$/);
expect(init.headers.Authorization).toBe('Bearer rkey');
expect(init.method).toBe('GET');
});
test('URL-encodes the websiteId for reserved chars', async () => {
mockJson([{ device: 'desktop', sessions: 1 }]);
await buildAdapter({ ...valid, websiteId: 'a/b?c' }).fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
const [calledUrl] = global.fetch.mock.calls[0];
expect(calledUrl).toContain('/api/site/a%2Fb%3Fc/breakdown');
});
test('normalises a typical {device, sessions} payload into percentages', async () => {
mockJson([
{ device: 'desktop', sessions: 60 },
{ device: 'mobile', sessions: 30 },
{ device: 'tablet', sessions: 10 },
]);
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
.toEqual({ desktop: 60, mobile: 30, tablet: 10 });
});
test('accepts the {data: [...]} envelope variant', async () => {
mockJson({ data: [
{ device: 'desktop', sessions: 1 },
{ device: 'mobile', sessions: 3 },
] });
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
.toEqual({ desktop: 25, mobile: 75, tablet: 0 });
});
test('falls back to `visitors` when `sessions` is absent', async () => {
mockJson([
{ device: 'desktop', visitors: 80 },
{ device: 'mobile', visitors: 20 },
]);
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
.toEqual({ desktop: 80, mobile: 20, tablet: 0 });
});
test('tolerates a `dimension` key as the bucket label', async () => {
mockJson([
{ dimension: 'desktop', sessions: 50 },
{ dimension: 'mobile', sessions: 50 },
]);
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
.toEqual({ desktop: 50, mobile: 50, tablet: 0 });
});
test('drops unknown buckets', async () => {
mockJson([
{ device: 'desktop', sessions: 80 },
{ device: 'mobile', sessions: 20 },
{ device: 'fridge', sessions: 100 },
]);
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
.toEqual({ desktop: 80, mobile: 20, tablet: 0 });
});
test('returns null on empty payload, non-2xx, invalid JSON, and network error', async () => {
mockJson([]);
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
mockJson({ error: 'unauthorized' }, { status: 401 });
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
global.fetch = jest.fn(async () => ({
ok: true, status: 200,
json: async () => { throw new SyntaxError('not json'); },
}));
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
global.fetch = jest.fn(async () => { throw new Error('ECONNREFUSED'); });
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
});
});
@@ -0,0 +1,91 @@
/**
* Factory tests for the pluggable-tracker registry (#663 Phase 1).
*
* Pins the contract that drives `adminDashboard.js` analytics route:
* - Returns null for 'none' / 'custom' / unset → route falls back to access_logs.
* - Returns an Umami adapter shape for provider='umami'.
* - Returns a Rybbit adapter shape for provider='rybbit'.
* - Back-compat: when `analytics_tracker_provider` is unset, infers
* 'umami' from the legacy `analytics_umami_enabled` flag.
* - Invalid provider strings fall through to the legacy back-compat path
* rather than crashing (defensive).
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-tracker-fact-')), 'db.sqlite',
);
const { bootCrmDb } = require('../integration/helpers/crmDb');
const trackers = require('../../src/services/trackers');
let db; let cleanup;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
}, 30000);
afterAll(async () => { if (cleanup) await cleanup(); });
beforeEach(async () => {
await db('app_settings').del();
});
async function setSetting(key, value) {
await db('app_settings').insert({
setting_key: key,
setting_value: JSON.stringify(value),
setting_type: 'analytics',
updated_at: new Date(),
});
}
describe('resolveAdapter (#663)', () => {
test('returns null when provider=\'none\'', async () => {
await setSetting('analytics_tracker_provider', 'none');
expect(await trackers.resolveAdapter()).toBeNull();
});
test('returns null when provider=\'custom\' (no metrics adapter, just a script slot)', async () => {
await setSetting('analytics_tracker_provider', 'custom');
expect(await trackers.resolveAdapter()).toBeNull();
});
test('back-compat: provider unset + legacy umami_enabled=true → umami adapter', async () => {
await setSetting('analytics_umami_enabled', true);
await setSetting('analytics_umami_url', 'https://u.example');
await setSetting('analytics_umami_website_id', 'w-1');
await setSetting('analytics_umami_api_key', 'k-1');
const adapter = await trackers.resolveAdapter();
expect(adapter).not.toBeNull();
expect(adapter.provider).toBe('umami');
});
test('provider=\'umami\' explicit → umami adapter with stored secrets', async () => {
await setSetting('analytics_tracker_provider', 'umami');
await setSetting('analytics_umami_url', 'https://u.example');
await setSetting('analytics_umami_website_id', 'w-1');
await setSetting('analytics_umami_api_key', 'k-1');
const adapter = await trackers.resolveAdapter();
expect(adapter.provider).toBe('umami');
});
test('provider=\'rybbit\' → rybbit adapter with stored secrets', async () => {
await setSetting('analytics_tracker_provider', 'rybbit');
await setSetting('analytics_rybbit_url', 'https://r.example');
await setSetting('analytics_rybbit_website_id', 'r-1');
await setSetting('analytics_rybbit_api_key', 'rk-1');
const adapter = await trackers.resolveAdapter();
expect(adapter.provider).toBe('rybbit');
});
test('garbage provider value falls through to legacy back-compat (defensive)', async () => {
await setSetting('analytics_tracker_provider', 'plausible-not-yet-supported');
// No legacy umami_enabled → resolves to null (= 'none')
expect(await trackers.resolveAdapter()).toBeNull();
});
});
@@ -0,0 +1,112 @@
/**
* Adapter-style tests for the Umami metrics client (#663 Phase 1, replaces
* the old `umamiClient.test.js` from #662 — same contract, new shape).
*
* Pins the same 10 cases that protected the original implementation: missing
* config / URL shape / encoding / payload normalisation / `laptop` mapping /
* unknown-bucket drop / empty / non-2xx / invalid JSON / network error.
*/
const { buildAdapter } = require('../../src/services/trackers/umamiAdapter');
const ORIGINAL_FETCH = global.fetch;
afterEach(() => {
global.fetch = ORIGINAL_FETCH;
});
function mockJson(body, { status = 200 } = {}) {
global.fetch = jest.fn(async () => ({
ok: status >= 200 && status < 300,
status,
json: async () => body,
}));
}
const valid = { baseUrl: 'https://u.example.com', websiteId: 'site-123', apiKey: 'secret' };
describe('umamiAdapter.fetchDeviceBreakdown (#663)', () => {
test('returns null when config is incomplete (back-compat path)', async () => {
const a = buildAdapter({});
expect(await a.fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
const b = buildAdapter({ baseUrl: 'https://u' });
expect(await b.fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
expect(global.fetch).toBe(ORIGINAL_FETCH);
});
test('builds the expected URL + sends `x-umami-api-key` header', async () => {
mockJson([{ x: 'desktop', y: 10 }]);
const a = buildAdapter({ ...valid, baseUrl: 'https://u.example.com/' });
await a.fetchDeviceBreakdown({ startMs: 1700000000000, endMs: 1700003600000 });
expect(global.fetch).toHaveBeenCalledTimes(1);
const [calledUrl, init] = global.fetch.mock.calls[0];
expect(calledUrl).toBe(
'https://u.example.com/api/websites/site-123/metrics?type=device&startAt=1700000000000&endAt=1700003600000',
);
expect(init.headers['x-umami-api-key']).toBe('secret');
expect(init.method).toBe('GET');
});
test('URL-encodes the websiteId for reserved chars', async () => {
mockJson([{ x: 'desktop', y: 1 }]);
const a = buildAdapter({ baseUrl: 'https://u', websiteId: 'a/b?c', apiKey: 'k' });
await a.fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
const [calledUrl] = global.fetch.mock.calls[0];
expect(calledUrl).toContain('/api/websites/a%2Fb%3Fc/metrics');
});
test('normalises { x, y } payload into integer percentages', async () => {
mockJson([
{ x: 'desktop', y: 60 },
{ x: 'mobile', y: 30 },
{ x: 'tablet', y: 10 },
]);
const a = buildAdapter(valid);
const out = await a.fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
expect(out).toEqual({ desktop: 60, mobile: 30, tablet: 10 });
});
test('maps `laptop` into `desktop` (matches our 3-bucket UI)', async () => {
mockJson([
{ x: 'desktop', y: 50 },
{ x: 'laptop', y: 20 },
{ x: 'mobile', y: 30 },
]);
const out = await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
expect(out).toEqual({ desktop: 70, mobile: 30, tablet: 0 });
});
test('drops unknown buckets (no silent miscategorisation)', async () => {
mockJson([
{ x: 'desktop', y: 80 },
{ x: 'mobile', y: 20 },
{ x: 'unknown-future-bucket', y: 100 },
]);
const out = await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
expect(out).toEqual({ desktop: 80, mobile: 20, tablet: 0 });
});
test('returns null on empty payload', async () => {
mockJson([]);
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
});
test('returns null on non-2xx', async () => {
mockJson({ error: 'unauthorized' }, { status: 401 });
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
});
test('returns null on invalid JSON', async () => {
global.fetch = jest.fn(async () => ({
ok: true,
status: 200,
json: async () => { throw new SyntaxError('not json'); },
}));
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
});
test('returns null on network error', async () => {
global.fetch = jest.fn(async () => { throw new Error('ECONNREFUSED'); });
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
});
});
@@ -0,0 +1,58 @@
/**
* Pins the date-merge fix in `adminDashboard.js` /analytics route
* (#661 Bug A). The merge previously failed on Postgres because pg's
* driver returns `DATE(timestamp)` as a JS Date object, while SQLite
* returns a string — the old `dateObj.date === row.date` comparison
* was false on Postgres so chartData stayed all-zero even with traffic.
*
* We test the normalisation helper here in isolation. The route-level
* integration is covered by the existing dashboard route test.
*/
// The helper is internal to the route file; reimport via a small wrapper
// so we don't need to export everything publicly.
const path = require('path');
const fs = require('fs');
const ROUTE_SRC = fs.readFileSync(
path.join(__dirname, '../../src/routes/adminDashboard.js'),
'utf8',
);
// Tiny evaluator that grabs the normaliseDateKey function definition from
// the route source so the test pins the actual shipping implementation,
// not a copy.
function extractNormaliseDateKey() {
const match = ROUTE_SRC.match(/function normaliseDateKey\([\s\S]*?\n\}/);
if (!match) throw new Error('normaliseDateKey not found in adminDashboard.js');
// eslint-disable-next-line no-new-func
return new Function(`${match[0]}; return normaliseDateKey;`)();
}
const normaliseDateKey = extractNormaliseDateKey();
describe('analytics route — normaliseDateKey (#661 Bug A)', () => {
test('passes through a YYYY-MM-DD string unchanged', () => {
expect(normaliseDateKey('2026-06-22')).toBe('2026-06-22');
});
test('slices off a time component on a longer ISO string', () => {
expect(normaliseDateKey('2026-06-22T00:00:00.000Z')).toBe('2026-06-22');
});
test('normalises a JS Date object (Postgres pg-driver shape) to YYYY-MM-DD', () => {
const d = new Date('2026-06-22T12:34:56Z');
expect(normaliseDateKey(d)).toBe('2026-06-22');
});
test('returns null for null / undefined / empty', () => {
expect(normaliseDateKey(null)).toBeNull();
expect(normaliseDateKey(undefined)).toBeNull();
expect(normaliseDateKey('')).toBeNull();
});
test('coerces unexpected types via String() to avoid throwing', () => {
// We don't expect to receive a number from either driver, but the
// helper should not crash if it does — date merge will simply miss.
expect(normaliseDateKey(20260622)).toBe('20260622');
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "3.69.0-beta.0",
"version": "3.71.0-beta.0",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
+82 -30
View File
@@ -4,8 +4,25 @@ const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { sanitizeDays, addDateRangeCondition } = require('../utils/sqlSecurity');
const { formatBoolean } = require('../utils/dbCompat');
const { resolveAdapter } = require('../services/trackers');
const logger = require('../utils/logger');
const router = express.Router();
/**
* Normalise a value coming back from `DATE(timestamp)` into a YYYY-MM-DD
* string. SQLite returns this column as a string already; Postgres' pg
* driver auto-converts it to a JavaScript Date object, which broke the
* old `dateObj.date === row.date` merge below — every Postgres install saw
* an all-zero `chartData[]` even with real traffic (#661 Bug A). Always
* normalise before comparing.
*/
function normaliseDateKey(value) {
if (!value) return null;
if (value instanceof Date) return value.toISOString().slice(0, 10);
// Strings might arrive with time component, slice defensively.
return String(value).slice(0, 10);
}
// Get dashboard statistics
router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req, res) => {
try {
@@ -265,20 +282,25 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
.where('timestamp', '>=', startDateStr)
.groupByRaw('DATE(timestamp)');
// Merge data into dates array
// Merge data into dates array. row.date is normalised because Postgres
// returns DATE() as a JS Date while SQLite returns a string (#661 Bug A).
// Counts come back as strings on Postgres too, so coerce via Number.
viewsData.forEach(row => {
const dateObj = dates.find(d => d.date === row.date);
if (dateObj) dateObj.views = row.count;
const key = normaliseDateKey(row.date);
const dateObj = dates.find(d => d.date === key);
if (dateObj) dateObj.views = Number(row.count) || 0;
});
downloadsData.forEach(row => {
const dateObj = dates.find(d => d.date === row.date);
if (dateObj) dateObj.downloads = row.count;
const key = normaliseDateKey(row.date);
const dateObj = dates.find(d => d.date === key);
if (dateObj) dateObj.downloads = Number(row.count) || 0;
});
visitorsData.forEach(row => {
const dateObj = dates.find(d => d.date === row.date);
if (dateObj) dateObj.uniqueVisitors = row.count;
const key = normaliseDateKey(row.date);
const dateObj = dates.find(d => d.date === key);
if (dateObj) dateObj.uniqueVisitors = Number(row.count) || 0;
});
// Get top galleries by views with additional metrics
@@ -293,31 +315,60 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
.orderBy('views', 'desc')
.limit(5);
// Get device breakdown (simplified - based on user agent)
const deviceData = await db('access_logs')
.select(
db.raw(`
CASE
WHEN user_agent LIKE '%Mobile%' THEN 'mobile'
WHEN user_agent LIKE '%Tablet%' OR user_agent LIKE '%iPad%' THEN 'tablet'
ELSE 'desktop'
END as device_type
`),
db.raw('COUNT(*) as count')
)
.where('timestamp', '>=', startDateStr)
.groupBy('device_type');
// Device breakdown — prefer the operator's analytics tracker (Umami /
// Rybbit) when configured (#661 Bug C + #663 Phase 1). The local
// access_logs heuristic below produces 0% on installs where guest user
// agents don't reliably contain "Mobile" / "Tablet" tokens; the tracker
// adapters track devices natively. Falls back to access_logs when no
// tracker is configured (provider=none/custom), the upstream call fails,
// or the response shape doesn't match what we expect.
let devices = { desktop: 0, mobile: 0, tablet: 0 };
let devicesSource = 'access_logs';
const totalDevices = deviceData.reduce((sum, d) => sum + d.count, 0);
const devices = {
desktop: 0,
mobile: 0,
tablet: 0
};
const adapter = await resolveAdapter();
if (adapter) {
try {
const trackerDevices = await adapter.fetchDeviceBreakdown({
startMs: startDate.getTime(),
endMs: Date.now(),
});
if (trackerDevices) {
devices = trackerDevices;
devicesSource = adapter.provider;
}
} catch (err) {
logger.warn(`Analytics: ${adapter.provider} device-breakdown fetch failed; falling back to access_logs`, {
error: err.message,
});
}
}
deviceData.forEach(d => {
devices[d.device_type] = Math.round((d.count / totalDevices) * 100);
});
if (devicesSource === 'access_logs') {
// Local heuristic on access_logs user_agent. Coarse — `LIKE` doesn't
// cover every UA shape (some Android browsers, embedded webviews, etc.)
// — and counts come back as strings on Postgres, hence Number() below.
const deviceData = await db('access_logs')
.select(
db.raw(`
CASE
WHEN user_agent LIKE '%Mobile%' THEN 'mobile'
WHEN user_agent LIKE '%Tablet%' OR user_agent LIKE '%iPad%' THEN 'tablet'
ELSE 'desktop'
END as device_type
`),
db.raw('COUNT(*) as count')
)
.where('timestamp', '>=', startDateStr)
.whereNotNull('user_agent')
.groupBy('device_type');
const totalDevices = deviceData.reduce((sum, d) => sum + (Number(d.count) || 0), 0);
if (totalDevices > 0) {
deviceData.forEach(d => {
devices[d.device_type] = Math.round(((Number(d.count) || 0) / totalDevices) * 100);
});
}
}
// Calculate totals for the period (matching /stats logic)
const totalViews = await db('access_logs')
@@ -341,6 +392,7 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
chartData: dates,
topGalleries,
devices,
devicesSource,
totals: {
views: totalViews?.count || 0,
downloads: totalDownloadsCount?.count || 0,
+39
View File
@@ -145,6 +145,16 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
if (settingsObject.security_recaptcha_secret_key) {
settingsObject.security_recaptcha_secret_key = '••••••••';
}
// Umami v2 API key (#661 Bug C) — read-write secret that authenticates
// outbound calls to the operator's Umami instance for the device
// breakdown. Masked on GET, same pattern as the recaptcha secret.
if (settingsObject.analytics_umami_api_key) {
settingsObject.analytics_umami_api_key = '••••••••';
}
// Rybbit API key (#663 Phase 1) — same pattern.
if (settingsObject.analytics_rybbit_api_key) {
settingsObject.analytics_rybbit_api_key = '••••••••';
}
res.json(settingsObject);
} catch (error) {
@@ -390,6 +400,16 @@ router.get('/:type', adminAuth, requirePermission('settings.view'), async (req,
if (settingsObject.security_recaptcha_secret_key) {
settingsObject.security_recaptcha_secret_key = '••••••••';
}
// Umami v2 API key (#661 Bug C) — read-write secret that authenticates
// outbound calls to the operator's Umami instance for the device
// breakdown. Masked on GET, same pattern as the recaptcha secret.
if (settingsObject.analytics_umami_api_key) {
settingsObject.analytics_umami_api_key = '••••••••';
}
// Rybbit API key (#663 Phase 1) — same pattern.
if (settingsObject.analytics_rybbit_api_key) {
settingsObject.analytics_rybbit_api_key = '••••••••';
}
res.json(settingsObject);
} catch (error) {
@@ -1049,6 +1069,25 @@ router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (r
try {
const settings = req.body;
// Validate the provider switch (#663 Phase 1). Reject unknown values
// so the dashboard route's factory doesn't have to defensively guard.
if (Object.prototype.hasOwnProperty.call(settings, 'analytics_tracker_provider')) {
const valid = ['none', 'umami', 'rybbit', 'custom'];
if (!valid.includes(settings.analytics_tracker_provider)) {
return res.status(400).json({
error: `analytics_tracker_provider must be one of: ${valid.join(', ')}`,
});
}
}
// Sanitise the custom-mode HTML snippet on save (#663 Phase 1). Stored
// pre-sanitised so the publicSettings endpoint surfaces it as-is on
// every gallery request — never re-running sanitize-html on the hot path.
if (Object.prototype.hasOwnProperty.call(settings, 'analytics_custom_head_html')) {
const { sanitizeTrackerSnippet } = require('../services/trackers/customScriptSanitiser');
settings.analytics_custom_head_html = sanitizeTrackerSnippet(settings.analytics_custom_head_html);
}
// Update or insert each setting
for (const [key, value] of Object.entries(settings)) {
await db('app_settings')
+34 -1
View File
@@ -130,11 +130,44 @@ router.get('/', async (req, res) => {
enable_recaptcha: settingsObject.security_enable_recaptcha === true || settingsObject.security_enable_recaptcha === 'true',
recaptcha_site_key: settingsObject.security_recaptcha_site_key || null,
maintenance_mode: settingsObject.general_maintenance_mode === true || settingsObject.general_maintenance_mode === 'true',
// Umami analytics configuration (only if enabled)
// Umami analytics configuration (only if enabled). Kept for
// back-compat: pre-#663 installs without `analytics_tracker_provider`
// still surface Umami settings under their original keys so the
// frontend tracker script switches over cleanly.
umami_enabled: settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true',
umami_url: (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true') ? (settingsObject.analytics_umami_url || null) : null,
umami_website_id: (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true') ? (settingsObject.analytics_umami_website_id || null) : null,
umami_share_url: (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true') ? (settingsObject.analytics_umami_share_url || null) : null,
// Tracker-provider switch (#663 Phase 1). Drives which provider's
// script gets injected into the gallery <head>. 'none' / unset =
// no tracker. The frontend tracker service picks the right shape
// from the (provider, *_url, *_website_id) tuple below.
analytics_tracker_provider: (() => {
const explicit = settingsObject.analytics_tracker_provider;
if (typeof explicit === 'string' && ['none', 'umami', 'rybbit', 'custom'].includes(explicit)) {
return explicit;
}
// Back-compat with installs that haven't picked yet.
return (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true')
? 'umami'
: 'none';
})(),
// Rybbit native provider (#663). Only exposed when actively chosen
// — otherwise hidden so the front-end never tries to inject a
// stale tracker.
rybbit_url: settingsObject.analytics_tracker_provider === 'rybbit'
? (settingsObject.analytics_rybbit_url || null)
: null,
rybbit_website_id: settingsObject.analytics_tracker_provider === 'rybbit'
? (settingsObject.analytics_rybbit_website_id || null)
: null,
// Custom-mode pre-sanitised HTML snippet (#663). Sanitised at save
// time via customScriptSanitiser; surfaced as-is here so the
// gallery <head> can render it without re-sanitising on every
// request.
analytics_custom_head_html: settingsObject.analytics_tracker_provider === 'custom'
? (settingsObject.analytics_custom_head_html || '')
: '',
// Event field requirements
event_require_customer_name: settingsObject.event_require_customer_name !== false,
event_require_customer_email: settingsObject.event_require_customer_email !== false,
@@ -0,0 +1,86 @@
/**
* Custom-tracker HTML sanitiser (#663 Phase 1).
*
* Operators picking "Custom" in Settings Analytics paste a `<head>`-style
* HTML snippet (script tag + sometimes a `<noscript>` fallback + DNS-prefetch
* `<link>`s). We sanitise on save and render the sanitised string into the
* gallery `<head>` server-side admin-only field, but defence-in-depth
* matters when the trust boundary widens to e.g. a delegated admin role.
*
* Allowlist (intentionally narrow):
* <script> src, async, defer, type, crossorigin, integrity, nonce,
* referrerpolicy, data-*
* <noscript> no attributes
* <link> rel (preconnect/dns-prefetch only), href, crossorigin
* <meta> name, content, charset
*
* Anything else is stripped. Inline script bodies pass through unchanged
* (the tracker's bootstrap snippet is the whole point), but we DO normalise
* URL schemes `javascript:` / `data:` URLs on `src` / `href` are removed.
*
* Returns the sanitised string. On parse failure, returns an empty string
* (defensive empty snippet just means the gallery `<head>` is unchanged).
*/
const sanitizeHtml = require('sanitize-html');
const ALLOWED_LINK_RELS = new Set(['preconnect', 'dns-prefetch', 'preload']);
function sanitizeTrackerSnippet(raw) {
if (typeof raw !== 'string') return '';
const trimmed = raw.trim();
if (!trimmed) return '';
try {
return sanitizeHtml(trimmed, {
// Allow <script> + a few related tags. sanitize-html disallows
// <script> by default for XSS-protection — we explicitly opt in
// because the entire point of the custom field is a tracker script.
allowedTags: ['script', 'noscript', 'link', 'meta'],
allowedAttributes: {
script: [
'src', 'async', 'defer', 'type', 'crossorigin', 'integrity',
'nonce', 'referrerpolicy',
// Common tracker config attributes — Umami / Plausible / Rybbit
// / Pirsch / GoatCounter all configure via data-* on the script
// tag. sanitize-html doesn't support data-* wildcards, so we
// list the ones the major trackers use. Operators with an
// exotic data-attr the major trackers don't use can either
// file an issue or switch to one of the native providers.
'data-website-id', 'data-site-id', 'data-host-url', 'data-host',
'data-domains', 'data-domain', 'data-auto-track',
'data-do-not-track', 'data-cache', 'data-include', 'data-exclude',
'data-tag', 'data-tracker-script-version', 'data-uniqueid',
'data-events', 'data-api-host', 'data-server',
],
noscript: [],
link: ['rel', 'href', 'crossorigin', 'as'],
meta: ['name', 'content', 'charset', 'http-equiv'],
},
allowedSchemes: ['http', 'https'],
allowedSchemesByTag: {
script: ['http', 'https'],
link: ['http', 'https'],
},
// Inline `<script>…</script>` content needs to survive intact — this
// is the operator's tracker bootstrap. sanitize-html escapes text by
// default for non-script tags; the `allowVulnerableTags` flag is
// required to keep <script> in the allowlist without warnings.
allowVulnerableTags: true,
transformTags: {
// Drop <link> rels we don't recognise (no stylesheet, no icon — those
// aren't tracker-related). Keeps the field narrowly purposeful.
link: (tagName, attribs) => {
if (!ALLOWED_LINK_RELS.has((attribs.rel || '').toLowerCase())) {
return { tagName: '', attribs: {} };
}
return { tagName, attribs };
},
},
});
} catch (_) {
return '';
}
}
module.exports = { sanitizeTrackerSnippet };
+75
View File
@@ -0,0 +1,75 @@
/**
* Pluggable analytics-tracker registry (#663 Phase 1).
*
* Read the `analytics_tracker_provider` app_setting return the matching
* adapter, configured with that provider's secrets. Used by the dashboard
* route to fetch the device breakdown from whichever tracker the operator
* picked, or null when the choice is "none" / "custom" (custom mode injects
* a script tag client-side but doesn't expose a metrics API back to us).
*
* const adapter = await resolveAdapter();
* if (adapter) {
* const devices = await adapter.fetchDeviceBreakdown({ startMs, endMs });
* if (devices) return devices;
* }
* // …fall back to local access_logs heuristic
*
* Back-compat: when `analytics_tracker_provider` is unset (every pre-#663
* install) we fall through to the legacy "is Umami enabled?" shape so the
* device-breakdown fix that landed in #662 keeps working without an admin
* touching settings. Once the admin picks an explicit provider from the
* dropdown introduced in this PR, that wins.
*/
const { getAppSetting } = require('../../utils/appSettings');
const umami = require('./umamiAdapter');
const rybbit = require('./rybbitAdapter');
const VALID_PROVIDERS = ['none', 'umami', 'rybbit', 'custom'];
/**
* Read all the tracker-related settings in one go and decide which adapter
* to instantiate. Returns null when no metrics adapter applies (None /
* Custom / unconfigured / missing key).
*/
async function resolveAdapter() {
const explicit = await getAppSetting('analytics_tracker_provider', null);
let provider = typeof explicit === 'string' && VALID_PROVIDERS.includes(explicit)
? explicit
: null;
// Back-compat: when no explicit provider is set, infer from the legacy
// analytics_umami_enabled flag. Once the admin saves the new dropdown,
// `provider` is always a string and we skip this.
if (!provider) {
const legacyUmami = await getAppSetting('analytics_umami_enabled', false);
provider = legacyUmami === true ? 'umami' : 'none';
}
if (provider === 'umami') {
return umami.buildAdapter({
baseUrl: await getAppSetting('analytics_umami_url', null),
websiteId: await getAppSetting('analytics_umami_website_id', null),
apiKey: await getAppSetting('analytics_umami_api_key', null),
});
}
if (provider === 'rybbit') {
return rybbit.buildAdapter({
baseUrl: await getAppSetting('analytics_rybbit_url', null),
websiteId: await getAppSetting('analytics_rybbit_website_id', null),
apiKey: await getAppSetting('analytics_rybbit_api_key', null),
});
}
// 'none' and 'custom' have no metrics adapter — caller falls back to
// access_logs (Custom mode is purely a client-side script slot).
return null;
}
module.exports = {
resolveAdapter,
VALID_PROVIDERS,
// Exported for tests + direct injection in unit-level scenarios where
// resolveAdapter's getAppSetting calls would be overkill.
buildUmamiAdapter: umami.buildAdapter,
buildRybbitAdapter: rybbit.buildAdapter,
};
@@ -0,0 +1,121 @@
/**
* Rybbit metrics-API adapter (#663 Phase 1).
*
* Rybbit is a self-hosted privacy-friendly analytics product (https://rybbit.io).
* Reporter @alexvaltchev specifically asked for it in #661 follow-up, hence
* its inclusion as the second native adapter alongside Umami.
*
* Contract: matches `umamiAdapter` exactly so the dashboard route can call
* either via the factory.
*
* Auth: Rybbit v1 issues per-account API keys (Account Settings API
* Keys). Sent via `Authorization: Bearer <key>`. Their docs at
* https://rybbit.io/docs/api describe the analytics endpoints.
*
* Endpoint shape (Rybbit v1 stats API, devices breakdown):
*
* GET {baseUrl}/api/site/{websiteId}/breakdown
* ?dimension=device
* &start={iso8601-or-epoch-ms}
* &end={iso8601-or-epoch-ms}
*
* Returns rows like `[{ device: 'desktop', visitors: 123, sessions: 456 }, …]`.
* We aggregate `sessions` into the same 3-bucket shape Umami returns.
*
* Per-bucket naming: Rybbit reports `desktop` / `mobile` / `tablet`
* directly (matches our UI). Anything unrecognised is dropped rather than
* silently miscategorised.
*
* NOTE: Rybbit's API is on v0.x at the time of writing. The endpoint /
* dimension names below match the documented v1 GA shape; if a tester
* confirms a deviation in the wild we adjust here, and the rest of the
* codebase keeps working because the adapter returns null on shape
* mismatch (route falls back to access_logs).
*/
const logger = require('../../utils/logger');
const REQUEST_TIMEOUT_MS = 5000;
function buildAdapter({ baseUrl, websiteId, apiKey }) {
return {
provider: 'rybbit',
async fetchDeviceBreakdown({ startMs, endMs }) {
if (!baseUrl || !websiteId || !apiKey) return null;
const trimmedBase = String(baseUrl).replace(/\/+$/, '');
const startIso = new Date(startMs).toISOString();
const endIso = new Date(endMs).toISOString();
const url = `${trimmedBase}/api/site/${encodeURIComponent(websiteId)}/breakdown`
+ `?dimension=device&start=${encodeURIComponent(startIso)}&end=${encodeURIComponent(endIso)}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
let response;
try {
response = await fetch(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: 'application/json',
},
signal: controller.signal,
});
} catch (err) {
clearTimeout(timer);
if (err.name === 'AbortError') {
logger.warn('Rybbit device fetch: timeout', { url });
return null;
}
logger.warn('Rybbit device fetch: network error', { error: err.message });
return null;
}
clearTimeout(timer);
if (!response.ok) {
logger.warn('Rybbit device fetch: non-2xx', { status: response.status });
return null;
}
let data;
try {
data = await response.json();
} catch (err) {
logger.warn('Rybbit device fetch: invalid JSON', { error: err.message });
return null;
}
// Rybbit might return either `[…]` or `{ data: [...] }` depending on
// version. Accept both shapes defensively.
const rows = Array.isArray(data) ? data : (Array.isArray(data?.data) ? data.data : null);
if (!rows) return null;
const counts = { desktop: 0, mobile: 0, tablet: 0 };
let total = 0;
for (const entry of rows) {
if (!entry) continue;
// Tolerate either `device` or generic `dimension` key for the bucket
// label. Numeric metric prefers sessions, falls back to visitors.
const bucket = entry.device || entry.dimension || entry.name;
if (typeof bucket !== 'string') continue;
const n = Number(entry.sessions ?? entry.visitors ?? entry.value ?? entry.count);
if (!Number.isFinite(n) || n <= 0) continue;
const key = bucket.toLowerCase();
if (key in counts) {
counts[key] += n;
total += n;
}
}
if (total === 0) return null;
return {
desktop: Math.round((counts.desktop / total) * 100),
mobile: Math.round((counts.mobile / total) * 100),
tablet: Math.round((counts.tablet / total) * 100),
};
},
};
}
module.exports = { buildAdapter };
@@ -0,0 +1,99 @@
/**
* Umami v2 metrics-API adapter (#663 extracted from `services/umamiClient.js`
* during the pluggable-tracker refactor in #663 Phase 1).
*
* Contract every tracker adapter implements `fetchDeviceBreakdown` with
* the same signature so the dashboard route can call them interchangeably
* via the factory in `./index.js`:
*
* fetchDeviceBreakdown({ startMs, endMs }) { desktop, mobile, tablet } | null
*
* Returns null on missing config / non-2xx / parse error / network error so
* the route layer can fall back to the local access_logs heuristic.
*
* Auth: per-account API keys generated in Umami Settings Profile API
* Keys. Sent via the `x-umami-api-key` header. Older session-cookie auth is
* intentionally NOT supported operators should issue an API key rather
* than embedding their Umami password in PicPeak.
*/
const logger = require('../../utils/logger');
const REQUEST_TIMEOUT_MS = 5000;
function buildAdapter({ baseUrl, websiteId, apiKey }) {
return {
provider: 'umami',
async fetchDeviceBreakdown({ startMs, endMs }) {
if (!baseUrl || !websiteId || !apiKey) return null;
const trimmedBase = String(baseUrl).replace(/\/+$/, '');
const url = `${trimmedBase}/api/websites/${encodeURIComponent(websiteId)}/metrics`
+ `?type=device&startAt=${encodeURIComponent(startMs)}&endAt=${encodeURIComponent(endMs)}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
let response;
try {
response = await fetch(url, {
method: 'GET',
headers: {
'x-umami-api-key': apiKey,
Accept: 'application/json',
},
signal: controller.signal,
});
} catch (err) {
clearTimeout(timer);
if (err.name === 'AbortError') {
logger.warn('Umami device fetch: timeout', { url });
return null;
}
logger.warn('Umami device fetch: network error', { error: err.message });
return null;
}
clearTimeout(timer);
if (!response.ok) {
logger.warn('Umami device fetch: non-2xx', { status: response.status });
return null;
}
let data;
try {
data = await response.json();
} catch (err) {
logger.warn('Umami device fetch: invalid JSON', { error: err.message });
return null;
}
if (!Array.isArray(data)) return null;
// Umami buckets device types into these strings: `desktop`, `mobile`,
// `tablet`, `laptop`. Map `laptop` → `desktop` for our 3-bucket UI; drop
// anything we don't recognise (vs. silently miscategorising).
const counts = { desktop: 0, mobile: 0, tablet: 0 };
let total = 0;
for (const entry of data) {
if (!entry || typeof entry.x !== 'string') continue;
const n = Number(entry.y);
if (!Number.isFinite(n) || n <= 0) continue;
const key = entry.x === 'laptop' ? 'desktop' : entry.x;
if (key in counts) {
counts[key] += n;
total += n;
}
}
if (total === 0) return null;
return {
desktop: Math.round((counts.desktop / total) * 100),
mobile: Math.round((counts.mobile / total) * 100),
tablet: Math.round((counts.tablet / total) * 100),
};
},
};
}
module.exports = { buildAdapter };
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "3.69.0-beta.0",
"version": "3.71.0-beta.0",
"type": "module",
"scripts": {
"dev": "vite",
+39 -6
View File
@@ -90,8 +90,13 @@ const queryClient = new QueryClient({
},
});
// Bootstraps Umami analytics from /public/settings. Lives inside QueryClientProvider
// so it shares the public-settings cache with every other consumer of usePublicSettings.
// Bootstraps the analytics tracker from /public/settings. Lives inside
// QueryClientProvider so it shares the public-settings cache with every
// other consumer of usePublicSettings. Dispatches based on the
// `analytics_tracker_provider` switch (#663 Phase 1) — Umami / Rybbit /
// Custom / None. Back-compat: when the provider field is missing or unset,
// falls through to the legacy `umami_enabled`-based behaviour so installs
// that haven't picked yet keep working.
function AnalyticsBootstrap() {
const { data: settings, isError } = usePublicSettings();
@@ -100,21 +105,49 @@ function AnalyticsBootstrap() {
const envUmamiUrl = import.meta.env.VITE_UMAMI_URL;
const envUmamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
const provider = settings?.analytics_tracker_provider;
if (settings?.umami_enabled && settings.umami_url && settings.umami_website_id) {
if (provider === 'rybbit' && settings?.rybbit_url && settings.rybbit_website_id) {
analyticsService.initialize({
websiteId: settings.umami_website_id,
hostUrl: settings.umami_url,
provider: 'rybbit',
hostUrl: settings.rybbit_url,
websiteId: settings.rybbit_website_id,
autoTrack: true,
doNotTrack: true,
});
return;
}
if (provider === 'custom') {
analyticsService.initialize({
provider: 'custom',
customHeadHtml: settings?.analytics_custom_head_html || '',
});
return;
}
// Umami: explicit provider OR legacy umami_enabled path.
if (
(provider === 'umami' || settings?.umami_enabled)
&& settings?.umami_url && settings?.umami_website_id
) {
analyticsService.initialize({
provider: 'umami',
hostUrl: settings.umami_url,
websiteId: settings.umami_website_id,
autoTrack: true,
doNotTrack: true,
});
return;
}
// Env-var fallback (legacy deploys). Only when no DB config and
// analytics aren't disabled at the public-site level.
if (envUmamiUrl && envUmamiWebsiteId && (isError || settings?.enable_analytics !== false)) {
analyticsService.initialize({
websiteId: envUmamiWebsiteId,
provider: 'umami',
hostUrl: envUmamiUrl,
websiteId: envUmamiWebsiteId,
autoTrack: true,
doNotTrack: true,
});
@@ -128,12 +128,21 @@ export const PublishGalleryDialog: React.FC<PublishGalleryDialogProps> = ({
</div>
)}
<div className="flex gap-3">
{/* Stack both buttons vertically (always). The German primary label
"Veröffentlichen & Kunden benachrichtigen" is ~40 chars including
the icon at max-w-md, no side-by-side row layout fits it on one
line, and the base .btn class has @apply whitespace-nowrap (see
index.css:149) which overrides a whitespace-normal className via
CSS cascade order, so the text won't wrap either. Side-by-side
would silently push the button past the modal frame (#670).
col-reverse keeps the DOM order semantically secondary-then-primary
while putting the primary action visually on top standard
confirmation-dialog pattern. */}
<div className="flex flex-col-reverse gap-3">
<Button
variant="outline"
onClick={onClose}
disabled={isPublishing}
className="flex-1"
>
{t('common.cancel', 'Cancel')}
</Button>
@@ -143,7 +152,6 @@ export const PublishGalleryDialog: React.FC<PublishGalleryDialogProps> = ({
disabled={isPublishing}
isLoading={isPublishing}
leftIcon={willNotify ? <Send className="w-4 h-4" /> : undefined}
className="flex-1"
>
{willNotify ? t('events.publishAndNotify') : t('events.publishDialog.justPublish', 'Publish')}
</Button>
@@ -45,11 +45,31 @@ export interface SecuritySettings {
recaptcha_secret_key: string;
}
export type TrackerProvider = 'none' | 'umami' | 'rybbit' | 'custom';
export interface AnalyticsSettings {
// Tracker-provider switch (#663 Phase 1). Drives which provider's
// settings panel renders + which tracker script gets injected into the
// public gallery. 'none' = no tracker; 'custom' = paste-your-own HTML.
tracker_provider: TrackerProvider;
umami_enabled: boolean;
umami_url: string;
umami_website_id: string;
umami_share_url: string;
// API key for Umami's v2 metrics API. Required ONLY for the device
// breakdown (#661 Bug C); the rest of the integration (embedded iframe,
// tracker script) still works without it. Server masks as `••••••••`
// on GET when a value is stored — submit the masked sentinel unchanged
// to keep the stored value, or a real key to replace it.
umami_api_key: string;
// Rybbit native provider (#663 Phase 1). Same shape as Umami.
rybbit_url: string;
rybbit_website_id: string;
rybbit_api_key: string;
// Custom-mode HTML snippet (#663). Sanitised server-side on save via
// sanitize-html with a tracker-script allowlist. Rendered into the
// public gallery <head> as-is on every request.
custom_head_html: string;
}
export interface EventSettings {
@@ -126,10 +146,16 @@ export function useSettingsState() {
// Analytics settings state
const [analyticsSettings, setAnalyticsSettings] = useState<AnalyticsSettings>({
tracker_provider: 'none',
umami_enabled: false,
umami_url: '',
umami_website_id: '',
umami_share_url: ''
umami_share_url: '',
umami_api_key: '',
rybbit_url: '',
rybbit_website_id: '',
rybbit_api_key: '',
custom_head_html: ''
});
// Event creation settings state
@@ -215,11 +241,27 @@ export function useSettingsState() {
recaptcha_secret_key: settings.security_recaptcha_secret_key ?? ''
});
// Tracker provider: prefer explicit setting; fall back to legacy
// umami_enabled flag for installs that haven't picked yet (#663).
const explicitProvider = settings.analytics_tracker_provider;
const provider: TrackerProvider = (
explicitProvider === 'none' || explicitProvider === 'umami'
|| explicitProvider === 'rybbit' || explicitProvider === 'custom'
)
? explicitProvider
: (toBoolean(settings.analytics_umami_enabled, false) ? 'umami' : 'none');
setAnalyticsSettings({
tracker_provider: provider,
umami_enabled: toBoolean(settings.analytics_umami_enabled, false),
umami_url: settings.analytics_umami_url || '',
umami_website_id: settings.analytics_umami_website_id || '',
umami_share_url: settings.analytics_umami_share_url || ''
umami_share_url: settings.analytics_umami_share_url || '',
umami_api_key: settings.analytics_umami_api_key || '',
rybbit_url: settings.analytics_rybbit_url || '',
rybbit_website_id: settings.analytics_rybbit_website_id || '',
rybbit_api_key: settings.analytics_rybbit_api_key || '',
custom_head_html: settings.analytics_custom_head_html || ''
});
setEventSettings({
@@ -315,8 +357,16 @@ export function useSettingsState() {
mutationFn: async () => {
const settingsData: Record<string, unknown> = {};
Object.entries(analyticsSettings).forEach(([key, value]) => {
// API keys (Umami / Rybbit) are returned masked as `••••••••` on
// GET so they don't leak in the response body. Don't re-save the
// sentinel — silently preserve whatever's already stored.
if ((key === 'umami_api_key' || key === 'rybbit_api_key') && value === '••••••••') return;
settingsData[`analytics_${key}`] = value;
});
// Keep the legacy `analytics_umami_enabled` flag in sync with the
// new `tracker_provider` switch so back-compat consumers (publicSettings
// surface, embedded Umami iframe) keep working when provider !== 'umami'.
settingsData.analytics_umami_enabled = analyticsSettings.tracker_provider === 'umami';
return settingsService.updateSettings(settingsData);
},
onSuccess: () => {
@@ -1,8 +1,8 @@
import React from 'react';
import { Save, Globe, Key, Activity, AlertCircle } from 'lucide-react';
import { Save, Globe, Key, Activity, AlertCircle, Code } from 'lucide-react';
import { Button, Card, Input } from '../../../components/common';
import { useTranslation } from 'react-i18next';
import type { AnalyticsSettings } from '../hooks/useSettingsState';
import type { AnalyticsSettings, TrackerProvider } from '../hooks/useSettingsState';
interface AnalyticsTabProps {
analyticsSettings: AnalyticsSettings;
@@ -13,6 +13,8 @@ interface AnalyticsTabProps {
};
}
const PROVIDER_OPTIONS: TrackerProvider[] = ['none', 'umami', 'rybbit', 'custom'];
export const AnalyticsTab: React.FC<AnalyticsTabProps> = ({
analyticsSettings,
setAnalyticsSettings,
@@ -20,87 +22,238 @@ export const AnalyticsTab: React.FC<AnalyticsTabProps> = ({
}) => {
const { t } = useTranslation();
const provider = analyticsSettings.tracker_provider;
return (
<div className="space-y-6">
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('settings.analytics.umamiIntegration')}</h2>
<div className="space-y-4">
<label className="flex items-center">
<input
type="checkbox"
checked={analyticsSettings.umami_enabled}
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_enabled: e.target.checked }))}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">{t('settings.analytics.enableUmami')}</span>
</label>
{analyticsSettings.umami_enabled && (
<>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.analytics.umamiUrl')}
</label>
<Input
type="url"
value={analyticsSettings.umami_url}
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_url: e.target.value }))}
placeholder="https://analytics.yourdomain.com"
leftIcon={<Globe className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.analytics.umamiUrlHelp')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.analytics.websiteId')}
</label>
<Input
type="text"
value={analyticsSettings.umami_website_id}
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_website_id: e.target.value }))}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.analytics.websiteIdHelp')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.analytics.shareUrl')}
</label>
<Input
type="url"
value={analyticsSettings.umami_share_url}
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_share_url: e.target.value }))}
placeholder="https://analytics.yourdomain.com/share/..."
leftIcon={<Activity className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.analytics.shareUrlHelp')}
</p>
</div>
</>
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1">
{t('settings.analytics.providerHeading', 'Analytics provider')}
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t(
'settings.analytics.providerDescription',
'Pick which tracker to use for the public gallery, or paste your own script. The admin dashboard\'s device-breakdown chart only enriches when you pick Umami or Rybbit — those expose a metrics API. Other providers (Plausible, Matomo, GA4, …) work via the Custom mode below.',
)}
</p>
{/* Provider dropdown — single source of truth for which panel renders. */}
<div className="mb-4">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.analytics.providerLabel', 'Provider')}
</label>
<select
value={provider}
onChange={(e) => setAnalyticsSettings((prev) => ({
...prev,
tracker_provider: e.target.value as TrackerProvider,
}))}
className="w-full sm:w-72 px-3 py-2 text-sm border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100"
>
{PROVIDER_OPTIONS.map((p) => (
<option key={p} value={p}>
{t(`settings.analytics.provider.${p}`, p)}
</option>
))}
</select>
</div>
{/* Umami panel */}
{provider === 'umami' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.analytics.umamiUrl')}
</label>
<Input
type="url"
value={analyticsSettings.umami_url}
onChange={(e) => setAnalyticsSettings((prev) => ({ ...prev, umami_url: e.target.value }))}
placeholder="https://analytics.yourdomain.com"
leftIcon={<Globe className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.analytics.umamiUrlHelp')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.analytics.websiteId')}
</label>
<Input
type="text"
value={analyticsSettings.umami_website_id}
onChange={(e) => setAnalyticsSettings((prev) => ({ ...prev, umami_website_id: e.target.value }))}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.analytics.websiteIdHelp')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.analytics.shareUrl')}
</label>
<Input
type="url"
value={analyticsSettings.umami_share_url}
onChange={(e) => setAnalyticsSettings((prev) => ({ ...prev, umami_share_url: e.target.value }))}
placeholder="https://analytics.yourdomain.com/share/..."
leftIcon={<Activity className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.analytics.shareUrlHelp')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.analytics.umamiApiKey', 'API key')}
</label>
<Input
type="password"
value={analyticsSettings.umami_api_key}
onChange={(e) => setAnalyticsSettings((prev) => ({ ...prev, umami_api_key: e.target.value }))}
placeholder="api_xxx…"
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
autoComplete="off"
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t(
'settings.analytics.umamiApiKeyHelp',
'Optional. Required only for the device-breakdown chart on the Analytics dashboard. Generate in Umami → Settings → Profile → API Keys. Stored masked as •••••••• once saved — leave the masked value to keep the existing key.',
)}
</p>
</div>
</div>
)}
{/* Rybbit panel */}
{provider === 'rybbit' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.analytics.rybbitUrl', 'Rybbit URL')}
</label>
<Input
type="url"
value={analyticsSettings.rybbit_url}
onChange={(e) => setAnalyticsSettings((prev) => ({ ...prev, rybbit_url: e.target.value }))}
placeholder="https://app.rybbit.io"
leftIcon={<Globe className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t(
'settings.analytics.rybbitUrlHelp',
'Your Rybbit instance URL — `https://app.rybbit.io` for the SaaS, or `https://rybbit.yourdomain.com` for self-hosted.',
)}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.analytics.rybbitWebsiteId', 'Site ID')}
</label>
<Input
type="text"
value={analyticsSettings.rybbit_website_id}
onChange={(e) => setAnalyticsSettings((prev) => ({ ...prev, rybbit_website_id: e.target.value }))}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t(
'settings.analytics.rybbitWebsiteIdHelp',
'Found in Rybbit → Sites → your site → Tracking script.',
)}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.analytics.rybbitApiKey', 'API key')}
</label>
<Input
type="password"
value={analyticsSettings.rybbit_api_key}
onChange={(e) => setAnalyticsSettings((prev) => ({ ...prev, rybbit_api_key: e.target.value }))}
placeholder="rybbit_xxx…"
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
autoComplete="off"
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t(
'settings.analytics.rybbitApiKeyHelp',
'Optional. Required only for the device-breakdown chart. Generate in Rybbit → Account → Settings → API Keys. Stored masked as •••••••• once saved.',
)}
</p>
</div>
</div>
)}
{/* Custom panel */}
{provider === 'custom' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.analytics.customHeadHtml', 'Custom <head> HTML')}
</label>
<div className="relative">
<span className="absolute left-3 top-3 pointer-events-none">
<Code className="w-5 h-5 text-neutral-400" />
</span>
<textarea
value={analyticsSettings.custom_head_html}
onChange={(e) => setAnalyticsSettings((prev) => ({ ...prev, custom_head_html: e.target.value }))}
placeholder={'<script async defer data-domain="example.com" src="https://plausible.io/js/script.js"></script>'}
rows={6}
spellCheck={false}
className="w-full pl-10 pr-3 py-2 font-mono text-xs border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100"
/>
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t(
'settings.analytics.customHeadHtmlHelp',
'Paste your tracker\'s `<head>` snippet (Plausible, Matomo, Pirsch, GA4, GoatCounter, Fathom, Cloudflare Web Analytics, …). Sanitised on save: only `<script>` / `<noscript>` / `<link rel="preconnect|dns-prefetch">` / `<meta>` tags survive, and event-handler attributes are stripped. The admin dashboard\'s device-breakdown chart falls back to a server-side user-agent heuristic in this mode — pick Umami or Rybbit if you want the tracker-side numbers.',
)}
</p>
</div>
<div className="p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0" />
<div className="text-sm text-amber-800 dark:text-amber-200">
<p className="font-medium mb-1">
{t('settings.analytics.customCspWarning', 'Content-Security-Policy reminder')}
</p>
<p>
{t(
'settings.analytics.customCspWarningText',
'PicPeak ships with a strict CSP (`script-src \'self\'`). If your tracker loads from another domain, add that domain to your reverse-proxy or nginx CSP config — otherwise the browser silently blocks the script.',
)}
</p>
</div>
</div>
</div>
</div>
)}
{provider === 'none' && (
<div className="p-4 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
<div className="text-sm text-blue-800 dark:text-blue-200">
<p className="font-medium mb-1">{t('settings.analytics.umamiInfo')}</p>
<p>{t('settings.analytics.umamiInfoText')}</p>
<a href="https://umami.is" target="_blank" rel="noopener noreferrer" className="underline mt-1 inline-block">
{t('settings.analytics.learnMore')}
</a>
{t(
'settings.analytics.providerNoneInfo',
'No external tracker injected. The admin dashboard still shows summary cards + the daily chart from PicPeak\'s own access_logs; the device-breakdown chart uses a coarse user-agent heuristic.',
)}
</div>
</div>
</div>
</div>
)}
<div className="mt-6">
<Button
+20
View File
@@ -1609,6 +1609,26 @@
},
"analytics": {
"title": "Analyse",
"providerHeading": "Analytics-Anbieter",
"providerDescription": "Tracker für die öffentliche Galerie wählen oder eigenes Skript einfügen. Das Geräte-Diagramm im Admin-Dashboard wird nur bei Umami oder Rybbit angereichert — diese stellen eine Metrics-API bereit. Andere Anbieter (Plausible, Matomo, GA4, …) funktionieren über den Custom-Modus unten.",
"providerLabel": "Anbieter",
"provider": {
"none": "Keiner",
"umami": "Umami",
"rybbit": "Rybbit",
"custom": "Custom (eigenes Skript einfügen)"
},
"providerNoneInfo": "Kein externer Tracker eingebunden. Das Admin-Dashboard zeigt weiterhin Übersichtskarten + den Tagesverlauf aus PicPeaks eigenen access_logs; das Geräte-Diagramm verwendet eine grobe User-Agent-Heuristik.",
"rybbitUrl": "Rybbit-URL",
"rybbitUrlHelp": "URL Ihrer Rybbit-Instanz — `https://app.rybbit.io` für SaaS oder `https://rybbit.ihre-domain.de` für selbst gehostet.",
"rybbitWebsiteId": "Site-ID",
"rybbitWebsiteIdHelp": "Zu finden in Rybbit → Sites → Ihre Site → Tracking-Skript.",
"rybbitApiKey": "API-Schlüssel",
"rybbitApiKeyHelp": "Optional. Nur für das Geräte-Diagramm erforderlich. Erstellen in Rybbit → Account → Settings → API Keys. Beim Abruf maskiert als •••••••• gespeichert.",
"customHeadHtml": "Eigenes <head>-HTML",
"customHeadHtmlHelp": "Tracker-`<head>`-Snippet einfügen (Plausible, Matomo, Pirsch, GA4, GoatCounter, Fathom, Cloudflare Web Analytics, …). Beim Speichern bereinigt: nur `<script>`, `<noscript>`, `<link rel=\"preconnect|dns-prefetch\">` und `<meta>` bleiben erhalten; Event-Handler-Attribute werden entfernt. Das Geräte-Diagramm fällt in diesem Modus auf eine serverseitige User-Agent-Heuristik zurück — für tracker-seitige Zahlen Umami oder Rybbit wählen.",
"customCspWarning": "Hinweis zur Content-Security-Policy",
"customCspWarningText": "PicPeak setzt eine strikte CSP (`script-src 'self'`) ein. Wenn Ihr Tracker von einer anderen Domain lädt, müssen Sie diese Domain in Ihrer Reverse-Proxy- oder nginx-CSP-Konfiguration zulassen — sonst blockiert der Browser das Skript stillschweigend.",
"umamiIntegration": "Umami Analytics Integration",
"enableUmami": "Umami Analytics aktivieren",
"umamiUrl": "Umami URL",
+20
View File
@@ -1165,6 +1165,26 @@
},
"analytics": {
"title": "Analytics",
"providerHeading": "Analytics provider",
"providerDescription": "Pick which tracker to use for the public gallery, or paste your own script. The admin dashboard's device-breakdown chart only enriches when you pick Umami or Rybbit — those expose a metrics API. Other providers (Plausible, Matomo, GA4, …) work via the Custom mode below.",
"providerLabel": "Provider",
"provider": {
"none": "None",
"umami": "Umami",
"rybbit": "Rybbit",
"custom": "Custom (paste your own script)"
},
"providerNoneInfo": "No external tracker injected. The admin dashboard still shows summary cards + the daily chart from PicPeak's own access_logs; the device-breakdown chart uses a coarse user-agent heuristic.",
"rybbitUrl": "Rybbit URL",
"rybbitUrlHelp": "Your Rybbit instance URL — `https://app.rybbit.io` for the SaaS, or `https://rybbit.yourdomain.com` for self-hosted.",
"rybbitWebsiteId": "Site ID",
"rybbitWebsiteIdHelp": "Found in Rybbit → Sites → your site → Tracking script.",
"rybbitApiKey": "API key",
"rybbitApiKeyHelp": "Optional. Required only for the device-breakdown chart. Generate in Rybbit → Account → Settings → API Keys. Stored masked as •••••••• once saved.",
"customHeadHtml": "Custom <head> HTML",
"customHeadHtmlHelp": "Paste your tracker's `<head>` snippet (Plausible, Matomo, Pirsch, GA4, GoatCounter, Fathom, Cloudflare Web Analytics, …). Sanitised on save: only `<script>`, `<noscript>`, `<link rel=\"preconnect|dns-prefetch\">` and `<meta>` tags survive, and event-handler attributes are stripped. The admin dashboard's device-breakdown chart falls back to a server-side user-agent heuristic in this mode — pick Umami or Rybbit if you want the tracker-side numbers.",
"customCspWarning": "Content-Security-Policy reminder",
"customCspWarningText": "PicPeak ships with a strict CSP (`script-src 'self'`). If your tracker loads from another domain, add that domain to your reverse-proxy or nginx CSP config — otherwise the browser silently blocks the script.",
"umamiIntegration": "Umami Analytics Integration",
"enableUmami": "Enable Umami Analytics",
"umamiUrl": "Umami URL",
+18 -16
View File
@@ -82,21 +82,19 @@ export const AnalyticsPage: React.FC = () => {
useEffect(() => {
const fetchUmamiConfig = async () => {
try {
// Use admin API endpoint with auth token since we're in admin area
// `/admin/settings` returns a key/value object (see backend
// `adminSettings.js:108`), NOT an array (#661 Bug B). The old
// `.reduce()` path threw `data.reduce is not a function` and the
// catch block silently rendered the "Umami Not Configured" banner
// even on perfectly-configured installs. Read keys directly.
const response = await api.get('/admin/settings');
const settings = response.data;
// Transform the settings array to object
const settingsMap = settings.reduce((acc: any, setting: any) => {
acc[setting.key] = setting.value;
return acc;
}, {});
const settings = response.data ?? {};
// Check if Umami is enabled in admin settings
if (settingsMap.analytics_umami_enabled && settingsMap.analytics_umami_url && settingsMap.analytics_umami_website_id) {
if (settings.analytics_umami_enabled && settings.analytics_umami_url && settings.analytics_umami_website_id) {
setUmamiConfig({
url: settingsMap.analytics_umami_url,
shareUrl: settingsMap.analytics_umami_share_url,
url: settings.analytics_umami_url,
shareUrl: settings.analytics_umami_share_url,
enabled: true
});
} else {
@@ -139,10 +137,14 @@ export const AnalyticsPage: React.FC = () => {
const analytics: ComponentAnalyticsData | undefined = React.useMemo(() => {
if (!apiData) return undefined;
// Calculate totals from chart data
const totalViews = apiData.chartData.reduce((sum, day) => sum + day.views, 0);
const totalVisitors = apiData.chartData.reduce((sum, day) => sum + day.uniqueVisitors, 0);
const totalDownloads = apiData.chartData.reduce((sum, day) => sum + day.downloads, 0);
// Headline totals come from the dedicated `totals` object that the
// backend computes via separate COUNT queries (#661 Bug A). The old
// sum-the-chartData path returned 0 on Postgres installs because the
// backend's date-string merge into chartData failed there. Postgres'
// pg driver returns COUNT(...) as strings, so coerce via Number().
const totalViews = Number(apiData.totals?.views ?? 0);
const totalVisitors = Number(apiData.totals?.uniqueVisitors ?? 0);
const totalDownloads = Number(apiData.totals?.downloads ?? 0);
// Calculate trends (comparing last half to first half)
const halfPoint = Math.floor(apiData.chartData.length / 2);
+12
View File
@@ -327,6 +327,18 @@ export interface AnalyticsData {
mobile: number;
tablet: number;
};
// Period totals computed via dedicated COUNT queries on the backend
// (#661 Bug A). Postgres returns these as strings, so callers should
// coerce via Number() before display. Optional on the type because
// older backends (pre-#661) didn't always emit it.
totals?: {
views: number | string;
downloads: number | string;
uniqueVisitors: number | string;
};
// Source of the device breakdown — `umami` when API-key auth succeeded,
// `access_logs` for the local user-agent heuristic fallback (#661 Bug C).
devicesSource?: 'umami' | 'access_logs';
}
export const adminService = {
+114 -52
View File
@@ -1,14 +1,47 @@
// Umami Analytics Service
// Provides integration with Umami for tracking page views and events
// Pluggable analytics service (#663 Phase 1).
//
// Routes initialization to the right tracker based on the operator's chosen
// provider in Settings → Analytics, and dispatches `track()` calls to the
// tracker's runtime API when one is loaded.
//
// None → no script, no-op tracking.
// Umami → inject Umami script tag; `window.umami.track(name, data)`.
// Rybbit → inject Rybbit script tag; `window.rybbit.event(name, data)`.
// Custom → render admin-pasted HTML (sanitised server-side) into <head>;
// no runtime API hook — `track()` becomes a no-op.
interface UmamiConfig {
websiteId?: string;
hostUrl?: string;
export type TrackerProvider = 'none' | 'umami' | 'rybbit' | 'custom';
interface BaseInitConfig {
provider: TrackerProvider;
autoTrack?: boolean;
doNotTrack?: boolean;
}
interface UmamiInitConfig extends BaseInitConfig {
provider: 'umami';
websiteId: string;
hostUrl: string;
domains?: string[];
}
interface RybbitInitConfig extends BaseInitConfig {
provider: 'rybbit';
websiteId: string;
hostUrl: string;
}
interface CustomInitConfig extends BaseInitConfig {
provider: 'custom';
customHeadHtml: string;
}
interface NoneInitConfig extends BaseInitConfig {
provider: 'none';
}
type InitConfig = UmamiInitConfig | RybbitInitConfig | CustomInitConfig | NoneInitConfig;
declare global {
interface Window {
umami?: {
@@ -21,74 +54,107 @@ declare global {
websiteId?: string
) => void;
};
rybbit?: {
event: (eventName: string, eventData?: any) => void;
pageview?: () => void;
};
}
}
class AnalyticsService {
private initialized = false;
private provider: TrackerProvider = 'none';
private websiteId: string | null = null;
// private hostUrl: string | null = null;
initialize(config: UmamiConfig) {
initialize(config: InitConfig) {
if (this.initialized) return;
const { websiteId, hostUrl, autoTrack = true, doNotTrack = true } = config;
if (!websiteId || !hostUrl) {
console.warn('Umami Analytics: Missing websiteId or hostUrl');
if (config.provider === 'none') {
this.initialized = true;
this.provider = 'none';
return;
}
this.websiteId = websiteId;
// this.hostUrl = hostUrl;
// Create and inject Umami script
const script = document.createElement('script');
script.async = true;
script.defer = true;
script.src = `${hostUrl}/script.js`;
script.setAttribute('data-website-id', websiteId);
if (!autoTrack) {
script.setAttribute('data-auto-track', 'false');
}
if (doNotTrack) {
script.setAttribute('data-do-not-track', 'true');
if (config.provider === 'umami') {
if (!config.websiteId || !config.hostUrl) {
console.warn('Umami: missing websiteId or hostUrl');
return;
}
this.websiteId = config.websiteId;
const script = document.createElement('script');
script.async = true;
script.defer = true;
script.src = `${config.hostUrl.replace(/\/+$/, '')}/script.js`;
script.setAttribute('data-website-id', config.websiteId);
if (config.autoTrack === false) script.setAttribute('data-auto-track', 'false');
if (config.doNotTrack !== false) script.setAttribute('data-do-not-track', 'true');
if (config.domains?.length) script.setAttribute('data-domains', config.domains.join(','));
document.head.appendChild(script);
} else if (config.provider === 'rybbit') {
if (!config.websiteId || !config.hostUrl) {
console.warn('Rybbit: missing websiteId or hostUrl');
return;
}
this.websiteId = config.websiteId;
const script = document.createElement('script');
script.async = true;
script.defer = true;
script.src = `${config.hostUrl.replace(/\/+$/, '')}/api/script.js`;
script.setAttribute('data-site-id', config.websiteId);
document.head.appendChild(script);
} else if (config.provider === 'custom') {
// The admin-pasted HTML is sanitised server-side (see
// backend `customScriptSanitiser.js`). We render it via a wrapper
// <div> and move each child node into <head> so <script> tags
// execute. Using innerHTML on a <head> directly is also fine
// here — the child nodes get parsed and inserted in order.
const html = (config.customHeadHtml || '').trim();
if (html) {
const container = document.createElement('div');
container.innerHTML = html;
// Re-create <script> elements so the browser actually evaluates
// them — assigning innerHTML to a parent inserts the nodes but
// doesn't trigger script execution per the HTML spec.
Array.from(container.childNodes).forEach((node) => {
if (node.nodeName === 'SCRIPT') {
const orig = node as HTMLScriptElement;
const fresh = document.createElement('script');
Array.from(orig.attributes).forEach((attr) => fresh.setAttribute(attr.name, attr.value));
if (orig.textContent) fresh.textContent = orig.textContent;
document.head.appendChild(fresh);
} else {
document.head.appendChild(node);
}
});
}
}
if (config.domains && config.domains.length > 0) {
script.setAttribute('data-domains', config.domains.join(','));
}
document.head.appendChild(script);
this.provider = config.provider;
this.initialized = true;
}
// Check if analytics is initialized
isInitialized() {
return this.initialized;
}
// Track custom events
// Track custom events. Dispatched to whichever tracker is loaded; custom
// mode no-ops (we don't know the operator's tracker's runtime API).
track(eventName: string, eventData?: Record<string, any>) {
if (!this.initialized || !window.umami) {
// Silently ignore if not initialized
return;
if (!this.initialized) return;
if (this.provider === 'umami' && typeof window !== 'undefined' && window.umami) {
window.umami.track(eventName, eventData);
} else if (this.provider === 'rybbit' && typeof window !== 'undefined' && window.rybbit) {
window.rybbit.event(eventName, eventData);
}
// Umami expects flat event data
window.umami.track(eventName, eventData);
// 'none' / 'custom' / unloaded → silently ignore.
}
// Track page views manually
trackPageView(url?: string, referrer?: string) {
if (!this.initialized || !window.umami) {
// Silently ignore if not initialized
return;
if (!this.initialized) return;
if (this.provider === 'umami' && typeof window !== 'undefined' && window.umami) {
window.umami.trackView(url, referrer, this.websiteId || undefined);
} else if (this.provider === 'rybbit' && typeof window !== 'undefined' && window.rybbit?.pageview) {
window.rybbit.pageview();
}
window.umami.trackView(url, referrer, this.websiteId || undefined);
}
// Gallery-specific tracking events
@@ -96,12 +162,10 @@ class AnalyticsService {
this.track(`gallery_${eventType}`, data);
}
// Admin-specific tracking events
trackAdminEvent(eventType: 'login' | 'event_created' | 'event_archived' | 'event_deleted' | 'settings_updated', data?: any) {
this.track(`admin_${eventType}`, data);
}
// Track download events with more context
trackDownload(photoId: string | number, gallerySlug: string, isBulk: boolean = false) {
this.track('photo_download', {
photo_id: photoId,
@@ -111,7 +175,6 @@ class AnalyticsService {
});
}
// Track expiration warning views
trackExpirationWarning(gallerySlug: string, daysRemaining: number) {
this.track('expiration_warning_viewed', {
gallery: gallerySlug,
@@ -120,7 +183,6 @@ class AnalyticsService {
});
}
// Track search usage
trackSearch(query: string, resultsCount: number, context: 'gallery' | 'admin') {
this.track('search_performed', {
query_length: query.length,
@@ -146,4 +208,4 @@ export const useAnalytics = () => {
}, [location]);
return analyticsService;
};
};
@@ -68,6 +68,14 @@ export interface PublicSettings {
umami_url: string | null;
umami_website_id: string | null;
umami_share_url: string | null;
// Pluggable trackers (#663 Phase 1). The backend always surfaces these;
// missing fields fall back via the existing umami_* shape so older
// builds keep working.
analytics_tracker_provider?: 'none' | 'umami' | 'rybbit' | 'custom';
rybbit_url?: string | null;
rybbit_website_id?: string | null;
// Custom-mode HTML snippet, already sanitised server-side.
analytics_custom_head_html?: string;
// Upload settings
allowed_file_types?: string;
// #613 — per-batch file count limit, surfaced so the guest UserPhotoUpload