277 lines
11 KiB
JavaScript
277 lines
11 KiB
JavaScript
"use strict";
|
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
};
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.videoStreamingRoutes = videoStreamingRoutes;
|
|
const fs_1 = require("fs");
|
|
const promises_1 = require("fs/promises");
|
|
const path_1 = require("path");
|
|
const mime_types_1 = require("mime-types");
|
|
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
|
|
const client_1 = require("@prisma/client");
|
|
const database_1 = require("../../../config/database");
|
|
const env_1 = require("../../../config/env");
|
|
const logger_1 = require("../../../utils/logger");
|
|
const roles_1 = require("../../../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) {
|
|
try {
|
|
// Extract token from Authorization header (priority) or query param (fallback)
|
|
let token;
|
|
const authHeader = request.headers.authorization;
|
|
if (authHeader?.startsWith('Bearer ')) {
|
|
token = authHeader.substring(7);
|
|
}
|
|
else {
|
|
const query = request.query;
|
|
token = query.token;
|
|
}
|
|
if (!token)
|
|
return false;
|
|
// Verify JWT signature
|
|
const payload = jsonwebtoken_1.default.verify(token, env_1.env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] });
|
|
// Check admin role from token (multi-role aware)
|
|
if (!(0, roles_1.hasAnyRole)(payload, roles_1.MEDIA_ROLES))
|
|
return false;
|
|
// Verify user is still active in DB
|
|
const user = await database_1.prisma.user.findUnique({
|
|
where: { id: payload.id },
|
|
select: { status: true },
|
|
});
|
|
return user?.status === client_1.UserStatus.ACTIVE;
|
|
}
|
|
catch {
|
|
return false;
|
|
}
|
|
}
|
|
/**
|
|
* Parse range header for video seeking
|
|
* Example: "bytes=0-1024" or "bytes=1024-"
|
|
*/
|
|
function parseRange(range, fileSize) {
|
|
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) {
|
|
return new Promise((resolve) => {
|
|
(0, fs_1.stat)(filePath, (err, stats) => {
|
|
if (err) {
|
|
resolve(null);
|
|
}
|
|
else {
|
|
resolve({ size: stats.size });
|
|
}
|
|
});
|
|
});
|
|
}
|
|
async function videoStreamingRoutes(fastify) {
|
|
/**
|
|
* 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, reply) => {
|
|
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 database_1.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_1.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
|
|
: (0, path_1.join)(video.path, video.filename);
|
|
// Check file exists
|
|
try {
|
|
await (0, promises_1.access)(filePath);
|
|
}
|
|
catch {
|
|
logger_1.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 = (0, mime_types_1.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 = (0, fs_1.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 = (0, fs_1.createReadStream)(filePath);
|
|
return reply.send(stream);
|
|
}
|
|
}
|
|
catch (error) {
|
|
logger_1.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, reply) => {
|
|
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 database_1.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_1.logger.warn(`Path traversal attempt detected: ${video.thumbnailPath}`);
|
|
return reply.code(403).send({ message: 'Access denied' });
|
|
}
|
|
// Check file exists
|
|
try {
|
|
await (0, promises_1.access)(video.thumbnailPath);
|
|
}
|
|
catch {
|
|
logger_1.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 = (0, mime_types_1.lookup)(video.thumbnailPath) || 'image/jpeg';
|
|
// Read and send thumbnail
|
|
const thumbnailBuffer = await (0, promises_1.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_1.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, reply) => {
|
|
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 database_1.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_1.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',
|
|
};
|
|
});
|
|
}
|
|
//# sourceMappingURL=video-streaming.routes.js.map
|