BillTracker/routes/admin.js

440 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const express = require('express');
const router = express.Router();
const { getDb, rollbackMigration } = require('../db/database');
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)
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' });
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);
});
// 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);
logAudit({
user_id: req.user.id, action: 'admin.password.reset',
entity_type: 'user', entity_id: Number(req.params.id),
details: { target_username: user.username },
ip_address: req.ip, user_agent: req.get('user-agent'),
});
res.json({ success: true });
});
// 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.' });
}
}
// 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 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));
});
// 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)
);
});
// 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' });
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 });
});
// ── 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 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.
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' });
}
});
// ── 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;