Files
picpeak/backend/src/routes/adminSystem.js
T
Paul Nothaft 9143997f8e style(backend): clear the eslint backlog to zero
929 problems (928 errors, 1 warning) -> 0, exit 0.

Rule breakdown, which corrects the report's premise -- `indent` dominated, not
`quotes`: indent 719, quotes 68, no-unused-vars 54, no-empty 36,
no-useless-escape 22, no-case-declarations 17, no-inner-declarations 6,
no-control-regex 5, no-useless-catch 1, no-console 1 (warn).

--fix handled only indent + quotes (719+68 = exactly the "fixable" count).
no-useless-escape was NOT auto-fixable in this eslint version, so the one
genuinely risky class never went through the autofixer -- all 22 were done by
hand. Two mechanical proofs on the autofix diff: a token-level AST diff
(espree, before vs after) shows exactly 68 differing tokens, all quotes, with
the 719 indent fixes producing zero token changes; and a cooked-value diff of
every string/template/regex literal shows 0 differences.

Regex escapes: eslint was correctly conservative and did not flag the
load-bearing ones -- \- in [^a-zA-Z0-9_\-\.] (unescaping makes an invalid
reversed _ -> . range) or in [!@#$%^&*()_+\-=...] (would become a + -> = range
silently matching ",-."). Every removal was a \/ \[ or \. inside a character
class; all 11 old/new pairs were brute-forced over 794 inputs with 0
mismatches.

Manual fixes: no-empty were all deliberate best-effort catches around activity
logging, annotated rather than restructured; no-case-declarations braced in
two adminBackup switches; no-inner-declarations converted to const arrows
after checking no call precedes the declaration and no this/arguments use;
no-control-regex and no-console got targeted disables with stated reasons;
one `catch (e) { throw e; }` wrapper removed.

Two unused bindings were near-misses worth noting: secureStatic.js's
`fullPath` is a path-traversal guard (safePathJoin throws on escape) and
restoreService.js's `backupManifest` is the throw-on-corrupt-manifest gate
before a rollback -- deleting either would have silently removed a check. Only
the bindings were dropped; the calls stay.

Two real bugs found and deliberately preserved with a comment plus a narrow
disable rather than deleted, since deleting would erase the evidence:
_workflowSeedBoot.js's `booted` is written but never read, so the intended
once-per-process guard is missing its early return and workflows re-seed on
every call; and quoteService.js's VALID_QUOTE_TRANSITIONS is a full state
machine nothing consults, so quote status changes are unvalidated.

Backend test suite: 253 suites / 2552 tests passing, 0 failures, before and
after.

Refs testplan REPORT.md #22 (Part 1.2.02).
2026-09-01 16:46:34 +02:00

493 lines
18 KiB
JavaScript

const express = require('express');
const { db } = 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);
// `single_container` lets the admin UI explain why a feature is
// unavailable instead of rendering a switch that silently refuses to
// stay on. Currently only face recognition is gated this way (#1074 on
// the all-in-one image, #1042 / PR #1068) — see
// faceSettings.isSingleContainerImage for the reasoning.
const { isSingleContainerImage } = require('../services/faceSettings');
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,
single_container: isSingleContainerImage()
});
} 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;