import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { createReadStream, stat } from 'fs'; import { access, readFile } from 'fs/promises'; import { join, resolve } 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, getUserRoles } from '../../../utils/roles'; import { signMediaPath, verifyMediaSignature } from '../../../utils/signed-url'; /** * Check if the request is from an authenticated admin user. * Accepts either (1) Bearer JWT or (2) path-scoped signed URL params * (`?sig=&exp=&uid=`). The legacy `?token=` path was removed on * 2026-04-12 — full JWTs in query strings were leaking via access logs * and referer headers. */ async function isAdminRequest(request: FastifyRequest): Promise { try { let userId: string | undefined; const authHeader = request.headers.authorization; const query = request.query as Record; if (authHeader?.startsWith('Bearer ')) { const payload = jwt.verify(authHeader.substring(7), env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as { id: string; role: UserRole; roles?: UserRole[]; }; if (!hasAnyRole(payload, MEDIA_ROLES)) return false; userId = payload.id; } else if (query.sig && query.exp && query.uid) { const result = verifyMediaSignature(request.url, query); if (!result.valid) return false; userId = result.userId; } if (!userId) return false; // Verify user still active AND has media role (signed URLs carry only uid, // so we re-check the role from DB to avoid stale-role privilege escalation). const user = await prisma.user.findUnique({ where: { id: userId }, select: { status: true, role: true, roles: true }, }); if (!user || user.status !== UserStatus.ACTIVE) return false; return hasAnyRole({ role: user.role as UserRole, roles: getUserRoles(user) }, MEDIA_ROLES); } 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 is within allowed media directory const MEDIA_BASE = '/media/local'; const candidatePath = video.path.endsWith(video.filename) ? video.path : join(video.path, video.filename); const filePath = resolve(candidatePath); if (!filePath.startsWith(resolve(MEDIA_BASE))) { logger.warn(`Path traversal attempt detected: ${filePath}`); return reply.code(403).send({ message: 'Access denied' }); } // 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 is within allowed media directory const resolvedThumb = resolve(video.thumbnailPath); if (!resolvedThumb.startsWith(resolve('/media/local'))) { logger.warn(`Path traversal attempt detected: ${resolvedThumb}`); return reply.code(403).send({ message: 'Access denied' }); } // Check file exists try { await access(resolvedThumb); } catch { logger.error(`Thumbnail file not found on disk: ${resolvedThumb}`); return reply.code(404).send({ message: 'Thumbnail file not found' }); } // Determine MIME type const mimeType = lookup(resolvedThumb) || '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; // HLS manifest URL — only present when transcoding has completed. // We emit browser-facing `/media/*` paths (rewritten by nginx to // `/api/*` and proxied to media-api). For admin previews we sign // against the post-rewrite server-side path so the verifier matches. let hlsManifestUrl: string | null = null; if (video.hlsStatus === 'READY' && video.hlsManifestPath) { const clientPath = admin ? `/media/videos/${video.id}/hls/master.m3u8` : `/media/public/${video.id}/hls/master.m3u8`; const serverPath = admin ? `/api/videos/${video.id}/hls/master.m3u8` : `/api/public/${video.id}/hls/master.m3u8`; if (admin) { let uid = 'admin'; try { const authHeader = request.headers.authorization; if (authHeader?.startsWith('Bearer ')) { const payload = jwt.verify( authHeader.substring(7), env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }, ) as { id?: string }; if (payload.id) uid = payload.id; } else { const q = request.query as Record; if (q.uid) uid = q.uid; } } catch { /* keep default */ } const signed = signMediaPath(serverPath, uid, 2 * 60 * 60); hlsManifestUrl = `${clientPath}?sig=${signed.sig}&exp=${signed.exp}&uid=${signed.uid}`; } else { // Public manifest: nginx-rewritten path; the public master route // is unsigned (gated by isPublished + access level on the server). hlsManifestUrl = clientPath; } } // 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, hlsStatus: video.hlsStatus, hlsManifestUrl, 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', }; }); }