Upgrade system finished
This commit is contained in:
@@ -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"}
|
||||
245
api/dist/modules/media/routes/comments.routes.js
vendored
245
api/dist/modules/media/routes/comments.routes.js
vendored
@@ -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
@@ -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"}
|
||||
105
api/dist/modules/media/routes/reactions.routes.js
vendored
105
api/dist/modules/media/routes/reactions.routes.js
vendored
@@ -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
@@ -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"}
|
||||
25
api/dist/modules/media/routes/upload.routes.js
vendored
25
api/dist/modules/media/routes/upload.routes.js
vendored
@@ -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
@@ -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"}
|
||||
@@ -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
@@ -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
@@ -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"}
|
||||
@@ -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
@@ -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"}
|
||||
@@ -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
@@ -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"}
|
||||
18
api/dist/modules/media/routes/videos.routes.js
vendored
18
api/dist/modules/media/routes/videos.routes.js
vendored
@@ -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
Reference in New Issue
Block a user