Merge pull request #665 from the-luap/feat/pluggable-trackers-663
feat(analytics): pluggable trackers — Umami + Rybbit + Custom (#663 Phase 1)
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Tests for the custom-tracker HTML sanitiser (#663 Phase 1).
|
||||
*
|
||||
* The field accepts admin-pasted `<head>`-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 = '<script defer data-domain="example.com" src="https://plausible.io/js/script.js"></script>';
|
||||
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 = '<script async defer src="https://analytics.example.com/script.js" data-website-id="aaa-bbb-ccc"></script>';
|
||||
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 = '<script>window.GA = "x"; window.tracker = function() { console.log("init"); };</script>';
|
||||
const out = sanitizeTrackerSnippet(input);
|
||||
expect(out).toContain('window.GA = "x"');
|
||||
expect(out).toContain('console.log("init")');
|
||||
});
|
||||
|
||||
test('allows <noscript> fallback', () => {
|
||||
const input = '<noscript><img src="https://t.example/?nojs=1" /></noscript>';
|
||||
const out = sanitizeTrackerSnippet(input);
|
||||
expect(out).toContain('<noscript>');
|
||||
});
|
||||
|
||||
test('allows <link rel="preconnect"> and <link rel="dns-prefetch">', () => {
|
||||
const out = sanitizeTrackerSnippet(
|
||||
'<link rel="preconnect" href="https://t.example.com">'
|
||||
+ '<link rel="dns-prefetch" href="https://t.example.com">',
|
||||
);
|
||||
expect(out).toContain('rel="preconnect"');
|
||||
expect(out).toContain('rel="dns-prefetch"');
|
||||
expect(out).toContain('href="https://t.example.com"');
|
||||
});
|
||||
|
||||
test('strips <link rel="stylesheet"> (not tracker-related)', () => {
|
||||
const out = sanitizeTrackerSnippet('<link rel="stylesheet" href="https://evil.example/x.css">');
|
||||
expect(out).not.toContain('stylesheet');
|
||||
expect(out).not.toContain('href');
|
||||
});
|
||||
|
||||
test('strips disallowed tags entirely', () => {
|
||||
const input = '<div><iframe src="https://evil.example/x.html"></iframe><h1>hi</h1></div>';
|
||||
const out = sanitizeTrackerSnippet(input);
|
||||
expect(out).not.toContain('iframe');
|
||||
expect(out).not.toContain('<div');
|
||||
expect(out).not.toContain('<h1');
|
||||
});
|
||||
|
||||
test('strips javascript: URLs from script src', () => {
|
||||
const input = '<script src="javascript:alert(1)"></script>';
|
||||
const out = sanitizeTrackerSnippet(input);
|
||||
expect(out).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
test('strips data: URLs from script src', () => {
|
||||
const input = '<script src="data:text/javascript,alert(1)"></script>';
|
||||
const out = sanitizeTrackerSnippet(input);
|
||||
expect(out).not.toContain('data:text/javascript');
|
||||
});
|
||||
|
||||
test('strips on* event-handler attributes (defence-in-depth)', () => {
|
||||
// event-handler attrs are not in our allowlist; sanitize-html strips them.
|
||||
const input = '<script src="https://t.example/x.js" onload="evil()"></script>';
|
||||
const out = sanitizeTrackerSnippet(input);
|
||||
expect(out).not.toContain('onload');
|
||||
expect(out).toContain('src="https://t.example/x.js"');
|
||||
});
|
||||
|
||||
test('returns empty string on unparseable input rather than throwing', () => {
|
||||
// sanitize-html is fault-tolerant — pass deliberately malformed and
|
||||
// confirm we don't blow up.
|
||||
expect(typeof sanitizeTrackerSnippet('<<<>>>')).toBe('string');
|
||||
expect(typeof sanitizeTrackerSnippet('<script')).toBe('string');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Tests for the Rybbit metrics-API adapter (#663 Phase 1). Mirrors the
|
||||
* `umamiAdapter` test contract: missing config / URL shape / encoding /
|
||||
* normalisation / unknown-bucket drop / failure modes.
|
||||
*
|
||||
* Rybbit's documented endpoint is `/api/site/{websiteId}/breakdown` with
|
||||
* `dimension=device`; we accept both bare-array and `{ data: [...] }`
|
||||
* envelopes since their docs hint at minor v0 → v1 shape variation.
|
||||
*/
|
||||
|
||||
const { buildAdapter } = require('../../src/services/trackers/rybbitAdapter');
|
||||
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
||||
const valid = { baseUrl: 'https://r.example.com', websiteId: 'rsite-789', apiKey: 'rkey' };
|
||||
|
||||
describe('rybbitAdapter.fetchDeviceBreakdown (#663)', () => {
|
||||
test('returns null when config is incomplete', async () => {
|
||||
expect(await buildAdapter({}).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
|
||||
expect(global.fetch).toBe(ORIGINAL_FETCH);
|
||||
});
|
||||
|
||||
test('builds the expected URL + sends Bearer auth', async () => {
|
||||
mockJson([{ device: 'desktop', sessions: 10 }]);
|
||||
await buildAdapter({ ...valid, baseUrl: 'https://r.example.com/' })
|
||||
.fetchDeviceBreakdown({ startMs: 1700000000000, endMs: 1700003600000 });
|
||||
const [calledUrl, init] = global.fetch.mock.calls[0];
|
||||
expect(calledUrl).toMatch(/^https:\/\/r\.example\.com\/api\/site\/rsite-789\/breakdown\?dimension=device&start=.*&end=.*$/);
|
||||
expect(init.headers.Authorization).toBe('Bearer rkey');
|
||||
expect(init.method).toBe('GET');
|
||||
});
|
||||
|
||||
test('URL-encodes the websiteId for reserved chars', async () => {
|
||||
mockJson([{ device: 'desktop', sessions: 1 }]);
|
||||
await buildAdapter({ ...valid, websiteId: 'a/b?c' }).fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
|
||||
const [calledUrl] = global.fetch.mock.calls[0];
|
||||
expect(calledUrl).toContain('/api/site/a%2Fb%3Fc/breakdown');
|
||||
});
|
||||
|
||||
test('normalises a typical {device, sessions} payload into percentages', async () => {
|
||||
mockJson([
|
||||
{ device: 'desktop', sessions: 60 },
|
||||
{ device: 'mobile', sessions: 30 },
|
||||
{ device: 'tablet', sessions: 10 },
|
||||
]);
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
|
||||
.toEqual({ desktop: 60, mobile: 30, tablet: 10 });
|
||||
});
|
||||
|
||||
test('accepts the {data: [...]} envelope variant', async () => {
|
||||
mockJson({ data: [
|
||||
{ device: 'desktop', sessions: 1 },
|
||||
{ device: 'mobile', sessions: 3 },
|
||||
] });
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
|
||||
.toEqual({ desktop: 25, mobile: 75, tablet: 0 });
|
||||
});
|
||||
|
||||
test('falls back to `visitors` when `sessions` is absent', async () => {
|
||||
mockJson([
|
||||
{ device: 'desktop', visitors: 80 },
|
||||
{ device: 'mobile', visitors: 20 },
|
||||
]);
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
|
||||
.toEqual({ desktop: 80, mobile: 20, tablet: 0 });
|
||||
});
|
||||
|
||||
test('tolerates a `dimension` key as the bucket label', async () => {
|
||||
mockJson([
|
||||
{ dimension: 'desktop', sessions: 50 },
|
||||
{ dimension: 'mobile', sessions: 50 },
|
||||
]);
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
|
||||
.toEqual({ desktop: 50, mobile: 50, tablet: 0 });
|
||||
});
|
||||
|
||||
test('drops unknown buckets', async () => {
|
||||
mockJson([
|
||||
{ device: 'desktop', sessions: 80 },
|
||||
{ device: 'mobile', sessions: 20 },
|
||||
{ device: 'fridge', sessions: 100 },
|
||||
]);
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
|
||||
.toEqual({ desktop: 80, mobile: 20, tablet: 0 });
|
||||
});
|
||||
|
||||
test('returns null on empty payload, non-2xx, invalid JSON, and network error', async () => {
|
||||
mockJson([]);
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
|
||||
|
||||
mockJson({ error: 'unauthorized' }, { status: 401 });
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
|
||||
|
||||
global.fetch = jest.fn(async () => ({
|
||||
ok: true, status: 200,
|
||||
json: async () => { throw new SyntaxError('not json'); },
|
||||
}));
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
|
||||
|
||||
global.fetch = jest.fn(async () => { throw new Error('ECONNREFUSED'); });
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Factory tests for the pluggable-tracker registry (#663 Phase 1).
|
||||
*
|
||||
* Pins the contract that drives `adminDashboard.js` analytics route:
|
||||
* - Returns null for 'none' / 'custom' / unset → route falls back to access_logs.
|
||||
* - Returns an Umami adapter shape for provider='umami'.
|
||||
* - Returns a Rybbit adapter shape for provider='rybbit'.
|
||||
* - Back-compat: when `analytics_tracker_provider` is unset, infers
|
||||
* 'umami' from the legacy `analytics_umami_enabled` flag.
|
||||
* - Invalid provider strings fall through to the legacy back-compat path
|
||||
* rather than crashing (defensive).
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-tracker-fact-')), 'db.sqlite',
|
||||
);
|
||||
|
||||
const { bootCrmDb } = require('../integration/helpers/crmDb');
|
||||
const trackers = require('../../src/services/trackers');
|
||||
|
||||
let db; let cleanup;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('app_settings').del();
|
||||
});
|
||||
|
||||
async function setSetting(key, value) {
|
||||
await db('app_settings').insert({
|
||||
setting_key: key,
|
||||
setting_value: JSON.stringify(value),
|
||||
setting_type: 'analytics',
|
||||
updated_at: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
describe('resolveAdapter (#663)', () => {
|
||||
test('returns null when provider=\'none\'', async () => {
|
||||
await setSetting('analytics_tracker_provider', 'none');
|
||||
expect(await trackers.resolveAdapter()).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when provider=\'custom\' (no metrics adapter, just a script slot)', async () => {
|
||||
await setSetting('analytics_tracker_provider', 'custom');
|
||||
expect(await trackers.resolveAdapter()).toBeNull();
|
||||
});
|
||||
|
||||
test('back-compat: provider unset + legacy umami_enabled=true → umami adapter', async () => {
|
||||
await setSetting('analytics_umami_enabled', true);
|
||||
await setSetting('analytics_umami_url', 'https://u.example');
|
||||
await setSetting('analytics_umami_website_id', 'w-1');
|
||||
await setSetting('analytics_umami_api_key', 'k-1');
|
||||
const adapter = await trackers.resolveAdapter();
|
||||
expect(adapter).not.toBeNull();
|
||||
expect(adapter.provider).toBe('umami');
|
||||
});
|
||||
|
||||
test('provider=\'umami\' explicit → umami adapter with stored secrets', async () => {
|
||||
await setSetting('analytics_tracker_provider', 'umami');
|
||||
await setSetting('analytics_umami_url', 'https://u.example');
|
||||
await setSetting('analytics_umami_website_id', 'w-1');
|
||||
await setSetting('analytics_umami_api_key', 'k-1');
|
||||
const adapter = await trackers.resolveAdapter();
|
||||
expect(adapter.provider).toBe('umami');
|
||||
});
|
||||
|
||||
test('provider=\'rybbit\' → rybbit adapter with stored secrets', async () => {
|
||||
await setSetting('analytics_tracker_provider', 'rybbit');
|
||||
await setSetting('analytics_rybbit_url', 'https://r.example');
|
||||
await setSetting('analytics_rybbit_website_id', 'r-1');
|
||||
await setSetting('analytics_rybbit_api_key', 'rk-1');
|
||||
const adapter = await trackers.resolveAdapter();
|
||||
expect(adapter.provider).toBe('rybbit');
|
||||
});
|
||||
|
||||
test('garbage provider value falls through to legacy back-compat (defensive)', async () => {
|
||||
await setSetting('analytics_tracker_provider', 'plausible-not-yet-supported');
|
||||
// No legacy umami_enabled → resolves to null (= 'none')
|
||||
expect(await trackers.resolveAdapter()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Adapter-style tests for the Umami metrics client (#663 Phase 1, replaces
|
||||
* the old `umamiClient.test.js` from #662 — same contract, new shape).
|
||||
*
|
||||
* Pins the same 10 cases that protected the original implementation: missing
|
||||
* config / URL shape / encoding / payload normalisation / `laptop` mapping /
|
||||
* unknown-bucket drop / empty / non-2xx / invalid JSON / network error.
|
||||
*/
|
||||
|
||||
const { buildAdapter } = require('../../src/services/trackers/umamiAdapter');
|
||||
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
||||
const valid = { baseUrl: 'https://u.example.com', websiteId: 'site-123', apiKey: 'secret' };
|
||||
|
||||
describe('umamiAdapter.fetchDeviceBreakdown (#663)', () => {
|
||||
test('returns null when config is incomplete (back-compat path)', async () => {
|
||||
const a = buildAdapter({});
|
||||
expect(await a.fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
|
||||
const b = buildAdapter({ baseUrl: 'https://u' });
|
||||
expect(await b.fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
|
||||
expect(global.fetch).toBe(ORIGINAL_FETCH);
|
||||
});
|
||||
|
||||
test('builds the expected URL + sends `x-umami-api-key` header', async () => {
|
||||
mockJson([{ x: 'desktop', y: 10 }]);
|
||||
const a = buildAdapter({ ...valid, baseUrl: 'https://u.example.com/' });
|
||||
await a.fetchDeviceBreakdown({ 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('URL-encodes the websiteId for reserved chars', async () => {
|
||||
mockJson([{ x: 'desktop', y: 1 }]);
|
||||
const a = buildAdapter({ baseUrl: 'https://u', websiteId: 'a/b?c', apiKey: 'k' });
|
||||
await a.fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
|
||||
const [calledUrl] = global.fetch.mock.calls[0];
|
||||
expect(calledUrl).toContain('/api/websites/a%2Fb%3Fc/metrics');
|
||||
});
|
||||
|
||||
test('normalises { x, y } payload into integer percentages', async () => {
|
||||
mockJson([
|
||||
{ x: 'desktop', y: 60 },
|
||||
{ x: 'mobile', y: 30 },
|
||||
{ x: 'tablet', y: 10 },
|
||||
]);
|
||||
const a = buildAdapter(valid);
|
||||
const out = await a.fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
|
||||
expect(out).toEqual({ desktop: 60, mobile: 30, tablet: 10 });
|
||||
});
|
||||
|
||||
test('maps `laptop` into `desktop` (matches our 3-bucket UI)', async () => {
|
||||
mockJson([
|
||||
{ x: 'desktop', y: 50 },
|
||||
{ x: 'laptop', y: 20 },
|
||||
{ x: 'mobile', y: 30 },
|
||||
]);
|
||||
const out = await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
|
||||
expect(out).toEqual({ desktop: 70, mobile: 30, tablet: 0 });
|
||||
});
|
||||
|
||||
test('drops unknown buckets (no silent miscategorisation)', async () => {
|
||||
mockJson([
|
||||
{ x: 'desktop', y: 80 },
|
||||
{ x: 'mobile', y: 20 },
|
||||
{ x: 'unknown-future-bucket', y: 100 },
|
||||
]);
|
||||
const out = await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
|
||||
expect(out).toEqual({ desktop: 80, mobile: 20, tablet: 0 });
|
||||
});
|
||||
|
||||
test('returns null on empty payload', async () => {
|
||||
mockJson([]);
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null on non-2xx', async () => {
|
||||
mockJson({ error: 'unauthorized' }, { status: 401 });
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null on invalid JSON', async () => {
|
||||
global.fetch = jest.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => { throw new SyntaxError('not json'); },
|
||||
}));
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null on network error', async () => {
|
||||
global.fetch = jest.fn(async () => { throw new Error('ECONNREFUSED'); });
|
||||
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 };
|
||||
@@ -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