mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-07-26 16:32:07 +00:00
feat: live TV FCTV, fournisseur OAuth, sources J1F/Cinestream, modération IA & durcissement Gros lot de travail synchronisé depuis le repo de dev. Les grands axes : migration de domaine (movix.date + miroirs), deux nouvelles sources films/séries (1jour1film, Cinestream) et une n ouvelle source livetv, un fournisseur OAuth « Se connecter avec Movix », une modération de contenu par IA revue, plus un lot de durcissement mémoire/réseau, sécurité et perf côté API et front. ## 🌐 Domaines & miroirs - Domaine canonique `movix.chat`/`movix.golf`/`movix.tax` → **movix.date** partout (front, allow-lists CORS/domaine, Discord RPC). Miroir `movix.health` → **movix.online** (Footer, Header, pages d'aide CaMarchePlus/Dns/Miroirs). Lien Telegram du footer → `mysticsaba.com`. ~10 clés i18n mises à jour. ## 🎬 Sources de streaming (VOD) - **J1F (1jour1film)** — nouvelle source de bout en bout (`routes/j1f.js`, monté sur `/api/j1f`). Résout le domaine tournant via une page `/go/`, décode les blobs base64 `J1F_SRV`/`j1fEpsData` (Dooplay/WP), ne garde que les players uniques, ignore les agrégateurs (frembed/vidsrc/videasy). Cache stale-while-revalidate dédié. Players 1J1F lus via l'extension/userscript. - **Cinestream** — nouvelle source films (`routes/cinestream.js`, scrape Next.js `cinestream.info`, match par id TMDB autoritaire). Wiflix bascule ses **films** sur Cinestream (bot-shield flemmix pénible sur les films) ; la **TV** reste sur flemmix. - **Wiflix** — `flemmix.farm` → **`flemmix.golf`** ; handshake cookie (`makeWiflixSearchRequest`, `h_check=25`) ; sélecteurs de template corrigés. - **Cpasmal** — axios+proxy → `makeCpasmalRequest` (CycleTLS JA3) car Cloudflare 403 ; recherche GET → POST form (le WAF bloque le GET). - **Fstream** — cookie anti-bot `fsschal` ; source épisodes JS statique (`/static/series/<id>.js`) avant l'AJAX `episodes_p.php` ; fallback `get_seasons.php`. - **FranceTV** — `fetchSeasonEpisodesViaDeepPage` (pagination JSON « Afficher plus »). - **Coflix** — `coflix.date` → `coflix.trade` ; logs 429 silencés en double. - **proxyManager** — cinq helpers de contournement Cloudflare exportés (`makeCinestreamRequest`, `make1j1fRequest`, `makeCpasmalRequest`, handshake Wiflix `makeWiflixSearchRequest`/`wiflixHandshake`, `classifyCloudflare429` worker-vs-site) ; CycleTLS + rotation ProxyScrape, ne throw jamais sur statut HTTP. Coflix/LecteurVideo quittent les Cloudflare Workers pour ProxyScrape. ## 📺 Live TV — remplacement de Sosplay/Vavoo par FCTV33 - Source sport **Sosplay → FCTV33** (API RBTV, protobuf brut) réécrite de bout en bout dans `liveTvRoutes.js`, **Vavoo retiré** (manifest/catalogue/stream) — tout passe par `/api/livetv/*`. - Pipeline neuf : décodeur protobuf maison, signature `sfver` (bs keys + MD5), auto-découverte des domaines API/player tournants (`hubu.ru/fctvlink` + Nuxt SSR / `params` rot47, cache 30 min), scraper de matchs (`scrapeFctvMatches` : équipes/logos, ligue, pays, statut live, tri live-first), résolution de flux (HLS natif d'abord, embed en secours), token CDN (XOR keystream + base64, gate `rb-session`). - Route `GET /api/livetv/fctv/playlist` : proxy m3u8 intelligent (résout le token, réécrit les segments, retry sur token périmé, modes `raw` extension / `proxy` VIP). - **Matchs plus réservés aux VIP** (seul `iptv_` le reste) : flux natif proxifié pour les VIP, stub `_fctvLocal` résolu côté client par l'extension/userscript (`RESOLVE_FCTV`), fallback embed sinon. - Front `LiveTV.tsx` : `matches` → `fctv`, UI accordéon par match (logos, badges ligue/pays, score/timer live, favori), disclaimer, fix icône `music`. Rotations d'hôtes : `witv.team`→`witv.football`, `livetv876.me`→`livetv901.me`. ## 🧩 Extensions · Userscript · Discord RPC - **FCTV en lecture native** : handlers `SETUP_FCTV_HEADERS`/`RESOLVE_FCTV` (règle DNR Referer/Origin/UA sur `/cfall/s*` + résolveur local IP-bindé : protobuf-lite + rot47 + token XOR). - **Vavoo retiré** , `cinepulse.lol` → `purstream.mx`. - Fix extracteur **Sibnet** (`Range: bytes=0-0`) ; fix bug userscript `matchToRegex` (patterns ancrés `^…$` cassant `/cfall/s` → dé-ancrés) ; override dev `localhost:25565`. - Bumps de version (Chrome 1.3.3→1.3.8, Firefox 1.5.1→1.5.5, userscript 1.4.0→1.4.7) ; PreMid nouveau `clientId` Discord + `regExp` élargi (1.0.3→1.0.4). ## 🔑 OAuth (fournisseur « Se connecter avec Movix ») & codes d'accès - Refonte du flux d'autorisation OAuth (`OAuthAuthorizePage.tsx`) + routes (`routes/authRoutes.js`, `routes/oauth.js`) et stockage des clients OAuth étoffé (`utils/oauthClientsDb.js`, `routes/adminOauthApps.js` pour la gestion admin des apps). - `AccessCodeForm.tsx` retouché en cohérence avec le nouveau flux ; `checkVip.js` ajusté. - Session centralisée : `accountAuth.ts` expose `clearStoredAuthSession()` / `broadcastAuthChange()` (liste `AUTH_KEYS` unifiée), désormais utilisés partout à la déconnexion. ## 💬 Modération de contenu (IA) - Modèle OpenRouter `google/gemini-2.5-flash-lite` → `deepseek/deepseek-v4-flash` (`commentsRoutes.js`, `sharedListsRoutes.js`) ; fonctions/logs renommés `moderateContentWithGemini` → `moderateContentWithAI` (découplés du nom du modèle). - Prompt durci (critères INSULTES / DEMANDE_AJOUT resserrés) pour éviter les faux positifs sur questions, signalements de bug et plaintes de contenu manquant. - `AdminComments.tsx` : libellé « par Gemini » → clé i18n générique. ## 🧊 Hydracker / Darkiworld - **Sortie du debrid** : AllDebrid/RealDebrid supprimés (`utils/hydrackerLive.js` : branche provider retirée ; vars `ALLDEBRID_*`/`REALDEBRID_*` sorties du `.env`) — hydracker renvoie directement `rawUrl`/`directDL`. Liens servis depuis les snapshots SQLite locaux (`utils/darkiworldSqlite.js`, `darkino-backups/`). - **Anti-scraping** sur `/decode/:id` (`routes/darkiworld.js`) : watchlist + alerte webhook Discord (`DISCORD_SCRAPER_WEBHOOK`) et empoisonnement/blocage de réponse pour les IP listées (`SCRAPER_BLOCKED_IPS`). - **Mémoire** : cache d'agents proxy borné (`utils/lruMap.js`) qui détruit les sockets des agents évincés. ## 🛠️ Admin — propriété des liens & équipe - **Propriété des liens** : les liens streaming gagnent l'attribution `added_by` et l'ownership est appliquée des deux côtés (uploader = ses liens, admin = tous). GET public masqué (`stripLinkOwner`), `GET /admin/streaming-links/:type/:id` authentifié conserve le tag ; `DELETE /admin/links` par URL unique ; `PUT /admin/links` masse admin-only. - **Équipe** : `GET/POST/DELETE /admin/admins` + `GET /admin/team/history` (audit), identité via `adminIdentity.js` (option `preferProfile` → premier profil Movix). UI `AdminTeam.tsx`. - **Privilèges** (`middleware/auth.js`) : `isAdmin` rejette `role !== 'admin'` (403) — les uploaders ne passent plus les routes admin-only. Migration `add_role_to_admins.sql`. ## 🎁 Wrapped v2 - Cartes de partage : logique canvas extraite (`utils/wrappedCanvas.ts`, `utils/wrappedShareCards.ts`), formats de partage supplémentaires + preview. - Nouvelles slides : jour record, répartition par jour de semaine, champion de rewatch, âge ciné (année de sortie médiane pondérée par le temps de visionnage). - Perf : données depuis le payload backend (zéro re-fetch TMDB), trailer lazy, percentile non bloquant (réchauffe en arrière-plan, lock Redis `NX`), shuffle seedé. ## 🔐 Auth · Profils · VIP - **Pseudo forcé** : `validateProfileName` (1–32 chars, strip control/zero-width, + `maxLength={32}` côté UI Profile/WatchParty) ; les pseudos OAuth trop longs → `requiresUsernameChange` + modale bloquante `RequireUsernameChange.tsx`. - **Sync durci** (`ProfileContext.tsx`, `syncPolicy.js`) : anti-replay cross-user, filtrage des ops à clés non-syncables (`access_code`/`access_code_expires` exclues), « wipe guard » qui préserve le local sur profil vide inattendu, dédupe des `loadProfiles()` concurrents. Logout centralisé (voir OAuth). - **VIP** : `is_vip` préservé au chargement de profil (corrige un downgrade VIP inter-onglets qui relançait la popup pub) ; `vipDonations.js` poll de secours `payment-status.php` (via `ipn_token`, colonne `paygate_ipn_token`) → plus d'invoices bloquées. ## ▶️ Lecteur (HLS) - Basse latence opt-in : `isLowLatencyEnabled('movies')` (scope movies/livetv, `lowLatencyPref.ts`) au lieu du mode forcé ; `FloatingPlayer` aligné. - Sous-titres cinep : wrapper m3u8 synthétique pour les URI `.vtt`/`.srt` directes, `isSubtitleLoadError` (une piste cassée ne tue plus la vidéo), rendu `<i>/<b>/<u>`. - Menu sources regroupé par langue (VF/VFQ/VFF/VOSTFR). ## 📡 Cast (AirPlay / Chromecast) - Retrait du contournement proxy darkibox (`buildApiProxyUrl`) → cast direct de l'URL ; `runtime.ts` supprime `API_PROXY_BASE_URL`/`buildApiProxyUrl` devenus morts. - Paramètre `streamType` (`BUFFERED` par défaut) propagé jusqu'à `loadMediaOnCastWithFallback`. ## 📢 Pubs - Modes de popup (`adPopupMode.ts`) : `normal`, `auto`, `click-anywhere` ; toggle « +18 » séparé (`adAdultMode.ts`). `SCRIPT_AD_MODE_ENABLED` → `false`, URL du script changée. ## 🔎 Recherche & priorité des sources - `searchUtils.ts` : le terme original contourne le filtre « trop générique » (fix zéro-résultat sur titres courts : Baki, Gantz). - `animeSignals.ts` : détection donghua (CN/TW/HK ou `zh` + Animation, hors C-drama). - `sourceAutoSelect.ts` : `VFF`/`VFQ` normalisés en `vf` (sinon reléguées derrière VOSTFR). `sourcePriorityPrefs.ts` : schéma v2 → v3 (nouvel ordre par défaut + `j1f`, migration non destructive des ordres personnalisés). `extractionPrefs.ts` : +daddylive. ## 🔒 Sécurité - Sanitisation d'URL avant rendu en `href` : `CommentsSection.tsx` (liens markdown `http(s)://`) et `Greenlight/AdminLinkSubmissions.tsx` — bloque les `javascript:`. ## ⚙️ Infra & perf - **server.js** : recyclage périodique des workers (12h) + `[memstats]` + hook `SIGUSR2` → heap-snapshot. - **app.js** : monte `/api/j1f`, retire la route statique `/oauth-icons`, passe `makeCpasmalRequest` au router Cpasmal, `coflix.date` → `coflix.trade`. - **lazyWithRetry.ts / main.tsx / ErrorBoundary.tsx** : récupération chunk-load réécrite (retry + reload budgété), garde `removeChild/insertBefore`, écran soft « Mise à jour » au lieu du crash-report. - **UI** : `TopProgressBar` écrit `transform` directement sur un ref (compositor-only, plus de re-render) ; `blur-text` relâche `will-change` après l'animation. - **proxiesembed/server.py** : `curl_cffi` (JA3) pour `cinep-proxy` + retry SOCKS5 sur 403 ; lookup clé VIP réaligné sur `checkVip.js` ; log WARNING par défaut ; SOCKS5 forcé IPv4 ; wrapper VTT/SRT pour hls.js ; re-label `application/zip` → `video/mp2t` ; `cinepulse.lol` → `purstream.mx`. ## 🧹 Nettoyage & suppressions - **Front** : carte promo « Loadix » sur Download (remplace l'encart beta), « 1jour1film » ajouté à « C'est quoi Movix », fallback Darkino/Nightflix gutté en no-op (`api.movix.chat` décommissionné), classes CSS mortes retirées, fix `fetchPriority` → `fetchpriority`. - **Backend** : scrapers/utilitaires morts retirés — `routes/dragiv.js`, `routes/topstream.js`, `routes/proxy.js`, `utils/cloudflareSession.js`, `utils/hydrackerBatch.js`, `utils/hydrackerQueue.js'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3934 lines
124 KiB
JavaScript
3934 lines
124 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 DeepSeek V4 Flash)
|
||
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
|
||
const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions";
|
||
const OPENROUTER_MODEL = "deepseek/deepseek-v4-flash";
|
||
|
||
// Fonction de modération avec OpenRouter (exécutée en background)
|
||
async function moderateContentWithAI(
|
||
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). Ne flag QUE les violations claires et évidentes ; en cas de doute, ne pas flagger :
|
||
1. INSULTES: Insultes, injures, propos haineux ou dégradants visant une personne ou un groupe. Le simple agacement, les majuscules ou la ponctuation excessive ne sont PAS des insultes.
|
||
2. EROTIQUE: Contient du contenu érotique, sexuel ou inapproprié
|
||
3. DEMANDE_AJOUT: UNIQUEMENT une demande explicite d'ajouter un nouveau film/série/contenu au catalogue (ex: "ajoutez le film X svp", "vous pouvez mettre la série Y ?"). NE PAS flagger : les questions, les signalements de bug ("les épisodes ne marchent pas", "lien mort", "ce n'est pas le bon film"), les plaintes sur du contenu manquant ou retiré ("où est la saison 5 ?", "pourquoi la saison 5 a disparu ?"), les demandes d'aide à un modérateur, ni la discussion normale.
|
||
4. PSEUDO_INAPPROPRIE: Le pseudo contient des insultes, contenu érotique, ou est inapproprié
|
||
|
||
Réponds UNIQUEMENT avec ce format JSON (sans markdown, sans backticks):
|
||
{"flagged": true/false, "reason": "INSULTES" ou "EROTIQUE" ou "DEMANDE_AJOUT" ou "PSEUDO_INAPPROPRIE" ou null, "details": "explication courte"}`;
|
||
|
||
const response = await axios.post(
|
||
OPENROUTER_API_URL,
|
||
{
|
||
model: OPENROUTER_MODEL,
|
||
messages: [
|
||
{
|
||
role: "user",
|
||
content: prompt,
|
||
},
|
||
],
|
||
max_tokens: 500,
|
||
temperature: 0.1,
|
||
},
|
||
{
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: `Bearer ${OPENROUTER_API_KEY}`,
|
||
"HTTP-Referer": FRONTEND_BASE_URL,
|
||
"X-Title": "Movix Comment Moderation",
|
||
},
|
||
timeout: 15000,
|
||
},
|
||
);
|
||
|
||
const responseText = response.data?.choices?.[0]?.message?.content || "";
|
||
|
||
// Parser la réponse JSON
|
||
let moderationResult;
|
||
try {
|
||
// Nettoyer la réponse (enlever les backticks markdown si présents)
|
||
const cleanedResponse = responseText
|
||
.replace(/```json\n?|```\n?/g, "")
|
||
.trim();
|
||
moderationResult = JSON.parse(cleanedResponse);
|
||
} catch (parseError) {
|
||
console.error(
|
||
"❌ Erreur parsing réponse OpenRouter:",
|
||
parseError,
|
||
"Response:",
|
||
responseText,
|
||
);
|
||
return { flagged: false };
|
||
}
|
||
|
||
if (moderationResult.flagged) {
|
||
console.log(
|
||
`🚨 Contenu flaggé (${contentType} ID: ${contentId}): ${moderationResult.reason} - ${moderationResult.details}`,
|
||
);
|
||
|
||
// Marquer le contenu comme supprimé (deleted = 1) et stocker la raison
|
||
const table = contentType === "comment" ? "comments" : "comment_replies";
|
||
await dbRun(
|
||
`UPDATE ${table} SET deleted = 1, moderation_reason = ?, moderation_details = ?, moderated_at = ? WHERE id = ?`,
|
||
[
|
||
moderationResult.reason,
|
||
moderationResult.details,
|
||
Date.now(),
|
||
contentId,
|
||
],
|
||
);
|
||
|
||
console.log(
|
||
`✅ ${contentType} ID ${contentId} marqué comme supprimé pour modération`,
|
||
);
|
||
}
|
||
|
||
return moderationResult;
|
||
} catch (error) {
|
||
console.error(
|
||
"❌ Erreur modération OpenRouter (non bloquant):",
|
||
error.message,
|
||
);
|
||
return { flagged: false };
|
||
}
|
||
}
|
||
|
||
// Fonctions helper MySQL pour remplacer SQLite (#16: pool caché)
|
||
const dbRun = async (sql, params = []) => {
|
||
const pool = getCachedPool();
|
||
const [result] = await pool.execute(sql, params);
|
||
return {
|
||
lastID: result.insertId || 0,
|
||
changes: result.affectedRows || 0,
|
||
};
|
||
};
|
||
|
||
const dbGet = async (sql, params = []) => {
|
||
const pool = getCachedPool();
|
||
const [rows] = await pool.execute(sql, params);
|
||
return rows.length > 0 ? rows[0] : null;
|
||
};
|
||
|
||
const dbAll = async (sql, params = []) => {
|
||
const pool = getCachedPool();
|
||
const [rows] = await pool.execute(sql, params);
|
||
return rows;
|
||
};
|
||
|
||
// Middleware pour vérifier l'authentification avec validation session MySQL
|
||
const requireAuth = async (req, res, next) => {
|
||
const token = req.headers.authorization?.split(" ")[1];
|
||
if (!token) {
|
||
return res.status(401).json({ error: "Non authentifié" });
|
||
}
|
||
|
||
// Vérifier le token JWT (#15: jwt importé au top du fichier)
|
||
try {
|
||
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ["HS256"] });
|
||
const { sub: userId, userType, sessionId } = decoded;
|
||
|
||
if (!["oauth", "bip39"].includes(userType) || !userId || !sessionId) {
|
||
return res.status(401).json({ error: "Token invalide" });
|
||
}
|
||
|
||
// Vérifier que la session existe en MySQL
|
||
const pool = getCachedPool();
|
||
const [rows] = await pool.execute(
|
||
"SELECT id FROM user_sessions WHERE id = ? AND user_id = ? AND user_type = ?",
|
||
[sessionId, userId, userType],
|
||
);
|
||
|
||
if (rows.length === 0) {
|
||
return res.status(401).json({ error: "Session invalide ou expirée" });
|
||
}
|
||
|
||
req.user = {
|
||
userId: userId,
|
||
userType: userType,
|
||
sessionId: sessionId,
|
||
};
|
||
next();
|
||
} catch (error) {
|
||
return res.status(401).json({ error: "Token invalide" });
|
||
}
|
||
};
|
||
|
||
// Rate limit pour les actions d'écriture (commentaires/réponses/réactions/notifications)
|
||
// - Clef = userId post-auth (fallback IP CF/X-Forwarded-For derrière Cloudflare)
|
||
// - Store Redis partagé entre workers du cluster (sinon chaque worker compte indépendamment)
|
||
// - passOnStoreError: si Redis tombe, fail-open au lieu de bloquer toutes les requêtes
|
||
const rateLimit = require("express-rate-limit");
|
||
const { ipKeyGenerator } = require("express-rate-limit");
|
||
const { createRedisRateLimitStore } = require("./utils/redisRateLimitStore");
|
||
const writeRateLimit = rateLimit({
|
||
windowMs: 60 * 1000,
|
||
max: 100,
|
||
store: createRedisRateLimitStore({
|
||
prefix: "rate-limit:comments:write:",
|
||
windowMs: 60 * 1000,
|
||
}),
|
||
passOnStoreError: true,
|
||
standardHeaders: true,
|
||
legacyHeaders: false,
|
||
message: { error: "Trop de requêtes. Réessayez dans une minute." },
|
||
keyGenerator: (req) => {
|
||
if (req.user) return `u:${req.user.userType}:${req.user.userId}`;
|
||
return (
|
||
req.headers["cf-connecting-ip"] ||
|
||
req.headers["x-forwarded-for"]?.split(",")[0].trim() ||
|
||
ipKeyGenerator(req.ip)
|
||
);
|
||
},
|
||
validate: {
|
||
xForwardedForHeader: false,
|
||
ip: false,
|
||
keyGeneratorIpFallback: false,
|
||
},
|
||
});
|
||
|
||
// Init paresseux des tables notifs/push (évite un DDL par requête, idempotent en cas de redémarrage)
|
||
let _notificationTablesInitialized = false;
|
||
let _notificationTablesInitPromise = null;
|
||
async function ensureNotificationTables() {
|
||
if (_notificationTablesInitialized) return;
|
||
if (_notificationTablesInitPromise) return _notificationTablesInitPromise;
|
||
_notificationTablesInitPromise = (async () => {
|
||
const pool = getCachedPool();
|
||
await pool.execute(
|
||
`CREATE TABLE IF NOT EXISTS user_notification_preferences (
|
||
user_id VARCHAR(255) NOT NULL,
|
||
user_type VARCHAR(50) NOT NULL,
|
||
notifications_disabled TINYINT(1) DEFAULT 0,
|
||
updated_at BIGINT,
|
||
PRIMARY KEY (user_id, user_type)
|
||
)`
|
||
);
|
||
await pool.execute(
|
||
`CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||
user_id VARCHAR(255) NOT NULL,
|
||
user_type VARCHAR(50) NOT NULL,
|
||
endpoint TEXT NOT NULL,
|
||
p256dh TEXT NOT NULL,
|
||
auth TEXT NOT NULL,
|
||
created_at BIGINT,
|
||
INDEX idx_user_push (user_id, user_type)
|
||
)`
|
||
);
|
||
_notificationTablesInitialized = true;
|
||
})();
|
||
try {
|
||
await _notificationTablesInitPromise;
|
||
} finally {
|
||
_notificationTablesInitPromise = null;
|
||
}
|
||
}
|
||
|
||
// Helper to get allowed profile IDs (security check)
|
||
async function getProfileIds(userId, userType) {
|
||
try {
|
||
// Sanitize userId to prevent path traversal
|
||
const safeUserId = String(userId).replace(/[^a-zA-Z0-9_\-]/g, "");
|
||
let userFilePath;
|
||
if (userType === "bip39") {
|
||
userFilePath = path.join(
|
||
__dirname,
|
||
"data",
|
||
"users",
|
||
`bip39-${safeUserId}.json`,
|
||
);
|
||
} else {
|
||
userFilePath = path.join(
|
||
__dirname,
|
||
"data",
|
||
"users",
|
||
`${safeUserId}.json`,
|
||
);
|
||
}
|
||
|
||
try {
|
||
const userFile = await fs.readFile(userFilePath, "utf8");
|
||
const user = JSON.parse(userFile);
|
||
|
||
const profileIds = [];
|
||
if (user.profiles) {
|
||
user.profiles.forEach((p) => profileIds.push(p.id));
|
||
} else {
|
||
// Fallback for old structure might be needed but typically not for profileId spoofing protection
|
||
}
|
||
return profileIds;
|
||
} catch (err) {
|
||
// if (err.code !== 'ENOENT') console.error('Error reading user file:', err);
|
||
return [];
|
||
}
|
||
} catch (error) {
|
||
console.error("Error getting profile IDs:", error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// === #14: Cache getUserData dans Redis (TTL 5 min) pour éviter les lectures fichier à chaque requête ===
|
||
const USER_DATA_CACHE_TTL = 300; // 5 minutes en secondes
|
||
|
||
// Fonction interne pour récupérer les données utilisateur depuis le disque/MySQL (sans cache)
|
||
async function _fetchUserData(userId, userType, profileId = null) {
|
||
try {
|
||
let userData = {
|
||
username: "Utilisateur",
|
||
avatar: null,
|
||
isVip: false,
|
||
isAdmin: false,
|
||
};
|
||
|
||
// Sanitize inputs to prevent path traversal
|
||
const safeUserId = String(userId).replace(/[^a-zA-Z0-9_\-]/g, "");
|
||
const safeUserType = ["oauth", "bip39"].includes(userType)
|
||
? userType
|
||
: "oauth";
|
||
const safeProfileId = profileId
|
||
? String(profileId).replace(/[^a-zA-Z0-9_\-]/g, "")
|
||
: null;
|
||
|
||
// Chemin vers le fichier utilisateur
|
||
let userFilePath;
|
||
if (safeUserType === "bip39") {
|
||
userFilePath = path.join(
|
||
__dirname,
|
||
"data",
|
||
"users",
|
||
`bip39-${safeUserId}.json`,
|
||
);
|
||
} else {
|
||
userFilePath = path.join(
|
||
__dirname,
|
||
"data",
|
||
"users",
|
||
`${safeUserId}.json`,
|
||
);
|
||
}
|
||
|
||
// Lire le fichier utilisateur
|
||
try {
|
||
const userFile = await fs.readFile(userFilePath, "utf8");
|
||
const user = JSON.parse(userFile);
|
||
|
||
// Récupérer le profil
|
||
if (profileId && user.profiles) {
|
||
const profile = user.profiles.find((p) => p.id === profileId);
|
||
if (profile) {
|
||
userData.username = profile.name || "Utilisateur";
|
||
// Sanitize avatar: must start with /avatars/
|
||
if (profile.avatar && profile.avatar.startsWith("/avatars/")) {
|
||
userData.avatar = profile.avatar;
|
||
} else {
|
||
userData.avatar = "/avatars/disney/disney_avatar_1.png"; // Default fallback
|
||
}
|
||
}
|
||
} else if (user.profiles && user.profiles.length > 0) {
|
||
// Utiliser le profil par défaut
|
||
const defaultProfile =
|
||
user.profiles.find((p) => p.isDefault) || user.profiles[0];
|
||
userData.username = defaultProfile.name || "Utilisateur";
|
||
// Sanitize avatar: must start with /avatars/
|
||
if (
|
||
defaultProfile.avatar &&
|
||
defaultProfile.avatar.startsWith("/avatars/")
|
||
) {
|
||
userData.avatar = defaultProfile.avatar;
|
||
} else {
|
||
userData.avatar = "/avatars/disney/disney_avatar_1.png"; // Default fallback
|
||
}
|
||
}
|
||
} catch (err) {
|
||
// Fichier utilisateur introuvable — on garde les valeurs par défaut
|
||
}
|
||
|
||
// Vérifier le statut VIP en lisant l'access_code depuis les données du profil
|
||
// puis en le vérifiant contre la table MySQL access_keys
|
||
if (safeProfileId && safeUserId) {
|
||
try {
|
||
const profileDataPath = path.join(
|
||
__dirname,
|
||
"data",
|
||
"users",
|
||
"profiles",
|
||
safeUserType,
|
||
safeUserId,
|
||
`${safeProfileId}.json`,
|
||
);
|
||
const profileData = JSON.parse(
|
||
await fs.readFile(profileDataPath, "utf8"),
|
||
);
|
||
const storedAccessCode = profileData.access_code || null;
|
||
if (storedAccessCode) {
|
||
const vipStatus = await verifyAccessKey(storedAccessCode);
|
||
userData.isVip = vipStatus.vip;
|
||
}
|
||
} catch (err) {
|
||
// Pas de données de profil ou pas d'access_code — isVip reste false
|
||
}
|
||
}
|
||
|
||
// Vérifier si Admin (en utilisant MySQL)
|
||
try {
|
||
const pool = getCachedPool();
|
||
const authType = userType === "bip39" ? "bip-39" : userType;
|
||
const [rows] = await pool.execute(
|
||
"SELECT 1 FROM admins WHERE user_id = ? AND auth_type = ? LIMIT 1",
|
||
[userId, authType],
|
||
);
|
||
userData.isAdmin = rows.length > 0;
|
||
} catch (err) {
|
||
console.error("❌ Erreur lors de la vérification admin:", err);
|
||
}
|
||
|
||
return userData;
|
||
} catch (error) {
|
||
console.error("Erreur _fetchUserData:", error);
|
||
return {
|
||
username: "Utilisateur",
|
||
avatar: null,
|
||
isVip: false,
|
||
isAdmin: false,
|
||
};
|
||
}
|
||
}
|
||
|
||
// Fonction publique avec cache Redis (#14)
|
||
async function getUserData(userId, userType, profileId = null) {
|
||
const cacheKey = `userData:${userType}:${userId}:${profileId || "default"}`;
|
||
try {
|
||
const cached = await redis.get(cacheKey);
|
||
if (cached) return JSON.parse(cached);
|
||
} catch {
|
||
/* Redis indisponible, on continue sans cache */
|
||
}
|
||
|
||
const userData = await _fetchUserData(userId, userType, profileId);
|
||
|
||
// Mettre en cache (fire-and-forget)
|
||
try {
|
||
redis
|
||
.set(cacheKey, JSON.stringify(userData), "EX", USER_DATA_CACHE_TTL)
|
||
.catch(() => {});
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
|
||
return userData;
|
||
}
|
||
|
||
// Fonction pour créer une notification
|
||
async function isNotificationDisabledForUser(userId, userType) {
|
||
try {
|
||
const [rows] = await getPool().execute(
|
||
'SELECT notifications_disabled FROM user_notification_preferences WHERE user_id = ? AND user_type = ? LIMIT 1',
|
||
[userId, userType]
|
||
);
|
||
return rows.length > 0 && rows[0].notifications_disabled === 1;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
let pushTableReady = false;
|
||
async function ensurePushTable() {
|
||
if (pushTableReady) return;
|
||
try {
|
||
await getPool().execute(
|
||
`CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||
user_id VARCHAR(255) NOT NULL,
|
||
user_type VARCHAR(50) NOT NULL,
|
||
endpoint TEXT NOT NULL,
|
||
p256dh TEXT NOT NULL,
|
||
auth TEXT NOT NULL,
|
||
created_at BIGINT,
|
||
INDEX idx_user_push (user_id, user_type)
|
||
)`
|
||
);
|
||
pushTableReady = true;
|
||
} catch {}
|
||
}
|
||
|
||
async function sendPushToUser(userId, userType, payload) {
|
||
if (!VAPID_CONFIGURED) return;
|
||
try {
|
||
await ensurePushTable();
|
||
const pool = getPool();
|
||
const [subs] = await pool.execute(
|
||
'SELECT endpoint, p256dh, auth FROM push_subscriptions WHERE user_id = ? AND user_type = ?',
|
||
[userId, userType]
|
||
);
|
||
const data = JSON.stringify(payload);
|
||
for (const sub of subs) {
|
||
try {
|
||
await webpush.sendNotification({
|
||
endpoint: sub.endpoint,
|
||
keys: { p256dh: sub.p256dh, auth: sub.auth }
|
||
}, data);
|
||
} catch (err) {
|
||
// Si la subscription est expirée ou invalide, la supprimer
|
||
if (err.statusCode === 410 || err.statusCode === 404) {
|
||
await pool.execute('DELETE FROM push_subscriptions WHERE endpoint = ?', [sub.endpoint]);
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error("Erreur lors de l'envoi push:", error);
|
||
}
|
||
}
|
||
|
||
async function createNotification(
|
||
toUserId,
|
||
toUserType,
|
||
toProfileId,
|
||
fromUserId,
|
||
fromProfileId,
|
||
fromUsername,
|
||
fromAvatar,
|
||
notificationType,
|
||
targetType,
|
||
targetId,
|
||
contentType,
|
||
contentId,
|
||
commentPreview,
|
||
) {
|
||
try {
|
||
if (await isNotificationDisabledForUser(toUserId, toUserType)) {
|
||
return;
|
||
}
|
||
|
||
await dbRun(
|
||
`INSERT INTO notifications (user_id, user_type, profile_id, from_user_id, from_profile_id, from_username, from_avatar, notification_type, target_type, target_id, content_type, content_id, comment_preview, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
[
|
||
toUserId,
|
||
toUserType,
|
||
toProfileId,
|
||
fromUserId,
|
||
fromProfileId,
|
||
fromUsername,
|
||
fromAvatar,
|
||
notificationType,
|
||
targetType,
|
||
targetId,
|
||
contentType,
|
||
contentId,
|
||
commentPreview,
|
||
Date.now(),
|
||
],
|
||
);
|
||
|
||
// Envoyer une notification push
|
||
const pushMessages = {
|
||
reply: `${fromUsername} a répondu à votre commentaire`,
|
||
like: `${fromUsername} a aimé votre commentaire`,
|
||
reaction: `${fromUsername} a réagi à votre commentaire`,
|
||
mention: `${fromUsername} vous a mentionné`,
|
||
report_resolved: `Votre signalement a été traité`,
|
||
report_resolved_deleted: `Votre signalement a été traité (contenu supprimé)`,
|
||
report_dismissed: `Votre signalement a été rejeté`,
|
||
};
|
||
sendPushToUser(toUserId, toUserType, {
|
||
title: "Movix",
|
||
body: pushMessages[notificationType] || "Nouvelle notification",
|
||
icon: "/movix.png",
|
||
data: { contentType, contentId, notificationType },
|
||
});
|
||
} catch (error) {
|
||
console.error("Erreur lors de la création de la notification:", error);
|
||
}
|
||
}
|
||
|
||
const REPORT_NOTIFICATION_TYPES = {
|
||
RESOLVED: "report_resolved",
|
||
RESOLVED_DELETED: "report_resolved_deleted",
|
||
DISMISSED: "report_dismissed",
|
||
};
|
||
|
||
async function getReportNotificationTarget(report) {
|
||
try {
|
||
if (report.target_type === "comment") {
|
||
const comment = await dbGet(
|
||
"SELECT content_type, content_id FROM comments WHERE id = ?",
|
||
[report.target_id],
|
||
);
|
||
if (comment) {
|
||
return {
|
||
contentType: comment.content_type,
|
||
contentId: String(comment.content_id),
|
||
targetId: Number(report.target_id),
|
||
};
|
||
}
|
||
} else if (report.target_type === "reply") {
|
||
const reply = await dbGet(
|
||
"SELECT comment_id FROM comment_replies WHERE id = ?",
|
||
[report.target_id],
|
||
);
|
||
if (reply) {
|
||
const comment = await dbGet(
|
||
"SELECT content_type, content_id FROM comments WHERE id = ?",
|
||
[reply.comment_id],
|
||
);
|
||
if (comment) {
|
||
return {
|
||
contentType: comment.content_type,
|
||
contentId: String(comment.content_id),
|
||
targetId: Number(report.target_id),
|
||
};
|
||
}
|
||
}
|
||
} else if (report.target_type === "shared_list") {
|
||
const pool = getCachedPool();
|
||
const [rows] = await pool.execute(
|
||
"SELECT id, share_code FROM shared_lists WHERE share_code = ? OR id = ?",
|
||
[report.target_id, report.target_id],
|
||
);
|
||
if (rows.length > 0) {
|
||
const sharedList = rows[0];
|
||
return {
|
||
contentType: "shared_list",
|
||
contentId: String(sharedList.share_code || sharedList.id || report.target_id),
|
||
targetId: Number(sharedList.id) || Number(report.target_id) || 0,
|
||
};
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error("Erreur lors de la préparation de la notification de signalement:", error);
|
||
}
|
||
|
||
return {
|
||
contentType: report.target_type,
|
||
contentId: String(report.target_id),
|
||
targetId: Number(report.target_id) || 0,
|
||
};
|
||
}
|
||
|
||
// === #18: Batch cascade delete — requêtes batch au lieu de récursion séquentielle ===
|
||
|
||
// Fonction helper pour récupérer tous les IDs de réponses d'un commentaire (ou sous-arbre d'une réponse)
|
||
async function getAllReplyIds(
|
||
commentId,
|
||
parentReplyId = null,
|
||
includeDeleted = false,
|
||
) {
|
||
const deletedFilter = includeDeleted ? "" : " AND deleted = 0";
|
||
if (parentReplyId === null) {
|
||
// Toutes les réponses du commentaire
|
||
const replies = await dbAll(
|
||
`SELECT id FROM comment_replies WHERE comment_id = ?${deletedFilter}`,
|
||
[commentId],
|
||
);
|
||
return replies.map((r) => r.id);
|
||
}
|
||
// Sous-arbre d'une réponse spécifique : récupérer récursivement via hierarchical_path
|
||
// Récupérer le path du parent
|
||
const parent = await dbGet(
|
||
"SELECT hierarchical_path FROM comment_replies WHERE id = ?",
|
||
[parentReplyId],
|
||
);
|
||
if (!parent || !parent.hierarchical_path) return [parentReplyId];
|
||
|
||
const descendants = await dbAll(
|
||
`SELECT id FROM comment_replies WHERE comment_id = ? AND hierarchical_path LIKE ?${deletedFilter}`,
|
||
[commentId, `${parent.hierarchical_path}.%`],
|
||
);
|
||
return [parentReplyId, ...descendants.map((r) => r.id)];
|
||
}
|
||
|
||
// Batch delete des réactions et notifications pour une liste d'IDs de réponses
|
||
async function batchDeleteReplyDependencies(replyIds) {
|
||
if (replyIds.length === 0) return;
|
||
const placeholders = replyIds.map(() => "?").join(",");
|
||
|
||
// Supprimer les réactions de toutes les réponses en batch
|
||
await dbRun(
|
||
`DELETE FROM comment_reactions WHERE target_type = 'reply' AND target_id IN (${placeholders})`,
|
||
replyIds,
|
||
);
|
||
|
||
// Supprimer les notifications liées à ces réponses en batch
|
||
await dbRun(
|
||
`DELETE FROM notifications WHERE target_type = 'reply' AND target_id IN (${placeholders})`,
|
||
replyIds,
|
||
);
|
||
|
||
// Supprimer les notifications de réaction sur ces réponses en batch
|
||
await dbRun(
|
||
`DELETE FROM notifications WHERE notification_type = 'reaction' AND target_type = 'reply' AND target_id IN (${placeholders})`,
|
||
replyIds,
|
||
);
|
||
}
|
||
|
||
// Soft delete cascade : marquer les réponses comme supprimées en batch
|
||
async function deleteRepliesCascade(commentId, replyId = null) {
|
||
try {
|
||
const replyIds = await getAllReplyIds(commentId, replyId, false);
|
||
if (replyIds.length === 0) return;
|
||
|
||
await batchDeleteReplyDependencies(replyIds);
|
||
|
||
// Soft delete toutes les réponses en batch
|
||
const placeholders = replyIds.map(() => "?").join(",");
|
||
await dbRun(
|
||
`UPDATE comment_replies SET deleted = 1 WHERE id IN (${placeholders})`,
|
||
replyIds,
|
||
);
|
||
|
||
console.log(
|
||
`✅ ${replyIds.length} réponse(s) supprimées en cascade (soft) pour commentaire ${commentId}`,
|
||
);
|
||
} catch (error) {
|
||
console.error(
|
||
"Erreur lors de la suppression en cascade des réponses:",
|
||
error,
|
||
);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// Soft delete cascade pour un commentaire entier
|
||
async function deleteCommentCascade(commentId) {
|
||
try {
|
||
// 1. Récupérer tous les IDs de réponses du commentaire
|
||
const replyIds = await getAllReplyIds(commentId, null, false);
|
||
|
||
// 2. Supprimer les dépendances de toutes les réponses en batch
|
||
if (replyIds.length > 0) {
|
||
await batchDeleteReplyDependencies(replyIds);
|
||
const placeholders = replyIds.map(() => "?").join(",");
|
||
await dbRun(
|
||
`UPDATE comment_replies SET deleted = 1 WHERE id IN (${placeholders})`,
|
||
replyIds,
|
||
);
|
||
}
|
||
|
||
// 3. Supprimer les réactions du commentaire
|
||
await dbRun(
|
||
`DELETE FROM comment_reactions WHERE target_type = 'comment' AND target_id = ?`,
|
||
[commentId],
|
||
);
|
||
|
||
// 4. Supprimer les notifications du commentaire
|
||
await dbRun(
|
||
`DELETE FROM notifications WHERE target_type = 'comment' AND target_id = ?`,
|
||
[commentId],
|
||
);
|
||
await dbRun(
|
||
`DELETE FROM notifications WHERE notification_type = 'reaction' AND target_type = 'comment' AND target_id = ?`,
|
||
[commentId],
|
||
);
|
||
|
||
// 5. Soft delete le commentaire
|
||
await dbRun("UPDATE comments SET deleted = 1 WHERE id = ?", [commentId]);
|
||
|
||
console.log(
|
||
`✅ Commentaire ${commentId} et ${replyIds.length} réponse(s) supprimés en cascade`,
|
||
);
|
||
} catch (error) {
|
||
console.error(
|
||
"Erreur lors de la suppression en cascade du commentaire:",
|
||
error,
|
||
);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// Hard delete cascade : suppression définitive d'une réponse et ses enfants
|
||
async function hardDeleteRepliesCascade(commentId, replyId) {
|
||
try {
|
||
const replyIds = await getAllReplyIds(commentId, replyId, true);
|
||
if (replyIds.length === 0) return;
|
||
|
||
await batchDeleteReplyDependencies(replyIds);
|
||
|
||
// Hard delete toutes les réponses en batch
|
||
const placeholders = replyIds.map(() => "?").join(",");
|
||
await dbRun(
|
||
`DELETE FROM comment_replies WHERE id IN (${placeholders})`,
|
||
replyIds,
|
||
);
|
||
|
||
console.log(
|
||
`🗑️ ${replyIds.length} réponse(s) supprimées définitivement pour commentaire ${commentId}`,
|
||
);
|
||
} catch (error) {
|
||
console.error(
|
||
"Erreur lors de la suppression définitive des réponses:",
|
||
error,
|
||
);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// Hard delete cascade pour un commentaire entier
|
||
async function hardDeleteCommentCascade(commentId) {
|
||
try {
|
||
// 1. Récupérer tous les IDs de réponses (y compris déjà supprimées)
|
||
const replyIds = await getAllReplyIds(commentId, null, true);
|
||
|
||
// 2. Supprimer les dépendances en batch
|
||
if (replyIds.length > 0) {
|
||
await batchDeleteReplyDependencies(replyIds);
|
||
const placeholders = replyIds.map(() => "?").join(",");
|
||
await dbRun(
|
||
`DELETE FROM comment_replies WHERE id IN (${placeholders})`,
|
||
replyIds,
|
||
);
|
||
}
|
||
|
||
// 3. Supprimer les réactions et notifications du commentaire
|
||
await dbRun(
|
||
`DELETE FROM comment_reactions WHERE target_type = 'comment' AND target_id = ?`,
|
||
[commentId],
|
||
);
|
||
await dbRun(
|
||
`DELETE FROM notifications WHERE target_type = 'comment' AND target_id = ?`,
|
||
[commentId],
|
||
);
|
||
await dbRun(
|
||
`DELETE FROM notifications WHERE notification_type = 'reaction' AND target_type = 'comment' AND target_id = ?`,
|
||
[commentId],
|
||
);
|
||
|
||
// 4. Hard delete le commentaire
|
||
await dbRun("DELETE FROM comments WHERE id = ?", [commentId]);
|
||
|
||
console.log(
|
||
`🗑️ Commentaire ${commentId} et ${replyIds.length} réponse(s) supprimés définitivement`,
|
||
);
|
||
} catch (error) {
|
||
console.error(
|
||
"Erreur lors de la suppression définitive du commentaire:",
|
||
error,
|
||
);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// Fonction pour récupérer l'adresse IP de la requête
|
||
// Priorité : cf-connecting-ip (Cloudflare, impossible à spoof) > x-real-ip > x-forwarded-for > fallbacks
|
||
function getClientIp(req) {
|
||
return (
|
||
req.headers["cf-connecting-ip"] ||
|
||
req.headers["x-real-ip"] ||
|
||
req.headers["x-forwarded-for"]?.split(",")[0].trim() ||
|
||
req.ip ||
|
||
req.connection?.remoteAddress ||
|
||
req.socket?.remoteAddress ||
|
||
"Unknown"
|
||
);
|
||
}
|
||
|
||
// Fonction pour envoyer un webhook Discord
|
||
async function sendDiscordWebhook(type, data) {
|
||
try {
|
||
const {
|
||
username,
|
||
avatar,
|
||
content,
|
||
contentType,
|
||
contentId,
|
||
isSpoiler,
|
||
isVip,
|
||
isAdmin,
|
||
replyToUsername,
|
||
userId,
|
||
userType,
|
||
profileId,
|
||
ipAddress,
|
||
} = data;
|
||
|
||
// Validation et limitation des valeurs selon les spécifications Discord
|
||
// Description: max 2048 caractères
|
||
const description =
|
||
content && typeof content === "string"
|
||
? content.length > 2048
|
||
? content.substring(0, 2045) + "..."
|
||
: content
|
||
: "Aucun contenu";
|
||
|
||
// Titre: max 256 caractères
|
||
const title = (
|
||
type === "comment" ? "💬 Nouveau commentaire" : "💬 Nouvelle réponse"
|
||
).substring(0, 256);
|
||
|
||
// Champs: name max 256, value max 1024 caractères
|
||
const usernameValue = String(username || "Utilisateur").substring(0, 1024);
|
||
const contentTypeValue = String(
|
||
contentType === "movie"
|
||
? "🎬 Film"
|
||
: contentType === "tv"
|
||
? "📺 Série"
|
||
: contentType || "Inconnu",
|
||
).substring(0, 1024);
|
||
const contentIdValue = String(contentId || "N/A").substring(0, 1024);
|
||
|
||
// Construire le lien vers le film/série
|
||
const contentUrl =
|
||
contentId && contentType
|
||
? `${FRONTEND_BASE_URL}/${contentType === "movie" ? "movie" : "tv"}/${contentId}`
|
||
: null;
|
||
|
||
const embed = {
|
||
title: title,
|
||
description: description,
|
||
color: isAdmin ? 0xff0000 : isVip ? 0xffd700 : 0x3498db,
|
||
url: contentUrl || undefined, // Lien cliquable sur le titre
|
||
fields: [
|
||
{
|
||
name: "👤 Utilisateur".substring(0, 256),
|
||
value: usernameValue,
|
||
inline: true,
|
||
},
|
||
{
|
||
name: "📺 Contenu".substring(0, 256),
|
||
value: contentTypeValue,
|
||
inline: true,
|
||
},
|
||
{
|
||
name: "🆔 ID Contenu".substring(0, 256),
|
||
value: contentIdValue,
|
||
inline: true,
|
||
},
|
||
],
|
||
timestamp: new Date().toISOString(),
|
||
footer: {
|
||
text: String(
|
||
isAdmin ? "👑 Administrateur" : isVip ? "⭐ VIP" : "👤 Utilisateur",
|
||
).substring(0, 2048),
|
||
},
|
||
};
|
||
|
||
// Ajouter les informations utilisateur
|
||
if (userId) {
|
||
embed.fields.push({
|
||
name: "🆔 User ID".substring(0, 256),
|
||
value: String(userId).substring(0, 1024),
|
||
inline: true,
|
||
});
|
||
}
|
||
|
||
if (profileId) {
|
||
embed.fields.push({
|
||
name: "🎭 Profile ID".substring(0, 256),
|
||
value: String(profileId).substring(0, 1024),
|
||
inline: true,
|
||
});
|
||
}
|
||
|
||
if (userType) {
|
||
embed.fields.push({
|
||
name: "🔐 User Type".substring(0, 256),
|
||
value: String(userType).substring(0, 1024),
|
||
inline: true,
|
||
});
|
||
}
|
||
|
||
if (ipAddress) {
|
||
embed.fields.push({
|
||
name: "🌐 IP Address".substring(0, 256),
|
||
value: String(ipAddress).substring(0, 1024),
|
||
inline: true,
|
||
});
|
||
}
|
||
|
||
// Ajouter un champ avec le lien si disponible
|
||
if (contentUrl) {
|
||
embed.fields.push({
|
||
name: "🔗 Lien".substring(0, 256),
|
||
value:
|
||
`[Voir ${contentType === "movie" ? "le film" : "la série"}](${contentUrl})`.substring(
|
||
0,
|
||
1024,
|
||
),
|
||
inline: false,
|
||
});
|
||
}
|
||
|
||
if (isSpoiler) {
|
||
embed.fields.push({
|
||
name: "⚠️ Spoiler".substring(0, 256),
|
||
value: "Oui",
|
||
inline: true,
|
||
});
|
||
}
|
||
|
||
if (type === "reply" && replyToUsername) {
|
||
embed.fields.push({
|
||
name: "↩️ Réponse à".substring(0, 256),
|
||
value: String(replyToUsername).substring(0, 1024),
|
||
inline: true,
|
||
});
|
||
}
|
||
|
||
// Vérifier que l'avatar est une URL valide
|
||
if (
|
||
avatar &&
|
||
typeof avatar === "string" &&
|
||
avatar.trim().length > 0 &&
|
||
(avatar.startsWith("http://") || avatar.startsWith("https://"))
|
||
) {
|
||
embed.thumbnail = {
|
||
url: avatar.trim().substring(0, 2048),
|
||
};
|
||
}
|
||
|
||
await axios.post(
|
||
DISCORD_WEBHOOK_URL,
|
||
{
|
||
embeds: [embed],
|
||
},
|
||
{
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
timeout: 10000,
|
||
},
|
||
);
|
||
} catch (error) {
|
||
console.error(
|
||
"Erreur lors de l'envoi du webhook Discord:",
|
||
error.response?.data || error.message,
|
||
);
|
||
// Ne pas bloquer l'exécution si le webhook échoue
|
||
}
|
||
}
|
||
|
||
// ==================== ROUTES NOTIFICATIONS ====================
|
||
|
||
// GET /api/comments/notifications - Récupérer les notifications de l'utilisateur
|
||
router.get("/notifications", requireAuth, async (req, res) => {
|
||
try {
|
||
const { page = 1, limit = 20, unreadOnly = false, profileId } = req.query;
|
||
const safePage = Math.max(1, Math.min(parseInt(page) || 1, 1000));
|
||
const safeLimit = Math.max(1, Math.min(parseInt(limit) || 20, 100));
|
||
const offset = (safePage - 1) * safeLimit;
|
||
|
||
// Validation du profileId
|
||
if (!profileId) {
|
||
return res.status(400).json({ error: "profileId requis" });
|
||
}
|
||
|
||
// Vérifier que le profileId appartient bien à l'utilisateur authentifié
|
||
const userProfileIds = await getProfileIds(
|
||
req.user.userId,
|
||
req.user.userType,
|
||
);
|
||
if (!userProfileIds.includes(profileId)) {
|
||
return res.status(403).json({ error: "Profil non autorisé" });
|
||
}
|
||
|
||
let query =
|
||
"SELECT * FROM notifications WHERE user_id = ? AND user_type = ? AND profile_id = ?";
|
||
const params = [req.user.userId, req.user.userType, profileId];
|
||
|
||
if (unreadOnly === "true") {
|
||
query += " AND is_read = 0";
|
||
}
|
||
|
||
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?";
|
||
params.push(safeLimit, offset);
|
||
|
||
const notifications = await dbAll(query, params);
|
||
|
||
res.json({ notifications });
|
||
} catch (error) {
|
||
console.error("Erreur lors de la récupération des notifications:", error);
|
||
res.status(500).json({ error: "Erreur serveur" });
|
||
}
|
||
});
|
||
|
||
// PUT /api/comments/notifications/:id/read - Marquer une notification comme lue
|
||
router.put("/notifications/:id/read", requireAuth, writeRateLimit, async (req, res) => {
|
||
try {
|
||
const { id } = req.params;
|
||
const { profileId } = req.body;
|
||
|
||
// Validation du profileId
|
||
if (!profileId) {
|
||
return res.status(400).json({ error: "profileId requis" });
|
||
}
|
||
|
||
// Vérifier que le profileId appartient bien à l'utilisateur authentifié
|
||
const userProfileIds = await getProfileIds(
|
||
req.user.userId,
|
||
req.user.userType,
|
||
);
|
||
if (!userProfileIds.includes(profileId)) {
|
||
return res.status(403).json({ error: "Profil non autorisé" });
|
||
}
|
||
|
||
await dbRun(
|
||
"UPDATE notifications SET is_read = 1 WHERE id = ? AND user_id = ? AND user_type = ? AND profile_id = ?",
|
||
[id, req.user.userId, req.user.userType, profileId],
|
||
);
|
||
|
||
res.json({ message: "Notification marquée comme lue" });
|
||
} catch (error) {
|
||
console.error("Erreur lors du marquage de la notification:", error);
|
||
res.status(500).json({ error: "Erreur serveur" });
|
||
}
|
||
});
|
||
|
||
// PUT /api/comments/notifications/read-all - Marquer toutes les notifications comme lues
|
||
router.put("/notifications/read-all", requireAuth, writeRateLimit, async (req, res) => {
|
||
try {
|
||
const { profileId } = req.body;
|
||
|
||
// Validation du profileId
|
||
if (!profileId) {
|
||
return res.status(400).json({ error: "profileId requis" });
|
||
}
|
||
|
||
// Vérifier que le profileId appartient bien à l'utilisateur authentifié
|
||
const userProfileIds = await getProfileIds(
|
||
req.user.userId,
|
||
req.user.userType,
|
||
);
|
||
if (!userProfileIds.includes(profileId)) {
|
||
return res.status(403).json({ error: "Profil non autorisé" });
|
||
}
|
||
|
||
await dbRun(
|
||
"UPDATE notifications SET is_read = 1 WHERE user_id = ? AND user_type = ? AND profile_id = ?",
|
||
[req.user.userId, req.user.userType, profileId],
|
||
);
|
||
|
||
res.json({
|
||
message: "Toutes les notifications ont été marquées comme lues",
|
||
});
|
||
} catch (error) {
|
||
console.error("Erreur lors du marquage des notifications:", error);
|
||
res.status(500).json({ error: "Erreur serveur" });
|
||
}
|
||
});
|
||
|
||
// DELETE /api/comments/notifications/:id - Supprimer une notification
|
||
router.delete("/notifications/:id", requireAuth, writeRateLimit, async (req, res) => {
|
||
try {
|
||
const { id } = req.params;
|
||
const { profileId } = req.query;
|
||
|
||
// Validation du profileId
|
||
if (!profileId) {
|
||
return res.status(400).json({ error: "profileId requis" });
|
||
}
|
||
|
||
// Vérifier que le profileId appartient bien à l'utilisateur authentifié
|
||
const userProfileIds = await getProfileIds(
|
||
req.user.userId,
|
||
req.user.userType,
|
||
);
|
||
if (!userProfileIds.includes(profileId)) {
|
||
return res.status(403).json({ error: "Profil non autorisé" });
|
||
}
|
||
|
||
// Vérifier que la notification appartient à l'utilisateur et au profil
|
||
const notification = await dbGet(
|
||
"SELECT * FROM notifications WHERE id = ? AND user_id = ? AND user_type = ? AND profile_id = ?",
|
||
[id, req.user.userId, req.user.userType, profileId],
|
||
);
|
||
|
||
if (!notification) {
|
||
return res.status(404).json({ error: "Notification non trouvée" });
|
||
}
|
||
|
||
// Supprimer la notification
|
||
await dbRun(
|
||
"DELETE FROM notifications WHERE id = ? AND user_id = ? AND user_type = ? AND profile_id = ?",
|
||
[id, req.user.userId, req.user.userType, profileId],
|
||
);
|
||
|
||
res.json({ message: "Notification supprimée" });
|
||
} catch (error) {
|
||
console.error("Erreur lors de la suppression de la notification:", error);
|
||
res.status(500).json({ error: "Erreur serveur" });
|
||
}
|
||
});
|
||
|
||
// GET /api/comments/notifications/preferences - Récupérer les préférences de notifications
|
||
router.get("/notifications/preferences", requireAuth, async (req, res) => {
|
||
try {
|
||
await ensureNotificationTables();
|
||
const pool = getCachedPool();
|
||
const [rows] = await pool.execute(
|
||
'SELECT notifications_disabled FROM user_notification_preferences WHERE user_id = ? AND user_type = ? LIMIT 1',
|
||
[req.user.userId, req.user.userType]
|
||
);
|
||
|
||
res.json({
|
||
success: true,
|
||
notificationsDisabled: rows.length > 0 && rows[0].notifications_disabled === 1,
|
||
});
|
||
} catch (error) {
|
||
console.error("Erreur lors de la récupération des préférences:", error);
|
||
res.status(500).json({ error: "Erreur serveur" });
|
||
}
|
||
});
|
||
|
||
// PUT /api/comments/notifications/preferences - Mettre à jour les préférences de notifications
|
||
router.put("/notifications/preferences", requireAuth, writeRateLimit, async (req, res) => {
|
||
try {
|
||
await ensureNotificationTables();
|
||
const disabled = req.body?.notificationsDisabled === true;
|
||
const pool = getCachedPool();
|
||
await pool.execute(
|
||
`INSERT INTO user_notification_preferences (user_id, user_type, notifications_disabled, updated_at)
|
||
VALUES (?, ?, ?, ?)
|
||
ON DUPLICATE KEY UPDATE notifications_disabled = VALUES(notifications_disabled), updated_at = VALUES(updated_at)`,
|
||
[req.user.userId, req.user.userType, disabled ? 1 : 0, Date.now()]
|
||
);
|
||
|
||
res.json({ success: true, notificationsDisabled: disabled });
|
||
} catch (error) {
|
||
console.error("Erreur lors de la mise à jour des préférences:", error);
|
||
res.status(500).json({ error: "Erreur serveur" });
|
||
}
|
||
});
|
||
|
||
// POST /api/comments/notifications/push/subscribe - Enregistrer une subscription push
|
||
router.post("/notifications/push/subscribe", requireAuth, writeRateLimit, async (req, res) => {
|
||
try {
|
||
const { subscription } = req.body;
|
||
// Validation stricte: endpoint + keys.p256dh + keys.auth requis
|
||
if (
|
||
!subscription ||
|
||
typeof subscription.endpoint !== "string" ||
|
||
!subscription.endpoint ||
|
||
!subscription.keys ||
|
||
typeof subscription.keys.p256dh !== "string" ||
|
||
!subscription.keys.p256dh ||
|
||
typeof subscription.keys.auth !== "string" ||
|
||
!subscription.keys.auth
|
||
) {
|
||
return res.status(400).json({ error: "Subscription invalide" });
|
||
}
|
||
|
||
await ensureNotificationTables();
|
||
const pool = getCachedPool();
|
||
|
||
// Vérifier si l'endpoint existe déjà — refuser le hijack cross-user
|
||
const [existing] = await pool.execute(
|
||
'SELECT user_id, user_type FROM push_subscriptions WHERE endpoint = ? LIMIT 1',
|
||
[subscription.endpoint]
|
||
);
|
||
|
||
if (existing.length > 0) {
|
||
const owner = existing[0];
|
||
if (
|
||
String(owner.user_id) !== String(req.user.userId) ||
|
||
String(owner.user_type) !== String(req.user.userType)
|
||
) {
|
||
// Endpoint appartient à un autre compte — refuser (anti-hijack push)
|
||
return res
|
||
.status(409)
|
||
.json({ error: "Endpoint déjà associé à un autre compte" });
|
||
}
|
||
// Même owner: rotate les clés (cas normal, le browser peut renouveler les clés)
|
||
await pool.execute(
|
||
'UPDATE push_subscriptions SET p256dh = ?, auth = ?, created_at = ? WHERE endpoint = ? AND user_id = ? AND user_type = ?',
|
||
[
|
||
subscription.keys.p256dh,
|
||
subscription.keys.auth,
|
||
Date.now(),
|
||
subscription.endpoint,
|
||
req.user.userId,
|
||
req.user.userType,
|
||
]
|
||
);
|
||
} else {
|
||
await pool.execute(
|
||
'INSERT INTO push_subscriptions (user_id, user_type, endpoint, p256dh, auth, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||
[
|
||
req.user.userId,
|
||
req.user.userType,
|
||
subscription.endpoint,
|
||
subscription.keys.p256dh,
|
||
subscription.keys.auth,
|
||
Date.now(),
|
||
]
|
||
);
|
||
}
|
||
res.json({ success: true });
|
||
} catch (error) {
|
||
console.error("Erreur lors de l'enregistrement push:", error);
|
||
res.status(500).json({ error: "Erreur serveur" });
|
||
}
|
||
});
|
||
|
||
// DELETE /api/comments/notifications/push/unsubscribe - Supprimer une subscription push
|
||
router.delete("/notifications/push/unsubscribe", requireAuth, writeRateLimit, async (req, res) => {
|
||
try {
|
||
const { endpoint } = req.body;
|
||
if (!endpoint) return res.status(400).json({ error: "Endpoint manquant" });
|
||
await ensureNotificationTables();
|
||
const pool = getCachedPool();
|
||
await pool.execute('DELETE FROM push_subscriptions WHERE endpoint = ? AND user_id = ? AND user_type = ?', [endpoint, req.user.userId, req.user.userType]);
|
||
res.json({ success: true });
|
||
} catch (error) {
|
||
console.error("Erreur lors de la désinscription push:", error);
|
||
res.status(500).json({ error: "Erreur serveur" });
|
||
}
|
||
});
|
||
|
||
// GET /api/comments/notifications/push/vapid-key - Récupérer la clé publique VAPID
|
||
router.get("/notifications/push/vapid-key", (req, res) => {
|
||
res.json({ publicKey: process.env.VAPID_PUBLIC_KEY });
|
||
});
|
||
|
||
// ==================== ROUTES RÉACTIONS ====================
|
||
|
||
// POST /api/comments/react - Ajouter/retirer une réaction
|
||
router.post("/react", requireAuth, writeRateLimit, async (req, res) => {
|
||
try {
|
||
const { targetType, targetId, profileId } = req.body; // targetType: 'comment' ou 'reply'
|
||
|
||
// Whitelist targetType pour éviter pollution de la table comment_reactions
|
||
if (!["comment", "reply"].includes(targetType)) {
|
||
return res.status(400).json({ error: "targetType invalide" });
|
||
}
|
||
|
||
// Verify profile ownership
|
||
const userProfileIds = await getProfileIds(
|
||
req.user.userId,
|
||
req.user.userType,
|
||
);
|
||
if (!userProfileIds.includes(profileId)) {
|
||
return res.status(403).json({ error: "Profil non autorisé" });
|
||
}
|
||
|
||
// Vérifier si la réaction existe déjà
|
||
const existingReaction = await dbGet(
|
||
"SELECT * FROM comment_reactions WHERE target_type = ? AND target_id = ? AND user_id = ? AND user_type = ? AND profile_id = ?",
|
||
[targetType, targetId, req.user.userId, req.user.userType, profileId],
|
||
);
|
||
|
||
if (existingReaction) {
|
||
// Retirer la réaction
|
||
await dbRun("DELETE FROM comment_reactions WHERE id = ?", [
|
||
existingReaction.id,
|
||
]);
|
||
|
||
res.json({ reacted: false });
|
||
} else {
|
||
// Ajouter la réaction
|
||
await dbRun(
|
||
"INSERT INTO comment_reactions (target_type, target_id, user_id, user_type, profile_id, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||
[
|
||
targetType,
|
||
targetId,
|
||
req.user.userId,
|
||
req.user.userType,
|
||
profileId,
|
||
Date.now(),
|
||
],
|
||
);
|
||
|
||
// Créer une notification pour l'auteur du commentaire/réponse
|
||
let targetUserId, targetUserType, targetProfileId, contentType, contentId;
|
||
if (targetType === "comment") {
|
||
const comment = await dbGet("SELECT * FROM comments WHERE id = ?", [
|
||
targetId,
|
||
]);
|
||
if (comment) {
|
||
targetUserId = comment.user_id;
|
||
targetUserType = comment.user_type;
|
||
targetProfileId = comment.profile_id;
|
||
contentType = comment.content_type;
|
||
contentId = comment.content_id;
|
||
|
||
// Ne pas créer de notification si on réagit à son propre commentaire
|
||
if (
|
||
targetUserId !== req.user.userId ||
|
||
targetUserType !== req.user.userType ||
|
||
targetProfileId !== profileId
|
||
) {
|
||
const userData = await getUserData(
|
||
req.user.userId,
|
||
req.user.userType,
|
||
profileId,
|
||
);
|
||
await createNotification(
|
||
targetUserId,
|
||
targetUserType,
|
||
targetProfileId,
|
||
req.user.userId,
|
||
profileId,
|
||
userData.username,
|
||
userData.avatar,
|
||
"reaction",
|
||
targetType,
|
||
targetId,
|
||
contentType,
|
||
contentId,
|
||
comment.content.substring(0, 100),
|
||
);
|
||
}
|
||
}
|
||
} else if (targetType === "reply") {
|
||
const reply = await dbGet(
|
||
"SELECT * FROM comment_replies WHERE id = ?",
|
||
[targetId],
|
||
);
|
||
if (reply) {
|
||
targetUserId = reply.user_id;
|
||
targetUserType = reply.user_type;
|
||
targetProfileId = reply.profile_id;
|
||
|
||
// Récupérer le commentaire parent pour avoir le contentType et contentId
|
||
const comment = await dbGet("SELECT * FROM comments WHERE id = ?", [
|
||
reply.comment_id,
|
||
]);
|
||
if (comment) {
|
||
contentType = comment.content_type;
|
||
contentId = comment.content_id;
|
||
|
||
// Ne pas créer de notification si on réagit à sa propre réponse
|
||
if (
|
||
targetUserId !== req.user.userId ||
|
||
targetUserType !== req.user.userType ||
|
||
targetProfileId !== profileId
|
||
) {
|
||
const userData = await getUserData(
|
||
req.user.userId,
|
||
req.user.userType,
|
||
profileId,
|
||
);
|
||
await createNotification(
|
||
targetUserId,
|
||
targetUserType,
|
||
targetProfileId,
|
||
req.user.userId,
|
||
profileId,
|
||
userData.username,
|
||
userData.avatar,
|
||
"reaction",
|
||
targetType,
|
||
targetId,
|
||
contentType,
|
||
contentId,
|
||
reply.content.substring(0, 100),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
res.json({ reacted: true });
|
||
}
|
||
} catch (error) {
|
||
console.error("Erreur lors de la gestion de la réaction:", error);
|
||
res.status(500).json({ error: "Erreur serveur" });
|
||
}
|
||
});
|
||
|
||
// GET /api/comments/reactions/:targetType/:targetId - Vérifier si l'utilisateur a réagi
|
||
router.get(
|
||
"/reactions/:targetType/:targetId",
|
||
requireAuth,
|
||
async (req, res) => {
|
||
try {
|
||
const { targetType, targetId } = req.params;
|
||
const { profileId } = req.query;
|
||
|
||
// Whitelist targetType
|
||
if (!["comment", "reply"].includes(targetType)) {
|
||
return res.status(400).json({ error: "targetType invalide" });
|
||
}
|
||
|
||
const reaction = await dbGet(
|
||
"SELECT * FROM comment_reactions WHERE target_type = ? AND target_id = ? AND user_id = ? AND user_type = ? AND profile_id = ?",
|
||
[targetType, targetId, req.user.userId, req.user.userType, profileId],
|
||
);
|
||
|
||
res.json({ reacted: !!reaction });
|
||
} catch (error) {
|
||
console.error("Erreur lors de la vérification de la réaction:", error);
|
||
res.status(500).json({ error: "Erreur serveur" });
|
||
}
|
||
},
|
||
);
|
||
|
||
// ==================== ROUTES RÉPONSES ====================
|
||
|
||
// GET /api/comments/:commentId/replies - Récupérer les réponses d'un commentaire (avec ordre hiérarchique)
|
||
// === #13: Optimisé — une seule requête SQL avec sous-requêtes au lieu de N+1 ===
|
||
router.get("/:commentId/replies", async (req, res) => {
|
||
try {
|
||
const { commentId } = req.params;
|
||
const safePage = Math.max(1, Math.min(parseInt(req.query.page) || 1, 1000));
|
||
const safeLimit = Math.max(1, Math.min(parseInt(req.query.limit) || 3, 50));
|
||
const offset = (safePage - 1) * safeLimit;
|
||
|
||
// Tenter de récupérer l'utilisateur connecté (optionnel)
|
||
let currentUser = null;
|
||
const token = req.headers.authorization?.split(" ")[1];
|
||
if (token) {
|
||
try {
|
||
const decoded = jwt.verify(token, JWT_SECRET, {
|
||
algorithms: ["HS256"],
|
||
});
|
||
currentUser = {
|
||
userId: decoded.sub,
|
||
userType: decoded.userType,
|
||
sessionId: decoded.sessionId,
|
||
};
|
||
} catch (error) {
|
||
// Token invalide, on continue sans utilisateur
|
||
}
|
||
}
|
||
|
||
const { profileId } = req.query;
|
||
|
||
// Récupérer le total de réponses
|
||
const totalResult = await dbGet(
|
||
"SELECT COUNT(*) as total FROM comment_replies WHERE comment_id = ? AND deleted = 0",
|
||
[commentId],
|
||
);
|
||
|
||
// #13: Requête unique avec COUNT des réactions + check user reaction via sous-requête
|
||
let repliesQuery;
|
||
let repliesParams;
|
||
|
||
if (currentUser && profileId) {
|
||
repliesQuery = `
|
||
SELECT cr.*,
|
||
(SELECT COUNT(*) FROM comment_reactions WHERE target_type = 'reply' AND target_id = cr.id) as reaction_count,
|
||
EXISTS(SELECT 1 FROM comment_reactions WHERE target_type = 'reply' AND target_id = cr.id
|
||
AND user_id = ? AND user_type = ? AND profile_id = ?) as user_reacted
|
||
FROM comment_replies cr
|
||
WHERE cr.comment_id = ? AND cr.deleted = 0
|
||
ORDER BY cr.hierarchical_path ASC
|
||
LIMIT ? OFFSET ?`;
|
||
repliesParams = [
|
||
currentUser.userId,
|
||
currentUser.userType,
|
||
profileId,
|
||
commentId,
|
||
safeLimit,
|
||
offset,
|
||
];
|
||
} else {
|
||
repliesQuery = `
|
||
SELECT cr.*,
|
||
(SELECT COUNT(*) FROM comment_reactions WHERE target_type = 'reply' AND target_id = cr.id) as reaction_count
|
||
FROM comment_replies cr
|
||
WHERE cr.comment_id = ? AND cr.deleted = 0
|
||
ORDER BY cr.hierarchical_path ASC
|
||
LIMIT ? OFFSET ?`;
|
||
repliesParams = [commentId, safeLimit, offset];
|
||
}
|
||
|
||
const replies = await dbAll(repliesQuery, repliesParams);
|
||
|
||
// Cache utilisateur en mémoire pour cette requête (getUserData est déjà caché dans Redis via #14)
|
||
const userDataCache = new Map();
|
||
|
||
const repliesWithDetails = await Promise.all(
|
||
replies.map(async (reply) => {
|
||
const userKey = `${reply.user_id}:${reply.user_type}:${reply.profile_id || "default"}`;
|
||
let userData = userDataCache.get(userKey);
|
||
if (!userData) {
|
||
userData = await getUserData(
|
||
reply.user_id,
|
||
reply.user_type,
|
||
reply.profile_id,
|
||
);
|
||
userDataCache.set(userKey, userData);
|
||
}
|
||
|
||
return {
|
||
...reply,
|
||
content: formatContentForResponse(reply.content),
|
||
username: userData.username,
|
||
avatar: userData.avatar,
|
||
is_vip: userData.isVip ? 1 : 0,
|
||
is_admin: userData.isAdmin ? 1 : 0,
|
||
reactions: reply.reaction_count,
|
||
userReaction:
|
||
reply.user_reacted !== undefined ? !!reply.user_reacted : null,
|
||
};
|
||
}),
|
||
);
|
||
|
||
res.json({
|
||
replies: repliesWithDetails,
|
||
total: totalResult.total,
|
||
page: safePage,
|
||
limit: safeLimit,
|
||
hasMore: offset + repliesWithDetails.length < totalResult.total,
|
||
});
|
||
} catch (error) {
|
||
console.error("Erreur lors de la récupération des réponses:", error);
|
||
res.status(500).json({ error: "Erreur serveur" });
|
||
}
|
||
});
|
||
|
||
// POST /api/comments/:commentId/replies - Créer une réponse
|
||
router.post("/:commentId/replies", requireAuth, writeRateLimit, async (req, res) => {
|
||
try {
|
||
const { commentId } = req.params;
|
||
let {
|
||
content,
|
||
isSpoiler,
|
||
profileId,
|
||
parentReplyId,
|
||
replyToUsername,
|
||
turnstileToken,
|
||
} = req.body;
|
||
|
||
// Vérification Turnstile
|
||
const turnstileResult = await verifyTurnstileFromRequest(
|
||
req,
|
||
turnstileToken,
|
||
);
|
||
if (!turnstileResult.valid)
|
||
return res
|
||
.status(turnstileResult.status)
|
||
.json({ error: turnstileResult.error });
|
||
|
||
// Normalize content while preserving the original characters
|
||
content = normalizeCommentContent(content);
|
||
|
||
console.log("💬 Création de réponse - Données reçues:", {
|
||
commentId,
|
||
content,
|
||
isSpoiler,
|
||
profileId,
|
||
parentReplyId,
|
||
replyToUsername,
|
||
});
|
||
|
||
// Verify profile ownership
|
||
const userProfileIds = await getProfileIds(
|
||
req.user.userId,
|
||
req.user.userType,
|
||
);
|
||
if (!userProfileIds.includes(profileId)) {
|
||
return res.status(403).json({ error: "Profil non autorisé" });
|
||
}
|
||
|
||
// Vérification de bannissement
|
||
const replyUserData = await getUserData(
|
||
req.user.userId,
|
||
req.user.userType,
|
||
profileId,
|
||
);
|
||
if (!replyUserData.isAdmin) {
|
||
const banStatus = await checkBan(
|
||
req.user.userId,
|
||
req.user.userType,
|
||
getClientIp(req),
|
||
);
|
||
if (banStatus.banned) {
|
||
return res.status(403).json({
|
||
error: "Vous êtes banni des commentaires.",
|
||
reason: banStatus.reason,
|
||
expires_at: banStatus.expires_at,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Validation
|
||
if (!content || content.length > 500) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: "La réponse doit contenir entre 1 et 500 caractères" });
|
||
}
|
||
|
||
// Vérifier que le commentaire existe
|
||
const comment = await dbGet(
|
||
"SELECT * FROM comments WHERE id = ? AND deleted = 0",
|
||
[commentId],
|
||
);
|
||
if (!comment) {
|
||
console.log(`❌ Commentaire ${commentId} non trouvé`);
|
||
return res.status(404).json({ error: "Commentaire non trouvé" });
|
||
}
|
||
console.log(`✅ Commentaire ${commentId} trouvé`);
|
||
// Récupérer les données utilisateur
|
||
const userData = await getUserData(
|
||
req.user.userId,
|
||
req.user.userType,
|
||
profileId,
|
||
);
|
||
console.log("👤 Données utilisateur pour la réponse:", userData);
|
||
|
||
// Calculer le hierarchical_path
|
||
let hierarchicalPath;
|
||
if (!parentReplyId) {
|
||
// Réponse racine : trouver le max des réponses racines
|
||
const maxPath = await dbGet(
|
||
`SELECT MAX(hierarchical_path) as max_path
|
||
FROM comment_replies
|
||
WHERE comment_id = ? AND parent_reply_id IS NULL AND deleted = 0`,
|
||
[commentId],
|
||
);
|
||
|
||
if (maxPath && maxPath.max_path) {
|
||
// Extraire le numéro et incrémenter
|
||
const currentNum = parseInt(maxPath.max_path.split(".")[0], 10);
|
||
hierarchicalPath = String(currentNum + 1).padStart(3, "0");
|
||
} else {
|
||
// Première réponse
|
||
hierarchicalPath = "001";
|
||
}
|
||
} else {
|
||
// Réponse à une autre réponse : récupérer le path du parent
|
||
const parentReply = await dbGet(
|
||
"SELECT hierarchical_path FROM comment_replies WHERE id = ?",
|
||
[parentReplyId],
|
||
);
|
||
|
||
if (!parentReply || !parentReply.hierarchical_path) {
|
||
return res.status(404).json({ error: "Réponse parent non trouvée" });
|
||
}
|
||
|
||
// Trouver le max des enfants de ce parent
|
||
const maxChildPath = await dbGet(
|
||
`SELECT MAX(hierarchical_path) as max_path
|
||
FROM comment_replies
|
||
WHERE comment_id = ? AND parent_reply_id = ? AND deleted = 0`,
|
||
[commentId, parentReplyId],
|
||
);
|
||
|
||
if (maxChildPath && maxChildPath.max_path) {
|
||
// Extraire le dernier numéro et incrémenter
|
||
const parts = maxChildPath.max_path.split(".");
|
||
const lastNum = parseInt(parts[parts.length - 1], 10);
|
||
hierarchicalPath = `${parentReply.hierarchical_path}.${String(lastNum + 1).padStart(3, "0")}`;
|
||
} else {
|
||
// Premier enfant de ce parent
|
||
hierarchicalPath = `${parentReply.hierarchical_path}.001`;
|
||
}
|
||
}
|
||
|
||
console.log(`📊 Hierarchical path calculé: ${hierarchicalPath}`);
|
||
|
||
// Insérer la réponse
|
||
console.log("💾 Insertion de la réponse dans la base...");
|
||
const result = await dbRun(
|
||
`INSERT INTO comment_replies (comment_id, parent_reply_id, user_id, user_type, profile_id, username, avatar, reply_to_username, content, is_spoiler, is_vip, is_admin, created_at, hierarchical_path, ip_address)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
[
|
||
commentId,
|
||
parentReplyId || null,
|
||
req.user.userId,
|
||
req.user.userType,
|
||
profileId,
|
||
userData.username,
|
||
userData.avatar,
|
||
replyToUsername || null,
|
||
content,
|
||
isSpoiler ? 1 : 0,
|
||
userData.isVip ? 1 : 0,
|
||
userData.isAdmin ? 1 : 0,
|
||
Date.now(),
|
||
hierarchicalPath,
|
||
getClientIp(req),
|
||
],
|
||
);
|
||
console.log("✅ Réponse insérée avec ID:", result.lastID);
|
||
|
||
// Créer une notification pour l'auteur du commentaire ou de la réponse parent
|
||
let targetUserId, targetUserType, targetProfileId;
|
||
if (parentReplyId) {
|
||
const parentReply = await dbGet(
|
||
"SELECT * FROM comment_replies WHERE id = ?",
|
||
[parentReplyId],
|
||
);
|
||
targetUserId = parentReply.user_id;
|
||
targetUserType = parentReply.user_type;
|
||
targetProfileId = parentReply.profile_id;
|
||
} else {
|
||
targetUserId = comment.user_id;
|
||
targetUserType = comment.user_type;
|
||
targetProfileId = comment.profile_id;
|
||
}
|
||
|
||
// Ne pas créer de notification si on se répond à soi-même
|
||
if (
|
||
targetUserId !== req.user.userId ||
|
||
targetUserType !== req.user.userType ||
|
||
targetProfileId !== profileId
|
||
) {
|
||
await createNotification(
|
||
targetUserId,
|
||
targetUserType,
|
||
targetProfileId,
|
||
req.user.userId,
|
||
profileId,
|
||
userData.username,
|
||
userData.avatar,
|
||
"reply",
|
||
"reply",
|
||
result.lastID,
|
||
comment.content_type,
|
||
comment.content_id,
|
||
content.substring(0, 100),
|
||
);
|
||
}
|
||
|
||
// Récupérer la réponse créée
|
||
const newReply = await dbGet("SELECT * FROM comment_replies WHERE id = ?", [
|
||
result.lastID,
|
||
]);
|
||
|
||
// Envoyer le webhook Discord en arrière-plan (ne pas attendre)
|
||
sendDiscordWebhook("reply", {
|
||
username: userData.username,
|
||
avatar: userData.avatar,
|
||
content,
|
||
contentType: comment.content_type,
|
||
contentId: comment.content_id,
|
||
isSpoiler,
|
||
isVip: userData.isVip,
|
||
isAdmin: userData.isAdmin,
|
||
replyToUsername,
|
||
userId: req.user.userId,
|
||
userType: req.user.userType,
|
||
profileId: profileId,
|
||
ipAddress: getClientIp(req),
|
||
}).catch((err) =>
|
||
console.error("Erreur webhook Discord (non bloquant):", err),
|
||
);
|
||
|
||
// Modération automatique avec l'IA en arrière-plan (ne pas attendre)
|
||
moderateContentWithAI(
|
||
result.lastID,
|
||
"reply",
|
||
content,
|
||
userData.username,
|
||
).catch((err) =>
|
||
console.error("Erreur modération IA réponse (non bloquant):", err),
|
||
);
|
||
|
||
res.status(201).json({
|
||
...newReply,
|
||
content: formatContentForResponse(newReply.content),
|
||
reactions: 0,
|
||
userReaction: null,
|
||
});
|
||
} catch (error) {
|
||
console.error("Erreur lors de la création de la réponse:", error);
|
||
res.status(500).json({ error: "Erreur serveur" });
|
||
}
|
||
});
|
||
|
||
// PUT /api/comments/replies/:id - Éditer une réponse
|
||
router.put("/replies/:id", requireAuth, writeRateLimit, async (req, res) => {
|
||
try {
|
||
const { id } = req.params;
|
||
let { content, isSpoiler, profileId } = req.body;
|
||
|
||
// Normalize content while preserving the original characters
|
||
content = normalizeCommentContent(content);
|
||
|
||
// Validation
|
||
if (!content || content.length > 500) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: "La réponse doit contenir entre 1 et 500 caractères" });
|
||
}
|
||
|
||
// Vérifier que la réponse appartient à l'utilisateur
|
||
const reply = await dbGet("SELECT * FROM comment_replies WHERE id = ?", [
|
||
id,
|
||
]);
|
||
if (!reply) {
|
||
return res.status(404).json({ error: "Réponse non trouvée" });
|
||
}
|
||
|
||
if (
|
||
reply.user_id !== req.user.userId ||
|
||
reply.user_type !== req.user.userType
|
||
) {
|
||
return res.status(403).json({ error: "Non autorisé" });
|
||
}
|
||
|
||
// Si la réponse a un profile_id, exiger que l'éditeur passe le même profileId
|
||
// (empêche un autre profil du même compte d'éditer)
|
||
if (reply.profile_id) {
|
||
if (!profileId || String(reply.profile_id) !== String(profileId)) {
|
||
return res
|
||
.status(403)
|
||
.json({ error: "Seul le profil auteur peut éditer cette réponse" });
|
||
}
|
||
}
|
||
|
||
// Mettre à jour la réponse
|
||
await dbRun(
|
||
"UPDATE comment_replies SET content = ?, is_spoiler = ?, is_edited = 1, updated_at = ? WHERE id = ?",
|
||
[content, isSpoiler ? 1 : 0, Date.now(), id],
|
||
);
|
||
|
||
// Récupérer la réponse mise à jour
|
||
const updatedReply = await dbGet(
|
||
"SELECT * FROM comment_replies WHERE id = ?",
|
||
[id],
|
||
);
|
||
|
||
res.json({
|
||
...updatedReply,
|
||
content: formatContentForResponse(updatedReply.content),
|
||
});
|
||
} catch (error) {
|
||
console.error("Erreur lors de l'édition de la réponse:", error);
|
||
res.status(500).json({ error: "Erreur serveur" });
|
||
}
|
||
});
|
||
|
||
// DELETE /api/comments/replies/:id - Supprimer une réponse (admin ou auteur)
|
||
router.delete("/replies/:id", requireAuth, writeRateLimit, async (req, res) => {
|
||
try {
|
||
const { id } = req.params;
|
||
const { profileId } = req.query;
|
||
|
||
// Récupérer la réponse
|
||
const reply = await dbGet(
|
||
"SELECT comment_id, user_id, user_type, profile_id FROM comment_replies WHERE id = ?",
|
||
[id],
|
||
);
|
||
if (!reply) {
|
||
return res.status(404).json({ error: "Réponse non trouvée" });
|
||
}
|
||
|
||
// Vérifier que l'utilisateur est admin ou auteur de la réponse
|
||
const userData = await getUserData(req.user.userId, req.user.userType);
|
||
const userMatch =
|
||
String(reply.user_id) === String(req.user.userId) &&
|
||
String(reply.user_type) === String(req.user.userType);
|
||
// Si la réponse a un profile_id, le profileId fourni doit matcher exactement
|
||
// (empêche un kid profile de supprimer la réponse d'un adult profile du même compte)
|
||
let profileMatch = true;
|
||
if (reply.profile_id) {
|
||
profileMatch =
|
||
!!profileId && String(reply.profile_id) === String(profileId);
|
||
}
|
||
const isOwner = userMatch && profileMatch;
|
||
|
||
if (!userData.isAdmin && !isOwner) {
|
||
return res.status(403).json({ error: "Non autorisé" });
|
||
}
|
||
|
||
// Supprimer la réponse et tous ses enfants en cascade
|
||
await deleteRepliesCascade(reply.comment_id, id);
|
||
|
||
res.json({
|
||
message: "Réponse et ses réponses enfants supprimées en cascade",
|
||
});
|
||
} catch (error) {
|
||
console.error("Erreur lors de la suppression de la réponse:", error);
|
||
res.status(500).json({ error: "Erreur serveur" });
|
||
}
|
||
});
|
||
|
||
// ==================== ROUTES COMMENTAIRES ====================
|
||
|
||
// GET /api/comments/admin/list - Lister tous les commentaires (admin uniquement)
|
||
router.get("/admin/list", requireAuth, async (req, res) => {
|
||
try {
|
||
const {
|
||
page = 1,
|
||
limit = 50,
|
||
search = "",
|
||
contentType = "all",
|
||
} = req.query;
|
||
const offset = (page - 1) * limit;
|
||
|
||
// Vérifier que l'utilisateur est admin
|
||
const userData = await getUserData(req.user.userId, req.user.userType);
|
||
if (!userData.isAdmin) {
|
||
return res.status(403).json({ error: "Non autorisé" });
|
||
}
|
||
|
||
let query =
|
||
'SELECT c.*, COUNT(r.id) as reaction_count FROM comments c LEFT JOIN comment_reactions r ON r.target_type = "comment" AND r.target_id = c.id WHERE c.deleted = 0';
|
||
const params = [];
|
||
|
||
if (search) {
|
||
query +=
|
||
" AND (c.content LIKE ? OR c.username LIKE ? OR c.content_id LIKE ?)";
|
||
const searchParam = `%${search}%`;
|
||
params.push(searchParam, searchParam, searchParam);
|
||
}
|
||
|
||
if (contentType !== "all") {
|
||
query += " AND c.content_type = ?";
|
||
params.push(contentType);
|
||
}
|
||
|
||
query += " GROUP BY c.id ORDER BY c.created_at DESC LIMIT ? OFFSET ?";
|
||
params.push(parseInt(limit), parseInt(offset));
|
||
|
||
const rawComments = await dbAll(query, params);
|
||
|
||
// Enrichir avec les données utilisateur actuelles (Username, Avatar, VIP, Admin)
|
||
const comments = await Promise.all(
|
||
rawComments.map(async (comment) => {
|
||
const userData = await getUserData(
|
||
comment.user_id,
|
||
comment.user_type,
|
||
comment.profile_id,
|
||
);
|
||
return {
|
||
...comment,
|
||
content: formatContentForResponse(comment.content),
|
||
username: userData.username,
|
||
avatar: userData.avatar,
|
||
is_vip: userData.isVip,
|
||
is_admin: userData.isAdmin,
|
||
};
|
||
}),
|
||
);
|
||
|
||
// Compter le total pour la pagination
|
||
let countQuery = "SELECT COUNT(*) as total FROM comments WHERE deleted = 0";
|
||
const countParams = [];
|
||
|
||
if (search) {
|
||
countQuery +=
|
||
" AND (content LIKE ? OR username LIKE ? OR content_id LIKE ?)";
|
||
const searchParam = `%${search}%`;
|
||
countParams.push(searchParam, searchParam, searchParam);
|
||
}
|
||
|
||
if (contentType !== "all") {
|
||
countQuery += " AND content_type = ?";
|
||
countParams.push(contentType);
|
||
}
|
||
|
||
const totalResult = await dbGet(countQuery, countParams);
|
||
|
||
// Stats — une seule requête avec CASE pour compter les stats
|
||
const statsResult = await dbGet(`
|
||
SELECT
|
||
COUNT(*) as total,
|
||
SUM(CASE WHEN content_type = 'movie' THEN 1 ELSE 0 END) as movies,
|
||
SUM(CASE WHEN content_type = 'tv' THEN 1 ELSE 0 END) as tv
|
||
FROM comments WHERE deleted = 0
|
||
`);
|
||
const moderatedCount = (
|
||
await dbGet(`
|
||
SELECT
|
||
(SELECT COUNT(*) FROM comments WHERE deleted = 1 AND moderation_reason IS NOT NULL) +
|
||
(SELECT COUNT(*) FROM comment_replies WHERE deleted = 1 AND moderation_reason IS NOT NULL) as total
|
||
`)
|
||
).total;
|
||
|
||
const stats = {
|
||
total: statsResult.total,
|
||
movies: statsResult.movies,
|
||
tv: statsResult.tv,
|
||
moderated: moderatedCount,
|
||
};
|
||
|
||
res.json({
|
||
comments,
|
||
stats,
|
||
total: totalResult.total,
|
||
page: parseInt(page),
|
||
limit: parseInt(limit),
|
||
});
|
||
} catch (error) {
|
||
console.error(
|
||
"Erreur lors de la récupération admin des commentaires:",
|
||
error,
|
||
);
|
||
res.status(500).json({ error: "Erreur serveur" });
|
||
}
|
||
});
|
||
|
||
// GET /api/comments/admin/moderated - Lister les commentaires/réponses modérés (admin uniquement)
|
||
// === #17: Pagination SQL avec UNION ALL au lieu de charger tout en mémoire ===
|
||
router.get("/admin/moderated", requireAuth, async (req, res) => {
|
||
try {
|
||
const { page = 1, limit = 50, type = "all" } = req.query;
|
||
const safePage = Math.max(1, parseInt(page) || 1);
|
||
const safeLimit = Math.max(1, Math.min(parseInt(limit) || 50, 100));
|
||
const offset = (safePage - 1) * safeLimit;
|
||
|
||
// Vérifier que l'utilisateur est admin
|
||
const userData = await getUserData(req.user.userId, req.user.userType);
|
||
if (!userData.isAdmin) {
|
||
return res.status(403).json({ error: "Non autorisé" });
|
||
}
|
||
|
||
// Construire les requêtes avec UNION ALL et pagination SQL
|
||
let unionParts = [];
|
||
let countParts = [];
|
||
|
||
if (type === "all" || type === "comments") {
|
||
unionParts.push(`
|
||
SELECT c.id, c.user_id, c.user_type, c.profile_id, c.content, c.content_type, c.content_id,
|
||
c.is_spoiler, c.is_edited, c.created_at, c.moderation_reason, c.moderation_details, c.moderated_at,
|
||
'comment' as item_type,
|
||
(SELECT COUNT(*) FROM comment_reactions WHERE target_type = 'comment' AND target_id = c.id) as reaction_count
|
||
FROM comments c
|
||
WHERE c.deleted = 1 AND c.moderation_reason IS NOT NULL
|
||
`);
|
||
countParts.push(
|
||
`SELECT COUNT(*) as cnt FROM comments WHERE deleted = 1 AND moderation_reason IS NOT NULL`,
|
||
);
|
||
}
|
||
|
||
if (type === "all" || type === "replies") {
|
||
unionParts.push(`
|
||
SELECT cr.id, cr.user_id, cr.user_type, cr.profile_id, cr.content, c2.content_type, c2.content_id,
|
||
cr.is_spoiler, cr.is_edited, cr.created_at, cr.moderation_reason, cr.moderation_details, cr.moderated_at,
|
||
'reply' as item_type,
|
||
(SELECT COUNT(*) FROM comment_reactions WHERE target_type = 'reply' AND target_id = cr.id) as reaction_count
|
||
FROM comment_replies cr
|
||
JOIN comments c2 ON c2.id = cr.comment_id
|
||
WHERE cr.deleted = 1 AND cr.moderation_reason IS NOT NULL
|
||
`);
|
||
countParts.push(
|
||
`SELECT COUNT(*) as cnt FROM comment_replies WHERE deleted = 1 AND moderation_reason IS NOT NULL`,
|
||
);
|
||
}
|
||
|
||
if (unionParts.length === 0) {
|
||
return res.json({
|
||
items: [],
|
||
total: 0,
|
||
page: safePage,
|
||
limit: safeLimit,
|
||
hasMore: false,
|
||
});
|
||
}
|
||
|
||
// Compter le total
|
||
const countQuery =
|
||
countParts.length === 1
|
||
? countParts[0]
|
||
: `SELECT (${countParts.map((q) => `(${q})`).join(" + ")}) as cnt`;
|
||
const totalResult = await dbGet(countQuery);
|
||
const total = totalResult.cnt;
|
||
|
||
// Requête combinée avec UNION ALL, tri et pagination
|
||
const dataQuery = `
|
||
SELECT * FROM (
|
||
${unionParts.join(" UNION ALL ")}
|
||
) AS combined
|
||
ORDER BY moderated_at DESC
|
||
LIMIT ? OFFSET ?
|
||
`;
|
||
|
||
const items = await dbAll(dataQuery, [safeLimit, offset]);
|
||
|
||
// Enrichir avec les données utilisateur (getUserData est caché dans Redis via #14)
|
||
const userDataCache = new Map();
|
||
const enrichedItems = await Promise.all(
|
||
items.map(async (item) => {
|
||
const userKey = `${item.user_id}:${item.user_type}:${item.profile_id || "default"}`;
|
||
let ud = userDataCache.get(userKey);
|
||
if (!ud) {
|
||
ud = await getUserData(item.user_id, item.user_type, item.profile_id);
|
||
userDataCache.set(userKey, ud);
|
||
}
|
||
return {
|
||
...item,
|
||
content: formatContentForResponse(item.content),
|
||
username: ud.username,
|
||
avatar: ud.avatar,
|
||
is_vip: ud.isVip,
|
||
is_admin: ud.isAdmin,
|
||
};
|
||
}),
|
||
);
|
||
|
||
res.json({
|
||
items: enrichedItems,
|
||
total,
|
||
page: safePage,
|
||
limit: safeLimit,
|
||
hasMore: offset + enrichedItems.length < total,
|
||
});
|
||
} catch (error) {
|
||
console.error(
|
||
"Erreur lors de la récupération des contenus modérés:",
|
||
error,
|
||
);
|
||
res.status(500).json({ error: "Erreur serveur" });
|
||
}
|
||
});
|
||
|
||
// PUT /api/comments/admin/moderated/:type/:id/approve - Approuver un contenu modéré (rendre visible)
|
||
router.put(
|
||
"/admin/moderated/:type/:id/approve",
|
||
requireAuth,
|
||
async (req, res) => {
|
||
try {
|
||
const { type, id } = req.params;
|
||
|
||
// Validate type parameter to prevent SQL injection via table name
|
||
if (!["comment", "reply"].includes(type)) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: 'Type invalide. Doit être "comment" ou "reply"' });
|
||
}
|
||
|
||
// Vérifier que l'utilisateur est admin
|
||
const userData = await getUserData(req.user.userId, req.user.userType);
|
||
if (!userData.isAdmin) {
|
||
return res.status(403).json({ error: "Non autorisé" });
|
||
}
|
||
|
||
const table = type === "comment" ? "comments" : "comment_replies";
|
||
|
||
// Remettre deleted à 0 et garder les infos de modération pour historique
|
||
await dbRun(
|
||
`UPDATE ${table} SET deleted = 0, approved_by_admin = 1, approved_at = ? WHERE id = ?`,
|
||
[Date.now(), id],
|
||
);
|
||
|
||
console.log(`✅ ${type} ID ${id} approuvé par admin`);
|
||
res.json({ message: "Contenu approuvé et rendu visible" });
|
||
} catch (error) {
|
||
console.error("Erreur lors de l'approbation du contenu:", error);
|
||
res.status(500).json({ error: "Erreur serveur" });
|
||
}
|
||
},
|
||
);
|
||
|
||
// DELETE /api/comments/admin/moderated/:type/:id - Supprimer définitivement un contenu modéré
|
||
router.delete("/admin/moderated/:type/:id", requireAuth, async (req, res) => {
|
||
try {
|
||
const { type, id } = req.params;
|
||
|
||
// Validate type parameter
|
||
if (!["comment", "reply"].includes(type)) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: 'Type invalide. Doit être "comment" ou "reply"' });
|
||
}
|
||
|
||
// Vérifier que l'utilisateur est admin
|
||
const userData = await getUserData(req.user.userId, req.user.userType);
|
||
if (!userData.isAdmin) {
|
||
return res.status(403).json({ error: "Non autorisé" });
|
||
}
|
||
|
||
if (type === "comment") {
|
||
// Supprimer définitivement le commentaire et toutes ses dépendances
|
||
await hardDeleteCommentCascade(id);
|
||
} else if (type === "reply") {
|
||
// Récupérer la réponse pour obtenir le commentId
|
||
const reply = await dbGet(
|
||
"SELECT comment_id FROM comment_replies WHERE id = ?",
|
||
[id],
|
||
);
|
||
if (reply) {
|
||
await hardDeleteRepliesCascade(reply.comment_id, id);
|
||
}
|
||
}
|
||
|
||
console.log(`🗑️ ${type} ID ${id} supprimé définitivement par admin`);
|
||
res.json({ message: "Contenu supprimé définitivement" });
|
||
} catch (error) {
|
||
console.error("Erreur lors de la suppression définitive:", error);
|
||
res.status(500).json({ error: "Erreur serveur" });
|
||
}
|
||
});
|
||
|
||
// ==================== ROUTES BANNISSEMENT ====================
|
||
// IMPORTANT: Ces routes doivent être AVANT /:contentType/:contentId pour éviter les conflits Express
|
||
|
||
// POST /api/comments/admin/ban - Bannir un utilisateur ou une IP
|
||
router.post("/admin/ban", requireAuth, async (req, res) => {
|
||
try {
|
||
const userData = await getUserData(req.user.userId, req.user.userType);
|
||
if (!userData.isAdmin) {
|
||
return res.status(403).json({ error: "Non autorisé" });
|
||
}
|
||
|
||
const {
|
||
banType,
|
||
banValue,
|
||
userType: targetUserType,
|
||
reason,
|
||
duration,
|
||
username,
|
||
deleteAll,
|
||
} = req.body;
|
||
|
||
if (!["ip", "user"].includes(banType) || !banValue) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: "banType (ip/user) et banValue requis" });
|
||
}
|
||
|
||
if (banType === "user" && !targetUserType) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: "userType requis pour un ban utilisateur" });
|
||
}
|
||
|
||
let expiresAt = null;
|
||
if (duration && duration !== "permanent") {
|
||
const durations = {
|
||
"1h": 3600000,
|
||
"24h": 86400000,
|
||
"7d": 604800000,
|
||
"30d": 2592000000,
|
||
};
|
||
if (durations[duration]) {
|
||
expiresAt = Date.now() + durations[duration];
|
||
}
|
||
}
|
||
|
||
const userTypeValue = banType === "user" ? targetUserType : null;
|
||
|
||
const existingBan = await dbGet(
|
||
"SELECT id FROM banned_users WHERE ban_type = ? AND ban_value = ? AND (user_type = ? OR (user_type IS NULL AND ? IS NULL))",
|
||
[banType, banValue, userTypeValue, userTypeValue],
|
||
);
|
||
|
||
if (existingBan) {
|
||
await dbRun(
|
||
"UPDATE banned_users SET reason = ?, banned_by = ?, banned_at = ?, expires_at = ?, username = ? WHERE id = ?",
|
||
[
|
||
reason || null,
|
||
req.user.userId,
|
||
Date.now(),
|
||
expiresAt,
|
||
username || null,
|
||
existingBan.id,
|
||
],
|
||
);
|
||
} else {
|
||
await dbRun(
|
||
"INSERT INTO banned_users (ban_type, ban_value, user_type, reason, banned_by, banned_at, expires_at, username) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||
[
|
||
banType,
|
||
banValue,
|
||
userTypeValue,
|
||
reason || null,
|
||
req.user.userId,
|
||
Date.now(),
|
||
expiresAt,
|
||
username || null,
|
||
],
|
||
);
|
||
}
|
||
|
||
let deletedCount = 0;
|
||
if (deleteAll && banType === "user") {
|
||
const commentsResult = await dbRun(
|
||
"UPDATE comments SET deleted = 1 WHERE user_id = ? AND deleted = 0",
|
||
[banValue],
|
||
);
|
||
const repliesResult = await dbRun(
|
||
"UPDATE comment_replies SET deleted = 1 WHERE user_id = ? AND deleted = 0",
|
||
[banValue],
|
||
);
|
||
deletedCount =
|
||
(commentsResult.changes || 0) + (repliesResult.changes || 0);
|
||
}
|
||
|
||
res.json({
|
||
message: "Utilisateur banni avec succès",
|
||
deletedCount,
|
||
expiresAt,
|
||
});
|
||
} catch (error) {
|
||
console.error("Erreur lors du bannissement:", error);
|
||
res.status(500).json({ error: "Erreur serveur" });
|
||
}
|
||
});
|
||
|
||
// DELETE /api/comments/admin/ban/:id - Débannir + restaurer les commentaires
|
||
router.delete("/admin/ban/:id", requireAuth, async (req, res) => {
|
||
try {
|
||
const userData = await getUserData(req.user.userId, req.user.userType);
|
||
if (!userData.isAdmin) {
|
||
return res.status(403).json({ error: "Non autorisé" });
|
||
}
|
||
|
||
const { id } = req.params;
|
||
const restoreComments = req.query.restore === "true";
|
||
|
||
let restoredCount = 0;
|
||
if (restoreComments) {
|
||
const ban = await dbGet("SELECT * FROM banned_users WHERE id = ?", [id]);
|
||
if (ban && ban.ban_type === "user") {
|
||
const r1 = await dbRun(
|
||
"UPDATE comments SET deleted = 0 WHERE user_id = ? AND deleted = 1 AND moderation_reason IS NULL",
|
||
[ban.ban_value],
|
||
);
|
||
const r2 = await dbRun(
|
||
"UPDATE comment_replies SET deleted = 0 WHERE user_id = ? AND deleted = 1 AND moderation_reason IS NULL",
|
||
[ban.ban_value],
|
||
);
|
||
restoredCount = (r1.changes || 0) + (r2.changes || 0);
|
||
}
|
||
}
|
||
|
||
await dbRun("DELETE FROM banned_users WHERE id = ?", [id]);
|
||
res.json({ message: "Ban supprimé avec succès", restoredCount });
|
||
} catch (error) {
|
||
console.error("Erreur lors du débannissement:", error);
|
||
res.status(500).json({ error: "Erreur serveur" });
|
||
}
|
||
});
|
||
|
||
// DELETE /api/comments/admin/bans - Supprimer tous les bans + restaurer les commentaires
|
||
router.delete("/admin/bans", requireAuth, async (req, res) => {
|
||
try {
|
||
const userData = await getUserData(req.user.userId, req.user.userType);
|
||
if (!userData.isAdmin) {
|
||
return res.status(403).json({ error: "Non autorisé" });
|
||
}
|
||
|
||
const restoreComments = req.query.restore === "true";
|
||
|
||
let restoredCount = 0;
|
||
if (restoreComments) {
|
||
const userBans = await dbAll(
|
||
"SELECT ban_value FROM banned_users WHERE ban_type = 'user'",
|
||
);
|
||
if (userBans.length > 0) {
|
||
const placeholders = userBans.map(() => "?").join(",");
|
||
const userIds = userBans.map((b) => b.ban_value);
|
||
const r1 = await dbRun(
|
||
`UPDATE comments SET deleted = 0 WHERE user_id IN (${placeholders}) AND deleted = 1 AND moderation_reason IS NULL`,
|
||
userIds,
|
||
);
|
||
const r2 = await dbRun(
|
||
`UPDATE comment_replies SET deleted = 0 WHERE user_id IN (${placeholders}) AND deleted = 1 AND moderation_reason IS NULL`,
|
||
userIds,
|
||
);
|
||
restoredCount = (r1.changes || 0) + (r2.changes || 0);
|
||
}
|
||
}
|
||
|
||
const result = await dbRun("DELETE FROM banned_users");
|
||
res.json({
|
||
message: `${result.changes || 0} ban(s) supprimé(s)`,
|
||
deletedCount: result.changes || 0,
|
||
restoredCount,
|
||
});
|
||
} catch (error) {
|
||
console.error("Erreur lors de la suppression de tous les bans:", error);
|
||
res.status(500).json({ error: "Erreur serveur" });
|
||
}
|
||
});
|
||
|
||
// GET /api/comments/admin/bans - Lister les bans actifs
|
||
router.get("/admin/bans", requireAuth, async (req, res) => {
|
||
try {
|
||
const userData = await getUserData(req.user.userId, req.user.userType);
|
||
if (!userData.isAdmin) {
|
||
return res.status(403).json({ error: "Non autorisé" });
|
||
}
|
||
|
||
const { showExpired, page = "1", limit = "30", search } = req.query;
|
||
const pageNum = Math.max(1, parseInt(page));
|
||
const limitNum = Math.min(100, Math.max(1, parseInt(limit)));
|
||
const offset = (pageNum - 1) * limitNum;
|
||
|
||
let whereClause = "";
|
||
const params = [];
|
||
const countParams = [];
|
||
|
||
if (showExpired !== "true") {
|
||
whereClause = " WHERE (expires_at IS NULL OR expires_at > ?)";
|
||
params.push(Date.now());
|
||
countParams.push(Date.now());
|
||
}
|
||
|
||
if (search && search.trim()) {
|
||
const searchTerm = `%${search.trim()}%`;
|
||
whereClause += whereClause ? " AND" : " WHERE";
|
||
whereClause += " (ban_value LIKE ? OR username LIKE ? OR reason LIKE ?)";
|
||
params.push(searchTerm, searchTerm, searchTerm);
|
||
countParams.push(searchTerm, searchTerm, searchTerm);
|
||
}
|
||
|
||
const countResult = await dbAll(
|
||
`SELECT COUNT(*) as total FROM banned_users${whereClause}`,
|
||
countParams,
|
||
);
|
||
const total = countResult[0]?.total || 0;
|
||
|
||
params.push(limitNum, offset);
|
||
const bans = await dbAll(
|
||
`SELECT * FROM banned_users${whereClause} ORDER BY banned_at DESC LIMIT ? OFFSET ?`,
|
||
params,
|
||
);
|
||
res.json({
|
||
bans,
|
||
total,
|
||
page: pageNum,
|
||
limit: limitNum,
|
||
hasMore: offset + bans.length < total,
|
||
});
|
||
} catch (error) {
|
||
console.error("Erreur lors de la récupération des bans:", error);
|
||
res.status(500).json({ error: "Erreur serveur" });
|
||
}
|
||
});
|
||
|
||
// POST /api/comments/admin/delete-all-by-user - Supprimer tous les commentaires d'un utilisateur
|
||
router.post("/admin/delete-all-by-user", requireAuth, async (req, res) => {
|
||
try {
|
||
const userData = await getUserData(req.user.userId, req.user.userType);
|
||
if (!userData.isAdmin) {
|
||
return res.status(403).json({ error: "Non autorisé" });
|
||
}
|
||
|
||
const { userId } = req.body;
|
||
if (!userId) {
|
||
return res.status(400).json({ error: "userId requis" });
|
||
}
|
||
|
||
const commentsResult = await dbRun(
|
||
"UPDATE comments SET deleted = 1 WHERE user_id = ? AND deleted = 0",
|
||
[userId],
|
||
);
|
||
const repliesResult = await dbRun(
|
||
"UPDATE comment_replies SET deleted = 1 WHERE user_id = ? AND deleted = 0",
|
||
[userId],
|
||
);
|
||
|
||
const totalDeleted =
|
||
(commentsResult.changes || 0) + (repliesResult.changes || 0);
|
||
res.json({
|
||
message: `${totalDeleted} commentaire(s) et réponse(s) supprimé(s)`,
|
||
deletedComments: commentsResult.changes || 0,
|
||
deletedReplies: repliesResult.changes || 0,
|
||
});
|
||
} catch (error) {
|
||
console.error("Erreur lors de la suppression en masse:", error);
|
||
res.status(500).json({ error: "Erreur serveur" });
|
||
}
|
||
});
|
||
|
||
// Normalise le texte Unicode : petites majuscules, caractères spéciaux → ASCII, puis trim + lowercase
|
||
function normalizeText(text) {
|
||
if (!text) return "";
|
||
// Map des petites majuscules Unicode et variantes courantes → ASCII
|
||
const unicodeMap = {
|
||
ᴀ: "a",
|
||
ʙ: "b",
|
||
ᴄ: "c",
|
||
ᴅ: "d",
|
||
ᴇ: "e",
|
||
ꜰ: "f",
|
||
ɢ: "g",
|
||
ʜ: "h",
|
||
ɪ: "i",
|
||
ᴊ: "j",
|
||
ᴋ: "k",
|
||
ʟ: "l",
|
||
ᴍ: "m",
|
||
ɴ: "n",
|
||
ᴏ: "o",
|
||
ᴘ: "p",
|
||
ǫ: "q",
|
||
ʀ: "r",
|
||
ꜱ: "s",
|
||
ᴛ: "t",
|
||
ᴜ: "u",
|
||
ᴠ: "v",
|
||
ᴡ: "w",
|
||
x: "x",
|
||
ʏ: "y",
|
||
ᴢ: "z",
|
||
a: "a",
|
||
b: "b",
|
||
c: "c",
|
||
d: "d",
|
||
e: "e",
|
||
f: "f",
|
||
g: "g",
|
||
h: "h",
|
||
i: "i",
|
||
j: "j",
|
||
k: "k",
|
||
l: "l",
|
||
m: "m",
|
||
n: "n",
|
||
o: "o",
|
||
p: "p",
|
||
q: "q",
|
||
r: "r",
|
||
s: "s",
|
||
t: "t",
|
||
u: "u",
|
||
v: "v",
|
||
w: "w",
|
||
x: "x",
|
||
y: "y",
|
||
z: "z",
|
||
A: "a",
|
||
B: "b",
|
||
C: "c",
|
||
D: "d",
|
||
E: "e",
|
||
F: "f",
|
||
G: "g",
|
||
H: "h",
|
||
I: "i",
|
||
J: "j",
|
||
K: "k",
|
||
L: "l",
|
||
M: "m",
|
||
N: "n",
|
||
O: "o",
|
||
P: "p",
|
||
Q: "q",
|
||
R: "r",
|
||
S: "s",
|
||
T: "t",
|
||
U: "u",
|
||
V: "v",
|
||
W: "w",
|
||
X: "x",
|
||
Y: "y",
|
||
Z: "z",
|
||
"𝐚": "a",
|
||
"𝐛": "b",
|
||
"𝐜": "c",
|
||
"𝐝": "d",
|
||
"𝐞": "e",
|
||
"𝐟": "f",
|
||
"𝐠": "g",
|
||
"𝐡": "h",
|
||
"𝐢": "i",
|
||
"𝐣": "j",
|
||
"𝐤": "k",
|
||
"𝐥": "l",
|
||
"𝐦": "m",
|
||
"𝐧": "n",
|
||
"𝐨": "o",
|
||
"𝐩": "p",
|
||
"𝐪": "q",
|
||
"𝐫": "r",
|
||
"𝐬": "s",
|
||
"𝐭": "t",
|
||
"𝐮": "u",
|
||
"𝐯": "v",
|
||
"𝐰": "w",
|
||
"𝐱": "x",
|
||
"𝐲": "y",
|
||
"𝐳": "z",
|
||
"⒜": "a",
|
||
"⒝": "b",
|
||
"⒞": "c",
|
||
"⒟": "d",
|
||
"⒠": "e",
|
||
"⒡": "f",
|
||
"⒢": "g",
|
||
"⒣": "h",
|
||
"⒤": "i",
|
||
"⒥": "j",
|
||
"⒦": "k",
|
||
"⒧": "l",
|
||
"⒨": "m",
|
||
"⒩": "n",
|
||
"⒪": "o",
|
||
"⒫": "p",
|
||
"⒬": "q",
|
||
"⒭": "r",
|
||
"⒮": "s",
|
||
"⒯": "t",
|
||
"⒰": "u",
|
||
"⒱": "v",
|
||
"⒲": "w",
|
||
"⒳": "x",
|
||
"⒴": "y",
|
||
"⒵": "z",
|
||
".": ".",
|
||
"。": ".",
|
||
"·": ".",
|
||
"⋅": ".",
|
||
"∙": ".",
|
||
};
|
||
let normalized = "";
|
||
for (const char of text) {
|
||
normalized += unicodeMap[char] || char;
|
||
}
|
||
return normalized
|
||
.normalize("NFKD")
|
||
.replace(/[\u0300-\u036f]/g, "") // supprimer les accents diacritiques
|
||
.toLowerCase()
|
||
.trim()
|
||
.replace(/\s+/g, " "); // normaliser les espaces multiples
|
||
}
|
||
|
||
// Détecte si un texte contient des caractères Unicode "fancy" (spam)
|
||
function containsFancyUnicode(text) {
|
||
if (!text) return false;
|
||
// IPA Extensions (ʟ,ʀ,ɴ,ʏ,ɢ,ʙ,ʜ,ɪ), petites majuscules latines, fullwidth, math bold/italic, parenthesized, circled
|
||
return /[\u0250-\u02AF\u1D00-\u1D7F\uFF01-\uFF5E\u{1D400}-\u{1D7FF}\u2474-\u2497\u249C-\u24E9\u2460-\u2473]/u.test(
|
||
text,
|
||
);
|
||
}
|
||
|
||
// POST /api/comments/admin/detect-duplicates - Détecter les commentaires en doublons
|
||
router.post("/admin/detect-duplicates", requireAuth, async (req, res) => {
|
||
try {
|
||
const userData = await getUserData(req.user.userId, req.user.userType);
|
||
if (!userData.isAdmin) {
|
||
return res.status(403).json({ error: "Non autorisé" });
|
||
}
|
||
|
||
// Récupérer tous les commentaires non supprimés
|
||
const allComments = await dbAll(`
|
||
SELECT id, user_id, user_type, profile_id, username, avatar,
|
||
content, content_type, content_id, created_at
|
||
FROM comments
|
||
WHERE deleted = 0
|
||
ORDER BY created_at ASC
|
||
`);
|
||
|
||
// Grouper par contenu normalisé (trim + lowercase + unicode → ASCII)
|
||
const groupMap = new Map();
|
||
const fancyUnicodeComments = [];
|
||
|
||
for (const c of allComments) {
|
||
const key = normalizeText(c.content);
|
||
|
||
if (!groupMap.has(key)) {
|
||
groupMap.set(key, {
|
||
content: c.content,
|
||
normalizedContent: key,
|
||
count: 0,
|
||
firstPosted: c.created_at,
|
||
lastPosted: c.created_at,
|
||
comments: [],
|
||
});
|
||
}
|
||
const group = groupMap.get(key);
|
||
group.count++;
|
||
group.lastPosted = c.created_at;
|
||
group.comments.push({
|
||
id: c.id,
|
||
user_id: c.user_id,
|
||
user_type: c.user_type,
|
||
profile_id: c.profile_id,
|
||
username: c.username,
|
||
avatar: c.avatar,
|
||
content: c.content,
|
||
content_type: c.content_type,
|
||
content_id: c.content_id,
|
||
created_at: c.created_at,
|
||
ip_address: c.ip_address,
|
||
});
|
||
|
||
// Détecter les commentaires avec polices Unicode spéciales (spam)
|
||
if (containsFancyUnicode(c.content)) {
|
||
fancyUnicodeComments.push({
|
||
id: c.id,
|
||
user_id: c.user_id,
|
||
user_type: c.user_type,
|
||
profile_id: c.profile_id,
|
||
username: c.username,
|
||
avatar: c.avatar,
|
||
content: c.content,
|
||
content_type: c.content_type,
|
||
content_id: c.content_id,
|
||
created_at: c.created_at,
|
||
ip_address: c.ip_address,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Ne garder que les vrais doublons (>1 commentaire par groupe)
|
||
const duplicates = [...groupMap.values()]
|
||
.filter((g) => g.count > 1)
|
||
.sort((a, b) => b.count - a.count)
|
||
.slice(0, 50);
|
||
|
||
// Grouper les commentaires fancy unicode
|
||
const fancyGroup =
|
||
fancyUnicodeComments.length > 0
|
||
? {
|
||
content: "⚠️ Polices Unicode spéciales (spam)",
|
||
normalizedContent: "__fancy_unicode__",
|
||
count: fancyUnicodeComments.length,
|
||
firstPosted: fancyUnicodeComments[0]?.created_at,
|
||
lastPosted:
|
||
fancyUnicodeComments[fancyUnicodeComments.length - 1]?.created_at,
|
||
comments: fancyUnicodeComments,
|
||
isFancyUnicode: true,
|
||
}
|
||
: null;
|
||
|
||
res.json({ duplicates, fancyUnicode: fancyGroup });
|
||
} catch (error) {
|
||
console.error("Erreur lors de la détection des doublons:", error);
|
||
res.status(500).json({ error: "Erreur serveur" });
|
||
}
|
||
});
|
||
|
||
// POST /api/comments/admin/delete-duplicates - Supprimer les doublons et optionnellement bannir les auteurs
|
||
router.post("/admin/delete-duplicates", requireAuth, async (req, res) => {
|
||
try {
|
||
const userData = await getUserData(req.user.userId, req.user.userType);
|
||
if (!userData.isAdmin) {
|
||
return res.status(403).json({ error: "Non autorisé" });
|
||
}
|
||
|
||
const { commentIds, banAuthors, banDuration, banReason } = req.body;
|
||
|
||
if (!commentIds || !Array.isArray(commentIds) || commentIds.length === 0) {
|
||
return res.status(400).json({ error: "commentIds requis (tableau)" });
|
||
}
|
||
|
||
const BATCH_SIZE = 500;
|
||
let totalDeleted = 0;
|
||
|
||
for (let i = 0; i < commentIds.length; i += BATCH_SIZE) {
|
||
const batch = commentIds.slice(i, i + BATCH_SIZE);
|
||
const placeholders = batch.map(() => "?").join(",");
|
||
const result = await dbRun(
|
||
`UPDATE comments SET deleted = 1 WHERE id IN (${placeholders}) AND deleted = 0`,
|
||
batch,
|
||
);
|
||
totalDeleted += result.changes || 0;
|
||
}
|
||
|
||
let bannedCount = 0;
|
||
if (banAuthors) {
|
||
const allPlaceholders = commentIds.map(() => "?").join(",");
|
||
const authors = await dbAll(
|
||
`SELECT DISTINCT user_id, user_type, username FROM comments WHERE id IN (${allPlaceholders})`,
|
||
commentIds,
|
||
);
|
||
|
||
if (authors.length > 0) {
|
||
const adminCheckPlaceholders = authors.map(() => "(?, ?)").join(",");
|
||
const adminCheckParams = authors.flatMap((a) => [
|
||
a.user_id,
|
||
a.user_type === "bip39" ? "bip-39" : a.user_type,
|
||
]);
|
||
const adminRows = await dbAll(
|
||
`SELECT user_id, auth_type FROM admins WHERE (user_id, auth_type) IN (${adminCheckPlaceholders})`,
|
||
adminCheckParams,
|
||
);
|
||
const adminSet = new Set(
|
||
adminRows.map((a) => `${a.user_id}:${a.auth_type}`),
|
||
);
|
||
|
||
const banCheckPlaceholders = authors.map(() => "(?, ?)").join(",");
|
||
const banCheckParams = authors.flatMap((a) => [a.user_id, a.user_type]);
|
||
const existingBans = await dbAll(
|
||
`SELECT ban_value, user_type FROM banned_users WHERE ban_type = 'user' AND (ban_value, user_type) IN (${banCheckPlaceholders})`,
|
||
banCheckParams,
|
||
);
|
||
const bannedSet = new Set(
|
||
existingBans.map((b) => `${b.ban_value}:${b.user_type}`),
|
||
);
|
||
|
||
let expiresAt = null;
|
||
if (banDuration && banDuration !== "permanent") {
|
||
const durations = {
|
||
"1h": 3600000,
|
||
"24h": 86400000,
|
||
"7d": 604800000,
|
||
"30d": 2592000000,
|
||
};
|
||
if (durations[banDuration])
|
||
expiresAt = Date.now() + durations[banDuration];
|
||
}
|
||
|
||
const now = Date.now();
|
||
const reasonText = banReason || "Spam / Doublons";
|
||
const toBan = authors.filter((a) => {
|
||
const authType = a.user_type === "bip39" ? "bip-39" : a.user_type;
|
||
const isAdmin = adminSet.has(`${a.user_id}:${authType}`);
|
||
const alreadyBanned = bannedSet.has(`${a.user_id}:${a.user_type}`);
|
||
return !isAdmin && !alreadyBanned;
|
||
});
|
||
|
||
for (let i = 0; i < toBan.length; i += 100) {
|
||
const batch = toBan.slice(i, i + 100);
|
||
const insertPlaceholders = batch
|
||
.map(() => "(?, ?, ?, ?, ?, ?, ?, ?)")
|
||
.join(",");
|
||
const insertParams = batch.flatMap((a) => [
|
||
"user",
|
||
a.user_id,
|
||
a.user_type,
|
||
reasonText,
|
||
req.user.userId,
|
||
now,
|
||
expiresAt,
|
||
a.username || null,
|
||
]);
|
||
await dbRun(
|
||
`INSERT IGNORE INTO banned_users (ban_type, ban_value, user_type, reason, banned_by, banned_at, expires_at, username) VALUES ${insertPlaceholders}`,
|
||
insertParams,
|
||
);
|
||
}
|
||
bannedCount = toBan.length;
|
||
}
|
||
}
|
||
|
||
res.json({
|
||
message: `${totalDeleted} commentaire(s) supprimé(s), ${bannedCount} utilisateur(s) banni(s)`,
|
||
deletedCount: totalDeleted,
|
||
bannedCount,
|
||
});
|
||
} catch (error) {
|
||
console.error("Erreur lors de la suppression des doublons:", error);
|
||
res.status(500).json({ error: "Erreur serveur" });
|
||
}
|
||
});
|
||
|
||
// GET /api/comments/limits - Vérifier les limites de commentaires
|
||
router.get("/limits", requireAuth, async (req, res) => {
|
||
try {
|
||
const { contentType, contentId, profileId } = req.query;
|
||
|
||
if (!contentType || !contentId) {
|
||
return res.status(400).json({ error: "contentType et contentId requis" });
|
||
}
|
||
|
||
const userData = await getUserData(
|
||
req.user.userId,
|
||
req.user.userType,
|
||
profileId,
|
||
);
|
||
|
||
if (userData.isAdmin) {
|
||
return res.json({
|
||
movieCount: 0,
|
||
hourCount: 0,
|
||
movieLimit: null,
|
||
hourLimit: null,
|
||
isAdmin: true,
|
||
});
|
||
}
|
||
|
||
const movieComments = await dbGet(
|
||
"SELECT COUNT(*) as count FROM comments WHERE user_id = ? AND content_type = ? AND content_id = ? AND deleted = 0",
|
||
[req.user.userId, contentType, contentId],
|
||
);
|
||
|
||
const oneHourAgo = Date.now() - 3600000;
|
||
const hourComments = await dbGet(
|
||
"SELECT COUNT(*) as count FROM comments WHERE user_id = ? AND created_at > ?",
|
||
[req.user.userId, oneHourAgo],
|
||
);
|
||
|
||
res.json({
|
||
movieCount: movieComments.count,
|
||
hourCount: hourComments.count,
|
||
movieLimit: 3,
|
||
hourLimit: 10,
|
||
isAdmin: false,
|
||
});
|
||
} catch (error) {
|
||
console.error("Erreur lors de la récupération des limites:", error);
|
||
res.status(500).json({ error: "Erreur serveur" });
|
||
}
|
||
});
|
||
|
||
// ==================== ROUTES REPORTS (avant les routes dynamiques) ====================
|
||
|
||
const reportRateLimit = rateLimit({
|
||
windowMs: 15 * 60 * 1000,
|
||
max: 10,
|
||
store: createRedisRateLimitStore({
|
||
prefix: "rate-limit:comments:report:",
|
||
windowMs: 15 * 60 * 1000,
|
||
}),
|
||
passOnStoreError: true,
|
||
standardHeaders: true,
|
||
legacyHeaders: false,
|
||
message: { error: "Trop de signalements. Réessayez dans 15 minutes." },
|
||
keyGenerator: (req) =>
|
||
req.headers["cf-connecting-ip"] ||
|
||
req.headers["x-forwarded-for"]?.split(",")[0].trim() ||
|
||
ipKeyGenerator(req.ip),
|
||
validate: {
|
||
xForwardedForHeader: false,
|
||
ip: false,
|
||
keyGeneratorIpFallback: false,
|
||
},
|
||
});
|
||
|
||
const VALID_REPORT_REASONS = [
|
||
"spam",
|
||
"harassment",
|
||
"sexual_content",
|
||
"unmarked_spoiler",
|
||
"impersonation",
|
||
"other",
|
||
];
|
||
const VALID_TARGET_TYPES = ["comment", "reply", "shared_list"];
|
||
|
||
// POST /api/comments/report - Créer un signalement
|
||
router.post("/report", requireAuth, reportRateLimit, async (req, res) => {
|
||
try {
|
||
const { targetType, targetId, reason, details } = req.body;
|
||
const { userId, userType } = req.user;
|
||
const profileId = req.body.profileId;
|
||
|
||
if (!targetType || !targetId || !reason) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: "targetType, targetId et reason sont requis" });
|
||
}
|
||
if (!VALID_TARGET_TYPES.includes(targetType)) {
|
||
return res.status(400).json({ error: "targetType invalide" });
|
||
}
|
||
if (!VALID_REPORT_REASONS.includes(reason)) {
|
||
return res.status(400).json({ error: "reason invalide" });
|
||
}
|
||
if (!profileId) {
|
||
return res.status(400).json({ error: "profileId requis" });
|
||
}
|
||
if (details && details.length > 500) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: "Les détails ne peuvent pas dépasser 500 caractères" });
|
||
}
|
||
|
||
const allowedProfiles = await getProfileIds(userId, userType);
|
||
if (allowedProfiles.length > 0 && !allowedProfiles.includes(profileId)) {
|
||
return res
|
||
.status(403)
|
||
.json({ error: "Ce profil ne vous appartient pas" });
|
||
}
|
||
|
||
if (targetType === "comment") {
|
||
const comment = await dbGet(
|
||
"SELECT id, user_id, profile_id FROM comments WHERE id = ? AND deleted = 0",
|
||
[targetId],
|
||
);
|
||
if (!comment)
|
||
return res.status(404).json({ error: "Commentaire non trouvé" });
|
||
if (
|
||
String(comment.user_id) === String(userId) &&
|
||
String(comment.profile_id) === String(profileId)
|
||
) {
|
||
return res
|
||
.status(400)
|
||
.json({
|
||
error: "Vous ne pouvez pas signaler votre propre commentaire",
|
||
});
|
||
}
|
||
} else if (targetType === "reply") {
|
||
const reply = await dbGet(
|
||
"SELECT id, user_id, profile_id FROM comment_replies WHERE id = ? AND deleted = 0",
|
||
[targetId],
|
||
);
|
||
if (!reply) return res.status(404).json({ error: "Réponse non trouvée" });
|
||
if (
|
||
String(reply.user_id) === String(userId) &&
|
||
String(reply.profile_id) === String(profileId)
|
||
) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: "Vous ne pouvez pas signaler votre propre réponse" });
|
||
}
|
||
} else if (targetType === "shared_list") {
|
||
const pool = getCachedPool();
|
||
const [rows] = await pool.execute(
|
||
"SELECT id, user_id, share_code FROM shared_lists WHERE share_code = ? OR id = ?",
|
||
[targetId, targetId],
|
||
);
|
||
if (rows.length === 0)
|
||
return res.status(404).json({ error: "Liste partagée non trouvée" });
|
||
if (String(rows[0].user_id) === String(userId)) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: "Vous ne pouvez pas signaler votre propre liste" });
|
||
}
|
||
}
|
||
|
||
try {
|
||
await dbRun(
|
||
`INSERT INTO reports (reporter_user_id, reporter_user_type, reporter_profile_id, target_type, target_id, reason, details, created_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
[
|
||
userId,
|
||
userType,
|
||
profileId,
|
||
targetType,
|
||
targetId,
|
||
reason,
|
||
details || null,
|
||
Date.now(),
|
||
],
|
||
);
|
||
} catch (err) {
|
||
if (err.code === "ER_DUP_ENTRY") {
|
||
return res
|
||
.status(409)
|
||
.json({ error: "Vous avez déjà signalé ce contenu" });
|
||
}
|
||
throw err;
|
||
}
|
||
|
||
res.status(201).json({ success: true, message: "Signalement envoyé" });
|
||
} catch (error) {
|
||
console.error("Erreur lors du signalement:", error);
|
||
res.status(500).json({ error: "Erreur serveur" });
|
||
}
|
||
});
|
||
|
||
// GET /api/comments/admin/reports - Lister les signalements (admin)
|
||
router.get("/admin/reports", requireAuth, async (req, res) => {
|
||
try {
|
||
const userData = await getUserData(req.user.userId, req.user.userType);
|
||
if (!userData.isAdmin)
|
||
return res.status(403).json({ error: "Non autorisé" });
|
||
|
||
const status = req.query.status || "pending";
|
||
const targetType = req.query.targetType || "all";
|
||
const pageNum = Math.max(1, Number(req.query.page) || 1);
|
||
const limitNum = Math.min(100, Math.max(1, Number(req.query.limit) || 30));
|
||
const offset = (pageNum - 1) * limitNum;
|
||
|
||
let query = `SELECT r.*,
|
||
(SELECT COUNT(*) FROM reports r2 WHERE r2.target_type = r.target_type AND r2.target_id = r.target_id AND r2.status = 'pending') as report_count
|
||
FROM reports r WHERE 1=1`;
|
||
const params = [];
|
||
|
||
if (status !== "all") {
|
||
query += " AND r.status = ?";
|
||
params.push(status);
|
||
}
|
||
if (targetType !== "all") {
|
||
query += " AND r.target_type = ?";
|
||
params.push(targetType);
|
||
}
|
||
|
||
query += " ORDER BY r.created_at DESC LIMIT ? OFFSET ?";
|
||
params.push(limitNum, offset);
|
||
|
||
const reports = await dbAll(query, params);
|
||
|
||
const enriched = await Promise.all(
|
||
reports.map(async (report) => {
|
||
let reporterData = { username: "Inconnu", avatar: null };
|
||
try {
|
||
reporterData = await getUserData(
|
||
report.reporter_user_id,
|
||
report.reporter_user_type,
|
||
report.reporter_profile_id,
|
||
);
|
||
} catch {
|
||
/* fallback */
|
||
}
|
||
|
||
let targetData = {};
|
||
try {
|
||
if (report.target_type === "comment") {
|
||
const comment = await dbGet(
|
||
"SELECT id, content, content_id, content_type, user_id, user_type, profile_id, username, created_at FROM comments WHERE id = ?",
|
||
[report.target_id],
|
||
);
|
||
if (comment) {
|
||
const authorData = await getUserData(
|
||
comment.user_id,
|
||
comment.user_type,
|
||
comment.profile_id,
|
||
);
|
||
targetData = {
|
||
...comment,
|
||
content: formatContentForResponse(comment.content),
|
||
authorUsername: authorData.username,
|
||
authorAvatar: authorData.avatar,
|
||
};
|
||
} else {
|
||
targetData = { deleted: true };
|
||
}
|
||
} else if (report.target_type === "reply") {
|
||
const reply = await dbGet(
|
||
"SELECT id, content, comment_id, user_id, user_type, profile_id, username, created_at FROM comment_replies WHERE id = ?",
|
||
[report.target_id],
|
||
);
|
||
if (reply) {
|
||
const authorData = await getUserData(
|
||
reply.user_id,
|
||
reply.user_type,
|
||
reply.profile_id,
|
||
);
|
||
const parentComment = await dbGet(
|
||
"SELECT content_id, content_type FROM comments WHERE id = ?",
|
||
[reply.comment_id],
|
||
);
|
||
targetData = {
|
||
...reply,
|
||
content: formatContentForResponse(reply.content),
|
||
authorUsername: authorData.username,
|
||
authorAvatar: authorData.avatar,
|
||
content_id: parentComment?.content_id,
|
||
content_type: parentComment?.content_type,
|
||
};
|
||
} else {
|
||
targetData = { deleted: true };
|
||
}
|
||
} else if (report.target_type === "shared_list") {
|
||
const pool = getCachedPool();
|
||
const [rows] = await pool.execute(
|
||
"SELECT id, name, user_id, share_code FROM shared_lists WHERE share_code = ? OR id = ?",
|
||
[report.target_id, report.target_id],
|
||
);
|
||
if (rows.length > 0) {
|
||
targetData = rows[0];
|
||
} else {
|
||
targetData = { deleted: true };
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error("Erreur enrichissement report:", err.message);
|
||
targetData = { error: true };
|
||
}
|
||
|
||
return {
|
||
...report,
|
||
reporter: {
|
||
username: reporterData.username,
|
||
avatar: reporterData.avatar,
|
||
},
|
||
target: targetData,
|
||
};
|
||
}),
|
||
);
|
||
|
||
const statsResult = await dbGet(`
|
||
SELECT
|
||
IFNULL(COUNT(*), 0) as total,
|
||
IFNULL(SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END), 0) as pending,
|
||
IFNULL(SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END), 0) as resolved,
|
||
IFNULL(SUM(CASE WHEN status = 'dismissed' THEN 1 ELSE 0 END), 0) as dismissed,
|
||
IFNULL(SUM(CASE WHEN target_type = 'comment' OR target_type = 'reply' THEN 1 ELSE 0 END), 0) as comments,
|
||
IFNULL(SUM(CASE WHEN target_type = 'shared_list' THEN 1 ELSE 0 END), 0) as lists
|
||
FROM reports
|
||
`);
|
||
|
||
let countQuery = "SELECT COUNT(*) as total FROM reports WHERE 1=1";
|
||
const countParams = [];
|
||
if (status !== "all") {
|
||
countQuery += " AND status = ?";
|
||
countParams.push(status);
|
||
}
|
||
if (targetType !== "all") {
|
||
countQuery += " AND target_type = ?";
|
||
countParams.push(targetType);
|
||
}
|
||
const totalResult = await dbGet(countQuery, countParams);
|
||
const total = Number(totalResult?.total) || 0;
|
||
|
||
res.json({
|
||
success: true,
|
||
reports: enriched,
|
||
stats: {
|
||
total: Number(statsResult?.total) || 0,
|
||
pending: Number(statsResult?.pending) || 0,
|
||
resolved: Number(statsResult?.resolved) || 0,
|
||
dismissed: Number(statsResult?.dismissed) || 0,
|
||
comments: Number(statsResult?.comments) || 0,
|
||
lists: Number(statsResult?.lists) || 0,
|
||
},
|
||
total,
|
||
hasMore: offset + limitNum < total,
|
||
});
|
||
} catch (error) {
|
||
console.error("Erreur lors de la récupération des reports:", error);
|
||
res.status(500).json({ error: "Erreur serveur" });
|
||
}
|
||
});
|
||
|
||
// PUT /api/comments/admin/reports/:id/resolve
|
||
router.put("/admin/reports/:id/resolve", requireAuth, async (req, res) => {
|
||
try {
|
||
const userData = await getUserData(req.user.userId, req.user.userType);
|
||
if (!userData.isAdmin)
|
||
return res.status(403).json({ error: "Non autorisé" });
|
||
|
||
const { id } = req.params;
|
||
const { deleteContent } = req.body;
|
||
|
||
const report = await dbGet("SELECT * FROM reports WHERE id = ? AND status = 'pending'", [id]);
|
||
if (!report)
|
||
return res.status(404).json({ error: "Signalement non trouvé" });
|
||
|
||
const pendingReports = await dbAll(
|
||
"SELECT reporter_user_id, reporter_user_type, reporter_profile_id, target_type, target_id FROM reports WHERE target_type = ? AND target_id = ? AND status = 'pending'",
|
||
[report.target_type, report.target_id],
|
||
);
|
||
const notificationTarget = await getReportNotificationTarget(report);
|
||
|
||
if (deleteContent) {
|
||
if (report.target_type === "comment") {
|
||
await deleteCommentCascade(report.target_id);
|
||
} else if (report.target_type === "reply") {
|
||
const reply = await dbGet(
|
||
"SELECT comment_id FROM comment_replies WHERE id = ?",
|
||
[report.target_id],
|
||
);
|
||
if (reply)
|
||
await deleteRepliesCascade(reply.comment_id, report.target_id);
|
||
} else if (report.target_type === "shared_list") {
|
||
const pool = getCachedPool();
|
||
await pool.execute(
|
||
"DELETE FROM shared_lists WHERE share_code = ? OR id = ?",
|
||
[report.target_id, report.target_id],
|
||
);
|
||
}
|
||
}
|
||
|
||
const updateResult = await dbRun(
|
||
`UPDATE reports SET status = 'resolved', resolved_by = ?, resolved_at = ? WHERE target_type = ? AND target_id = ? AND status = 'pending'`,
|
||
[req.user.userId, Date.now(), report.target_type, report.target_id],
|
||
);
|
||
|
||
if (updateResult.changes > 0 && pendingReports.length > 0) {
|
||
const notificationType = deleteContent
|
||
? REPORT_NOTIFICATION_TYPES.RESOLVED_DELETED
|
||
: REPORT_NOTIFICATION_TYPES.RESOLVED;
|
||
|
||
await Promise.all(pendingReports.map(async (pendingReport) => {
|
||
await createNotification(
|
||
pendingReport.reporter_user_id,
|
||
pendingReport.reporter_user_type,
|
||
pendingReport.reporter_profile_id,
|
||
req.user.userId,
|
||
null,
|
||
userData.username,
|
||
userData.avatar,
|
||
notificationType,
|
||
pendingReport.target_type,
|
||
notificationTarget.targetId,
|
||
notificationTarget.contentType,
|
||
notificationTarget.contentId,
|
||
null,
|
||
);
|
||
}));
|
||
}
|
||
|
||
res.json({ success: true, message: "Signalement résolu" });
|
||
} catch (error) {
|
||
console.error("Erreur résolution report:", error);
|
||
res.status(500).json({ error: "Erreur serveur" });
|
||
}
|
||
});
|
||
|
||
// PUT /api/comments/admin/reports/:id/dismiss
|
||
router.put("/admin/reports/:id/dismiss", requireAuth, async (req, res) => {
|
||
try {
|
||
const userData = await getUserData(req.user.userId, req.user.userType);
|
||
if (!userData.isAdmin)
|
||
return res.status(403).json({ error: "Non autorisé" });
|
||
|
||
const { id } = req.params;
|
||
const report = await dbGet("SELECT * FROM reports WHERE id = ? AND status = 'pending'", [id]);
|
||
if (!report)
|
||
return res.status(404).json({ error: "Signalement non trouvé" });
|
||
|
||
const pendingReports = await dbAll(
|
||
"SELECT reporter_user_id, reporter_user_type, reporter_profile_id, target_type, target_id FROM reports WHERE target_type = ? AND target_id = ? AND status = 'pending'",
|
||
[report.target_type, report.target_id],
|
||
);
|
||
const notificationTarget = await getReportNotificationTarget(report);
|
||
|
||
const updateResult = await dbRun(
|
||
`UPDATE reports SET status = 'dismissed', resolved_by = ?, resolved_at = ? WHERE target_type = ? AND target_id = ? AND status = 'pending'`,
|
||
[req.user.userId, Date.now(), report.target_type, report.target_id],
|
||
);
|
||
|
||
if (updateResult.changes > 0 && pendingReports.length > 0) {
|
||
await Promise.all(pendingReports.map(async (pendingReport) => {
|
||
await createNotification(
|
||
pendingReport.reporter_user_id,
|
||
pendingReport.reporter_user_type,
|
||
pendingReport.reporter_profile_id,
|
||
req.user.userId,
|
||
null,
|
||
userData.username,
|
||
userData.avatar,
|
||
REPORT_NOTIFICATION_TYPES.DISMISSED,
|
||
pendingReport.target_type,
|
||
notificationTarget.targetId,
|
||
notificationTarget.contentType,
|
||
notificationTarget.contentId,
|
||
null,
|
||
);
|
||
}));
|
||
}
|
||
|
||
res.json({ success: true, message: "Signalement rejeté" });
|
||
} catch (error) {
|
||
console.error("Erreur dismiss report:", error);
|
||
res.status(500).json({ error: "Erreur serveur" });
|
||
}
|
||
});
|
||
|
||
// ==================== ROUTES DYNAMIQUES (doivent être en dernier) ====================
|
||
|
||
// GET /api/comments/:contentType/:contentId - Récupérer les commentaires avec pagination
|
||
// === #13: Optimisé — une seule requête SQL avec sous-requêtes au lieu de N+1 ===
|
||
router.get("/:contentType/:contentId", async (req, res) => {
|
||
try {
|
||
const { contentType, contentId } = req.params;
|
||
const safePage = Math.max(1, Math.min(parseInt(req.query.page) || 1, 1000));
|
||
const safeLimit = Math.max(1, Math.min(parseInt(req.query.limit) || 20, 50));
|
||
const offset = (safePage - 1) * safeLimit;
|
||
|
||
// Tenter de récupérer l'utilisateur connecté (optionnel)
|
||
let currentUser = null;
|
||
const token = req.headers.authorization?.split(" ")[1];
|
||
if (token) {
|
||
try {
|
||
const decoded = jwt.verify(token, JWT_SECRET, {
|
||
algorithms: ["HS256"],
|
||
});
|
||
currentUser = {
|
||
userId: decoded.sub,
|
||
userType: decoded.userType,
|
||
sessionId: decoded.sessionId,
|
||
};
|
||
} catch (error) {
|
||
// Token invalide, on continue sans utilisateur
|
||
}
|
||
}
|
||
|
||
const { profileId } = req.query;
|
||
|
||
// Récupérer le total de commentaires
|
||
const totalResult = await dbGet(
|
||
"SELECT COUNT(*) as total FROM comments WHERE content_type = ? AND content_id = ? AND deleted = 0",
|
||
[contentType, contentId],
|
||
);
|
||
|
||
// #13: Requête unique — reaction_count + replies_count + user_reacted en sous-requêtes
|
||
let commentsQuery;
|
||
let commentsParams;
|
||
|
||
if (currentUser && profileId) {
|
||
commentsQuery = `
|
||
SELECT c.*,
|
||
(SELECT COUNT(*) FROM comment_reactions WHERE target_type = 'comment' AND target_id = c.id) as reaction_count,
|
||
(SELECT COUNT(*) FROM comment_replies WHERE comment_id = c.id AND deleted = 0) as replies_count,
|
||
EXISTS(SELECT 1 FROM comment_reactions WHERE target_type = 'comment' AND target_id = c.id
|
||
AND user_id = ? AND user_type = ? AND profile_id = ?) as user_reacted
|
||
FROM comments c
|
||
WHERE c.content_type = ? AND c.content_id = ? AND c.deleted = 0
|
||
ORDER BY (SELECT COUNT(*) FROM comment_reactions WHERE target_type = 'comment' AND target_id = c.id) DESC, c.created_at DESC
|
||
LIMIT ? OFFSET ?`;
|
||
commentsParams = [
|
||
currentUser.userId,
|
||
currentUser.userType,
|
||
profileId,
|
||
contentType,
|
||
contentId,
|
||
safeLimit,
|
||
offset,
|
||
];
|
||
} else {
|
||
commentsQuery = `
|
||
SELECT c.*,
|
||
(SELECT COUNT(*) FROM comment_reactions WHERE target_type = 'comment' AND target_id = c.id) as reaction_count,
|
||
(SELECT COUNT(*) FROM comment_replies WHERE comment_id = c.id AND deleted = 0) as replies_count
|
||
FROM comments c
|
||
WHERE c.content_type = ? AND c.content_id = ? AND c.deleted = 0
|
||
ORDER BY (SELECT COUNT(*) FROM comment_reactions WHERE target_type = 'comment' AND target_id = c.id) DESC, c.created_at DESC
|
||
LIMIT ? OFFSET ?`;
|
||
commentsParams = [
|
||
contentType,
|
||
contentId,
|
||
safeLimit,
|
||
offset,
|
||
];
|
||
}
|
||
|
||
const comments = await dbAll(commentsQuery, commentsParams);
|
||
|
||
// Cache utilisateur en mémoire pour cette requête (getUserData est déjà caché dans Redis via #14)
|
||
const userDataCache = new Map();
|
||
|
||
const commentsWithDetails = await Promise.all(
|
||
comments.map(async (comment) => {
|
||
const userKey = `${comment.user_id}:${comment.user_type}:${comment.profile_id || "default"}`;
|
||
let userData = userDataCache.get(userKey);
|
||
if (!userData) {
|
||
userData = await getUserData(
|
||
comment.user_id,
|
||
comment.user_type,
|
||
comment.profile_id,
|
||
);
|
||
userDataCache.set(userKey, userData);
|
||
}
|
||
|
||
return {
|
||
...comment,
|
||
content: formatContentForResponse(comment.content),
|
||
username: userData.username,
|
||
avatar: userData.avatar,
|
||
is_vip: userData.isVip ? 1 : 0,
|
||
is_admin: userData.isAdmin ? 1 : 0,
|
||
repliesCount: comment.replies_count,
|
||
reactions: comment.reaction_count,
|
||
userReaction:
|
||
comment.user_reacted !== undefined ? !!comment.user_reacted : null,
|
||
};
|
||
}),
|
||
);
|
||
|
||
res.json({
|
||
comments: commentsWithDetails,
|
||
total: totalResult.total,
|
||
page: safePage,
|
||
limit: safeLimit,
|
||
hasMore: offset + commentsWithDetails.length < totalResult.total,
|
||
});
|
||
} catch (error) {
|
||
console.error("Erreur lors de la récupération des commentaires:", error);
|
||
res.status(500).json({ error: "Erreur serveur" });
|
||
}
|
||
});
|
||
|
||
// POST /api/comments - Créer un commentaire
|
||
router.post("/", requireAuth, writeRateLimit, async (req, res) => {
|
||
// Lock Redis par user pour sérialiser les créations concurrentes
|
||
// (évite la race count >=3 / count >=10 entre SELECT et INSERT)
|
||
const userLockKey = `comments:create:lock:${req.user.userType}:${req.user.userId}`;
|
||
let lockAcquired = null;
|
||
let lockHeld = false;
|
||
try {
|
||
try {
|
||
lockAcquired = await redis.set(userLockKey, "1", "EX", 10, "NX");
|
||
} catch {
|
||
// Redis indisponible — on accepte le risque de race (best effort)
|
||
lockAcquired = "OK";
|
||
}
|
||
if (!lockAcquired) {
|
||
return res
|
||
.status(429)
|
||
.json({ error: "Une création est déjà en cours, réessayez." });
|
||
}
|
||
lockHeld = true;
|
||
|
||
let {
|
||
contentType,
|
||
contentId,
|
||
content,
|
||
isSpoiler,
|
||
profileId,
|
||
turnstileToken,
|
||
} = req.body;
|
||
|
||
// Vérification Turnstile
|
||
const turnstileResult = await verifyTurnstileFromRequest(
|
||
req,
|
||
turnstileToken,
|
||
);
|
||
if (!turnstileResult.valid)
|
||
return res
|
||
.status(turnstileResult.status)
|
||
.json({ error: turnstileResult.error });
|
||
|
||
// Normalize content while preserving the original characters
|
||
content = normalizeCommentContent(content);
|
||
|
||
// Verify profile ownership
|
||
const userProfileIds = await getProfileIds(
|
||
req.user.userId,
|
||
req.user.userType,
|
||
);
|
||
if (!userProfileIds.includes(profileId)) {
|
||
return res.status(403).json({ error: "Profil non autorisé" });
|
||
}
|
||
|
||
// Récupérer les données utilisateur
|
||
const userData = await getUserData(
|
||
req.user.userId,
|
||
req.user.userType,
|
||
profileId,
|
||
);
|
||
|
||
// Vérification de bannissement
|
||
if (!userData.isAdmin) {
|
||
const banStatus = await checkBan(
|
||
req.user.userId,
|
||
req.user.userType,
|
||
getClientIp(req),
|
||
);
|
||
if (banStatus.banned) {
|
||
return res.status(403).json({
|
||
error: "Vous êtes banni des commentaires.",
|
||
reason: banStatus.reason,
|
||
expires_at: banStatus.expires_at,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Rate Limit 1: Max 3 comments per movie/content (sauf pour les admins)
|
||
if (!userData.isAdmin) {
|
||
const movieComments = await dbGet(
|
||
"SELECT COUNT(*) as count FROM comments WHERE user_id = ? AND content_type = ? AND content_id = ? AND deleted = 0",
|
||
[req.user.userId, contentType, contentId],
|
||
);
|
||
|
||
if (movieComments.count >= 3) {
|
||
return res
|
||
.status(429)
|
||
.json({ error: "Limite de 3 commentaires par film atteinte." });
|
||
}
|
||
|
||
// Rate Limit 2: Max 10 comments per hour (sauf pour les admins)
|
||
const oneHourAgo = Date.now() - 3600000;
|
||
const hourComments = await dbGet(
|
||
"SELECT COUNT(*) as count FROM comments WHERE user_id = ? AND created_at > ?",
|
||
[req.user.userId, oneHourAgo],
|
||
);
|
||
|
||
if (hourComments.count >= 10) {
|
||
return res
|
||
.status(429)
|
||
.json({ error: "Limite de 10 commentaires par heure atteinte." });
|
||
}
|
||
}
|
||
|
||
// Validation
|
||
if (!content || content.length > 500) {
|
||
return res
|
||
.status(400)
|
||
.json({
|
||
error: "Le commentaire doit contenir entre 1 et 500 caractères",
|
||
});
|
||
}
|
||
|
||
// Insérer le commentaire
|
||
const result = await dbRun(
|
||
`INSERT INTO comments (content_type, content_id, user_id, user_type, profile_id, username, avatar, content, is_spoiler, is_vip, is_admin, created_at, ip_address)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
[
|
||
contentType,
|
||
contentId,
|
||
req.user.userId,
|
||
req.user.userType,
|
||
profileId,
|
||
userData.username,
|
||
userData.avatar,
|
||
content,
|
||
isSpoiler ? 1 : 0,
|
||
userData.isVip ? 1 : 0,
|
||
userData.isAdmin ? 1 : 0,
|
||
Date.now(),
|
||
getClientIp(req),
|
||
],
|
||
);
|
||
|
||
// Récupérer le commentaire créé
|
||
const newComment = await dbGet("SELECT * FROM comments WHERE id = ?", [
|
||
result.lastID,
|
||
]);
|
||
|
||
// Envoyer le webhook Discord en arrière-plan (ne pas attendre)
|
||
sendDiscordWebhook("comment", {
|
||
username: userData.username,
|
||
avatar: userData.avatar,
|
||
content,
|
||
contentType,
|
||
contentId,
|
||
isSpoiler,
|
||
isVip: userData.isVip,
|
||
isAdmin: userData.isAdmin,
|
||
userId: req.user.userId,
|
||
userType: req.user.userType,
|
||
profileId: profileId,
|
||
ipAddress: getClientIp(req),
|
||
}).catch((err) =>
|
||
console.error("Erreur webhook Discord (non bloquant):", err),
|
||
);
|
||
|
||
// Modération automatique avec l'IA en arrière-plan (ne pas attendre)
|
||
moderateContentWithAI(
|
||
result.lastID,
|
||
"comment",
|
||
content,
|
||
userData.username,
|
||
).catch((err) =>
|
||
console.error(
|
||
"Erreur modération IA commentaire (non bloquant):",
|
||
err,
|
||
),
|
||
);
|
||
|
||
res.status(201).json({
|
||
...newComment,
|
||
content: formatContentForResponse(newComment.content),
|
||
repliesCount: 0,
|
||
reactions: 0,
|
||
userReaction: null,
|
||
});
|
||
} catch (error) {
|
||
console.error("Erreur lors de la création du commentaire:", error);
|
||
res.status(500).json({ error: "Erreur serveur" });
|
||
} finally {
|
||
if (lockHeld) {
|
||
try {
|
||
await redis.del(userLockKey);
|
||
} catch {
|
||
/* Redis indisponible — le lock expirera via TTL */
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// PUT /api/comments/:id - Éditer un commentaire
|
||
router.put("/:id", requireAuth, writeRateLimit, async (req, res) => {
|
||
try {
|
||
const { id } = req.params;
|
||
let { content, isSpoiler, profileId } = req.body;
|
||
|
||
// Normalize content while preserving the original characters
|
||
content = normalizeCommentContent(content);
|
||
|
||
// Validation
|
||
if (!content || content.length > 500) {
|
||
return res
|
||
.status(400)
|
||
.json({
|
||
error: "Le commentaire doit contenir entre 1 et 500 caractères",
|
||
});
|
||
}
|
||
|
||
// Vérifier que le commentaire appartient à l'utilisateur
|
||
const comment = await dbGet("SELECT * FROM comments WHERE id = ?", [id]);
|
||
if (!comment) {
|
||
return res.status(404).json({ error: "Commentaire non trouvé" });
|
||
}
|
||
|
||
if (
|
||
comment.user_id !== req.user.userId ||
|
||
comment.user_type !== req.user.userType
|
||
) {
|
||
return res.status(403).json({ error: "Non autorisé" });
|
||
}
|
||
|
||
// Si le commentaire a un profile_id, exiger que l'éditeur passe le même profileId
|
||
// (empêche un autre profil du même compte d'éditer)
|
||
if (comment.profile_id) {
|
||
if (!profileId || String(comment.profile_id) !== String(profileId)) {
|
||
return res
|
||
.status(403)
|
||
.json({ error: "Seul le profil auteur peut éditer ce commentaire" });
|
||
}
|
||
}
|
||
|
||
// Mettre à jour le commentaire
|
||
await dbRun(
|
||
"UPDATE comments SET content = ?, is_spoiler = ?, is_edited = 1, updated_at = ? WHERE id = ?",
|
||
[content, isSpoiler ? 1 : 0, Date.now(), id],
|
||
);
|
||
|
||
// Récupérer le commentaire mis à jour
|
||
const updatedComment = await dbGet("SELECT * FROM comments WHERE id = ?", [
|
||
id,
|
||
]);
|
||
|
||
res.json({
|
||
...updatedComment,
|
||
content: formatContentForResponse(updatedComment.content),
|
||
});
|
||
} catch (error) {
|
||
console.error("Erreur lors de l'édition du commentaire:", error);
|
||
res.status(500).json({ error: "Erreur serveur" });
|
||
}
|
||
});
|
||
|
||
// DELETE /api/comments/:id - Supprimer un commentaire (admin ou auteur)
|
||
router.delete("/:id", requireAuth, writeRateLimit, async (req, res) => {
|
||
try {
|
||
const { id } = req.params;
|
||
const { profileId } = req.query;
|
||
|
||
// Vérifier que le commentaire existe
|
||
const comment = await dbGet(
|
||
"SELECT id, user_id, user_type, profile_id FROM comments WHERE id = ?",
|
||
[id],
|
||
);
|
||
if (!comment) {
|
||
return res.status(404).json({ error: "Commentaire non trouvé" });
|
||
}
|
||
|
||
// Vérifier que l'utilisateur est admin ou auteur du commentaire
|
||
const userData = await getUserData(req.user.userId, req.user.userType);
|
||
const userMatch =
|
||
String(comment.user_id) === String(req.user.userId) &&
|
||
String(comment.user_type) === String(req.user.userType);
|
||
// Si le commentaire a un profile_id, le profileId fourni doit matcher exactement
|
||
// (empêche un kid profile de supprimer le commentaire d'un adult profile du même compte)
|
||
let profileMatch = true;
|
||
if (comment.profile_id) {
|
||
profileMatch =
|
||
!!profileId && String(comment.profile_id) === String(profileId);
|
||
}
|
||
const isOwner = userMatch && profileMatch;
|
||
|
||
if (!userData.isAdmin && !isOwner) {
|
||
return res.status(403).json({ error: "Non autorisé" });
|
||
}
|
||
|
||
// Supprimer le commentaire et toutes ses dépendances en cascade
|
||
await deleteCommentCascade(id);
|
||
|
||
res.json({
|
||
message:
|
||
"Commentaire, réponses, réactions et notifications supprimés en cascade",
|
||
});
|
||
} catch (error) {
|
||
console.error("Erreur lors de la suppression du commentaire:", error);
|
||
res.status(500).json({ error: "Erreur serveur" });
|
||
}
|
||
});
|
||
|
||
module.exports = router;
|