const express = require("express"); const router = express.Router(); const fs = require("fs").promises; const path = require("path"); const axios = require("axios"); const jwt = require("jsonwebtoken"); const { getPool } = require("./mysqlPool"); const { verifyAccessKey } = require("./checkVip"); // Réutiliser l'instance Redis partagée au lieu d'en créer une nouvelle (évite les fuites mémoire) const { redis } = require("./config/redis"); const { verifyTurnstileFromRequest } = require("./utils/turnstile"); const webpush = require("web-push"); // === Web Push VAPID config === const VAPID_CONFIGURED = !!(process.env.VAPID_PUBLIC_KEY && process.env.VAPID_PRIVATE_KEY); if (VAPID_CONFIGURED) { webpush.setVapidDetails( process.env.VAPID_SUBJECT || "mailto:contact@movix.blog", process.env.VAPID_PUBLIC_KEY, process.env.VAPID_PRIVATE_KEY ); } else { console.warn("⚠️ VAPID keys not configured — push notifications disabled"); } // === #15: JWT_SECRET chargé une seule fois au démarrage === const JWT_SECRET = process.env.JWT_SECRET; // === Vérification de bannissement === async function checkBan(userId, userType, ip) { const now = Date.now(); // Vérifier ban par user_id const userBan = await dbGet( `SELECT * FROM banned_users WHERE ban_type = 'user' AND ban_value = ? AND user_type = ? AND (expires_at IS NULL OR expires_at > ?)`, [userId, userType, now], ); if (userBan) return { banned: true, reason: userBan.reason, expires_at: userBan.expires_at, }; // Vérifier ban par IP if (ip) { const ipBan = await dbGet( `SELECT * FROM banned_users WHERE ban_type = 'ip' AND ban_value = ? AND (expires_at IS NULL OR expires_at > ?)`, [ip, now], ); if (ipBan) return { banned: true, reason: ipBan.reason, expires_at: ipBan.expires_at, }; } return { banned: false }; } // === #16: Pool MySQL caché pour éviter d'appeler getPool() à chaque query === let _cachedPool = null; function getCachedPool() { if (!_cachedPool) _cachedPool = getPool(); return _cachedPool; } const DISCORD_WEBHOOK_URL = process.env.DISCORD_COMMENTS_WEBHOOK_URL; const FRONTEND_BASE_URL = process.env.FRONTEND_BASE_URL; // Normalize user-entered content while keeping the original characters intact. function normalizeCommentContent(text) { if (typeof text !== "string") return text; // Replace 3+ consecutive newlines (with optional spaces/tabs between them) with max 2 newlines return text.replace(/(\s*\n\s*){3,}/g, "\n\n").trim(); } function decodeHtmlEntities(text) { if (typeof text !== "string" || !text.includes("&")) return text; const namedEntities = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: "\u00A0", }; return text.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, entity) => { const lowerEntity = entity.toLowerCase(); if (namedEntities[lowerEntity]) { return namedEntities[lowerEntity]; } if (lowerEntity.startsWith("#x")) { const codePoint = parseInt(lowerEntity.slice(2), 16); if (Number.isFinite(codePoint)) { try { return String.fromCodePoint(codePoint); } catch { return match; } } return match; } if (lowerEntity.startsWith("#")) { const codePoint = parseInt(lowerEntity.slice(1), 10); if (Number.isFinite(codePoint)) { try { return String.fromCodePoint(codePoint); } catch { return match; } } } return match; }); } // Decode legacy escaped content before returning it to the frontend. function formatContentForResponse(text) { let formatted = normalizeCommentContent(text); // Older comments were stored escaped, and some routes escaped them a second time on read. // Decoding twice fixes both stored legacy entities and previously double-escaped payloads. for (let i = 0; i < 2; i += 1) { const decoded = decodeHtmlEntities(formatted); if (decoded === formatted) break; formatted = decoded; } return formatted; } // OpenRouter API Configuration for content moderation (using Gemini 2.5 Flash Lite) const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY; const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions"; const OPENROUTER_MODEL = "google/gemini-2.5-flash-lite"; // Fonction de modération avec OpenRouter/Gemini (exécutée en background) async function moderateContentWithGemini( contentId, contentType, content, username, ) { try { const prompt = `Tu es un modérateur de commentaires. Analyse le commentaire ET le pseudo suivants et réponds UNIQUEMENT par un JSON valide. Pseudo de l'utilisateur: ${JSON.stringify(username)} Commentaire à analyser: ${JSON.stringify(content)} Critères de modération (s'appliquent au pseudo ET au commentaire): 1. INSULTES: Contient des insultes, injures, propos haineux ou dégradants 2. EROTIQUE: Contient du contenu érotique, sexuel ou inapproprié 3. DEMANDE_AJOUT: Demande d'ajout de films, séries, fonctionnalités ou autre contenu 4. PSEUDO_INAPPROPRIE: Le pseudo contient des insultes, contenu érotique, ou est inapproprié Réponds UNIQUEMENT avec ce format JSON (sans markdown, sans backticks): {"flagged": true/false, "reason": "INSULTES" ou "EROTIQUE" ou "DEMANDE_AJOUT" ou "PSEUDO_INAPPROPRIE" ou null, "details": "explication courte"}`; const response = await axios.post( OPENROUTER_API_URL, { model: OPENROUTER_MODEL, messages: [ { role: "user", content: prompt, }, ], max_tokens: 500, temperature: 0.1, }, { headers: { "Content-Type": "application/json", Authorization: `Bearer ${OPENROUTER_API_KEY}`, "HTTP-Referer": FRONTEND_BASE_URL, "X-Title": "Movix Comment Moderation", }, timeout: 15000, }, ); const responseText = response.data?.choices?.[0]?.message?.content || ""; // Parser la réponse JSON let moderationResult; try { // Nettoyer la réponse (enlever les backticks markdown si présents) const cleanedResponse = responseText .replace(/```json\n?|```\n?/g, "") .trim(); moderationResult = JSON.parse(cleanedResponse); } catch (parseError) { console.error( "❌ Erreur parsing réponse OpenRouter:", parseError, "Response:", responseText, ); return { flagged: false }; } if (moderationResult.flagged) { console.log( `🚨 Contenu flaggé (${contentType} ID: ${contentId}): ${moderationResult.reason} - ${moderationResult.details}`, ); // Marquer le contenu comme supprimé (deleted = 1) et stocker la raison const table = contentType === "comment" ? "comments" : "comment_replies"; await dbRun( `UPDATE ${table} SET deleted = 1, moderation_reason = ?, moderation_details = ?, moderated_at = ? WHERE id = ?`, [ moderationResult.reason, moderationResult.details, Date.now(), contentId, ], ); console.log( `✅ ${contentType} ID ${contentId} marqué comme supprimé pour modération`, ); } return moderationResult; } catch (error) { console.error( "❌ Erreur modération OpenRouter (non bloquant):", error.message, ); return { flagged: false }; } } // Fonctions helper MySQL pour remplacer SQLite (#16: pool caché) const dbRun = async (sql, params = []) => { const pool = getCachedPool(); const [result] = await pool.execute(sql, params); return { lastID: result.insertId || 0, changes: result.affectedRows || 0, }; }; const dbGet = async (sql, params = []) => { const pool = getCachedPool(); const [rows] = await pool.execute(sql, params); return rows.length > 0 ? rows[0] : null; }; const dbAll = async (sql, params = []) => { const pool = getCachedPool(); const [rows] = await pool.execute(sql, params); return rows; }; // Middleware pour vérifier l'authentification avec validation session MySQL const requireAuth = async (req, res, next) => { const token = req.headers.authorization?.split(" ")[1]; if (!token) { return res.status(401).json({ error: "Non authentifié" }); } // Vérifier le token JWT (#15: jwt importé au top du fichier) try { const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ["HS256"] }); const { sub: userId, userType, sessionId } = decoded; if (!["oauth", "bip39"].includes(userType) || !userId || !sessionId) { return res.status(401).json({ error: "Token invalide" }); } // Vérifier que la session existe en MySQL const pool = getCachedPool(); const [rows] = await pool.execute( "SELECT id FROM user_sessions WHERE id = ? AND user_id = ? AND user_type = ?", [sessionId, userId, userType], ); if (rows.length === 0) { return res.status(401).json({ error: "Session invalide ou expirée" }); } req.user = { userId: userId, userType: userType, sessionId: sessionId, }; next(); } catch (error) { return res.status(401).json({ error: "Token invalide" }); } }; // Rate limit pour les actions d'écriture (commentaires/réponses/réactions/notifications) // - Clef = userId post-auth (fallback IP CF/X-Forwarded-For derrière Cloudflare) // - Store Redis partagé entre workers du cluster (sinon chaque worker compte indépendamment) // - passOnStoreError: si Redis tombe, fail-open au lieu de bloquer toutes les requêtes const rateLimit = require("express-rate-limit"); const { ipKeyGenerator } = require("express-rate-limit"); const { createRedisRateLimitStore } = require("./utils/redisRateLimitStore"); const writeRateLimit = rateLimit({ windowMs: 60 * 1000, max: 100, store: createRedisRateLimitStore({ prefix: "rate-limit:comments:write:", windowMs: 60 * 1000, }), passOnStoreError: true, standardHeaders: true, legacyHeaders: false, message: { error: "Trop de requêtes. Réessayez dans une minute." }, keyGenerator: (req) => { if (req.user) return `u:${req.user.userType}:${req.user.userId}`; return ( req.headers["cf-connecting-ip"] || req.headers["x-forwarded-for"]?.split(",")[0].trim() || ipKeyGenerator(req.ip) ); }, validate: { xForwardedForHeader: false, ip: false, keyGeneratorIpFallback: false, }, }); // Init paresseux des tables notifs/push (évite un DDL par requête, idempotent en cas de redémarrage) let _notificationTablesInitialized = false; let _notificationTablesInitPromise = null; async function ensureNotificationTables() { if (_notificationTablesInitialized) return; if (_notificationTablesInitPromise) return _notificationTablesInitPromise; _notificationTablesInitPromise = (async () => { const pool = getCachedPool(); await pool.execute( `CREATE TABLE IF NOT EXISTS user_notification_preferences ( user_id VARCHAR(255) NOT NULL, user_type VARCHAR(50) NOT NULL, notifications_disabled TINYINT(1) DEFAULT 0, updated_at BIGINT, PRIMARY KEY (user_id, user_type) )` ); await pool.execute( `CREATE TABLE IF NOT EXISTS push_subscriptions ( id INT AUTO_INCREMENT PRIMARY KEY, user_id VARCHAR(255) NOT NULL, user_type VARCHAR(50) NOT NULL, endpoint TEXT NOT NULL, p256dh TEXT NOT NULL, auth TEXT NOT NULL, created_at BIGINT, INDEX idx_user_push (user_id, user_type) )` ); _notificationTablesInitialized = true; })(); try { await _notificationTablesInitPromise; } finally { _notificationTablesInitPromise = null; } } // Helper to get allowed profile IDs (security check) async function getProfileIds(userId, userType) { try { // Sanitize userId to prevent path traversal const safeUserId = String(userId).replace(/[^a-zA-Z0-9_\-]/g, ""); let userFilePath; if (userType === "bip39") { userFilePath = path.join( __dirname, "data", "users", `bip39-${safeUserId}.json`, ); } else { userFilePath = path.join( __dirname, "data", "users", `${safeUserId}.json`, ); } try { const userFile = await fs.readFile(userFilePath, "utf8"); const user = JSON.parse(userFile); const profileIds = []; if (user.profiles) { user.profiles.forEach((p) => profileIds.push(p.id)); } else { // Fallback for old structure might be needed but typically not for profileId spoofing protection } return profileIds; } catch (err) { // if (err.code !== 'ENOENT') console.error('Error reading user file:', err); return []; } } catch (error) { console.error("Error getting profile IDs:", error); return []; } } // === #14: Cache getUserData dans Redis (TTL 5 min) pour éviter les lectures fichier à chaque requête === const USER_DATA_CACHE_TTL = 300; // 5 minutes en secondes // Fonction interne pour récupérer les données utilisateur depuis le disque/MySQL (sans cache) async function _fetchUserData(userId, userType, profileId = null) { try { let userData = { username: "Utilisateur", avatar: null, isVip: false, isAdmin: false, }; // Sanitize inputs to prevent path traversal const safeUserId = String(userId).replace(/[^a-zA-Z0-9_\-]/g, ""); const safeUserType = ["oauth", "bip39"].includes(userType) ? userType : "oauth"; const safeProfileId = profileId ? String(profileId).replace(/[^a-zA-Z0-9_\-]/g, "") : null; // Chemin vers le fichier utilisateur let userFilePath; if (safeUserType === "bip39") { userFilePath = path.join( __dirname, "data", "users", `bip39-${safeUserId}.json`, ); } else { userFilePath = path.join( __dirname, "data", "users", `${safeUserId}.json`, ); } // Lire le fichier utilisateur try { const userFile = await fs.readFile(userFilePath, "utf8"); const user = JSON.parse(userFile); // Récupérer le profil if (profileId && user.profiles) { const profile = user.profiles.find((p) => p.id === profileId); if (profile) { userData.username = profile.name || "Utilisateur"; // Sanitize avatar: must start with /avatars/ if (profile.avatar && profile.avatar.startsWith("/avatars/")) { userData.avatar = profile.avatar; } else { userData.avatar = "/avatars/disney/disney_avatar_1.png"; // Default fallback } } } else if (user.profiles && user.profiles.length > 0) { // Utiliser le profil par défaut const defaultProfile = user.profiles.find((p) => p.isDefault) || user.profiles[0]; userData.username = defaultProfile.name || "Utilisateur"; // Sanitize avatar: must start with /avatars/ if ( defaultProfile.avatar && defaultProfile.avatar.startsWith("/avatars/") ) { userData.avatar = defaultProfile.avatar; } else { userData.avatar = "/avatars/disney/disney_avatar_1.png"; // Default fallback } } } catch (err) { // Fichier utilisateur introuvable — on garde les valeurs par défaut } // Vérifier le statut VIP en lisant l'access_code depuis les données du profil // puis en le vérifiant contre la table MySQL access_keys if (safeProfileId && safeUserId) { try { const profileDataPath = path.join( __dirname, "data", "users", "profiles", safeUserType, safeUserId, `${safeProfileId}.json`, ); const profileData = JSON.parse( await fs.readFile(profileDataPath, "utf8"), ); const storedAccessCode = profileData.access_code || null; if (storedAccessCode) { const vipStatus = await verifyAccessKey(storedAccessCode); userData.isVip = vipStatus.vip; } } catch (err) { // Pas de données de profil ou pas d'access_code — isVip reste false } } // Vérifier si Admin (en utilisant MySQL) try { const pool = getCachedPool(); const authType = userType === "bip39" ? "bip-39" : userType; const [rows] = await pool.execute( "SELECT 1 FROM admins WHERE user_id = ? AND auth_type = ? LIMIT 1", [userId, authType], ); userData.isAdmin = rows.length > 0; } catch (err) { console.error("❌ Erreur lors de la vérification admin:", err); } return userData; } catch (error) { console.error("Erreur _fetchUserData:", error); return { username: "Utilisateur", avatar: null, isVip: false, isAdmin: false, }; } } // Fonction publique avec cache Redis (#14) async function getUserData(userId, userType, profileId = null) { const cacheKey = `userData:${userType}:${userId}:${profileId || "default"}`; try { const cached = await redis.get(cacheKey); if (cached) return JSON.parse(cached); } catch { /* Redis indisponible, on continue sans cache */ } const userData = await _fetchUserData(userId, userType, profileId); // Mettre en cache (fire-and-forget) try { redis .set(cacheKey, JSON.stringify(userData), "EX", USER_DATA_CACHE_TTL) .catch(() => {}); } catch { /* ignore */ } return userData; } // Fonction pour créer une notification async function isNotificationDisabledForUser(userId, userType) { try { const [rows] = await getPool().execute( 'SELECT notifications_disabled FROM user_notification_preferences WHERE user_id = ? AND user_type = ? LIMIT 1', [userId, userType] ); return rows.length > 0 && rows[0].notifications_disabled === 1; } catch { return false; } } let pushTableReady = false; async function ensurePushTable() { if (pushTableReady) return; try { await getPool().execute( `CREATE TABLE IF NOT EXISTS push_subscriptions ( id INT AUTO_INCREMENT PRIMARY KEY, user_id VARCHAR(255) NOT NULL, user_type VARCHAR(50) NOT NULL, endpoint TEXT NOT NULL, p256dh TEXT NOT NULL, auth TEXT NOT NULL, created_at BIGINT, INDEX idx_user_push (user_id, user_type) )` ); pushTableReady = true; } catch {} } async function sendPushToUser(userId, userType, payload) { if (!VAPID_CONFIGURED) return; try { await ensurePushTable(); const pool = getPool(); const [subs] = await pool.execute( 'SELECT endpoint, p256dh, auth FROM push_subscriptions WHERE user_id = ? AND user_type = ?', [userId, userType] ); const data = JSON.stringify(payload); for (const sub of subs) { try { await webpush.sendNotification({ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }, data); } catch (err) { // Si la subscription est expirée ou invalide, la supprimer if (err.statusCode === 410 || err.statusCode === 404) { await pool.execute('DELETE FROM push_subscriptions WHERE endpoint = ?', [sub.endpoint]); } } } } catch (error) { console.error("Erreur lors de l'envoi push:", error); } } async function createNotification( toUserId, toUserType, toProfileId, fromUserId, fromProfileId, fromUsername, fromAvatar, notificationType, targetType, targetId, contentType, contentId, commentPreview, ) { try { if (await isNotificationDisabledForUser(toUserId, toUserType)) { return; } await dbRun( `INSERT INTO notifications (user_id, user_type, profile_id, from_user_id, from_profile_id, from_username, from_avatar, notification_type, target_type, target_id, content_type, content_id, comment_preview, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ toUserId, toUserType, toProfileId, fromUserId, fromProfileId, fromUsername, fromAvatar, notificationType, targetType, targetId, contentType, contentId, commentPreview, Date.now(), ], ); // Envoyer une notification push const pushMessages = { reply: `${fromUsername} a répondu à votre commentaire`, like: `${fromUsername} a aimé votre commentaire`, reaction: `${fromUsername} a réagi à votre commentaire`, mention: `${fromUsername} vous a mentionné`, report_resolved: `Votre signalement a été traité`, report_resolved_deleted: `Votre signalement a été traité (contenu supprimé)`, report_dismissed: `Votre signalement a été rejeté`, }; sendPushToUser(toUserId, toUserType, { title: "Movix", body: pushMessages[notificationType] || "Nouvelle notification", icon: "/movix.png", data: { contentType, contentId, notificationType }, }); } catch (error) { console.error("Erreur lors de la création de la notification:", error); } } const REPORT_NOTIFICATION_TYPES = { RESOLVED: "report_resolved", RESOLVED_DELETED: "report_resolved_deleted", DISMISSED: "report_dismissed", }; async function getReportNotificationTarget(report) { try { if (report.target_type === "comment") { const comment = await dbGet( "SELECT content_type, content_id FROM comments WHERE id = ?", [report.target_id], ); if (comment) { return { contentType: comment.content_type, contentId: String(comment.content_id), targetId: Number(report.target_id), }; } } else if (report.target_type === "reply") { const reply = await dbGet( "SELECT comment_id FROM comment_replies WHERE id = ?", [report.target_id], ); if (reply) { const comment = await dbGet( "SELECT content_type, content_id FROM comments WHERE id = ?", [reply.comment_id], ); if (comment) { return { contentType: comment.content_type, contentId: String(comment.content_id), targetId: Number(report.target_id), }; } } } else if (report.target_type === "shared_list") { const pool = getCachedPool(); const [rows] = await pool.execute( "SELECT id, share_code FROM shared_lists WHERE share_code = ? OR id = ?", [report.target_id, report.target_id], ); if (rows.length > 0) { const sharedList = rows[0]; return { contentType: "shared_list", contentId: String(sharedList.share_code || sharedList.id || report.target_id), targetId: Number(sharedList.id) || Number(report.target_id) || 0, }; } } } catch (error) { console.error("Erreur lors de la préparation de la notification de signalement:", error); } return { contentType: report.target_type, contentId: String(report.target_id), targetId: Number(report.target_id) || 0, }; } // === #18: Batch cascade delete — requêtes batch au lieu de récursion séquentielle === // Fonction helper pour récupérer tous les IDs de réponses d'un commentaire (ou sous-arbre d'une réponse) async function getAllReplyIds( commentId, parentReplyId = null, includeDeleted = false, ) { const deletedFilter = includeDeleted ? "" : " AND deleted = 0"; if (parentReplyId === null) { // Toutes les réponses du commentaire const replies = await dbAll( `SELECT id FROM comment_replies WHERE comment_id = ?${deletedFilter}`, [commentId], ); return replies.map((r) => r.id); } // Sous-arbre d'une réponse spécifique : récupérer récursivement via hierarchical_path // Récupérer le path du parent const parent = await dbGet( "SELECT hierarchical_path FROM comment_replies WHERE id = ?", [parentReplyId], ); if (!parent || !parent.hierarchical_path) return [parentReplyId]; const descendants = await dbAll( `SELECT id FROM comment_replies WHERE comment_id = ? AND hierarchical_path LIKE ?${deletedFilter}`, [commentId, `${parent.hierarchical_path}.%`], ); return [parentReplyId, ...descendants.map((r) => r.id)]; } // Batch delete des réactions et notifications pour une liste d'IDs de réponses async function batchDeleteReplyDependencies(replyIds) { if (replyIds.length === 0) return; const placeholders = replyIds.map(() => "?").join(","); // Supprimer les réactions de toutes les réponses en batch await dbRun( `DELETE FROM comment_reactions WHERE target_type = 'reply' AND target_id IN (${placeholders})`, replyIds, ); // Supprimer les notifications liées à ces réponses en batch await dbRun( `DELETE FROM notifications WHERE target_type = 'reply' AND target_id IN (${placeholders})`, replyIds, ); // Supprimer les notifications de réaction sur ces réponses en batch await dbRun( `DELETE FROM notifications WHERE notification_type = 'reaction' AND target_type = 'reply' AND target_id IN (${placeholders})`, replyIds, ); } // Soft delete cascade : marquer les réponses comme supprimées en batch async function deleteRepliesCascade(commentId, replyId = null) { try { const replyIds = await getAllReplyIds(commentId, replyId, false); if (replyIds.length === 0) return; await batchDeleteReplyDependencies(replyIds); // Soft delete toutes les réponses en batch const placeholders = replyIds.map(() => "?").join(","); await dbRun( `UPDATE comment_replies SET deleted = 1 WHERE id IN (${placeholders})`, replyIds, ); console.log( `✅ ${replyIds.length} réponse(s) supprimées en cascade (soft) pour commentaire ${commentId}`, ); } catch (error) { console.error( "Erreur lors de la suppression en cascade des réponses:", error, ); throw error; } } // Soft delete cascade pour un commentaire entier async function deleteCommentCascade(commentId) { try { // 1. Récupérer tous les IDs de réponses du commentaire const replyIds = await getAllReplyIds(commentId, null, false); // 2. Supprimer les dépendances de toutes les réponses en batch if (replyIds.length > 0) { await batchDeleteReplyDependencies(replyIds); const placeholders = replyIds.map(() => "?").join(","); await dbRun( `UPDATE comment_replies SET deleted = 1 WHERE id IN (${placeholders})`, replyIds, ); } // 3. Supprimer les réactions du commentaire await dbRun( `DELETE FROM comment_reactions WHERE target_type = 'comment' AND target_id = ?`, [commentId], ); // 4. Supprimer les notifications du commentaire await dbRun( `DELETE FROM notifications WHERE target_type = 'comment' AND target_id = ?`, [commentId], ); await dbRun( `DELETE FROM notifications WHERE notification_type = 'reaction' AND target_type = 'comment' AND target_id = ?`, [commentId], ); // 5. Soft delete le commentaire await dbRun("UPDATE comments SET deleted = 1 WHERE id = ?", [commentId]); console.log( `✅ Commentaire ${commentId} et ${replyIds.length} réponse(s) supprimés en cascade`, ); } catch (error) { console.error( "Erreur lors de la suppression en cascade du commentaire:", error, ); throw error; } } // Hard delete cascade : suppression définitive d'une réponse et ses enfants async function hardDeleteRepliesCascade(commentId, replyId) { try { const replyIds = await getAllReplyIds(commentId, replyId, true); if (replyIds.length === 0) return; await batchDeleteReplyDependencies(replyIds); // Hard delete toutes les réponses en batch const placeholders = replyIds.map(() => "?").join(","); await dbRun( `DELETE FROM comment_replies WHERE id IN (${placeholders})`, replyIds, ); console.log( `🗑️ ${replyIds.length} réponse(s) supprimées définitivement pour commentaire ${commentId}`, ); } catch (error) { console.error( "Erreur lors de la suppression définitive des réponses:", error, ); throw error; } } // Hard delete cascade pour un commentaire entier async function hardDeleteCommentCascade(commentId) { try { // 1. Récupérer tous les IDs de réponses (y compris déjà supprimées) const replyIds = await getAllReplyIds(commentId, null, true); // 2. Supprimer les dépendances en batch if (replyIds.length > 0) { await batchDeleteReplyDependencies(replyIds); const placeholders = replyIds.map(() => "?").join(","); await dbRun( `DELETE FROM comment_replies WHERE id IN (${placeholders})`, replyIds, ); } // 3. Supprimer les réactions et notifications du commentaire await dbRun( `DELETE FROM comment_reactions WHERE target_type = 'comment' AND target_id = ?`, [commentId], ); await dbRun( `DELETE FROM notifications WHERE target_type = 'comment' AND target_id = ?`, [commentId], ); await dbRun( `DELETE FROM notifications WHERE notification_type = 'reaction' AND target_type = 'comment' AND target_id = ?`, [commentId], ); // 4. Hard delete le commentaire await dbRun("DELETE FROM comments WHERE id = ?", [commentId]); console.log( `🗑️ Commentaire ${commentId} et ${replyIds.length} réponse(s) supprimés définitivement`, ); } catch (error) { console.error( "Erreur lors de la suppression définitive du commentaire:", error, ); throw error; } } // Fonction pour récupérer l'adresse IP de la requête // Priorité : cf-connecting-ip (Cloudflare, impossible à spoof) > x-real-ip > x-forwarded-for > fallbacks function getClientIp(req) { return ( req.headers["cf-connecting-ip"] || req.headers["x-real-ip"] || req.headers["x-forwarded-for"]?.split(",")[0].trim() || req.ip || req.connection?.remoteAddress || req.socket?.remoteAddress || "Unknown" ); } // Fonction pour envoyer un webhook Discord async function sendDiscordWebhook(type, data) { try { const { username, avatar, content, contentType, contentId, isSpoiler, isVip, isAdmin, replyToUsername, userId, userType, profileId, ipAddress, } = data; // Validation et limitation des valeurs selon les spécifications Discord // Description: max 2048 caractères const description = content && typeof content === "string" ? content.length > 2048 ? content.substring(0, 2045) + "..." : content : "Aucun contenu"; // Titre: max 256 caractères const title = ( type === "comment" ? "💬 Nouveau commentaire" : "💬 Nouvelle réponse" ).substring(0, 256); // Champs: name max 256, value max 1024 caractères const usernameValue = String(username || "Utilisateur").substring(0, 1024); const contentTypeValue = String( contentType === "movie" ? "🎬 Film" : contentType === "tv" ? "📺 Série" : contentType || "Inconnu", ).substring(0, 1024); const contentIdValue = String(contentId || "N/A").substring(0, 1024); // Construire le lien vers le film/série const contentUrl = contentId && contentType ? `${FRONTEND_BASE_URL}/${contentType === "movie" ? "movie" : "tv"}/${contentId}` : null; const embed = { title: title, description: description, color: isAdmin ? 0xff0000 : isVip ? 0xffd700 : 0x3498db, url: contentUrl || undefined, // Lien cliquable sur le titre fields: [ { name: "👤 Utilisateur".substring(0, 256), value: usernameValue, inline: true, }, { name: "📺 Contenu".substring(0, 256), value: contentTypeValue, inline: true, }, { name: "🆔 ID Contenu".substring(0, 256), value: contentIdValue, inline: true, }, ], timestamp: new Date().toISOString(), footer: { text: String( isAdmin ? "👑 Administrateur" : isVip ? "⭐ VIP" : "👤 Utilisateur", ).substring(0, 2048), }, }; // Ajouter les informations utilisateur if (userId) { embed.fields.push({ name: "🆔 User ID".substring(0, 256), value: String(userId).substring(0, 1024), inline: true, }); } if (profileId) { embed.fields.push({ name: "🎭 Profile ID".substring(0, 256), value: String(profileId).substring(0, 1024), inline: true, }); } if (userType) { embed.fields.push({ name: "🔐 User Type".substring(0, 256), value: String(userType).substring(0, 1024), inline: true, }); } if (ipAddress) { embed.fields.push({ name: "🌐 IP Address".substring(0, 256), value: String(ipAddress).substring(0, 1024), inline: true, }); } // Ajouter un champ avec le lien si disponible if (contentUrl) { embed.fields.push({ name: "🔗 Lien".substring(0, 256), value: `[Voir ${contentType === "movie" ? "le film" : "la série"}](${contentUrl})`.substring( 0, 1024, ), inline: false, }); } if (isSpoiler) { embed.fields.push({ name: "⚠️ Spoiler".substring(0, 256), value: "Oui", inline: true, }); } if (type === "reply" && replyToUsername) { embed.fields.push({ name: "↩️ Réponse à".substring(0, 256), value: String(replyToUsername).substring(0, 1024), inline: true, }); } // Vérifier que l'avatar est une URL valide if ( avatar && typeof avatar === "string" && avatar.trim().length > 0 && (avatar.startsWith("http://") || avatar.startsWith("https://")) ) { embed.thumbnail = { url: avatar.trim().substring(0, 2048), }; } await axios.post( DISCORD_WEBHOOK_URL, { embeds: [embed], }, { headers: { "Content-Type": "application/json", }, timeout: 10000, }, ); } catch (error) { console.error( "Erreur lors de l'envoi du webhook Discord:", error.response?.data || error.message, ); // Ne pas bloquer l'exécution si le webhook échoue } } // ==================== ROUTES NOTIFICATIONS ==================== // GET /api/comments/notifications - Récupérer les notifications de l'utilisateur router.get("/notifications", requireAuth, async (req, res) => { try { const { page = 1, limit = 20, unreadOnly = false, profileId } = req.query; const safePage = Math.max(1, Math.min(parseInt(page) || 1, 1000)); const safeLimit = Math.max(1, Math.min(parseInt(limit) || 20, 100)); const offset = (safePage - 1) * safeLimit; // Validation du profileId if (!profileId) { return res.status(400).json({ error: "profileId requis" }); } // Vérifier que le profileId appartient bien à l'utilisateur authentifié const userProfileIds = await getProfileIds( req.user.userId, req.user.userType, ); if (!userProfileIds.includes(profileId)) { return res.status(403).json({ error: "Profil non autorisé" }); } let query = "SELECT * FROM notifications WHERE user_id = ? AND user_type = ? AND profile_id = ?"; const params = [req.user.userId, req.user.userType, profileId]; if (unreadOnly === "true") { query += " AND is_read = 0"; } query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"; params.push(safeLimit, offset); const notifications = await dbAll(query, params); res.json({ notifications }); } catch (error) { console.error("Erreur lors de la récupération des notifications:", error); res.status(500).json({ error: "Erreur serveur" }); } }); // PUT /api/comments/notifications/:id/read - Marquer une notification comme lue router.put("/notifications/:id/read", requireAuth, writeRateLimit, async (req, res) => { try { const { id } = req.params; const { profileId } = req.body; // Validation du profileId if (!profileId) { return res.status(400).json({ error: "profileId requis" }); } // Vérifier que le profileId appartient bien à l'utilisateur authentifié const userProfileIds = await getProfileIds( req.user.userId, req.user.userType, ); if (!userProfileIds.includes(profileId)) { return res.status(403).json({ error: "Profil non autorisé" }); } await dbRun( "UPDATE notifications SET is_read = 1 WHERE id = ? AND user_id = ? AND user_type = ? AND profile_id = ?", [id, req.user.userId, req.user.userType, profileId], ); res.json({ message: "Notification marquée comme lue" }); } catch (error) { console.error("Erreur lors du marquage de la notification:", error); res.status(500).json({ error: "Erreur serveur" }); } }); // PUT /api/comments/notifications/read-all - Marquer toutes les notifications comme lues router.put("/notifications/read-all", requireAuth, writeRateLimit, async (req, res) => { try { const { profileId } = req.body; // Validation du profileId if (!profileId) { return res.status(400).json({ error: "profileId requis" }); } // Vérifier que le profileId appartient bien à l'utilisateur authentifié const userProfileIds = await getProfileIds( req.user.userId, req.user.userType, ); if (!userProfileIds.includes(profileId)) { return res.status(403).json({ error: "Profil non autorisé" }); } await dbRun( "UPDATE notifications SET is_read = 1 WHERE user_id = ? AND user_type = ? AND profile_id = ?", [req.user.userId, req.user.userType, profileId], ); res.json({ message: "Toutes les notifications ont été marquées comme lues", }); } catch (error) { console.error("Erreur lors du marquage des notifications:", error); res.status(500).json({ error: "Erreur serveur" }); } }); // DELETE /api/comments/notifications/:id - Supprimer une notification router.delete("/notifications/:id", requireAuth, writeRateLimit, async (req, res) => { try { const { id } = req.params; const { profileId } = req.query; // Validation du profileId if (!profileId) { return res.status(400).json({ error: "profileId requis" }); } // Vérifier que le profileId appartient bien à l'utilisateur authentifié const userProfileIds = await getProfileIds( req.user.userId, req.user.userType, ); if (!userProfileIds.includes(profileId)) { return res.status(403).json({ error: "Profil non autorisé" }); } // Vérifier que la notification appartient à l'utilisateur et au profil const notification = await dbGet( "SELECT * FROM notifications WHERE id = ? AND user_id = ? AND user_type = ? AND profile_id = ?", [id, req.user.userId, req.user.userType, profileId], ); if (!notification) { return res.status(404).json({ error: "Notification non trouvée" }); } // Supprimer la notification await dbRun( "DELETE FROM notifications WHERE id = ? AND user_id = ? AND user_type = ? AND profile_id = ?", [id, req.user.userId, req.user.userType, profileId], ); res.json({ message: "Notification supprimée" }); } catch (error) { console.error("Erreur lors de la suppression de la notification:", error); res.status(500).json({ error: "Erreur serveur" }); } }); // GET /api/comments/notifications/preferences - Récupérer les préférences de notifications router.get("/notifications/preferences", requireAuth, async (req, res) => { try { await ensureNotificationTables(); const pool = getCachedPool(); const [rows] = await pool.execute( 'SELECT notifications_disabled FROM user_notification_preferences WHERE user_id = ? AND user_type = ? LIMIT 1', [req.user.userId, req.user.userType] ); res.json({ success: true, notificationsDisabled: rows.length > 0 && rows[0].notifications_disabled === 1, }); } catch (error) { console.error("Erreur lors de la récupération des préférences:", error); res.status(500).json({ error: "Erreur serveur" }); } }); // PUT /api/comments/notifications/preferences - Mettre à jour les préférences de notifications router.put("/notifications/preferences", requireAuth, writeRateLimit, async (req, res) => { try { await ensureNotificationTables(); const disabled = req.body?.notificationsDisabled === true; const pool = getCachedPool(); await pool.execute( `INSERT INTO user_notification_preferences (user_id, user_type, notifications_disabled, updated_at) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE notifications_disabled = VALUES(notifications_disabled), updated_at = VALUES(updated_at)`, [req.user.userId, req.user.userType, disabled ? 1 : 0, Date.now()] ); res.json({ success: true, notificationsDisabled: disabled }); } catch (error) { console.error("Erreur lors de la mise à jour des préférences:", error); res.status(500).json({ error: "Erreur serveur" }); } }); // POST /api/comments/notifications/push/subscribe - Enregistrer une subscription push router.post("/notifications/push/subscribe", requireAuth, writeRateLimit, async (req, res) => { try { const { subscription } = req.body; // Validation stricte: endpoint + keys.p256dh + keys.auth requis if ( !subscription || typeof subscription.endpoint !== "string" || !subscription.endpoint || !subscription.keys || typeof subscription.keys.p256dh !== "string" || !subscription.keys.p256dh || typeof subscription.keys.auth !== "string" || !subscription.keys.auth ) { return res.status(400).json({ error: "Subscription invalide" }); } await ensureNotificationTables(); const pool = getCachedPool(); // Vérifier si l'endpoint existe déjà — refuser le hijack cross-user const [existing] = await pool.execute( 'SELECT user_id, user_type FROM push_subscriptions WHERE endpoint = ? LIMIT 1', [subscription.endpoint] ); if (existing.length > 0) { const owner = existing[0]; if ( String(owner.user_id) !== String(req.user.userId) || String(owner.user_type) !== String(req.user.userType) ) { // Endpoint appartient à un autre compte — refuser (anti-hijack push) return res .status(409) .json({ error: "Endpoint déjà associé à un autre compte" }); } // Même owner: rotate les clés (cas normal, le browser peut renouveler les clés) await pool.execute( 'UPDATE push_subscriptions SET p256dh = ?, auth = ?, created_at = ? WHERE endpoint = ? AND user_id = ? AND user_type = ?', [ subscription.keys.p256dh, subscription.keys.auth, Date.now(), subscription.endpoint, req.user.userId, req.user.userType, ] ); } else { await pool.execute( 'INSERT INTO push_subscriptions (user_id, user_type, endpoint, p256dh, auth, created_at) VALUES (?, ?, ?, ?, ?, ?)', [ req.user.userId, req.user.userType, subscription.endpoint, subscription.keys.p256dh, subscription.keys.auth, Date.now(), ] ); } res.json({ success: true }); } catch (error) { console.error("Erreur lors de l'enregistrement push:", error); res.status(500).json({ error: "Erreur serveur" }); } }); // DELETE /api/comments/notifications/push/unsubscribe - Supprimer une subscription push router.delete("/notifications/push/unsubscribe", requireAuth, writeRateLimit, async (req, res) => { try { const { endpoint } = req.body; if (!endpoint) return res.status(400).json({ error: "Endpoint manquant" }); await ensureNotificationTables(); const pool = getCachedPool(); await pool.execute('DELETE FROM push_subscriptions WHERE endpoint = ? AND user_id = ? AND user_type = ?', [endpoint, req.user.userId, req.user.userType]); res.json({ success: true }); } catch (error) { console.error("Erreur lors de la désinscription push:", error); res.status(500).json({ error: "Erreur serveur" }); } }); // GET /api/comments/notifications/push/vapid-key - Récupérer la clé publique VAPID router.get("/notifications/push/vapid-key", (req, res) => { res.json({ publicKey: process.env.VAPID_PUBLIC_KEY }); }); // ==================== ROUTES RÉACTIONS ==================== // POST /api/comments/react - Ajouter/retirer une réaction router.post("/react", requireAuth, writeRateLimit, async (req, res) => { try { const { targetType, targetId, profileId } = req.body; // targetType: 'comment' ou 'reply' // Whitelist targetType pour éviter pollution de la table comment_reactions if (!["comment", "reply"].includes(targetType)) { return res.status(400).json({ error: "targetType invalide" }); } // Verify profile ownership const userProfileIds = await getProfileIds( req.user.userId, req.user.userType, ); if (!userProfileIds.includes(profileId)) { return res.status(403).json({ error: "Profil non autorisé" }); } // Vérifier si la réaction existe déjà const existingReaction = await dbGet( "SELECT * FROM comment_reactions WHERE target_type = ? AND target_id = ? AND user_id = ? AND user_type = ? AND profile_id = ?", [targetType, targetId, req.user.userId, req.user.userType, profileId], ); if (existingReaction) { // Retirer la réaction await dbRun("DELETE FROM comment_reactions WHERE id = ?", [ existingReaction.id, ]); res.json({ reacted: false }); } else { // Ajouter la réaction await dbRun( "INSERT INTO comment_reactions (target_type, target_id, user_id, user_type, profile_id, created_at) VALUES (?, ?, ?, ?, ?, ?)", [ targetType, targetId, req.user.userId, req.user.userType, profileId, Date.now(), ], ); // Créer une notification pour l'auteur du commentaire/réponse let targetUserId, targetUserType, targetProfileId, contentType, contentId; if (targetType === "comment") { const comment = await dbGet("SELECT * FROM comments WHERE id = ?", [ targetId, ]); if (comment) { targetUserId = comment.user_id; targetUserType = comment.user_type; targetProfileId = comment.profile_id; contentType = comment.content_type; contentId = comment.content_id; // Ne pas créer de notification si on réagit à son propre commentaire if ( targetUserId !== req.user.userId || targetUserType !== req.user.userType || targetProfileId !== profileId ) { const userData = await getUserData( req.user.userId, req.user.userType, profileId, ); await createNotification( targetUserId, targetUserType, targetProfileId, req.user.userId, profileId, userData.username, userData.avatar, "reaction", targetType, targetId, contentType, contentId, comment.content.substring(0, 100), ); } } } else if (targetType === "reply") { const reply = await dbGet( "SELECT * FROM comment_replies WHERE id = ?", [targetId], ); if (reply) { targetUserId = reply.user_id; targetUserType = reply.user_type; targetProfileId = reply.profile_id; // Récupérer le commentaire parent pour avoir le contentType et contentId const comment = await dbGet("SELECT * FROM comments WHERE id = ?", [ reply.comment_id, ]); if (comment) { contentType = comment.content_type; contentId = comment.content_id; // Ne pas créer de notification si on réagit à sa propre réponse if ( targetUserId !== req.user.userId || targetUserType !== req.user.userType || targetProfileId !== profileId ) { const userData = await getUserData( req.user.userId, req.user.userType, profileId, ); await createNotification( targetUserId, targetUserType, targetProfileId, req.user.userId, profileId, userData.username, userData.avatar, "reaction", targetType, targetId, contentType, contentId, reply.content.substring(0, 100), ); } } } } res.json({ reacted: true }); } } catch (error) { console.error("Erreur lors de la gestion de la réaction:", error); res.status(500).json({ error: "Erreur serveur" }); } }); // GET /api/comments/reactions/:targetType/:targetId - Vérifier si l'utilisateur a réagi router.get( "/reactions/:targetType/:targetId", requireAuth, async (req, res) => { try { const { targetType, targetId } = req.params; const { profileId } = req.query; // Whitelist targetType if (!["comment", "reply"].includes(targetType)) { return res.status(400).json({ error: "targetType invalide" }); } const reaction = await dbGet( "SELECT * FROM comment_reactions WHERE target_type = ? AND target_id = ? AND user_id = ? AND user_type = ? AND profile_id = ?", [targetType, targetId, req.user.userId, req.user.userType, profileId], ); res.json({ reacted: !!reaction }); } catch (error) { console.error("Erreur lors de la vérification de la réaction:", error); res.status(500).json({ error: "Erreur serveur" }); } }, ); // ==================== ROUTES RÉPONSES ==================== // GET /api/comments/:commentId/replies - Récupérer les réponses d'un commentaire (avec ordre hiérarchique) // === #13: Optimisé — une seule requête SQL avec sous-requêtes au lieu de N+1 === router.get("/:commentId/replies", async (req, res) => { try { const { commentId } = req.params; const safePage = Math.max(1, Math.min(parseInt(req.query.page) || 1, 1000)); const safeLimit = Math.max(1, Math.min(parseInt(req.query.limit) || 3, 50)); const offset = (safePage - 1) * safeLimit; // Tenter de récupérer l'utilisateur connecté (optionnel) let currentUser = null; const token = req.headers.authorization?.split(" ")[1]; if (token) { try { const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ["HS256"], }); currentUser = { userId: decoded.sub, userType: decoded.userType, sessionId: decoded.sessionId, }; } catch (error) { // Token invalide, on continue sans utilisateur } } const { profileId } = req.query; // Récupérer le total de réponses const totalResult = await dbGet( "SELECT COUNT(*) as total FROM comment_replies WHERE comment_id = ? AND deleted = 0", [commentId], ); // #13: Requête unique avec COUNT des réactions + check user reaction via sous-requête let repliesQuery; let repliesParams; if (currentUser && profileId) { repliesQuery = ` SELECT cr.*, (SELECT COUNT(*) FROM comment_reactions WHERE target_type = 'reply' AND target_id = cr.id) as reaction_count, EXISTS(SELECT 1 FROM comment_reactions WHERE target_type = 'reply' AND target_id = cr.id AND user_id = ? AND user_type = ? AND profile_id = ?) as user_reacted FROM comment_replies cr WHERE cr.comment_id = ? AND cr.deleted = 0 ORDER BY cr.hierarchical_path ASC LIMIT ? OFFSET ?`; repliesParams = [ currentUser.userId, currentUser.userType, profileId, commentId, safeLimit, offset, ]; } else { repliesQuery = ` SELECT cr.*, (SELECT COUNT(*) FROM comment_reactions WHERE target_type = 'reply' AND target_id = cr.id) as reaction_count FROM comment_replies cr WHERE cr.comment_id = ? AND cr.deleted = 0 ORDER BY cr.hierarchical_path ASC LIMIT ? OFFSET ?`; repliesParams = [commentId, safeLimit, offset]; } const replies = await dbAll(repliesQuery, repliesParams); // Cache utilisateur en mémoire pour cette requête (getUserData est déjà caché dans Redis via #14) const userDataCache = new Map(); const repliesWithDetails = await Promise.all( replies.map(async (reply) => { const userKey = `${reply.user_id}:${reply.user_type}:${reply.profile_id || "default"}`; let userData = userDataCache.get(userKey); if (!userData) { userData = await getUserData( reply.user_id, reply.user_type, reply.profile_id, ); userDataCache.set(userKey, userData); } return { ...reply, content: formatContentForResponse(reply.content), username: userData.username, avatar: userData.avatar, is_vip: userData.isVip ? 1 : 0, is_admin: userData.isAdmin ? 1 : 0, reactions: reply.reaction_count, userReaction: reply.user_reacted !== undefined ? !!reply.user_reacted : null, }; }), ); res.json({ replies: repliesWithDetails, total: totalResult.total, page: safePage, limit: safeLimit, hasMore: offset + repliesWithDetails.length < totalResult.total, }); } catch (error) { console.error("Erreur lors de la récupération des réponses:", error); res.status(500).json({ error: "Erreur serveur" }); } }); // POST /api/comments/:commentId/replies - Créer une réponse router.post("/:commentId/replies", requireAuth, writeRateLimit, async (req, res) => { try { const { commentId } = req.params; let { content, isSpoiler, profileId, parentReplyId, replyToUsername, turnstileToken, } = req.body; // Vérification Turnstile const turnstileResult = await verifyTurnstileFromRequest( req, turnstileToken, ); if (!turnstileResult.valid) return res .status(turnstileResult.status) .json({ error: turnstileResult.error }); // Normalize content while preserving the original characters content = normalizeCommentContent(content); console.log("💬 Création de réponse - Données reçues:", { commentId, content, isSpoiler, profileId, parentReplyId, replyToUsername, }); // Verify profile ownership const userProfileIds = await getProfileIds( req.user.userId, req.user.userType, ); if (!userProfileIds.includes(profileId)) { return res.status(403).json({ error: "Profil non autorisé" }); } // Vérification de bannissement const replyUserData = await getUserData( req.user.userId, req.user.userType, profileId, ); if (!replyUserData.isAdmin) { const banStatus = await checkBan( req.user.userId, req.user.userType, getClientIp(req), ); if (banStatus.banned) { return res.status(403).json({ error: "Vous êtes banni des commentaires.", reason: banStatus.reason, expires_at: banStatus.expires_at, }); } } // Validation if (!content || content.length > 500) { return res .status(400) .json({ error: "La réponse doit contenir entre 1 et 500 caractères" }); } // Vérifier que le commentaire existe const comment = await dbGet( "SELECT * FROM comments WHERE id = ? AND deleted = 0", [commentId], ); if (!comment) { console.log(`❌ Commentaire ${commentId} non trouvé`); return res.status(404).json({ error: "Commentaire non trouvé" }); } console.log(`✅ Commentaire ${commentId} trouvé`); // Récupérer les données utilisateur const userData = await getUserData( req.user.userId, req.user.userType, profileId, ); console.log("👤 Données utilisateur pour la réponse:", userData); // Calculer le hierarchical_path let hierarchicalPath; if (!parentReplyId) { // Réponse racine : trouver le max des réponses racines const maxPath = await dbGet( `SELECT MAX(hierarchical_path) as max_path FROM comment_replies WHERE comment_id = ? AND parent_reply_id IS NULL AND deleted = 0`, [commentId], ); if (maxPath && maxPath.max_path) { // Extraire le numéro et incrémenter const currentNum = parseInt(maxPath.max_path.split(".")[0], 10); hierarchicalPath = String(currentNum + 1).padStart(3, "0"); } else { // Première réponse hierarchicalPath = "001"; } } else { // Réponse à une autre réponse : récupérer le path du parent const parentReply = await dbGet( "SELECT hierarchical_path FROM comment_replies WHERE id = ?", [parentReplyId], ); if (!parentReply || !parentReply.hierarchical_path) { return res.status(404).json({ error: "Réponse parent non trouvée" }); } // Trouver le max des enfants de ce parent const maxChildPath = await dbGet( `SELECT MAX(hierarchical_path) as max_path FROM comment_replies WHERE comment_id = ? AND parent_reply_id = ? AND deleted = 0`, [commentId, parentReplyId], ); if (maxChildPath && maxChildPath.max_path) { // Extraire le dernier numéro et incrémenter const parts = maxChildPath.max_path.split("."); const lastNum = parseInt(parts[parts.length - 1], 10); hierarchicalPath = `${parentReply.hierarchical_path}.${String(lastNum + 1).padStart(3, "0")}`; } else { // Premier enfant de ce parent hierarchicalPath = `${parentReply.hierarchical_path}.001`; } } console.log(`📊 Hierarchical path calculé: ${hierarchicalPath}`); // Insérer la réponse console.log("💾 Insertion de la réponse dans la base..."); const result = await dbRun( `INSERT INTO comment_replies (comment_id, parent_reply_id, user_id, user_type, profile_id, username, avatar, reply_to_username, content, is_spoiler, is_vip, is_admin, created_at, hierarchical_path, ip_address) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ commentId, parentReplyId || null, req.user.userId, req.user.userType, profileId, userData.username, userData.avatar, replyToUsername || null, content, isSpoiler ? 1 : 0, userData.isVip ? 1 : 0, userData.isAdmin ? 1 : 0, Date.now(), hierarchicalPath, getClientIp(req), ], ); console.log("✅ Réponse insérée avec ID:", result.lastID); // Créer une notification pour l'auteur du commentaire ou de la réponse parent let targetUserId, targetUserType, targetProfileId; if (parentReplyId) { const parentReply = await dbGet( "SELECT * FROM comment_replies WHERE id = ?", [parentReplyId], ); targetUserId = parentReply.user_id; targetUserType = parentReply.user_type; targetProfileId = parentReply.profile_id; } else { targetUserId = comment.user_id; targetUserType = comment.user_type; targetProfileId = comment.profile_id; } // Ne pas créer de notification si on se répond à soi-même if ( targetUserId !== req.user.userId || targetUserType !== req.user.userType || targetProfileId !== profileId ) { await createNotification( targetUserId, targetUserType, targetProfileId, req.user.userId, profileId, userData.username, userData.avatar, "reply", "reply", result.lastID, comment.content_type, comment.content_id, content.substring(0, 100), ); } // Récupérer la réponse créée const newReply = await dbGet("SELECT * FROM comment_replies WHERE id = ?", [ result.lastID, ]); // Envoyer le webhook Discord en arrière-plan (ne pas attendre) sendDiscordWebhook("reply", { username: userData.username, avatar: userData.avatar, content, contentType: comment.content_type, contentId: comment.content_id, isSpoiler, isVip: userData.isVip, isAdmin: userData.isAdmin, replyToUsername, userId: req.user.userId, userType: req.user.userType, profileId: profileId, ipAddress: getClientIp(req), }).catch((err) => console.error("Erreur webhook Discord (non bloquant):", err), ); // Modération automatique avec Gemini en arrière-plan (ne pas attendre) moderateContentWithGemini( result.lastID, "reply", content, userData.username, ).catch((err) => console.error("Erreur modération Gemini réponse (non bloquant):", err), ); res.status(201).json({ ...newReply, content: formatContentForResponse(newReply.content), reactions: 0, userReaction: null, }); } catch (error) { console.error("Erreur lors de la création de la réponse:", error); res.status(500).json({ error: "Erreur serveur" }); } }); // PUT /api/comments/replies/:id - Éditer une réponse router.put("/replies/:id", requireAuth, writeRateLimit, async (req, res) => { try { const { id } = req.params; let { content, isSpoiler, profileId } = req.body; // Normalize content while preserving the original characters content = normalizeCommentContent(content); // Validation if (!content || content.length > 500) { return res .status(400) .json({ error: "La réponse doit contenir entre 1 et 500 caractères" }); } // Vérifier que la réponse appartient à l'utilisateur const reply = await dbGet("SELECT * FROM comment_replies WHERE id = ?", [ id, ]); if (!reply) { return res.status(404).json({ error: "Réponse non trouvée" }); } if ( reply.user_id !== req.user.userId || reply.user_type !== req.user.userType ) { return res.status(403).json({ error: "Non autorisé" }); } // Si la réponse a un profile_id, exiger que l'éditeur passe le même profileId // (empêche un autre profil du même compte d'éditer) if (reply.profile_id) { if (!profileId || String(reply.profile_id) !== String(profileId)) { return res .status(403) .json({ error: "Seul le profil auteur peut éditer cette réponse" }); } } // Mettre à jour la réponse await dbRun( "UPDATE comment_replies SET content = ?, is_spoiler = ?, is_edited = 1, updated_at = ? WHERE id = ?", [content, isSpoiler ? 1 : 0, Date.now(), id], ); // Récupérer la réponse mise à jour const updatedReply = await dbGet( "SELECT * FROM comment_replies WHERE id = ?", [id], ); res.json({ ...updatedReply, content: formatContentForResponse(updatedReply.content), }); } catch (error) { console.error("Erreur lors de l'édition de la réponse:", error); res.status(500).json({ error: "Erreur serveur" }); } }); // DELETE /api/comments/replies/:id - Supprimer une réponse (admin ou auteur) router.delete("/replies/:id", requireAuth, writeRateLimit, async (req, res) => { try { const { id } = req.params; const { profileId } = req.query; // Récupérer la réponse const reply = await dbGet( "SELECT comment_id, user_id, user_type, profile_id FROM comment_replies WHERE id = ?", [id], ); if (!reply) { return res.status(404).json({ error: "Réponse non trouvée" }); } // Vérifier que l'utilisateur est admin ou auteur de la réponse const userData = await getUserData(req.user.userId, req.user.userType); const userMatch = String(reply.user_id) === String(req.user.userId) && String(reply.user_type) === String(req.user.userType); // Si la réponse a un profile_id, le profileId fourni doit matcher exactement // (empêche un kid profile de supprimer la réponse d'un adult profile du même compte) let profileMatch = true; if (reply.profile_id) { profileMatch = !!profileId && String(reply.profile_id) === String(profileId); } const isOwner = userMatch && profileMatch; if (!userData.isAdmin && !isOwner) { return res.status(403).json({ error: "Non autorisé" }); } // Supprimer la réponse et tous ses enfants en cascade await deleteRepliesCascade(reply.comment_id, id); res.json({ message: "Réponse et ses réponses enfants supprimées en cascade", }); } catch (error) { console.error("Erreur lors de la suppression de la réponse:", error); res.status(500).json({ error: "Erreur serveur" }); } }); // ==================== ROUTES COMMENTAIRES ==================== // GET /api/comments/admin/list - Lister tous les commentaires (admin uniquement) router.get("/admin/list", requireAuth, async (req, res) => { try { const { page = 1, limit = 50, search = "", contentType = "all", } = req.query; const offset = (page - 1) * limit; // Vérifier que l'utilisateur est admin const userData = await getUserData(req.user.userId, req.user.userType); if (!userData.isAdmin) { return res.status(403).json({ error: "Non autorisé" }); } let query = 'SELECT c.*, COUNT(r.id) as reaction_count FROM comments c LEFT JOIN comment_reactions r ON r.target_type = "comment" AND r.target_id = c.id WHERE c.deleted = 0'; const params = []; if (search) { query += " AND (c.content LIKE ? OR c.username LIKE ? OR c.content_id LIKE ?)"; const searchParam = `%${search}%`; params.push(searchParam, searchParam, searchParam); } if (contentType !== "all") { query += " AND c.content_type = ?"; params.push(contentType); } query += " GROUP BY c.id ORDER BY c.created_at DESC LIMIT ? OFFSET ?"; params.push(parseInt(limit), parseInt(offset)); const rawComments = await dbAll(query, params); // Enrichir avec les données utilisateur actuelles (Username, Avatar, VIP, Admin) const comments = await Promise.all( rawComments.map(async (comment) => { const userData = await getUserData( comment.user_id, comment.user_type, comment.profile_id, ); return { ...comment, content: formatContentForResponse(comment.content), username: userData.username, avatar: userData.avatar, is_vip: userData.isVip, is_admin: userData.isAdmin, }; }), ); // Compter le total pour la pagination let countQuery = "SELECT COUNT(*) as total FROM comments WHERE deleted = 0"; const countParams = []; if (search) { countQuery += " AND (content LIKE ? OR username LIKE ? OR content_id LIKE ?)"; const searchParam = `%${search}%`; countParams.push(searchParam, searchParam, searchParam); } if (contentType !== "all") { countQuery += " AND content_type = ?"; countParams.push(contentType); } const totalResult = await dbGet(countQuery, countParams); // Stats — une seule requête avec CASE pour compter les stats const statsResult = await dbGet(` SELECT COUNT(*) as total, SUM(CASE WHEN content_type = 'movie' THEN 1 ELSE 0 END) as movies, SUM(CASE WHEN content_type = 'tv' THEN 1 ELSE 0 END) as tv FROM comments WHERE deleted = 0 `); const moderatedCount = ( await dbGet(` SELECT (SELECT COUNT(*) FROM comments WHERE deleted = 1 AND moderation_reason IS NOT NULL) + (SELECT COUNT(*) FROM comment_replies WHERE deleted = 1 AND moderation_reason IS NOT NULL) as total `) ).total; const stats = { total: statsResult.total, movies: statsResult.movies, tv: statsResult.tv, moderated: moderatedCount, }; res.json({ comments, stats, total: totalResult.total, page: parseInt(page), limit: parseInt(limit), }); } catch (error) { console.error( "Erreur lors de la récupération admin des commentaires:", error, ); res.status(500).json({ error: "Erreur serveur" }); } }); // GET /api/comments/admin/moderated - Lister les commentaires/réponses modérés (admin uniquement) // === #17: Pagination SQL avec UNION ALL au lieu de charger tout en mémoire === router.get("/admin/moderated", requireAuth, async (req, res) => { try { const { page = 1, limit = 50, type = "all" } = req.query; const safePage = Math.max(1, parseInt(page) || 1); const safeLimit = Math.max(1, Math.min(parseInt(limit) || 50, 100)); const offset = (safePage - 1) * safeLimit; // Vérifier que l'utilisateur est admin const userData = await getUserData(req.user.userId, req.user.userType); if (!userData.isAdmin) { return res.status(403).json({ error: "Non autorisé" }); } // Construire les requêtes avec UNION ALL et pagination SQL let unionParts = []; let countParts = []; if (type === "all" || type === "comments") { unionParts.push(` SELECT c.id, c.user_id, c.user_type, c.profile_id, c.content, c.content_type, c.content_id, c.is_spoiler, c.is_edited, c.created_at, c.moderation_reason, c.moderation_details, c.moderated_at, 'comment' as item_type, (SELECT COUNT(*) FROM comment_reactions WHERE target_type = 'comment' AND target_id = c.id) as reaction_count FROM comments c WHERE c.deleted = 1 AND c.moderation_reason IS NOT NULL `); countParts.push( `SELECT COUNT(*) as cnt FROM comments WHERE deleted = 1 AND moderation_reason IS NOT NULL`, ); } if (type === "all" || type === "replies") { unionParts.push(` SELECT cr.id, cr.user_id, cr.user_type, cr.profile_id, cr.content, c2.content_type, c2.content_id, cr.is_spoiler, cr.is_edited, cr.created_at, cr.moderation_reason, cr.moderation_details, cr.moderated_at, 'reply' as item_type, (SELECT COUNT(*) FROM comment_reactions WHERE target_type = 'reply' AND target_id = cr.id) as reaction_count FROM comment_replies cr JOIN comments c2 ON c2.id = cr.comment_id WHERE cr.deleted = 1 AND cr.moderation_reason IS NOT NULL `); countParts.push( `SELECT COUNT(*) as cnt FROM comment_replies WHERE deleted = 1 AND moderation_reason IS NOT NULL`, ); } if (unionParts.length === 0) { return res.json({ items: [], total: 0, page: safePage, limit: safeLimit, hasMore: false, }); } // Compter le total const countQuery = countParts.length === 1 ? countParts[0] : `SELECT (${countParts.map((q) => `(${q})`).join(" + ")}) as cnt`; const totalResult = await dbGet(countQuery); const total = totalResult.cnt; // Requête combinée avec UNION ALL, tri et pagination const dataQuery = ` SELECT * FROM ( ${unionParts.join(" UNION ALL ")} ) AS combined ORDER BY moderated_at DESC LIMIT ? OFFSET ? `; const items = await dbAll(dataQuery, [safeLimit, offset]); // Enrichir avec les données utilisateur (getUserData est caché dans Redis via #14) const userDataCache = new Map(); const enrichedItems = await Promise.all( items.map(async (item) => { const userKey = `${item.user_id}:${item.user_type}:${item.profile_id || "default"}`; let ud = userDataCache.get(userKey); if (!ud) { ud = await getUserData(item.user_id, item.user_type, item.profile_id); userDataCache.set(userKey, ud); } return { ...item, content: formatContentForResponse(item.content), username: ud.username, avatar: ud.avatar, is_vip: ud.isVip, is_admin: ud.isAdmin, }; }), ); res.json({ items: enrichedItems, total, page: safePage, limit: safeLimit, hasMore: offset + enrichedItems.length < total, }); } catch (error) { console.error( "Erreur lors de la récupération des contenus modérés:", error, ); res.status(500).json({ error: "Erreur serveur" }); } }); // PUT /api/comments/admin/moderated/:type/:id/approve - Approuver un contenu modéré (rendre visible) router.put( "/admin/moderated/:type/:id/approve", requireAuth, async (req, res) => { try { const { type, id } = req.params; // Validate type parameter to prevent SQL injection via table name if (!["comment", "reply"].includes(type)) { return res .status(400) .json({ error: 'Type invalide. Doit être "comment" ou "reply"' }); } // Vérifier que l'utilisateur est admin const userData = await getUserData(req.user.userId, req.user.userType); if (!userData.isAdmin) { return res.status(403).json({ error: "Non autorisé" }); } const table = type === "comment" ? "comments" : "comment_replies"; // Remettre deleted à 0 et garder les infos de modération pour historique await dbRun( `UPDATE ${table} SET deleted = 0, approved_by_admin = 1, approved_at = ? WHERE id = ?`, [Date.now(), id], ); console.log(`✅ ${type} ID ${id} approuvé par admin`); res.json({ message: "Contenu approuvé et rendu visible" }); } catch (error) { console.error("Erreur lors de l'approbation du contenu:", error); res.status(500).json({ error: "Erreur serveur" }); } }, ); // DELETE /api/comments/admin/moderated/:type/:id - Supprimer définitivement un contenu modéré router.delete("/admin/moderated/:type/:id", requireAuth, async (req, res) => { try { const { type, id } = req.params; // Validate type parameter if (!["comment", "reply"].includes(type)) { return res .status(400) .json({ error: 'Type invalide. Doit être "comment" ou "reply"' }); } // Vérifier que l'utilisateur est admin const userData = await getUserData(req.user.userId, req.user.userType); if (!userData.isAdmin) { return res.status(403).json({ error: "Non autorisé" }); } if (type === "comment") { // Supprimer définitivement le commentaire et toutes ses dépendances await hardDeleteCommentCascade(id); } else if (type === "reply") { // Récupérer la réponse pour obtenir le commentId const reply = await dbGet( "SELECT comment_id FROM comment_replies WHERE id = ?", [id], ); if (reply) { await hardDeleteRepliesCascade(reply.comment_id, id); } } console.log(`🗑️ ${type} ID ${id} supprimé définitivement par admin`); res.json({ message: "Contenu supprimé définitivement" }); } catch (error) { console.error("Erreur lors de la suppression définitive:", error); res.status(500).json({ error: "Erreur serveur" }); } }); // ==================== ROUTES BANNISSEMENT ==================== // IMPORTANT: Ces routes doivent être AVANT /:contentType/:contentId pour éviter les conflits Express // POST /api/comments/admin/ban - Bannir un utilisateur ou une IP router.post("/admin/ban", requireAuth, async (req, res) => { try { const userData = await getUserData(req.user.userId, req.user.userType); if (!userData.isAdmin) { return res.status(403).json({ error: "Non autorisé" }); } const { banType, banValue, userType: targetUserType, reason, duration, username, deleteAll, } = req.body; if (!["ip", "user"].includes(banType) || !banValue) { return res .status(400) .json({ error: "banType (ip/user) et banValue requis" }); } if (banType === "user" && !targetUserType) { return res .status(400) .json({ error: "userType requis pour un ban utilisateur" }); } let expiresAt = null; if (duration && duration !== "permanent") { const durations = { "1h": 3600000, "24h": 86400000, "7d": 604800000, "30d": 2592000000, }; if (durations[duration]) { expiresAt = Date.now() + durations[duration]; } } const userTypeValue = banType === "user" ? targetUserType : null; const existingBan = await dbGet( "SELECT id FROM banned_users WHERE ban_type = ? AND ban_value = ? AND (user_type = ? OR (user_type IS NULL AND ? IS NULL))", [banType, banValue, userTypeValue, userTypeValue], ); if (existingBan) { await dbRun( "UPDATE banned_users SET reason = ?, banned_by = ?, banned_at = ?, expires_at = ?, username = ? WHERE id = ?", [ reason || null, req.user.userId, Date.now(), expiresAt, username || null, existingBan.id, ], ); } else { await dbRun( "INSERT INTO banned_users (ban_type, ban_value, user_type, reason, banned_by, banned_at, expires_at, username) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [ banType, banValue, userTypeValue, reason || null, req.user.userId, Date.now(), expiresAt, username || null, ], ); } let deletedCount = 0; if (deleteAll && banType === "user") { const commentsResult = await dbRun( "UPDATE comments SET deleted = 1 WHERE user_id = ? AND deleted = 0", [banValue], ); const repliesResult = await dbRun( "UPDATE comment_replies SET deleted = 1 WHERE user_id = ? AND deleted = 0", [banValue], ); deletedCount = (commentsResult.changes || 0) + (repliesResult.changes || 0); } res.json({ message: "Utilisateur banni avec succès", deletedCount, expiresAt, }); } catch (error) { console.error("Erreur lors du bannissement:", error); res.status(500).json({ error: "Erreur serveur" }); } }); // DELETE /api/comments/admin/ban/:id - Débannir + restaurer les commentaires router.delete("/admin/ban/:id", requireAuth, async (req, res) => { try { const userData = await getUserData(req.user.userId, req.user.userType); if (!userData.isAdmin) { return res.status(403).json({ error: "Non autorisé" }); } const { id } = req.params; const restoreComments = req.query.restore === "true"; let restoredCount = 0; if (restoreComments) { const ban = await dbGet("SELECT * FROM banned_users WHERE id = ?", [id]); if (ban && ban.ban_type === "user") { const r1 = await dbRun( "UPDATE comments SET deleted = 0 WHERE user_id = ? AND deleted = 1 AND moderation_reason IS NULL", [ban.ban_value], ); const r2 = await dbRun( "UPDATE comment_replies SET deleted = 0 WHERE user_id = ? AND deleted = 1 AND moderation_reason IS NULL", [ban.ban_value], ); restoredCount = (r1.changes || 0) + (r2.changes || 0); } } await dbRun("DELETE FROM banned_users WHERE id = ?", [id]); res.json({ message: "Ban supprimé avec succès", restoredCount }); } catch (error) { console.error("Erreur lors du débannissement:", error); res.status(500).json({ error: "Erreur serveur" }); } }); // DELETE /api/comments/admin/bans - Supprimer tous les bans + restaurer les commentaires router.delete("/admin/bans", requireAuth, async (req, res) => { try { const userData = await getUserData(req.user.userId, req.user.userType); if (!userData.isAdmin) { return res.status(403).json({ error: "Non autorisé" }); } const restoreComments = req.query.restore === "true"; let restoredCount = 0; if (restoreComments) { const userBans = await dbAll( "SELECT ban_value FROM banned_users WHERE ban_type = 'user'", ); if (userBans.length > 0) { const placeholders = userBans.map(() => "?").join(","); const userIds = userBans.map((b) => b.ban_value); const r1 = await dbRun( `UPDATE comments SET deleted = 0 WHERE user_id IN (${placeholders}) AND deleted = 1 AND moderation_reason IS NULL`, userIds, ); const r2 = await dbRun( `UPDATE comment_replies SET deleted = 0 WHERE user_id IN (${placeholders}) AND deleted = 1 AND moderation_reason IS NULL`, userIds, ); restoredCount = (r1.changes || 0) + (r2.changes || 0); } } const result = await dbRun("DELETE FROM banned_users"); res.json({ message: `${result.changes || 0} ban(s) supprimé(s)`, deletedCount: result.changes || 0, restoredCount, }); } catch (error) { console.error("Erreur lors de la suppression de tous les bans:", error); res.status(500).json({ error: "Erreur serveur" }); } }); // GET /api/comments/admin/bans - Lister les bans actifs router.get("/admin/bans", requireAuth, async (req, res) => { try { const userData = await getUserData(req.user.userId, req.user.userType); if (!userData.isAdmin) { return res.status(403).json({ error: "Non autorisé" }); } const { showExpired, page = "1", limit = "30", search } = req.query; const pageNum = Math.max(1, parseInt(page)); const limitNum = Math.min(100, Math.max(1, parseInt(limit))); const offset = (pageNum - 1) * limitNum; let whereClause = ""; const params = []; const countParams = []; if (showExpired !== "true") { whereClause = " WHERE (expires_at IS NULL OR expires_at > ?)"; params.push(Date.now()); countParams.push(Date.now()); } if (search && search.trim()) { const searchTerm = `%${search.trim()}%`; whereClause += whereClause ? " AND" : " WHERE"; whereClause += " (ban_value LIKE ? OR username LIKE ? OR reason LIKE ?)"; params.push(searchTerm, searchTerm, searchTerm); countParams.push(searchTerm, searchTerm, searchTerm); } const countResult = await dbAll( `SELECT COUNT(*) as total FROM banned_users${whereClause}`, countParams, ); const total = countResult[0]?.total || 0; params.push(limitNum, offset); const bans = await dbAll( `SELECT * FROM banned_users${whereClause} ORDER BY banned_at DESC LIMIT ? OFFSET ?`, params, ); res.json({ bans, total, page: pageNum, limit: limitNum, hasMore: offset + bans.length < total, }); } catch (error) { console.error("Erreur lors de la récupération des bans:", error); res.status(500).json({ error: "Erreur serveur" }); } }); // POST /api/comments/admin/delete-all-by-user - Supprimer tous les commentaires d'un utilisateur router.post("/admin/delete-all-by-user", requireAuth, async (req, res) => { try { const userData = await getUserData(req.user.userId, req.user.userType); if (!userData.isAdmin) { return res.status(403).json({ error: "Non autorisé" }); } const { userId } = req.body; if (!userId) { return res.status(400).json({ error: "userId requis" }); } const commentsResult = await dbRun( "UPDATE comments SET deleted = 1 WHERE user_id = ? AND deleted = 0", [userId], ); const repliesResult = await dbRun( "UPDATE comment_replies SET deleted = 1 WHERE user_id = ? AND deleted = 0", [userId], ); const totalDeleted = (commentsResult.changes || 0) + (repliesResult.changes || 0); res.json({ message: `${totalDeleted} commentaire(s) et réponse(s) supprimé(s)`, deletedComments: commentsResult.changes || 0, deletedReplies: repliesResult.changes || 0, }); } catch (error) { console.error("Erreur lors de la suppression en masse:", error); res.status(500).json({ error: "Erreur serveur" }); } }); // Normalise le texte Unicode : petites majuscules, caractères spéciaux → ASCII, puis trim + lowercase function normalizeText(text) { if (!text) return ""; // Map des petites majuscules Unicode et variantes courantes → ASCII const unicodeMap = { ᴀ: "a", ʙ: "b", ᴄ: "c", ᴅ: "d", ᴇ: "e", ꜰ: "f", ɢ: "g", ʜ: "h", ɪ: "i", ᴊ: "j", ᴋ: "k", ʟ: "l", ᴍ: "m", ɴ: "n", ᴏ: "o", ᴘ: "p", ǫ: "q", ʀ: "r", ꜱ: "s", ᴛ: "t", ᴜ: "u", ᴠ: "v", ᴡ: "w", x: "x", ʏ: "y", ᴢ: "z", a: "a", b: "b", c: "c", d: "d", e: "e", f: "f", g: "g", h: "h", i: "i", j: "j", k: "k", l: "l", m: "m", n: "n", o: "o", p: "p", q: "q", r: "r", s: "s", t: "t", u: "u", v: "v", w: "w", x: "x", y: "y", z: "z", A: "a", B: "b", C: "c", D: "d", E: "e", F: "f", G: "g", H: "h", I: "i", J: "j", K: "k", L: "l", M: "m", N: "n", O: "o", P: "p", Q: "q", R: "r", S: "s", T: "t", U: "u", V: "v", W: "w", X: "x", Y: "y", Z: "z", "𝐚": "a", "𝐛": "b", "𝐜": "c", "𝐝": "d", "𝐞": "e", "𝐟": "f", "𝐠": "g", "𝐡": "h", "𝐢": "i", "𝐣": "j", "𝐤": "k", "𝐥": "l", "𝐦": "m", "𝐧": "n", "𝐨": "o", "𝐩": "p", "𝐪": "q", "𝐫": "r", "𝐬": "s", "𝐭": "t", "𝐮": "u", "𝐯": "v", "𝐰": "w", "𝐱": "x", "𝐲": "y", "𝐳": "z", "⒜": "a", "⒝": "b", "⒞": "c", "⒟": "d", "⒠": "e", "⒡": "f", "⒢": "g", "⒣": "h", "⒤": "i", "⒥": "j", "⒦": "k", "⒧": "l", "⒨": "m", "⒩": "n", "⒪": "o", "⒫": "p", "⒬": "q", "⒭": "r", "⒮": "s", "⒯": "t", "⒰": "u", "⒱": "v", "⒲": "w", "⒳": "x", "⒴": "y", "⒵": "z", ".": ".", "。": ".", "·": ".", "⋅": ".", "∙": ".", }; let normalized = ""; for (const char of text) { normalized += unicodeMap[char] || char; } return normalized .normalize("NFKD") .replace(/[\u0300-\u036f]/g, "") // supprimer les accents diacritiques .toLowerCase() .trim() .replace(/\s+/g, " "); // normaliser les espaces multiples } // Détecte si un texte contient des caractères Unicode "fancy" (spam) function containsFancyUnicode(text) { if (!text) return false; // IPA Extensions (ʟ,ʀ,ɴ,ʏ,ɢ,ʙ,ʜ,ɪ), petites majuscules latines, fullwidth, math bold/italic, parenthesized, circled return /[\u0250-\u02AF\u1D00-\u1D7F\uFF01-\uFF5E\u{1D400}-\u{1D7FF}\u2474-\u2497\u249C-\u24E9\u2460-\u2473]/u.test( text, ); } // POST /api/comments/admin/detect-duplicates - Détecter les commentaires en doublons router.post("/admin/detect-duplicates", requireAuth, async (req, res) => { try { const userData = await getUserData(req.user.userId, req.user.userType); if (!userData.isAdmin) { return res.status(403).json({ error: "Non autorisé" }); } // Récupérer tous les commentaires non supprimés const allComments = await dbAll(` SELECT id, user_id, user_type, profile_id, username, avatar, content, content_type, content_id, created_at FROM comments WHERE deleted = 0 ORDER BY created_at ASC `); // Grouper par contenu normalisé (trim + lowercase + unicode → ASCII) const groupMap = new Map(); const fancyUnicodeComments = []; for (const c of allComments) { const key = normalizeText(c.content); if (!groupMap.has(key)) { groupMap.set(key, { content: c.content, normalizedContent: key, count: 0, firstPosted: c.created_at, lastPosted: c.created_at, comments: [], }); } const group = groupMap.get(key); group.count++; group.lastPosted = c.created_at; group.comments.push({ id: c.id, user_id: c.user_id, user_type: c.user_type, profile_id: c.profile_id, username: c.username, avatar: c.avatar, content: c.content, content_type: c.content_type, content_id: c.content_id, created_at: c.created_at, ip_address: c.ip_address, }); // Détecter les commentaires avec polices Unicode spéciales (spam) if (containsFancyUnicode(c.content)) { fancyUnicodeComments.push({ id: c.id, user_id: c.user_id, user_type: c.user_type, profile_id: c.profile_id, username: c.username, avatar: c.avatar, content: c.content, content_type: c.content_type, content_id: c.content_id, created_at: c.created_at, ip_address: c.ip_address, }); } } // Ne garder que les vrais doublons (>1 commentaire par groupe) const duplicates = [...groupMap.values()] .filter((g) => g.count > 1) .sort((a, b) => b.count - a.count) .slice(0, 50); // Grouper les commentaires fancy unicode const fancyGroup = fancyUnicodeComments.length > 0 ? { content: "⚠️ Polices Unicode spéciales (spam)", normalizedContent: "__fancy_unicode__", count: fancyUnicodeComments.length, firstPosted: fancyUnicodeComments[0]?.created_at, lastPosted: fancyUnicodeComments[fancyUnicodeComments.length - 1]?.created_at, comments: fancyUnicodeComments, isFancyUnicode: true, } : null; res.json({ duplicates, fancyUnicode: fancyGroup }); } catch (error) { console.error("Erreur lors de la détection des doublons:", error); res.status(500).json({ error: "Erreur serveur" }); } }); // POST /api/comments/admin/delete-duplicates - Supprimer les doublons et optionnellement bannir les auteurs router.post("/admin/delete-duplicates", requireAuth, async (req, res) => { try { const userData = await getUserData(req.user.userId, req.user.userType); if (!userData.isAdmin) { return res.status(403).json({ error: "Non autorisé" }); } const { commentIds, banAuthors, banDuration, banReason } = req.body; if (!commentIds || !Array.isArray(commentIds) || commentIds.length === 0) { return res.status(400).json({ error: "commentIds requis (tableau)" }); } const BATCH_SIZE = 500; let totalDeleted = 0; for (let i = 0; i < commentIds.length; i += BATCH_SIZE) { const batch = commentIds.slice(i, i + BATCH_SIZE); const placeholders = batch.map(() => "?").join(","); const result = await dbRun( `UPDATE comments SET deleted = 1 WHERE id IN (${placeholders}) AND deleted = 0`, batch, ); totalDeleted += result.changes || 0; } let bannedCount = 0; if (banAuthors) { const allPlaceholders = commentIds.map(() => "?").join(","); const authors = await dbAll( `SELECT DISTINCT user_id, user_type, username FROM comments WHERE id IN (${allPlaceholders})`, commentIds, ); if (authors.length > 0) { const adminCheckPlaceholders = authors.map(() => "(?, ?)").join(","); const adminCheckParams = authors.flatMap((a) => [ a.user_id, a.user_type === "bip39" ? "bip-39" : a.user_type, ]); const adminRows = await dbAll( `SELECT user_id, auth_type FROM admins WHERE (user_id, auth_type) IN (${adminCheckPlaceholders})`, adminCheckParams, ); const adminSet = new Set( adminRows.map((a) => `${a.user_id}:${a.auth_type}`), ); const banCheckPlaceholders = authors.map(() => "(?, ?)").join(","); const banCheckParams = authors.flatMap((a) => [a.user_id, a.user_type]); const existingBans = await dbAll( `SELECT ban_value, user_type FROM banned_users WHERE ban_type = 'user' AND (ban_value, user_type) IN (${banCheckPlaceholders})`, banCheckParams, ); const bannedSet = new Set( existingBans.map((b) => `${b.ban_value}:${b.user_type}`), ); let expiresAt = null; if (banDuration && banDuration !== "permanent") { const durations = { "1h": 3600000, "24h": 86400000, "7d": 604800000, "30d": 2592000000, }; if (durations[banDuration]) expiresAt = Date.now() + durations[banDuration]; } const now = Date.now(); const reasonText = banReason || "Spam / Doublons"; const toBan = authors.filter((a) => { const authType = a.user_type === "bip39" ? "bip-39" : a.user_type; const isAdmin = adminSet.has(`${a.user_id}:${authType}`); const alreadyBanned = bannedSet.has(`${a.user_id}:${a.user_type}`); return !isAdmin && !alreadyBanned; }); for (let i = 0; i < toBan.length; i += 100) { const batch = toBan.slice(i, i + 100); const insertPlaceholders = batch .map(() => "(?, ?, ?, ?, ?, ?, ?, ?)") .join(","); const insertParams = batch.flatMap((a) => [ "user", a.user_id, a.user_type, reasonText, req.user.userId, now, expiresAt, a.username || null, ]); await dbRun( `INSERT IGNORE INTO banned_users (ban_type, ban_value, user_type, reason, banned_by, banned_at, expires_at, username) VALUES ${insertPlaceholders}`, insertParams, ); } bannedCount = toBan.length; } } res.json({ message: `${totalDeleted} commentaire(s) supprimé(s), ${bannedCount} utilisateur(s) banni(s)`, deletedCount: totalDeleted, bannedCount, }); } catch (error) { console.error("Erreur lors de la suppression des doublons:", error); res.status(500).json({ error: "Erreur serveur" }); } }); // GET /api/comments/limits - Vérifier les limites de commentaires router.get("/limits", requireAuth, async (req, res) => { try { const { contentType, contentId, profileId } = req.query; if (!contentType || !contentId) { return res.status(400).json({ error: "contentType et contentId requis" }); } const userData = await getUserData( req.user.userId, req.user.userType, profileId, ); if (userData.isAdmin) { return res.json({ movieCount: 0, hourCount: 0, movieLimit: null, hourLimit: null, isAdmin: true, }); } const movieComments = await dbGet( "SELECT COUNT(*) as count FROM comments WHERE user_id = ? AND content_type = ? AND content_id = ? AND deleted = 0", [req.user.userId, contentType, contentId], ); const oneHourAgo = Date.now() - 3600000; const hourComments = await dbGet( "SELECT COUNT(*) as count FROM comments WHERE user_id = ? AND created_at > ?", [req.user.userId, oneHourAgo], ); res.json({ movieCount: movieComments.count, hourCount: hourComments.count, movieLimit: 3, hourLimit: 10, isAdmin: false, }); } catch (error) { console.error("Erreur lors de la récupération des limites:", error); res.status(500).json({ error: "Erreur serveur" }); } }); // ==================== ROUTES REPORTS (avant les routes dynamiques) ==================== const reportRateLimit = rateLimit({ windowMs: 15 * 60 * 1000, max: 10, store: createRedisRateLimitStore({ prefix: "rate-limit:comments:report:", windowMs: 15 * 60 * 1000, }), passOnStoreError: true, standardHeaders: true, legacyHeaders: false, message: { error: "Trop de signalements. Réessayez dans 15 minutes." }, keyGenerator: (req) => req.headers["cf-connecting-ip"] || req.headers["x-forwarded-for"]?.split(",")[0].trim() || ipKeyGenerator(req.ip), validate: { xForwardedForHeader: false, ip: false, keyGeneratorIpFallback: false, }, }); const VALID_REPORT_REASONS = [ "spam", "harassment", "sexual_content", "unmarked_spoiler", "impersonation", "other", ]; const VALID_TARGET_TYPES = ["comment", "reply", "shared_list"]; // POST /api/comments/report - Créer un signalement router.post("/report", requireAuth, reportRateLimit, async (req, res) => { try { const { targetType, targetId, reason, details } = req.body; const { userId, userType } = req.user; const profileId = req.body.profileId; if (!targetType || !targetId || !reason) { return res .status(400) .json({ error: "targetType, targetId et reason sont requis" }); } if (!VALID_TARGET_TYPES.includes(targetType)) { return res.status(400).json({ error: "targetType invalide" }); } if (!VALID_REPORT_REASONS.includes(reason)) { return res.status(400).json({ error: "reason invalide" }); } if (!profileId) { return res.status(400).json({ error: "profileId requis" }); } if (details && details.length > 500) { return res .status(400) .json({ error: "Les détails ne peuvent pas dépasser 500 caractères" }); } const allowedProfiles = await getProfileIds(userId, userType); if (allowedProfiles.length > 0 && !allowedProfiles.includes(profileId)) { return res .status(403) .json({ error: "Ce profil ne vous appartient pas" }); } if (targetType === "comment") { const comment = await dbGet( "SELECT id, user_id, profile_id FROM comments WHERE id = ? AND deleted = 0", [targetId], ); if (!comment) return res.status(404).json({ error: "Commentaire non trouvé" }); if ( String(comment.user_id) === String(userId) && String(comment.profile_id) === String(profileId) ) { return res .status(400) .json({ error: "Vous ne pouvez pas signaler votre propre commentaire", }); } } else if (targetType === "reply") { const reply = await dbGet( "SELECT id, user_id, profile_id FROM comment_replies WHERE id = ? AND deleted = 0", [targetId], ); if (!reply) return res.status(404).json({ error: "Réponse non trouvée" }); if ( String(reply.user_id) === String(userId) && String(reply.profile_id) === String(profileId) ) { return res .status(400) .json({ error: "Vous ne pouvez pas signaler votre propre réponse" }); } } else if (targetType === "shared_list") { const pool = getCachedPool(); const [rows] = await pool.execute( "SELECT id, user_id, share_code FROM shared_lists WHERE share_code = ? OR id = ?", [targetId, targetId], ); if (rows.length === 0) return res.status(404).json({ error: "Liste partagée non trouvée" }); if (String(rows[0].user_id) === String(userId)) { return res .status(400) .json({ error: "Vous ne pouvez pas signaler votre propre liste" }); } } try { await dbRun( `INSERT INTO reports (reporter_user_id, reporter_user_type, reporter_profile_id, target_type, target_id, reason, details, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [ userId, userType, profileId, targetType, targetId, reason, details || null, Date.now(), ], ); } catch (err) { if (err.code === "ER_DUP_ENTRY") { return res .status(409) .json({ error: "Vous avez déjà signalé ce contenu" }); } throw err; } res.status(201).json({ success: true, message: "Signalement envoyé" }); } catch (error) { console.error("Erreur lors du signalement:", error); res.status(500).json({ error: "Erreur serveur" }); } }); // GET /api/comments/admin/reports - Lister les signalements (admin) router.get("/admin/reports", requireAuth, async (req, res) => { try { const userData = await getUserData(req.user.userId, req.user.userType); if (!userData.isAdmin) return res.status(403).json({ error: "Non autorisé" }); const status = req.query.status || "pending"; const targetType = req.query.targetType || "all"; const pageNum = Math.max(1, Number(req.query.page) || 1); const limitNum = Math.min(100, Math.max(1, Number(req.query.limit) || 30)); const offset = (pageNum - 1) * limitNum; let query = `SELECT r.*, (SELECT COUNT(*) FROM reports r2 WHERE r2.target_type = r.target_type AND r2.target_id = r.target_id AND r2.status = 'pending') as report_count FROM reports r WHERE 1=1`; const params = []; if (status !== "all") { query += " AND r.status = ?"; params.push(status); } if (targetType !== "all") { query += " AND r.target_type = ?"; params.push(targetType); } query += " ORDER BY r.created_at DESC LIMIT ? OFFSET ?"; params.push(limitNum, offset); const reports = await dbAll(query, params); const enriched = await Promise.all( reports.map(async (report) => { let reporterData = { username: "Inconnu", avatar: null }; try { reporterData = await getUserData( report.reporter_user_id, report.reporter_user_type, report.reporter_profile_id, ); } catch { /* fallback */ } let targetData = {}; try { if (report.target_type === "comment") { const comment = await dbGet( "SELECT id, content, content_id, content_type, user_id, user_type, profile_id, username, created_at FROM comments WHERE id = ?", [report.target_id], ); if (comment) { const authorData = await getUserData( comment.user_id, comment.user_type, comment.profile_id, ); targetData = { ...comment, content: formatContentForResponse(comment.content), authorUsername: authorData.username, authorAvatar: authorData.avatar, }; } else { targetData = { deleted: true }; } } else if (report.target_type === "reply") { const reply = await dbGet( "SELECT id, content, comment_id, user_id, user_type, profile_id, username, created_at FROM comment_replies WHERE id = ?", [report.target_id], ); if (reply) { const authorData = await getUserData( reply.user_id, reply.user_type, reply.profile_id, ); const parentComment = await dbGet( "SELECT content_id, content_type FROM comments WHERE id = ?", [reply.comment_id], ); targetData = { ...reply, content: formatContentForResponse(reply.content), authorUsername: authorData.username, authorAvatar: authorData.avatar, content_id: parentComment?.content_id, content_type: parentComment?.content_type, }; } else { targetData = { deleted: true }; } } else if (report.target_type === "shared_list") { const pool = getCachedPool(); const [rows] = await pool.execute( "SELECT id, name, user_id, share_code FROM shared_lists WHERE share_code = ? OR id = ?", [report.target_id, report.target_id], ); if (rows.length > 0) { targetData = rows[0]; } else { targetData = { deleted: true }; } } } catch (err) { console.error("Erreur enrichissement report:", err.message); targetData = { error: true }; } return { ...report, reporter: { username: reporterData.username, avatar: reporterData.avatar, }, target: targetData, }; }), ); const statsResult = await dbGet(` SELECT IFNULL(COUNT(*), 0) as total, IFNULL(SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END), 0) as pending, IFNULL(SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END), 0) as resolved, IFNULL(SUM(CASE WHEN status = 'dismissed' THEN 1 ELSE 0 END), 0) as dismissed, IFNULL(SUM(CASE WHEN target_type = 'comment' OR target_type = 'reply' THEN 1 ELSE 0 END), 0) as comments, IFNULL(SUM(CASE WHEN target_type = 'shared_list' THEN 1 ELSE 0 END), 0) as lists FROM reports `); let countQuery = "SELECT COUNT(*) as total FROM reports WHERE 1=1"; const countParams = []; if (status !== "all") { countQuery += " AND status = ?"; countParams.push(status); } if (targetType !== "all") { countQuery += " AND target_type = ?"; countParams.push(targetType); } const totalResult = await dbGet(countQuery, countParams); const total = Number(totalResult?.total) || 0; res.json({ success: true, reports: enriched, stats: { total: Number(statsResult?.total) || 0, pending: Number(statsResult?.pending) || 0, resolved: Number(statsResult?.resolved) || 0, dismissed: Number(statsResult?.dismissed) || 0, comments: Number(statsResult?.comments) || 0, lists: Number(statsResult?.lists) || 0, }, total, hasMore: offset + limitNum < total, }); } catch (error) { console.error("Erreur lors de la récupération des reports:", error); res.status(500).json({ error: "Erreur serveur" }); } }); // PUT /api/comments/admin/reports/:id/resolve router.put("/admin/reports/:id/resolve", requireAuth, async (req, res) => { try { const userData = await getUserData(req.user.userId, req.user.userType); if (!userData.isAdmin) return res.status(403).json({ error: "Non autorisé" }); const { id } = req.params; const { deleteContent } = req.body; const report = await dbGet("SELECT * FROM reports WHERE id = ? AND status = 'pending'", [id]); if (!report) return res.status(404).json({ error: "Signalement non trouvé" }); const pendingReports = await dbAll( "SELECT reporter_user_id, reporter_user_type, reporter_profile_id, target_type, target_id FROM reports WHERE target_type = ? AND target_id = ? AND status = 'pending'", [report.target_type, report.target_id], ); const notificationTarget = await getReportNotificationTarget(report); if (deleteContent) { if (report.target_type === "comment") { await deleteCommentCascade(report.target_id); } else if (report.target_type === "reply") { const reply = await dbGet( "SELECT comment_id FROM comment_replies WHERE id = ?", [report.target_id], ); if (reply) await deleteRepliesCascade(reply.comment_id, report.target_id); } else if (report.target_type === "shared_list") { const pool = getCachedPool(); await pool.execute( "DELETE FROM shared_lists WHERE share_code = ? OR id = ?", [report.target_id, report.target_id], ); } } const updateResult = await dbRun( `UPDATE reports SET status = 'resolved', resolved_by = ?, resolved_at = ? WHERE target_type = ? AND target_id = ? AND status = 'pending'`, [req.user.userId, Date.now(), report.target_type, report.target_id], ); if (updateResult.changes > 0 && pendingReports.length > 0) { const notificationType = deleteContent ? REPORT_NOTIFICATION_TYPES.RESOLVED_DELETED : REPORT_NOTIFICATION_TYPES.RESOLVED; await Promise.all(pendingReports.map(async (pendingReport) => { await createNotification( pendingReport.reporter_user_id, pendingReport.reporter_user_type, pendingReport.reporter_profile_id, req.user.userId, null, userData.username, userData.avatar, notificationType, pendingReport.target_type, notificationTarget.targetId, notificationTarget.contentType, notificationTarget.contentId, null, ); })); } res.json({ success: true, message: "Signalement résolu" }); } catch (error) { console.error("Erreur résolution report:", error); res.status(500).json({ error: "Erreur serveur" }); } }); // PUT /api/comments/admin/reports/:id/dismiss router.put("/admin/reports/:id/dismiss", requireAuth, async (req, res) => { try { const userData = await getUserData(req.user.userId, req.user.userType); if (!userData.isAdmin) return res.status(403).json({ error: "Non autorisé" }); const { id } = req.params; const report = await dbGet("SELECT * FROM reports WHERE id = ? AND status = 'pending'", [id]); if (!report) return res.status(404).json({ error: "Signalement non trouvé" }); const pendingReports = await dbAll( "SELECT reporter_user_id, reporter_user_type, reporter_profile_id, target_type, target_id FROM reports WHERE target_type = ? AND target_id = ? AND status = 'pending'", [report.target_type, report.target_id], ); const notificationTarget = await getReportNotificationTarget(report); const updateResult = await dbRun( `UPDATE reports SET status = 'dismissed', resolved_by = ?, resolved_at = ? WHERE target_type = ? AND target_id = ? AND status = 'pending'`, [req.user.userId, Date.now(), report.target_type, report.target_id], ); if (updateResult.changes > 0 && pendingReports.length > 0) { await Promise.all(pendingReports.map(async (pendingReport) => { await createNotification( pendingReport.reporter_user_id, pendingReport.reporter_user_type, pendingReport.reporter_profile_id, req.user.userId, null, userData.username, userData.avatar, REPORT_NOTIFICATION_TYPES.DISMISSED, pendingReport.target_type, notificationTarget.targetId, notificationTarget.contentType, notificationTarget.contentId, null, ); })); } res.json({ success: true, message: "Signalement rejeté" }); } catch (error) { console.error("Erreur dismiss report:", error); res.status(500).json({ error: "Erreur serveur" }); } }); // ==================== ROUTES DYNAMIQUES (doivent être en dernier) ==================== // GET /api/comments/:contentType/:contentId - Récupérer les commentaires avec pagination // === #13: Optimisé — une seule requête SQL avec sous-requêtes au lieu de N+1 === router.get("/:contentType/:contentId", async (req, res) => { try { const { contentType, contentId } = req.params; const safePage = Math.max(1, Math.min(parseInt(req.query.page) || 1, 1000)); const safeLimit = Math.max(1, Math.min(parseInt(req.query.limit) || 20, 50)); const offset = (safePage - 1) * safeLimit; // Tenter de récupérer l'utilisateur connecté (optionnel) let currentUser = null; const token = req.headers.authorization?.split(" ")[1]; if (token) { try { const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ["HS256"], }); currentUser = { userId: decoded.sub, userType: decoded.userType, sessionId: decoded.sessionId, }; } catch (error) { // Token invalide, on continue sans utilisateur } } const { profileId } = req.query; // Récupérer le total de commentaires const totalResult = await dbGet( "SELECT COUNT(*) as total FROM comments WHERE content_type = ? AND content_id = ? AND deleted = 0", [contentType, contentId], ); // #13: Requête unique — reaction_count + replies_count + user_reacted en sous-requêtes let commentsQuery; let commentsParams; if (currentUser && profileId) { commentsQuery = ` SELECT c.*, (SELECT COUNT(*) FROM comment_reactions WHERE target_type = 'comment' AND target_id = c.id) as reaction_count, (SELECT COUNT(*) FROM comment_replies WHERE comment_id = c.id AND deleted = 0) as replies_count, EXISTS(SELECT 1 FROM comment_reactions WHERE target_type = 'comment' AND target_id = c.id AND user_id = ? AND user_type = ? AND profile_id = ?) as user_reacted FROM comments c WHERE c.content_type = ? AND c.content_id = ? AND c.deleted = 0 ORDER BY (SELECT COUNT(*) FROM comment_reactions WHERE target_type = 'comment' AND target_id = c.id) DESC, c.created_at DESC LIMIT ? OFFSET ?`; commentsParams = [ currentUser.userId, currentUser.userType, profileId, contentType, contentId, safeLimit, offset, ]; } else { commentsQuery = ` SELECT c.*, (SELECT COUNT(*) FROM comment_reactions WHERE target_type = 'comment' AND target_id = c.id) as reaction_count, (SELECT COUNT(*) FROM comment_replies WHERE comment_id = c.id AND deleted = 0) as replies_count FROM comments c WHERE c.content_type = ? AND c.content_id = ? AND c.deleted = 0 ORDER BY (SELECT COUNT(*) FROM comment_reactions WHERE target_type = 'comment' AND target_id = c.id) DESC, c.created_at DESC LIMIT ? OFFSET ?`; commentsParams = [ contentType, contentId, safeLimit, offset, ]; } const comments = await dbAll(commentsQuery, commentsParams); // Cache utilisateur en mémoire pour cette requête (getUserData est déjà caché dans Redis via #14) const userDataCache = new Map(); const commentsWithDetails = await Promise.all( comments.map(async (comment) => { const userKey = `${comment.user_id}:${comment.user_type}:${comment.profile_id || "default"}`; let userData = userDataCache.get(userKey); if (!userData) { userData = await getUserData( comment.user_id, comment.user_type, comment.profile_id, ); userDataCache.set(userKey, userData); } return { ...comment, content: formatContentForResponse(comment.content), username: userData.username, avatar: userData.avatar, is_vip: userData.isVip ? 1 : 0, is_admin: userData.isAdmin ? 1 : 0, repliesCount: comment.replies_count, reactions: comment.reaction_count, userReaction: comment.user_reacted !== undefined ? !!comment.user_reacted : null, }; }), ); res.json({ comments: commentsWithDetails, total: totalResult.total, page: safePage, limit: safeLimit, hasMore: offset + commentsWithDetails.length < totalResult.total, }); } catch (error) { console.error("Erreur lors de la récupération des commentaires:", error); res.status(500).json({ error: "Erreur serveur" }); } }); // POST /api/comments - Créer un commentaire router.post("/", requireAuth, writeRateLimit, async (req, res) => { // Lock Redis par user pour sérialiser les créations concurrentes // (évite la race count >=3 / count >=10 entre SELECT et INSERT) const userLockKey = `comments:create:lock:${req.user.userType}:${req.user.userId}`; let lockAcquired = null; let lockHeld = false; try { try { lockAcquired = await redis.set(userLockKey, "1", "EX", 10, "NX"); } catch { // Redis indisponible — on accepte le risque de race (best effort) lockAcquired = "OK"; } if (!lockAcquired) { return res .status(429) .json({ error: "Une création est déjà en cours, réessayez." }); } lockHeld = true; let { contentType, contentId, content, isSpoiler, profileId, turnstileToken, } = req.body; // Vérification Turnstile const turnstileResult = await verifyTurnstileFromRequest( req, turnstileToken, ); if (!turnstileResult.valid) return res .status(turnstileResult.status) .json({ error: turnstileResult.error }); // Normalize content while preserving the original characters content = normalizeCommentContent(content); // Verify profile ownership const userProfileIds = await getProfileIds( req.user.userId, req.user.userType, ); if (!userProfileIds.includes(profileId)) { return res.status(403).json({ error: "Profil non autorisé" }); } // Récupérer les données utilisateur const userData = await getUserData( req.user.userId, req.user.userType, profileId, ); // Vérification de bannissement if (!userData.isAdmin) { const banStatus = await checkBan( req.user.userId, req.user.userType, getClientIp(req), ); if (banStatus.banned) { return res.status(403).json({ error: "Vous êtes banni des commentaires.", reason: banStatus.reason, expires_at: banStatus.expires_at, }); } } // Rate Limit 1: Max 3 comments per movie/content (sauf pour les admins) if (!userData.isAdmin) { const movieComments = await dbGet( "SELECT COUNT(*) as count FROM comments WHERE user_id = ? AND content_type = ? AND content_id = ? AND deleted = 0", [req.user.userId, contentType, contentId], ); if (movieComments.count >= 3) { return res .status(429) .json({ error: "Limite de 3 commentaires par film atteinte." }); } // Rate Limit 2: Max 10 comments per hour (sauf pour les admins) const oneHourAgo = Date.now() - 3600000; const hourComments = await dbGet( "SELECT COUNT(*) as count FROM comments WHERE user_id = ? AND created_at > ?", [req.user.userId, oneHourAgo], ); if (hourComments.count >= 10) { return res .status(429) .json({ error: "Limite de 10 commentaires par heure atteinte." }); } } // Validation if (!content || content.length > 500) { return res .status(400) .json({ error: "Le commentaire doit contenir entre 1 et 500 caractères", }); } // Insérer le commentaire const result = await dbRun( `INSERT INTO comments (content_type, content_id, user_id, user_type, profile_id, username, avatar, content, is_spoiler, is_vip, is_admin, created_at, ip_address) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ contentType, contentId, req.user.userId, req.user.userType, profileId, userData.username, userData.avatar, content, isSpoiler ? 1 : 0, userData.isVip ? 1 : 0, userData.isAdmin ? 1 : 0, Date.now(), getClientIp(req), ], ); // Récupérer le commentaire créé const newComment = await dbGet("SELECT * FROM comments WHERE id = ?", [ result.lastID, ]); // Envoyer le webhook Discord en arrière-plan (ne pas attendre) sendDiscordWebhook("comment", { username: userData.username, avatar: userData.avatar, content, contentType, contentId, isSpoiler, isVip: userData.isVip, isAdmin: userData.isAdmin, userId: req.user.userId, userType: req.user.userType, profileId: profileId, ipAddress: getClientIp(req), }).catch((err) => console.error("Erreur webhook Discord (non bloquant):", err), ); // Modération automatique avec Gemini en arrière-plan (ne pas attendre) moderateContentWithGemini( result.lastID, "comment", content, userData.username, ).catch((err) => console.error( "Erreur modération Gemini commentaire (non bloquant):", err, ), ); res.status(201).json({ ...newComment, content: formatContentForResponse(newComment.content), repliesCount: 0, reactions: 0, userReaction: null, }); } catch (error) { console.error("Erreur lors de la création du commentaire:", error); res.status(500).json({ error: "Erreur serveur" }); } finally { if (lockHeld) { try { await redis.del(userLockKey); } catch { /* Redis indisponible — le lock expirera via TTL */ } } } }); // PUT /api/comments/:id - Éditer un commentaire router.put("/:id", requireAuth, writeRateLimit, async (req, res) => { try { const { id } = req.params; let { content, isSpoiler, profileId } = req.body; // Normalize content while preserving the original characters content = normalizeCommentContent(content); // Validation if (!content || content.length > 500) { return res .status(400) .json({ error: "Le commentaire doit contenir entre 1 et 500 caractères", }); } // Vérifier que le commentaire appartient à l'utilisateur const comment = await dbGet("SELECT * FROM comments WHERE id = ?", [id]); if (!comment) { return res.status(404).json({ error: "Commentaire non trouvé" }); } if ( comment.user_id !== req.user.userId || comment.user_type !== req.user.userType ) { return res.status(403).json({ error: "Non autorisé" }); } // Si le commentaire a un profile_id, exiger que l'éditeur passe le même profileId // (empêche un autre profil du même compte d'éditer) if (comment.profile_id) { if (!profileId || String(comment.profile_id) !== String(profileId)) { return res .status(403) .json({ error: "Seul le profil auteur peut éditer ce commentaire" }); } } // Mettre à jour le commentaire await dbRun( "UPDATE comments SET content = ?, is_spoiler = ?, is_edited = 1, updated_at = ? WHERE id = ?", [content, isSpoiler ? 1 : 0, Date.now(), id], ); // Récupérer le commentaire mis à jour const updatedComment = await dbGet("SELECT * FROM comments WHERE id = ?", [ id, ]); res.json({ ...updatedComment, content: formatContentForResponse(updatedComment.content), }); } catch (error) { console.error("Erreur lors de l'édition du commentaire:", error); res.status(500).json({ error: "Erreur serveur" }); } }); // DELETE /api/comments/:id - Supprimer un commentaire (admin ou auteur) router.delete("/:id", requireAuth, writeRateLimit, async (req, res) => { try { const { id } = req.params; const { profileId } = req.query; // Vérifier que le commentaire existe const comment = await dbGet( "SELECT id, user_id, user_type, profile_id FROM comments WHERE id = ?", [id], ); if (!comment) { return res.status(404).json({ error: "Commentaire non trouvé" }); } // Vérifier que l'utilisateur est admin ou auteur du commentaire const userData = await getUserData(req.user.userId, req.user.userType); const userMatch = String(comment.user_id) === String(req.user.userId) && String(comment.user_type) === String(req.user.userType); // Si le commentaire a un profile_id, le profileId fourni doit matcher exactement // (empêche un kid profile de supprimer le commentaire d'un adult profile du même compte) let profileMatch = true; if (comment.profile_id) { profileMatch = !!profileId && String(comment.profile_id) === String(profileId); } const isOwner = userMatch && profileMatch; if (!userData.isAdmin && !isOwner) { return res.status(403).json({ error: "Non autorisé" }); } // Supprimer le commentaire et toutes ses dépendances en cascade await deleteCommentCascade(id); res.json({ message: "Commentaire, réponses, réactions et notifications supprimés en cascade", }); } catch (error) { console.error("Erreur lors de la suppression du commentaire:", error); res.status(500).json({ error: "Erreur serveur" }); } }); module.exports = router;