const express = require('express'); const bcrypt = require('bcryptjs'); const { execSync } = require('child_process'); const path = require('path'); const { db } = require('../db/database'); const { authenticate, adminOnly } = require('../middleware/auth'); const router = express.Router(); // All admin routes require authentication and admin role router.use(authenticate, adminOnly); // GET /api/admin/users router.get('/users', (req, res) => { const users = db.prepare( 'SELECT id, username, email, role, created_at, updated_at, last_login FROM users ORDER BY created_at DESC' ).all(); // Add online status from WebSocket connections let onlineUserIds = new Set(); try { const { getOnlineUserIds } = require('../websocket'); onlineUserIds = getOnlineUserIds(); } catch { /* */ } const usersWithStatus = users.map(u => ({ ...u, online: onlineUserIds.has(u.id) })); res.json({ users: usersWithStatus }); }); // POST /api/admin/users router.post('/users', (req, res) => { const { username, email, password, role } = req.body; if (!username?.trim() || !email?.trim() || !password?.trim()) { return res.status(400).json({ error: 'Benutzername, E-Mail und Passwort sind erforderlich' }); } if (role && !['user', 'admin'].includes(role)) { return res.status(400).json({ error: 'Ungültige Rolle' }); } const existingUsername = db.prepare('SELECT id FROM users WHERE username = ?').get(username.trim()); if (existingUsername) return res.status(409).json({ error: 'Benutzername bereits vergeben' }); const existingEmail = db.prepare('SELECT id FROM users WHERE email = ?').get(email.trim()); if (existingEmail) return res.status(409).json({ error: 'E-Mail bereits vergeben' }); const passwordHash = bcrypt.hashSync(password.trim(), 10); const result = db.prepare( 'INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)' ).run(username.trim(), email.trim(), passwordHash, role || 'user'); const user = db.prepare( 'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?' ).get(result.lastInsertRowid); res.status(201).json({ user }); }); // PUT /api/admin/users/:id router.put('/users/:id', (req, res) => { const { username, email, role, password } = req.body; const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id); if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); if (role && !['user', 'admin'].includes(role)) { return res.status(400).json({ error: 'Ungültige Rolle' }); } if (username && username !== user.username) { const conflict = db.prepare('SELECT id FROM users WHERE username = ? AND id != ?').get(username, req.params.id); if (conflict) return res.status(409).json({ error: 'Benutzername bereits vergeben' }); } if (email && email !== user.email) { const conflict = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?').get(email, req.params.id); if (conflict) return res.status(409).json({ error: 'E-Mail bereits vergeben' }); } const passwordHash = password ? bcrypt.hashSync(password, 10) : null; db.prepare(` UPDATE users SET username = COALESCE(?, username), email = COALESCE(?, email), role = COALESCE(?, role), password_hash = COALESCE(?, password_hash), updated_at = CURRENT_TIMESTAMP WHERE id = ? `).run(username || null, email || null, role || null, passwordHash, req.params.id); const updated = db.prepare( 'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?' ).get(req.params.id); res.json({ user: updated }); }); // DELETE /api/admin/users/:id router.delete('/users/:id', (req, res) => { if (parseInt(req.params.id) === req.user.id) { return res.status(400).json({ error: 'Eigenes Konto kann nicht gelöscht werden' }); } const user = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id); if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id); res.json({ success: true }); }); // GET /api/admin/stats router.get('/stats', (req, res) => { const totalUsers = db.prepare('SELECT COUNT(*) as count FROM users').get().count; const totalTrips = db.prepare('SELECT COUNT(*) as count FROM trips').get().count; const totalPlaces = db.prepare('SELECT COUNT(*) as count FROM places').get().count; const totalFiles = db.prepare('SELECT COUNT(*) as count FROM trip_files').get().count; res.json({ totalUsers, totalTrips, totalPlaces, totalFiles }); }); // GET /api/admin/oidc — get OIDC config router.get('/oidc', (req, res) => { const get = (key) => db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key)?.value || ''; res.json({ issuer: get('oidc_issuer'), client_id: get('oidc_client_id'), client_secret: get('oidc_client_secret'), display_name: get('oidc_display_name'), }); }); // PUT /api/admin/oidc — update OIDC config router.put('/oidc', (req, res) => { const { issuer, client_id, client_secret, display_name } = req.body; const set = (key, val) => db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val || ''); set('oidc_issuer', issuer); set('oidc_client_id', client_id); set('oidc_client_secret', client_secret); set('oidc_display_name', display_name); res.json({ success: true }); }); // POST /api/admin/save-demo-baseline (demo mode only) router.post('/save-demo-baseline', (req, res) => { if (process.env.DEMO_MODE !== 'true') { return res.status(404).json({ error: 'Not found' }); } try { const { saveBaseline } = require('../demo/demo-reset'); saveBaseline(); res.json({ success: true, message: 'Demo baseline saved. Hourly resets will restore to this state.' }); } catch (err) { res.status(500).json({ error: 'Failed to save baseline: ' + err.message }); } }); // ── Version check ────────────────────────────────────────── // Detect if running inside Docker const isDocker = (() => { try { const fs = require('fs'); return fs.existsSync('/.dockerenv') || (fs.existsSync('/proc/1/cgroup') && fs.readFileSync('/proc/1/cgroup', 'utf8').includes('docker')); } catch { return false } })(); router.get('/version-check', async (req, res) => { const { version: currentVersion } = require('../../package.json'); try { const resp = await fetch( 'https://api.github.com/repos/mauriceboe/NOMAD/releases/latest', { headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'NOMAD-Server' } } ); if (!resp.ok) return res.json({ current: currentVersion, latest: currentVersion, update_available: false }); const data = await resp.json(); const latest = (data.tag_name || '').replace(/^v/, ''); const update_available = latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0; res.json({ current: currentVersion, latest, update_available, release_url: data.html_url || '', is_docker: isDocker }); } catch { res.json({ current: currentVersion, latest: currentVersion, update_available: false, is_docker: isDocker }); } }); function compareVersions(a, b) { const pa = a.split('.').map(Number); const pb = b.split('.').map(Number); for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const na = pa[i] || 0, nb = pb[i] || 0; if (na > nb) return 1; if (na < nb) return -1; } return 0; } // POST /api/admin/update — pull latest code, install deps, restart router.post('/update', async (req, res) => { const rootDir = path.resolve(__dirname, '../../..'); const serverDir = path.resolve(__dirname, '../..'); const clientDir = path.join(rootDir, 'client'); const steps = []; try { // 1. git pull const pullOutput = execSync('git pull origin main', { cwd: rootDir, timeout: 60000, encoding: 'utf8' }); steps.push({ step: 'git pull', success: true, output: pullOutput.trim() }); // 2. npm install server execSync('npm install --production', { cwd: serverDir, timeout: 120000, encoding: 'utf8' }); steps.push({ step: 'npm install (server)', success: true }); // 3. npm install + build client (production only) if (process.env.NODE_ENV === 'production') { execSync('npm install', { cwd: clientDir, timeout: 120000, encoding: 'utf8' }); execSync('npm run build', { cwd: clientDir, timeout: 120000, encoding: 'utf8' }); steps.push({ step: 'npm install + build (client)', success: true }); } // Read new version delete require.cache[require.resolve('../../package.json')]; const { version: newVersion } = require('../../package.json'); steps.push({ step: 'version', version: newVersion }); // 4. Send response before restart res.json({ success: true, steps, restarting: true }); // 5. Graceful restart — exit and let process manager (Docker/systemd/pm2) restart setTimeout(() => { console.log('[Update] Restarting after update...'); process.exit(0); }, 1000); } catch (err) { steps.push({ step: 'error', success: false, output: err.message }); res.status(500).json({ success: false, steps }); } }); // ── Addons ───────────────────────────────────────────────── router.get('/addons', (req, res) => { const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all(); res.json({ addons: addons.map(a => ({ ...a, enabled: !!a.enabled, config: JSON.parse(a.config || '{}') })) }); }); router.put('/addons/:id', (req, res) => { const addon = db.prepare('SELECT * FROM addons WHERE id = ?').get(req.params.id); if (!addon) return res.status(404).json({ error: 'Addon not found' }); const { enabled, config } = req.body; if (enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, req.params.id); if (config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(config), req.params.id); const updated = db.prepare('SELECT * FROM addons WHERE id = ?').get(req.params.id); res.json({ addon: { ...updated, enabled: !!updated.enabled, config: JSON.parse(updated.config || '{}') } }); }); module.exports = router;