522 lines
19 KiB
JavaScript
522 lines
19 KiB
JavaScript
const express = require('express');
|
||
const router = express.Router();
|
||
const { getDb, getSetting, setSetting, rollbackMigration } = require('../db/database');
|
||
const { getBankSyncConfig, setBankSyncEnabled, setSyncIntervalHours, setSyncDays, setDebugLogging } = 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, debug_logging } = 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);
|
||
if (typeof debug_logging === 'boolean') config = setDebugLogging(debug_logging);
|
||
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;
|