Merge remote-tracking branch 'origin/beta' into feat/workflow-engine
# Conflicts: # frontend/src/components/admin/PublishGalleryDialog.tsx
This commit is contained in:
@@ -4,8 +4,25 @@ const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { sanitizeDays, addDateRangeCondition } = require('../utils/sqlSecurity');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { resolveAdapter } = require('../services/trackers');
|
||||
const logger = require('../utils/logger');
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* Normalise a value coming back from `DATE(timestamp)` into a YYYY-MM-DD
|
||||
* string. SQLite returns this column as a string already; Postgres' pg
|
||||
* driver auto-converts it to a JavaScript Date object, which broke the
|
||||
* old `dateObj.date === row.date` merge below — every Postgres install saw
|
||||
* an all-zero `chartData[]` even with real traffic (#661 Bug A). Always
|
||||
* normalise before comparing.
|
||||
*/
|
||||
function normaliseDateKey(value) {
|
||||
if (!value) return null;
|
||||
if (value instanceof Date) return value.toISOString().slice(0, 10);
|
||||
// Strings might arrive with time component, slice defensively.
|
||||
return String(value).slice(0, 10);
|
||||
}
|
||||
|
||||
// Get dashboard statistics
|
||||
router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req, res) => {
|
||||
try {
|
||||
@@ -265,20 +282,25 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
|
||||
.where('timestamp', '>=', startDateStr)
|
||||
.groupByRaw('DATE(timestamp)');
|
||||
|
||||
// Merge data into dates array
|
||||
// Merge data into dates array. row.date is normalised because Postgres
|
||||
// returns DATE() as a JS Date while SQLite returns a string (#661 Bug A).
|
||||
// Counts come back as strings on Postgres too, so coerce via Number.
|
||||
viewsData.forEach(row => {
|
||||
const dateObj = dates.find(d => d.date === row.date);
|
||||
if (dateObj) dateObj.views = row.count;
|
||||
const key = normaliseDateKey(row.date);
|
||||
const dateObj = dates.find(d => d.date === key);
|
||||
if (dateObj) dateObj.views = Number(row.count) || 0;
|
||||
});
|
||||
|
||||
downloadsData.forEach(row => {
|
||||
const dateObj = dates.find(d => d.date === row.date);
|
||||
if (dateObj) dateObj.downloads = row.count;
|
||||
const key = normaliseDateKey(row.date);
|
||||
const dateObj = dates.find(d => d.date === key);
|
||||
if (dateObj) dateObj.downloads = Number(row.count) || 0;
|
||||
});
|
||||
|
||||
visitorsData.forEach(row => {
|
||||
const dateObj = dates.find(d => d.date === row.date);
|
||||
if (dateObj) dateObj.uniqueVisitors = row.count;
|
||||
const key = normaliseDateKey(row.date);
|
||||
const dateObj = dates.find(d => d.date === key);
|
||||
if (dateObj) dateObj.uniqueVisitors = Number(row.count) || 0;
|
||||
});
|
||||
|
||||
// Get top galleries by views with additional metrics
|
||||
@@ -293,31 +315,60 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
|
||||
.orderBy('views', 'desc')
|
||||
.limit(5);
|
||||
|
||||
// Get device breakdown (simplified - based on user agent)
|
||||
const deviceData = await db('access_logs')
|
||||
.select(
|
||||
db.raw(`
|
||||
CASE
|
||||
WHEN user_agent LIKE '%Mobile%' THEN 'mobile'
|
||||
WHEN user_agent LIKE '%Tablet%' OR user_agent LIKE '%iPad%' THEN 'tablet'
|
||||
ELSE 'desktop'
|
||||
END as device_type
|
||||
`),
|
||||
db.raw('COUNT(*) as count')
|
||||
)
|
||||
.where('timestamp', '>=', startDateStr)
|
||||
.groupBy('device_type');
|
||||
// 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 totalDevices = deviceData.reduce((sum, d) => sum + d.count, 0);
|
||||
const devices = {
|
||||
desktop: 0,
|
||||
mobile: 0,
|
||||
tablet: 0
|
||||
};
|
||||
const adapter = await resolveAdapter();
|
||||
if (adapter) {
|
||||
try {
|
||||
const trackerDevices = await adapter.fetchDeviceBreakdown({
|
||||
startMs: startDate.getTime(),
|
||||
endMs: Date.now(),
|
||||
});
|
||||
if (trackerDevices) {
|
||||
devices = trackerDevices;
|
||||
devicesSource = adapter.provider;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Analytics: ${adapter.provider} device-breakdown fetch failed; falling back to access_logs`, {
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
deviceData.forEach(d => {
|
||||
devices[d.device_type] = Math.round((d.count / totalDevices) * 100);
|
||||
});
|
||||
if (devicesSource === 'access_logs') {
|
||||
// Local heuristic on access_logs user_agent. Coarse — `LIKE` doesn't
|
||||
// cover every UA shape (some Android browsers, embedded webviews, etc.)
|
||||
// — and counts come back as strings on Postgres, hence Number() below.
|
||||
const deviceData = await db('access_logs')
|
||||
.select(
|
||||
db.raw(`
|
||||
CASE
|
||||
WHEN user_agent LIKE '%Mobile%' THEN 'mobile'
|
||||
WHEN user_agent LIKE '%Tablet%' OR user_agent LIKE '%iPad%' THEN 'tablet'
|
||||
ELSE 'desktop'
|
||||
END as device_type
|
||||
`),
|
||||
db.raw('COUNT(*) as count')
|
||||
)
|
||||
.where('timestamp', '>=', startDateStr)
|
||||
.whereNotNull('user_agent')
|
||||
.groupBy('device_type');
|
||||
|
||||
const totalDevices = deviceData.reduce((sum, d) => sum + (Number(d.count) || 0), 0);
|
||||
if (totalDevices > 0) {
|
||||
deviceData.forEach(d => {
|
||||
devices[d.device_type] = Math.round(((Number(d.count) || 0) / totalDevices) * 100);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate totals for the period (matching /stats logic)
|
||||
const totalViews = await db('access_logs')
|
||||
@@ -341,6 +392,7 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
|
||||
chartData: dates,
|
||||
topGalleries,
|
||||
devices,
|
||||
devicesSource,
|
||||
totals: {
|
||||
views: totalViews?.count || 0,
|
||||
downloads: totalDownloadsCount?.count || 0,
|
||||
|
||||
@@ -145,6 +145,16 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
|
||||
if (settingsObject.security_recaptcha_secret_key) {
|
||||
settingsObject.security_recaptcha_secret_key = '••••••••';
|
||||
}
|
||||
// Umami v2 API key (#661 Bug C) — read-write secret that authenticates
|
||||
// outbound calls to the operator's Umami instance for the device
|
||||
// breakdown. Masked on GET, same pattern as the recaptcha secret.
|
||||
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) {
|
||||
@@ -390,6 +400,16 @@ router.get('/:type', adminAuth, requirePermission('settings.view'), async (req,
|
||||
if (settingsObject.security_recaptcha_secret_key) {
|
||||
settingsObject.security_recaptcha_secret_key = '••••••••';
|
||||
}
|
||||
// Umami v2 API key (#661 Bug C) — read-write secret that authenticates
|
||||
// outbound calls to the operator's Umami instance for the device
|
||||
// breakdown. Masked on GET, same pattern as the recaptcha secret.
|
||||
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) {
|
||||
@@ -1049,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 };
|
||||
Reference in New Issue
Block a user