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 } ) ;
} ) ;
2026-05-10 00:03:12 -05:00
// 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 ) ;
2026-05-10 00:03:12 -05:00
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 ) ;
2026-05-10 00:03:12 -05:00
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 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.
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
}
} ) ;
2026-05-10 10:44:39 -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 ;