9143997f8e
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).
493 lines
18 KiB
JavaScript
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;
|