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:
@@ -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 };
|
||||
@@ -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,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 };
|
||||
Reference in New Issue
Block a user