e6c0e2292b
Newer versions of jsonwebtoken reject undefined for expiresIn with "expiresIn should be a number of seconds or string representing a timespan". Omit the option entirely when no expiration is desired — cookie maxAge already controls session lifetime. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
357 lines
10 KiB
JavaScript
357 lines
10 KiB
JavaScript
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';
|
|
|
|
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) {
|
|
// No expiration — the cookie's maxAge (~10y) controls session lifetime.
|
|
// Newer jsonwebtoken rejects expiresIn:undefined, so we omit the field.
|
|
return jwt.sign({ userId }, getJwtSecret());
|
|
}
|
|
|
|
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;
|