feat(analytics): pluggable trackers — Umami + Rybbit + Custom (#663 Phase 1)

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 (`<script>` / `<noscript>` / `<link rel=preconnect|
    dns-prefetch>` / `<meta>`). Strips event-handler attributes,
    `javascript:` and `data:` URLs.
  - `index.js` factory: `resolveAdapter()` reads
    `analytics_tracker_provider` setting → dispatches. Back-compat:
    when provider is unset, infers `umami` from the legacy
    `analytics_umami_enabled` flag so #662 installs keep working
    without an admin touching settings.

**Backend routes**:
  - `adminDashboard.js /analytics`: now goes through `resolveAdapter()`.
    Old `fetchUmamiDeviceBreakdown` direct import removed; both `umamiClient.js`
    and its test file deleted (replaced by the adapter shape).
  - `adminSettings.js PUT /analytics`: validates the new
    `analytics_tracker_provider` enum, sanitises any incoming
    `analytics_custom_head_html` on save via the sanitiser. Masks
    the new `analytics_rybbit_api_key` on every GET — same pattern as
    Umami's API key and recaptcha secret.
  - `publicSettings.js`: emits `analytics_tracker_provider`,
    `rybbit_url`/`rybbit_website_id` (only when provider=rybbit), and
    the pre-sanitised `analytics_custom_head_html` (only when
    provider=custom). Legacy `umami_*` fields stay for back-compat.

**Frontend**:
  - `analytics.service.ts` reworked into a provider-aware shape.
    `initialize({provider, ...config})` dispatches to Umami /
    Rybbit / Custom / None. `track()` calls dispatch to
    `window.umami.track` / `window.rybbit.event` / no-op based on
    the loaded provider.
  - `App.tsx` `AnalyticsBootstrap` reads `analytics_tracker_provider`
    from public-settings and routes to the right `initialize` call.
    Legacy `umami_enabled`-based path preserved as fallback when the
    new field is missing.
  - `AnalyticsTab.tsx` (Settings → Analytics) reworked with a
    "Provider" dropdown switching between None / Umami / Rybbit /
    Custom panels. Each panel renders its own config fields; Custom
    panel surfaces an explicit CSP-reminder banner.
  - `useSettingsState.ts` shape extended with `tracker_provider`,
    `rybbit_url`/`rybbit_website_id`/`rybbit_api_key`,
    `custom_head_html`. Save mutation keeps `umami_enabled` in sync
    with `tracker_provider==='umami'` for back-compat with downstream
    consumers (publicSettings shape, embedded iframe).
  - `publicSettings.service.ts` type extended.

**i18n**: EN + DE for the provider heading + description + dropdown
options + Rybbit fields + Custom HTML field + CSP warning.

## Custom mode — script execution caveat

When the gallery `<head>` receives the custom HTML, simply assigning
innerHTML to a container element wouldn't execute the embedded
`<script>` tags (per the HTML spec, dynamically-inserted scripts via
innerHTML are non-running). `analytics.service.ts:120-130` re-creates
each `<script>` element manually so the browser actually evaluates
it. Non-script nodes (link, meta, noscript) move in directly.

## Tests

**Backend** (42 cases, all pass locally):
  - `umamiAdapter.test.js` (10) — pinned from the original
    `umamiClient.test.js`: missing-config / URL shape / encoding /
    payload normalisation / `laptop`→`desktop` / unknown buckets /
    empty / non-2xx / invalid JSON / network error.
  - `rybbitAdapter.test.js` (9) — same shape adapted for Rybbit:
    bare-array + envelope payload, `sessions`/`visitors`/`dimension`
    key tolerance, encoding, failure modes.
  - `trackerFactory.test.js` (6) — resolves null for `none`/`custom`,
    correct adapter for `umami`/`rybbit`, back-compat path via
    legacy `analytics_umami_enabled`, garbage-provider defensive null.
  - `customScriptSanitiser.test.js` (12) — Plausible-style passthrough,
    Umami-style passthrough, inline body passthrough, `<noscript>`
    allowed, `<link rel="preconnect|dns-prefetch">` allowed,
    `<link rel="stylesheet">` stripped, disallowed tags stripped,
    `javascript:`/`data:` URLs stripped, `on*` event handlers
    stripped, defensive on malformed input.
  - `analyticsDateMerge.test.js` (5) — preserved from #662.

**Frontend**: full 84-case vitest suite green; tsc + eslint clean
on changed files. Adapter changes are narrow refactors of code
covered by backend tests; no new analytics-page unit test added.

## End-to-end smoke (dockerised backend + my changes mounted)

```
test 1 (back-compat: no provider, umami_enabled=true)
  → factory returns umami adapter, /analytics returns
    devicesSource:access_logs (umami fetch to fake host fails
    gracefully). ✓

test 2 (invalid provider value)
  → 400 "analytics_tracker_provider must be one of: none, umami,
    rybbit, custom" ✓

test 3 (save custom HTML with XSS payload)
  → stored sanitised:
    `<script>alert(1)</script>evil<script async defer
     data-domain="x.com" src="https://plausible.io/js/script.js"></script>`
    (<div> 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: '<sanitised>'` ✓
```

## 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.
This commit is contained in:
Paul Nothaft
2026-06-23 18:11:58 +02:00
parent 349f566e87
commit ab501459a4
20 changed files with 1344 additions and 427 deletions
-142
View File
@@ -1,142 +0,0 @@
/**
* Unit tests for the Umami v2 metrics-API client (#661 Bug C).
*
* The client is only consumed by `/admin/dashboard/analytics` today to fetch
* the device-breakdown chart, so these tests pin:
* - The exact URL shape sent to Umami (`/api/websites/<id>/metrics?type=device&startAt=…&endAt=…`)
* - The `x-umami-api-key` auth header
* - The `{ x, y }` → `{ desktop, mobile, tablet }` percentage normalisation
* - `laptop` mapping into `desktop` for our 3-bucket UI
* - Defensive returns: missing config / non-2xx / non-JSON / empty array
* all return `null` so the route layer can fall back to access_logs.
*/
const { fetchUmamiDeviceBreakdown } = require('../../src/services/umamiClient');
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,
}));
}
describe('fetchUmamiDeviceBreakdown', () => {
test('returns null when config is incomplete (back-compat for installs without API key)', async () => {
expect(await fetchUmamiDeviceBreakdown({})).toBeNull();
expect(await fetchUmamiDeviceBreakdown({ baseUrl: 'https://u.example' })).toBeNull();
expect(await fetchUmamiDeviceBreakdown({ baseUrl: 'https://u.example', websiteId: 'w' })).toBeNull();
// No fetch should be issued in any of those cases.
expect(global.fetch).toBe(ORIGINAL_FETCH);
});
test('builds the expected URL + sends the x-umami-api-key header', async () => {
mockJson([{ x: 'desktop', y: 10 }]);
await fetchUmamiDeviceBreakdown({
baseUrl: 'https://u.example.com/',
websiteId: 'site-123',
apiKey: 'secret',
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('encodes the websiteId so a path segment with reserved chars is safe', async () => {
mockJson([{ x: 'desktop', y: 1 }]);
await fetchUmamiDeviceBreakdown({
baseUrl: 'https://u.example.com',
websiteId: 'a/b?c',
apiKey: 'k',
startMs: 0, endMs: 0,
});
const [calledUrl] = global.fetch.mock.calls[0];
expect(calledUrl).toContain('/api/websites/a%2Fb%3Fc/metrics');
});
test('normalises a typical { x, y } payload into integer percentages', async () => {
mockJson([
{ x: 'desktop', y: 60 },
{ x: 'mobile', y: 30 },
{ x: 'tablet', y: 10 },
]);
const out = await fetchUmamiDeviceBreakdown({
baseUrl: 'https://u', websiteId: 'w', apiKey: 'k', startMs: 0, endMs: 0,
});
expect(out).toEqual({ desktop: 60, mobile: 30, tablet: 10 });
});
test('maps `laptop` into `desktop` for the 3-bucket UI', async () => {
mockJson([
{ x: 'desktop', y: 50 },
{ x: 'laptop', y: 20 },
{ x: 'mobile', y: 30 },
]);
const out = await fetchUmamiDeviceBreakdown({
baseUrl: 'https://u', websiteId: 'w', apiKey: 'k', startMs: 0, endMs: 0,
});
// desktop = (50 + 20) / 100 = 70%
expect(out).toEqual({ desktop: 70, mobile: 30, tablet: 0 });
});
test('drops unknown buckets entirely (avoids silent miscategorisation)', async () => {
mockJson([
{ x: 'desktop', y: 80 },
{ x: 'mobile', y: 20 },
{ x: 'unknown-future-bucket', y: 100 },
]);
const out = await fetchUmamiDeviceBreakdown({
baseUrl: 'https://u', websiteId: 'w', apiKey: 'k', startMs: 0, endMs: 0,
});
// 100 isn't counted into total, so 80/(80+20) = 80%, 20/(80+20) = 20%.
expect(out).toEqual({ desktop: 80, mobile: 20, tablet: 0 });
});
test('returns null on empty payload (caller falls back to access_logs)', async () => {
mockJson([]);
const out = await fetchUmamiDeviceBreakdown({
baseUrl: 'https://u', websiteId: 'w', apiKey: 'k', startMs: 0, endMs: 0,
});
expect(out).toBeNull();
});
test('returns null on non-2xx upstream response', async () => {
mockJson({ error: 'unauthorized' }, { status: 401 });
const out = await fetchUmamiDeviceBreakdown({
baseUrl: 'https://u', websiteId: 'w', apiKey: 'k', startMs: 0, endMs: 0,
});
expect(out).toBeNull();
});
test('returns null on invalid JSON body', async () => {
global.fetch = jest.fn(async () => ({
ok: true,
status: 200,
json: async () => { throw new SyntaxError('not json'); },
}));
const out = await fetchUmamiDeviceBreakdown({
baseUrl: 'https://u', websiteId: 'w', apiKey: 'k', startMs: 0, endMs: 0,
});
expect(out).toBeNull();
});
test('returns null on network error (fetch throws)', async () => {
global.fetch = jest.fn(async () => { throw new Error('ECONNREFUSED'); });
const out = await fetchUmamiDeviceBreakdown({
baseUrl: 'https://u', websiteId: 'w', apiKey: 'k', startMs: 0, endMs: 0,
});
expect(out).toBeNull();
});
});