- Pin HS256 algorithm on all jwt.verify() calls (9 sites) and jwt.sign() calls (3 sites) — prevents algorithm confusion attacks - Add JWT_INVITE_SECRET env var; volunteer invite tokens now use a dedicated key separate from access/refresh secrets - Remove req.query.secret fallback from Listmonk webhook route — secrets must not appear in nginx access logs - Replace child_process.spawn in email template seed endpoint with direct function import; add require.main guard to seed script - Add sanitizeCsvField() to location CSV export to prevent formula injection in Excel/Sheets (=, +, -, @ prefix → apostrophe prefix) - Cap QR endpoint text input at 2000 chars to prevent DoS via large payloads - Fix pre-existing TS errors: type participantNeeds as UpsertNeedsInput in meeting-planner service; add sso field to UpdateResourcePayload Bunker Admin
319 lines
10 KiB
TypeScript
319 lines
10 KiB
TypeScript
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
|
import { createReadStream, stat } from 'fs';
|
|
import { access, readFile } from 'fs/promises';
|
|
import { join } from 'path';
|
|
import { lookup } from 'mime-types';
|
|
import jwt from 'jsonwebtoken';
|
|
import { UserRole, UserStatus } from '@prisma/client';
|
|
import { prisma } from '../../../config/database';
|
|
import { env } from '../../../config/env';
|
|
import { logger } from '../../../utils/logger';
|
|
import { hasAnyRole, MEDIA_ROLES } from '../../../utils/roles';
|
|
|
|
/**
|
|
* Check if the request is from an authenticated admin user.
|
|
* Supports JWT from Authorization header or ?token= query parameter
|
|
* (needed for <video src> and <img src> which can't send headers).
|
|
*/
|
|
async function isAdminRequest(request: FastifyRequest): Promise<boolean> {
|
|
try {
|
|
// Extract token from Authorization header (priority) or query param (fallback)
|
|
let token: string | undefined;
|
|
const authHeader = request.headers.authorization;
|
|
if (authHeader?.startsWith('Bearer ')) {
|
|
token = authHeader.substring(7);
|
|
} else {
|
|
const query = request.query as Record<string, string | undefined>;
|
|
token = query.token;
|
|
}
|
|
|
|
if (!token) return false;
|
|
|
|
// Verify JWT signature
|
|
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as {
|
|
id: string;
|
|
role: UserRole;
|
|
roles?: UserRole[];
|
|
};
|
|
|
|
// Check admin role from token (multi-role aware)
|
|
if (!hasAnyRole(payload, MEDIA_ROLES)) return false;
|
|
|
|
// Verify user is still active in DB
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: payload.id },
|
|
select: { status: true },
|
|
});
|
|
|
|
return user?.status === UserStatus.ACTIVE;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse range header for video seeking
|
|
* Example: "bytes=0-1024" or "bytes=1024-"
|
|
*/
|
|
function parseRange(range: string, fileSize: number): { start: number; end: number } | null {
|
|
const parts = range.replace(/bytes=/, '').split('-');
|
|
const start = parseInt(parts[0], 10);
|
|
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
|
|
|
if (isNaN(start) || isNaN(end) || start > end || end >= fileSize) {
|
|
return null;
|
|
}
|
|
|
|
return { start, end };
|
|
}
|
|
|
|
/**
|
|
* Get video file stats safely
|
|
*/
|
|
async function getFileStats(filePath: string): Promise<{ size: number } | null> {
|
|
return new Promise((resolve) => {
|
|
stat(filePath, (err, stats) => {
|
|
if (err) {
|
|
resolve(null);
|
|
} else {
|
|
resolve({ size: stats.size });
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function videoStreamingRoutes(fastify: FastifyInstance) {
|
|
/**
|
|
* Stream video file with HTTP range support for seeking
|
|
* GET /api/videos/:id/stream
|
|
* Public endpoint (no auth) - videos are public by default
|
|
*/
|
|
fastify.get(
|
|
'/:id/stream',
|
|
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
|
try {
|
|
const videoId = parseInt(request.params.id);
|
|
|
|
if (isNaN(videoId)) {
|
|
return reply.code(400).send({ message: 'Invalid video ID' });
|
|
}
|
|
|
|
// Admin bypass: skip publication filter for authenticated admin users
|
|
const admin = await isAdminRequest(request);
|
|
const video = await prisma.video.findFirst({
|
|
where: admin
|
|
? { id: videoId }
|
|
: { id: videoId, isPublished: true, isLocked: false },
|
|
});
|
|
|
|
if (!video) {
|
|
return reply.code(404).send({ message: 'Video not found or not published' });
|
|
}
|
|
|
|
// Security: Validate path doesn't contain traversal attempts
|
|
if (video.path.includes('..') || video.filename.includes('..')) {
|
|
logger.warn(`Path traversal attempt detected: ${video.path}/${video.filename}`);
|
|
return reply.code(403).send({ message: 'Access denied' });
|
|
}
|
|
|
|
// Construct full file path
|
|
// Handle both new format (full path) and legacy format (directory only)
|
|
const filePath = video.path.endsWith(video.filename)
|
|
? video.path
|
|
: join(video.path, video.filename);
|
|
|
|
// Check file exists
|
|
try {
|
|
await access(filePath);
|
|
} catch {
|
|
logger.error(`Video file not found on disk: ${filePath}`);
|
|
return reply.code(404).send({ message: 'Video file not found' });
|
|
}
|
|
|
|
// Get file stats
|
|
const stats = await getFileStats(filePath);
|
|
if (!stats) {
|
|
return reply.code(500).send({ message: 'Failed to read video file' });
|
|
}
|
|
|
|
const fileSize = stats.size;
|
|
|
|
// Determine MIME type
|
|
const mimeType = lookup(video.filename) || 'video/mp4';
|
|
|
|
// Handle range requests for seeking
|
|
const rangeHeader = request.headers.range;
|
|
|
|
if (rangeHeader) {
|
|
const range = parseRange(rangeHeader, fileSize);
|
|
|
|
if (!range) {
|
|
return reply.code(416).send({ message: 'Invalid range' });
|
|
}
|
|
|
|
const { start, end } = range;
|
|
const contentLength = end - start + 1;
|
|
|
|
// Set partial content headers
|
|
reply.code(206);
|
|
reply.header('Content-Range', `bytes ${start}-${end}/${fileSize}`);
|
|
reply.header('Accept-Ranges', 'bytes');
|
|
reply.header('Content-Length', contentLength);
|
|
reply.header('Content-Type', mimeType);
|
|
reply.header('Cache-Control', 'public, max-age=31536000'); // 1 year
|
|
|
|
// Stream the requested range
|
|
const stream = createReadStream(filePath, { start, end });
|
|
return reply.send(stream);
|
|
} else {
|
|
// No range header - stream entire file
|
|
reply.header('Content-Length', fileSize);
|
|
reply.header('Content-Type', mimeType);
|
|
reply.header('Accept-Ranges', 'bytes');
|
|
reply.header('Cache-Control', 'public, max-age=31536000'); // 1 year
|
|
|
|
const stream = createReadStream(filePath);
|
|
return reply.send(stream);
|
|
}
|
|
} catch (error) {
|
|
logger.error('Video streaming error:', error);
|
|
return reply.code(500).send({
|
|
message: error instanceof Error ? error.message : 'Failed to stream video',
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
/**
|
|
* Serve video thumbnail
|
|
* GET /api/videos/:id/thumbnail
|
|
* Public endpoint - returns thumbnail image or 404
|
|
*/
|
|
fastify.get(
|
|
'/:id/thumbnail',
|
|
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
|
try {
|
|
const videoId = parseInt(request.params.id);
|
|
|
|
if (isNaN(videoId)) {
|
|
return reply.code(400).send({ message: 'Invalid video ID' });
|
|
}
|
|
|
|
// Admin bypass: skip publication filter for authenticated admin users
|
|
const admin = await isAdminRequest(request);
|
|
const video = await prisma.video.findFirst({
|
|
where: admin
|
|
? { id: videoId }
|
|
: { id: videoId, isPublished: true, isLocked: false },
|
|
});
|
|
|
|
if (!video) {
|
|
return reply.code(404).send({ message: 'Video not found or not published' });
|
|
}
|
|
|
|
// Check if thumbnail exists
|
|
if (!video.thumbnailPath) {
|
|
return reply.code(404).send({ message: 'Thumbnail not found' });
|
|
}
|
|
|
|
// Security: Validate path
|
|
if (video.thumbnailPath.includes('..')) {
|
|
logger.warn(`Path traversal attempt detected: ${video.thumbnailPath}`);
|
|
return reply.code(403).send({ message: 'Access denied' });
|
|
}
|
|
|
|
// Check file exists
|
|
try {
|
|
await access(video.thumbnailPath);
|
|
} catch {
|
|
logger.error(`Thumbnail file not found on disk: ${video.thumbnailPath}`);
|
|
return reply.code(404).send({ message: 'Thumbnail file not found' });
|
|
}
|
|
|
|
// Determine MIME type
|
|
const mimeType = lookup(video.thumbnailPath) || 'image/jpeg';
|
|
|
|
// Read and send thumbnail
|
|
const thumbnailBuffer = await readFile(video.thumbnailPath);
|
|
|
|
reply.header('Content-Type', mimeType);
|
|
reply.header('Content-Length', thumbnailBuffer.length);
|
|
reply.header('Cache-Control', 'public, max-age=31536000'); // 1 year
|
|
|
|
return reply.send(thumbnailBuffer);
|
|
} catch (error) {
|
|
logger.error('Thumbnail serving error:', error);
|
|
return reply.code(500).send({
|
|
message: error instanceof Error ? error.message : 'Failed to serve thumbnail',
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
/**
|
|
* Get public video metadata for embedding
|
|
* GET /api/videos/:id/metadata
|
|
* Public endpoint - returns essential metadata for video players
|
|
*/
|
|
fastify.get(
|
|
'/:id/metadata',
|
|
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
|
try {
|
|
const videoId = parseInt(request.params.id);
|
|
|
|
if (isNaN(videoId)) {
|
|
return reply.code(400).send({ message: 'Invalid video ID' });
|
|
}
|
|
|
|
// Admin bypass: skip publication filter for authenticated admin users
|
|
const admin = await isAdminRequest(request);
|
|
const video = await prisma.video.findFirst({
|
|
where: admin
|
|
? { id: videoId }
|
|
: { id: videoId, isPublished: true, isLocked: false },
|
|
});
|
|
|
|
if (!video) {
|
|
return reply.code(404).send({ message: 'Video not found or not published' });
|
|
}
|
|
|
|
// Construct public URLs (use relative paths for nginx proxy routing)
|
|
const streamUrl = `/media/videos/${video.id}/stream`;
|
|
const thumbnailUrl = video.thumbnailPath
|
|
? `/media/videos/${video.id}/thumbnail`
|
|
: null;
|
|
|
|
// Return public metadata
|
|
return {
|
|
id: video.id,
|
|
title: video.title || video.filename,
|
|
durationSeconds: video.durationSeconds,
|
|
width: video.width,
|
|
height: video.height,
|
|
orientation: video.orientation,
|
|
hasAudio: video.hasAudio,
|
|
quality: video.quality,
|
|
streamUrl,
|
|
thumbnailUrl,
|
|
createdAt: video.createdAt,
|
|
};
|
|
} catch (error) {
|
|
logger.error('Metadata retrieval error:', error);
|
|
return reply.code(500).send({
|
|
message: error instanceof Error ? error.message : 'Failed to retrieve metadata',
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
/**
|
|
* Health check for streaming routes
|
|
*/
|
|
fastify.get('/stream/health', async () => {
|
|
return {
|
|
status: 'ok',
|
|
service: 'video-streaming',
|
|
};
|
|
});
|
|
}
|