Upgrade system finished

This commit is contained in:
2026-03-22 21:47:09 -06:00
parent a71ba20176
commit bb1935027d
299 changed files with 8067 additions and 2758 deletions

View File

@@ -1 +1 @@
{"version":3,"file":"comments.routes.d.ts","sourceRoot":"","sources":["../../../../src/modules/media/routes/comments.routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAG1C,wBAAsB,cAAc,CAAC,OAAO,EAAE,eAAe,iBAE5D"}
{"version":3,"file":"comments.routes.d.ts","sourceRoot":"","sources":["../../../../src/modules/media/routes/comments.routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAkB,MAAM,SAAS,CAAC;AAsB1D,wBAAsB,cAAc,CAAC,OAAO,EAAE,eAAe,iBAwR5D"}

View File

@@ -1,8 +1,249 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.commentsRoutes = commentsRoutes;
// TODO: Implement comments routes
const crypto_1 = require("crypto");
const database_1 = require("../../../config/database");
const chat_stream_routes_js_1 = require("./chat-stream.routes.js");
const auth_1 = require("../middleware/auth");
const word_filter_service_1 = require("../services/word-filter.service");
const chat_notifications_routes_1 = require("./chat-notifications.routes");
// Rate limiting map: userId/sessionId -> array of timestamps
const commentRateLimitMap = new Map();
const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
const RATE_LIMIT_MAX = 5; // 5 comments per minute
async function commentsRoutes(fastify) {
// Placeholder - no routes yet
/**
* GET /public/:id/comments
* List comments for a video (non-hidden, paginated)
*/
fastify.get('/public/:id/comments', async (request, reply) => {
try {
const videoId = parseInt(request.params.id, 10);
const limit = parseInt(request.query.limit || '50', 10);
const offset = parseInt(request.query.offset || '0', 10);
if (isNaN(videoId)) {
return reply.code(400).send({ message: 'Invalid video ID' });
}
// Fetch comments with user info (if userId is set)
const comments = await database_1.prisma.comment.findMany({
where: {
mediaId: videoId,
isHidden: false,
},
include: {
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
orderBy: {
createdAt: 'asc',
},
take: limit,
skip: offset,
});
// Transform for frontend
const transformedComments = comments.map((comment) => ({
id: comment.id,
content: comment.content,
createdAt: comment.createdAt.toISOString(),
safetyStatus: comment.safetyStatus,
safetyCategories: comment.safetyCategories,
user: comment.user
? {
id: comment.user.id,
name: comment.user.name || 'Anonymous',
}
: null,
}));
return reply.send({
comments: transformedComments,
total: await database_1.prisma.comment.count({
where: { mediaId: videoId, isHidden: false },
}),
});
}
catch (error) {
console.error('Failed to fetch comments:', error);
return reply.code(500).send({ message: 'Failed to fetch comments' });
}
});
/**
* POST /public/:id/comments
* Create a new comment (rate limited, requires session or auth)
*/
fastify.post('/public/:id/comments', async (request, reply) => {
// Optionally authenticate (attaches request.user if Bearer token present)
await (0, auth_1.optionalAuth)(request, reply);
try {
const videoId = parseInt(request.params.id, 10);
const { content } = request.body;
if (isNaN(videoId)) {
return reply.code(400).send({ message: 'Invalid video ID' });
}
if (!content || content.trim().length === 0) {
return reply.code(400).send({ message: 'Comment content is required' });
}
if (content.length > 1000) {
return reply.code(400).send({
message: 'Comment must be 1000 characters or less',
});
}
// Get session ID from X-Session-ID header (set by frontend)
let sessionId = request.headers['x-session-id'];
let userId = null;
// Check if user is authenticated (from optionalAuth preHandler)
if (request.user) {
userId = request.user.id;
}
// If no session ID from header, generate one
if (!sessionId) {
sessionId = (0, crypto_1.randomUUID)();
}
// Ensure session record exists
await database_1.prisma.session.upsert({
where: { id: sessionId },
update: {},
create: {
id: sessionId,
ipAddress: request.ip,
userAgent: request.headers['user-agent'] || '',
},
});
// Rate limiting check — use IP for anonymous users to prevent header-based bypass
const rateLimitKey = userId || `ip:${request.ip}`;
const now = Date.now();
const timestamps = commentRateLimitMap.get(rateLimitKey) || [];
const recentTimestamps = timestamps.filter((ts) => now - ts < RATE_LIMIT_WINDOW);
if (recentTimestamps.length >= RATE_LIMIT_MAX) {
return reply.code(429).send({
message: `Rate limit exceeded. Maximum ${RATE_LIMIT_MAX} comments per minute.`,
});
}
recentTimestamps.push(now);
commentRateLimitMap.set(rateLimitKey, recentTimestamps);
// Run word filter check
const filterResult = await (0, word_filter_service_1.checkContent)(content.trim());
// High-severity words: block submission entirely
if (filterResult.blocked) {
return reply.code(400).send({
message: 'Your comment contains content that is not allowed.',
});
}
// Determine safety status and hidden state based on filter result
let safetyStatus = 'pending';
let isHidden = false;
let hiddenReason = null;
if (filterResult.autoHide) {
// Medium-severity: save but auto-hide
safetyStatus = 'flagged';
isHidden = true;
hiddenReason = 'word_filter';
}
else if (filterResult.flagged) {
// Low-severity: visible but flagged for review
safetyStatus = 'flagged';
}
// Create comment
const newComment = await database_1.prisma.comment.create({
data: {
mediaId: videoId,
sessionId,
userId,
content: content.trim(),
safetyStatus,
isHidden,
hiddenReason,
safetyReasoning: filterResult.reason || null,
safetyCategories: filterResult.matchedWords.length > 0
? filterResult.matchedWords
: undefined,
},
include: {
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
});
// Broadcast to SSE subscribers (only if not hidden)
const broadcastData = {
id: newComment.id,
content: newComment.content,
createdAt: newComment.createdAt.toISOString(),
safetyStatus: newComment.safetyStatus,
user: newComment.user
? {
id: newComment.user.id,
name: newComment.user.name || 'Anonymous',
}
: null,
};
if (!isHidden) {
(0, chat_stream_routes_js_1.broadcastCommentToVideo)(videoId, broadcastData);
// Notify other users who commented on this video
try {
const otherCommenters = await database_1.prisma.comment.findMany({
where: {
mediaId: videoId,
userId: { not: null, ...(userId ? { not: userId } : {}) },
},
select: { userId: true },
distinct: ['userId'],
});
const video = await database_1.prisma.video.findUnique({
where: { id: videoId },
select: { filename: true },
});
const commenterName = newComment.user?.name || 'Someone';
const contentPreview = content.trim().length > 80
? content.trim().substring(0, 80) + '...'
: content.trim();
for (const { userId: targetUserId } of otherCommenters) {
if (targetUserId) {
(0, chat_notifications_routes_1.notifyUser)(targetUserId, {
type: 'chat_reply',
videoId,
videoTitle: video?.filename || `Video #${videoId}`,
commentId: newComment.id,
commenterName,
contentPreview,
});
}
}
}
catch (notifyErr) {
// Non-critical: don't fail the comment creation
console.error('Failed to send chat notifications:', notifyErr);
}
}
return reply.code(201).send(broadcastData);
}
catch (error) {
console.error('Failed to create comment:', error);
return reply.code(500).send({ message: 'Failed to create comment' });
}
});
/**
* Cleanup rate limit map periodically (every 5 minutes)
*/
setInterval(() => {
const now = Date.now();
for (const [key, timestamps] of commentRateLimitMap.entries()) {
const recentTimestamps = timestamps.filter((ts) => now - ts < RATE_LIMIT_WINDOW);
if (recentTimestamps.length === 0) {
commentRateLimitMap.delete(key);
}
else {
commentRateLimitMap.set(key, recentTimestamps);
}
}
}, 5 * 60 * 1000);
}
//# sourceMappingURL=comments.routes.js.map

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"version":3,"file":"reactions.routes.d.ts","sourceRoot":"","sources":["../../../../src/modules/media/routes/reactions.routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAgC,MAAM,SAAS,CAAC;AAsCxE,wBAAsB,eAAe,CAAC,OAAO,EAAE,eAAe,iBAgG7D"}
{"version":3,"file":"reactions.routes.d.ts","sourceRoot":"","sources":["../../../../src/modules/media/routes/reactions.routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AA2C1C,wBAAsB,eAAe,CAAC,OAAO,EAAE,eAAe,iBA2M7D"}

View File

@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.reactionsRoutes = reactionsRoutes;
const database_1 = require("../../../config/database");
const auth_1 = require("../middleware/auth");
const chat_stream_routes_js_1 = require("./chat-stream.routes.js");
// Rebranded reaction emojis (6 standard social reactions)
const REACTION_EMOJIS = {
like: '👍',
@@ -12,6 +13,9 @@ const REACTION_EMOJIS = {
sad: '😢',
angry: '😠',
};
// Cooldown tracking: userId+type -> lastTimestamp
const reactionCooldowns = new Map();
const COOLDOWN_SECONDS = 30;
// Format video timestamp as MM:SS or H:MM:SS
function formatVideoTime(seconds) {
const h = Math.floor(seconds / 3600);
@@ -33,6 +37,20 @@ async function reactionsRoutes(fastify) {
if (!REACTION_EMOJIS[reactionType]) {
return reply.code(400).send({ message: 'Invalid reaction type' });
}
// Check cooldown
const cooldownKey = `${userId}:${reactionType}`;
const now = Date.now();
const lastReactionTime = reactionCooldowns.get(cooldownKey);
if (lastReactionTime) {
const timeSinceLastReaction = (now - lastReactionTime) / 1000;
if (timeSinceLastReaction < COOLDOWN_SECONDS) {
const cooldownRemaining = Math.ceil(COOLDOWN_SECONDS - timeSinceLastReaction);
return reply.code(429).send({
message: `Please wait ${cooldownRemaining}s before reacting again`,
cooldownEndsAt: new Date(lastReactionTime + COOLDOWN_SECONDS * 1000).toISOString(),
});
}
}
// Check if video exists
const video = await database_1.prisma.video.findUnique({
where: { id: mediaId },
@@ -40,6 +58,8 @@ async function reactionsRoutes(fastify) {
if (!video) {
return reply.code(404).send({ message: 'Video not found' });
}
// Update cooldown
reactionCooldowns.set(cooldownKey, now);
// Create reaction
const reaction = await database_1.prisma.videoReaction.create({
data: {
@@ -49,14 +69,35 @@ async function reactionsRoutes(fastify) {
videoTimestamp,
createdAt: new Date(),
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
// Broadcast to SSE subscribers
const broadcastData = {
id: reaction.id,
reactionType: reaction.reactionType,
emoji: REACTION_EMOJIS[reactionType],
videoTimestamp: reaction.videoTimestamp,
formattedTime: formatVideoTime(videoTimestamp),
createdAt: reaction.createdAt.toISOString(),
user: reaction.user
? {
id: reaction.user.id,
name: reaction.user.name || reaction.user.email,
}
: null,
};
(0, chat_stream_routes_js_1.broadcastReactionToVideo)(mediaId, broadcastData);
return {
success: true,
reaction: {
...reaction,
emoji: REACTION_EMOJIS[reactionType],
formattedTime: formatVideoTime(videoTimestamp),
},
reaction: broadcastData,
};
});
// Get reactions
@@ -98,5 +139,59 @@ async function reactionsRoutes(fastify) {
})),
};
});
// Get reactions for chat timeline (non-aggregated, for display in chat)
fastify.get('/:mediaId/chat', async (request, reply) => {
const mediaId = parseInt(request.params.mediaId, 10);
const limit = parseInt(request.query.limit || '500', 10);
if (isNaN(mediaId)) {
return reply.code(400).send({ message: 'Invalid media ID' });
}
const reactions = await database_1.prisma.videoReaction.findMany({
where: { mediaId },
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: 'asc',
},
take: limit,
});
// Transform for chat timeline
const transformedReactions = reactions.map((r) => ({
id: r.id,
type: 'reaction',
reactionType: r.reactionType,
emoji: REACTION_EMOJIS[r.reactionType] || '❓',
videoTimestamp: r.videoTimestamp,
formattedTime: formatVideoTime(r.videoTimestamp),
createdAt: r.createdAt.toISOString(),
user: r.user
? {
id: r.user.id,
name: r.user.name || r.user.email,
}
: null,
}));
return {
reactions: transformedReactions,
};
});
/**
* Cleanup cooldown map periodically (every 5 minutes)
*/
setInterval(() => {
const now = Date.now();
for (const [key, timestamp] of reactionCooldowns.entries()) {
if (now - timestamp > COOLDOWN_SECONDS * 1000) {
reactionCooldowns.delete(key);
}
}
}, 5 * 60 * 1000);
}
//# sourceMappingURL=reactions.routes.js.map

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"version":3,"file":"upload.routes.d.ts","sourceRoot":"","sources":["../../../../src/modules/media/routes/upload.routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAgC,MAAM,SAAS,CAAC;AAgSxE,wBAAsB,YAAY,CAAC,OAAO,EAAE,eAAe,iBAkB1D"}
{"version":3,"file":"upload.routes.d.ts","sourceRoot":"","sources":["../../../../src/modules/media/routes/upload.routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAgC,MAAM,SAAS,CAAC;AAmSxE,wBAAsB,YAAY,CAAC,OAAO,EAAE,eAAe,iBAkB1D"}

View File

@@ -38,7 +38,18 @@ async function uploadVideo(request, reply) {
message: `Invalid file type. Allowed: ${ALLOWED_EXTENSIONS.join(', ')}`,
});
}
// Extract metadata fields from form data
// Generate unique filename
const filename = `${(0, crypto_1.randomUUID)()}${ext}`;
const inboxDir = '/media/local/inbox';
// Ensure inbox directory exists
await (0, promises_2.mkdir)(inboxDir, { recursive: true });
const filePath = (0, path_1.join)(inboxDir, filename);
tempFilePath = filePath;
// Stream file to disk (must consume file stream BEFORE reading metadata fields,
// because Fastify multipart/busboy only makes fields available after the file stream ends)
logger_1.logger.info(`Uploading video to ${filePath}`);
await (0, promises_1.pipeline)(data.file, (0, fs_1.createWriteStream)(filePath));
// Extract metadata fields from form data (now available after file stream consumed)
const metadataFields = data.fields;
const metadata = {
title: metadataFields.title?.value,
@@ -47,16 +58,6 @@ async function uploadVideo(request, reply) {
};
// Validate metadata
const validatedMetadata = UploadMetadataSchema.parse(metadata);
// Generate unique filename
const filename = `${(0, crypto_1.randomUUID)()}${ext}`;
const inboxDir = '/media/local/inbox';
// Ensure inbox directory exists
await (0, promises_2.mkdir)(inboxDir, { recursive: true });
const filePath = (0, path_1.join)(inboxDir, filename);
tempFilePath = filePath;
// Stream file to disk
logger_1.logger.info(`Uploading video to ${filePath}`);
await (0, promises_1.pipeline)(data.file, (0, fs_1.createWriteStream)(filePath));
// Validate video file
logger_1.logger.info(`Validating video file: ${filePath}`);
const isValid = await (0, ffprobe_service_1.validateVideoFile)(filePath);
@@ -83,6 +84,7 @@ async function uploadVideo(request, reply) {
fileSize: videoMetadata.fileSize,
directoryType: 'inbox',
isValid: true,
isShort: videoMetadata.durationSeconds != null && videoMetadata.durationSeconds <= 60,
producer: validatedMetadata.producer || null,
creator: validatedMetadata.creator || null,
},
@@ -192,6 +194,7 @@ async function uploadBatch(request, reply) {
fileSize: videoMetadata.fileSize,
directoryType: 'inbox',
isValid: true,
isShort: videoMetadata.durationSeconds != null && videoMetadata.durationSeconds <= 60,
},
});
// Generate thumbnail

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"version":3,"file":"video-actions.routes.d.ts","sourceRoot":"","sources":["../../../../src/modules/media/routes/video-actions.routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAgC,MAAM,SAAS,CAAC;AAUxE,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,eAAe,iBAgUhE"}
{"version":3,"file":"video-actions.routes.d.ts","sourceRoot":"","sources":["../../../../src/modules/media/routes/video-actions.routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAuB1C,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,eAAe,iBA8ahE"}

View File

@@ -7,7 +7,76 @@ const video_analytics_service_1 = require("../services/video-analytics.service")
const logger_1 = require("../../../utils/logger");
const jsonwebtoken_1 = require("jsonwebtoken");
const env_1 = require("../../../config/env");
const path_1 = require("path");
const zod_1 = require("zod");
const UpdateVideoSchema = zod_1.z.object({
title: zod_1.z.string().min(1).max(500).optional(),
producer: zod_1.z.string().max(200).nullable().optional(),
creator: zod_1.z.string().max(200).nullable().optional(),
category: zod_1.z.enum(['videos', 'curated', 'compilations', 'playback', 'highlights']).nullable().optional(),
tags: zod_1.z.array(zod_1.z.string().max(100)).max(50).nullable().optional(),
quality: zod_1.z.string().max(50).nullable().optional(),
position: zod_1.z.number().int().min(0).nullable().optional(),
isShort: zod_1.z.boolean().optional(),
accessLevel: zod_1.z.enum(['free', 'member', 'premium']).optional(),
});
async function videoActionsRoutes(fastify) {
/**
* PATCH /videos/:id
* Update video metadata
*/
fastify.patch('/:id', {
preHandler: auth_1.requireAdminRole,
}, async (request, reply) => {
const videoId = parseInt(request.params.id);
const parseResult = UpdateVideoSchema.safeParse(request.body);
if (!parseResult.success) {
return reply.code(400).send({ message: 'Invalid input', errors: parseResult.error.errors });
}
try {
const video = await database_1.prisma.video.findUnique({ where: { id: videoId } });
if (!video) {
return reply.code(404).send({ message: 'Video not found' });
}
const data = {};
const updates = parseResult.data;
if (updates.title !== undefined)
data.title = updates.title;
if (updates.producer !== undefined)
data.producer = updates.producer;
if (updates.creator !== undefined)
data.creator = updates.creator;
if (updates.category !== undefined)
data.category = updates.category;
if (updates.tags !== undefined)
data.tags = updates.tags;
if (updates.quality !== undefined)
data.quality = updates.quality;
if (updates.position !== undefined)
data.position = updates.position;
if (updates.isShort !== undefined)
data.isShort = updates.isShort;
if (updates.accessLevel !== undefined)
data.accessLevel = updates.accessLevel;
const updatedVideo = await database_1.prisma.video.update({
where: { id: videoId },
data,
});
logger_1.logger.info(`Updated video ${videoId} metadata`, { fields: Object.keys(data) });
return {
success: true,
video: {
...updatedVideo,
duration: updatedVideo.durationSeconds,
thumbnailUrl: updatedVideo.thumbnailPath ? `/media/videos/${updatedVideo.id}/thumbnail` : null,
},
};
}
catch (error) {
logger_1.logger.error('Failed to update video', { error, videoId });
return reply.code(500).send({ message: 'Failed to update video' });
}
});
/**
* POST /videos/:id/duplicate
* Duplicate a video with a new title
@@ -67,11 +136,33 @@ async function videoActionsRoutes(fastify) {
* Replace video file while keeping metadata and URL
* Note: This endpoint accepts a new file path - actual file upload should go through upload routes
*/
const ReplaceVideoSchema = zod_1.z.object({
newPath: zod_1.z.string().min(1).max(500),
newFilename: zod_1.z.string().min(1).max(255),
durationSeconds: zod_1.z.number().optional(),
width: zod_1.z.number().int().optional(),
height: zod_1.z.number().int().optional(),
fileSize: zod_1.z.number().optional(),
});
fastify.post('/:id/replace', {
preHandler: auth_1.requireAdminRole,
}, async (request, reply) => {
const videoId = parseInt(request.params.id);
const { newPath, newFilename, durationSeconds, width, height, fileSize } = request.body;
// Validate input with Zod
const parseResult = ReplaceVideoSchema.safeParse(request.body);
if (!parseResult.success) {
return reply.code(400).send({ message: 'Invalid input' });
}
const { newPath, newFilename, durationSeconds, width, height, fileSize } = parseResult.data;
// Path traversal protection
if (newPath.includes('\0') || newFilename.includes('\0')) {
return reply.code(400).send({ message: 'Invalid file path' });
}
const normalizedPath = (0, path_1.normalize)(newPath);
if (normalizedPath.includes('..') || normalizedPath.startsWith('/') || normalizedPath.startsWith('\\')) {
return reply.code(400).send({ message: 'Invalid file path: must be relative with no traversal' });
}
const sanitizedFilename = (0, path_1.basename)(newFilename);
try {
const existingVideo = await database_1.prisma.video.findUnique({
where: { id: videoId },
@@ -83,8 +174,8 @@ async function videoActionsRoutes(fastify) {
const updatedVideo = await database_1.prisma.video.update({
where: { id: videoId },
data: {
path: newPath,
filename: newFilename,
path: normalizedPath,
filename: sanitizedFilename,
originalPath: existingVideo.path, // Save old path for reference
originalFilename: existingVideo.filename,
durationSeconds: durationSeconds || existingVideo.durationSeconds,
@@ -172,7 +263,7 @@ async function videoActionsRoutes(fastify) {
videoId,
purpose: 'preview',
}, env_1.env.JWT_ACCESS_SECRET, { expiresIn: `${expiryHours}h` });
const previewUrl = `${env_1.env.MEDIA_API_URL}/api/videos/${videoId}/preview?token=${token}`;
const previewUrl = `${env_1.env.MEDIA_API_PUBLIC_URL}/api/videos/${videoId}/preview?token=${token}`;
logger_1.logger.info(`Generated preview link for video ${videoId}`, { expiresInHours: expiryHours });
return {
previewUrl,
@@ -244,5 +335,37 @@ async function videoActionsRoutes(fastify) {
return reply.code(500).send({ message: 'Failed to fetch analytics overview' });
}
});
/**
* POST /videos/bulk-access-level
* Set access level on multiple videos at once
*/
fastify.post('/bulk-access-level', {
preHandler: auth_1.requireAdminRole,
}, async (request, reply) => {
const schema = zod_1.z.object({
videoIds: zod_1.z.array(zod_1.z.number().int()).min(1).max(500),
accessLevel: zod_1.z.enum(['free', 'member', 'premium']),
});
const parseResult = schema.safeParse(request.body);
if (!parseResult.success) {
return reply.code(400).send({ message: 'Invalid input', errors: parseResult.error.errors });
}
const { videoIds, accessLevel } = parseResult.data;
try {
const result = await database_1.prisma.video.updateMany({
where: { id: { in: videoIds } },
data: { accessLevel },
});
logger_1.logger.info(`Bulk updated access level to "${accessLevel}" for ${result.count} videos`, { videoIds });
return {
success: true,
updatedCount: result.count,
};
}
catch (error) {
logger_1.logger.error('Failed to bulk update access level', { error, videoIds });
return reply.code(500).send({ message: 'Failed to update access levels' });
}
});
}
//# sourceMappingURL=video-actions.routes.js.map

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"version":3,"file":"video-schedule.routes.d.ts","sourceRoot":"","sources":["../../../../src/modules/media/routes/video-schedule.routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAgC,MAAM,SAAS,CAAC;AAkBxE,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,eAAe,iBA0VjE"}
{"version":3,"file":"video-schedule.routes.d.ts","sourceRoot":"","sources":["../../../../src/modules/media/routes/video-schedule.routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAkB1C,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,eAAe,iBA2UjE"}

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"version":3,"file":"video-streaming.routes.d.ts","sourceRoot":"","sources":["../../../../src/modules/media/routes/video-streaming.routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAgC,MAAM,SAAS,CAAC;AAuCxE,wBAAsB,oBAAoB,CAAC,OAAO,EAAE,eAAe,iBAiOlE"}
{"version":3,"file":"video-streaming.routes.d.ts","sourceRoot":"","sources":["../../../../src/modules/media/routes/video-streaming.routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAgC,MAAM,SAAS,CAAC;AAoFxE,wBAAsB,oBAAoB,CAAC,OAAO,EAAE,eAAe,iBAyOlE"}

View File

@@ -1,12 +1,54 @@
"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-"
@@ -47,12 +89,15 @@ async function videoStreamingRoutes(fastify) {
if (isNaN(videoId)) {
return reply.code(400).send({ message: 'Invalid video ID' });
}
// Fetch video from database
const video = await database_1.prisma.video.findUnique({
where: { id: videoId },
// 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' });
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('..')) {
@@ -128,12 +173,15 @@ async function videoStreamingRoutes(fastify) {
if (isNaN(videoId)) {
return reply.code(400).send({ message: 'Invalid video ID' });
}
// Fetch video from database
const video = await database_1.prisma.video.findUnique({
where: { id: videoId },
// 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' });
return reply.code(404).send({ message: 'Video not found or not published' });
}
// Check if thumbnail exists
if (!video.thumbnailPath) {
@@ -179,18 +227,20 @@ async function videoStreamingRoutes(fastify) {
if (isNaN(videoId)) {
return reply.code(400).send({ message: 'Invalid video ID' });
}
// Fetch video from database
const video = await database_1.prisma.video.findUnique({
where: { id: videoId },
// 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' });
return reply.code(404).send({ message: 'Video not found or not published' });
}
// Construct public URLs
const baseUrl = process.env.MEDIA_API_PUBLIC_URL || 'http://localhost:4100';
const streamUrl = `${baseUrl}/api/videos/${video.id}/stream`;
// Construct public URLs (use relative paths for nginx proxy routing)
const streamUrl = `/media/videos/${video.id}/stream`;
const thumbnailUrl = video.thumbnailPath
? `${baseUrl}/api/videos/${video.id}/thumbnail`
? `/media/videos/${video.id}/thumbnail`
: null;
// Return public metadata
return {

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"version":3,"file":"video-tracking.routes.d.ts","sourceRoot":"","sources":["../../../../src/modules/media/routes/video-tracking.routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAgC,MAAM,SAAS,CAAC;AA8BxE,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,eAAe,iBAqPjE"}
{"version":3,"file":"video-tracking.routes.d.ts","sourceRoot":"","sources":["../../../../src/modules/media/routes/video-tracking.routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAwB1C,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,eAAe,iBAkOjE"}

View File

@@ -5,11 +5,6 @@ const auth_1 = require("../middleware/auth");
const video_analytics_service_1 = require("../services/video-analytics.service");
const logger_1 = require("../../../utils/logger");
const zod_1 = require("zod");
// Rate limiting: 100 requests per minute per IP for tracking
const trackingRateLimit = {
max: 100,
timeWindow: '1 minute',
};
// Validation schemas
const recordViewSchema = zod_1.z.object({
videoId: zod_1.z.number(),
@@ -33,9 +28,6 @@ async function videoTrackingRoutes(fastify) {
*/
fastify.post('/view', {
preHandler: auth_1.optionalAuth,
config: {
rateLimit: trackingRateLimit,
},
}, async (request, reply) => {
const { videoId, referer } = request.body;
// Validate input
@@ -78,11 +70,7 @@ async function videoTrackingRoutes(fastify) {
* Record a video event (play, pause, seek, complete)
* Public endpoint - no auth required
*/
fastify.post('/event', {
config: {
rateLimit: trackingRateLimit,
},
}, async (request, reply) => {
fastify.post('/event', async (request, reply) => {
const { videoId, viewId, eventType, timestamp } = request.body;
// Validate input
try {
@@ -115,14 +103,7 @@ async function videoTrackingRoutes(fastify) {
* Update watch time for a view (called every 10 seconds during playback)
* Public endpoint - no auth required
*/
fastify.post('/heartbeat', {
config: {
rateLimit: {
max: 200, // Higher limit for heartbeats (every 10s)
timeWindow: '1 minute',
},
},
}, async (request, reply) => {
fastify.post('/heartbeat', async (request, reply) => {
const { viewId, watchTimeSeconds } = request.body;
// Validate input
try {
@@ -150,14 +131,7 @@ async function videoTrackingRoutes(fastify) {
* Batch record multiple events (useful for reducing requests)
* Public endpoint - no auth required
*/
fastify.post('/batch', {
config: {
rateLimit: {
max: 50,
timeWindow: '1 minute',
},
},
}, async (request, reply) => {
fastify.post('/batch', async (request, reply) => {
const { events } = request.body;
if (!Array.isArray(events) || events.length === 0) {
return reply.code(400).send({ message: 'Events array is required' });
@@ -166,6 +140,22 @@ async function videoTrackingRoutes(fastify) {
return reply.code(400).send({ message: 'Maximum 50 events per batch' });
}
try {
// Validate each event's data against per-type schemas before processing
for (const event of events) {
switch (event.type) {
case 'view':
recordViewSchema.parse(event.data);
break;
case 'event':
recordEventSchema.parse(event.data);
break;
case 'heartbeat':
updateWatchTimeSchema.parse(event.data);
break;
default:
return reply.code(400).send({ message: `Unknown event type: ${event.type}` });
}
}
const results = await Promise.allSettled(events.map(async (event) => {
switch (event.type) {
case 'view':

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"version":3,"file":"videos.routes.d.ts","sourceRoot":"","sources":["../../../../src/modules/media/routes/videos.routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAgC,MAAM,SAAS,CAAC;AAmBxE,wBAAsB,YAAY,CAAC,OAAO,EAAE,eAAe,iBAoc1D"}
{"version":3,"file":"videos.routes.d.ts","sourceRoot":"","sources":["../../../../src/modules/media/routes/videos.routes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAoB1C,wBAAsB,YAAY,CAAC,OAAO,EAAE,eAAe,iBA2c1D"}

View File

@@ -17,8 +17,12 @@ async function videosRoutes(fastify) {
const search = request.query.search;
const orientation = request.query.orientation;
const producers = request.query.producers?.split(',').filter(Boolean);
const isShort = request.query.isShort;
// Build Prisma WHERE clause
const where = {};
if (isShort !== undefined) {
where.isShort = isShort === 'true';
}
if (search) {
where.title = {
contains: search,
@@ -52,6 +56,8 @@ async function videosRoutes(fastify) {
scheduledPublishAt: true,
scheduledUnpublishAt: true,
category: true,
isShort: true,
accessLevel: true,
},
orderBy: {
createdAt: 'desc',
@@ -64,7 +70,7 @@ async function videosRoutes(fastify) {
// Map videos to include thumbnailUrl
const videosWithThumbnails = videos.map((video) => ({
...video,
duration: video.durationSeconds, // Add duration alias for frontend
duration: video.durationSeconds ?? 0, // Add duration alias for frontend
thumbnailUrl: video.thumbnailPath ? `/media/videos/${video.id}/thumbnail` : null,
}));
return {
@@ -88,7 +94,7 @@ async function videosRoutes(fastify) {
return {
video: {
...video,
duration: video.durationSeconds,
duration: video.durationSeconds ?? 0,
thumbnailUrl: video.thumbnailPath ? `/media/videos/${video.id}/thumbnail` : null,
},
};
@@ -288,8 +294,8 @@ async function videosRoutes(fastify) {
const thumbnailPath = await thumbnail_service_1.ThumbnailService.generateThumbnail({
videoPath: video.path,
videoId: video.id,
duration: video.durationSeconds,
orientation: video.orientation,
duration: video.durationSeconds ?? 0,
orientation: video.orientation ?? '',
});
// Update video with thumbnail path
const updatedVideo = await database_1.prisma.video.update({
@@ -335,8 +341,8 @@ async function videosRoutes(fastify) {
const thumbnailPath = await thumbnail_service_1.ThumbnailService.generateThumbnail({
videoPath: video.path,
videoId: video.id,
duration: video.durationSeconds,
orientation: video.orientation,
duration: video.durationSeconds ?? 0,
orientation: video.orientation ?? '',
});
// Update video with thumbnail path
await database_1.prisma.video.update({

File diff suppressed because one or more lines are too long