"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