import { Router } from 'express'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; import crypto from 'crypto'; import { getSetting, setSetting, getAppUserCount, createAppUser, getAppUserByUsername, getAppUserById, getAllAppUsers, updateAppUser, deleteAppUser, getUserFolderAccess, setUserFolderAccess, getUserRouteAccess, setUserRouteAccess, getAllIndexedFolders, } from './db.js'; const router = Router(); const TOKEN_COOKIE = 'ofapp_token'; const TOKEN_EXPIRY = undefined; // no expiration function getJwtSecret() { let secret = getSetting('jwt_secret'); if (!secret) { secret = crypto.randomBytes(48).toString('hex'); setSetting('jwt_secret', secret); console.log('[auth] Generated new JWT secret'); } return secret; } function signToken(userId) { return jwt.sign({ userId }, getJwtSecret(), { expiresIn: TOKEN_EXPIRY }); } function setTokenCookie(res, token) { res.cookie(TOKEN_COOKIE, token, { httpOnly: true, sameSite: 'lax', maxAge: 10 * 365 * 24 * 60 * 60 * 1000, // ~10 years secure: false, // allow HTTP (self-signed HTTPS + local network) }); } function userPayload(user, routes, folders) { return { id: user.id, username: user.username, display_name: user.display_name, role: user.role, enabled: user.enabled, routes, folders: user.role === 'admin' ? null : folders, // null = all access }; } // --- Middleware --- export function requireAuth(req, res, next) { // Setup mode: if no users exist, allow all requests as synthetic admin const userCount = getAppUserCount(); if (userCount === 0) { req.user = { id: 0, username: 'setup', role: 'admin', enabled: 1 }; return next(); } const token = req.cookies?.[TOKEN_COOKIE]; if (!token) { return res.status(401).json({ error: 'Authentication required' }); } try { const decoded = jwt.verify(token, getJwtSecret()); const user = getAppUserById(decoded.userId); if (!user || !user.enabled) { return res.status(401).json({ error: 'Account disabled or not found' }); } req.user = user; next(); } catch (err) { return res.status(401).json({ error: 'Invalid or expired token' }); } } export function requireAdmin(req, res, next) { if (req.user?.role !== 'admin') { return res.status(403).json({ error: 'Admin access required' }); } next(); } // Route permission map: API path prefix -> route key const ROUTE_PERMISSION_MAP = { '/api/feed': 'feed', '/api/subscriptions': 'users', '/api/users': 'users', '/api/download': 'downloads', '/api/gallery': 'gallery', '/api/hls': 'gallery', '/api/scrape': 'scrape', '/api/settings': 'settings', '/api/auth': 'settings', '/api/dashboard': 'dashboard', '/api/health': 'dashboard', '/api/videos': 'videos', '/api/video-hls': 'videos', '/api/drm': null, '/api/media-proxy': null, '/api/me': null, '/api/admin': null, // handled by requireAdmin '/api/app-auth': null, // public }; export function checkRoutePermission(req, res, next) { // Admins bypass all route checks if (req.user?.role === 'admin') return next(); // Find matching route key const path = req.path; let routeKey = undefined; for (const [prefix, key] of Object.entries(ROUTE_PERMISSION_MAP)) { if (path.startsWith(prefix)) { routeKey = key; break; } } // null = always allowed for authenticated users if (routeKey === null || routeKey === undefined) return next(); // Check user's route access const userRoutes = getUserRouteAccess(req.user.id); if (!userRoutes.includes(routeKey)) { return res.status(403).json({ error: 'Access denied' }); } next(); } // --- Public endpoints (no auth required) --- // GET /api/app-auth/status — check if setup is needed router.get('/api/app-auth/status', (req, res) => { const count = getAppUserCount(); res.json({ setupRequired: count === 0 }); }); // POST /api/app-auth/setup — create initial admin (only when 0 users exist) router.post('/api/app-auth/setup', (req, res) => { const count = getAppUserCount(); if (count > 0) { return res.status(400).json({ error: 'Setup already completed' }); } const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ error: 'Username and password required' }); } if (password.length < 4) { return res.status(400).json({ error: 'Password must be at least 4 characters' }); } const hash = bcrypt.hashSync(password, 10); const userId = createAppUser(username.trim(), hash, 'admin', username.trim()); const user = getAppUserById(userId); const token = signToken(userId); setTokenCookie(res, token); res.json({ ok: true, user: userPayload(user, [], null), }); }); // POST /api/app-auth/login — validate creds, set JWT cookie router.post('/api/app-auth/login', (req, res) => { const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ error: 'Username and password required' }); } const user = getAppUserByUsername(username.trim()); if (!user) { return res.status(401).json({ error: 'Invalid username or password' }); } if (!user.enabled) { return res.status(401).json({ error: 'Account is disabled' }); } if (!bcrypt.compareSync(password, user.password_hash)) { return res.status(401).json({ error: 'Invalid username or password' }); } const routes = getUserRouteAccess(user.id); const folders = getUserFolderAccess(user.id); const token = signToken(user.id); setTokenCookie(res, token); res.json({ ok: true, user: userPayload(user, routes, folders), }); }); // POST /api/app-auth/logout — clear JWT cookie router.post('/api/app-auth/logout', (req, res) => { res.clearCookie(TOKEN_COOKIE); res.json({ ok: true }); }); // --- Authenticated endpoints --- // GET /api/app-auth/me — current user + permissions router.get('/api/app-auth/me', requireAuth, (req, res) => { if (!req.user || req.user.id === 0) { return res.json({ setupRequired: true }); } const routes = getUserRouteAccess(req.user.id); const folders = getUserFolderAccess(req.user.id); res.json({ user: userPayload(req.user, routes, folders) }); }); // --- Admin-only endpoints --- // GET /api/admin/users — list all users with permissions router.get('/api/admin/users', requireAuth, requireAdmin, (req, res) => { const users = getAllAppUsers(); const result = users.map((u) => ({ ...u, routes: getUserRouteAccess(u.id), folders: getUserFolderAccess(u.id), })); res.json(result); }); // POST /api/admin/users — create user router.post('/api/admin/users', requireAuth, requireAdmin, (req, res) => { const { username, password, role, display_name, routes, folders } = req.body; if (!username || !password) { return res.status(400).json({ error: 'Username and password required' }); } if (password.length < 4) { return res.status(400).json({ error: 'Password must be at least 4 characters' }); } const existing = getAppUserByUsername(username.trim()); if (existing) { return res.status(400).json({ error: 'Username already exists' }); } const hash = bcrypt.hashSync(password, 10); const userId = createAppUser(username.trim(), hash, role || 'user', display_name || username.trim()); if (routes && Array.isArray(routes)) { setUserRouteAccess(userId, routes); } if (folders && Array.isArray(folders)) { setUserFolderAccess(userId, folders); } const user = getAppUserById(userId); res.json({ ...user, routes: getUserRouteAccess(userId), folders: getUserFolderAccess(userId), }); }); // PUT /api/admin/users/:id — update user router.put('/api/admin/users/:id', requireAuth, requireAdmin, (req, res) => { const id = parseInt(req.params.id, 10); const target = getAppUserById(id); if (!target) { return res.status(404).json({ error: 'User not found' }); } const { username, password, role, display_name, enabled, routes, folders } = req.body; // Prevent demoting/disabling last admin if (target.role === 'admin' && (role === 'user' || enabled === 0)) { const allUsers = getAllAppUsers(); const adminCount = allUsers.filter(u => u.role === 'admin' && u.enabled).length; if (adminCount <= 1) { return res.status(400).json({ error: 'Cannot demote or disable the last admin' }); } } const fields = {}; if (username !== undefined) fields.username = username.trim(); if (display_name !== undefined) fields.display_name = display_name; if (role !== undefined) fields.role = role; if (enabled !== undefined) fields.enabled = enabled; if (password) { if (password.length < 4) { return res.status(400).json({ error: 'Password must be at least 4 characters' }); } fields.password_hash = bcrypt.hashSync(password, 10); } if (Object.keys(fields).length > 0) { updateAppUser(id, fields); } if (routes !== undefined && Array.isArray(routes)) { setUserRouteAccess(id, routes); } if (folders !== undefined && Array.isArray(folders)) { setUserFolderAccess(id, folders); } const updated = getAppUserById(id); res.json({ id: updated.id, username: updated.username, display_name: updated.display_name, role: updated.role, enabled: updated.enabled, created_at: updated.created_at, updated_at: updated.updated_at, routes: getUserRouteAccess(id), folders: getUserFolderAccess(id), }); }); // DELETE /api/admin/users/:id — delete user router.delete('/api/admin/users/:id', requireAuth, requireAdmin, (req, res) => { const id = parseInt(req.params.id, 10); const target = getAppUserById(id); if (!target) { return res.status(404).json({ error: 'User not found' }); } // Cannot delete self if (req.user.id === id) { return res.status(400).json({ error: 'Cannot delete your own account' }); } // Cannot delete last admin if (target.role === 'admin') { const allUsers = getAllAppUsers(); const adminCount = allUsers.filter(u => u.role === 'admin').length; if (adminCount <= 1) { return res.status(400).json({ error: 'Cannot delete the last admin' }); } } deleteAppUser(id); res.json({ ok: true }); }); // GET /api/admin/available-folders — all gallery folders router.get('/api/admin/available-folders', requireAuth, requireAdmin, (req, res) => { const folders = getAllIndexedFolders(); res.json(folders); }); export default router;