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:
Trey T
2026-04-16 07:48:10 -05:00
parent 4903b84aef
commit 236f36aae6
54 changed files with 9986 additions and 420 deletions
+355
View File
@@ -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;