249 lines
10 KiB
JavaScript
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
|