249 lines
10 KiB
JavaScript

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.commentsRoutes = commentsRoutes;
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) {
/**
* 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