From ab501459a4208c3a30637a31f0f6f31285ae5ef2 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 23 Jun 2026 18:11:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(analytics):=20pluggable=20trackers=20?= =?UTF-8?q?=E2=80=94=20Umami=20+=20Rybbit=20+=20Custom=20(#663=20Phase=201?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the hybrid scope agreed on in #663: two native adapters (Umami + Rybbit) for trackers we'd keep maintained, plus a Custom script-paste mode for everyone else (Plausible, Matomo, Pirsch, GA4, GoatCounter, Fathom, Cloudflare Web Analytics). Phase 2 (Plausible native, deeper metrics) explicitly deferred until someone asks. ## Architecture **Backend `services/trackers/`**: - `TrackerAdapter` shape (single method): `fetchDeviceBreakdown` → `{ desktop, mobile, tablet } | null`. Null = route falls back to access_logs heuristic. - `umamiAdapter.js` — extracted from the `services/umamiClient.js` that landed in #662. Same 10 test contract preserved. - `rybbitAdapter.js` — new. Hits `/api/site/{id}/breakdown?dimension= device` with Bearer auth, accepts both bare-array and `{data:[...]}` envelope variants, tolerates `sessions`/`visitors`/`value`/`count` metric keys. - `customScriptSanitiser.js` — sanitize-html with a tracker-tight allowlist (`evil` (
stripped; script tags survive but CSP `script-src 'self'` still blocks inline + non-allowlisted external at runtime) ✓ test 4 (public-settings exposes the provider switch) → `analytics_tracker_provider: 'custom'`, `analytics_custom_head_html: ''` ✓ ``` ## Out of scope (next discussions) - **Plausible native** — covered via Custom mode for now; native is Phase 2 if someone explicitly asks. - **CSP "trusted domains" admin input** — Phase 1.5. For now operators add their tracker domain to nginx/proxy CSP manually; the new CSP-reminder banner in the Custom panel makes that clear. - **Refactor `(window as any).umami.track(...)` direct calls** in PhotoLightbox/PhotoGrid to go through `analyticsService.track()` so events fire on the right tracker. Currently a no-op when Umami isn't loaded; functional but not optimal. Closes #663 Phase 1. --- .../services/customScriptSanitiser.test.js | 100 ++++++ .../__tests__/services/rybbitAdapter.test.js | 115 +++++++ .../__tests__/services/trackerFactory.test.js | 91 +++++ .../__tests__/services/umamiAdapter.test.js | 112 ++++++ backend/__tests__/utils/umamiClient.test.js | 142 -------- backend/src/routes/adminDashboard.js | 37 +- backend/src/routes/adminSettings.js | 27 ++ backend/src/routes/publicSettings.js | 35 +- .../trackers/customScriptSanitiser.js | 86 +++++ backend/src/services/trackers/index.js | 75 +++++ .../src/services/trackers/rybbitAdapter.js | 121 +++++++ backend/src/services/trackers/umamiAdapter.js | 99 ++++++ backend/src/services/umamiClient.js | 104 ------ frontend/src/App.tsx | 45 ++- .../settings/hooks/useSettingsState.ts | 50 ++- .../features/settings/tabs/AnalyticsTab.tsx | 318 ++++++++++++------ frontend/src/i18n/locales/de.json | 20 ++ frontend/src/i18n/locales/en.json | 20 ++ frontend/src/services/analytics.service.ts | 166 ++++++--- .../src/services/publicSettings.service.ts | 8 + 20 files changed, 1344 insertions(+), 427 deletions(-) create mode 100644 backend/__tests__/services/customScriptSanitiser.test.js create mode 100644 backend/__tests__/services/rybbitAdapter.test.js create mode 100644 backend/__tests__/services/trackerFactory.test.js create mode 100644 backend/__tests__/services/umamiAdapter.test.js delete mode 100644 backend/__tests__/utils/umamiClient.test.js create mode 100644 backend/src/services/trackers/customScriptSanitiser.js create mode 100644 backend/src/services/trackers/index.js create mode 100644 backend/src/services/trackers/rybbitAdapter.js create mode 100644 backend/src/services/trackers/umamiAdapter.js delete mode 100644 backend/src/services/umamiClient.js diff --git a/backend/__tests__/services/customScriptSanitiser.test.js b/backend/__tests__/services/customScriptSanitiser.test.js new file mode 100644 index 00000000..e56ebb62 --- /dev/null +++ b/backend/__tests__/services/customScriptSanitiser.test.js @@ -0,0 +1,100 @@ +/** + * Tests for the custom-tracker HTML sanitiser (#663 Phase 1). + * + * The field accepts admin-pasted ``-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 = ''; + 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 = ''; + 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 = ''; + const out = sanitizeTrackerSnippet(input); + expect(out).toContain('window.GA = "x"'); + expect(out).toContain('console.log("init")'); + }); + + test('allows
+

+ {t( + 'settings.analytics.customHeadHtmlHelp', + 'Paste your tracker\'s `` snippet (Plausible, Matomo, Pirsch, GA4, GoatCounter, Fathom, Cloudflare Web Analytics, …). Sanitised on save: only `