const express = require('express'); const router = express.Router(); const { getDb, getSetting, setSetting, rollbackMigration } = require('../db/database'); const { getBankSyncConfig, setBankSyncEnabled, setSyncIntervalHours, setSyncDays } = require('../services/bankSyncConfigService'); const { getStatus: getBankSyncWorkerStatus } = require('../services/bankSyncWorker'); const { isEnvKeyActive } = require('../services/encryptionService'); const { hashPassword } = require('../services/authService'); const { logAudit } = require('../services/auditService'); const { createBackup, deleteBackup, getBackupFile, importBackupBuffer, listBackups, restoreBackup, } = require('../services/backupService'); const { getScheduleStatus, runScheduledBackupNow, saveSettings: saveBackupScheduleSettings, } = require('../services/backupScheduler'); const { getCleanupStatus, runAllCleanup, validateAndApplySettings: applyCleanupSettings, } = require('../services/cleanupService'); const { backupOperationLimiter } = require('../middleware/rateLimiter'); // All routes mounted at /api/admin (requireAuth + requireAdmin applied at server level) // Returns a validated positive integer from req.params.id, or null. function parseUserId(params) { const n = parseInt(params.id, 10); return Number.isInteger(n) && n > 0 ? n : null; } function sendError(res, err) { const status = err.status || 500; res.status(status).json({ error: status === 500 ? 'Backup operation failed' : err.message }); } // GET /api/admin/has-users router.get('/has-users', (req, res) => { const count = getDb().prepare('SELECT COUNT(*) AS n FROM users WHERE id != ?').get(req.user.id).n; res.json({ has_users: count > 0 }); }); // GET /api/admin/users router.get('/users', (req, res) => { res.json( getDb().prepare( 'SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users ORDER BY is_default_admin DESC, role DESC, username ASC' ).all() ); }); // POST /api/admin/backups router.post('/backups', backupOperationLimiter, async (req, res) => { try { const backup = await createBackup(); res.status(201).json(backup); } catch (err) { sendError(res, err); } }); // GET /api/admin/backups router.get('/backups', backupOperationLimiter, (req, res) => { try { res.json({ backups: listBackups() }); } catch (err) { sendError(res, err); } }); // GET /api/admin/backups/settings router.get('/backups/settings', backupOperationLimiter, (req, res) => { try { res.json(getScheduleStatus()); } catch (err) { sendError(res, err); } }); // PUT /api/admin/backups/settings router.put('/backups/settings', backupOperationLimiter, (req, res) => { try { res.json(saveBackupScheduleSettings(req.body)); } catch (err) { sendError(res, err); } }); // POST /api/admin/backups/run-scheduled-now router.post('/backups/run-scheduled-now', backupOperationLimiter, async (req, res) => { try { res.status(201).json(await runScheduledBackupNow()); } catch (err) { sendError(res, err); } }); // POST /api/admin/backups/import router.post( '/backups/import', backupOperationLimiter, express.raw({ type: ['application/octet-stream', 'application/x-sqlite3', 'application/vnd.sqlite3'], limit: '100mb', }), async (req, res) => { try { // Extract expected checksum from request headers or query const expectedChecksum = req.headers['x-checksum-sha256'] || req.query.checksum; const backup = await importBackupBuffer(req.body, { expectedChecksum: expectedChecksum ? String(expectedChecksum).trim() : undefined, }); res.status(201).json(backup); } catch (err) { sendError(res, err); } }, ); // GET /api/admin/backups/:id/download router.get('/backups/:id/download', (req, res) => { try { const backup = getBackupFile(req.params.id); res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('X-Content-Type-Options', 'nosniff'); res.download(backup.path, backup.metadata.id, (err) => { if (err && !res.headersSent) sendError(res, err); }); } catch (err) { sendError(res, err); } }); // POST /api/admin/backups/:id/restore router.post('/backups/:id/restore', backupOperationLimiter, async (req, res) => { try { res.json(await restoreBackup(req.params.id)); } catch (err) { sendError(res, err); } }); // DELETE /api/admin/backups/:id router.delete('/backups/:id', backupOperationLimiter, (req, res) => { try { res.json(deleteBackup(req.params.id)); } catch (err) { sendError(res, err); } }); // POST /api/admin/users router.post('/users', async (req, res) => { const { username, password } = req.body; if (!username || username.length < 3) return res.status(400).json({ error: 'Username must be at least 3 characters' }); if (!password || password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' }); const db = getDb(); if (db.prepare('SELECT id FROM users WHERE username = ?').get(username)) return res.status(409).json({ error: 'Username already taken' }); try { const hash = await hashPassword(password); const result = db.prepare( "INSERT INTO users (username, password_hash, role, first_login) VALUES (?, ?, 'user', 1)" ).run(username, hash); const created = db.prepare( 'SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?' ).get(result.lastInsertRowid); logAudit({ user_id: req.user.id, action: 'admin.user.create', entity_type: 'user', entity_id: created.id, details: { created_username: username }, ip_address: req.ip, user_agent: req.get('user-agent'), }); res.status(201).json(created); } catch (err) { console.error('[admin] create-user error:', err.message); res.status(500).json({ error: 'Failed to create user' }); } }); // PUT /api/admin/users/:id/password router.put('/users/:id/password', async (req, res) => { const targetId = parseUserId(req.params); if (!targetId) return res.status(400).json({ error: 'Invalid user ID' }); const { password } = req.body; if (!password || password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' }); const db = getDb(); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId); if (!user) return res.status(404).json({ error: 'User not found' }); try { const hash = await hashPassword(password); db.prepare("UPDATE users SET password_hash=?, must_change_password=1, updated_at=datetime('now') WHERE id=?") .run(hash, targetId); db.prepare('DELETE FROM sessions WHERE user_id = ?').run(targetId); logAudit({ user_id: req.user.id, action: 'admin.password.reset', entity_type: 'user', entity_id: targetId, details: { target_username: user.username }, ip_address: req.ip, user_agent: req.get('user-agent'), }); res.json({ success: true }); } catch (err) { console.error('[admin] reset-password error:', err.message); res.status(500).json({ error: 'Failed to reset password' }); } }); // PUT /api/admin/users/:id/role // Promote/demote an existing user. Prevents removing the last admin or // changing your own role mid-session. router.put('/users/:id/role', (req, res) => { const targetId = parseUserId(req.params); if (!targetId) return res.status(400).json({ error: 'Invalid user ID' }); const { role } = req.body; if (!['admin', 'user'].includes(role)) { return res.status(400).json({ error: 'role must be "admin" or "user"' }); } const db = getDb(); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId); if (!user) return res.status(404).json({ error: 'User not found' }); if (req.user?.id === targetId) { return res.status(400).json({ error: 'You cannot change your own admin role.' }); } if (user.role === 'admin' && role === 'user') { const adminCount = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'").get().n; if (adminCount <= 1) { return res.status(400).json({ error: 'Cannot remove the last admin account.' }); } } // SECURITY FIX (2026-05-08): Delete all sessions for the target user when role changes. // This forces re-authentication with the new role, preventing session hijacking // from being used to bypass privilege checks. db.prepare('DELETE FROM sessions WHERE user_id = ?').run(targetId); const previousRole = user.role; db.prepare("UPDATE users SET role = ?, updated_at = datetime('now') WHERE id = ?") .run(role, targetId); logAudit({ user_id: req.user.id, action: 'role.change', entity_type: 'user', entity_id: targetId, details: { old_role: previousRole, new_role: role }, ip_address: req.ip, user_agent: req.get('user-agent') }); const updated = db.prepare( 'SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?' ).get(targetId); res.json(updated); }); // PUT /api/admin/users/:id/active router.put('/users/:id/active', (req, res) => { const targetId = parseUserId(req.params); if (!targetId) return res.status(400).json({ error: 'Invalid user ID' }); const active = req.body?.active ? 1 : 0; const db = getDb(); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId); if (!user) return res.status(404).json({ error: 'User not found' }); if (req.user?.id === targetId) { return res.status(400).json({ error: 'You cannot deactivate your own account.' }); } db.prepare("UPDATE users SET active = ?, updated_at = datetime('now') WHERE id = ?").run(active, targetId); if (!active) db.prepare('DELETE FROM sessions WHERE user_id = ?').run(targetId); res.json(db.prepare( 'SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?' ).get(targetId)); }); // PUT /api/admin/users/:id/username router.put('/users/:id/username', (req, res) => { const { username } = req.body; if (!username || typeof username !== 'string') { return res.status(400).json({ error: 'username is required' }); } const trimmed = username.trim(); if (trimmed.length < 3) { return res.status(400).json({ error: 'Username must be at least 3 characters' }); } if (trimmed.length > 50) { return res.status(400).json({ error: 'Username must be 50 characters or fewer' }); } const targetId = parseUserId(req.params); if (!targetId) return res.status(400).json({ error: 'Invalid user ID' }); const db = getDb(); const user = db.prepare('SELECT id, username FROM users WHERE id = ?').get(targetId); if (!user) return res.status(404).json({ error: 'User not found' }); const taken = db.prepare( 'SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?' ).get(trimmed, targetId); if (taken) return res.status(409).json({ error: 'Username already taken' }); const previousUsername = user.username; db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?") .run(trimmed, targetId); logAudit({ user_id: req.user.id, action: 'admin.username.change', entity_type: 'user', entity_id: targetId, details: { old_username: previousUsername, new_username: trimmed }, ip_address: req.ip, user_agent: req.get('user-agent'), }); res.json( db.prepare('SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?') .get(targetId) ); }); // DELETE /api/admin/users/:id router.delete('/users/:id', (req, res) => { const targetId = parseUserId(req.params); if (!targetId) return res.status(400).json({ error: 'Invalid user ID' }); const db = getDb(); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId); if (!user) return res.status(404).json({ error: 'User not found' }); if (req.user?.id === user.id) return res.status(400).json({ error: 'You cannot delete your own account.' }); const deleteUser = db.transaction(() => { // These three tables have no FK/CASCADE to users — must delete explicitly. // Sessions also has CASCADE but we keep the explicit delete as a safety net // for the rare case where foreign_keys is temporarily OFF during a migration. db.prepare('DELETE FROM import_sessions WHERE user_id = ?').run(user.id); db.prepare('DELETE FROM import_history WHERE user_id = ?').run(user.id); db.prepare('DELETE FROM audit_log WHERE user_id = ?').run(user.id); db.prepare('DELETE FROM sessions WHERE user_id = ?').run(user.id); db.prepare('DELETE FROM users WHERE id = ?').run(user.id); // ON DELETE CASCADE handles: bills, payments, categories, monthly_bill_state, // bill_history_ranges, notifications, data_sources, financial_accounts, // transactions, user_settings, user_login_history, monthly_income, // monthly_starting_amounts, autopay_suggestion_dismissals, bill_templates, // match_suggestion_rejections, declined_subscription_hints, bill_merchant_rules, // snowball_plans. }); deleteUser(); res.json({ success: true, deleted_user_id: user.id }); }); // ── Cleanup endpoints ───────────────────────────────────────────────────────── // GET /api/admin/cleanup // Returns current cleanup settings and the result of the last cleanup run. router.get('/cleanup', (req, res) => { try { res.json(getCleanupStatus()); } catch (err) { sendError(res, err); } }); // PUT /api/admin/cleanup // Updates one or more cleanup settings. Accepts partial objects. // import_sessions_enabled boolean prune expired import preview sessions // temp_exports_enabled boolean prune stale SQLite export temp files // temp_export_max_age_hours 1–72 hours before an orphaned export file is removed // backup_partials_enabled boolean prune orphaned .partial/.upload backup files // import_history_enabled boolean prune old import history rows (disabled by default) // import_history_max_age_days 30–3650 age threshold for import history rows router.put('/cleanup', (req, res) => { try { res.json(applyCleanupSettings(req.body)); } catch (err) { sendError(res, err); } }); // POST /api/admin/cleanup/run // Runs all enabled cleanup tasks immediately and returns the result. router.post('/cleanup/run', backupOperationLimiter, async (req, res) => { try { const result = await runAllCleanup(); res.json(result); } catch (err) { sendError(res, err); } }); // ── Auth-mode helpers ───────────────────────────────────────────────────────── const { applyAuthModeSettings, buildAuthModeStatus, buildSubmittedOidcConfig, testOidcConfiguration, } = require('../services/oidcService'); // GET /api/admin/auth-mode router.get('/auth-mode', (req, res) => { res.json(buildAuthModeStatus()); }); // POST /api/admin/auth-mode/oidc-test // Tests submitted or saved OIDC provider settings with OIDC discovery. // Never returns client secret or token material. router.post('/auth-mode/oidc-test', async (req, res) => { const config = buildSubmittedOidcConfig(req.body || {}); const result = await testOidcConfiguration(config); res.status(result.ok ? 200 : 400).json(result); }); // PUT /api/admin/auth-mode // Accepts legacy auth_mode/default_user_id fields plus new auth method settings. // Validates lockout protection before saving. router.put('/auth-mode', (req, res) => { try { res.json(applyAuthModeSettings(req.body || {})); } catch (err) { res.status(err.status || 500).json({ error: err.status ? err.message : 'Failed to update authentication settings' }); } }); // ── Bank Sync Config ────────────────────────────────────────────────────────── // GET /api/admin/bank-sync-config router.get('/bank-sync-config', (req, res) => { res.json({ ...getBankSyncConfig(), worker: getBankSyncWorkerStatus(), encryption_key_source: isEnvKeyActive() ? 'env' : 'db' }); }); // PUT /api/admin/bank-sync-config router.put('/bank-sync-config', (req, res) => { const { enabled, sync_interval_hours, sync_days } = req.body || {}; try { let config = getBankSyncConfig(); if (typeof enabled === 'boolean') config = setBankSyncEnabled(enabled); if (sync_interval_hours !== undefined) config = setSyncIntervalHours(sync_interval_hours); if (sync_days !== undefined) config = setSyncDays(sync_days); res.json(config); } catch (err) { res.status(err.status || 500).json({ error: err.message || 'Failed to update bank sync config' }); } }); // ── Privacy Settings ────────────────────────────────────────────────────────── // GET /api/admin/privacy router.get('/privacy', (req, res) => { res.json({ geolocation_enabled: getSetting('geolocation_enabled') === 'true', }); }); // PUT /api/admin/privacy router.put('/privacy', (req, res) => { const { geolocation_enabled } = req.body || {}; if (typeof geolocation_enabled === 'boolean') { setSetting('geolocation_enabled', geolocation_enabled ? 'true' : 'false'); } res.json({ geolocation_enabled: getSetting('geolocation_enabled') === 'true', }); }); // ── Migration Rollback ──────────────────────────────────────────────────────── router.post('/migrations/rollback', async (req, res) => { const { version } = req.body; if (!version) { return res.status(400).json({ error: 'Version is required' }); } try { const result = rollbackMigration(version); logAudit({ user_id: req.user.id, action: 'migration.rollback', entity_type: 'migration', entity_id: null, details: { version, performed_by: req.user.username }, ip_address: req.ip, user_agent: req.get('user-agent') }); res.json({ success: true, ...result }); } catch (err) { logAudit({ user_id: req.user.id, action: 'migration.rollback.failure', entity_type: 'migration', entity_id: null, details: { version, error: err.message, performed_by: req.user.username }, ip_address: req.ip, user_agent: req.get('user-agent') }); if (err.code === 'NOT_APPLIED') { return res.status(404).json({ error: err.message }); } if (err.code === 'ROLLBACK_NOT_SUPPORTED') { return res.status(422).json({ error: err.message }); } res.status(500).json({ error: 'Rollback failed', details: err.message }); } }); module.exports = router;