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
+15 -22
View File
@@ -4,8 +4,7 @@ const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { sanitizeDays, addDateRangeCondition } = require('../utils/sqlSecurity');
const { formatBoolean } = require('../utils/dbCompat');
const { getAppSetting } = require('../utils/appSettings');
const { fetchUmamiDeviceBreakdown } = require('../services/umamiClient');
const { resolveAdapter } = require('../services/trackers');
const logger = require('../utils/logger');
const router = express.Router();
@@ -316,35 +315,29 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
.orderBy('views', 'desc')
.limit(5);
// Device breakdown — prefer Umami's metrics API when configured (#661
// Bug C). The local access_logs heuristic below produces 0% on installs
// where guest user agents don't reliably contain "Mobile" / "Tablet"
// tokens; Umami tracks devices natively. Falls back to access_logs when
// Umami is unconfigured, unreachable, or rate-limited.
// 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 umamiConfig = {
enabled: (await getAppSetting('analytics_umami_enabled', false)) === true,
baseUrl: await getAppSetting('analytics_umami_url', null),
websiteId: await getAppSetting('analytics_umami_website_id', null),
apiKey: await getAppSetting('analytics_umami_api_key', null),
};
if (umamiConfig.enabled && umamiConfig.baseUrl && umamiConfig.websiteId && umamiConfig.apiKey) {
const adapter = await resolveAdapter();
if (adapter) {
try {
const umamiDevices = await fetchUmamiDeviceBreakdown({
baseUrl: umamiConfig.baseUrl,
websiteId: umamiConfig.websiteId,
apiKey: umamiConfig.apiKey,
const trackerDevices = await adapter.fetchDeviceBreakdown({
startMs: startDate.getTime(),
endMs: Date.now(),
});
if (umamiDevices) {
devices = umamiDevices;
devicesSource = 'umami';
if (trackerDevices) {
devices = trackerDevices;
devicesSource = adapter.provider;
}
} catch (err) {
logger.warn('Analytics: Umami device-breakdown fetch failed; falling back to access_logs', {
logger.warn(`Analytics: ${adapter.provider} device-breakdown fetch failed; falling back to access_logs`, {
error: err.message,
});
}
+27
View File
@@ -151,6 +151,10 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
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) {
@@ -402,6 +406,10 @@ router.get('/:type', adminAuth, requirePermission('settings.view'), async (req,
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) {
@@ -1061,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 };
-104
View File
@@ -1,104 +0,0 @@
/**
* Thin Umami v2 API client (#661 Bug C).
*
* We use this in exactly one place today — `GET /admin/dashboard/analytics`
* pulls device-breakdown stats from Umami when an admin has provided an
* API key, because our own user-agent heuristic on `access_logs` returns
* 0/0/0 on installs where guest UAs don't reliably contain "Mobile" /
* "Tablet" tokens. Umami tracks devices natively.
*
* Auth: Umami v2 supports per-account API keys generated in the Umami UI
* (Settings → Profile → API Keys). We send them via the `x-umami-api-key`
* header. Older session-cookie-based auth is intentionally NOT supported
* here — operators should generate an API key rather than embedding their
* Umami account password in PicPeak.
*/
const logger = require('../utils/logger');
const REQUEST_TIMEOUT_MS = 5000;
/**
* Fetch the device breakdown for a Umami website in [startMs, endMs]. Returns
* `{ desktop, mobile, tablet }` as integer percentages summing to ~100, or
* `null` when the upstream call fails / returns no data — callers should
* treat null as "fall back to the local heuristic".
*
* @param {object} opts
* @param {string} opts.baseUrl — Umami instance root, e.g. https://analytics.example.com
* @param {string} opts.websiteId — UUID from the Umami website settings
* @param {string} opts.apiKey — Umami API key (x-umami-api-key)
* @param {number} opts.startMs — epoch ms, start of window
* @param {number} opts.endMs — epoch ms, end of window
*/
async function fetchUmamiDeviceBreakdown({ baseUrl, websiteId, apiKey, 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 = { fetchUmamiDeviceBreakdown };