Add app auth, dashboard, scheduler, video management, and new scrapers
- JWT-based app authentication with user roles, folder/route access control - Dashboard with storage stats, health checks, and recent activity - Auto-download/scrape scheduler (12h interval) with per-user and per-job configs - Video upload, tagging, HLS transcoding, and detail pages - New scrapers: LeakGallery, Mega (megajs), yt-dlp - FlareSolverr integration for Cloudflare-protected sites - Gallery: advanced filtering (date, size, search), sort modes, equal-mix shuffle - Forum sites management with stored cookies/auth - GridWall/GridCell components for responsive media grid - Media API with folder-access permissions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+355
@@ -0,0 +1,355 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user