Authenticate browser media with durable HttpOnly login cookies
Adapted cookie, origin, URL validation and secret handling from MikaelKW/nodecast-tv-plus. Protect API roles, revoke logins across restart, preserve existing accounts, and isolate transcode sessions by account. Co-authored-by: Mikael <mikaelkwessel@gmail.com>
This commit is contained in:
@ -2,6 +2,9 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../db');
|
||||
const auth = require('../auth');
|
||||
const { requestLimit } = require('../services/requestLimits');
|
||||
const authTokens = require('../services/authTokens');
|
||||
const loginLimit = requestLimit(20);
|
||||
|
||||
// Configure Passport strategies
|
||||
auth.configureLocalStrategy(
|
||||
@ -41,8 +44,8 @@ router.get('/oidc/callback',
|
||||
// Successful authentication
|
||||
const token = auth.generateToken(req.user);
|
||||
|
||||
// Redirect to hompage with token
|
||||
res.redirect(`/?token=${token}`);
|
||||
auth.setAuthCookie(req, res, token);
|
||||
req.session.regenerate(error => error ? res.status(500).json({ error: 'Login failed' }) : res.redirect('/'));
|
||||
}
|
||||
);
|
||||
|
||||
@ -52,7 +55,7 @@ router.get('/oidc/callback',
|
||||
*/
|
||||
router.get('/setup-required', async (req, res) => {
|
||||
try {
|
||||
const userCount = await db.users.count();
|
||||
const userCount = (await db.checkHealth()).users.length;
|
||||
res.json({ setupRequired: userCount === 0 });
|
||||
} catch (err) {
|
||||
console.error('Error in /setup-required:', err);
|
||||
@ -64,9 +67,9 @@ router.get('/setup-required', async (req, res) => {
|
||||
* Initial setup - Create admin user
|
||||
* POST /api/auth/setup
|
||||
*/
|
||||
router.post('/setup', async (req, res) => {
|
||||
router.post('/setup', loginLimit, async (req, res) => {
|
||||
try {
|
||||
const userCount = await db.users.count();
|
||||
const userCount = (await db.checkHealth()).users.length;
|
||||
|
||||
// Check if setup already done
|
||||
if (userCount > 0) {
|
||||
@ -75,11 +78,11 @@ router.post('/setup', async (req, res) => {
|
||||
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
if (typeof username !== 'string' || !username.trim() || username.length > 64 || typeof password !== 'string') {
|
||||
return res.status(400).json({ error: 'Username and password required' });
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
if (password.length < 6 || Buffer.byteLength(password) > 72) {
|
||||
return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||
}
|
||||
|
||||
@ -89,10 +92,11 @@ router.post('/setup', async (req, res) => {
|
||||
username,
|
||||
passwordHash,
|
||||
role: 'admin'
|
||||
});
|
||||
}, { initialSetup: true });
|
||||
|
||||
// Generate token for immediate login
|
||||
const token = auth.generateToken(adminUser);
|
||||
auth.setAuthCookie(req, res, token);
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Admin user created successfully',
|
||||
@ -109,7 +113,10 @@ router.post('/setup', async (req, res) => {
|
||||
* Login with Passport Local Strategy
|
||||
* POST /api/auth/login
|
||||
*/
|
||||
router.post('/login', (req, res, next) => {
|
||||
router.post('/login', loginLimit, (req, res, next) => {
|
||||
if (typeof req.body.username !== 'string' || typeof req.body.password !== 'string' || req.body.password.length > 1024) {
|
||||
return res.status(400).json({ error: 'Username and password required' });
|
||||
}
|
||||
auth.passport.authenticate('local', { session: false }, (err, user, info) => {
|
||||
if (err) {
|
||||
console.error('Login error:', err);
|
||||
@ -122,6 +129,7 @@ router.post('/login', (req, res, next) => {
|
||||
|
||||
// Generate JWT token
|
||||
const token = auth.generateToken(user);
|
||||
auth.setAuthCookie(req, res, token);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
@ -139,11 +147,19 @@ router.post('/login', (req, res, next) => {
|
||||
* POST /api/auth/logout
|
||||
*/
|
||||
router.post('/logout', (req, res) => {
|
||||
// With JWT, logout is handled client-side by removing the token
|
||||
// This endpoint exists for consistency and future server-side token blacklisting
|
||||
const payload = auth.verifyToken(auth.extractToken(req));
|
||||
if (payload) authTokens.revoke(payload);
|
||||
auth.clearAuthCookie(req, res);
|
||||
if (req.session) req.session.destroy(() => {});
|
||||
res.json({ success: true, message: 'Logged out successfully' });
|
||||
});
|
||||
|
||||
// Exchange a valid API token for a browser cookie during migration.
|
||||
router.post('/session', auth.requireAuth, (req, res) => {
|
||||
auth.setAuthCookie(req, res, auth.extractToken(req));
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Get current user
|
||||
* GET /api/auth/me
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
const { redactUrl, redactText, validateHttpUrl, FFMPEG_PROTOCOL_WHITELIST } = require('../services/urlSecurity');
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { spawn } = require('child_process');
|
||||
@ -41,6 +42,7 @@ function probeStream(url, ffprobePath, userAgent = null, timeout = 15000) {
|
||||
'-print_format', 'json',
|
||||
'-show_streams',
|
||||
'-show_format',
|
||||
'-protocol_whitelist', FFMPEG_PROTOCOL_WHITELIST,
|
||||
'-probesize', '5000000',
|
||||
'-analyzeduration', '5000000',
|
||||
url
|
||||
@ -150,6 +152,7 @@ function analyzeProbeResult(probeResult, url) {
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const { url, ua } = req.query;
|
||||
try { validateHttpUrl(url); } catch (error) { return res.status(400).json({ error: error.message }); }
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: 'URL parameter is required' });
|
||||
}
|
||||
@ -173,11 +176,11 @@ router.get('/', async (req, res) => {
|
||||
// Check cache
|
||||
const cached = probeCache.get(cacheKey);
|
||||
if (cached && (Date.now() - cached.timestamp < CACHE_TTL)) {
|
||||
console.log(`[Probe] Cache hit for: ${url.substring(0, 50)}...`);
|
||||
console.log(`[Probe] Cache hit for: ${redactUrl(url)}...`);
|
||||
return res.json(cached.result);
|
||||
}
|
||||
|
||||
console.log(`[Probe] Probing: ${url.substring(0, 80)}... ${ua ? `(UA: ${ua})` : ''}`);
|
||||
console.log(`[Probe] Probing: ${redactUrl(url)}... ${ua ? `(UA: ${ua})` : ''}`);
|
||||
|
||||
try {
|
||||
const probeResult = await probeStream(url, ffprobePath, ua);
|
||||
|
||||
@ -1,5 +1,13 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { validateHttpUrl } = require('../services/urlSecurity');
|
||||
router.use((req, res, next) => {
|
||||
if (req.query.url !== undefined) {
|
||||
try { validateHttpUrl(req.query.url); }
|
||||
catch (error) { return res.status(400).json({ error: error.message }); }
|
||||
}
|
||||
next();
|
||||
});
|
||||
const { sources } = require('../db');
|
||||
const { getDb } = require('../db/sqlite'); // Import SQLite
|
||||
const xtreamApi = require('../services/xtreamApi');
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
const { redactUrl, redactText, validateHttpUrl } = require('../services/urlSecurity');
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { spawn } = require('child_process');
|
||||
@ -62,6 +63,7 @@ function validateStreamUrl(raw) {
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
const { url } = req.query;
|
||||
try { validateHttpUrl(url); } catch (error) { return res.status(400).json({ error: error.message }); }
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: 'URL parameter is required' });
|
||||
}
|
||||
@ -84,7 +86,7 @@ router.get('/', async (req, res) => {
|
||||
const settings = await db.settings.get();
|
||||
const userAgent = db.getUserAgent(settings);
|
||||
|
||||
console.log(`[Remux] Starting remux for: ${safeUrl}`);
|
||||
console.log(`[Remux] Starting remux for: ${redactUrl(safeUrl)}`);
|
||||
console.log(`[Remux] Using User-Agent: ${settings.userAgentPreset}`);
|
||||
|
||||
// FFmpeg arguments for pure remux (no encoding)
|
||||
@ -137,13 +139,13 @@ router.get('/', async (req, res) => {
|
||||
'-' // Output to stdout
|
||||
];
|
||||
|
||||
console.log(`[Remux] Full command: ${ffmpegPath} ${args.join(' ')}`);
|
||||
console.log(`[Remux] Full command: ${ffmpegPath} ${redactText(args.join(' '))}`);
|
||||
|
||||
let ffmpeg;
|
||||
try {
|
||||
ffmpeg = spawn(ffmpegPath, args);
|
||||
} catch (spawnErr) {
|
||||
console.error('[Remux] Failed to spawn FFmpeg:', spawnErr);
|
||||
console.error('[Remux] Failed to spawn FFmpeg:', redactText(spawnErr.message));
|
||||
return res.status(500).json({ error: 'FFmpeg spawn failed', details: spawnErr.message });
|
||||
}
|
||||
|
||||
@ -159,7 +161,7 @@ router.get('/', async (req, res) => {
|
||||
const msg = data.toString();
|
||||
// Only log warnings/errors, not progress
|
||||
if (msg.includes('Warning') || msg.includes('Error') || msg.includes('error')) {
|
||||
console.log(`[Remux FFmpeg] ${msg}`);
|
||||
console.log(`[Remux FFmpeg] ${redactText(msg)}`);
|
||||
}
|
||||
});
|
||||
|
||||
@ -178,7 +180,7 @@ router.get('/', async (req, res) => {
|
||||
|
||||
// Handle spawn errors
|
||||
ffmpeg.on('error', (err) => {
|
||||
console.error('[Remux] Failed to spawn FFmpeg:', err);
|
||||
console.error('[Remux] Failed to spawn FFmpeg:', redactText(err.message));
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Remux failed to start' });
|
||||
}
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
const { redactUrl, redactText, validateHttpUrl, FFMPEG_PROTOCOL_WHITELIST } = require('../services/urlSecurity');
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const db = require('../db');
|
||||
const { requireAdmin } = require('../auth');
|
||||
const transcodeSession = require('../services/transcodeSession');
|
||||
|
||||
/**
|
||||
@ -35,12 +37,14 @@ router.post('/session', async (req, res) => {
|
||||
return res.status(400).json({ error: 'URL is required' });
|
||||
}
|
||||
|
||||
try { validateHttpUrl(url); } catch (error) { return res.status(400).json({ error: error.message }); }
|
||||
const ffmpegPath = req.app.locals.ffmpegPath || 'ffmpeg';
|
||||
const settings = await db.settings.get();
|
||||
const userAgent = db.getUserAgent(settings);
|
||||
|
||||
try {
|
||||
const session = await transcodeSession.createSession(url, {
|
||||
ownerId: req.user.id,
|
||||
ffmpegPath,
|
||||
userAgent,
|
||||
seekOffset: seekOffset || 0,
|
||||
@ -76,7 +80,7 @@ router.post('/session', async (req, res) => {
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('[Transcode] Session creation failed:', err);
|
||||
console.error('[Transcode] Session creation failed:', redactText(err.message));
|
||||
res.status(500).json({ error: 'Failed to create session', details: err.message });
|
||||
}
|
||||
});
|
||||
@ -87,7 +91,7 @@ router.post('/session', async (req, res) => {
|
||||
*/
|
||||
router.get('/:sessionId/stream.m3u8', async (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
const session = transcodeSession.getSession(sessionId);
|
||||
const session = transcodeSession.getSession(sessionId, req.user.id);
|
||||
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
@ -115,7 +119,7 @@ router.get('/:sessionId/:segment', async (req, res) => {
|
||||
return res.status(404).json({ error: 'Invalid segment' });
|
||||
}
|
||||
|
||||
const session = transcodeSession.getSession(sessionId);
|
||||
const session = transcodeSession.getSession(sessionId, req.user.id);
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
@ -138,7 +142,8 @@ router.delete('/:sessionId', async (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
|
||||
try {
|
||||
await transcodeSession.removeSession(sessionId);
|
||||
if (!transcodeSession.getSession(sessionId, req.user.id)) return res.status(404).json({ error: 'Session not found' });
|
||||
await transcodeSession.removeSession(sessionId, req.user.id);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to remove session', details: err.message });
|
||||
@ -149,7 +154,7 @@ router.delete('/:sessionId', async (req, res) => {
|
||||
* List all active sessions (for debugging)
|
||||
* GET /api/transcode/sessions
|
||||
*/
|
||||
router.get('/sessions', (req, res) => {
|
||||
router.get('/sessions', requireAdmin, (req, res) => {
|
||||
res.json(transcodeSession.getAllSessions());
|
||||
});
|
||||
|
||||
@ -162,6 +167,7 @@ router.get('/sessions', (req, res) => {
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
const { url } = req.query;
|
||||
try { validateHttpUrl(url); } catch (error) { return res.status(400).json({ error: error.message }); }
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: 'URL parameter is required' });
|
||||
}
|
||||
@ -172,7 +178,7 @@ router.get('/', async (req, res) => {
|
||||
const settings = await db.settings.get();
|
||||
const userAgent = db.getUserAgent(settings);
|
||||
|
||||
console.log(`[Transcode] Starting transcoding for: ${url}`);
|
||||
console.log(`[Transcode] Starting transcoding for: ${redactUrl(url)}`);
|
||||
console.log(`[Transcode] Using User-Agent: ${settings.userAgentPreset}`);
|
||||
console.log(`[Transcode] Using binary: ${ffmpegPath}`);
|
||||
|
||||
@ -182,6 +188,7 @@ router.get('/', async (req, res) => {
|
||||
const args = [
|
||||
'-hide_banner',
|
||||
'-loglevel', 'warning',
|
||||
'-protocol_whitelist', FFMPEG_PROTOCOL_WHITELIST,
|
||||
'-user_agent', userAgent,
|
||||
// Faster startup - reduced probe/analyze for quicker first bytes
|
||||
'-probesize', '2000000', // 2MB (reduced from 5MB)
|
||||
@ -223,13 +230,13 @@ router.get('/', async (req, res) => {
|
||||
'-' // Output to stdout
|
||||
];
|
||||
|
||||
console.log(`[Transcode] Full command: ${ffmpegPath} ${args.join(' ')}`);
|
||||
console.log(`[Transcode] Full command: ${ffmpegPath} ${redactText(args.join(' '))}`);
|
||||
|
||||
let ffmpeg;
|
||||
try {
|
||||
ffmpeg = spawn(ffmpegPath, args);
|
||||
} catch (spawnErr) {
|
||||
console.error('[Transcode] Failed to spawn FFmpeg:', spawnErr);
|
||||
console.error('[Transcode] Failed to spawn FFmpeg:', redactText(spawnErr.message));
|
||||
return res.status(500).json({ error: 'FFmpeg spawn failed', details: spawnErr.message });
|
||||
}
|
||||
|
||||
@ -247,7 +254,7 @@ router.get('/', async (req, res) => {
|
||||
ffmpeg.stderr.on('data', (data) => {
|
||||
const msg = data.toString();
|
||||
stderrBuffer += msg;
|
||||
console.log(`[FFmpeg] ${msg}`);
|
||||
console.log(`[FFmpeg] ${redactText(msg)}`);
|
||||
});
|
||||
|
||||
// Cleanup on client disconnect
|
||||
@ -265,7 +272,7 @@ router.get('/', async (req, res) => {
|
||||
|
||||
// Handle spawn errors
|
||||
ffmpeg.on('error', (err) => {
|
||||
console.error('[Transcode] Failed to spawn FFmpeg:', err);
|
||||
console.error('[Transcode] Failed to spawn FFmpeg:', redactText(err.message));
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Transcoding failed to start' });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user