diff --git a/API/Mainapi/app.js b/API/Mainapi/app.js index abc8789..1025d6b 100644 --- a/API/Mainapi/app.js +++ b/API/Mainapi/app.js @@ -31,6 +31,8 @@ const { shouldUpdateCacheFrenchStream, shouldUpdateCacheLecteurVideo, shouldUpdateCache24h, + shouldUpdateCache48h, + generateCacheKey, CACHE_DIR, } = require("./utils/cacheManager"); @@ -64,6 +66,17 @@ const DARKIWORLD_BASE_URL = normalizeBaseUrl( const cookieJar = new tough.CookieJar(); // === Darkino session & headers setup === +// UA + client hints stables (Chrome/Brave 148 Windows). darkiworld a une +// limite de session par client, donc TOUTES les requêtes (refresh `/`, +// /api/v1/titles/.../content/liens, /api/v1/download-premium/..., +// seasons/episodes) doivent partager exactement ce fingerprint pour rester +// sur une seule session côté upstream. +// +// Cookies + x-xsrf-token : viennent de l'env (DARKIWORLD_COOKIES / +// DARKIWORLD_XSRF_TOKEN). cf_clearance volontairement absent de l'env : +// Cloudflare le renouvelle régulièrement, le set-cookie de réponse arrive +// dans le tough-cookie jar et `mergeCookieHeaders(jarState, configured)` +// ajoute les cookies du jar absents de la string env sans écraser ceux fixés. const darkiHeaders = { accept: "application/json", "accept-encoding": "gzip, deflate, br", @@ -72,18 +85,31 @@ const darkiHeaders = { cookie: process.env.DARKIWORLD_COOKIES || "", pragma: "no-cache", priority: "u=1, i", + "sec-ch-ua": + '"Chromium";v="148", "Brave";v="148", "Not/A)Brand";v="99"', + "sec-ch-ua-arch": '"x86"', + "sec-ch-ua-bitness": '"64"', + "sec-ch-ua-full-version-list": + '"Chromium";v="148.0.0.0", "Brave";v="148.0.0.0", "Not/A)Brand";v="99.0.0.0"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-model": '""', + "sec-ch-ua-platform": '"Windows"', + "sec-ch-ua-platform-version": '"19.0.0"', "sec-fetch-dest": "empty", "sec-fetch-mode": "cors", "sec-fetch-site": "same-origin", + "sec-gpc": "1", + "user-agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36", "x-xsrf-token": process.env.DARKIWORLD_XSRF_TOKEN || "", }; // Coflix config -const COFLIX_BASE_URL = "https://coflix.click"; +const COFLIX_BASE_URL = "https://coflix.date"; const coflixHeaders = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", - Referer: "https://coflix.click", + Referer: "https://coflix.date", }; // === Axios instances for each source === @@ -132,16 +158,32 @@ const axiosFStream = axios.create({ decompress: true, }); -// Darkino session refresh -let lastDarkinoHomeRequest = 0; -const DARKINO_SESSION_REFRESH_INTERVAL = 5 * 60 * 1000; // 5 minutes +// Darkino session refresh — coordonné via Redis pour qu'un SEUL worker du +// cluster pinge l'upstream toutes les 10 minutes (et pas N workers en parallèle, +// ce qui ferait exploser la limite de session côté darkiworld). +const DARKINO_SESSION_REFRESH_INTERVAL = 10 * 60 * 1000; // 10 minutes +const DARKINO_REFRESH_LAST_KEY = "darkino:lastRefreshAt"; +const DARKINO_REFRESH_LOCK_KEY = "darkino:refreshLock"; +const DARKINO_REFRESH_LOCK_TTL_MS = 30 * 1000; // filet si le worker crash pendant le GET (axios timeout = 5s) const refreshDarkinoSessionIfNeeded = async () => { - const now = Date.now(); - if (now - lastDarkinoHomeRequest > DARKINO_SESSION_REFRESH_INTERVAL) { + try { + const last = Number(await redis.get(DARKINO_REFRESH_LAST_KEY)) || 0; + if (Date.now() - last <= DARKINO_SESSION_REFRESH_INTERVAL) return; + + // SET NX PX : seul le worker qui acquiert exécute le refresh, les autres no-op. + const acquired = await redis.set( + DARKINO_REFRESH_LOCK_KEY, + String(process.pid), + "PX", + DARKINO_REFRESH_LOCK_TTL_MS, + "NX", + ); + if (!acquired) return; + try { await axiosHelpers.axiosDarkinoRequest({ method: "get", url: "/" }); - lastDarkinoHomeRequest = now; + await redis.set(DARKINO_REFRESH_LAST_KEY, String(Date.now())); console.log("[DARKINO] Session refreshed"); } catch (error) { if ( @@ -150,7 +192,12 @@ const refreshDarkinoSessionIfNeeded = async () => { ) { console.error("[DARKINO] Failed to refresh session:", error.message); } + } finally { + await redis.del(DARKINO_REFRESH_LOCK_KEY).catch(() => {}); } + } catch (_) { + // Redis down → skip silently. Pas de fallback in-memory : ce serait + // réintroduire le bug N-workers-refreshent-en-parallèle. } }; @@ -359,7 +406,9 @@ darkiworldRouter.configure({ saveToCache, shouldUpdateCache, shouldUpdateCache24h, + shouldUpdateCache48h, refreshDarkinoSessionIfNeeded, + redis, }); // --- Configure voirdrama --- @@ -589,6 +638,35 @@ function getAppPool() { return getPool(); } +// === Hydracker queue drain timer === +// Every worker fires the tick. drainQueueOnce uses Redis worker_lock so only +// one worker actually drains; the others get { drained: false, reason: 'lock_taken' } +// and skip. This means the drain runs in a worker process which has full +// darkiworld auth context (cookies, XSRF, darkiHeaders, axiosDarkinoRequest +// configured via axiosHelpers.configure earlier in this file). +const hydrackerQueue = require('./utils/hydrackerQueue'); +const DRAIN_INTERVAL_MS = 5000; +setInterval(async () => { + try { + const result = await hydrackerQueue.drainQueueOnce({ + redis, + cacheDir: DOWNLOAD_CACHE_DIR, + generateCacheKey, + getFromCacheNoExpiration, + saveToCache, + axiosDarkinoRequest: axiosHelpers.axiosDarkinoRequest, + refreshDarkinoSessionIfNeeded + }); + if (result.drained) { + console.log(`[hydracker] drained batch of ${result.batchSize}`); + } else if (result.error) { + console.warn(`[hydracker] drain error: ${result.error} (requeued ${result.requeued || 0})`); + } + } catch (e) { + console.warn(`[hydracker] drain tick threw:`, e?.message || e); + } +}, DRAIN_INTERVAL_MS); + // === Unified error handler === app.use((err, req, res, next) => { if (err.message !== "Not allowed by CORS") { diff --git a/API/Mainapi/commentsRoutes.js b/API/Mainapi/commentsRoutes.js index 6f3b59c..9c2720a 100644 --- a/API/Mainapi/commentsRoutes.js +++ b/API/Mainapi/commentsRoutes.js @@ -299,6 +299,77 @@ const requireAuth = async (req, res, next) => { } }; +// 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 { @@ -1086,6 +1157,15 @@ router.get("/notifications", requireAuth, async (req, res) => { 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]; @@ -1107,7 +1187,7 @@ router.get("/notifications", requireAuth, async (req, res) => { }); // PUT /api/comments/notifications/:id/read - Marquer une notification comme lue -router.put("/notifications/:id/read", requireAuth, async (req, res) => { +router.put("/notifications/:id/read", requireAuth, writeRateLimit, async (req, res) => { try { const { id } = req.params; const { profileId } = req.body; @@ -1117,6 +1197,15 @@ router.put("/notifications/:id/read", requireAuth, async (req, res) => { 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], @@ -1130,7 +1219,7 @@ router.put("/notifications/:id/read", requireAuth, async (req, res) => { }); // PUT /api/comments/notifications/read-all - Marquer toutes les notifications comme lues -router.put("/notifications/read-all", requireAuth, async (req, res) => { +router.put("/notifications/read-all", requireAuth, writeRateLimit, async (req, res) => { try { const { profileId } = req.body; @@ -1139,6 +1228,15 @@ router.put("/notifications/read-all", requireAuth, async (req, res) => { 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], @@ -1154,7 +1252,7 @@ router.put("/notifications/read-all", requireAuth, async (req, res) => { }); // DELETE /api/comments/notifications/:id - Supprimer une notification -router.delete("/notifications/:id", requireAuth, async (req, res) => { +router.delete("/notifications/:id", requireAuth, writeRateLimit, async (req, res) => { try { const { id } = req.params; const { profileId } = req.query; @@ -1164,6 +1262,15 @@ router.delete("/notifications/:id", requireAuth, async (req, res) => { 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 = ?", @@ -1190,17 +1297,8 @@ router.delete("/notifications/:id", requireAuth, async (req, res) => { // GET /api/comments/notifications/preferences - Récupérer les préférences de notifications router.get("/notifications/preferences", requireAuth, async (req, res) => { try { - const pool = getPool(); - await pool.execute( - `CREATE TABLE IF NOT EXISTS user_notification_preferences ( - user_id VARCHAR(255) NOT NULL, - user_type VARCHAR(50) NOT NULL, - notifications_disabled TINYINT(1) DEFAULT 0, - updated_at BIGINT, - PRIMARY KEY (user_id, user_type) - )` - ); - + 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] @@ -1217,21 +1315,11 @@ router.get("/notifications/preferences", requireAuth, async (req, res) => { }); // PUT /api/comments/notifications/preferences - Mettre à jour les préférences de notifications -router.put("/notifications/preferences", requireAuth, async (req, res) => { +router.put("/notifications/preferences", requireAuth, writeRateLimit, async (req, res) => { try { + await ensureNotificationTables(); const disabled = req.body?.notificationsDisabled === true; - const pool = getPool(); - - await pool.execute( - `CREATE TABLE IF NOT EXISTS user_notification_preferences ( - user_id VARCHAR(255) NOT NULL, - user_type VARCHAR(50) NOT NULL, - notifications_disabled TINYINT(1) DEFAULT 0, - updated_at BIGINT, - PRIMARY KEY (user_id, user_type) - )` - ); - + const pool = getCachedPool(); await pool.execute( `INSERT INTO user_notification_preferences (user_id, user_type, notifications_disabled, updated_at) VALUES (?, ?, ?, ?) @@ -1247,31 +1335,68 @@ router.put("/notifications/preferences", requireAuth, async (req, res) => { }); // POST /api/comments/notifications/push/subscribe - Enregistrer une subscription push -router.post("/notifications/push/subscribe", requireAuth, async (req, res) => { +router.post("/notifications/push/subscribe", requireAuth, writeRateLimit, async (req, res) => { try { const { subscription } = req.body; - if (!subscription || !subscription.endpoint) { + // 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" }); } - const pool = getPool(); - await pool.execute( - `CREATE TABLE IF NOT EXISTS push_subscriptions ( - id INT AUTO_INCREMENT PRIMARY KEY, - user_id VARCHAR(255) NOT NULL, - user_type VARCHAR(50) NOT NULL, - endpoint TEXT NOT NULL, - p256dh TEXT NOT NULL, - auth TEXT NOT NULL, - created_at BIGINT, - INDEX idx_user_push (user_id, user_type) - )` - ); - // Supprimer les anciennes subscriptions du même endpoint - await pool.execute('DELETE FROM push_subscriptions WHERE endpoint = ?', [subscription.endpoint]); - await pool.execute( - 'INSERT INTO push_subscriptions (user_id, user_type, endpoint, p256dh, auth, created_at) VALUES (?, ?, ?, ?, ?, ?)', - [req.user.userId, req.user.userType, subscription.endpoint, subscription.keys.p256dh, subscription.keys.auth, Date.now()] + + 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); @@ -1280,11 +1405,12 @@ router.post("/notifications/push/subscribe", requireAuth, async (req, res) => { }); // DELETE /api/comments/notifications/push/unsubscribe - Supprimer une subscription push -router.delete("/notifications/push/unsubscribe", requireAuth, async (req, res) => { +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" }); - const pool = getPool(); + 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) { @@ -1301,10 +1427,15 @@ router.get("/notifications/push/vapid-key", (req, res) => { // ==================== ROUTES RÉACTIONS ==================== // POST /api/comments/react - Ajouter/retirer une réaction -router.post("/react", requireAuth, async (req, res) => { +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, @@ -1448,6 +1579,11 @@ router.get( 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], @@ -1468,8 +1604,9 @@ router.get( router.get("/:commentId/replies", async (req, res) => { try { const { commentId } = req.params; - const { page = 1, limit = 3 } = req.query; - const offset = (page - 1) * limit; + 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; @@ -1516,8 +1653,8 @@ router.get("/:commentId/replies", async (req, res) => { currentUser.userType, profileId, commentId, - parseInt(limit), - parseInt(offset), + safeLimit, + offset, ]; } else { repliesQuery = ` @@ -1527,7 +1664,7 @@ router.get("/:commentId/replies", async (req, res) => { WHERE cr.comment_id = ? AND cr.deleted = 0 ORDER BY cr.hierarchical_path ASC LIMIT ? OFFSET ?`; - repliesParams = [commentId, parseInt(limit), parseInt(offset)]; + repliesParams = [commentId, safeLimit, offset]; } const replies = await dbAll(repliesQuery, repliesParams); @@ -1565,8 +1702,8 @@ router.get("/:commentId/replies", async (req, res) => { res.json({ replies: repliesWithDetails, total: totalResult.total, - page: parseInt(page), - limit: parseInt(limit), + page: safePage, + limit: safeLimit, hasMore: offset + repliesWithDetails.length < totalResult.total, }); } catch (error) { @@ -1576,7 +1713,7 @@ router.get("/:commentId/replies", async (req, res) => { }); // POST /api/comments/:commentId/replies - Créer une réponse -router.post("/:commentId/replies", requireAuth, async (req, res) => { +router.post("/:commentId/replies", requireAuth, writeRateLimit, async (req, res) => { try { const { commentId } = req.params; let { @@ -1827,10 +1964,10 @@ router.post("/:commentId/replies", requireAuth, async (req, res) => { }); // PUT /api/comments/replies/:id - Éditer une réponse -router.put("/replies/:id", requireAuth, async (req, res) => { +router.put("/replies/:id", requireAuth, writeRateLimit, async (req, res) => { try { const { id } = req.params; - let { content, isSpoiler } = req.body; + let { content, isSpoiler, profileId } = req.body; // Normalize content while preserving the original characters content = normalizeCommentContent(content); @@ -1857,6 +1994,16 @@ router.put("/replies/:id", requireAuth, async (req, res) => { 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 = ?", @@ -1880,7 +2027,7 @@ router.put("/replies/:id", requireAuth, async (req, res) => { }); // DELETE /api/comments/replies/:id - Supprimer une réponse (admin ou auteur) -router.delete("/replies/:id", requireAuth, async (req, res) => { +router.delete("/replies/:id", requireAuth, writeRateLimit, async (req, res) => { try { const { id } = req.params; const { profileId } = req.query; @@ -1899,10 +2046,13 @@ router.delete("/replies/:id", requireAuth, async (req, res) => { const userMatch = String(reply.user_id) === String(req.user.userId) && String(reply.user_type) === String(req.user.userType); - const profileMatch = - !reply.profile_id || - !profileId || - String(reply.profile_id) === String(profileId); + // 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) { @@ -2927,18 +3077,21 @@ router.get("/limits", requireAuth, async (req, res) => { // ==================== ROUTES REPORTS (avant les routes dynamiques) ==================== -const rateLimit = require("express-rate-limit"); - 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() || - req.ip, + ipKeyGenerator(req.ip), validate: { xForwardedForHeader: false, ip: false, @@ -3364,8 +3517,9 @@ router.put("/admin/reports/:id/dismiss", requireAuth, async (req, res) => { router.get("/:contentType/:contentId", async (req, res) => { try { const { contentType, contentId } = req.params; - const { page = 1, limit = 20 } = req.query; - const offset = (page - 1) * limit; + 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; @@ -3414,8 +3568,8 @@ router.get("/:contentType/:contentId", async (req, res) => { profileId, contentType, contentId, - parseInt(limit), - parseInt(offset), + safeLimit, + offset, ]; } else { commentsQuery = ` @@ -3429,8 +3583,8 @@ router.get("/:contentType/:contentId", async (req, res) => { commentsParams = [ contentType, contentId, - parseInt(limit), - parseInt(offset), + safeLimit, + offset, ]; } @@ -3470,8 +3624,8 @@ router.get("/:contentType/:contentId", async (req, res) => { res.json({ comments: commentsWithDetails, total: totalResult.total, - page: parseInt(page), - limit: parseInt(limit), + page: safePage, + limit: safeLimit, hasMore: offset + commentsWithDetails.length < totalResult.total, }); } catch (error) { @@ -3481,8 +3635,26 @@ router.get("/:contentType/:contentId", async (req, res) => { }); // POST /api/comments - Créer un commentaire -router.post("/", requireAuth, async (req, res) => { +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, @@ -3640,14 +3812,22 @@ router.post("/", requireAuth, async (req, res) => { } 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, async (req, res) => { +router.put("/:id", requireAuth, writeRateLimit, async (req, res) => { try { const { id } = req.params; - let { content, isSpoiler } = req.body; + let { content, isSpoiler, profileId } = req.body; // Normalize content while preserving the original characters content = normalizeCommentContent(content); @@ -3674,6 +3854,16 @@ router.put("/:id", requireAuth, async (req, res) => { 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 = ?", @@ -3696,7 +3886,7 @@ router.put("/:id", requireAuth, async (req, res) => { }); // DELETE /api/comments/:id - Supprimer un commentaire (admin ou auteur) -router.delete("/:id", requireAuth, async (req, res) => { +router.delete("/:id", requireAuth, writeRateLimit, async (req, res) => { try { const { id } = req.params; const { profileId } = req.query; @@ -3715,11 +3905,13 @@ router.delete("/:id", requireAuth, async (req, res) => { const userMatch = String(comment.user_id) === String(req.user.userId) && String(comment.user_type) === String(req.user.userType); - // Vérifier le profile_id si le commentaire en a un - const profileMatch = - !comment.profile_id || - !profileId || - String(comment.profile_id) === String(profileId); + // 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) { diff --git a/API/Mainapi/liveTvRoutes.js b/API/Mainapi/liveTvRoutes.js index 6f88a9f..a10c1f8 100644 --- a/API/Mainapi/liveTvRoutes.js +++ b/API/Mainapi/liveTvRoutes.js @@ -95,7 +95,7 @@ const WITV_CATEGORIES = { }; // URL de base pour Sosplay -const SOSPLAY_BASE_URL = "https://ligue1live.xyz"; +const SOSPLAY_BASE_URL = "https://streamonsport.art"; // Source Bolaloca/Elitegol (remplace l'ancien catalogue Sosplay pour les chaines) const BOLALOCA_BASE_URL = "https://bolaloca.my"; @@ -3571,7 +3571,6 @@ router.get("/manifest", async (req, res) => { manifest.catalogs = [...matchesCatalogs, ...manifest.catalogs]; manifest.idPrefixes.push("match_"); - // [DÉSACTIVÉ] Linkzy temporairement désactivé // for (const [catId, config] of Object.entries(LINKZY_CATEGORIES)) { // manifest.catalogs.push({ @@ -3762,8 +3761,7 @@ router.get("/stream/:type/:channelId", async (req, res) => { // Block VIP-only sources immediately if not VIP if ( - (channelId.startsWith("matches_") || - channelId.startsWith("iptv_")) && + (channelId.startsWith("matches_") || channelId.startsWith("iptv_")) && !isVip.vip ) { return res.status(403).json({ error: "Réservé aux membres VIP" }); @@ -4328,8 +4326,8 @@ router.delete("/cache", async (req, res) => { const XTREAM_URL = (process.env.XTREAM_URL || "").replace(/\/+$/, ""); const XTREAM_USER = process.env.XTREAM_USER || ""; const XTREAM_PASS = process.env.XTREAM_PASS || ""; -const IPTV_IMAGE_PROXY = "https://proxy.movix.blog/proxy"; -const IPTV_STREAM_PROXY = "https://proxiesembed.movix.blog/proxy"; +const IPTV_IMAGE_PROXY = "https://proxy.movix.cash/proxy"; +const IPTV_STREAM_PROXY = "https://proxiesembed.movix.cash/proxy"; // Cache catégories IPTV en mémoire (change rarement) let iptvCategoriesCache = null; @@ -4390,7 +4388,7 @@ router.get("/iptv/categories", requireVip, async (req, res) => { /** * GET /api/livetv/iptv/streams/:categoryId * Récupère les chaînes d'une catégorie Xtream (VIP only) - * Images proxifiées via proxy.movix.blog + * Images proxifiées via proxy.movix.cash */ router.get("/iptv/streams/:categoryId", requireVip, async (req, res) => { const { categoryId } = req.params; diff --git a/API/Mainapi/routes/coflix.js b/API/Mainapi/routes/coflix.js index f50894d..0347688 100644 --- a/API/Mainapi/routes/coflix.js +++ b/API/Mainapi/routes/coflix.js @@ -680,7 +680,7 @@ async function getTvDataFromCoflix(url, seasonNumber, episodeNumber) { (ep) => parseInt(ep.number) === parseInt(episodeNumber), ); if (episode && episode.links) { - episodeUrl = episode.links.startsWith("https://coflix.click") + episodeUrl = episode.links.startsWith("https://coflix.date") ? `${episode.links}` : episode.links; } @@ -688,7 +688,7 @@ async function getTvDataFromCoflix(url, seasonNumber, episodeNumber) { } if (!episodeUrl) { - episodeUrl = `https://coflix.click/episode/${seriesSlug}-${seasonNumber}x${episodeNumber}/`; + episodeUrl = `https://coflix.date/episode/${seriesSlug}-${seasonNumber}x${episodeNumber}/`; } try { diff --git a/API/Mainapi/routes/darkiworld.js b/API/Mainapi/routes/darkiworld.js index bbd2404..8ef09f6 100644 --- a/API/Mainapi/routes/darkiworld.js +++ b/API/Mainapi/routes/darkiworld.js @@ -13,6 +13,13 @@ const fsp = require('fs').promises; const { generateCacheKey } = require('../utils/cacheManager'); const { getAuthIfValid } = require('../middleware/auth'); const { getPool: getMovixPool } = require('../mysqlPool'); +const hydrackerQueue = require('../utils/hydrackerQueue'); + +// TTL pour les échecs de /decode (ex. "Lien d'embed invalide" persistant côté +// upstream). On stocke un marker `{ failed: true, failedAt }` au lieu du +// cachedData habituel, pour servir directement un 404 pendant ce délai sans +// re-taper hydracker.com à chaque clic. +const DECODE_FAILED_TTL_MS = 2 * 60 * 60 * 1000; const HOST_ICON_MAP = { '1fichier': '/hosts/1fichier.svg', @@ -102,6 +109,8 @@ let saveToCache; let shouldUpdateCache; let shouldUpdateCache24h; let refreshDarkinoSessionIfNeeded; +let redis; +let shouldUpdateCache48h; /** * Inject runtime dependencies that still live in server.js. @@ -116,6 +125,8 @@ function configure(deps) { if (deps.shouldUpdateCache) shouldUpdateCache = deps.shouldUpdateCache; if (deps.shouldUpdateCache24h) shouldUpdateCache24h = deps.shouldUpdateCache24h; if (deps.refreshDarkinoSessionIfNeeded) refreshDarkinoSessionIfNeeded = deps.refreshDarkinoSessionIfNeeded; + if (deps.redis) redis = deps.redis; + if (deps.shouldUpdateCache48h) shouldUpdateCache48h = deps.shouldUpdateCache48h; } function parsePositiveInt(value, fallback) { @@ -372,16 +383,9 @@ router.get('/download/:type/:id', async (req, res) => { const hostInfo = entry.host; const provider = hostInfo?.name || 'unknown'; - // Pour darkibox, le `lien` direct n'est pas exploitable, on garde - // null afin que le frontend déclenche /decode pour construire - // l'URL d'embed. Pour les autres providers (1fichier, sendcm, …), - // `entry.lien` est l'URL de téléchargement directe. - const directLien = provider.toLowerCase() === 'darkibox' ? null : (entry.lien || null); return { id: entry.id, - lien: directLien, - id_user: entry.id_user || undefined, language: (entry?.langues_compact && entry.langues_compact.length > 0) ? entry.langues_compact.map(l => l.name).join(', ') : undefined, @@ -420,15 +424,9 @@ router.get('/download/:type/:id', async (req, res) => { const hostInfo = entry.host; const provider = hostInfo?.name || 'unknown'; - // Pour darkibox, on laisse le frontend appeler /decode (URL d'embed - // construite côté serveur). Pour les autres providers, le `lien` - // est l'URL directe de téléchargement. - const directLien = provider.toLowerCase() === 'darkibox' ? null : (entry.lien || null); return { id: entry.id, - lien: directLien, - id_user: entry.id_user || undefined, language: (entry?.langues_compact && entry.langues_compact.length > 0) ? entry.langues_compact.map(l => l.name).join(', ') : undefined, @@ -534,189 +532,54 @@ router.get('/download/:type/:id', async (req, res) => { router.get('/decode/:id', async (req, res) => { try { const { id } = req.params; - const { title_id: titleIdParam } = req.query; - const titleId = titleIdParam ? String(titleIdParam) : null; + if (!id) return res.status(400).json({ success: false, error: 'ID du lien requis' }); - if (!id) { - return res.status(400).json({ - success: false, - error: 'ID du lien requis' - }); - } - - // Cache key v2 — invalide les anciens caches qui ont stocké des - // URLs d'embed invalides retournées par /api/v1/liens/{id}/download. - const cacheKey = generateCacheKey(`darkiworld_decode_v2_${id}`); - - // Check if results are in cache without expiration (stale-while-revalidate) - const cachedData = await getFromCacheNoExpiration(DOWNLOAD_CACHE_DIR, cacheKey); - let dataReturned = false; - let shouldDoBackgroundUpdate = false; - - if (cachedData) { - // Vérifier si le lien en cache est un lien d'embed invalide - const embedUrl = cachedData.embed_url || ''; - const isInvalidEmbedLink = /\/embed-\d+\.html$/i.test(embedUrl); - - if (isInvalidEmbedLink) { - // Cache invalide (lien d'embed `/embed-NN.html`), refetch silencieusement - } else { - // Cache valide, le retourner immédiatement - res.status(200).json(cachedData); - dataReturned = true; - - // Vérifier si on doit faire un background update (fichier modifié il y a plus de 24h) - shouldDoBackgroundUpdate = await shouldUpdateCache24h(DOWNLOAD_CACHE_DIR, cacheKey); - - // Si pas besoin de background update, on s'arrête là - if (!shouldDoBackgroundUpdate) { - return; - } - // Sinon on continue pour faire le fetch en arrière-plan - } - } - - - - // Si pas de cache valide - // Si en maintenance if (DARKINO_MAINTENANCE) { - if (!dataReturned) { - return res.status(200).json({ error: 'Service Darkino temporairement indisponible (maintenance)' }); - } - return; // Si on a déjà retourné des données, on arrête juste le traitement (pas de background update) + return res.status(200).json({ error: 'Service Darkino temporairement indisponible (maintenance)' }); } - // Fonction pour récupérer les données fraîches via l'ancien endpoint - // `/api/v1/liens/{id}/download`. Le `title_id` est ignoré côté backend - // pour le moment (le nouvel endpoint content/liens requiert une session - // authentifiée), mais reste accepté en query pour compat future. - void titleId; - const fetchFreshData = async () => { - let linkInfo = null; - let embedUrl = null; - let provider = 'unknown'; - let retryCount = 0; - const maxRetries = 2; + const result = await hydrackerQueue.decodeRequest(id, { + redis, + cacheDir: DOWNLOAD_CACHE_DIR, + generateCacheKey, + getFromCacheNoExpiration, + shouldUpdateCache48h + }); - while (retryCount < maxRetries) { - try { - await refreshDarkinoSessionIfNeeded(); - - const linkResp = await axiosDarkinoRequest({ - method: 'get', - url: `/api/v1/liens/${id}/download` - }); - - linkInfo = linkResp.data; - provider = linkInfo?.host?.name || 'unknown'; - - if (provider === 'darkibox') { - const rawDarkiboxLink = typeof linkInfo?.lien === 'string' ? linkInfo.lien : ''; - const darkiboxCodeMatch = rawDarkiboxLink.match(/darkibox\.com\/(?:embed-)?([a-z0-9]{12,})(?:\.html)?/i); - const darkiboxCode = darkiboxCodeMatch ? darkiboxCodeMatch[1] : null; - embedUrl = darkiboxCode - ? `https://darkibox.com/embed-${darkiboxCode}.html` - : (rawDarkiboxLink || `https://darkibox.com/embed-${id}.html`); - } else { - embedUrl = linkInfo?.lien || `https://darkibox.com/embed-${id}.html`; - } - - const isInvalidEmbedLink = /\/embed-\d+\.html$/i.test(embedUrl); - - if (isInvalidEmbedLink && retryCount < maxRetries - 1) { - retryCount++; - await new Promise(resolve => setTimeout(resolve, 1000)); - continue; - } else if (isInvalidEmbedLink) { - throw new Error('Lien d\'embed invalide'); - } - - break; - } catch (err) { - if (retryCount < maxRetries - 1) { - retryCount++; - await new Promise(resolve => setTimeout(resolve, 1000)); - continue; - } - throw err; - } - } - - return { - success: true, - id: id, - provider: provider, - embed_url: embedUrl, - metadata: linkInfo ? { - language: (linkInfo?.langues_compact && linkInfo.langues_compact.length > 0) - ? linkInfo.langues_compact.map(l => l.name).join(', ') - : undefined, - quality: linkInfo?.qual?.qual, - sub: (linkInfo?.subs_compact && linkInfo.subs_compact.length > 0) - ? linkInfo.subs_compact.map(s => s.name).join(', ') - : undefined, - size: linkInfo?.size, - upload_date: linkInfo?.created_at - } : null - }; - }; - - // Si on fait un background update (données déjà retournées au client) - if (dataReturned && shouldDoBackgroundUpdate) { - // Background update du cache - (async () => { - try { - const freshData = await fetchFreshData(); - if (freshData && freshData.embed_url) { - await saveToCache(DOWNLOAD_CACHE_DIR, cacheKey, freshData); - } - } catch (bgError) { - // Silent fail on background update - } - })(); - return; - } - - // Si on n'a pas encore retourné de données, faire le fetch normal - try { - const responseData = await fetchFreshData(); - - // Retourner les données - res.json(responseData); - - // Mise à jour du cache - if (responseData.embed_url) { - try { - await saveToCache(DOWNLOAD_CACHE_DIR, cacheKey, responseData); - } catch (cacheError) { - // Silent fail on cache save - } - } - } catch (fetchError) { + if (result.payload) return res.status(200).json(result.payload); + if (result.failed) { return res.status(404).json({ success: false, - error: 'Lien non trouvé ou inaccessible', - id: id, - debug: fetchError.message + error: result.failed.error || 'Lien non trouvé ou inaccessible', + id: result.failed.id || id, + debug: result.failed.debug || '' + }); + } + if (result.queued) { + return res.status(202).json({ + status: 'queued', + queue_size: result.queue_size, + id + }); + } + if (result.rateLimited) { + return res.status(503).json({ + success: false, + error: 'rate_limited', + retry_at: result.retryAt + }); + } + if (result.unavailable) { + return res.status(503).json({ + success: false, + error: 'queue_unavailable', + message: 'Infrastructure indisponible, réessaie plus tard' }); } + return res.status(500).json({ success: false, error: 'Unknown decode result' }); + } catch (error) { - if (res.headersSent) { - return; - } - - // Si Darkino retourne 500, ne pas créer/mettre à jour le cache et renvoyer le cache existant si présent - if (error.response && error.response.status >= 500) { - try { - const fallbackCache = await getFromCacheNoExpiration(DOWNLOAD_CACHE_DIR, cacheKey); - if (fallbackCache) { - return res.status(200).json(fallbackCache); - } - } catch (_) { } - } - if (!res.headersSent) { res.status(500).json({ success: false, diff --git a/API/Mainapi/routes/francetv.js b/API/Mainapi/routes/francetv.js index 27a2ad7..4ea8750 100644 --- a/API/Mainapi/routes/francetv.js +++ b/API/Mainapi/routes/francetv.js @@ -58,16 +58,18 @@ function configure(deps) { // getFtvNextActionHash -- dynamically retrieve the Next.js server action hash // --------------------------------------------------------------------------- /** - * Récupère dynamiquement le hash next-action depuis la page /recherche/ + * Récupère dynamiquement le hash next-action depuis la page /recherche/. * Ce hash change à chaque redéploiement de france.tv (Next.js Server Actions). * * Étapes : * 1. GET https://www.france.tv/recherche/ - * 2. Trouver le