b118695474
* feat(permissions): granular permission gating + role editor & presets Make every admin feature permission-gateable so multi-user studios can split capability across roles (#747, and phase 1 of #743). - Split the catch-all settings.edit into dedicated dangerous-config perms (banking / domains / security / integrations / features): a team member can no longer change IBAN, domains, SSO, webhooks, API tokens or feature flags. Reads keep an OR with settings.view so existing roles keep visibility. The site-URL write inside /general is change-gated on settings.domains. - Add dedicated perms for admin surfaces miscategorised under settings.* (whatsapp, event_types, image_security, notifications, system) plus roles.manage and vat_codes.view; gate the previously-ungated VAT read. - Boot self-heal (_permissionsBoot.js): super_admin always holds every permission (tracks-all) so new perms never need a compensation migration; all other roles stay frozen (no silent escalation on upgrade). - Seed two presets: Solo Photographer (full operator) and Team Photographer (contributor — view events + manage photos + read-only CRM; no settings/users/billing edits, no events.edit). - Role editor: adminRoles CRUD (create/edit/clone/delete + permission matrix; system roles protected, super_admin immutable) and a Roles tab with a category-grouped matrix and preset cloning. - Settings page tabs are permission-gated with snap-back; i18n en/de. Migration 174. Backward-compatible: admin/editor/viewer unchanged. * feat(permissions): hide in-page action buttons a role can't use Wrap mutating controls on the surfaces restricted roles actually reach (Events list, Archives, gallery photo grid, event detail) in PermissionGate so they are HIDDEN when the user lacks the permission, rather than shown-then-403: - Events list: create / bulk archive / bulk delete / row archive / row delete / download-archive. - Archives: restore / download / delete. - Photo grid: single + bulk delete (photos.delete), per-photo download (photos.download), bulk move/hide/show (photos.edit). - Event detail: edit / rename / publish (events.edit), duplicate (events.create), archive (events.archive), create-invoice (bills.manage); the Actions card is hidden entirely for view-only roles. - Photos tab: upload / external import (photos.upload), export menu (photos.download). Backend already enforces these with 403; this is the matching UX so a Team Photographer never sees delete/settings controls. * fix(permissions): close settings-split bypass via generic settings writers Security review found the settings.edit split was bypassable: the generic settings writers (/general, /analytics, /seo, /security) upsert arbitrary setting_keys, so a role holding only settings.edit (or settings.security) could write keys owned by a narrower permission — repointing the public site URL (settings.domains), security policy (settings.security) or VAT/accounting config (settings.banking) via the wrong endpoint. Add stripUnauthorizedProtectedKeys(): before every generic upsert, drop any protected key the caller isn't permitted to write (general_site_url → settings.domains, security_* → settings.security, accounting_* → settings.banking). Dedicated routes still work because their caller holds the matching perm. Replaces the narrower in-handler site-URL guard. Also fix two tests affected by the RBAC changes: - authzPermissionGaps: API-token management moved to settings.integrations, so grant that (not settings.edit) to exercise the ownership 404. - AdminPhotoGrid.viewToggle: stub PermissionGate (its buttons are now gated and the test renders without a PermissionsProvider). * fix(permissions): address upstream review (#1045) - Renumber migration 174 -> 175 (174 now taken by 174_sqlite_nullable_event_dates from #1035; the collision made picpeakImportService's forward-only restore guard treat both as order 174 and accept a newer .picpeak onto an older schema). - Contain the roles.manage blast radius (delegation, not root escalation): a non-super_admin can no longer edit their own role, nor grant any permission their own role doesn't already hold (createRole + updateRole). - Protected-key denial now 403s (naming the keys + required perms) instead of silently stripping and reporting "saved" (adminSettings generic writers). - Reserve team_photographer so a custom role can't squat the preset name. - Boot self-heal: per-step try/catch so a role_permissions insert race on one replica doesn't skip preset seeding. - Forward-project the feature .manage perms that also replaced settings.edit gates (whatsapp/event_types/image_security/notifications/system), matching the settings.* split projection so the pattern is symmetric for phase-2. - Guard exports.down's roles/admin_users queries with hasTable. * fix(permissions): change-detection on protected-key 403 + commit guard tests (#1045) Round-2 review: - The protected-key 403 fired on key PRESENCE. The General tab re-posts general_site_url on every save, so a settings.edit-only role (the office manager this PR enables) got 403'd on every General save even when the URL was unchanged. Restore change-detection: compare the incoming value against the stored one and 403 only on an actual change; unchanged protected keys are dropped so the rest of the save proceeds. Only /general is affected. - Commit the self-amplification guard test (was run locally, never staged): adminRolesGuards.test.js — non-super can't grant perms it lacks, can't edit its own role, can't escalate another role; super_admin bypasses; team_photographer name reserved. - Add adminSettingsProtectedKeys.test.js pinning the change-detection: an unchanged general_site_url saves, an actual change 403s, super_admin changes it.
485 lines
17 KiB
JavaScript
485 lines
17 KiB
JavaScript
const express = require('express');
|
|
const { db, withRetry } = require('../database/db');
|
|
const { adminAuth } = require('../middleware/auth');
|
|
const { requirePermission } = require('../middleware/permissions');
|
|
const fs = require('fs').promises;
|
|
const path = require('path');
|
|
const os = require('os');
|
|
const { formatBoolean } = require('../utils/dbCompat');
|
|
const logger = require('../utils/logger');
|
|
const { resolveSqlitePath } = require('../utils/databaseEngine');
|
|
const { checkForUpdates, getCurrentChannel, getCurrentVersion, getReleasesSince, compareVersions } = require('../services/updateCheckService');
|
|
const { getAppSetting, upsertAppSetting } = require('../utils/appSettings');
|
|
const { parseWhatsNew } = require('../utils/whatsNew');
|
|
const { detectEnvironment, generateUpdateInstructions } = require('../services/environmentService');
|
|
const {
|
|
checkAndNotifyUpdates,
|
|
sendTestUpdateNotification,
|
|
getUpdateNotificationSettings
|
|
} = require('../services/updateNotificationService');
|
|
const router = express.Router();
|
|
|
|
// Get system version
|
|
router.get('/version', adminAuth, requirePermission(['settings.view', 'system.view']), async (req, res) => {
|
|
try {
|
|
// Read backend version from package.json
|
|
let backendVersion = '1.0.0';
|
|
try {
|
|
const packagePath = path.join(__dirname, '../../package.json');
|
|
const packageContent = await fs.readFile(packagePath, 'utf8');
|
|
const packageJson = JSON.parse(packageContent);
|
|
backendVersion = packageJson.version || '1.0.0';
|
|
} catch (err) {
|
|
logger.error('Could not read package.json:', err);
|
|
}
|
|
|
|
const channel = getCurrentChannel(backendVersion);
|
|
|
|
res.json({
|
|
backend: backendVersion,
|
|
frontend: '1.0.0', // This will be set by frontend
|
|
node: process.version,
|
|
environment: process.env.NODE_ENV || 'production',
|
|
channel: channel
|
|
});
|
|
} catch (error) {
|
|
logger.error('Error fetching version:', error);
|
|
res.status(500).json({ error: 'Failed to fetch version information' });
|
|
}
|
|
});
|
|
|
|
// Check for updates
|
|
router.get('/updates', adminAuth, requirePermission(['settings.view', 'system.view']), async (req, res) => {
|
|
try {
|
|
// Check if update checking is enabled
|
|
const updateCheckEnabled = process.env.UPDATE_CHECK_ENABLED !== 'false';
|
|
|
|
if (!updateCheckEnabled) {
|
|
return res.json({
|
|
enabled: false,
|
|
message: 'Update checking is disabled'
|
|
});
|
|
}
|
|
|
|
const forceRefresh = req.query.refresh === 'true';
|
|
const updateInfo = await checkForUpdates(forceRefresh);
|
|
|
|
// Pre-update teaser: the target version's top highlights, so the
|
|
// "Update Available" banner can show "New features include …".
|
|
let latestHighlights = [];
|
|
if (updateInfo.updateAvailable) {
|
|
try {
|
|
const newer = await getReleasesSince(updateInfo.current, updateInfo.channel);
|
|
if (newer[0]) latestHighlights = parseWhatsNew(newer[0].body);
|
|
} catch (_) { /* teaser is best-effort */ }
|
|
}
|
|
|
|
res.json({
|
|
enabled: true,
|
|
...updateInfo,
|
|
latestHighlights
|
|
});
|
|
} catch (error) {
|
|
logger.error('Error checking for updates:', error);
|
|
res.status(500).json({ error: 'Failed to check for updates' });
|
|
}
|
|
});
|
|
|
|
// What's New — after-update highlights. Returns the curated bullets for
|
|
// every release the instance moved THROUGH since it last acknowledged one
|
|
// (lastSeen < version <= running). Seen-tracking is per-INSTANCE: the first
|
|
// admin to dismiss clears it for everyone (a single app_settings row). A
|
|
// brand-new install initialises the marker silently so it never pops
|
|
// "what's new" with nothing to compare against. Best-effort: any failure
|
|
// (GitHub unreachable, etc.) returns hasNews:false, never errors.
|
|
router.get('/updates/whatsnew', adminAuth, requirePermission(['settings.view', 'system.view']), async (req, res) => {
|
|
try {
|
|
if (process.env.UPDATE_CHECK_ENABLED === 'false') {
|
|
return res.json({ enabled: false, hasNews: false });
|
|
}
|
|
const running = await getCurrentVersion();
|
|
const channel = getCurrentChannel(running);
|
|
const lastSeen = await getAppSetting('whatsnew_last_seen_version', null);
|
|
|
|
if (!lastSeen) {
|
|
await upsertAppSetting('whatsnew_last_seen_version', JSON.stringify(running), 'system');
|
|
return res.json({ enabled: true, hasNews: false, running });
|
|
}
|
|
if (compareVersions(running, lastSeen) <= 0) {
|
|
return res.json({ enabled: true, hasNews: false, running });
|
|
}
|
|
|
|
// Releases in (lastSeen, running], newest-first, with their highlights.
|
|
const releases = (await getReleasesSince(lastSeen, channel))
|
|
.filter((r) => compareVersions(r.version, running) <= 0);
|
|
const versions = releases
|
|
.map((r) => ({
|
|
version: r.version,
|
|
name: r.name,
|
|
publishedAt: r.publishedAt,
|
|
htmlUrl: r.htmlUrl,
|
|
bullets: parseWhatsNew(r.body),
|
|
}))
|
|
.filter((v) => v.bullets.length > 0);
|
|
|
|
return res.json({
|
|
enabled: true,
|
|
hasNews: versions.length > 0,
|
|
fromVersion: lastSeen,
|
|
toVersion: running,
|
|
versions,
|
|
});
|
|
} catch (error) {
|
|
logger.error('Error building what\'s-new:', error);
|
|
res.json({ enabled: true, hasNews: false });
|
|
}
|
|
});
|
|
|
|
// Acknowledge the What's New — advance the per-instance marker to the
|
|
// running version so it stops showing for every admin.
|
|
router.post('/updates/whatsnew/seen', adminAuth, requirePermission(['settings.view', 'system.view']), async (req, res) => {
|
|
try {
|
|
const running = await getCurrentVersion();
|
|
await upsertAppSetting('whatsnew_last_seen_version', JSON.stringify(running), 'system');
|
|
res.json({ ok: true, lastSeen: running });
|
|
} catch (error) {
|
|
logger.error('Error marking what\'s-new seen:', error);
|
|
res.status(500).json({ error: 'Failed to update marker' });
|
|
}
|
|
});
|
|
|
|
// Aggregated changelog — every release between current and latest in
|
|
// the user's channel. Powers the update-available modal (#567) so the
|
|
// admin can read release notes for ALL versions they're behind on, not
|
|
// just the latest. Body is raw GitHub-flavoured markdown; rendering is
|
|
// the client's job (frontend uses `marked`).
|
|
router.get('/updates/changelog', adminAuth, requirePermission(['settings.view', 'system.view']), async (req, res) => {
|
|
try {
|
|
const updateCheckEnabled = process.env.UPDATE_CHECK_ENABLED !== 'false';
|
|
if (!updateCheckEnabled) {
|
|
return res.json({ enabled: false, releases: [] });
|
|
}
|
|
|
|
const updateInfo = await checkForUpdates();
|
|
const releases = await getReleasesSince(updateInfo.current, updateInfo.channel);
|
|
|
|
res.json({
|
|
enabled: true,
|
|
current: updateInfo.current,
|
|
channel: updateInfo.channel,
|
|
releases,
|
|
});
|
|
} catch (error) {
|
|
logger.error('Error fetching update changelog:', error);
|
|
res.status(500).json({ error: 'Failed to fetch changelog' });
|
|
}
|
|
});
|
|
|
|
// Get update instructions for current environment
|
|
router.get('/updates/instructions', adminAuth, requirePermission(['settings.view', 'system.view']), async (req, res) => {
|
|
try {
|
|
// Check if update checking is enabled
|
|
const updateCheckEnabled = process.env.UPDATE_CHECK_ENABLED !== 'false';
|
|
|
|
if (!updateCheckEnabled) {
|
|
return res.json({
|
|
enabled: false,
|
|
message: 'Update checking is disabled'
|
|
});
|
|
}
|
|
|
|
const env = await detectEnvironment();
|
|
const updateInfo = await checkForUpdates();
|
|
|
|
if (!updateInfo.updateAvailable) {
|
|
return res.json({
|
|
updateAvailable: false,
|
|
currentVersion: updateInfo.current,
|
|
message: 'You are running the latest version'
|
|
});
|
|
}
|
|
|
|
const instructions = generateUpdateInstructions(env, updateInfo.latest.forChannel);
|
|
|
|
res.json({
|
|
updateAvailable: true,
|
|
currentVersion: updateInfo.current,
|
|
targetVersion: updateInfo.latest.forChannel,
|
|
channel: updateInfo.channel,
|
|
environment: env,
|
|
instructions,
|
|
releaseNotesUrl: `https://github.com/PicPeak/picpeak/releases/tag/v${updateInfo.latest.forChannel}`
|
|
});
|
|
} catch (error) {
|
|
logger.error('Error generating update instructions:', error);
|
|
res.status(500).json({ error: 'Failed to generate update instructions' });
|
|
}
|
|
});
|
|
|
|
// Get comprehensive system status
|
|
router.get('/status', adminAuth, requirePermission(['settings.view', 'system.view']), async (req, res) => {
|
|
try {
|
|
// Database size. Read the LIVE connection rather than re-deriving any of
|
|
// this from the environment (#1038): DATABASE_CLIENT is not the only thing
|
|
// that decides the engine, DB_NAME is not the only thing that decides the
|
|
// database, and DATABASE_PATH was ignored outright here — so a SQLite
|
|
// install with a custom path, or a Postgres install without an explicit
|
|
// DATABASE_CLIENT, reported the size of something it was not using.
|
|
let dbSize = 0;
|
|
const liveConnection = db.client.config.connection || {};
|
|
|
|
if (db.client.config.client === 'pg') {
|
|
try {
|
|
const result = await db.raw('SELECT pg_database_size(current_database()) as size');
|
|
dbSize = result.rows[0]?.size || 0;
|
|
} catch (error) {
|
|
logger.error('Error getting PostgreSQL database size:', error);
|
|
}
|
|
} else {
|
|
try {
|
|
const stats = await fs.stat(liveConnection.filename || resolveSqlitePath());
|
|
dbSize = stats.size;
|
|
} catch (error) {
|
|
logger.error('Error getting SQLite database size:', error);
|
|
}
|
|
}
|
|
|
|
// Count various entities
|
|
const [eventsCount] = await db('events').count('* as count');
|
|
const [photosCount] = await db('photos').count('* as count');
|
|
const [adminsCount] = await db('admin_users').count('* as count');
|
|
const [categoriesCount] = await db('photo_categories').count('* as count');
|
|
|
|
// Email queue status
|
|
const [pendingEmails] = await db('email_queue').where('status', 'pending').count('* as count');
|
|
const [processableEmails] = await db('email_queue')
|
|
.where('status', 'pending')
|
|
.where('retry_count', '<', 3)
|
|
.count('* as count');
|
|
const [sentEmails] = await db('email_queue').where('status', 'sent').count('* as count');
|
|
const [failedEmails] = await db('email_queue').where('status', 'failed').count('* as count');
|
|
const [stuckEmails] = await db('email_queue')
|
|
.where('status', 'pending')
|
|
.where('retry_count', '>=', 3)
|
|
.count('* as count');
|
|
|
|
// Activity logs count
|
|
const [activityCount] = await db('activity_logs').count('* as count');
|
|
|
|
// Storage info
|
|
const [{ totalPhotoStorage }] = await db('photos')
|
|
.sum('size_bytes as totalPhotoStorage');
|
|
|
|
const archives = await db('events')
|
|
.where('is_archived', formatBoolean(true))
|
|
.whereNotNull('archive_path')
|
|
.select('archive_path');
|
|
|
|
let archiveStorage = 0;
|
|
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
|
|
|
for (const archive of archives) {
|
|
if (archive.archive_path) {
|
|
try {
|
|
const fullArchivePath = path.join(storagePath, archive.archive_path);
|
|
const stats = await fs.stat(fullArchivePath);
|
|
archiveStorage += stats.size;
|
|
} catch (error) {
|
|
logger.error('Archive file not found:', archive.archive_path);
|
|
}
|
|
}
|
|
}
|
|
|
|
const totalStorage = (parseInt(totalPhotoStorage) || 0) + archiveStorage;
|
|
|
|
// System info
|
|
const systemInfo = {
|
|
platform: os.platform(),
|
|
arch: os.arch(),
|
|
hostname: os.hostname(),
|
|
uptime: Math.floor(process.uptime()),
|
|
nodeVersion: process.version,
|
|
memory: {
|
|
total: os.totalmem(),
|
|
free: os.freemem(),
|
|
used: os.totalmem() - os.freemem()
|
|
},
|
|
cpu: {
|
|
model: os.cpus()[0]?.model || 'Unknown',
|
|
cores: os.cpus().length
|
|
}
|
|
};
|
|
|
|
// Build response
|
|
const status = {
|
|
database: {
|
|
size: dbSize,
|
|
tables: {
|
|
events: eventsCount.count,
|
|
photos: photosCount.count,
|
|
admins: adminsCount.count,
|
|
categories: categoriesCount.count,
|
|
activityLogs: activityCount.count
|
|
}
|
|
},
|
|
storage: {
|
|
totalUsed: totalStorage,
|
|
photoStorage: parseInt(totalPhotoStorage) || 0,
|
|
archiveStorage: archiveStorage
|
|
},
|
|
emailQueue: {
|
|
pending: pendingEmails.count,
|
|
processable: processableEmails.count,
|
|
stuck: stuckEmails.count,
|
|
sent: sentEmails.count,
|
|
failed: failedEmails.count
|
|
},
|
|
system: systemInfo,
|
|
services: {
|
|
fileWatcher: { status: 'active' }, // These would ideally check actual service status
|
|
expirationChecker: { status: 'active' },
|
|
emailProcessor: { status: 'active' }
|
|
},
|
|
timestamp: new Date()
|
|
};
|
|
|
|
res.json(status);
|
|
} catch (error) {
|
|
logger.error('Error fetching system status:', error);
|
|
res.status(500).json({ error: 'Failed to fetch system status' });
|
|
}
|
|
});
|
|
|
|
// Get database statistics
|
|
router.get('/database', adminAuth, requirePermission(['settings.view', 'system.view']), async (req, res) => {
|
|
try {
|
|
// Get table info
|
|
const tables = [
|
|
'events', 'photos', 'admin_users', 'photo_categories',
|
|
'cms_pages', 'email_templates', 'email_queue', 'activity_logs',
|
|
'app_settings', 'email_configs', 'access_logs', 'migrations'
|
|
];
|
|
|
|
const tableInfo = [];
|
|
|
|
for (const table of tables) {
|
|
try {
|
|
const [count] = await db(table).count('* as count');
|
|
|
|
// Get last update time
|
|
let lastUpdate = null;
|
|
try {
|
|
const lastRow = await db(table)
|
|
.orderBy('updated_at', 'desc')
|
|
.orOrderBy('created_at', 'desc')
|
|
.orOrderBy('timestamp', 'desc')
|
|
.orOrderBy('applied_at', 'desc')
|
|
.first();
|
|
|
|
if (lastRow) {
|
|
lastUpdate = lastRow.updated_at || lastRow.created_at || lastRow.timestamp || lastRow.applied_at;
|
|
}
|
|
} catch (e) {
|
|
// Table might not have timestamp columns
|
|
}
|
|
|
|
tableInfo.push({
|
|
name: table,
|
|
rows: count.count,
|
|
lastUpdate
|
|
});
|
|
} catch (error) {
|
|
// Table might not exist
|
|
logger.warn('Failed to retrieve table info', {
|
|
table,
|
|
error: error.message
|
|
});
|
|
tableInfo.push({
|
|
name: table,
|
|
rows: 0,
|
|
error: 'Unable to retrieve table details'
|
|
});
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
tables: tableInfo,
|
|
timestamp: new Date()
|
|
});
|
|
} catch (error) {
|
|
logger.error('Error fetching database info:', error);
|
|
res.status(500).json({ error: 'Failed to fetch database information' });
|
|
}
|
|
});
|
|
|
|
// Get update notification settings
|
|
router.get('/updates/notifications', adminAuth, requirePermission(['settings.view', 'system.view']), async (req, res) => {
|
|
try {
|
|
const settings = await getUpdateNotificationSettings();
|
|
res.json(settings);
|
|
} catch (error) {
|
|
logger.error('Error fetching update notification settings:', error);
|
|
res.status(500).json({ error: 'Failed to fetch update notification settings' });
|
|
}
|
|
});
|
|
|
|
// Update notification settings
|
|
router.put('/updates/notifications', adminAuth, requirePermission('system.manage'), async (req, res) => {
|
|
try {
|
|
const { enabled, recipients } = req.body;
|
|
|
|
if (typeof enabled !== 'undefined') {
|
|
await db('app_settings')
|
|
.where('setting_key', 'update_email_notifications_enabled')
|
|
.update({
|
|
setting_value: JSON.stringify(enabled === true),
|
|
updated_at: db.fn.now()
|
|
});
|
|
}
|
|
|
|
if (typeof recipients !== 'undefined') {
|
|
await db('app_settings')
|
|
.where('setting_key', 'update_email_recipients')
|
|
.update({
|
|
setting_value: JSON.stringify(recipients || ''),
|
|
updated_at: db.fn.now()
|
|
});
|
|
}
|
|
|
|
const updatedSettings = await getUpdateNotificationSettings();
|
|
res.json({ success: true, settings: updatedSettings });
|
|
} catch (error) {
|
|
logger.error('Error updating notification settings:', error);
|
|
res.status(500).json({ error: 'Failed to update notification settings' });
|
|
}
|
|
});
|
|
|
|
// Manually trigger update notification email
|
|
// Send a test update notification email. Uses the dedicated
|
|
// `version_update_test` template (migration 087) rather than reusing
|
|
// `version_update_available`, so admins on the latest version can still
|
|
// verify their SMTP + recipient config — the previous handler bailed
|
|
// with "No updates available" when nothing was pending (#418).
|
|
router.post('/updates/notifications/send', adminAuth, requirePermission('system.manage'), async (req, res) => {
|
|
try {
|
|
const result = await sendTestUpdateNotification();
|
|
res.json(result);
|
|
} catch (error) {
|
|
logger.error('Error sending test update notification:', error);
|
|
res.status(500).json({ error: 'Failed to send test update notification' });
|
|
}
|
|
});
|
|
|
|
// Check and send update notifications (called on admin login or periodically)
|
|
router.post('/updates/notifications/check', adminAuth, requirePermission(['settings.view', 'system.view']), async (req, res) => {
|
|
try {
|
|
const result = await checkAndNotifyUpdates();
|
|
res.json(result);
|
|
} catch (error) {
|
|
logger.error('Error checking for update notifications:', error);
|
|
res.status(500).json({ error: 'Failed to check for update notifications' });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|