mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-07-27 00:52:08 +00:00
3742 lines
117 KiB
JavaScript
3742 lines
117 KiB
JavaScript
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" });
|
||
}
|
||
};
|
||
|
||
// 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" });
|
||
}
|
||
|
||
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, 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" });
|
||
}
|
||
|
||
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, async (req, res) => {
|
||
try {
|
||
const { profileId } = req.body;
|
||
|
||
// Validation du profileId
|
||
if (!profileId) {
|
||
return res.status(400).json({ error: "profileId requis" });
|
||
}
|
||
|
||
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, 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 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 {
|
||
const pool = getPool();
|
||
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)
|
||
)`
|
||
);
|
||
|
||
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, async (req, res) => {
|
||
try {
|
||
const disabled = req.body?.notificationsDisabled === true;
|
||
const pool = getPool();
|
||
|
||
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(
|
||
`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, async (req, res) => {
|
||
try {
|
||
const { subscription } = req.body;
|
||
if (!subscription || !subscription.endpoint) {
|
||
return res.status(400).json({ error: "Subscription invalide" });
|
||
}
|
||
const pool = getPool();
|
||
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)
|
||
)`
|
||
);
|
||
// Supprimer les anciennes subscriptions du même endpoint
|
||
await pool.execute('DELETE FROM push_subscriptions WHERE endpoint = ?', [subscription.endpoint]);
|
||
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, async (req, res) => {
|
||
try {
|
||
const { endpoint } = req.body;
|
||
if (!endpoint) return res.status(400).json({ error: "Endpoint manquant" });
|
||
const pool = getPool();
|
||
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, async (req, res) => {
|
||
try {
|
||
const { targetType, targetId, profileId } = req.body; // targetType: 'comment' ou 'reply'
|
||
|
||
// 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;
|
||
|
||
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 { page = 1, limit = 3 } = req.query;
|
||
const offset = (page - 1) * limit;
|
||
|
||
// 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,
|
||
parseInt(limit),
|
||
parseInt(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, parseInt(limit), parseInt(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: parseInt(page),
|
||
limit: parseInt(limit),
|
||
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, 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, async (req, res) => {
|
||
try {
|
||
const { id } = req.params;
|
||
let { content, isSpoiler } = 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é" });
|
||
}
|
||
|
||
// 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, 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);
|
||
const profileMatch =
|
||
!reply.profile_id ||
|
||
!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 rateLimit = require("express-rate-limit");
|
||
|
||
const reportRateLimit = rateLimit({
|
||
windowMs: 15 * 60 * 1000,
|
||
max: 10,
|
||
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() ||
|
||
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 { page = 1, limit = 20 } = req.query;
|
||
const offset = (page - 1) * limit;
|
||
|
||
// 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,
|
||
parseInt(limit),
|
||
parseInt(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,
|
||
parseInt(limit),
|
||
parseInt(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: parseInt(page),
|
||
limit: parseInt(limit),
|
||
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, async (req, res) => {
|
||
try {
|
||
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" });
|
||
}
|
||
});
|
||
|
||
// PUT /api/comments/:id - Éditer un commentaire
|
||
router.put("/:id", requireAuth, async (req, res) => {
|
||
try {
|
||
const { id } = req.params;
|
||
let { content, isSpoiler } = 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é" });
|
||
}
|
||
|
||
// 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, 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);
|
||
// Vérifier le profile_id si le commentaire en a un
|
||
const profileMatch =
|
||
!comment.profile_id ||
|
||
!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;
|