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:
@@ -45,7 +45,13 @@ 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;
|
||||
@@ -56,6 +62,14 @@ export interface AnalyticsSettings {
|
||||
// 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 {
|
||||
@@ -132,11 +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_api_key: ''
|
||||
umami_api_key: '',
|
||||
rybbit_url: '',
|
||||
rybbit_website_id: '',
|
||||
rybbit_api_key: '',
|
||||
custom_head_html: ''
|
||||
});
|
||||
|
||||
// Event creation settings state
|
||||
@@ -222,12 +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_api_key: settings.analytics_umami_api_key || ''
|
||||
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({
|
||||
@@ -323,12 +357,16 @@ export function useSettingsState() {
|
||||
mutationFn: async () => {
|
||||
const settingsData: Record<string, unknown> = {};
|
||||
Object.entries(analyticsSettings).forEach(([key, value]) => {
|
||||
// The Umami API key (#661 Bug C) is returned masked as `••••••••`
|
||||
// on GET so it doesn't leak in the response body. Don't re-save
|
||||
// that sentinel — silently preserve whatever's already stored.
|
||||
if (key === 'umami_api_key' && value === '••••••••') return;
|
||||
// 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: () => {
|
||||
|
||||
Reference in New Issue
Block a user