From cbc3f1dad94595a7c8c448cedd69f46f846a074b Mon Sep 17 00:00:00 2001 From: Movix <178902026+MysticSaba-max@users.noreply.github.com> Date: Sun, 3 May 2026 12:47:41 +0200 Subject: [PATCH] =?UTF-8?q?Merge=20depuis=20le=20repo=20priv=C3=A9=20apr?= =?UTF-8?q?=C3=A8s=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrections de vulnérabilités sur les commentaires (IDOR) Rajouts de ratelimit sur les commentaires Correction de pas de son sur hlsplayer Livetvplayer détecte le type du flux automatiquement Amélioration du scraper darkiworld (très chiant) Correction du détection de l'extension Correction de la vérification du VIP sur server.py (proxy) Correction du scraper seekstreaming proxy Nouvelle url streamonsport et coflix Correction du scraper fstream (get seasons) car la route fonctionne plus sur leur site Correction du scraper francetv Ajout de hydrackerBatch (c'étais pour des tests) --- API/Mainapi/app.js | 94 +++++- API/Mainapi/commentsRoutes.js | 362 +++++++++++++++++----- API/Mainapi/liveTvRoutes.js | 12 +- API/Mainapi/routes/coflix.js | 4 +- API/Mainapi/routes/darkiworld.js | 233 +++----------- API/Mainapi/routes/francetv.js | 82 ++--- API/Mainapi/routes/fstream.js | 90 +++--- API/Mainapi/routes/proxy.js | 2 +- API/Mainapi/routes/search.js | 24 +- API/Mainapi/server.js | 2 +- API/Mainapi/utils/axiosHelpers.js | 16 +- API/Mainapi/utils/cacheManager.js | 18 ++ API/Mainapi/utils/hydrackerBatch.js | 461 ++++++++++++++++++++++++++++ API/Mainapi/utils/hydrackerQueue.js | 397 ++++++++++++++++++++++++ API/Mainapi/utils/proxyManager.js | 8 +- API/proxiesembed/server.py | 117 ++++--- package-lock.json | 25 +- package.json | 2 + src/App.tsx | 24 +- src/components/CommentsSection.tsx | 6 +- src/components/HLSPlayer.tsx | 4 + src/components/LiveTVPlayer.tsx | 116 ++++++- src/pages/DownloadPage.tsx | 36 +-- src/pages/LiveTV.tsx | 118 ++++++- src/utils/extensionProxy.ts | 12 +- 25 files changed, 1781 insertions(+), 484 deletions(-) create mode 100644 API/Mainapi/utils/hydrackerBatch.js create mode 100644 API/Mainapi/utils/hydrackerQueue.js 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