changemaker.lite/api/src/modules/media/routes/video-streaming.routes.ts
bunker-admin 647efffdc4 Security hardening: JWT algorithm pinning, key separation, injection fixes
- 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
2026-03-22 12:35:04 -06:00

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',
};
});
}