BillTracker/routes/admin.js

426 lines
15 KiB
JavaScript
Raw Normal View History

2026-05-03 19:51:57 -05:00
const express = require('express');
const router = express.Router();
2026-05-16 15:38:28 -05:00
const { getDb, rollbackMigration } = require('../db/database');
2026-05-03 19:51:57 -05:00
const { hashPassword } = require('../services/authService');
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');
2026-05-09 13:03:36 -05:00
const { backupOperationLimiter } = require('../middleware/rateLimiter');
2026-05-03 19:51:57 -05:00
// All routes mounted at /api/admin (requireAuth + requireAdmin applied at server level)
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) => {
2026-05-04 23:34:24 -05:00
const count = getDb().prepare('SELECT COUNT(*) AS n FROM users WHERE id != ?').get(req.user.id).n;
2026-05-03 19:51:57 -05:00
res.json({ has_users: count > 0 });
});
// GET /api/admin/users
router.get('/users', (req, res) => {
res.json(
getDb().prepare(
2026-05-04 23:34:24 -05:00
'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'
2026-05-03 19:51:57 -05:00
).all()
);
});
// POST /api/admin/backups
2026-05-09 13:03:36 -05:00
router.post('/backups', backupOperationLimiter, async (req, res) => {
2026-05-03 19:51:57 -05:00
try {
const backup = await createBackup();
res.status(201).json(backup);
} catch (err) {
sendError(res, err);
}
});
// GET /api/admin/backups
2026-05-09 13:03:36 -05:00
router.get('/backups', backupOperationLimiter, (req, res) => {
2026-05-03 19:51:57 -05:00
try {
res.json({ backups: listBackups() });
} catch (err) {
sendError(res, err);
}
});
// GET /api/admin/backups/settings
2026-05-09 13:03:36 -05:00
router.get('/backups/settings', backupOperationLimiter, (req, res) => {
2026-05-03 19:51:57 -05:00
try {
res.json(getScheduleStatus());
} catch (err) {
sendError(res, err);
}
});
// PUT /api/admin/backups/settings
2026-05-09 13:03:36 -05:00
router.put('/backups/settings', backupOperationLimiter, (req, res) => {
2026-05-03 19:51:57 -05:00
try {
res.json(saveBackupScheduleSettings(req.body));
} catch (err) {
sendError(res, err);
}
});
// POST /api/admin/backups/run-scheduled-now
2026-05-09 13:03:36 -05:00
router.post('/backups/run-scheduled-now', backupOperationLimiter, async (req, res) => {
2026-05-03 19:51:57 -05:00
try {
res.status(201).json(await runScheduledBackupNow());
} catch (err) {
sendError(res, err);
}
});
// POST /api/admin/backups/import
router.post(
'/backups/import',
2026-05-09 13:03:36 -05:00
backupOperationLimiter,
2026-05-03 19:51:57 -05:00
express.raw({
type: ['application/octet-stream', 'application/x-sqlite3', 'application/vnd.sqlite3'],
limit: '100mb',
}),
async (req, res) => {
try {
2026-05-09 13:03:36 -05:00
// 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,
});
2026-05-03 19:51:57 -05:00
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
2026-05-09 13:03:36 -05:00
router.post('/backups/:id/restore', backupOperationLimiter, async (req, res) => {
2026-05-03 19:51:57 -05:00
try {
res.json(await restoreBackup(req.params.id));
} catch (err) {
sendError(res, err);
}
});
// DELETE /api/admin/backups/:id
2026-05-09 13:03:36 -05:00
router.delete('/backups/:id', backupOperationLimiter, (req, res) => {
2026-05-03 19:51:57 -05:00
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' });
const hash = await hashPassword(password);
const result = db.prepare(
"INSERT INTO users (username, password_hash, role, first_login) VALUES (?, ?, 'user', 1)"
).run(username, hash);
res.status(201).json(
2026-05-04 23:34:24 -05:00
db.prepare('SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?')
2026-05-03 19:51:57 -05:00
.get(result.lastInsertRowid)
);
});
// PUT /api/admin/users/:id/password
router.put('/users/:id/password', async (req, res) => {
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(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
const hash = await hashPassword(password);
db.prepare("UPDATE users SET password_hash=?, must_change_password=1, updated_at=datetime('now') WHERE id=?")
.run(hash, req.params.id);
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(req.params.id);
res.json({ success: true });
});
// Import audit service
const { logAudit } = require('../services/auditService');
2026-05-03 19:51:57 -05:00
// 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 { role } = req.body;
if (!['admin', 'user'].includes(role)) {
return res.status(400).json({ error: 'role must be "admin" or "user"' });
}
const targetId = Number(req.params.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 === 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.' });
}
}
2026-05-09 13:03:36 -05:00
// 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;
2026-05-03 19:51:57 -05:00
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') });
2026-05-03 19:51:57 -05:00
const updated = db.prepare(
2026-05-04 23:34:24 -05:00
'SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?'
2026-05-03 19:51:57 -05:00
).get(targetId);
res.json(updated);
});
2026-05-04 23:34:24 -05:00
// PUT /api/admin/users/:id/active
router.put('/users/:id/active', (req, res) => {
const active = req.body?.active ? 1 : 0;
const targetId = Number(req.params.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 === 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));
});
2026-05-14 01:17:05 -05:00
// 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 = Number(req.params.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)
);
});
2026-05-03 19:51:57 -05:00
// DELETE /api/admin/users/:id
router.delete('/users/:id', (req, res) => {
const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
2026-05-04 23:34:24 -05:00
if (req.user?.id === user.id) return res.status(400).json({ error: 'You cannot delete your own account.' });
const deleteUser = db.transaction(() => {
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 sessions WHERE user_id = ?').run(user.id);
db.prepare('DELETE FROM users WHERE id = ?').run(user.id);
});
deleteUser();
res.json({ success: true, deleted_user_id: user.id });
2026-05-03 19:51:57 -05:00
});
// ── 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);
}
});
2026-05-09 13:03:36 -05:00
2026-05-03 19:51:57 -05:00
// 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 172 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 303650 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.
2026-05-09 13:03:36 -05:00
router.post('/cleanup/run', backupOperationLimiter, async (req, res) => {
2026-05-03 19:51:57 -05:00
try {
const result = await runAllCleanup();
res.json(result);
} catch (err) {
sendError(res, err);
}
});
// ── Auth-mode helpers ─────────────────────────────────────────────────────────
const {
2026-05-16 15:38:28 -05:00
applyAuthModeSettings,
buildAuthModeStatus,
buildSubmittedOidcConfig,
2026-05-03 19:51:57 -05:00
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) => {
2026-05-16 15:38:28 -05:00
try {
res.json(applyAuthModeSettings(req.body || {}));
} catch (err) {
res.status(err.status || 500).json({ error: err.status ? err.message : 'Failed to update authentication settings' });
2026-05-03 19:51:57 -05:00
}
});
// ── 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 });
}
});
2026-05-03 19:51:57 -05:00
module.exports = router;