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