Replaces single-MP4 + range-request streaming with HLS multi-bitrate
segments to fix video stutter through the Newt tunnel. Range-request
bursts were the root cause; HLS chunks are small and tunnel-friendly,
plus the player adapts bitrate to bandwidth.
Backend
- New BullMQ `hls-transcode` queue (in-process worker, concurrency 1)
- FFmpeg single-pass transcode → 360p/720p/1080p variants with aligned
keyframes; output at /media/local/hls/{id}/master.m3u8
- New /api/{videos|public}/{id}/hls/* routes serving signed manifests
and segments (URLs emitted as /media/* so nginx rewrites to media-api)
- Prisma: HlsStatus enum + 6 fields on Video + index, migration
- Upload + yt-dlp fetch paths enqueue transcode jobs
- ENABLE_HLS_TRANSCODE flag (default off; gates enqueue only)
- Backfill script: `npm run backfill:hls`
- media-api bumped to 4 CPU / 2G for FFmpeg headroom
Frontend
- New useHls hook: lazy-imports hls.js (kept out of main bundle),
native HLS on Safari/iOS, gives up after 2 NETWORK_ERRORs so MP4
fallback engages cleanly
- VideoPlayer, VideoViewerModal, ShortsPage, ProductDetailPage now
prefer HLS when ready; MP4 fallback is automatic
- ShortsPage prefetches next-3 master manifests via <link rel="prefetch">
- PublicVideoCard hover preview stays MP4 (avoids hls.js init latency)
Bunker Admin
359 lines
12 KiB
TypeScript
359 lines
12 KiB
TypeScript
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=<JWT>` 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<boolean> {
|
|
try {
|
|
let userId: string | undefined;
|
|
|
|
const authHeader = request.headers.authorization;
|
|
const query = request.query as Record<string, string | undefined>;
|
|
|
|
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<string, string | undefined>;
|
|
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',
|
|
};
|
|
});
|
|
}
|