Sync: merge upstream movixcorp/MovixOpenSource (20 commits - iOS fixes, app icon, runtime fixes, extensions update)

This commit is contained in:
Claude 2026-06-11 13:35:34 +00:00
commit 6ace48f36d
No known key found for this signature in database
215 changed files with 14748 additions and 7061 deletions

View file

@ -85,3 +85,13 @@ VIP_PAYBLIS_STORE_NAME=Movix
# incoming request, and the checkout domain defaults to pay.payblis.com.
VIP_PAYBLIS_IPN_BASE_URL=
VIP_PAYBLIS_DOMAIN=pay.payblis.com
# === Hydracker queue decoding ===
# When deployed without frontend support for 202, set to 'false' to disable
# the queue (route returns 404 on cache miss instead of 202). Default: true.
HYDRACKER_QUEUE_ENABLED=true
# When 'false', /decode/:id bypasses Redis/queue entirely and POSTs hydracker
# inline (synchronous mode, like before the queue was introduced). The drain
# timer is also skipped. Useful in dev / low-traffic where the queue rarely
# reaches 50. Default: true (queue+batch of 50 enabled).
HYDRACKER_BATCHING_ENABLED=true

View file

@ -65,7 +65,7 @@ Le fichier `API/Mainapi/.env.example` est la référence complète. En pratique,
- cache et coordination : `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD`, `NUM_WORKERS`
- scraping / proxy : `PROXY_SERVER_URL`, `CF_PROXY_403_URL`, `BYPASS403_SERVER_URL`, `SOCKS5_PROXIES`, `HTTP_PROXIES`
- anti-abuse / forms : `TURNSTILE_SECRET_KEY`, `TURNSTILE_INVISIBLE_SECRETKEY`
- paiement / VIP : variables `VIP_*`, `BTC_EXPLORER_API`, `LTC_EXPLORER_API`
- paiement / VIP : variables `VIP_*`, `BLOCKCYPHER_TOKEN`
Certaines intégrations sont très spécifiques à des sources données, par exemple les cookies `DARKIWORLD_*`, `FSTREAM_LOGIN_*` ou `XTREAM_*`.

View file

@ -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.
}
};
@ -213,6 +260,20 @@ app.use(jsonParseErrorHandler);
app.use(express.urlencoded({ extended: true, limit: "5mb" })); // Reduced from 1000mb to prevent abuse
// 8. Serve uploaded OAuth app icons (`public/oauth-icons/<filename>`).
// Le panel admin upload ici, OAuthAuthorizePage lit `/oauth-icons/<filename>`.
const { ICON_DIR: OAUTH_ICON_DIR } = require('./utils/oauthClientsDb');
app.use(
'/oauth-icons',
express.static(OAUTH_ICON_DIR, {
fallthrough: false,
maxAge: '7d',
setHeaders: (res) => {
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
},
}),
);
// ==========================================================================
// Configure route modules with dependencies from extracted utilities
// ==========================================================================
@ -359,7 +420,9 @@ darkiworldRouter.configure({
saveToCache,
shouldUpdateCache,
shouldUpdateCache24h,
shouldUpdateCache48h,
refreshDarkinoSessionIfNeeded,
redis,
});
// --- Configure voirdrama ---
@ -410,6 +473,7 @@ app.use('/api/profiles', require('./routes/profiles'));
app.use('/api/help', require('./routes/helpFeedback'));
app.use('/api/auth', require('./routes/authRoutes'));
app.use('/api/oauth', oauthRouter);
app.use('/api/admin/oauth-apps', require('./routes/adminOauthApps'));
app.use('/api/sessions', require('./routes/sessions'));
app.use('/api', require('./routes/debrid'));
app.use('/proxy', require('./routes/proxy'));
@ -484,6 +548,16 @@ const appReady = (async () => {
await ensureOAuthStorage(pool);
console.log('OAuth tables initialized successfully');
// OAuth client config (table oauth_clients + stats + grants VIP).
// ensureTables et migrateLegacyJsonIfNeeded sont protégés par le lock
// mais idempotents — sûr sur restart cluster. reloadCache hydrate
// le cache in-process de CE worker (chaque worker a le sien).
const oauthClientsDb = require('./utils/oauthClientsDb');
await oauthClientsDb.ensureTables();
await oauthClientsDb.migrateLegacyJsonIfNeeded();
await oauthClientsDb.reloadCache();
console.log('OAuth client tables initialized successfully');
// Initialize Wishboard routes
const { createWishboardRouter } = require("./wishboardRoutes");
const wishboardRouter = createWishboardRouter(pool, redis);
@ -589,6 +663,39 @@ 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;
if (hydrackerQueue.BATCHING_ENABLED) {
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);
} else {
console.log('[hydracker] HYDRACKER_BATCHING_ENABLED=false → drain timer skipped, decode runs synchronously');
}
// === Unified error handler ===
app.use((err, req, res, next) => {
if (err.message !== "Not allowed by CORS") {

View file

@ -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) {

View file

@ -0,0 +1,112 @@
-- Migration : passage du fichier `data/oauth-clients.json` à 3 tables MySQL.
-- - oauth_clients : config des apps (remplace le JSON)
-- - oauth_app_stats : compteur d'appels par app + type d'event
-- - oauth_vip_grants : historique des grants VIP émis par chaque app
--
-- Idempotent grâce à `CREATE TABLE IF NOT EXISTS`.
-- Lance avec : `mysql -u <user> -p movix < add_oauth_apps_tables.sql`
-- ou via le script `routes/admin.js` au démarrage (auto-migrate).
CREATE TABLE IF NOT EXISTS oauth_clients (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
-- Identifiant public visible dans le query OAuth (?client_id=...).
client_id VARCHAR(128) NOT NULL UNIQUE,
-- Nom affiché sur la page d'autorisation et dans le panel admin.
client_name VARCHAR(200) NOT NULL,
description TEXT NULL,
homepage_url VARCHAR(500) NULL,
-- JSON arrays — sérialisation gérée côté Node.
redirect_uris JSON NOT NULL,
allowed_scopes JSON NOT NULL,
-- Type de client : public (PKCE obligatoire, pas de secret) ou
-- confidentiel (client_secret nécessaire).
public_client TINYINT(1) NOT NULL DEFAULT 1,
require_pkce TINYINT(1) NOT NULL DEFAULT 1,
-- Secret en clair (uniquement si publicClient = 0).
client_secret VARCHAR(256) NULL,
-- Nom de fichier de l'icône (relatif à `public/oauth-icons/`).
-- Ex : "movix-mcp-1234567890.png". NULL = pas d'icône custom.
icon_filename VARCHAR(200) NULL,
-- Compteur de jours VIP que l'app peut distribuer via /api/oauth/vip/grant.
-- Décrément à chaque grant ; admin peut alimenter via le panel.
vip_days_balance INT NOT NULL DEFAULT 0,
-- Désactivation soft (cache l'app de la list mais garde l'historique).
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at BIGINT UNSIGNED NOT NULL,
updated_at BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (id),
KEY idx_client_id (client_id),
KEY idx_is_active (is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Stats : un event = une ligne. Permet de grapher l'usage par app.
-- Cleanup périodique : `DELETE FROM oauth_app_stats WHERE created_at < (now - 90j)`.
CREATE TABLE IF NOT EXISTS oauth_app_stats (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
-- Référence vers oauth_clients.client_id (pas la PK numérique, pour
-- survivre à une suppression).
client_id VARCHAR(128) NOT NULL,
-- Type d'event : 'authorize' (page d'auth affichée), 'authorize_granted'
-- (user a cliqué Autoriser), 'authorize_denied', 'token' (échange code →
-- token), 'api_call' (toute requête OAuth authentifiée), 'vip_grant'.
event_type VARCHAR(32) NOT NULL,
-- User concerné (si applicable). Format `userType:userId`.
user_id VARCHAR(160) NULL,
-- Métadonnées libres (path, status, scope demandé, etc.) en JSON.
metadata JSON NULL,
created_at BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (id),
KEY idx_client_event (client_id, event_type, created_at),
KEY idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Historique des grants VIP. Chaque ligne = un grant fait par une app
-- à un user. Sert d'audit + sert à recréer une access_key si l'user
-- perd la sienne.
CREATE TABLE IF NOT EXISTS oauth_vip_grants (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
client_id VARCHAR(128) NOT NULL,
-- User qui reçoit le VIP. Format `userType:userId`.
user_id VARCHAR(160) NOT NULL,
user_type VARCHAR(16) NOT NULL,
user_id_only VARCHAR(128) NOT NULL,
-- Jours grantés (décrémenté de oauth_clients.vip_days_balance).
days_granted INT UNSIGNED NOT NULL,
-- Access key générée (référence vers access_keys.key_value).
access_key_value VARCHAR(128) NOT NULL,
-- Date de validité de la clé
expires_at DATETIME NOT NULL,
-- Audit
granted_at BIGINT UNSIGNED NOT NULL,
-- Si l'admin révoque le grant : on flag (mais on n'efface pas la clé
-- automatiquement — l'admin doit le faire séparément).
revoked_at BIGINT UNSIGNED NULL,
PRIMARY KEY (id),
KEY idx_client_id (client_id, granted_at),
KEY idx_user_id (user_id, granted_at),
KEY idx_access_key (access_key_value)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View file

@ -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";
@ -265,7 +265,7 @@ const BOLALOCA_CHANNELS = [
},
];
const LIVETV_BASE_URL = "https://livetv876.me";
const LIVETV_BASE_URL = "https://livetv882.me";
const LIVETV_EMBED_REFERER = `${LIVETV_BASE_URL}/`;
const LIVETV_ALLUPCOMING_PATHS = ["/frx/allupcoming/", "/frx/ads/"];
const LIVETV_CATEGORIES = {
@ -1958,7 +1958,7 @@ function shouldIgnoreLiveTvIframeUrl(rawUrl) {
const combined = `${hostname}${pathname}${search}`;
if (
hostname === "ads.livetv876.me" ||
hostname === "ads.livetv882.me" ||
hostname.startsWith("ads.") ||
hostname.startsWith("ad.")
) {
@ -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.tax/proxy";
const IPTV_STREAM_PROXY = "https://proxiesembed.movix.tax/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;

View file

@ -7,6 +7,8 @@ const cors = require("cors");
const { getOAuthAllowedCorsOrigins } = require('../utils/oauthClients');
const STATIC_ALLOWED_DOMAINS = [
'movix.tax',
'movix.cash',
'movix.blog',
'movix.rodeo',
'movix.club',

View file

@ -40,6 +40,8 @@ function domainRestriction(req, res, next) {
const allowedDomains = [
'localhost:3000',
'movix.tax',
'movix.cash',
'movix.blog',
'movix.rodeo',
'movix.club',

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View file

@ -0,0 +1,604 @@
/**
* Routes admin pour gérer les applications OAuth Movix.
* Mount : `app.use('/api/admin/oauth-apps', adminOauthAppsRouter)`.
*
* Toutes les routes sont protégées par `isAdmin` (table `admins`).
* Source de vérité : la table `oauth_clients` (alimentée au boot par
* `oauthClientsDb.reloadCache()`). Toute mutation appelle `reloadCache()`
* en fin de requête pour rafraîchir le cache du worker courant.
*
* Note multi-worker : chaque worker a son propre cache in-process. Une
* mutation depuis le worker A ne rafraîchit pas le cache du worker B
* immédiatement. C'est acceptable car :
* 1) les opérations admin sont rares ;
* 2) le cache est rechargé au boot ;
* 3) une lecture stale max 1 requête.
* Si besoin d'invalidation cross-worker publier un message Redis.
*/
const express = require('express');
const crypto = require('crypto');
const path = require('path');
const fs = require('fs');
const fsp = require('fs').promises;
const rateLimit = require('express-rate-limit');
const { ipKeyGenerator } = require('express-rate-limit');
const { isAdmin } = require('../middleware/auth');
const { getPool } = require('../mysqlPool');
const oauthClientsDb = require('../utils/oauthClientsDb');
const { KNOWN_OAUTH_SCOPES } = require('../utils/oauthClients');
const { createRedisRateLimitStore } = require('../utils/redisRateLimitStore');
const router = express.Router();
const ALLOWED_ICON_MIME = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/webp': 'webp',
};
const MAX_ICON_SIZE_BYTES = 256 * 1024; // 256 KB
const CLIENT_ID_RE = /^[a-z0-9][a-z0-9-]{1,64}$/;
// Petit rate-limiter pour les routes admin OAuth (anti-bruteforce sur les secrets).
const adminOauthAppsLimiter = rateLimit({
windowMs: 60 * 1000,
max: 60,
store: createRedisRateLimitStore({ prefix: 'rate-limit:admin:oauth-apps:' }),
passOnStoreError: true,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) =>
req.headers['cf-connecting-ip']
|| req.headers['x-forwarded-for']?.split(',')[0].trim()
|| ipKeyGenerator(req.ip),
validate: { xForwardedForHeader: false, ip: false },
});
router.use(adminOauthAppsLimiter);
router.use(isAdmin);
// ─── helpers ────────────────────────────────────────────────────────────
function badRequest(res, message) {
return res.status(400).json({ success: false, error: message });
}
function notFound(res, message = 'Application OAuth introuvable') {
return res.status(404).json({ success: false, error: message });
}
function serverError(res, error, message = 'Erreur serveur') {
console.error('[adminOauthApps]', message, error?.message || error);
return res.status(500).json({ success: false, error: message });
}
function sanitizeClientId(raw) {
const value = String(raw || '').trim().toLowerCase();
return CLIENT_ID_RE.test(value) ? value : null;
}
function sanitizeClientName(raw) {
if (typeof raw !== 'string') return null;
const value = raw.trim().slice(0, 200);
return value.length >= 2 ? value : null;
}
function sanitizeDescription(raw) {
if (raw == null || raw === '') return null;
if (typeof raw !== 'string') return null;
return raw.trim().slice(0, 2000) || null;
}
function sanitizeHttpUrl(raw) {
if (raw == null || raw === '') return null;
if (typeof raw !== 'string') return null;
try {
const url = new URL(raw.trim());
// HTTPS only : le homepageUrl est rendu en lien cliquable sur la page
// d'autorisation OAuth (boundary de confiance pour l'utilisateur).
// Pas d'exception loopback ici — c'est pour le marketing, pas pour OAuth.
if (url.protocol !== 'https:') return null;
url.hash = '';
return url.toString();
} catch {
return null;
}
}
function sanitizeRedirectUris(rawArray) {
if (!Array.isArray(rawArray)) return null;
const result = [];
for (const raw of rawArray) {
if (typeof raw !== 'string') continue;
try {
const url = new URL(raw.trim());
const host = url.hostname.toLowerCase();
const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '::1' || host.endsWith('.localhost');
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLoopback)) {
continue;
}
url.hash = '';
result.push(url.toString());
} catch { /* skip */ }
}
return result.length > 0 ? Array.from(new Set(result)) : null;
}
function sanitizeScopes(rawArray) {
if (!Array.isArray(rawArray)) return null;
const result = Array.from(new Set(
rawArray
.map((s) => String(s || '').trim())
.filter((s) => KNOWN_OAUTH_SCOPES.includes(s)),
));
return result.length > 0 ? result : null;
}
function generateClientSecret() {
// 64 chars hex = 256 bits — assez pour un secret OAuth.
return crypto.randomBytes(32).toString('hex');
}
function serializeAppRow(row) {
return {
id: Number(row.id),
clientId: row.client_id,
clientName: row.client_name,
description: row.description || null,
homepageUrl: row.homepage_url || null,
redirectUris: safeJsonParse(row.redirect_uris, []),
allowedScopes: safeJsonParse(row.allowed_scopes, []),
publicClient: row.public_client === 1 || row.public_client === true,
requirePkce: row.require_pkce === 1 || row.require_pkce === true,
hasClientSecret: !!row.client_secret,
iconFilename: row.icon_filename || null,
iconUrl: row.icon_filename ? `/oauth-icons/${row.icon_filename}` : null,
vipDaysBalance: Number(row.vip_days_balance || 0),
isActive: row.is_active === 1 || row.is_active === true,
createdAt: Number(row.created_at || 0),
updatedAt: Number(row.updated_at || 0),
};
}
function safeJsonParse(raw, fallback) {
if (typeof raw !== 'string' || !raw.trim()) {
return Array.isArray(raw) ? raw : fallback;
}
try {
return JSON.parse(raw);
} catch {
return fallback;
}
}
async function fetchAppByClientId(pool, clientId) {
const [rows] = await pool.execute(
'SELECT * FROM oauth_clients WHERE client_id = ? LIMIT 1',
[clientId],
);
return rows[0] || null;
}
async function removeIconFile(filename) {
if (!filename) return;
const target = path.join(oauthClientsDb.ICON_DIR, path.basename(filename));
try {
await fsp.unlink(target);
} catch {
/* swallow: déjà absent */
}
}
// ─── routes ─────────────────────────────────────────────────────────────
router.get('/scopes', (req, res) => {
res.json({ success: true, scopes: [...KNOWN_OAUTH_SCOPES] });
});
router.get('/', async (req, res) => {
try {
const pool = getPool();
const includeInactive = req.query?.inactive === '1' || req.query?.inactive === 'true';
const whereSql = includeInactive ? '' : 'WHERE is_active = 1';
const [rows] = await pool.execute(
`SELECT * FROM oauth_clients ${whereSql} ORDER BY created_at DESC`,
);
// Stats compactes par app (30 derniers jours) pour l'affichage en liste.
const since = Date.now() - 30 * 24 * 60 * 60 * 1000;
const [statsRows] = await pool.execute(
`SELECT client_id, event_type, COUNT(*) AS n
FROM oauth_app_stats
WHERE created_at >= ?
GROUP BY client_id, event_type`,
[since],
);
const statsByClient = new Map();
for (const r of statsRows) {
if (!statsByClient.has(r.client_id)) statsByClient.set(r.client_id, {});
statsByClient.get(r.client_id)[r.event_type] = Number(r.n);
}
const apps = rows.map((row) => ({
...serializeAppRow(row),
stats30d: statsByClient.get(row.client_id) || {},
}));
return res.json({ success: true, apps });
} catch (err) {
return serverError(res, err, 'Impossible de lister les applications');
}
});
router.get('/:clientId', async (req, res) => {
try {
const clientId = sanitizeClientId(req.params.clientId);
if (!clientId) return badRequest(res, 'clientId invalide');
const pool = getPool();
const row = await fetchAppByClientId(pool, clientId);
if (!row) return notFound(res);
return res.json({ success: true, app: serializeAppRow(row) });
} catch (err) {
return serverError(res, err, 'Impossible de charger l\'application');
}
});
router.post('/', async (req, res) => {
try {
const clientId = sanitizeClientId(req.body?.clientId);
if (!clientId) return badRequest(res, 'clientId invalide (a-z, 0-9, -, 2 à 65 caractères)');
const clientName = sanitizeClientName(req.body?.clientName);
if (!clientName) return badRequest(res, 'clientName requis (≥2 caractères)');
const redirectUris = sanitizeRedirectUris(req.body?.redirectUris);
if (!redirectUris) return badRequest(res, 'redirectUris requis (≥1 URI HTTPS ou loopback http)');
const allowedScopes = sanitizeScopes(req.body?.allowedScopes);
if (!allowedScopes) return badRequest(res, 'allowedScopes requis (≥1 scope connu)');
const description = sanitizeDescription(req.body?.description);
const homepageUrl = sanitizeHttpUrl(req.body?.homepageUrl);
const publicClient = req.body?.publicClient !== false;
const requirePkce = publicClient ? true : req.body?.requirePkce === true;
const generatedSecret = !publicClient ? generateClientSecret() : null;
const pool = getPool();
const existing = await fetchAppByClientId(pool, clientId);
if (existing) return badRequest(res, 'Cet clientId existe déjà');
const now = Date.now();
await pool.execute(
`INSERT INTO oauth_clients
(client_id, client_name, description, homepage_url, redirect_uris,
allowed_scopes, public_client, require_pkce, client_secret,
is_active, vip_days_balance, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, ?, ?)`,
[
clientId,
clientName,
description,
homepageUrl,
JSON.stringify(redirectUris),
JSON.stringify(allowedScopes),
publicClient ? 1 : 0,
requirePkce ? 1 : 0,
generatedSecret,
now,
now,
],
);
await oauthClientsDb.reloadCache();
const row = await fetchAppByClientId(pool, clientId);
const serialized = serializeAppRow(row);
// Le secret n'est exposé qu'UNE fois (à la création) — l'admin doit le copier.
return res.json({
success: true,
app: serialized,
clientSecret: generatedSecret, // null pour les clients publics
});
} catch (err) {
return serverError(res, err, 'Impossible de créer l\'application');
}
});
router.put('/:clientId', async (req, res) => {
try {
const clientId = sanitizeClientId(req.params.clientId);
if (!clientId) return badRequest(res, 'clientId invalide');
const pool = getPool();
const existing = await fetchAppByClientId(pool, clientId);
if (!existing) return notFound(res);
const updates = [];
const params = [];
if (req.body?.clientName !== undefined) {
const v = sanitizeClientName(req.body.clientName);
if (!v) return badRequest(res, 'clientName invalide');
updates.push('client_name = ?'); params.push(v);
}
if (req.body?.description !== undefined) {
updates.push('description = ?'); params.push(sanitizeDescription(req.body.description));
}
if (req.body?.homepageUrl !== undefined) {
updates.push('homepage_url = ?'); params.push(sanitizeHttpUrl(req.body.homepageUrl));
}
if (req.body?.redirectUris !== undefined) {
const v = sanitizeRedirectUris(req.body.redirectUris);
if (!v) return badRequest(res, 'redirectUris invalide (≥1 URI HTTPS ou loopback http)');
updates.push('redirect_uris = ?'); params.push(JSON.stringify(v));
}
if (req.body?.allowedScopes !== undefined) {
const v = sanitizeScopes(req.body.allowedScopes);
if (!v) return badRequest(res, 'allowedScopes invalide');
updates.push('allowed_scopes = ?'); params.push(JSON.stringify(v));
}
if (req.body?.publicClient !== undefined) {
const becomesPublic = req.body.publicClient === true;
updates.push('public_client = ?'); params.push(becomesPublic ? 1 : 0);
if (becomesPublic) {
// Switch confidential → public : on force pkce et on supprime le secret.
updates.push('require_pkce = 1');
updates.push('client_secret = NULL');
}
}
if (req.body?.requirePkce !== undefined) {
updates.push('require_pkce = ?'); params.push(req.body.requirePkce === true ? 1 : 0);
}
if (req.body?.isActive !== undefined) {
updates.push('is_active = ?'); params.push(req.body.isActive === true ? 1 : 0);
}
if (updates.length === 0) return badRequest(res, 'Aucun champ à mettre à jour');
updates.push('updated_at = ?'); params.push(Date.now());
params.push(clientId);
await pool.execute(
`UPDATE oauth_clients SET ${updates.join(', ')} WHERE client_id = ?`,
params,
);
await oauthClientsDb.reloadCache();
const row = await fetchAppByClientId(pool, clientId);
return res.json({ success: true, app: serializeAppRow(row) });
} catch (err) {
return serverError(res, err, 'Impossible de mettre à jour l\'application');
}
});
router.post('/:clientId/regenerate-secret', async (req, res) => {
try {
const clientId = sanitizeClientId(req.params.clientId);
if (!clientId) return badRequest(res, 'clientId invalide');
const pool = getPool();
const existing = await fetchAppByClientId(pool, clientId);
if (!existing) return notFound(res);
if (existing.public_client === 1 || existing.public_client === true) {
return badRequest(res, 'Les clients publics n\'utilisent pas de clientSecret');
}
const newSecret = generateClientSecret();
await pool.execute(
'UPDATE oauth_clients SET client_secret = ?, updated_at = ? WHERE client_id = ?',
[newSecret, Date.now(), clientId],
);
await oauthClientsDb.reloadCache();
return res.json({ success: true, clientSecret: newSecret });
} catch (err) {
return serverError(res, err, 'Impossible de régénérer le secret');
}
});
router.delete('/:clientId', async (req, res) => {
try {
const clientId = sanitizeClientId(req.params.clientId);
if (!clientId) return badRequest(res, 'clientId invalide');
const pool = getPool();
const existing = await fetchAppByClientId(pool, clientId);
if (!existing) return notFound(res);
// Hard delete : on supprime la ligne ; les stats et grants restent
// (FK absente volontairement — historique d'audit).
await pool.execute('DELETE FROM oauth_clients WHERE client_id = ?', [clientId]);
// Cleanup icône si présente.
if (existing.icon_filename) {
await removeIconFile(existing.icon_filename);
}
await oauthClientsDb.reloadCache();
return res.json({ success: true });
} catch (err) {
return serverError(res, err, 'Impossible de supprimer l\'application');
}
});
// Upload icône : JSON body { mimeType, dataBase64 }
// On évite multer pour ne pas ajouter une dépendance ; les icônes sont
// petites (< 256KB) donc base64 dans le body JSON est OK.
router.post('/:clientId/icon', async (req, res) => {
try {
const clientId = sanitizeClientId(req.params.clientId);
if (!clientId) return badRequest(res, 'clientId invalide');
const mimeType = String(req.body?.mimeType || '').trim().toLowerCase();
const ext = ALLOWED_ICON_MIME[mimeType];
if (!ext) return badRequest(res, 'mimeType non supporté (png / jpeg / webp)');
const dataBase64 = String(req.body?.dataBase64 || '');
if (!dataBase64) return badRequest(res, 'dataBase64 requis');
let buffer;
try {
buffer = Buffer.from(dataBase64, 'base64');
} catch {
return badRequest(res, 'dataBase64 invalide');
}
if (buffer.length === 0) return badRequest(res, 'Fichier vide');
if (buffer.length > MAX_ICON_SIZE_BYTES) {
return badRequest(res, `Fichier trop gros (max ${Math.round(MAX_ICON_SIZE_BYTES / 1024)} KB)`);
}
// Vérification rapide du magic number pour bloquer un PNG renommé en .jpg etc.
const isPng = buffer.length >= 8 && buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47;
const isJpeg = buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
const isWebp = buffer.length >= 12
&& buffer.slice(0, 4).toString('ascii') === 'RIFF'
&& buffer.slice(8, 12).toString('ascii') === 'WEBP';
if ((ext === 'png' && !isPng) || (ext === 'jpg' && !isJpeg) || (ext === 'webp' && !isWebp)) {
return badRequest(res, 'Le contenu ne correspond pas au mimeType déclaré');
}
const pool = getPool();
const existing = await fetchAppByClientId(pool, clientId);
if (!existing) return notFound(res);
// Ensure dir exists (sécurité : ICON_DIR géré par ensureTables au boot).
if (!fs.existsSync(oauthClientsDb.ICON_DIR)) {
fs.mkdirSync(oauthClientsDb.ICON_DIR, { recursive: true, mode: 0o755 });
}
const filename = `${clientId}-${Date.now()}.${ext}`;
const targetPath = path.join(oauthClientsDb.ICON_DIR, filename);
await fsp.writeFile(targetPath, buffer, { mode: 0o644 });
// Cleanup ancienne icône avant d'enregistrer la nouvelle.
const previousFilename = existing.icon_filename;
await pool.execute(
'UPDATE oauth_clients SET icon_filename = ?, updated_at = ? WHERE client_id = ?',
[filename, Date.now(), clientId],
);
if (previousFilename && previousFilename !== filename) {
await removeIconFile(previousFilename);
}
await oauthClientsDb.reloadCache();
return res.json({
success: true,
iconFilename: filename,
iconUrl: `/oauth-icons/${filename}`,
});
} catch (err) {
return serverError(res, err, 'Impossible d\'uploader l\'icône');
}
});
router.delete('/:clientId/icon', async (req, res) => {
try {
const clientId = sanitizeClientId(req.params.clientId);
if (!clientId) return badRequest(res, 'clientId invalide');
const pool = getPool();
const existing = await fetchAppByClientId(pool, clientId);
if (!existing) return notFound(res);
if (existing.icon_filename) {
await removeIconFile(existing.icon_filename);
await pool.execute(
'UPDATE oauth_clients SET icon_filename = NULL, updated_at = ? WHERE client_id = ?',
[Date.now(), clientId],
);
await oauthClientsDb.reloadCache();
}
return res.json({ success: true });
} catch (err) {
return serverError(res, err, 'Impossible de supprimer l\'icône');
}
});
// Alimente le compteur de jours VIP que l'app peut distribuer.
// Body : { delta: number } → positif (ajoute) ou négatif (retire, sans descendre sous 0).
router.post('/:clientId/vip-balance', async (req, res) => {
try {
const clientId = sanitizeClientId(req.params.clientId);
if (!clientId) return badRequest(res, 'clientId invalide');
const delta = Number(req.body?.delta);
if (!Number.isInteger(delta) || delta === 0) {
return badRequest(res, 'delta doit être un entier non nul');
}
if (Math.abs(delta) > 100000) {
return badRequest(res, 'delta trop grand');
}
const pool = getPool();
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [rows] = await conn.execute(
'SELECT id, vip_days_balance FROM oauth_clients WHERE client_id = ? FOR UPDATE',
[clientId],
);
if (rows.length === 0) {
await conn.rollback();
return notFound(res);
}
const current = Number(rows[0].vip_days_balance || 0);
const next = Math.max(0, current + delta); // clamp à 0 pour éviter un balance négatif
await conn.execute(
'UPDATE oauth_clients SET vip_days_balance = ?, updated_at = ? WHERE id = ?',
[next, Date.now(), rows[0].id],
);
await conn.commit();
await oauthClientsDb.reloadCache();
return res.json({
success: true,
previousBalance: current,
newBalance: next,
deltaApplied: next - current,
});
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
} catch (err) {
return serverError(res, err, 'Impossible de mettre à jour le balance VIP');
}
});
router.get('/:clientId/stats', async (req, res) => {
try {
const clientId = sanitizeClientId(req.params.clientId);
if (!clientId) return badRequest(res, 'clientId invalide');
const sinceDays = Math.min(Math.max(Number(req.query?.sinceDays) || 30, 1), 365);
const sinceMs = Date.now() - sinceDays * 24 * 60 * 60 * 1000;
const stats = await oauthClientsDb.getStats(clientId, sinceMs);
if (!stats) return serverError(res, null, 'DB indisponible');
return res.json({ success: true, sinceDays, ...stats });
} catch (err) {
return serverError(res, err, 'Impossible de récupérer les stats');
}
});
router.get('/:clientId/grants', async (req, res) => {
try {
const clientId = sanitizeClientId(req.params.clientId);
if (!clientId) return badRequest(res, 'clientId invalide');
const limit = Math.min(Math.max(Number(req.query?.limit) || 50, 1), 500);
const pool = getPool();
const [rows] = await pool.execute(
`SELECT id, client_id, user_id, user_type, user_id_only, days_granted,
access_key_value, expires_at, granted_at, revoked_at
FROM oauth_vip_grants
WHERE client_id = ?
ORDER BY granted_at DESC
LIMIT ?`,
[clientId, limit],
);
const grants = rows.map((row) => ({
id: Number(row.id),
clientId: row.client_id,
userId: row.user_id,
userType: row.user_type,
userIdOnly: row.user_id_only,
daysGranted: Number(row.days_granted),
// accessKey n'est PAS retournée — c'est un secret porté à l'user.
// On expose juste les 4 derniers chars pour identifier.
accessKeyHint: typeof row.access_key_value === 'string' && row.access_key_value.length > 4
? `${row.access_key_value.slice(-4)}`
: null,
expiresAt: row.expires_at,
grantedAt: Number(row.granted_at),
revokedAt: row.revoked_at ? Number(row.revoked_at) : null,
}));
return res.json({ success: true, grants });
} catch (err) {
return serverError(res, err, 'Impossible de récupérer les grants');
}
});
module.exports = router;

View file

@ -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 {

View file

@ -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',
@ -23,6 +30,9 @@ const HOST_ICON_MAP = {
'Dropbox': '/hosts/dropbox.svg',
};
// Hydracker uploaders to filter out (unreliable / spam). Hardcoded; not env-driven.
const BLOCKED_DARKIWORLD_USERS = new Set(['Guest']);
async function resolveMovixUsername(userId, authType) {
try {
const safeUserId = String(userId).replace(/[^a-zA-Z0-9_\-]/g, '');
@ -102,6 +112,8 @@ let saveToCache;
let shouldUpdateCache;
let shouldUpdateCache24h;
let refreshDarkinoSessionIfNeeded;
let redis;
let shouldUpdateCache48h;
/**
* Inject runtime dependencies that still live in server.js.
@ -116,6 +128,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) {
@ -296,6 +310,34 @@ async function findAllEntriesForEpisode({ titleId, seasonId, episodeId, perPage
return foundEntries;
}
// ---------------------------------------------------------------------------
// partitionLinksByDecodeCache — orders darkiworld links so that entries with
// a successful decode cache file on disk appear first. Cache file presence is
// verified via getFromCacheNoExpiration; only `success === true` payloads
// count (failed markers and missing files do NOT count as "cached").
// Movix links are passed through untouched (caller prepends them).
// ---------------------------------------------------------------------------
async function partitionLinksByDecodeCache(links) {
if (!Array.isArray(links) || links.length === 0) return links || [];
const probes = await Promise.all(links.map(async (link) => {
if (link?.id == null) return { link, available: false };
try {
const cacheKey = generateCacheKey(`darkiworld_decode_v2_${link.id}`);
const payload = await getFromCacheNoExpiration(DOWNLOAD_CACHE_DIR, cacheKey);
return { link, available: payload?.success === true };
} catch (_) {
return { link, available: false };
}
}));
const available = [];
const rest = [];
for (const { link, available: ok } of probes) {
if (ok) available.push(link);
else rest.push(link);
}
return [...available, ...rest];
}
// ---------------------------------------------------------------------------
// GET /download/:type/:id
// Récupérer tous les liens d'amélioration DarkiWorld pour un film ou un épisode
@ -339,16 +381,46 @@ router.get('/download/:type/:id', async (req, res) => {
let dataReturned = false;
if (cachedData) {
// console.log(`Résultats de téléchargement pour ${type}/${id} récupérés du cache`);
// Re-sort by disk decode cache presence on every read so previously
// decoded entries (from past clicks / past prewarms) bubble to the top
// even though the list cache itself is frozen between writes.
const cachedAll = Array.isArray(cachedData?.all) ? cachedData.all.map(r => ({ ...r, source: r.source || 'darkiworld' })) : [];
res.status(200).json({ ...cachedData, all: [...movixLinks, ...cachedAll], movixCount: movixLinks.length });
const sortedCachedAll = await partitionLinksByDecodeCache(cachedAll);
res.status(200).json({ ...cachedData, all: [...movixLinks, ...sortedCachedAll], movixCount: movixLinks.length });
dataReturned = true;
}
// Determine refresh staleness once and reuse below. shouldUpdateCache
// returns true if the file is missing OR older than 40 min.
const cacheNeedsUpdate = await shouldUpdateCache(DOWNLOAD_CACHE_DIR, cacheKey);
// Vérifier si l'utilisateur a accès premium (optionnel)
const auth = await getAuthIfValid(req);
const darkiworld_premium = auth && auth.userType === 'premium';
// Pre-warm decode cache via hydracker's new /download endpoint. Fires on
// cache miss AND on stale cache (>40min) — the new /download endpoint is
// separate from the /decode endpoint that gets rate-limited, so prewarm
// can keep the decode cache hot for the curated subset even during a
// rate-limit window. Best-effort: a failure here is a no-op for the
// response — the queue still handles uncached ids on click.
const prewarmPromise = cacheNeedsUpdate
? hydrackerQueue.prewarmDecodeCache({
type, id, season, episode,
deps: {
axiosDarkinoRequest,
refreshDarkinoSessionIfNeeded,
cacheDir: DOWNLOAD_CACHE_DIR,
generateCacheKey,
saveToCache,
blockedUsers: BLOCKED_DARKIWORLD_USERS
}
}).catch((e) => {
console.warn(`[hydracker] prewarm launcher caught: ${e?.message || e}`);
return { warmed: 0, warmedIds: new Set() };
})
: Promise.resolve({ warmed: 0, warmedIds: new Set() });
let allEnhancementLinks = [];
if (type === 'movie') {
@ -364,7 +436,8 @@ router.get('/download/:type/:id', async (req, res) => {
url: `/api/v1/titles/${id}/content/liens?perPage=100&loader=linksdl&filters=&paginate=preferLengthAware`
});
const allEntries = liensResp.data?.pagination?.data || [];
const rawEntries = liensResp.data?.pagination?.data || [];
const allEntries = rawEntries.filter(e => !BLOCKED_DARKIWORLD_USERS.has(e?.id_user));
// Traiter directement les entrées sans faire de requête de décodage
const enhancementSources = allEntries.map(entry => {
@ -372,16 +445,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,
@ -406,7 +472,7 @@ router.get('/download/:type/:id', async (req, res) => {
// Pour les séries (épisodes)
try {
// 1. Paginer intelligemment pour trouver l'épisode
const allEntries = await findAllEntriesForEpisode({
const rawEntries = await findAllEntriesForEpisode({
titleId: id,
seasonId: parseInt(season),
episodeId: parseInt(episode),
@ -414,21 +480,17 @@ router.get('/download/:type/:id', async (req, res) => {
maxPages: 10
});
const allEntries = rawEntries.filter(e => !BLOCKED_DARKIWORLD_USERS.has(e?.id_user));
// Traiter directement les entrées sans faire de requête de décodage
const enhancementSources = allEntries.map(entry => {
if (!entry) return null;
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,
@ -455,7 +517,22 @@ router.get('/download/:type/:id', async (req, res) => {
} catch (_) { /* upstream failure leaves allEnhancementLinks empty */ }
}
const taggedDarkiLinks = allEnhancementLinks.map(r => ({ ...r, source: 'darkiworld' }));
// Wait for the prewarm to settle so the disk decode cache is hot before
// we respond — without this await the client could click a link before
// the pre-warmed entry was written and would needlessly hit the queue.
const prewarmResult = await prewarmPromise;
if (prewarmResult?.warmed) {
console.log(`[hydracker] prewarm ${type}/${id} warmed=${prewarmResult.warmed}`);
}
// Sort by disk decode cache presence: anything with an existing
// success-shaped payload at darkiworld_decode_v2_{id} (whether from a
// prior queue decode, a prior decodeRequestSync, or this request's
// prewarm) bubbles to the top. The prewarm just completed above so its
// writes are already on disk and counted here.
const orderedEnhancementLinks = await partitionLinksByDecodeCache(allEnhancementLinks);
const taggedDarkiLinks = orderedEnhancementLinks.map(r => ({ ...r, source: 'darkiworld' }));
const responseData = {
success: true,
all: [...movixLinks, ...taggedDarkiLinks],
@ -467,21 +544,20 @@ router.get('/download/:type/:id', async (req, res) => {
res.json(responseData);
}
// Background update du cache
// Background update du cache — reuses cacheNeedsUpdate computed above
// so we don't restat the file twice per request.
(async () => {
try {
// Vérifier si le cache doit être mis à jour
const shouldUpdate = await shouldUpdateCache(DOWNLOAD_CACHE_DIR, cacheKey);
if (!shouldUpdate) {
return; // Ne pas mettre à jour le cache
if (!cacheNeedsUpdate) {
return; // Cache encore frais (<40 min), pas de réécriture.
}
// Si on a des données, sauvegarder dans le cache
if (allEnhancementLinks && allEnhancementLinks.length > 0) {
if (orderedEnhancementLinks && orderedEnhancementLinks.length > 0) {
// Store only DarkiWorld entries in cache — Movix links are fetched fresh each request
const darkiOnlyData = {
success: true,
all: allEnhancementLinks.map(r => ({ ...r, source: 'darkiworld' }))
all: orderedEnhancementLinks.map(r => ({ ...r, source: 'darkiworld' }))
};
await saveToCache(DOWNLOAD_CACHE_DIR, cacheKey, darkiOnlyData);
}
@ -534,189 +610,62 @@ 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 = hydrackerQueue.BATCHING_ENABLED
? await hydrackerQueue.decodeRequest(id, {
redis,
cacheDir: DOWNLOAD_CACHE_DIR,
generateCacheKey,
getFromCacheNoExpiration
})
: await hydrackerQueue.decodeRequestSync(id, {
cacheDir: DOWNLOAD_CACHE_DIR,
generateCacheKey,
getFromCacheNoExpiration,
saveToCache,
axiosDarkinoRequest,
refreshDarkinoSessionIfNeeded
});
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,

View file

@ -1,23 +1,8 @@
const express = require('express');
const router = express.Router();
const path = require('path');
const fsp = require('fs').promises;
const { getPool } = require('../mysqlPool');
const { isUploaderOrAdmin } = require('../middleware/auth');
async function getUserData(userId, userType) {
try {
const safeUserId = String(userId).replace(/[^a-zA-Z0-9_\-]/g, '');
const safeUserType = userType === 'bip39' ? 'bip39' : 'oauth';
const userPath = path.join(__dirname, '..', 'data', 'users', safeUserType, `${safeUserId}.json`);
const data = JSON.parse(await fsp.readFile(userPath, 'utf8'));
if (data.profiles && data.profiles.length > 0) {
const p = data.profiles[0];
return { username: p.name || 'Admin', avatar: p.avatar || null };
}
} catch { /* fall through */ }
return { username: 'Admin', avatar: null };
}
const { resolveAdminIdentity } = require('../utils/adminIdentity');
router.get('/admin/leaderboard', isUploaderOrAdmin, async (req, res) => {
try {
@ -75,14 +60,13 @@ router.get('/admin/leaderboard', isUploaderOrAdmin, async (req, res) => {
}
const leaderboard = await Promise.all(rows.map(async (row) => {
const userType = row.admin_auth_type === 'bip-39' ? 'bip39' : 'oauth';
const u = await getUserData(row.admin_id, userType);
const identity = await resolveAdminIdentity(row.admin_id, row.admin_auth_type);
return {
admin_id: row.admin_id,
admin_auth_type: row.admin_auth_type,
role: adminRoles[row.admin_id] || 'admin',
username: u.username,
avatar: u.avatar,
username: identity.username,
avatar: identity.avatar,
score: Number(row.score),
last_action_at: row.last_action_at,
};

View file

@ -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 <script src="/_next/static/chunks/app/recherche/page-XXXX.js">
* 3. GET ce fichier JS
* 4. Extraire le hash de createServerReference("HASH", ...)
* 2. Fallback rapide : essayer d'extraire $ACTION_ID_HASH inliné dans le HTML
* 3. Sinon, scanner TOUS les chunks /_next/static/chunks/*.js en parallèle
* et chercher createServerReference("HASH", ..., "searchAction") dans chacun.
* On exige le marqueur "searchAction" pour ne pas confondre avec les autres
* Server Actions que Turbopack peut grouper dans le même chunk.
*
* Retente jusqu'à 3 fois en cas d'échec (connexion directe, sans proxy).
* Retente jusqu'à 3 fois en cas d'échec réseau.
*/
async function getFtvNextActionHash() {
const now = Date.now();
@ -76,12 +78,12 @@ async function getFtvNextActionHash() {
}
const MAX_RETRIES = 3;
const SEARCH_ACTION_RE = /createServerReference\)?\s*\(\s*"([a-f0-9]{40,})"[^)]*?"searchAction"/;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
console.log(`[FTV] Fetching next-action hash (attempt ${attempt}/${MAX_RETRIES}, direct) ...`);
try {
// Étape 1: Charger la page /recherche/
const pageResponse = await axios.get(`${FTV_BASE}/recherche/`, {
headers: { ...FTV_BROWSER_HEADERS },
proxy: false,
@ -92,17 +94,23 @@ async function getFtvNextActionHash() {
const html = pageResponse.data;
let hash = null;
// Étape 2: Trouver le script chunk de la page recherche
// Pattern: <script src="/_next/static/chunks/app/recherche/page-XXXX.js" async="">
const scriptMatch = html.match(/<script[^>]+src="(\/_next\/static\/chunks\/app\/recherche\/page-[^"]+\.js)"/);
// Fast path: hash inliné dans le HTML (certains layouts Next.js le font)
const inlineMatch = html.match(/\$ACTION_ID_([a-f0-9]{40,})/);
if (inlineMatch) {
hash = inlineMatch[1];
console.log(`[FTV] Found next-action hash inline in HTML: ${hash}`);
}
if (scriptMatch) {
const scriptUrl = `${FTV_BASE}${scriptMatch[1]}`;
console.log(`[FTV] Found recherche chunk: ${scriptUrl}`);
if (!hash) {
// Lister TOUS les chunks JS — Turbopack ne respecte plus la structure /app/PAGE/page-HASH.js
const chunkUrls = [...new Set(
[...html.matchAll(/<script[^>]+src="(\/_next\/static\/chunks\/[^"]+\.js)"/g)]
.map((m) => m[1])
)];
console.log(`[FTV] Scanning ${chunkUrls.length} chunks for searchAction reference...`);
// Étape 3: Télécharger le fichier JS
try {
const jsResponse = await axios.get(scriptUrl, {
const results = await Promise.allSettled(chunkUrls.map(async (url) => {
const res = await axios.get(`${FTV_BASE}${url}`, {
headers: {
'User-Agent': FTV_BROWSER_HEADERS['User-Agent'],
'Accept': '*/*',
@ -117,39 +125,18 @@ async function getFtvNextActionHash() {
'Sec-Fetch-Site': 'same-origin',
},
proxy: false,
timeout: 15000,
timeout: 10000,
});
const m = (typeof res.data === 'string' ? res.data : '').match(SEARCH_ACTION_RE);
return m ? m[1] : null;
}));
const jsCode = jsResponse.data;
// Étape 4: Extraire le hash de createServerReference("HASH", ...)
const serverRefMatch = jsCode.match(/createServerReference\)\s*\(\s*"([a-f0-9]{40,})"/);
if (serverRefMatch) {
hash = serverRefMatch[1];
console.log(`[FTV] Found next-action hash via createServerReference: ${hash}`);
for (const r of results) {
if (r.status === 'fulfilled' && r.value) {
hash = r.value;
console.log(`[FTV] Found next-action hash in chunk: ${hash}`);
break;
}
// Fallback: chercher aussi le pattern searchAction
if (!hash) {
const searchActionMatch = jsCode.match(/"([a-f0-9]{40,})"[^]*?"searchAction"/);
if (searchActionMatch) {
hash = searchActionMatch[1];
console.log(`[FTV] Found next-action hash via searchAction: ${hash}`);
}
}
} catch (jsErr) {
console.error(`[FTV] Error fetching JS chunk: ${jsErr.message}`);
}
} else {
console.warn('[FTV] Could not find recherche page chunk script tag');
}
// Fallback: chercher directement dans le HTML
if (!hash) {
const actionIdMatch = html.match(/\$ACTION_ID_([a-f0-9]{40,})/);
if (actionIdMatch) {
hash = actionIdMatch[1];
console.log(`[FTV] Found next-action hash via $ACTION_ID_ fallback: ${hash}`);
}
}
@ -159,12 +146,11 @@ async function getFtvNextActionHash() {
return hash;
}
console.warn(`[FTV] Attempt ${attempt}: could not extract hash from page content`);
console.warn(`[FTV] Attempt ${attempt}: could not extract hash from page or chunks`);
} catch (err) {
console.error(`[FTV] Attempt ${attempt} failed (direct): ${err.response?.status || err.message}`);
if (attempt < MAX_RETRIES) {
// Petit délai avant de retry avec un autre proxy
await new Promise(r => setTimeout(r, 500));
await new Promise((r) => setTimeout(r, 500));
}
}
}

View file

@ -28,7 +28,7 @@ const { PROXIES, DARKINO_PROXIES, getProxyAgent, getDarkinoHttpProxyAgent } = re
// === FStream Configuration ===
const TMDB_API_KEY = process.env.TMDB_API_KEY || '';
const TMDB_API_URL = 'https://api.themoviedb.org/3';
const FSTREAM_BASE_URL = 'https://french-stream.one/';
const FSTREAM_BASE_URL = 'https://french-stream.one';
const FSTREAM_SEARCH_URL = `${FSTREAM_BASE_URL}/engine/ajax/search.php`;
// === FStream Authentication (disabled) ===
@ -281,14 +281,18 @@ async function searchFStreamDirect(query, page = 1) {
}
}
// Fallback "fuzzy" : search.php avec titre nu + filtre permissif (pas de filtre annee).
// Remplace l'ancien get_seasons.php (mort cote upstream depuis ~2026-05) qui prenait
// un TMDB id ; on simule le meme role en listant toutes les saisons matchant le titre.
async function fetchFStreamSeasonSearchResults(tmdbId, serieTitle) {
if (!serieTitle) return [];
try {
const formData = new URLSearchParams();
formData.append('serie_tag', `s-${tmdbId}`);
formData.append('query', serieTitle);
const response = await axiosFStreamRequest({
method: 'post',
url: `${FSTREAM_BASE_URL}/engine/ajax/get_seasons.php`,
url: FSTREAM_SEARCH_URL,
data: formData,
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
@ -297,53 +301,55 @@ async function fetchFStreamSeasonSearchResults(tmdbId, serieTitle) {
timeout: 6000
});
let seasonsData = response.data;
if (typeof seasonsData === 'string') {
const trimmed = seasonsData.trim();
if (!trimmed) return [];
try { seasonsData = JSON.parse(trimmed); }
catch (parseError) {
console.error(`[FSTREAM TV] Impossible de parser les saisons pour ${tmdbId}: ${parseError.message}`);
return [];
}
}
const html = typeof response.data === 'string' ? response.data : '';
if (!html.trim()) return [];
if (!Array.isArray(seasonsData)) return [];
const $ = cheerio.load(html);
const normalize = (s) => (s || '').toLowerCase().normalize('NFD')
.replace(/[̀-ͯ]/g, '').replace(/[''`´]/g, '')
.replace(/[^a-z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim();
const normalizedSerie = normalize(serieTitle);
if (!normalizedSerie) return [];
const normalizedSerieTitle = serieTitle || '';
const results = [];
$('div.search-item').each((_, element) => {
const $el = $(element);
const rawTitle = $el.find('.search-title').text().trim();
const onclickAttr = $el.attr('onclick') || '';
const linkMatch = onclickAttr.match(/location\.href=['"]([^'"]+)['"]/);
const link = linkMatch ? linkMatch[1] : null;
if (!rawTitle || !link) return;
return seasonsData
.map((season) => {
if (!season) return null;
const rawTitle = season.title || '';
const altName = season.alt_name || '';
const seasonMatch = rawTitle.match(/Saison\s+(\d+)/i) || altName.match(/saison-(\d+)/i);
if (!seasonMatch) return null;
const seasonMatch = rawTitle.match(/Saison\s+(\d+)/i);
if (!seasonMatch) return;
const seasonNumber = parseInt(seasonMatch[1], 10);
if (Number.isNaN(seasonNumber)) return;
const seasonNumber = parseInt(seasonMatch[1], 10);
if (Number.isNaN(seasonNumber)) return null;
const baseTitle = rawTitle.replace(/\s*-\s*Saison\s+\d+.*$/i, '').replace(/\s*\(\d{4}\)\s*$/, '').trim();
const normalizedBase = normalize(baseTitle);
if (!normalizedBase) return;
if (!normalizedBase.includes(normalizedSerie) && !normalizedSerie.includes(normalizedBase)) return;
const rawYear = season.serie_anne;
const year = rawYear && /^\d{4}$/.test(String(rawYear)) ? parseInt(rawYear, 10) : null;
const titleYearMatch = rawTitle.match(/\((\d{4})\)/);
const urlYearMatch = link.match(/-(\d{4})\.html/);
const year = titleYearMatch ? parseInt(titleYearMatch[1], 10)
: urlYearMatch ? parseInt(urlYearMatch[1], 10) : null;
const fullUrl = (season.full_url || '').replace(/\\/g, '/');
if (!fullUrl) return null;
const normalizedLink = fullUrl.startsWith('http')
? fullUrl
: `${FSTREAM_BASE_URL}${fullUrl.startsWith('/') ? '' : '/'}${fullUrl}`;
const cleanTitle = baseTitle ? `${baseTitle} - Saison ${seasonNumber}` : rawTitle;
const normalizedLink = link.startsWith('http')
? link
: `${FSTREAM_BASE_URL}${link.startsWith('/') ? '' : '/'}${link}`;
const baseTitle = rawTitle || `Saison ${seasonNumber}`;
const combinedTitle = normalizedSerieTitle ? `${normalizedSerieTitle} - ${baseTitle}` : baseTitle;
results.push({
title: cleanTitle,
originalTitle: rawTitle,
link: normalizedLink,
seasonNumber,
year
});
});
return {
title: combinedTitle,
originalTitle: rawTitle || combinedTitle,
link: normalizedLink,
seasonNumber,
year
};
})
.filter(Boolean);
return results;
} catch (error) {
console.error(`[FSTREAM TV] Erreur lors de la recuperation des saisons pour ${tmdbId}: ${error.message}`);
return [];

File diff suppressed because it is too large Load diff

View file

@ -159,7 +159,7 @@ router.get(/^\/(.*)/, async (req, res) => {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache',
'Priority': 'u=0, i',
'Referer': 'https://coflix.click/',
'Referer': 'https://coflix.date/',
'Sec-CH-UA': '"Brave";v="141", "Not?A_Brand";v="8", "Chromium";v="141"',
'Sec-CH-UA-Mobile': '?0',
'Sec-CH-UA-Platform': '"Windows"',

View file

@ -42,7 +42,7 @@ function configure(deps) {
/** Wrap une URL m3u8 dans le proxy cinep si VIP et PROXY_SERVER_URL configuré */
function wrapSourceUrl(url, isVip) {
if (isVip && PROXY_SERVER_URL && url) {
// PROXY_SERVER_URL = "https://proxy.movix.cash/proxy" → on veut la base sans /proxy
// PROXY_SERVER_URL = "https://proxy.movix.tax/proxy" → on veut la base sans /proxy
const serverBase = PROXY_SERVER_URL.replace(/\/proxy\/?$/, '').replace(/\/+$/, '');
return `${serverBase}/cinep-proxy?url=${encodeURIComponent(url)}`;
}

View file

@ -117,6 +117,18 @@ async function fetchAddableContent(title, category) {
}
}
// Garde-fou : si TOUTES les requêtes upstream traînent, on coupe pour éviter
// que /api/search reste pending côté frontend. Chaque axios a son propre
// timeout 5s, mais si plusieurs proxies retry-loop, l'agrégat peut dépasser.
const SEARCH_UPSTREAM_TIMEOUT_MS = 5000;
function withTimeout(promise, ms, label) {
return Promise.race([
promise,
new Promise((_, reject) => setTimeout(() => reject(new Error(`${label} timeout (${ms}ms)`)), ms))
]);
}
async function fetchMergedSearchData(title) {
let searchData = null;
let titlesData = null;
@ -126,18 +138,18 @@ async function fetchMergedSearchData(title) {
let titlesError = null;
const [searchResult, titlesResult, addable15Result, addable2Result] = await Promise.allSettled([
axiosDarkinoRequest({
withTimeout(axiosDarkinoRequest({
method: 'get',
url: `/api/v1/search/${encodeURIComponent(title)}`,
params: { loader: 'searchPage' }
}),
axiosDarkinoRequest({
}), SEARCH_UPSTREAM_TIMEOUT_MS, 'search'),
withTimeout(axiosDarkinoRequest({
method: 'get',
url: '/api/v1/titles',
params: { perPage: 15, query: title }
}),
fetchAddableContent(title, 15),
fetchAddableContent(title, 2)
}), SEARCH_UPSTREAM_TIMEOUT_MS, 'titles'),
withTimeout(fetchAddableContent(title, 15), SEARCH_UPSTREAM_TIMEOUT_MS, 'addable15'),
withTimeout(fetchAddableContent(title, 2), SEARCH_UPSTREAM_TIMEOUT_MS, 'addable2')
]);
if (searchResult.status === 'fulfilled') {

View file

@ -675,4 +675,5 @@ module.exports.readUserData = readUserData;
module.exports.writeUserData = writeUserData;
module.exports.readProfileData = readProfileData;
module.exports.writeProfileData = writeProfileData;
module.exports.withProfileSyncLock = withProfileSyncLock;
module.exports.USERS_DIR = USERS_DIR;

View file

@ -90,7 +90,7 @@ if (cluster.isPrimary ?? cluster.isMaster) {
`);
// Le master ne fait RIEN d'autre — pas de require express, mysql, etc.
// Le master ne fait RIEN d'autre — pas de require express, mysql, redis, etc.
return;
}

View file

@ -0,0 +1,69 @@
/**
* Résout l'identité affichable d'un admin/uploader (nom + avatar) à partir
* de son `userId` + `authType` (`'oauth'` ou `'bip-39'` / `'bip39'`).
*
* Priorité :
* 1) `auth.userProfile.username` + `auth.userProfile.avatar` du provider
* OAuth (Discord/Google) le "vrai" nom de la personne, pas le profil
* Movix interne (qui est souvent "Profil" + un avatar Disney random).
* 2) Le profil Movix `isDefault` ou le premier profil pour les comptes
* BIP-39 qui n'ont pas d'identité OAuth.
* 3) Fallback `{ username: 'Admin', avatar: null }`.
*
* Utilisé par les leaderboards Wishboard et Download-links pour éviter
* d'afficher "Admin" partout au lieu des vrais noms.
*/
const { readUserData } = require('../routes/sync');
const DEFAULT = Object.freeze({ username: 'Admin', avatar: null });
function safeParseJson(raw) {
if (typeof raw !== 'string' || !raw.trim()) return null;
try { return JSON.parse(raw); } catch { return null; }
}
/**
* @param {string} userId
* @param {string} authType 'oauth', 'bip39' ou 'bip-39' (DB legacy)
* @returns {Promise<{ username: string, avatar: string | null }>}
*/
async function resolveAdminIdentity(userId, authType) {
if (!userId) return { ...DEFAULT };
const userType = authType === 'bip-39' || authType === 'bip39' ? 'bip39' : 'oauth';
let data;
try {
data = await readUserData(userType, userId);
} catch {
return { ...DEFAULT };
}
if (!data || typeof data !== 'object') return { ...DEFAULT };
// 1) OAuth : nom + avatar du provider (Discord/Google).
const auth = safeParseJson(data.auth);
if (auth?.userProfile?.username) {
return {
username: String(auth.userProfile.username),
avatar: auth.userProfile.avatar ? String(auth.userProfile.avatar) : null,
};
}
// 2) BIP-39 ou OAuth sans `auth.userProfile` : profil Movix par défaut.
const profiles = Array.isArray(data.profiles) ? data.profiles : [];
const defaultProfile = profiles.find((p) => p && p.isDefault) || profiles[0];
if (defaultProfile?.name) {
return {
username: String(defaultProfile.name),
avatar: defaultProfile.avatar ? String(defaultProfile.avatar) : null,
};
}
return { ...DEFAULT };
}
module.exports = {
resolveAdminIdentity,
};

View file

@ -254,16 +254,12 @@ async function axiosDarkinoRequest(config) {
const requestUrl = `${DARKIWORLD_BASE_URL}${config.url}`;
const darkinoRequestHeaders = await buildDarkinoRequestHeaders(config);
const debugDarkino = process.env.DEBUG_DARKINO !== 'false';
const darkinoAuthSnapshot = {
referer: darkinoRequestHeaders.referer,
xsrfToken: truncateForLog(darkinoRequestHeaders['x-xsrf-token'] || '', 200),
cookie: truncateForLog(darkinoRequestHeaders.cookie || '', 500),
cookieLength: (darkinoRequestHeaders.cookie || '').length,
};
if (debugDarkino) {
console.log(`[DARKINO REQUEST][DEBUG] ${String(config.method || 'get').toUpperCase()} ${requestUrl}`, darkinoAuthSnapshot);
}
if (!ENABLE_DARKINO_PROXY) {
// Si le proxy est d\u00e9sactiv\u00e9, faire la requ\u00eate directe
@ -592,7 +588,17 @@ async function axiosFStreamRequest(config) {
return response;
} catch (error) {
const status = error.response?.status;
console.error(`[FSTREAM REQUEST] Erreur ${status || error.code || 'unknown'} avec proxy ${proxyLabel}: ${error.message}`);
const method = (config.method || 'GET').toUpperCase();
const rawUrl = config.url || '';
const isAbs = /^https?:\/\//i.test(rawUrl);
const fullUrl = isAbs
? rawUrl
: (config.baseURL ? `${config.baseURL.replace(/\/$/, '')}/${rawUrl.replace(/^\//, '')}` : rawUrl);
let qs = '';
if (config.params && typeof config.params === 'object') {
try { qs = '?' + new URLSearchParams(config.params).toString(); } catch (_) {}
}
console.error(`[FSTREAM REQUEST] Erreur ${status || error.code || 'unknown'} avec proxy ${proxyLabel}: ${error.message} | ${method} ${fullUrl}${qs}`);
throw error;
}
}

View file

@ -407,6 +407,23 @@ const shouldUpdateCache24h = async (cacheDir, cacheKey) => {
}
};
const shouldUpdateCache48h = async (cacheDir, cacheKey) => {
const cacheFilePath = path.join(cacheDir, `${cacheKey}.json`);
try {
const stats = await fsp.stat(cacheFilePath);
const now = Date.now();
const fileAge = now - stats.mtime.getTime();
const fortyEightHours = 48 * 60 * 60 * 1000;
if (fileAge < fortyEightHours) {
return false;
}
return true;
} catch (error) {
return true;
}
};
module.exports = {
CACHE_DIR,
ANIME_SAMA_CACHE_DIR,
@ -421,6 +438,7 @@ module.exports = {
shouldUpdateCacheFrenchStream,
shouldUpdateCacheLecteurVideo,
shouldUpdateCache24h,
shouldUpdateCache48h,
ongoingFStreamRequests,
getOrCreateFStreamRequest,
saveFStreamToCache,

View file

@ -0,0 +1,461 @@
/**
* Hydracker batch decoding orchestrator (predecode-on-page-load).
*
* /decode/:id at click = cache hit OR direct POST single (cache miss fallback).
* /download/:type/:id triggers predecodePage(entries) in setImmediate after
* res.json that batches all uncached IDs of a download page into one upstream
* POST. Result: most user clicks land on cache hits; the upstream quota is
* burned ~once per page open instead of once per click.
*
* Cache lives ONLY on disk (DOWNLOAD_CACHE_DIR). Redis carries:
* - hydracker:lock:{id} (string, 60s TTL) single-flight per ID
* - hydracker:predecode_lock:{tid}:{S}:{E} (string, 30s TTL) one worker per page
* - hydracker:rate_limited_until (string, EXPIREAT) kill-switch quota
*
* Pivoted on 2026-05-02 from the queue-based design (cf. spec
* docs/superpowers/specs/2026-05-01-hydracker-queue-decoding-design.md, now
* superseded). Kept the pure helpers and Redis primitives from that work; the
* orchestrators (decodeRequest, drainQueueOnce) were replaced by decodeSingle
* and predecodePage.
*/
const fsp = require('fs').promises;
const path = require('path');
// Redis keys
const LOCK_KEY = (id) => `hydracker:lock:${id}`;
const PREDECODE_LOCK_KEY = (titleId, season, episode) =>
`hydracker:predecode_lock:${titleId}:${season || 0}:${episode || 0}`;
const RATE_LIMIT_KEY = 'hydracker:rate_limited_until';
// Tunables
const BATCH_CHUNK_SIZE = 50; // hydracker accepts up to 50 IDs per POST
const LOCK_TTL_SEC = 60; // per-ID single-flight lock TTL
const PREDECODE_LOCK_TTL_SEC = 30; // page-level predecode lock TTL
const FAILED_MARKER_TTL_MS = 2 * 60 * 60 * 1000; // 2h
const STALE_REVALIDATE_MS = 48 * 60 * 60 * 1000; // 48h
// ---------------------------------------------------------------------------
// Pure helpers (no Redis, no fs side-effects)
// ---------------------------------------------------------------------------
function chunk(arr, size) {
if (!Array.isArray(arr) || size <= 0) return [];
const out = [];
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
return out;
}
function buildPayload(id, linkInfo) {
const rawLien = typeof linkInfo?.lien === 'string' ? linkInfo.lien : '';
const provider = /darkibox\.com/i.test(rawLien) ? 'darkibox' : 'direct';
let resolvedUrl;
if (provider === 'darkibox') {
const m = rawLien.match(/darkibox\.com\/(?:embed-)?([a-z0-9]{12,})(?:\.html)?/i);
const code = m ? m[1] : null;
resolvedUrl = code
? `https://darkibox.com/embed-${code}.html`
: (rawLien || `https://darkibox.com/embed-${id}.html`);
} else {
resolvedUrl = rawLien || `https://darkibox.com/embed-${id}.html`;
}
// Sparse linkInfo (empty lien AND no taille) → fallback URL is /embed-{id}.html
// which matches the invalid embed pattern. Return null to signal upstream-failure
// path to callers — distinguishes truly sparse linkInfo from a real file with
// empty lien that carries metadata.
const isInvalidEmbed = /\/embed-\d+\.html$/i.test(resolvedUrl);
if (isInvalidEmbed && !rawLien && linkInfo?.taille === undefined) {
return null;
}
const embedUrlPayload = linkInfo
? { ...linkInfo, lien: resolvedUrl }
: resolvedUrl;
return {
success: true,
id: String(id),
provider,
embed_url: embedUrlPayload,
metadata: linkInfo ? {
language: undefined,
quality: undefined,
sub: undefined,
size: linkInfo?.taille,
upload_date: linkInfo?.created_at
} : null
};
}
function buildFailedMarker(id, errorMsg, debugMsg) {
return {
failed: true,
failedAt: Date.now(),
id: String(id),
error: errorMsg || 'Lien non trouvé ou inaccessible',
debug: debugMsg || ''
};
}
function parseRateLimitError(error) {
const data = error?.response?.data;
if (!data || data.error !== 'daily_api_limit_exceeded') {
return { isRateLimit: false, resetsAt: null };
}
const resetsAtIso = data.resets_at;
const resetsAt = resetsAtIso ? Date.parse(resetsAtIso) : null;
return { isRateLimit: true, resetsAt: Number.isFinite(resetsAt) ? resetsAt : null };
}
function isFailedMarkerActive(payload) {
return Boolean(
payload?.failed === true &&
typeof payload.failedAt === 'number' &&
(Date.now() - payload.failedAt < FAILED_MARKER_TTL_MS)
);
}
// ---------------------------------------------------------------------------
// Redis primitives — coordination only, NEVER cache data here.
// All functions tolerate Redis errors silently so the system degrades
// gracefully when Redis is unavailable.
// ---------------------------------------------------------------------------
async function acquireLock(redis, key, ttlSec) {
if (!redis) return false;
try {
const result = await redis.set(key, String(process.pid), 'NX', 'EX', ttlSec);
return result === 'OK';
} catch (e) { return false; }
}
async function releaseLock(redis, key) {
if (!redis) return;
try { await redis.del(key); }
catch (e) { /* silent */ }
}
async function isRateLimited(redis) {
if (!redis) return false;
try { return Boolean(await redis.exists(RATE_LIMIT_KEY)); }
catch (e) { return false; }
}
async function getRateLimitedUntil(redis) {
if (!redis) return null;
try {
const v = await redis.get(RATE_LIMIT_KEY);
return v ? Number(v) : null;
} catch (e) { return null; }
}
async function armRateLimit(redis, resetsAtMs) {
if (!redis || !resetsAtMs) return;
try {
const ttlSec = Math.max(1, Math.ceil((resetsAtMs - Date.now()) / 1000));
await redis.set(RATE_LIMIT_KEY, String(resetsAtMs), 'EX', ttlSec);
console.warn(`[hydracker] daily quota exhausted, kill-switch armed until ${new Date(resetsAtMs).toISOString()}`);
} catch (e) { /* silent */ }
}
async function readDiskCache(id, { cacheDir, generateCacheKey, getFromCacheNoExpiration }) {
const cacheKey = generateCacheKey(`darkiworld_decode_v2_${id}`);
try {
const payload = await getFromCacheNoExpiration(cacheDir, cacheKey);
if (!payload) return null;
const filePath = path.join(cacheDir, `${cacheKey}.json`);
const stats = await fsp.stat(filePath);
return { payload, mtimeMs: stats.mtime.getTime(), cacheKey };
} catch (e) { return null; }
}
// ---------------------------------------------------------------------------
// decodeSingle — handles a single /decode/:id click. Cache hit returns 200;
// cache miss does a single POST upstream (small fallback for IDs that the
// predecode missed or that are stale). Returns:
// { payload } — 200 OK
// { failed: <marker> } — 404
// { rateLimited: true, retryAt } — 503 rate_limited
// ---------------------------------------------------------------------------
async function decodeSingle(id, deps) {
const {
redis,
cacheDir,
generateCacheKey,
getFromCacheNoExpiration,
shouldUpdateCache48h
} = deps;
// 1. Read disk cache
const cached = await readDiskCache(id, { cacheDir, generateCacheKey, getFromCacheNoExpiration });
if (cached) {
if (isFailedMarkerActive(cached.payload)) {
return { failed: cached.payload };
}
if (cached.payload.success === true) {
const stale = await shouldUpdateCache48h(cacheDir, cached.cacheKey);
if (stale) {
// Background refresh — gate behind per-id lock so concurrent stale reads
// from N workers fire only 1 upstream POST instead of N.
setImmediate(async () => {
const lockKey = LOCK_KEY(id);
const gotLock = await acquireLock(redis, lockKey, LOCK_TTL_SEC);
if (!gotLock) return;
try {
await postSingleAndPersist(id, deps);
} finally {
await releaseLock(redis, lockKey);
}
});
}
return { payload: cached.payload };
}
// Malformed cache — fall through to refetch
}
// 2. Kill-switch
if (await isRateLimited(redis)) {
const retryAt = await getRateLimitedUntil(redis);
if (retryAt) return { rateLimited: true, retryAt };
// retryAt unavailable — fall through to attempt POST
}
// 3. Acquire single-flight lock; if taken, poll cache for the holder's result
const lockKey = LOCK_KEY(id);
const gotLock = await acquireLock(redis, lockKey, LOCK_TTL_SEC);
if (!gotLock) {
// Another worker (or the predecode batch) is decoding this ID. Poll the
// disk cache for up to 30s; the holding worker will write the result.
for (let i = 0; i < 30; i += 1) {
await new Promise((r) => setTimeout(r, 1000));
const recheck = await readDiskCache(id, { cacheDir, generateCacheKey, getFromCacheNoExpiration });
if (recheck) {
if (isFailedMarkerActive(recheck.payload)) return { failed: recheck.payload };
if (recheck.payload.success === true) return { payload: recheck.payload };
}
}
// Timeout — fallthrough; lock should have expired by now (60s TTL).
}
// 4. POST single upstream
try {
return await postSingleAndPersist(id, deps);
} finally {
if (gotLock) await releaseLock(redis, lockKey);
}
}
/**
* Internal helper POSTs a single ID upstream, persists the result to disk,
* returns the decodeSingle result shape. Used by decodeSingle (path 4) and by
* the SWR background refresh (caller already holds the lock there).
*/
async function postSingleAndPersist(id, deps) {
const {
redis,
cacheDir,
generateCacheKey,
getFromCacheNoExpiration,
saveToCache,
axiosDarkinoRequest,
refreshDarkinoSessionIfNeeded
} = deps;
const cacheKey = generateCacheKey(`darkiworld_decode_v2_${id}`);
try {
await refreshDarkinoSessionIfNeeded();
const resp = await axiosDarkinoRequest({
method: 'post',
url: `/api/v1/download-premium/${id}`
});
const linkInfo = resp.data?.liens?.[0] || null;
const payload = buildPayload(id, linkInfo);
if (!payload) {
const marker = buildFailedMarker(id, "Lien d'embed invalide", 'embed-NN.html shape detected');
const existing = await readDiskCache(id, { cacheDir, generateCacheKey, getFromCacheNoExpiration });
if (!existing) {
await saveToCache(cacheDir, cacheKey, marker).catch((cacheErr) => {
console.warn(`[hydracker] failure marker write failed for ${id}:`, cacheErr?.message);
});
}
return { failed: marker };
}
await saveToCache(cacheDir, cacheKey, payload).catch((cacheErr) => {
console.warn(`[hydracker] payload write failed for ${id}:`, cacheErr?.message);
});
return { payload };
} catch (err) {
const rl = parseRateLimitError(err);
if (rl.isRateLimit) {
await armRateLimit(redis, rl.resetsAt);
return { rateLimited: true, retryAt: rl.resetsAt };
}
const marker = buildFailedMarker(id, 'Lien non trouvé ou inaccessible', err?.message || '');
const existing = await readDiskCache(id, { cacheDir, generateCacheKey, getFromCacheNoExpiration });
if (!existing) {
await saveToCache(cacheDir, cacheKey, marker).catch((cacheErr) => {
console.warn(`[hydracker] failure marker write failed for ${id}:`, cacheErr?.message);
});
}
return { failed: marker };
}
}
// ---------------------------------------------------------------------------
// predecodePage — fire-and-forget batch decoding for a download page.
//
// Called from /download/:type/:id route AFTER res.json() has been sent.
// Errors are logged but never propagate. Designed to be wrapped in
// setImmediate(...) so it never blocks the response.
//
// Skips IDs already in disk cache (success or active failed marker), already
// in-flight (per-id lock taken), or upstream-rate-limited. Reserves the
// remaining IDs via per-id locks, batches them in chunks of 50, POSTs to
// hydracker, distributes results to disk, releases locks.
// ---------------------------------------------------------------------------
async function predecodePage({ entries, titleId, season, episode }, deps) {
const {
redis,
cacheDir,
generateCacheKey,
getFromCacheNoExpiration,
saveToCache,
shouldUpdateCache48h,
axiosDarkinoRequest,
refreshDarkinoSessionIfNeeded
} = deps;
if (!Array.isArray(entries) || entries.length === 0) return;
if (!titleId) return;
// 1. Page-level lock — prevents 2 workers from pre-decoding the same page
const pageLockKey = PREDECODE_LOCK_KEY(titleId, season, episode);
const gotPageLock = await acquireLock(redis, pageLockKey, PREDECODE_LOCK_TTL_SEC);
if (!gotPageLock) return;
try {
// 2. Kill-switch — skip if upstream is rate-limited
if (await isRateLimited(redis)) return;
// 3. Filter IDs needing decode + reserve them via per-id locks
const idsToBatch = [];
for (const entry of entries) {
if (!entry || entry.id == null) continue;
// Skip if entry already has a direct lien (resolved by /content/liens)
if (typeof entry.lien === 'string' && entry.lien.length > 0) continue;
// Skip if cache hit AND fresh AND not a failed marker
const cached = await readDiskCache(entry.id, { cacheDir, generateCacheKey, getFromCacheNoExpiration });
if (cached) {
if (isFailedMarkerActive(cached.payload)) continue;
if (cached.payload.success === true) {
const stale = await shouldUpdateCache48h(cacheDir, cached.cacheKey);
if (!stale) continue;
// Stale, include in batch to refresh
}
}
// Reserve via per-id lock
const got = await acquireLock(redis, LOCK_KEY(entry.id), LOCK_TTL_SEC);
if (!got) continue; // another worker holds this id
idsToBatch.push(String(entry.id));
}
if (idsToBatch.length === 0) return;
// 4. Chunked batch POST (50 IDs per POST per upstream limit)
const chunks = chunk(idsToBatch, BATCH_CHUNK_SIZE);
for (const ids of chunks) {
try {
await refreshDarkinoSessionIfNeeded();
const resp = await axiosDarkinoRequest({
method: 'post',
url: `/api/v1/download-premium/${ids.join(',')}`
});
const liens = Array.isArray(resp.data?.liens) ? resp.data.liens : [];
const byId = new Map();
for (const li of liens) {
if (li && li.id != null) byId.set(String(li.id), li);
}
for (const id of ids) {
const linkInfo = byId.get(String(id)) || null;
const cacheKey = generateCacheKey(`darkiworld_decode_v2_${id}`);
if (!linkInfo) {
const marker = buildFailedMarker(id, 'Absent de la réponse batch hydracker', '');
await saveToCache(cacheDir, cacheKey, marker).catch((cacheErr) => {
console.warn(`[hydracker] failure marker write failed for ${id}:`, cacheErr?.message);
});
} else {
const payload = buildPayload(id, linkInfo);
if (!payload) {
const marker = buildFailedMarker(id, "Lien d'embed invalide", 'embed-NN.html shape detected');
const existing = await readDiskCache(id, { cacheDir, generateCacheKey, getFromCacheNoExpiration });
if (!existing) {
await saveToCache(cacheDir, cacheKey, marker).catch((cacheErr) => {
console.warn(`[hydracker] failure marker write failed for ${id}:`, cacheErr?.message);
});
}
} else {
await saveToCache(cacheDir, cacheKey, payload).catch((cacheErr) => {
console.warn(`[hydracker] payload write failed for ${id}:`, cacheErr?.message);
});
}
}
await releaseLock(redis, LOCK_KEY(id));
}
} catch (err) {
const rl = parseRateLimitError(err);
if (rl.isRateLimit) {
await armRateLimit(redis, rl.resetsAt);
for (const id of ids) await releaseLock(redis, LOCK_KEY(id));
return; // skip remaining chunks
}
console.warn(`[hydracker] predecode chunk failed (${ids.length} ids):`, err?.message || err);
for (const id of ids) await releaseLock(redis, LOCK_KEY(id));
// continue with next chunk
}
}
} finally {
await releaseLock(redis, pageLockKey);
}
}
module.exports = {
// pure helpers
chunk,
buildPayload,
buildFailedMarker,
parseRateLimitError,
isFailedMarkerActive,
// Redis primitives
acquireLock,
releaseLock,
isRateLimited,
getRateLimitedUntil,
armRateLimit,
// disk cache
readDiskCache,
// orchestrators
decodeSingle,
predecodePage,
postSingleAndPersist,
// constants
LOCK_KEY,
PREDECODE_LOCK_KEY,
RATE_LIMIT_KEY,
BATCH_CHUNK_SIZE,
LOCK_TTL_SEC,
PREDECODE_LOCK_TTL_SEC,
FAILED_MARKER_TTL_MS,
STALE_REVALIDATE_MS
};

View file

@ -0,0 +1,529 @@
/**
* Hydracker queue-based decoding orchestrator.
*
* /decode/:id at click = cache hit OR SADD queue + 202 (frontend retry).
* Drain worker in worker process: when queue size >= 50, SPOP 50 atomically,
* batch POST hydracker, persist results to disk cache, release lock.
*
* Cache lives ONLY on disk (DOWNLOAD_CACHE_DIR). Redis carries:
* - hydracker:queue:pending (SET, dedup-safe)
* - hydracker:worker_lock (string, 60s TTL)
* - hydracker:rate_limited_until (string, EXPIREAT)
*
* See: docs/superpowers/specs/2026-05-01-hydracker-queue-decoding-design.md
*/
const fsp = require('fs').promises;
const path = require('path');
// Redis keys
const QUEUE_KEY = 'hydracker:queue:pending';
const WORKER_LOCK_KEY = 'hydracker:worker_lock';
const RATE_LIMIT_KEY = 'hydracker:rate_limited_until';
// Tunables
// HYDRACKER_BATCHING_ENABLED=false → bypass complet : la route /decode/:id
// utilise decodeRequestSync (POST direct, pas de Redis, pas de queue) et
// le drain timer dans app.js est skip. Comportement identique à l'ancien
// code synchrone, avant l'introduction de la queue.
const BATCHING_ENABLED = process.env.HYDRACKER_BATCHING_ENABLED !== 'false';
const BATCH_SIZE = 50;
const WORKER_LOCK_TTL_SEC = 60;
const FAILED_MARKER_TTL_MS = 2 * 60 * 60 * 1000; // 2h
// Bumped 48h → 7d : un lien déjà en cache reste servi sans refetch pendant
// 7 jours, ce qui réduit la pression sur hydracker pour des contenus stables.
const STALE_REVALIDATE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
// ---------------------------------------------------------------------------
// Pure helpers (no Redis, no fs side-effects)
// ---------------------------------------------------------------------------
function chunk(arr, size) {
if (!Array.isArray(arr) || size <= 0) return [];
const out = [];
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
return out;
}
function buildPayload(id, linkInfo) {
const rawLien = typeof linkInfo?.lien === 'string' ? linkInfo.lien : '';
const provider = /darkibox\.com/i.test(rawLien) ? 'darkibox' : 'direct';
let resolvedUrl;
if (provider === 'darkibox') {
const m = rawLien.match(/darkibox\.com\/(?:embed-)?([a-z0-9]{12,})(?:\.html)?/i);
const code = m ? m[1] : null;
resolvedUrl = code
? `https://darkibox.com/embed-${code}.html`
: (rawLien || `https://darkibox.com/embed-${id}.html`);
} else {
resolvedUrl = rawLien || `https://darkibox.com/embed-${id}.html`;
}
// Sparse linkInfo (empty lien AND no taille) → fallback URL is /embed-{id}.html
// which matches the invalid embed pattern. Return null to signal upstream-failure
// path to callers — distinguishes truly sparse linkInfo from a real file with
// empty lien that carries metadata.
const isInvalidEmbed = /\/embed-\d+\.html$/i.test(resolvedUrl);
if (isInvalidEmbed && !rawLien && linkInfo?.taille === undefined) {
return null;
}
const embedUrlPayload = linkInfo
? { ...linkInfo, lien: resolvedUrl }
: resolvedUrl;
return {
success: true,
id: String(id),
provider,
embed_url: embedUrlPayload,
metadata: linkInfo ? {
language: undefined,
quality: undefined,
sub: undefined,
size: linkInfo?.taille,
upload_date: linkInfo?.created_at
} : null
};
}
function buildFailedMarker(id, errorMsg, debugMsg) {
return {
failed: true,
failedAt: Date.now(),
id: String(id),
error: errorMsg || 'Lien non trouvé ou inaccessible',
debug: debugMsg || ''
};
}
function parseRateLimitError(error) {
const data = error?.response?.data;
if (!data || data.error !== 'daily_api_limit_exceeded') {
return { isRateLimit: false, resetsAt: null };
}
const resetsAtIso = data.resets_at;
const resetsAt = resetsAtIso ? Date.parse(resetsAtIso) : null;
return { isRateLimit: true, resetsAt: Number.isFinite(resetsAt) ? resetsAt : null };
}
function isFailedMarkerActive(payload) {
return Boolean(
payload?.failed === true &&
typeof payload.failedAt === 'number' &&
(Date.now() - payload.failedAt < FAILED_MARKER_TTL_MS)
);
}
// ---------------------------------------------------------------------------
// Redis primitives — coordination only, NEVER cache data here.
// All functions tolerate Redis errors silently so the system degrades
// gracefully when Redis is unavailable.
// ---------------------------------------------------------------------------
async function enqueueId(redis, id) {
if (!redis) return false;
const idStr = id == null ? '' : String(id);
if (!idStr) return false;
try {
await redis.sadd(QUEUE_KEY, idStr);
return true;
} catch (e) { return false; }
}
async function getQueueSize(redis) {
if (!redis) return 0;
try { return await redis.scard(QUEUE_KEY); }
catch (e) { return 0; }
}
async function popBatch(redis, size) {
if (!redis) return [];
try {
const ids = await redis.spop(QUEUE_KEY, size);
return Array.isArray(ids) ? ids : [];
} catch (e) { return []; }
}
async function requeueIds(redis, ids) {
if (!redis || !Array.isArray(ids) || ids.length === 0) return;
try { await redis.sadd(QUEUE_KEY, ...ids.map(String)); }
catch (e) { /* silent */ }
}
async function isRateLimited(redis) {
if (!redis) return false;
try { return Boolean(await redis.exists(RATE_LIMIT_KEY)); }
catch (e) { return false; }
}
async function getRateLimitedUntil(redis) {
if (!redis) return null;
try {
const v = await redis.get(RATE_LIMIT_KEY);
return v ? Number(v) : null;
} catch (e) { return null; }
}
async function armRateLimit(redis, resetsAtMs) {
if (!redis || !resetsAtMs) return;
try {
const ttlSec = Math.max(1, Math.ceil((resetsAtMs - Date.now()) / 1000));
await redis.set(RATE_LIMIT_KEY, String(resetsAtMs), 'EX', ttlSec);
console.warn(`[hydracker] daily quota exhausted, kill-switch armed until ${new Date(resetsAtMs).toISOString()}`);
} catch (e) { /* silent */ }
}
async function acquireWorkerLock(redis) {
if (!redis) return false;
try {
const result = await redis.set(WORKER_LOCK_KEY, String(process.pid), 'NX', 'EX', WORKER_LOCK_TTL_SEC);
return result === 'OK';
} catch (e) { return false; }
}
async function releaseWorkerLock(redis) {
if (!redis) return;
try { await redis.del(WORKER_LOCK_KEY); }
catch (e) { /* silent */ }
}
async function readDiskCache(id, { cacheDir, generateCacheKey, getFromCacheNoExpiration }) {
const cacheKey = generateCacheKey(`darkiworld_decode_v2_${id}`);
try {
const payload = await getFromCacheNoExpiration(cacheDir, cacheKey);
if (!payload) return null;
const filePath = path.join(cacheDir, `${cacheKey}.json`);
const stats = await fsp.stat(filePath);
return { payload, mtimeMs: stats.mtime.getTime(), cacheKey };
} catch (e) { return null; }
}
// ---------------------------------------------------------------------------
// decodeRequest — handles a single /decode/:id click from the route handler.
// Never POSTs upstream; just reads cache or enqueues. Returns:
// { payload } — 200 OK
// { failed: <marker> } — 404
// { queued: true, queue_size: N } — 202 (frontend retries)
// { rateLimited: true, retryAt } — 503 rate_limited
// { unavailable: true } — 503 queue_unavailable (Redis down at enqueue)
// ---------------------------------------------------------------------------
async function decodeRequest(id, deps) {
const {
redis,
cacheDir,
generateCacheKey,
getFromCacheNoExpiration
} = deps;
// 1. Read disk cache
const cached = await readDiskCache(id, { cacheDir, generateCacheKey, getFromCacheNoExpiration });
if (cached) {
if (isFailedMarkerActive(cached.payload)) {
return { failed: cached.payload };
}
if (cached.payload.success === true) {
// Stale check (STALE_REVALIDATE_MS = 7d) — return immediately, optionally
// enqueue for refresh. mtimeMs vient de readDiskCache, pas de re-stat.
const stale = (Date.now() - cached.mtimeMs) >= STALE_REVALIDATE_MS;
if (stale) {
// Refresh asynchronously by adding to the queue.
// No await — fire-and-forget, this is best-effort.
enqueueId(redis, id).catch(() => {});
}
return { payload: cached.payload };
}
// Malformed cache — fall through to enqueue
}
// 2. Kill-switch check
if (await isRateLimited(redis)) {
const retryAt = await getRateLimitedUntil(redis);
if (retryAt) {
return { rateLimited: true, retryAt };
}
// retryAt unavailable (Redis flake, key just expired) — fall through to enqueue
// rather than serving an undefined retry hint to the user.
}
// 3. Enqueue (or fail fast if Redis is unavailable)
const enqueued = await enqueueId(redis, id);
if (!enqueued) {
// Redis unreachable — caller can't queue, so polling would never succeed.
// Surface as a 503 with a distinct error code so the frontend can show a
// clear "infra issue, retry later" message rather than spinning 5 min.
return { unavailable: true };
}
const queue_size = await getQueueSize(redis);
return { queued: true, queue_size };
}
// ---------------------------------------------------------------------------
// decodeRequestSync — bypass mode (HYDRACKER_BATCHING_ENABLED=false).
// POST hydracker directement pour un ID, pas de Redis, pas de queue.
// Comportement identique à l'ancien code synchrone d'avant la queue.
// Returns:
// { payload } — 200 OK
// { failed: <marker> } — 404
// ---------------------------------------------------------------------------
async function decodeRequestSync(id, deps) {
const {
cacheDir,
generateCacheKey,
getFromCacheNoExpiration,
saveToCache,
axiosDarkinoRequest,
refreshDarkinoSessionIfNeeded
} = deps;
// 1. Read disk cache
const cached = await readDiskCache(id, { cacheDir, generateCacheKey, getFromCacheNoExpiration });
if (cached) {
if (isFailedMarkerActive(cached.payload)) {
return { failed: cached.payload };
}
if (cached.payload.success === true) {
return { payload: cached.payload };
}
// Malformed cache — fall through to refetch
}
const cacheKey = generateCacheKey(`darkiworld_decode_v2_${id}`);
try {
await refreshDarkinoSessionIfNeeded();
const resp = await axiosDarkinoRequest({
method: 'post',
url: `/api/v1/download-premium/${id}`
});
const linkInfo = resp.data?.liens?.[0] || null;
if (!linkInfo) {
const marker = buildFailedMarker(id, 'Lien non trouvé', 'download-premium response empty');
await saveToCache(cacheDir, cacheKey, marker).catch(() => {});
return { failed: marker };
}
const payload = buildPayload(id, linkInfo);
if (!payload) {
const marker = buildFailedMarker(id, 'Lien d\'embed invalide', 'embed-NN.html shape detected');
await saveToCache(cacheDir, cacheKey, marker).catch(() => {});
return { failed: marker };
}
await saveToCache(cacheDir, cacheKey, payload).catch(() => {});
return { payload };
} catch (err) {
const marker = buildFailedMarker(id, 'Erreur upstream hydracker', err?.message || String(err));
// Ne pas écrire de marker si un cache existe déjà — évite de l'empoisonner.
if (!cached) {
await saveToCache(cacheDir, cacheKey, marker).catch(() => {});
}
return { failed: marker };
}
}
// ---------------------------------------------------------------------------
// drainQueueOnce — one tick of the drain loop. Returns:
// { drained: false, reason: 'rate_limited' | 'queue_too_small' | 'lock_taken' }
// { drained: true, batchSize: N }
// { drained: false, error: <msg> }
//
// Caller (timer in every HTTP worker process) invokes this every ~5s; only one worker drains per tick via Redis lock.
// ---------------------------------------------------------------------------
async function drainQueueOnce(deps) {
const {
redis,
cacheDir,
generateCacheKey,
getFromCacheNoExpiration,
saveToCache,
axiosDarkinoRequest,
refreshDarkinoSessionIfNeeded
} = deps;
// 1. Skip if rate-limited
if (await isRateLimited(redis)) {
return { drained: false, reason: 'rate_limited' };
}
// 2. Skip if queue too small
const sizeBefore = await getQueueSize(redis);
if (sizeBefore < BATCH_SIZE) {
return { drained: false, reason: 'queue_too_small', queueSize: sizeBefore };
}
// 3. Try to acquire worker lock
const gotLock = await acquireWorkerLock(redis);
if (!gotLock) return { drained: false, reason: 'lock_taken' };
let popped = [];
try {
// 4. Re-check size after lock (anti-race)
const sizeAfter = await getQueueSize(redis);
if (sizeAfter < BATCH_SIZE) {
return { drained: false, reason: 'queue_too_small', queueSize: sizeAfter };
}
// 5. SPOP 50 atomically
popped = await popBatch(redis, BATCH_SIZE);
if (popped.length === 0) {
return { drained: false, reason: 'queue_empty_after_pop' };
}
// 6. POST hydracker
await refreshDarkinoSessionIfNeeded();
const resp = await axiosDarkinoRequest({
method: 'post',
url: `/api/v1/download-premium/${popped.join(',')}`
});
// 7. Distribute results to disk cache
const liens = Array.isArray(resp.data?.liens) ? resp.data.liens : [];
const byId = new Map();
for (const li of liens) {
if (li && li.id != null) byId.set(String(li.id), li);
}
for (const id of popped) {
const cacheKey = generateCacheKey(`darkiworld_decode_v2_${id}`);
const linkInfo = byId.get(String(id)) || null;
if (!linkInfo) {
// Absent from response → write failure marker
const marker = buildFailedMarker(id, 'Absent de la réponse batch hydracker', '');
await saveToCache(cacheDir, cacheKey, marker).catch((cacheErr) => {
console.warn(`[hydracker] failure marker write failed for ${id}:`, cacheErr?.message);
});
} else {
const payload = buildPayload(id, linkInfo);
if (!payload) {
// Invalid embed shape → write failure marker (only if no prior cache)
const existing = await readDiskCache(id, { cacheDir, generateCacheKey, getFromCacheNoExpiration });
if (!existing) {
const marker = buildFailedMarker(id, 'Lien d\'embed invalide', 'embed-NN.html shape detected');
await saveToCache(cacheDir, cacheKey, marker).catch((cacheErr) => {
console.warn(`[hydracker] failure marker write failed for ${id}:`, cacheErr?.message);
});
}
} else {
await saveToCache(cacheDir, cacheKey, payload).catch((cacheErr) => {
console.warn(`[hydracker] payload write failed for ${id}:`, cacheErr?.message);
});
}
}
}
return { drained: true, batchSize: popped.length };
} catch (err) {
console.warn(`[hydracker] drain failed (popped=${popped.length}):`, err?.message || err);
// 8. Error handling — requeue popped IDs so nothing is lost
const rl = parseRateLimitError(err);
if (rl.isRateLimit) {
await armRateLimit(redis, rl.resetsAt);
await requeueIds(redis, popped);
return { drained: false, reason: 'rate_limited', requeued: popped.length };
}
// Other error
await requeueIds(redis, popped);
return { drained: false, error: err?.message || String(err), requeued: popped.length };
} finally {
await releaseWorkerLock(redis);
}
}
// ---------------------------------------------------------------------------
// prewarmDecodeCache — call the new hydracker /download endpoint and seed the
// disk decode cache for every entry that carries a direct `lien`. Best-effort:
// silent on failure, never writes a failed marker. Triggered in parallel with
// the existing /content/liens fetch in routes/darkiworld.js (cache miss path).
// ---------------------------------------------------------------------------
async function prewarmDecodeCache({ type, id, season, episode, deps }) {
const {
axiosDarkinoRequest,
refreshDarkinoSessionIfNeeded,
cacheDir,
generateCacheKey,
saveToCache,
blockedUsers
} = deps;
const blocked = blockedUsers instanceof Set ? blockedUsers : new Set();
try { await refreshDarkinoSessionIfNeeded(); } catch (_) { /* non-fatal */ }
const url = type === 'movie'
? `/api/v1/titles/${id}/download`
: `/api/v1/titles/${id}/season/${season}/episode/${episode}/download`;
let resp;
try {
resp = await axiosDarkinoRequest({ method: 'get', url });
} catch (e) {
console.warn(`[hydracker] prewarm upstream fail ${type}/${id}: ${e?.message || e}`);
return { warmed: 0, warmedIds: new Set() };
}
const entries = [
resp.data?.video,
...(resp.data?.alternative_videos || [])
].filter(Boolean);
const warmedIds = new Set();
const seen = new Set();
for (const entry of entries) {
if (entry.id == null) continue;
const idKey = String(entry.id);
if (seen.has(idKey)) continue;
if (typeof entry.lien !== 'string' || entry.lien.trim() === '') continue;
if (entry.id_user && blocked.has(entry.id_user)) continue;
seen.add(idKey);
const payload = buildPayload(entry.id, entry);
if (!payload) continue;
const cacheKey = generateCacheKey(`darkiworld_decode_v2_${entry.id}`);
await saveToCache(cacheDir, cacheKey, payload).catch(() => {});
warmedIds.add(idKey);
}
return { warmed: warmedIds.size, warmedIds };
}
module.exports = {
// helpers
chunk,
buildPayload,
buildFailedMarker,
parseRateLimitError,
isFailedMarkerActive,
// Redis primitives
enqueueId,
getQueueSize,
popBatch,
requeueIds,
isRateLimited,
getRateLimitedUntil,
armRateLimit,
acquireWorkerLock,
releaseWorkerLock,
// orchestrators
decodeRequest,
decodeRequestSync,
drainQueueOnce,
prewarmDecodeCache,
// disk cache
readDiskCache,
// constants
QUEUE_KEY,
WORKER_LOCK_KEY,
RATE_LIMIT_KEY,
BATCH_SIZE,
BATCHING_ENABLED,
WORKER_LOCK_TTL_SEC,
FAILED_MARKER_TTL_MS,
STALE_REVALIDATE_MS
};

View file

@ -1,17 +1,63 @@
const fs = require('fs');
const path = require('path');
/**
* Source de vérité = la table `oauth_clients` (cache en mémoire alimenté
* au boot par `oauthClientsDb.reloadCache()`). On garde l'API synchrone
* historique (`loadOAuthClients()`, `getOAuthClient()`) pour ne pas avoir
* à toucher aux 30+ call sites.
*
* L'env `MOVIX_OAUTH_CLIENTS_JSON` reste supportée en surcouche (dev local
* uniquement) ; le fichier `data/oauth-clients.json` n'est plus lu une fois
* la migration vers DB effectuée (il est archivé en `.migrated`).
*/
const { getCachedClients } = require('./oauthClientsDb');
const OAUTH_CLIENTS_FILE = path.join(__dirname, '..', 'data', 'oauth-clients.json');
const OAUTH_CLIENTS_ENV = 'MOVIX_OAUTH_CLIENTS_JSON';
const KNOWN_OAUTH_SCOPES = ['profile.read', 'profile.list', 'profile.manage', 'vip.read', 'vip.manage'];
const KNOWN_OAUTH_SCOPES = [
// Compte / profils
'profile.read',
'profile.list',
'profile.manage',
// VIP
'vip.read',
'vip.manage',
// Émission de jours VIP par l'app (depuis son balance admin-alimenté).
'vip.grant',
// Favoris (1 read + 2 write granulaires)
'favorites.read',
'favorites.add',
'favorites.remove',
// Listes personnalisées (1 read + 5 write granulaires)
'lists.read',
'lists.create',
'lists.rename',
'lists.delete',
'lists.add-item',
'lists.remove-item',
// Watchlist (1 read + 2 write granulaires)
'watchlist.read',
'watchlist.add',
'watchlist.remove',
// Historique (films/séries marqués comme vus)
'history.read',
'history.add',
'history.remove',
// Continue watching (reprise en cours)
'continue-watching.read',
// Notifications / alertes nouvelles saisons
'alerts.read',
'alerts.manage',
// Notes personnelles (1-10) + texte facultatif
'ratings.read',
'ratings.manage',
];
const DEFAULT_SCOPE = 'profile.read';
const OAUTH_DEBUG_ENABLED = process.env.MOVIX_OAUTH_DEBUG === 'true';
let cache = {
fileMtimeMs: -1,
envRaw: null,
clients: [],
};
// Préfixe public servant les icônes d'apps (relatif à l'API : `/oauth-icons/<filename>`).
// Si tu sers via un CDN, set OAUTH_ICON_PUBLIC_BASE_URL.
const OAUTH_ICON_PUBLIC_BASE_URL = (
process.env.OAUTH_ICON_PUBLIC_BASE_URL || '/oauth-icons'
).replace(/\/+$/, '');
function safeJsonParse(rawValue, fallback) {
if (typeof rawValue !== 'string' || !rawValue.trim()) {
@ -128,6 +174,17 @@ function normalizeScopes(rawScopes) {
);
}
function buildIconUrl(iconFilename) {
if (typeof iconFilename !== 'string' || !iconFilename.trim()) {
return null;
}
// L'iconFilename est juste le basename — pas de path traversal possible
// (validé au moment du upload côté route admin).
const safeName = iconFilename.trim().replace(/[^a-zA-Z0-9._-]/g, '');
if (!safeName) return null;
return `${OAUTH_ICON_PUBLIC_BASE_URL}/${safeName}`;
}
function normalizeClient(rawClient) {
if (!rawClient || typeof rawClient !== 'object' || Array.isArray(rawClient)) {
return null;
@ -152,7 +209,10 @@ function normalizeClient(rawClient) {
const requirePkce = rawClient.requirePkce === true || publicClient;
const allowedScopes = normalizeScopes(rawClient.allowedScopes);
const homepageUrl = normalizeHttpUrl(rawClient.homepageUrl);
// Compat ascendante : l'ancien JSON avait `logoUrl` (URL absolue), la
// nouvelle DB a `iconFilename` (basename). On expose les deux.
const logoUrl = normalizeHttpUrl(rawClient.logoUrl);
const iconUrl = buildIconUrl(rawClient.iconFilename) || logoUrl;
const description = typeof rawClient.description === 'string' && rawClient.description.trim()
? rawClient.description.trim()
: null;
@ -167,69 +227,30 @@ function normalizeClient(rawClient) {
allowedScopes: allowedScopes.length > 0 ? allowedScopes : [DEFAULT_SCOPE],
homepageUrl,
logoUrl,
iconUrl,
iconFilename: typeof rawClient.iconFilename === 'string' ? rawClient.iconFilename : null,
description,
vipDaysBalance: Number.isFinite(rawClient.vipDaysBalance) ? Number(rawClient.vipDaysBalance) : 0,
};
}
function readClientsFile() {
try {
if (!fs.existsSync(OAUTH_CLIENTS_FILE)) {
return [];
}
const fileContent = fs.readFileSync(OAUTH_CLIENTS_FILE, 'utf8');
const parsed = safeJsonParse(fileContent, []);
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
console.error('[OAuth Clients] Failed to read oauth-clients.json:', error.message || error);
return [];
}
}
function getClientsFileMtimeMs() {
try {
if (!fs.existsSync(OAUTH_CLIENTS_FILE)) {
return -1;
}
return fs.statSync(OAUTH_CLIENTS_FILE).mtimeMs || -1;
} catch {
return -1;
}
}
function loadOAuthClients() {
// Source 1: env var (override dev/test).
const envRaw = process.env[OAUTH_CLIENTS_ENV] || '';
const fileMtimeMs = getClientsFileMtimeMs();
const fromEnv = envRaw ? safeJsonParse(envRaw, []) : [];
if (cache.envRaw === envRaw && cache.fileMtimeMs === fileMtimeMs) {
return cache.clients;
}
const fromEnv = safeJsonParse(envRaw, []);
const fromFile = readClientsFile();
const mergedSources = [
...(Array.isArray(fromEnv) ? fromEnv : []),
...(Array.isArray(fromFile) ? fromFile : []),
];
// Source 2: DB cache (source de vérité prod).
const fromDb = getCachedClients() || [];
const byClientId = new Map();
mergedSources.forEach((entry) => {
// L'env override la DB (utile pour les tests E2E qui injectent un client éphémère).
[...(Array.isArray(fromDb) ? fromDb : []), ...(Array.isArray(fromEnv) ? fromEnv : [])].forEach((entry) => {
const normalized = normalizeClient(entry);
if (!normalized) {
return;
}
if (!normalized) return;
byClientId.set(normalized.clientId, normalized);
});
cache = {
envRaw,
fileMtimeMs,
clients: Array.from(byClientId.values()),
};
return cache.clients;
return Array.from(byClientId.values());
}
function getOAuthClient(clientId) {
@ -252,6 +273,7 @@ function getOAuthClientPublicMetadata(client) {
description: client.description,
homepageUrl: client.homepageUrl,
logoUrl: client.logoUrl,
iconUrl: client.iconUrl,
publicClient: client.publicClient,
requirePkce: client.requirePkce,
allowedScopes: [...client.allowedScopes],

View file

@ -0,0 +1,344 @@
/**
* Stockage DB des clients OAuth + stats + grants VIP. Remplace le fichier
* `data/oauth-clients.json` (déprécié migration auto au boot).
*
* Les autres modules continuent d'appeler `loadOAuthClients()` (sync) de
* `oauthClients.js`, qui lit depuis le cache pré-warmé par les fonctions
* async ci-dessous.
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { getPool } = require('../mysqlPool');
const SCHEMA_PATH = path.join(__dirname, '..', 'exportscripts', 'add_oauth_apps_tables.sql');
const LEGACY_JSON_PATH = path.join(__dirname, '..', 'data', 'oauth-clients.json');
const ICON_DIR = path.join(__dirname, '..', 'public', 'oauth-icons');
// Cache en mémoire : refresh par invalidate() ou refresh périodique.
let memCache = {
loadedAt: 0,
clients: [],
};
const KNOWN_OAUTH_SCOPES_SET = new Set([
'profile.read',
'profile.list',
'profile.manage',
'vip.read',
'vip.manage',
'vip.grant',
'favorites.read',
'favorites.add',
'favorites.remove',
'lists.read',
'lists.create',
'lists.rename',
'lists.delete',
'lists.add-item',
'lists.remove-item',
'watchlist.read',
'watchlist.add',
'watchlist.remove',
'history.read',
'history.add',
'history.remove',
'continue-watching.read',
'alerts.read',
'alerts.manage',
'ratings.read',
'ratings.manage',
]);
/** Strip les commentaires `-- ` ligne par ligne avant le split.
* Note : ne gère pas `/* … *\/` mais le schéma n'en utilise pas. */
function stripSqlLineComments(sqlText) {
return sqlText
.split('\n')
.filter((line) => !line.trim().startsWith('--'))
.join('\n');
}
/** Crée les tables si elles n'existent pas (idempotent). */
async function ensureTables() {
const pool = getPool();
if (!pool) throw new Error('MySQL pool not ready');
if (!fs.existsSync(SCHEMA_PATH)) return;
// On strip d'abord TOUS les commentaires ligne `-- …` puis on split sur `;`.
// Sans le strip, le premier statement embarquait le header de commentaires
// du fichier et était filtré par `!startsWith('--')` → aucune table créée
// et le INSERT migrate plantait sur "Table 'oauth_clients' doesn't exist".
const sql = stripSqlLineComments(fs.readFileSync(SCHEMA_PATH, 'utf-8'));
const statements = sql
.split(';')
.map((s) => s.trim())
.filter((s) => s.length > 0);
for (const stmt of statements) {
await pool.query(stmt);
}
// Crée aussi le dossier oauth-icons s'il n'existe pas.
if (!fs.existsSync(ICON_DIR)) {
fs.mkdirSync(ICON_DIR, { recursive: true, mode: 0o755 });
}
}
/** Import unique du JSON legacy vers DB. Idempotent : skip si déjà importé. */
async function migrateLegacyJsonIfNeeded() {
const pool = getPool();
if (!pool) return;
if (!fs.existsSync(LEGACY_JSON_PATH)) return;
const [rows] = await pool.execute('SELECT COUNT(*) AS n FROM oauth_clients');
const existing = Number(rows[0]?.n || 0);
if (existing > 0) {
// Migration déjà faite : on archive le JSON et on continue.
try {
const archivePath = LEGACY_JSON_PATH + '.migrated';
if (!fs.existsSync(archivePath)) {
fs.renameSync(LEGACY_JSON_PATH, archivePath);
console.log('[OAuth Clients DB] Archived legacy JSON to', archivePath);
}
} catch (err) {
console.warn('[OAuth Clients DB] Could not archive legacy JSON:', err.message);
}
return;
}
try {
const content = fs.readFileSync(LEGACY_JSON_PATH, 'utf-8');
const parsed = JSON.parse(content);
if (!Array.isArray(parsed)) return;
const now = Date.now();
for (const entry of parsed) {
if (!entry || typeof entry !== 'object') continue;
const clientId = String(entry.clientId || '').trim();
const clientName = String(entry.clientName || '').trim();
if (!clientId || !clientName) continue;
const redirectUris = Array.isArray(entry.redirectUris) ? entry.redirectUris : [];
const allowedScopes = Array.isArray(entry.allowedScopes) ? entry.allowedScopes : [];
const description = entry.description ? String(entry.description) : null;
const homepageUrl = entry.homepageUrl ? String(entry.homepageUrl) : null;
const publicClient = entry.publicClient === false ? 0 : 1;
const requirePkce = entry.requirePkce === false ? 0 : 1;
const clientSecret = entry.clientSecret ? String(entry.clientSecret) : null;
await pool.execute(
`INSERT INTO oauth_clients
(client_id, client_name, description, homepage_url, redirect_uris,
allowed_scopes, public_client, require_pkce, client_secret,
is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
ON DUPLICATE KEY UPDATE updated_at = VALUES(updated_at)`,
[
clientId,
clientName,
description,
homepageUrl,
JSON.stringify(redirectUris),
JSON.stringify(allowedScopes),
publicClient,
requirePkce,
clientSecret,
now,
now,
],
);
}
console.log('[OAuth Clients DB] Migrated', parsed.length, 'client(s) from JSON to MySQL');
// Archive le JSON
try {
fs.renameSync(LEGACY_JSON_PATH, LEGACY_JSON_PATH + '.migrated');
} catch {
/* ignore */
}
} catch (err) {
console.error('[OAuth Clients DB] Legacy migration failed:', err.message);
}
}
function safeParseJson(raw, fallback) {
if (typeof raw !== 'string' || !raw.trim()) {
return Array.isArray(raw) ? raw : fallback;
}
try {
return JSON.parse(raw);
} catch {
return fallback;
}
}
function rowToClient(row) {
return {
id: Number(row.id),
clientId: row.client_id,
clientName: row.client_name,
description: row.description || null,
homepageUrl: row.homepage_url || null,
redirectUris: safeParseJson(row.redirect_uris, []).filter((u) => typeof u === 'string'),
allowedScopes: safeParseJson(row.allowed_scopes, []).filter((s) => typeof s === 'string' && KNOWN_OAUTH_SCOPES_SET.has(s)),
publicClient: row.public_client === 1 || row.public_client === true,
requirePkce: row.require_pkce === 1 || row.require_pkce === true,
clientSecret: row.client_secret || null,
iconFilename: row.icon_filename || null,
vipDaysBalance: Number(row.vip_days_balance || 0),
isActive: row.is_active === 1 || row.is_active === true,
createdAt: Number(row.created_at || 0),
updatedAt: Number(row.updated_at || 0),
};
}
/** Charge tous les clients actifs depuis la DB. À appeler au boot + après chaque modif. */
async function reloadCache() {
const pool = getPool();
if (!pool) return;
const [rows] = await pool.execute('SELECT * FROM oauth_clients WHERE is_active = 1 ORDER BY id ASC');
memCache = {
loadedAt: Date.now(),
clients: rows.map(rowToClient),
};
}
function getCachedClients() {
return memCache.clients;
}
function invalidateCache() {
memCache = { loadedAt: 0, clients: [] };
}
// ─── Stats helpers ───────────────────────────────────────────────────────
async function recordEvent(clientId, eventType, userId, metadata) {
const pool = getPool();
if (!pool) return;
try {
await pool.execute(
`INSERT INTO oauth_app_stats (client_id, event_type, user_id, metadata, created_at)
VALUES (?, ?, ?, ?, ?)`,
[
String(clientId),
String(eventType).slice(0, 32),
userId ? String(userId).slice(0, 160) : null,
metadata ? JSON.stringify(metadata) : null,
Date.now(),
],
);
} catch (err) {
console.warn('[OAuth stats] recordEvent failed:', err.message);
}
}
async function getStats(clientId, sinceMs) {
const pool = getPool();
if (!pool) return null;
const since = Number(sinceMs) || Date.now() - 30 * 24 * 60 * 60 * 1000;
const [byType] = await pool.execute(
`SELECT event_type, COUNT(*) AS n
FROM oauth_app_stats
WHERE client_id = ? AND created_at >= ?
GROUP BY event_type`,
[clientId, since],
);
const [byDay] = await pool.execute(
`SELECT FROM_UNIXTIME(FLOOR(created_at/1000), '%Y-%m-%d') AS day,
COUNT(*) AS n
FROM oauth_app_stats
WHERE client_id = ? AND created_at >= ?
GROUP BY day
ORDER BY day ASC`,
[clientId, since],
);
const [uniqueUsers] = await pool.execute(
`SELECT COUNT(DISTINCT user_id) AS n
FROM oauth_app_stats
WHERE client_id = ? AND created_at >= ? AND user_id IS NOT NULL`,
[clientId, since],
);
return {
sinceMs: since,
byType,
byDay,
uniqueUsers: Number(uniqueUsers[0]?.n || 0),
};
}
// ─── VIP grants helpers ──────────────────────────────────────────────────
function generateAccessKeyValue() {
// 32 chars base32-like uppercase (lisible).
return crypto.randomBytes(20).toString('hex').toUpperCase();
}
/**
* Décrémente atomiquement le balance et émet une access_key valide N jours.
* Throw si balance insuffisant.
*/
async function grantVip({ clientId, userType, userId, days }) {
if (!clientId || !userType || !userId || !Number.isInteger(days) || days <= 0 || days > 365) {
throw new Error('Paramètres grant invalides');
}
const pool = getPool();
if (!pool) throw new Error('DB indisponible');
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
// Lock + check balance
const [rows] = await conn.execute(
'SELECT id, vip_days_balance FROM oauth_clients WHERE client_id = ? FOR UPDATE',
[clientId],
);
if (rows.length === 0) throw new Error('Client OAuth introuvable');
const balance = Number(rows[0].vip_days_balance || 0);
if (balance < days) {
throw new Error(`Solde VIP insuffisant : ${balance} jour(s) disponible(s), ${days} demandé(s)`);
}
// Décrément
await conn.execute(
'UPDATE oauth_clients SET vip_days_balance = vip_days_balance - ?, updated_at = ? WHERE id = ?',
[days, Date.now(), rows[0].id],
);
// Génère access_key
const keyValue = generateAccessKeyValue();
const expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000);
const expiresAtSql = expiresAt.toISOString().slice(0, 19).replace('T', ' ');
await conn.execute(
`INSERT INTO access_keys (key_value, active, expires_at, duree_validite)
VALUES (?, 1, ?, ?)`,
[keyValue, expiresAtSql, `${days}d`],
);
// Audit
const userIdComposite = `${userType}:${userId}`;
await conn.execute(
`INSERT INTO oauth_vip_grants
(client_id, user_id, user_type, user_id_only, days_granted,
access_key_value, expires_at, granted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[clientId, userIdComposite, userType, userId, days, keyValue, expiresAtSql, Date.now()],
);
await conn.commit();
return {
accessKey: keyValue,
expiresAt: expiresAt.toISOString(),
daysGranted: days,
remainingBalance: balance - days,
};
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
}
module.exports = {
ensureTables,
migrateLegacyJsonIfNeeded,
reloadCache,
getCachedClients,
invalidateCache,
recordEvent,
getStats,
grantVip,
ICON_DIR,
KNOWN_OAUTH_SCOPES_SET,
};

View file

@ -664,8 +664,8 @@ async function makeLecteurVideoRequest(targetUrl, options = {}) {
ja3: CHROME_JA3,
userAgent: CHROME_UA,
headers: {
Referer: "https://coflix.click/",
Origin: "https://coflix.click",
Referer: "https://coflix.date/",
Origin: "https://coflix.date",
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "fr-FR,fr;q=0.9",
@ -714,8 +714,8 @@ async function makeLecteurVideoRequest(targetUrl, options = {}) {
userAgent: CHROME_UA,
proxy: proxyUrl,
headers: {
Referer: "https://coflix.click/",
Origin: "https://coflix.click",
Referer: "https://coflix.date/",
Origin: "https://coflix.date",
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "fr-FR,fr;q=0.9",

View file

@ -35,7 +35,9 @@ const SYNCABLE_EXACT_KEYS = new Set([
'subtitleStyle',
'support_popup_seen',
'user_language',
'is_vip',
// SECURITY (audit P0) : `is_vip` retiré du sync — c'est juste un cache UI
// côté frontend qui doit être recalculé via /api/check-vip à chaque session.
// Le laisser syncable permettait à n'importe qui de forger son statut VIP.
'watched_movie',
'watched_tv',
'watchPartyNickname'

View file

@ -12,6 +12,7 @@ const path = require('path');
const { verifyAccessKey } = require('./checkVip');
const { searchTmdb } = require('./utils/tmdbCache');
const { verifyTurnstileFromRequest } = require('./utils/turnstile');
const { resolveAdminIdentity } = require('./utils/adminIdentity');
const TURNSTILE_INVISIBLE_SECRETKEY = process.env.TURNSTILE_INVISIBLE_SECRETKEY;
const TMDB_API_URL = 'https://api.themoviedb.org/3';
@ -945,24 +946,18 @@ function createWishboardRouter(mysqlPool, redis) {
}
}
// Resolve user data (username, avatar) for each admin
// Resolve user data (username, avatar) for each admin via the
// shared helper — prefers OAuth provider identity over the
// generic Movix profile, so we display "Maxou DM" instead of
// "Admin" / "Profil".
const leaderboard = await Promise.all(rows.map(async (row) => {
let userData = { username: 'Admin', avatar: null };
try {
const userType = row.admin_auth_type === 'bip-39' ? 'bip39' : 'oauth';
const basicData = await getUserData(row.admin_id, userType);
if (basicData.username) userData.username = basicData.username;
if (basicData.avatar) userData.avatar = basicData.avatar;
} catch (err) {
// Keep defaults
}
const identity = await resolveAdminIdentity(row.admin_id, row.admin_auth_type);
return {
admin_id: row.admin_id,
admin_auth_type: row.admin_auth_type,
role: adminRoles[row.admin_id] || 'admin',
username: userData.username,
avatar: userData.avatar,
username: identity.username,
avatar: identity.avatar,
greenlight_count: row.greenlight_count,
last_greenlight_at: row.last_greenlight_at
};

View file

@ -938,7 +938,7 @@ class ProxyServer:
RE_NUMERIC_CDN = re.compile(r'([a-z0-9]+\.\d+\.net|epicquest|questher|hero.*\.com|trainer\.net|dishtrainer)', re.IGNORECASE)
RE_DOODSTREAM = re.compile(r'd0000d\.com|doodstream\.com|dood\.(cx|la|pm|sh|so|to|watch|wf|yt|re)|cloudatacdn\.com|dsvplay\.com|doply\.net', re.IGNORECASE)
RE_DOODSTREAM_PASS = re.compile(r'/pass_md5/[\w-]+/(?P<token>[\w-]+)')
RE_SEEKSTREAMING = re.compile(r'embed4me\.com|lpayer\.embed4me\.com|servicecatalog\.site|embedseek\.com', re.IGNORECASE)
RE_SEEKSTREAMING = re.compile(r'embed4me\.com|lpayer\.embed4me\.com|servicecatalog\.site|technicalcatalog\.site|embedseek\.com|seekplayer\.me', re.IGNORECASE)
RE_RANGE = re.compile(r'bytes=(\d+)-(\d*)')
RE_M3U8_URI_DQ = re.compile(r'URI="([^"]+)"', re.IGNORECASE)
RE_M3U8_URI_SQ = re.compile(r"URI='([^']+)'", re.IGNORECASE)
@ -2746,27 +2746,32 @@ class ProxyServer:
self.vip_cache.set(access_key, False)
return False
# Check expiration (if set)
# Check expiration (if set). access_keys.expires_at is BIGINT (Unix epoch ms),
# but legacy/admin paths could still produce DATETIME or ISO strings — handle all three.
# Note: do NOT fall back to datetime.fromisoformat(str(int)) — Python 3.11+ parses
# e.g. "1808043002043" as year 1808, marking every non-null key as expired.
if expires_at is not None:
now = datetime.now(timezone.utc)
if isinstance(expires_at, datetime):
# MySQL returns naive datetimes (no tz) – assume UTC
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if expires_at < now:
self.vip_cache.set(access_key, False)
return False
else:
# expires_at might be a string
exp = None
if isinstance(expires_at, bool):
pass # bool is an int subclass — ignore
elif isinstance(expires_at, (int, float)):
try:
exp = datetime.fromisoformat(str(expires_at))
if exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
if exp < now:
self.vip_cache.set(access_key, False)
return False
exp = datetime.fromtimestamp(expires_at / 1000, tz=timezone.utc)
except (ValueError, OSError, OverflowError):
exp = None
elif isinstance(expires_at, datetime):
exp = expires_at if expires_at.tzinfo else expires_at.replace(tzinfo=timezone.utc)
elif isinstance(expires_at, str):
try:
parsed = datetime.fromisoformat(expires_at)
exp = parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
except (ValueError, TypeError):
pass
exp = None
if exp is not None and exp < now:
self.vip_cache.set(access_key, False)
return False
# Key is valid
self.vip_cache.set(access_key, True)
@ -3239,15 +3244,19 @@ class ProxyServer:
return full_url
async def _extract_uqload_mp4_url(self, embed_url: str) -> str:
"""Extract MP4 URL from UQLOAD embed"""
"""Extract video URL from UQLOAD embed.
Prefers HLS master.m3u8 (audio + video) over the v.mp4 fallback,
because Uqload's v.mp4 is currently a video-only track.
"""
validated = self._validate_uqload_url(embed_url)
urls = [validated, validated.replace('embed-', '')]
headers = {
'User-Agent': 'Mozilla/5.0 Chrome/91.0.0.0',
'Accept': 'text/html,*/*'
}
html = None
for url in urls:
try:
@ -3259,18 +3268,22 @@ class ProxyServer:
break
except:
continue
if not html:
raise ValueError('No content from UQLOAD')
if 'File was deleted' in html:
raise ValueError('Video deleted')
matches = re.findall(r'https?://.+/v\.mp4', html)
if not matches:
raise ValueError('MP4 URL not found')
return matches[0]
m3u8_matches = re.findall(r'https?://[^\s"\'<>]+\.m3u8[^\s"\'<>]*', html)
if m3u8_matches:
return m3u8_matches[0]
mp4_matches = re.findall(r'https?://[^\s"\'<>]+/v\.mp4', html)
if not mp4_matches:
raise ValueError('Video URL not found')
return mp4_matches[0]
async def uqload_extract_handler(self, request: Request) -> Response:
"""UQLOAD extraction"""
@ -3392,7 +3405,20 @@ class ProxyServer:
return web.json_response({'error': str(e)}, status=500, headers=CORS_HEADERS)
# ===== SeekStreaming (Embed4me) Extraction =====
async def _check_url_alive(self, url: str, headers: Dict, timeout_s: int = 4) -> bool:
"""Quick liveness check: True if upstream returns 2xx within timeout."""
try:
async with self.sessions['no_ssl'].get(
url,
headers=headers,
timeout=ClientTimeout(total=timeout_s),
allow_redirects=True,
) as r:
return 200 <= r.status < 300
except Exception:
return False
def _decrypt_seekstreaming_data(self, hex_str: str) -> Optional[str]:
"""Decrypt AES-CBC encrypted data from seekstreaming/embed4me API"""
try:
@ -3494,13 +3520,34 @@ class ProxyServer:
# Pass the correct origin/referer to the proxy
proxy_queries = f"&referer=https%3A//{api_domain}/&origin=https%3A//{api_domain}"
if raw_cf:
result['url'] = f"{PROXY_BASE}/seekstreaming-proxy?url={urllib.parse.quote(raw_cf)}{proxy_queries}"
if raw_source:
result['ip_url'] = f"{PROXY_BASE}/seekstreaming-proxy?url={urllib.parse.quote(raw_source)}{proxy_queries}"
if not raw_cf and not raw_source:
return web.json_response({'error': 'No video source found'}, status=404, headers=CORS_HEADERS)
# Liveness check: probe both upstreams in parallel and only return
# one that actually responds. CF-fronted hosts (technicalcatalog.site,
# servicecatalog.site, ...) can get flagged as phishing and 403
# globally; the IP-direct URL (signed token + expiry) keeps working.
# Reuse the player domain from the request as Referer/Origin.
upstream_headers = {
'Accept': '*/*',
'Referer': f'https://{api_domain}/',
'Origin': f'https://{api_domain}',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36',
}
source_alive, cf_alive = await asyncio.gather(
self._check_url_alive(raw_source, upstream_headers) if raw_source else asyncio.sleep(0, result=False),
self._check_url_alive(raw_cf, upstream_headers) if raw_cf else asyncio.sleep(0, result=False),
)
if not source_alive and not cf_alive:
return web.json_response(
{'error': 'All upstream sources failed liveness check (likely 403/blocked)'},
status=502,
headers=CORS_HEADERS,
)
chosen = raw_source if source_alive else raw_cf
result['url'] = f"{PROXY_BASE}/seekstreaming-proxy?url={urllib.parse.quote(chosen)}{proxy_queries}"
self.seekstreaming_cache.set(cache_key, result)
resp = web.json_response(result)

View file

@ -1,5 +1,5 @@
export const SITE_NAME = 'Movix'
export const FALLBACK_SITE_URL = 'https://movix.cash'
export const FALLBACK_SITE_URL = 'https://movix.cloud'
export const FALLBACK_LOGO = `${FALLBACK_SITE_URL}/movix512.png`
export const TMDB_API_BASE = 'https://api.themoviedb.org/3'
export const TMDB_IMAGE_BASE = 'https://image.tmdb.org/t/p'

View file

@ -1,4 +1,5 @@
{
"$schema": "https://schemas.premid.app/metadata/1.16",
"apiVersion": 1,
"author": {
"name": "Movix",
@ -9,11 +10,11 @@
"en": "Movix is a streaming platform for movies, series, anime, and watch parties.",
"fr": "Movix est une plateforme de streaming pour films, series, anime et watch parties."
},
"url": ["movix.cash", "movix.cash", "movix.cash"],
"regExp": "^https:\\/\\/([a-z0-9-]+[.])*(movix[.](?:rodeo|website|blog))(?:[/]|$)",
"version": "1.0.0",
"logo": "https://movix.cash/movix512.png",
"thumbnail": "https://movix.cash/thumbnail.png",
"url": ["movix.tax", "movix.cash", "movix.cloud"],
"regExp": "^https?[:][/][/]([a-z0-9-]+[.])*movix[.](cash|tax|cloud)[/]",
"version": "1.0.5",
"logo": "https://cdn.rcd.gg/PreMiD/websites/M/Movix/assets/logo.png",
"thumbnail": "https://cdn.rcd.gg/PreMiD/websites/M/Movix/assets/thumbnail.png",
"color": "#dc2626",
"category": "videos",
"tags": ["movies", "series", "anime", "watchparty", "streaming"],

6
PreMid/tsconfig.json Normal file
View file

@ -0,0 +1,6 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist/"
}
}

View file

@ -20,8 +20,8 @@ android {
applicationId "com.movix.app"
minSdk rootProject.ext.minSdkVersion
targetSdk rootProject.ext.targetSdkVersion
versionCode 10
versionName "2.5.1"
versionCode 12
versionName "2.5.3"
buildConfigField "int", "VERSION_CODE_INT", "${versionCode}"
buildConfigField "String", "VERSION_NAME_STR", "\"${versionName}\""
@ -71,3 +71,11 @@ dependencies {
implementation("com.google.android.gms:play-services-cast-framework:21.5.0")
implementation("androidx.mediarouter:mediarouter:1.7.0")
}
// androidx.legacy:legacy-support-core-utils:1.0.0 (tiré transitivement par
// mediarouter palette) embarque ses propres classes androidx.autofill.R$attr
// qui entrent en collision avec le module androidx.autofill:autofill:1.1.0
// utilisé par appcompat "duplicate class" au mergeDexRelease.
configurations.all {
exclude group: 'androidx.legacy', module: 'legacy-support-core-utils'
}

View file

@ -22,6 +22,7 @@
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:keepScreenOn="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View file

@ -1,6 +1,9 @@
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
android.useAndroidX=true
android.enableJetifier=true
# Évite la régénération transitive des R classes (chaque lib recompilait
# androidx.autofill.R$attr → "duplicate class" au mergeDexRelease).
android.nonTransitiveRClass=true
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
newArchEnabled=false
hermesEnabled=true

BIN
app/assets/movix512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

88
app/ios/.gitignore vendored Normal file
View file

@ -0,0 +1,88 @@
# OSX
.DS_Store
# Xcode
build/
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
xcuserdata
*.xccheckout
*.moved-aside
DerivedData
*.hmap
*.ipa
*.xcuserstate
ios/.xcode.env.local
**/.xcode.env.local
# Android / IntelliJ
build/
.idea/
.gradle/
local.properties
*.iml
*.hprof
.cxx/
*.keystore
!debug.keystore
*.jks
# Android signing secrets (never commit)
keystore.properties
android/keystore.properties
android/app/keystore.properties
signing.properties
release-signing.properties
# node.js
node_modules/
npm-debug.log
yarn-error.log
yarn-debug.log
# fastlane
**/fastlane/report.xml
**/fastlane/Preview.html
**/fastlane/screenshots
**/fastlane/test_output
# Bundle artifact
*.jsbundle
# Ruby / CocoaPods
/vendor/bundle/
/ios/Pods/
# Metro
.metro-health-check*
# Testing
/coverage
# Yarn
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Environment
.env
.env.local
.env.*.local
# Generated userscript source (regenerated via `npm run build:userscript`)
src/injection/userscript-source.ts
# OS / editor
Thumbs.db
*.swp
*.swo
*.log

11
app/ios/.xcode.env Normal file
View file

@ -0,0 +1,11 @@
# This `.xcode.env` file is versioned and is used to source the environment
# used when running script phases inside Xcode.
# To customize your local environment, you can create an `.xcode.env.local`
# file that is not versioned.
# NODE_BINARY variable contains the PATH to the node executable.
#
# Customize the NODE_BINARY variable here.
# For example, to use nvm with brew, add the following line
# . "$(brew --prefix nvm)/nvm.sh" --no-use
export NODE_BINARY=$(command -v node)

View file

@ -0,0 +1,5 @@
#import <RCTAppDelegate.h>
#import <UIKit/UIKit.h>
@interface AppDelegate : RCTAppDelegate
@end

View file

@ -0,0 +1,27 @@
#import "AppDelegate.h"
#import <React/RCTBundleURLProvider.h>
@implementation AppDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
self.moduleName = @"Movix";
self.initialProps = @{};
return [super application:application didFinishLaunchingWithOptions:launchOptions];
}
- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
{
return [self bundleURL];
}
- (NSURL *)bundleURL
{
#if DEBUG
return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"];
#else
return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
#endif
}
@end

View file

@ -0,0 +1,24 @@
{
"images" : [
{ "filename": "icon-20.png", "idiom": "universal", "platform": "ios", "size": "20x20", "scale": "1x" },
{ "filename": "icon-40.png", "idiom": "universal", "platform": "ios", "size": "20x20", "scale": "2x" },
{ "filename": "icon-60.png", "idiom": "universal", "platform": "ios", "size": "20x20", "scale": "3x" },
{ "filename": "icon-29.png", "idiom": "universal", "platform": "ios", "size": "29x29", "scale": "1x" },
{ "filename": "icon-58.png", "idiom": "universal", "platform": "ios", "size": "29x29", "scale": "2x" },
{ "filename": "icon-87.png", "idiom": "universal", "platform": "ios", "size": "29x29", "scale": "3x" },
{ "filename": "icon-40.png", "idiom": "universal", "platform": "ios", "size": "40x40", "scale": "1x" },
{ "filename": "icon-80.png", "idiom": "universal", "platform": "ios", "size": "40x40", "scale": "2x" },
{ "filename": "icon-120.png", "idiom": "universal", "platform": "ios", "size": "40x40", "scale": "3x" },
{ "filename": "icon-60.png", "idiom": "universal", "platform": "ios", "size": "60x60", "scale": "1x" },
{ "filename": "icon-120.png", "idiom": "universal", "platform": "ios", "size": "60x60", "scale": "2x" },
{ "filename": "icon-180.png", "idiom": "universal", "platform": "ios", "size": "60x60", "scale": "3x" },
{ "filename": "icon-76.png", "idiom": "universal", "platform": "ios", "size": "76x76", "scale": "1x" },
{ "filename": "icon-152.png", "idiom": "universal", "platform": "ios", "size": "76x76", "scale": "2x" },
{ "filename": "icon-167.png", "idiom": "universal", "platform": "ios", "size": "83.5x83.5", "scale": "2x" },
{ "filename": "icon-1024.png", "idiom": "universal", "platform": "ios", "size": "1024x1024", "scale": "1x" }
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "movix512.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

View file

@ -57,5 +57,9 @@
<array>
<string>audio</string>
</array>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>tg</string>
</array>
</dict>
</plist>

View file

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="23727" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23721"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" misplaced="YES" image="MovixLogo" translatesAutoresizingMaskIntoConstraints="NO" id="IMG-mv-001">
<rect key="frame" x="95" y="323" width="202" height="207"/>
<constraints>
<constraint firstAttribute="height" constant="150" id="h-mv-001"/>
<constraint firstAttribute="width" constant="150" id="w-mv-001"/>
</constraints>
</imageView>
</subviews>
<viewLayoutGuide key="safeArea" id="tZ6-bH-Vph"/>
<color key="backgroundColor" red="0.70599999999999996" green="0.188" blue="0.17299999999999999" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="IMG-mv-001" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="cx-mv-001"/>
<constraint firstItem="IMG-mv-001" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="cy-mv-001"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="6.8702290076335872" y="3.5211267605633805"/>
</scene>
</scenes>
<resources>
<image name="MovixLogo" width="512" height="512"/>
</resources>
</document>

View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
</array>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyTracking</key>
<false/>
</dict>
</plist>

View file

@ -0,0 +1,4 @@
#import <React/RCTBridgeModule.h>
@interface UpdateModule : NSObject <RCTBridgeModule>
@end

View file

@ -0,0 +1,21 @@
#import "UpdateModule.h"
@implementation UpdateModule
RCT_EXPORT_MODULE()
RCT_EXPORT_METHOD(getVersionName:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject)
{
NSString *version = [NSBundle mainBundle].infoDictionary[@"CFBundleShortVersionString"];
resolve(version ?: @"unknown");
}
RCT_EXPORT_METHOD(getVersionCode:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject)
{
NSString *build = [NSBundle mainBundle].infoDictionary[@"CFBundleVersion"];
resolve(@([build integerValue]));
}
@end

9
app/ios/Movix/main.m Normal file
View file

@ -0,0 +1,9 @@
#import <UIKit/UIKit.h>
#import "AppDelegate.h"
int main(int argc, char *argv[])
{
@autoreleasepool {
return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}
}

View file

@ -0,0 +1,672 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
00E356F31AD99517003FC87E /* MovixAppTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 00E356F21AD99517003FC87E /* MovixAppTests.m */; };
0C80B921A6F3F58F76C31292 /* libPods-MovixApp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5DCACB8F33CDC322A6C60F78 /* libPods-MovixApp.a */; };
AA000003AA000003AA000003 /* UpdateModule.m in Sources */ = {isa = PBXBuildFile; fileRef = AA000002AA000002AA000002 /* UpdateModule.m */; };
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.mm */; };
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
23DB1FEBE14D043071FF72DB /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; };
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
00E356F41AD99517003FC87E /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 13B07F861A680F5B00A75B9A;
remoteInfo = MovixApp;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
00E356EE1AD99517003FC87E /* MovixAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MovixAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
00E356F11AD99517003FC87E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
00E356F21AD99517003FC87E /* MovixAppTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MovixAppTests.m; sourceTree = "<group>"; };
13B07F961A680F5B00A75B9A /* MovixApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MovixApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
AA000001AA000001AA000001 /* UpdateModule.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = UpdateModule.h; path = Movix/UpdateModule.h; sourceTree = "<group>"; };
AA000002AA000002AA000002 /* UpdateModule.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = UpdateModule.m; path = Movix/UpdateModule.m; sourceTree = "<group>"; };
13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = Movix/AppDelegate.h; sourceTree = "<group>"; };
13B07FB01A68108700A75B9A /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = Movix/AppDelegate.mm; sourceTree = "<group>"; };
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Movix/Images.xcassets; sourceTree = "<group>"; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Movix/Info.plist; sourceTree = "<group>"; };
13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Movix/main.m; sourceTree = "<group>"; };
13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = Movix/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
3B4392A12AC88292D35C810B /* Pods-MovixApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MovixApp.debug.xcconfig"; path = "Target Support Files/Pods-MovixApp/Pods-MovixApp.debug.xcconfig"; sourceTree = "<group>"; };
5709B34CF0A7D63546082F79 /* Pods-MovixApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MovixApp.release.xcconfig"; path = "Target Support Files/Pods-MovixApp/Pods-MovixApp.release.xcconfig"; sourceTree = "<group>"; };
5DCACB8F33CDC322A6C60F78 /* libPods-MovixApp.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-MovixApp.a"; sourceTree = BUILT_PRODUCTS_DIR; };
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = Movix/LaunchScreen.storyboard; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
00E356EB1AD99517003FC87E /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
13B07F8C1A680F5B00A75B9A /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
0C80B921A6F3F58F76C31292 /* libPods-MovixApp.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
00E356EF1AD99517003FC87E /* MovixAppTests */ = {
isa = PBXGroup;
children = (
00E356F21AD99517003FC87E /* MovixAppTests.m */,
00E356F01AD99517003FC87E /* Supporting Files */,
);
path = MovixAppTests;
sourceTree = "<group>";
};
00E356F01AD99517003FC87E /* Supporting Files */ = {
isa = PBXGroup;
children = (
00E356F11AD99517003FC87E /* Info.plist */,
);
name = "Supporting Files";
sourceTree = "<group>";
};
13B07FAE1A68108700A75B9A /* MovixApp */ = {
isa = PBXGroup;
children = (
AA000001AA000001AA000001 /* UpdateModule.h */,
AA000002AA000002AA000002 /* UpdateModule.m */,
13B07FAF1A68108700A75B9A /* AppDelegate.h */,
13B07FB01A68108700A75B9A /* AppDelegate.mm */,
13B07FB51A68108700A75B9A /* Images.xcassets */,
13B07FB61A68108700A75B9A /* Info.plist */,
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */,
13B07FB71A68108700A75B9A /* main.m */,
13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */,
);
name = MovixApp;
sourceTree = "<group>";
};
2D16E6871FA4F8E400B85C8A /* Frameworks */ = {
isa = PBXGroup;
children = (
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
5DCACB8F33CDC322A6C60F78 /* libPods-MovixApp.a */,
);
name = Frameworks;
sourceTree = "<group>";
};
832341AE1AAA6A7D00B99B32 /* Libraries */ = {
isa = PBXGroup;
children = (
);
name = Libraries;
sourceTree = "<group>";
};
83CBB9F61A601CBA00E9B192 = {
isa = PBXGroup;
children = (
13B07FAE1A68108700A75B9A /* MovixApp */,
832341AE1AAA6A7D00B99B32 /* Libraries */,
00E356EF1AD99517003FC87E /* MovixAppTests */,
83CBBA001A601CBA00E9B192 /* Products */,
2D16E6871FA4F8E400B85C8A /* Frameworks */,
BBD78D7AC51CEA395F1C20DB /* Pods */,
);
indentWidth = 2;
sourceTree = "<group>";
tabWidth = 2;
usesTabs = 0;
};
83CBBA001A601CBA00E9B192 /* Products */ = {
isa = PBXGroup;
children = (
13B07F961A680F5B00A75B9A /* MovixApp.app */,
00E356EE1AD99517003FC87E /* MovixAppTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
BBD78D7AC51CEA395F1C20DB /* Pods */ = {
isa = PBXGroup;
children = (
3B4392A12AC88292D35C810B /* Pods-MovixApp.debug.xcconfig */,
5709B34CF0A7D63546082F79 /* Pods-MovixApp.release.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
00E356ED1AD99517003FC87E /* MovixAppTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 00E357021AD99517003FC87E /* Build configuration list for PBXNativeTarget "MovixAppTests" */;
buildPhases = (
00E356EA1AD99517003FC87E /* Sources */,
00E356EB1AD99517003FC87E /* Frameworks */,
00E356EC1AD99517003FC87E /* Resources */,
);
buildRules = (
);
dependencies = (
00E356F51AD99517003FC87E /* PBXTargetDependency */,
);
name = MovixAppTests;
productName = MovixAppTests;
productReference = 00E356EE1AD99517003FC87E /* MovixAppTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
13B07F861A680F5B00A75B9A /* MovixApp */ = {
isa = PBXNativeTarget;
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "MovixApp" */;
buildPhases = (
C38B50BA6285516D6DCD4F65 /* [CP] Check Pods Manifest.lock */,
13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */,
13B07F8E1A680F5B00A75B9A /* Resources */,
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
00EEFC60759A1932668264C0 /* [CP] Embed Pods Frameworks */,
E235C05ADACE081382539298 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
dependencies = (
);
name = MovixApp;
productName = MovixApp;
productReference = 13B07F961A680F5B00A75B9A /* MovixApp.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
83CBB9F71A601CBA00E9B192 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1630;
TargetAttributes = {
00E356ED1AD99517003FC87E = {
CreatedOnToolsVersion = 6.2;
TestTargetID = 13B07F861A680F5B00A75B9A;
};
13B07F861A680F5B00A75B9A = {
LastSwiftMigration = 1120;
};
};
};
buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "MovixApp" */;
compatibilityVersion = "Xcode 12.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 83CBB9F61A601CBA00E9B192;
productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
13B07F861A680F5B00A75B9A /* MovixApp */,
00E356ED1AD99517003FC87E /* MovixAppTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
00E356EC1AD99517003FC87E /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
13B07F8E1A680F5B00A75B9A /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */,
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
23DB1FEBE14D043071FF72DB /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"$(SRCROOT)/.xcode.env.local",
"$(SRCROOT)/.xcode.env",
);
name = "Bundle React Native code and images";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "set -e\n\nWITH_ENVIRONMENT=\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"$REACT_NATIVE_PATH/scripts/react-native-xcode.sh\"\n\n/bin/sh -c \"$WITH_ENVIRONMENT $REACT_NATIVE_XCODE\"\n";
};
00EEFC60759A1932668264C0 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-MovixApp/Pods-MovixApp-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-MovixApp/Pods-MovixApp-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-MovixApp/Pods-MovixApp-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
C38B50BA6285516D6DCD4F65 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-MovixApp-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
E235C05ADACE081382539298 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-MovixApp/Pods-MovixApp-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-MovixApp/Pods-MovixApp-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-MovixApp/Pods-MovixApp-resources.sh\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
00E356EA1AD99517003FC87E /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
00E356F31AD99517003FC87E /* MovixAppTests.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
13B07F871A680F5B00A75B9A /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
AA000003AA000003AA000003 /* UpdateModule.m in Sources */,
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */,
13B07FC11A68108700A75B9A /* main.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
00E356F51AD99517003FC87E /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 13B07F861A680F5B00A75B9A /* MovixApp */;
targetProxy = 00E356F41AD99517003FC87E /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
00E356F61AD99517003FC87E /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
INFOPLIST_FILE = MovixAppTests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
OTHER_LDFLAGS = (
"-ObjC",
"-lc++",
"$(inherited)",
);
PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MovixApp.app/MovixApp";
};
name = Debug;
};
00E356F71AD99517003FC87E /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
COPY_PHASE_STRIP = NO;
INFOPLIST_FILE = MovixAppTests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
OTHER_LDFLAGS = (
"-ObjC",
"-lc++",
"$(inherited)",
);
PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MovixApp.app/MovixApp";
};
name = Release;
};
13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 3B4392A12AC88292D35C810B /* Pods-MovixApp.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 52RQH3U49D;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Movix/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
"-lc++",
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.movixcorp.MovixApp;
PRODUCT_NAME = MovixApp;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
13B07F951A680F5B00A75B9A /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 5709B34CF0A7D63546082F79 /* Pods-MovixApp.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 52RQH3U49D;
INFOPLIST_FILE = Movix/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
"-lc++",
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.movixcorp.MovixApp;
PRODUCT_NAME = MovixApp;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
83CBBA201A601CBA00E9B192 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CC = "";
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
CXX = "";
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_SYMBOLS_PRIVATE_EXTERN = NO;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
LD = "";
LDPLUSPLUS = "";
LD_RUNPATH_SEARCH_PATHS = (
/usr/lib/swift,
"$(inherited)",
);
LIBRARY_SEARCH_PATHS = (
"\"$(SDKROOT)/usr/lib/swift\"",
"\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"",
"\"$(inherited)\"",
);
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
OTHER_CFLAGS = "$(inherited)";
OTHER_CPLUSPLUSFLAGS = (
"$(OTHER_CFLAGS)",
"-DFOLLY_NO_CONFIG",
"-DFOLLY_MOBILE=1",
"-DFOLLY_USE_LIBCPP=1",
"-DFOLLY_CFG_NO_COROUTINES=1",
"-DFOLLY_HAVE_CLOCK_GETTIME=1",
);
OTHER_LDFLAGS = "$(inherited) ";
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
USE_HERMES = true;
};
name = Debug;
};
83CBBA211A601CBA00E9B192 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CC = "";
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = YES;
CXX = "";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
LD = "";
LDPLUSPLUS = "";
LD_RUNPATH_SEARCH_PATHS = (
/usr/lib/swift,
"$(inherited)",
);
LIBRARY_SEARCH_PATHS = (
"\"$(SDKROOT)/usr/lib/swift\"",
"\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"",
"\"$(inherited)\"",
);
MTL_ENABLE_DEBUG_INFO = NO;
OTHER_CFLAGS = "$(inherited)";
OTHER_CPLUSPLUSFLAGS = (
"$(OTHER_CFLAGS)",
"-DFOLLY_NO_CONFIG",
"-DFOLLY_MOBILE=1",
"-DFOLLY_USE_LIBCPP=1",
"-DFOLLY_CFG_NO_COROUTINES=1",
"-DFOLLY_HAVE_CLOCK_GETTIME=1",
);
OTHER_LDFLAGS = "$(inherited) ";
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
USE_HERMES = true;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
00E357021AD99517003FC87E /* Build configuration list for PBXNativeTarget "MovixAppTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
00E356F61AD99517003FC87E /* Debug */,
00E356F71AD99517003FC87E /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "MovixApp" */ = {
isa = XCConfigurationList;
buildConfigurations = (
13B07F941A680F5B00A75B9A /* Debug */,
13B07F951A680F5B00A75B9A /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "MovixApp" */ = {
isa = XCConfigurationList;
buildConfigurations = (
83CBBA201A601CBA00E9B192 /* Debug */,
83CBBA211A601CBA00E9B192 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */;
}

View file

@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1630"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "MovixApp.app"
BlueprintName = "MovixApp"
ReferencedContainer = "container:MovixApp.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "00E356ED1AD99517003FC87E"
BuildableName = "MovixAppTests.xctest"
BlueprintName = "MovixAppTests"
ReferencedContainer = "container:MovixApp.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Release"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "MovixApp.app"
BlueprintName = "MovixApp"
ReferencedContainer = "container:MovixApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "MovixApp.app"
BlueprintName = "MovixApp"
ReferencedContainer = "container:MovixApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:MovixApp.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>

View file

@ -0,0 +1,66 @@
#import <UIKit/UIKit.h>
#import <XCTest/XCTest.h>
#import <React/RCTLog.h>
#import <React/RCTRootView.h>
#define TIMEOUT_SECONDS 600
#define TEXT_TO_LOOK_FOR @"Welcome to React"
@interface MovixAppTests : XCTestCase
@end
@implementation MovixAppTests
- (BOOL)findSubviewInView:(UIView *)view matching:(BOOL (^)(UIView *view))test
{
if (test(view)) {
return YES;
}
for (UIView *subview in [view subviews]) {
if ([self findSubviewInView:subview matching:test]) {
return YES;
}
}
return NO;
}
- (void)testRendersWelcomeScreen
{
UIViewController *vc = [[[RCTSharedApplication() delegate] window] rootViewController];
NSDate *date = [NSDate dateWithTimeIntervalSinceNow:TIMEOUT_SECONDS];
BOOL foundElement = NO;
__block NSString *redboxError = nil;
#ifdef DEBUG
RCTSetLogFunction(
^(RCTLogLevel level, RCTLogSource source, NSString *fileName, NSNumber *lineNumber, NSString *message) {
if (level >= RCTLogLevelError) {
redboxError = message;
}
});
#endif
while ([date timeIntervalSinceNow] > 0 && !foundElement && !redboxError) {
[[NSRunLoop mainRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]];
[[NSRunLoop mainRunLoop] runMode:NSRunLoopCommonModes beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]];
foundElement = [self findSubviewInView:vc.view
matching:^BOOL(UIView *view) {
if ([view.accessibilityLabel isEqualToString:TEXT_TO_LOOK_FOR]) {
return YES;
}
return NO;
}];
}
#ifdef DEBUG
RCTSetLogFunction(RCTDefaultLogFunction);
#endif
XCTAssertNil(redboxError, @"RedBox error: %@", redboxError);
XCTAssertTrue(foundElement, @"Couldn't find element with text '%@' in %d seconds", TEXT_TO_LOOK_FOR, TIMEOUT_SECONDS);
}
@end

View file

@ -14,7 +14,7 @@ if linkage != nil
use_frameworks! :linkage => linkage.to_sym
end
target 'Movix' do
target 'MovixApp' do
config = use_native_modules!
use_react_native!(
@ -28,5 +28,24 @@ target 'Movix' do
config[:reactNativePath],
:mac_catalyst_enabled => false
)
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['CLANG_CXX_LIBRARY'] = 'libc++'
config.build_settings['OTHER_CPLUSPLUSFLAGS'] = '$(inherited) -include "$(PODS_ROOT)/../char_traits_fix.h"'
config.build_settings['CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES'] = 'YES'
end
end
# Remove Expo build phases injected by react-native (no expo in this project)
installer.aggregate_targets.each do |agg_target|
agg_target.user_project.targets.each do |target|
target.build_phases.select { |p|
p.is_a?(Xcodeproj::Project::Object::PBXShellScriptBuildPhase) &&
p.name.to_s.include?("[Expo]")
}.each(&:remove_from_project)
end
agg_target.user_project.save
end
end
end

1810
app/ios/Podfile.lock Normal file

File diff suppressed because it is too large Load diff

26
app/ios/char_traits_fix.h Normal file
View file

@ -0,0 +1,26 @@
#pragma once
#include <string>
namespace std {
template <>
struct char_traits<unsigned char> {
using char_type = unsigned char;
using int_type = unsigned int;
using off_type = std::streamoff;
using pos_type = std::streampos;
using state_type = std::mbstate_t;
static void assign(char_type& c1, const char_type& c2) noexcept { c1 = c2; }
static bool eq(char_type c1, char_type c2) noexcept { return c1 == c2; }
static bool lt(char_type c1, char_type c2) noexcept { return c1 < c2; }
static int compare(const char_type* s1, const char_type* s2, size_t n) { return memcmp(s1, s2, n); }
static size_t length(const char_type* s) { size_t i = 0; while (s[i]) ++i; return i; }
static const char_type* find(const char_type* s, size_t n, const char_type& a) { return (const char_type*)memchr(s, a, n); }
static char_type* move(char_type* s1, const char_type* s2, size_t n) { return (char_type*)memmove(s1, s2, n); }
static char_type* copy(char_type* s1, const char_type* s2, size_t n) { return (char_type*)memcpy(s1, s2, n); }
static char_type* assign(char_type* s, size_t n, char_type a) { return (char_type*)memset(s, a, n); }
static int_type not_eof(int_type c) noexcept { return c != eof() ? c : 0; }
static char_type to_char_type(int_type c) noexcept { return (char_type)c; }
static int_type to_int_type(char_type c) noexcept { return (int_type)c; }
static bool eq_int_type(int_type c1, int_type c2) noexcept { return c1 == c2; }
static int_type eof() noexcept { return (int_type)EOF; }
};
}

Binary file not shown.

View file

@ -4,11 +4,12 @@ import React, {
useImperativeHandle,
useRef,
} from 'react';
import { Platform } from 'react-native';
import { Linking, Platform } from 'react-native';
import { WebView, type WebViewNavigation } from 'react-native-webview';
import type {
WebViewErrorEvent,
WebViewMessageEvent,
ShouldStartLoadRequest,
} from 'react-native-webview/lib/WebViewTypes';
import { handleBridgeMessage } from '../services/bridge';
import { buildInjectedJavaScript } from '../injection/inject';
@ -26,12 +27,13 @@ interface WebViewBrowserProps {
url: string;
onNavigationStateChange?: (state: WebViewNavigation) => void;
onError?: (error: string) => void;
onLoadEnd?: () => void;
}
const injectedJS = buildInjectedJavaScript();
const WebViewBrowser = forwardRef<WebViewBrowserRef, WebViewBrowserProps>(
({ url, onNavigationStateChange, onError }, ref) => {
({ url, onNavigationStateChange, onError, onLoadEnd }, ref) => {
const webViewRef = useRef<WebView>(null);
useImperativeHandle(ref, () => ({
@ -61,6 +63,27 @@ const WebViewBrowser = forwardRef<WebViewBrowserRef, WebViewBrowserProps>(
[onError],
);
const onShouldStartLoadWithRequest = useCallback(
(request: ShouldStartLoadRequest) => {
const { url, navigationType } = request;
if (
url.startsWith('https://') ||
url.startsWith('http://') ||
url.startsWith('about:') ||
url.startsWith('blob:')
) {
return true;
}
// Ouvre uniquement les deep links déclenchés par un vrai clic utilisateur.
// Les redirections automatiques (pubs, iframes) sont silencieusement bloquées.
if (navigationType === 'click') {
Linking.openURL(url).catch(() => {});
}
return false;
},
[],
);
const onWebViewError = useCallback(
(event: WebViewErrorEvent) => {
onError?.(event.nativeEvent.description);
@ -83,10 +106,12 @@ const WebViewBrowser = forwardRef<WebViewBrowserRef, WebViewBrowserProps>(
// Bridge messages
onMessage={onMessage}
// Navigation
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
onNavigationStateChange={onNavigationStateChange}
// Errors
onError={onWebViewError}
onHttpError={onHttpError}
onLoadEnd={onLoadEnd}
// Config
userAgent={userAgent}
javaScriptEnabled={true}
@ -96,7 +121,7 @@ const WebViewBrowser = forwardRef<WebViewBrowserRef, WebViewBrowserProps>(
allowsFullscreenVideo={true}
allowsBackForwardNavigationGestures={true}
// Sécurité
originWhitelist={['https://*', 'http://*']}
originWhitelist={['https://*', 'http://*', 'about:*', 'blob:*']}
mixedContentMode="compatibility"
// Cache
cacheEnabled={true}

View file

@ -7,7 +7,8 @@ import {
Platform,
Modal,
TouchableOpacity,
ActivityIndicator,
Image,
Animated,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import type { WebViewNavigation } from 'react-native-webview';
@ -44,6 +45,8 @@ export default function BrowserScreen() {
const [currentUrl, setCurrentUrl] = useState('');
const [dnsEnabled, setDnsEnabled] = useState(false);
const [settingsVisible, setSettingsVisible] = useState(false);
const [webViewReady, setWebViewReady] = useState(false);
const splashFade = useRef(new Animated.Value(1)).current;
const activeUrl = urlChain[mirrorIndex] ?? '';
@ -95,6 +98,15 @@ export default function BrowserScreen() {
[activeUrl, mirrorIndex, urlChain.length],
);
const onWebViewLoadEnd = useCallback(() => {
if (webViewReady) return;
Animated.timing(splashFade, {
toValue: 0,
duration: 400,
useNativeDriver: true,
}).start(() => setWebViewReady(true));
}, [webViewReady, splashFade]);
const closeSettings = useCallback(() => {
setSettingsVisible(false);
AsyncStorage.getItem('dns_enabled').then(val => {
@ -105,36 +117,34 @@ export default function BrowserScreen() {
const onRetry = useCallback(async () => {
setAllMirrorsFailed(false);
setMirrorIndex(0);
setWebViewReady(false);
splashFade.setValue(1);
await refresh();
}, [refresh]);
}, [refresh, splashFade]);
if (isLoading || !config) {
return (
<View style={[styles.container, styles.centered, { paddingTop: insets.top }]}>
<ActivityIndicator size="large" color="#8b5cf6" />
</View>
);
}
if (allMirrorsFailed) {
return (
<MirrorErrorScreen telegramUrl={config.telegramUrl} onRetry={onRetry} />
);
}
const showWebView = !isLoading && !!config && !allMirrorsFailed;
const showSplash = (!webViewReady || isLoading || !config) && !allMirrorsFailed;
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
<View style={styles.webViewContainer}>
<WebViewBrowser
key={activeUrl}
ref={webViewRef}
url={activeUrl}
onNavigationStateChange={onNavigationStateChange}
onError={onWebViewError}
/>
</View>
{showWebView && (
<View style={styles.webViewContainer}>
<WebViewBrowser
key={activeUrl}
ref={webViewRef}
url={activeUrl}
onNavigationStateChange={onNavigationStateChange}
onError={onWebViewError}
onLoadEnd={onWebViewLoadEnd}
/>
</View>
)}
{!toolbarHidden && (
{allMirrorsFailed && config && (
<MirrorErrorScreen telegramUrl={config.telegramUrl} onRetry={onRetry} />
)}
{!toolbarHidden && showWebView && (
<View style={{ paddingBottom: insets.bottom }}>
<BrowserToolbar
canGoBack={canGoBack}
@ -153,23 +163,39 @@ export default function BrowserScreen() {
</View>
)}
<Modal
visible={settingsVisible}
animationType="slide"
onRequestClose={closeSettings}>
<View style={[styles.modalContainer, { paddingTop: insets.top }]}>
<View style={styles.modalHeader}>
<TouchableOpacity onPress={closeSettings} style={styles.closeButton}>
<Text style={styles.closeText}>Fermer</Text>
</TouchableOpacity>
<Text style={styles.modalTitle}>Paramètres</Text>
<View style={styles.closeButton} />
{showWebView && (
<Modal
visible={settingsVisible}
animationType="slide"
onRequestClose={closeSettings}>
<View style={[styles.modalContainer, { paddingTop: insets.top }]}>
<View style={styles.modalHeader}>
<TouchableOpacity onPress={closeSettings} style={styles.closeButton}>
<Text style={styles.closeText}>Fermer</Text>
</TouchableOpacity>
<Text style={styles.modalTitle}>Paramètres</Text>
<View style={styles.closeButton} />
</View>
<SettingsScreen />
</View>
<SettingsScreen />
</View>
</Modal>
</Modal>
)}
{navBarHidden && <MiniPill onPress={() => setSettingsVisible(true)} />}
{navBarHidden && showWebView && (
<MiniPill onPress={() => setSettingsVisible(true)} />
)}
{showSplash && (
<Animated.View
style={[StyleSheet.absoluteFillObject, styles.splash, { opacity: splashFade }]}
pointerEvents="none">
<Image
source={require('../../assets/movix512.png')}
style={styles.splashLogo}
resizeMode="contain"
/>
</Animated.View>
)}
</View>
);
}
@ -179,10 +205,6 @@ const styles = StyleSheet.create({
flex: 1,
backgroundColor: '#0a0a0a',
},
centered: {
justifyContent: 'center',
alignItems: 'center',
},
webViewContainer: {
flex: 1,
},
@ -213,4 +235,13 @@ const styles = StyleSheet.create({
fontSize: 15,
fontWeight: '500',
},
splash: {
backgroundColor: '#B5302C',
justifyContent: 'center',
alignItems: 'center',
},
splashLogo: {
width: 150,
height: 150,
},
});

View file

@ -1,4 +1,4 @@
import { DeviceEventEmitter, NativeModules } from 'react-native';
import { DeviceEventEmitter, NativeModules, Platform } from 'react-native';
export type CastSessionState = 'idle' | 'starting' | 'connected' | 'ending';
@ -29,6 +29,7 @@ function ensureModule(): CastModuleType {
}
export async function isCastSupported(): Promise<boolean> {
if (Platform.OS !== 'android') return false;
try {
return await ensureModule().isSupported();
} catch (err) {

View file

@ -1,13 +1,13 @@
{
"version": "2.5.1",
"buildNumber": 10,
"apkUrl": "https://github.com/movixcorp/MovixOpenSource/raw/refs/heads/main/app/movix-android.apk",
"apkSizeBytes": 72040798,
"apkSha256": "715544f47ad1aa7525081e07e7dfc892e58ba57b8419e5ec9d00c524527004c4",
"version": "2.5.3",
"buildNumber": 12,
"apkUrl": "https://raw.githubusercontent.com/movixcorp/MovixOpenSource/main/app/movix-android.apk",
"apkSizeBytes": 72023883,
"apkSha256": "6e63593a1c47cee9cf60414219e4e05ef11c7a72e77256ebaffb65af1cc8e977",
"mandatory": false,
"releasedAt": "2026-04-25T16:26:33.137Z",
"releasedAt": "2026-05-10T09:47:30.589Z",
"releaseNotes": {
"fr": "",
"fr": "Correction extraction du lecteur Uqload\nL'écran se s'etteint plus lors de l'utilisation de l'app",
"en": ""
}
}

View file

@ -1,8 +1,8 @@
const VAVOO_BASE_URL = "https://tvvoo.hayd.uk/cfg-fr";
const WITV_BASE_URL = "https://witv.team";
const SOSPLAY_BASE_URL = "https://ligue1live.xyz";
const LIVETV_BASE_URL = "https://livetv876.me/frx/";
const LIVETV_EMBED_ORIGIN = "https://livetv876.me";
const SOSPLAY_BASE_URL = "https://streamonsport.art";
const LIVETV_BASE_URL = "https://livetv882.me/frx/";
const LIVETV_EMBED_ORIGIN = "https://livetv882.me";
const LIVETV_EMBED_REFERER = LIVETV_BASE_URL;
// Backend API URL for got-scraping based extraction
const API_BASE_URL = "https://api.movix.cash";
@ -113,7 +113,8 @@ async function setupRules() {
"localhost",
"127.0.0.1",
"movix.cash",
"movix.cash",
"movix.cloud",
"movix.tax",
"movix.club",
],
resourceTypes: [
@ -1772,7 +1773,7 @@ function shouldIgnoreLiveTvIframeUrl(rawUrl) {
const combined = `${hostname}${pathname}${search}`;
if (
hostname === "ads.livetv876.me" ||
hostname === "ads.livetv882.me" ||
hostname.startsWith("ads.") ||
hostname.startsWith("ad.")
) {

View file

@ -748,11 +748,18 @@ async function extractUqload(uqloadUrl) {
if (!html) return { success: false, error: 'Uqload: Could not fetch page' };
if (html.includes('File was deleted')) return { success: false, error: 'Uqload: File was deleted' };
const matches = html.match(/https?:\/\/.+\/v\.mp4/g);
if (!matches || matches.length === 0) return { success: false, error: 'Uqload: MP4 URL not found' };
// Préférer le HLS master.m3u8 (multi-bitrate) au mp4 single-quality
const m3u8Matches = html.match(/https?:\/\/[^"'\s]+\/master\.m3u8/g) || html.match(/https?:\/\/[^"'\s]+\.m3u8/g);
let videoUrl = m3u8Matches?.[0];
const mp4Url = matches[0];
const result = { m3u8Url: mp4Url, success: true, source: 'uqload' };
if (!videoUrl) {
const mp4Matches = html.match(/https?:\/\/.+\/v\.mp4/g);
videoUrl = mp4Matches?.[0];
}
if (!videoUrl) return { success: false, error: 'Uqload: video URL not found' };
const result = { m3u8Url: videoUrl, success: true, source: 'uqload' };
caches.uqload.set(cacheKey, result);
return result;

View file

@ -38,8 +38,10 @@
"*://localhost/*",
"*://movix.cash/*",
"*://*.movix.cash/*",
"*://movix.cash/*",
"*://*.movix.cash/*",
"*://movix.cloud/*",
"*://*.movix.cloud/*",
"*://movix.tax/*",
"*://*.movix.tax/*",
"*://movix.club/*",
"*://*.movix.club/*"
]

View file

@ -424,7 +424,7 @@
</div>
</div>
<div style="margin-top: 12px; text-align: center;">
<a href="https://movix.cash/settings#extractions" target="_blank" style="color:#6366f1;text-decoration:none;font-size:12px;font-weight:600;">
<a href="https://movix.cloud/settings#extractions" target="_blank" style="color:#6366f1;text-decoration:none;font-size:12px;font-weight:600;">
Configurer →
</a>
</div>
@ -456,7 +456,7 @@
<!-- Footer -->
<div class="footer fade-in fade-in-delay-6">
<a href="https://movix.cash" target="_blank">
<a href="https://movix.cloud" target="_blank">
Ouvrir Movix
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>

View file

@ -6,9 +6,9 @@ const browserAPI = typeof browser !== "undefined" ? browser : chrome;
const VAVOO_BASE_URL = "https://tvvoo.hayd.uk/cfg-fr";
const WITV_BASE_URL = "https://witv.team";
const SOSPLAY_BASE_URL = "https://ligue1live.xyz";
const LIVETV_BASE_URL = "https://livetv876.me/frx/";
const LIVETV_EMBED_ORIGIN = "https://livetv876.me";
const SOSPLAY_BASE_URL = "https://streamonsport.art";
const LIVETV_BASE_URL = "https://livetv882.me/frx/";
const LIVETV_EMBED_ORIGIN = "https://livetv882.me";
const LIVETV_EMBED_REFERER = LIVETV_BASE_URL;
// Backend API URL for got-scraping based extraction
const API_BASE_URL = "https://api.movix.cash";
@ -119,7 +119,8 @@ async function setupRules() {
"localhost",
"127.0.0.1",
"movix.cash",
"movix.cash",
"movix.cloud",
"movix.tax",
"movix.club",
],
resourceTypes: [
@ -1804,7 +1805,7 @@ function shouldIgnoreLiveTvIframeUrl(rawUrl) {
const combined = `${hostname}${pathname}${search}`;
if (
hostname === "ads.livetv876.me" ||
hostname === "ads.livetv882.me" ||
hostname.startsWith("ads.") ||
hostname.startsWith("ad.")
) {

View file

@ -710,11 +710,18 @@ async function extractUqload(uqloadUrl) {
if (!html) return { success: false, error: 'Uqload: Could not fetch page' };
if (html.includes('File was deleted')) return { success: false, error: 'Uqload: File was deleted' };
const matches = html.match(/https?:\/\/.+\/v\.mp4/g);
if (!matches || matches.length === 0) return { success: false, error: 'Uqload: MP4 URL not found' };
// Préférer le HLS master.m3u8 (multi-bitrate) au mp4 single-quality
const m3u8Matches = html.match(/https?:\/\/[^"'\s]+\/master\.m3u8/g) || html.match(/https?:\/\/[^"'\s]+\.m3u8/g);
let videoUrl = m3u8Matches?.[0];
const mp4Url = matches[0];
const result = { m3u8Url: mp4Url, success: true, source: 'uqload' };
if (!videoUrl) {
const mp4Matches = html.match(/https?:\/\/.+\/v\.mp4/g);
videoUrl = mp4Matches?.[0];
}
if (!videoUrl) return { success: false, error: 'Uqload: video URL not found' };
const result = { m3u8Url: videoUrl, success: true, source: 'uqload' };
caches.uqload.set(cacheKey, result);
return result;

View file

@ -424,7 +424,7 @@
</div>
</div>
<div style="margin-top: 12px; text-align: center;">
<a href="https://movix.cash/settings#extractions" target="_blank" style="color:#6366f1;text-decoration:none;font-size:12px;font-weight:600;">
<a href="https://movix.cloud/settings#extractions" target="_blank" style="color:#6366f1;text-decoration:none;font-size:12px;font-weight:600;">
Configurer →
</a>
</div>
@ -456,7 +456,7 @@
<!-- Footer -->
<div class="footer fade-in fade-in-delay-6">
<a href="https://movix.cash" target="_blank">
<a href="https://movix.cloud" target="_blank">
Ouvrir Movix
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>

View file

@ -187,6 +187,16 @@
<div id="root"></div>
<script type="text/javascript" src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"></script>
<script>
// Cache-bust the Cast SDK URL on every page load so a stale SDK never
// sticks around in the browser/SW cache (defensive — gstatic already
// sets sane Cache-Control, but cinepulse does this too).
(function loadCastSender() {
var s = document.createElement('script');
s.type = 'text/javascript';
s.src = 'https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1&cb=' + Date.now();
document.body.appendChild(s);
})();
</script>
</body>
</html>

3686
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,7 @@
"build": "vite build",
"build:cf": "vite build",
"build:coolify": "vite build",
"build:report": "vite build && node scripts/bundle-report.mjs",
"lint": "eslint .",
"preview": "vite preview",
"start": "node server/index.js",
@ -25,55 +26,48 @@
"@dnd-kit/utilities": "^3.2.2",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@firebase/firestore": "^4.9.2",
"@headlessui/react": "^2.2.0",
"@hono/node-server": "^1.19.14",
"@noble/hashes": "^1.8.0",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-hook/resize-observer": "^2.0.2",
"@tanstack/react-virtual": "^3.13.24",
"axios": "^1.13.2",
"canvas-confetti": "^1.9.4",
"chart.js": "^4.4.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dashjs": "^5.1.0",
"dashjs": "^5.1.1",
"date-fns": "^4.1.0",
"embla-carousel": "^8.6.0",
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"emoji-mart": "^5.6.0",
"emoji-picker-react": "^4.12.2",
"file-saver": "^2.0.5",
"firebase": "^12.6.0",
"framer-motion": "11.11.10",
"hls.js": "^1.6.16",
"hono": "^4.12.14",
"i18next": "^25.8.10",
"i18next-browser-languagedetector": "^8.2.1",
"jszip": "^3.10.1",
"lenis": "^1.3.17",
"lenis": "^1.3.23",
"lucide-react": "^0.344.0",
"mpegts.js": "^1.8.0",
"pako": "^2.1.0",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-chartjs-2": "^5.3.0",
"react-colorful": "^5.6.1",
"react-country-flag": "^3.1.0",
"react-dom": "^18.3.1",
"react-force-graph-2d": "^1.29.1",
"react-helmet-async": "^2.0.5",
"react-i18next": "^16.5.4",
"react-icons": "^5.5.0",
"react-input-mask": "^2.0.4",
"react-loading-skeleton": "^3.5.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.22.3",
"react-snowfall": "^2.3.0",
"recharts": "^2.15.3",
"react-snowfall": "^2.4.0",
"remark-emoji": "^5.0.1",
"remark-gfm": "^4.0.1",
"shaka-player": "^5.0.0",
@ -83,12 +77,7 @@
"tailwindcss-animate": "^1.0.7",
"uuid": "^11.1.0",
"video.js": "^8.23.4",
"web-haptics": "^0.0.6",
"workbox-core": "^7.3.0",
"workbox-precaching": "^7.3.0",
"workbox-routing": "^7.3.0",
"workbox-strategies": "^7.3.0",
"workbox-window": "^7.3.0"
"web-haptics": "^0.0.6"
},
"devDependencies": {
"@eslint/js": "^9.9.1",
@ -98,8 +87,8 @@
"@types/react-input-mask": "^3.0.6",
"@types/uuid": "^10.0.0",
"@types/video.js": "^7.3.58",
"@vitejs/plugin-basic-ssl": "^1.2.0",
"@vitejs/plugin-react": "^4.3.1",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.4.18",
"eslint": "^9.9.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
@ -107,10 +96,11 @@
"eslint-plugin-unused-imports": "^4.3.0",
"globals": "^15.9.0",
"postcss": "^8.4.35",
"rollup-plugin-visualizer": "^7.0.1",
"tailwindcss": "^3.4.1",
"typescript": "^5.5.3",
"typescript-eslint": "^8.3.0",
"vite": "^5.4.2",
"vite": "^8.0.11",
"wrangler": "^4.15.1"
},
"overrides": {

View file

@ -5,8 +5,74 @@ const DEFAULT_MIRRORS = __MOVIX_DEFAULT_MIRRORS__;
const CONFIG_URL = __MOVIX_CONFIG_URL__;
const NAV_TIMEOUT_MS = 3000;
const CONFIG_TIMEOUT_MS = 3000;
// Ping de confirmation sur un asset statique de l'origine. Volontairement
// généreux : sur mobile, sortir de veille peut prendre 3-5s (DNS + TLS +
// radio cellulaire qui se réveille). Si même ce ping fail, l'origine est
// vraiment injoignable.
const REACHABILITY_TIMEOUT_MS = 4000;
const REACHABILITY_PROBE_PATH = '/movix.png';
const HOSTNAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
// ============================================================================
// Image cache — TMDB images (posters, backdrops, logos)
// ============================================================================
//
// Stratégie : cache-first sur image.tmdb.org. Premier hit = network + cache,
// hits suivants = direct depuis CacheStorage (instantané, no network).
//
// Bonus : queue de concurrence côté SW, cap à 6 simultanés. Sans throttle,
// le navigateur peut déclencher 30+ fetchs parallèles au mount du home.
//
// Bump IMAGE_CACHE_NAME pour invalider toutes les images cachées d'un coup
// (ex. quand on change la taille standard w500→w342 — sinon on continue à
// servir les vieilles URLs pendant des semaines). v2 = passage à w342 posters
// + w300 logos.
const IMAGE_CACHE_NAME = 'movix-tmdb-images-v2';
const TMDB_IMAGE_HOST = 'image.tmdb.org';
const MAX_CONCURRENT_IMAGE_FETCHES = 6;
let activeImageFetches = 0;
const imageFetchQueue = [];
function acquireImageFetchSlot() {
return new Promise((resolve) => {
if (activeImageFetches < MAX_CONCURRENT_IMAGE_FETCHES) {
activeImageFetches++;
resolve();
} else {
imageFetchQueue.push(resolve);
}
});
}
function releaseImageFetchSlot() {
const next = imageFetchQueue.shift();
if (next) {
next();
} else {
activeImageFetches = Math.max(0, activeImageFetches - 1);
}
}
async function handleTmdbImage(req) {
const cache = await caches.open(IMAGE_CACHE_NAME);
const cached = await cache.match(req);
if (cached) return cached;
await acquireImageFetchSlot();
try {
const res = await fetch(req);
if (res && (res.ok || res.type === 'opaque')) {
// .clone() avant .put() : la response ne peut être consommée qu'une fois.
// .catch silently : QuotaExceededError quand storage full → on sert la
// réponse non-cachée à l'utilisateur, qui marche quand même.
cache.put(req, res.clone()).catch(() => {});
}
return res;
} finally {
releaseImageFetchSlot();
}
}
// ============================================================================
// Helpers fallback domain
// ============================================================================
@ -91,6 +157,15 @@ function escapeHtml(str) {
.replace(/'/g, '&#39;');
}
function buildMirrorUrl(targetHost, { from, reason, error, via } = {}) {
const url = new URL(`https://${targetHost}/`);
if (from) url.searchParams.set('from', from);
if (reason) url.searchParams.set('reason', reason);
if (error) url.searchParams.set('error', String(error).slice(0, 100));
if (via) url.searchParams.set('via', via);
return url.href;
}
function renderRedirectPage(url) {
const safe = escapeHtml(url);
const html = `<!DOCTYPE html>
@ -186,7 +261,15 @@ self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(keys.map((k) => caches.delete(k)));
// Préserve le cache d'images courant ; supprime tout le reste (anciennes
// versions de cache, caches légacy d'avant cette logique). Quand on bumpe
// IMAGE_CACHE_NAME (ex. v1 → v2), l'ancienne version sera supprimée ici
// automatiquement.
await Promise.all(
keys
.filter((k) => k !== IMAGE_CACHE_NAME)
.map((k) => caches.delete(k))
);
await self.clients.claim();
})()
);
@ -236,6 +319,48 @@ self.addEventListener('notificationclick', (event) => {
// Fetch — intercepte les navigations top-level pour fallback domain
// ============================================================================
// Ping de confirmation : l'origine répond-elle réellement ? Utilisé pour
// distinguer un VRAI blocage FAI (toute l'origine bloquée) d'un échec
// transient (mobile qui sort de veille, throttling de tab background, blip
// réseau). On vise un asset statique stable, cache: 'no-store' pour forcer
// un round-trip frais.
async function probeOriginOnce() {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REACHABILITY_TIMEOUT_MS);
try {
const url = new URL(REACHABILITY_PROBE_PATH, self.location.origin).href;
const res = await fetch(`${url}?_swprobe=${Date.now()}`, {
method: 'HEAD',
cache: 'no-store',
signal: controller.signal,
credentials: 'omit',
redirect: 'manual',
});
clearTimeout(timer);
// 2xx, 3xx, 4xx = origine répond (même un 404 confirme que le serveur
// est joignable). Seul un échec réseau ou un 5xx massif = injoignable.
return res.status < 500;
} catch {
clearTimeout(timer);
return false;
}
}
// Deux tentatives avant de conclure à un blocage. Un seul HEAD raté n'est
// pas un signal suffisant : un blip réseau ponctuel (perte de paquet, switch
// de cell tower, throttling court) peut le faire échouer. Si la 1ère échoue
// on attend 500ms et on retente — un VRAI blocage FAI est persistant et
// échouera les deux fois ; un blip transient laissera passer la 2ème.
async function isOriginReachable() {
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
return false;
}
const first = await probeOriginOnce();
if (first) return true;
await new Promise((r) => setTimeout(r, 500));
return await probeOriginOnce();
}
async function handleNavigation(req) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), NAV_TIMEOUT_MS);
@ -249,21 +374,51 @@ async function handleNavigation(req) {
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
throw err;
}
return await redirectToMirror();
// Confirmation : l'origine est-elle vraiment injoignable ? Sinon (mobile
// qui se réveille, network blip, tab throttlé), on relaie l'erreur
// d'origine et on laisse le browser gérer (retry naturel, page d'erreur).
// On ne bascule au miroir QUE si même un HEAD simple échoue.
const reachable = await isOriginReachable();
if (reachable) {
throw err;
}
return await redirectToMirror({
from: self.location.hostname,
reason: 'unreachable',
error: `${err.name}: ${err.message}`,
via: 'sw-fetch',
});
}
}
async function redirectToMirror() {
async function redirectToMirror({ from, reason, error, via } = {}) {
const mirrors = await loadMirrors();
const target = pickNextMirror(mirrors, self.location.hostname);
if (!target) return render503Page();
const redirectUrl = `https://${target}/`;
const redirectUrl = buildMirrorUrl(target, { from, reason, error, via });
return renderRedirectPage(redirectUrl);
}
self.addEventListener('fetch', (event) => {
const req = event.request;
if (req.mode !== 'navigate' || req.method !== 'GET') return;
if (req.method !== 'GET') return;
// 1. TMDB image cache — intercepte tous les GET sur image.tmdb.org peu
// importe l'origine du SW. Pas de garde localhost ici : le cache est utile
// aussi en dev pour éviter de re-fetcher les mêmes posters à chaque reload.
let url;
try {
url = new URL(req.url);
} catch {
return;
}
if (url.hostname === TMDB_IMAGE_HOST) {
event.respondWith(handleTmdbImage(req));
return;
}
// 2. Navigation fallback (logique existante — préservée)
if (req.mode !== 'navigate') return;
if (isLocalHost(self.location.hostname)) return;
event.respondWith(handleNavigation(req));
});
@ -277,10 +432,21 @@ self.addEventListener('message', async (event) => {
if (!data || data.type !== 'MOVIX_FORCE_REDIRECT') return;
if (isLocalHost(self.location.hostname)) return;
try {
// Garde-fou : la page peut compter ses erreurs de manière trop
// optimiste (burst d'API calls qui fail au réveil mobile). On confirme
// avant de rediriger — sinon on enverrait l'utilisateur sur un miroir
// alors que l'origine répond très bien.
const reachable = await isOriginReachable();
if (reachable) return;
const mirrors = await loadMirrors();
const target = pickNextMirror(mirrors, self.location.hostname);
if (!target) return;
const url = `https://${target}/`;
const url = buildMirrorUrl(target, {
from: self.location.hostname,
reason: 'api-errors',
error: data.error || 'API error threshold reached',
via: 'sw-message',
});
event.source?.postMessage({ type: 'MOVIX_REDIRECT_TO', url });
} catch {}
});

View file

@ -1,28 +1,10 @@
import React, { useEffect, useLayoutEffect, useState, useRef } from 'react';
import { BrowserRouter, Routes, Route, Navigate, useLocation, useNavigationType, useNavigate } from 'react-router-dom';
import React, { useEffect, useLayoutEffect, useState, useRef, lazy } from 'react';
import { BrowserRouter, Routes, Route, Navigate, useLocation, useNavigationType, useNavigate, matchPath } from 'react-router-dom';
import { Toaster } from './components/ui/sonner';
import { TooltipProvider } from './components/ui/tooltip';
import Header from './components/Header';
import Home from './pages/Home';
import Search from './pages/Search';
import MovieDetails from './pages/MovieDetails';
import TVDetails from './pages/TVDetails';
import Movies from './pages/Movies';
import Anime from './pages/Anime';
import TVShows from './pages/TVShows';
import Collections from './pages/Collections';
import CollectionDetails from './pages/CollectionDetails';
import GenrePage from './pages/GenrePage';
import WatchMovie from './pages/Watch/WatchMovie';
import WatchTv from './pages/Watch/WatchTv';
import ProviderContent from './pages/ProviderContent';
import ProviderCatalogPage from './pages/ProviderCatalogPage';
import RoulettePage from './pages/RoulettePage';
import DiscordAuth from './components/DiscordAuth';
import GoogleAuth from './components/GoogleAuth';
import DnsBlockBanner from './components/DnsBlockBanner';
import HelpRouter from './pages/help/HelpRouter';
import Profile from './pages/Profile';
import { AdFreePopupProvider } from './context/AdFreePopupContext';
import { SearchProvider } from './context/SearchContext';
import { AuthProvider } from './context/AuthContext';
@ -30,67 +12,34 @@ import { AdWarningProvider } from './context/AdWarningContext';
import { VipModalProvider } from './context/VipModalContext';
import { ProfileProvider, useProfile } from './context/ProfileContext';
import { TurnstileProvider } from './context/TurnstileContext';
import { LightModeProvider, useLightMode } from './context/LightModeContext';
import LiveTV from './pages/LiveTV';
import PersonDetails from './pages/PersonDetails';
import SuggestionPage from './pages/SuggestionPage';
import ExtensionPage from './pages/ExtensionPage';
import AppDownloadPage from './pages/AppDownloadPage';
import SharedListPage from './pages/SharedListPage';
import SharedListsCatalogPage from './pages/SharedListsCatalogPage';
import NotFound from './pages/NotFound';
import 'video.js/dist/video-js.css';
import './styles/videojs-custom.css';
import WatchAnime from './pages/Watch/WatchAnime';
import WatchPartyCreate from './pages/WatchPartyCreate';
import WatchPartyRoom from './pages/WatchPartyRoom';
import WatchPartyJoin from './pages/WatchPartyJoin';
import WatchPartyList from './pages/WatchPartyList';
import axios from 'axios';
import Footer from './components/Footer';
import CreateAccount from './pages/CreateAccount';
import LoginBip39 from './pages/LoginBip39';
import AlertsPage from './pages/AlertsPage';
import { AlertService } from './services/alertService';
import NotificationToast from './components/NotificationToast';
import { NotificationData } from './types/alerts';
import DMCA from './pages/DMCA';
import AdminPage from './pages/AdminPage';
import DownloadPage from './pages/DownloadPage';
import DebridPage from './pages/DebridPage';
import ProfileSelection from './pages/ProfileSelection';
import ProfileManagement from './pages/ProfileManagement';
import RedirectPopup from './components/RedirectPopup';
import WishboardPage from './pages/Greenlight/WishboardPage';
import WishboardNewRequest from './pages/Greenlight/WishboardNewRequest';
import WishboardUserRequests from './pages/Greenlight/WishboardUserRequests';
import SubmitLinkPage from './pages/Greenlight/SubmitLinkPage';
import VipPage from './pages/VipPage';
import VipDonatePage from './pages/VipDonatePage';
import VipInvoicesPage from './pages/VipInvoicesPage';
import VipInvoicePage from './pages/VipInvoicePage';
import VipGiftPage from './pages/VipGiftPage';
import WhatIsMovixPage from './pages/WhatIsMovixPage';
import Privacy from './pages/Privacy';
import TermsOfService from './pages/TermsOfService';
import { TopProgressBar } from './components/TopProgressBar';
import SmoothScroll from './components/SmoothScroll';
import WrappedPage from './pages/WrappedPage';
import CineGraphPage from './pages/CineGraph';
import SettingsPage from './pages/SettingsPage';
import Top10Page from './pages/Top10Page';
import OAuthAuthorizePage from './pages/OAuthAuthorizePage';
import FranceTVBrowse from './pages/FranceTV/FranceTVBrowse';
import FranceTVInfo from './pages/FranceTV/FranceTVInfo';
import FranceTVPlayer from './pages/FranceTV/FranceTVPlayer';
import AprilFoolsAdminPage from './pages/AprilFoolsAdminPage';
import ProfileSelection from './pages/ProfileSelection';
import { ROUTES, type RouteEntry } from './routing/registry';
import { DelayedSuspense } from './components/DelayedSuspense';
import { RouteProgressBar } from './components/RouteProgressBar';
import ScreenSaver from './components/ScreenSaver';
import { useIdleTimer } from './hooks/useIdleTimer';
import { startVipVerification } from './utils/vipUtils';
import { broadcastAuthChange, clearStoredAuthSession, getResolvedAccountContext } from './utils/accountAuth';
import { isSyncableStorageKey } from './utils/syncStorage';
import { isSyncableStorageKey, SYNC_OUTBOX_STORAGE_KEY } from './utils/syncStorage';
import i18n, { detectInitialLanguage } from './i18n';
import { useTranslation } from 'react-i18next';
import { motion, AnimatePresence } from 'framer-motion';
import { motion, AnimatePresence, MotionConfig } from 'framer-motion';
import IntroAnimation from './components/IntroAnimation';
import { IntroProvider, useIntro } from './context/IntroContext';
import { APRIL_FOOLS_ADMIN_PATH, isAprilFoolsAdminEnabled } from './utils/aprilFools';
@ -519,6 +468,53 @@ const PrivateRoute = ({ children }: { children: React.ReactNode }) => {
return isAuthenticated ? children : <Navigate to="/login" />;
};
// Cache module-level des composants Lazy par path. Sans ça, chaque appel à
// renderRouteEntry (ROUTES.map à chaque render d'App) créerait une nouvelle
// instance lazy() avec son propre cache de chunk → instabilité d'identité.
const lazyComponentCache = new Map<string, React.LazyExoticComponent<React.ComponentType<unknown>>>();
const getCachedLazy = (entry: RouteEntry) => {
let cached = lazyComponentCache.get(entry.path);
if (!cached) {
cached = lazy(entry.loader as () => Promise<{ default: React.ComponentType<unknown> }>);
lazyComponentCache.set(entry.path, cached);
}
return cached;
};
// Wrapper qui injecte `key={location.pathname}` sur le composant lazy. Sans ça,
// quand l'utilisateur navigue entre deux URLs matchant le même Route pattern
// (ex. /movie/abc → /movie/xyz), React Router réutilise l'instance composant
// avec juste les params updated. Les useState de la page (movie, cast, crew,
// loading…) gardent les valeurs de l'ancien id pendant que le nouveau fetch
// tourne — l'utilisateur voit l'ancien film tant que TMDB répond pas.
// Avec key={pathname}, la clé change → React remount le composant → state reset
// → loader/skeleton affiché jusqu'au nouveau fetch.
const RouteLazyContent: React.FC<{
Lazy: React.LazyExoticComponent<React.ComponentType<unknown>>;
fallback: React.ReactNode;
}> = ({ Lazy, fallback }) => {
const location = useLocation();
return (
<DelayedSuspense fallback={fallback}>
<Lazy key={location.pathname} />
</DelayedSuspense>
);
};
const renderRouteEntry = (entry: RouteEntry) => {
const Lazy = getCachedLazy(entry);
let element: React.ReactNode = (
<RouteLazyContent
Lazy={Lazy}
fallback={entry.fallback ?? <RouteProgressBar />}
/>
);
if (entry.guard === 'private') {
element = <PrivateRoute>{element}</PrivateRoute>;
}
return <Route key={entry.path} path={entry.path} element={element} />;
};
// PersistenceManager component to sync localStorage with backend (disabled for guests and VIP)
const PersistenceManager = () => {
const [isInitialSyncDone, setIsInitialSyncDone] = useState<boolean>(false);
@ -544,6 +540,8 @@ const PersistenceManager = () => {
React.useEffect(() => {
(window as any).setProfileDataLoading = (loading: boolean) => {
isProfileDataLoadingRef.current = loading;
const diag = (window as unknown as { __syncDiag?: Record<string, unknown> }).__syncDiag;
if (diag) diag.gateLastSetTo = loading;
debugAppLog('Profile data loading state changed:', loading);
};
@ -625,6 +623,41 @@ const PersistenceManager = () => {
useEffect(() => {
// Setup localStorage sync (now allowed on watch routes)
// Diagnostic counters (window.__syncDiag) — readable from remote inspector
// to pinpoint which guard silently drops sync ops on Firefox mobile.
const syncDiag = ((window as unknown as { __syncDiag?: Record<string, unknown> }).__syncDiag = {
setItemIntercepted: 0,
removeItemIntercepted: 0,
diffsQueued: 0,
diffsDrained: 0,
skipSuppress: 0,
skipNotSyncable: 0,
skipNoop: 0,
skipForceClear: 0,
skipGateSet: 0,
skipGateRemove: 0,
skipGateEnqueue: 0,
opsEnqueued: 0,
flushGeneralCalled: 0,
flushGeneralBlockedGate: 0,
sendOpsCalled: 0,
sendOpsEmpty: 0,
sendOpsBlockedForceClear: 0,
sendOpsBlockedGate: 0,
sendOpsBlockedUserInfo: 0,
sendOpsBlockedNoToken: 0,
sendOpsAttempted: 0,
sendOpsSuccess: 0,
sendOpsError: 0,
lastSkippedKey: '',
lastUserInfo: '',
lastError: '',
gateInitialState: undefined as boolean | undefined,
gateLastSetTo: undefined as boolean | undefined,
profileLoadingExposed: typeof (window as unknown as { setProfileDataLoading?: unknown }).setProfileDataLoading === 'function'
});
syncDiag.gateInitialState = isProfileDataLoadingRef.current;
// Initialize snapshot of current localStorage
const prevValues = new Map<string, string | null>();
for (let i = 0; i < localStorage.length; i++) {
@ -658,9 +691,10 @@ const PersistenceManager = () => {
const enqueueGeneralOp = (op: any) => {
// Skip sync during profile data loading (but allow on watch routes)
if (isProfileDataLoadingRef.current) return;
if (isProfileDataLoadingRef.current) { syncDiag.skipGateEnqueue++; return; }
generalOpsRef.current.push(op);
syncDiag.opsEnqueued++;
if (generalFlushTimeoutRef.current) return;
generalFlushTimeoutRef.current = setTimeout(() => {
flushGeneralOps();
@ -669,29 +703,38 @@ const PersistenceManager = () => {
};
const sendOps = async (ops: any[]) => {
if (!ops.length) return;
if (!ops.length) { syncDiag.sendOpsEmpty++; return; }
syncDiag.sendOpsCalled++;
// Vérifier si un clear forcé est en cours (erreur 401)
if ((window as any).__forceClearInProgress) {
syncDiag.sendOpsBlockedForceClear++;
debugAppLog('Skipping sync - force clear in progress');
return;
}
// Skip sync during profile data loading (but allow on watch routes)
if (isProfileDataLoadingRef.current) {
syncDiag.sendOpsBlockedGate++;
debugAppLog('Skipping sync - profile data loading in progress');
return;
}
const userInfo = getUserInfo();
// Only sync for oauth and bip39 users with a selected profile
if (!userInfo.type || !userInfo.profileId || !['oauth', 'bip39'].includes(userInfo.type)) return;
if (!userInfo.type || !userInfo.profileId || !['oauth', 'bip39'].includes(userInfo.type)) {
syncDiag.sendOpsBlockedUserInfo++;
syncDiag.lastUserInfo = JSON.stringify(userInfo);
return;
}
const authToken = localStorage.getItem('auth_token');
if (!authToken) {
syncDiag.sendOpsBlockedNoToken++;
debugAppLog('Skipping sync - no auth token available');
return;
}
syncDiag.sendOpsAttempted++;
try {
for (let index = 0; index < ops.length; index += MAX_SYNC_OPS_PER_REQUEST) {
@ -712,18 +755,35 @@ const PersistenceManager = () => {
});
}
// NOTE: don't clear SYNC_OUTBOX_STORAGE_KEY here. The outbox holds
// un-replayed ops from previous sessions; in-session sendOps only
// sees current-session ops. The two sets are disjoint, so clearing
// here would silently drop a previous session's ops that hadn't yet
// succeeded a replay. ProfileContext.replayOutboxIfAny owns the
// outbox lifecycle; backend ops are idempotent so leaving stale
// outbox entries doesn't cause incorrect state, only one extra POST
// on next boot.
syncDiag.sendOpsSuccess++;
debugAppLog('Sync request successful');
window.dispatchEvent(new CustomEvent('sync_storage_updated'));
} catch (e) {
syncDiag.sendOpsError++;
syncDiag.lastError = (e instanceof Error ? e.message : String(e)).slice(0, 200);
console.error('Delta sync failed', e);
}
};
const flushGeneralOps = () => {
syncDiag.flushGeneralCalled++;
const ops = generalOpsRef.current;
generalOpsRef.current = [];
// Skip sync during profile data loading (but allow on watch routes)
if (!isProfileDataLoadingRef.current && ops.length) sendOps(ops);
if (isProfileDataLoadingRef.current) {
syncDiag.flushGeneralBlockedGate++;
return;
}
if (ops.length) sendOps(ops);
};
const flushProgressOps = () => {
@ -914,13 +974,13 @@ const PersistenceManager = () => {
};
const processSet = (key: string, oldVal: string | null, newVal: string) => {
if (suppressSyncRef.current) return;
if (!isSyncableStorageKey(key)) return;
if (oldVal === newVal) return;
if (suppressSyncRef.current) { syncDiag.skipSuppress++; return; }
if (!isSyncableStorageKey(key)) { syncDiag.skipNotSyncable++; return; }
if (oldVal === newVal) { syncDiag.skipNoop++; return; }
// Vérifier si un clear forcé est en cours (erreur 401)
if ((window as any).__forceClearInProgress) return;
if ((window as any).__forceClearInProgress) { syncDiag.skipForceClear++; return; }
// Skip sync during profile data loading (but allow on watch routes)
if (isProfileDataLoadingRef.current) return;
if (isProfileDataLoadingRef.current) { syncDiag.skipGateSet++; syncDiag.lastSkippedKey = key; return; }
if (isProgressKey(key)) {
// Accumulate latest patch for progress keys
const delta = computeObjectPatch(oldVal, newVal);
@ -953,12 +1013,12 @@ const PersistenceManager = () => {
};
const processRemove = (key: string) => {
if (suppressSyncRef.current) return;
if (!isSyncableStorageKey(key)) return;
if (suppressSyncRef.current) { syncDiag.skipSuppress++; return; }
if (!isSyncableStorageKey(key)) { syncDiag.skipNotSyncable++; return; }
// Vérifier si un clear forcé est en cours (erreur 401)
if ((window as any).__forceClearInProgress) return;
if ((window as any).__forceClearInProgress) { syncDiag.skipForceClear++; return; }
// Skip sync during profile data loading (but allow on watch routes)
if (isProfileDataLoadingRef.current) return;
if (isProfileDataLoadingRef.current) { syncDiag.skipGateRemove++; syncDiag.lastSkippedKey = key; return; }
enqueueGeneralOp({ op: 'remove', key });
if (isProgressKey(key)) {
progressOpsMapRef.current.delete(key);
@ -976,6 +1036,7 @@ const PersistenceManager = () => {
queueMicrotask(() => {
diffQueueScheduled = false;
const batch = pendingDiffs.splice(0);
syncDiag.diffsDrained += batch.length;
for (const entry of batch) {
if (entry.newVal === null) {
processRemove(entry.key);
@ -986,8 +1047,13 @@ const PersistenceManager = () => {
});
};
// BroadcastChannel for cross-tab sync (replaces 2s/5s polling on Safari/Firefox).
// Other tabs receive { key, value } and reconcile against their own prevValuesRefLocal.
// BroadcastChannel for cross-tab sync. Receivers ONLY refresh prev so
// future local diffs are correct against shared localStorage state — they
// must NOT enqueue sync ops here. The originating tab handles its own
// sync; re-syncing in receivers races with this tab's pending 1s flush
// and can interleave a `remove` between a user's `arrayAdd X` and the
// backing `arrayAdd A,B,C,D` (when another tab's loadProfileData wipes
// and re-applies the syncable keys), losing X from the backend.
const supportsBroadcastChannel = typeof BroadcastChannel !== 'undefined';
let channel: BroadcastChannel | null = null;
if (supportsBroadcastChannel && isLocalStorageAvailable) {
@ -998,12 +1064,11 @@ const PersistenceManager = () => {
const key = data.key;
const value = data.value as string | null | undefined;
if (typeof key !== 'string') return;
const oldVal = prevValuesRefLocal.current.get(key) ?? null;
const newVal = (value === undefined ? null : value) as string | null;
if (oldVal !== newVal) {
if (newVal === null) {
prevValuesRefLocal.current.delete(key);
} else {
prevValuesRefLocal.current.set(key, newVal);
pendingDiffs.push({ key, oldVal, newVal });
scheduleDiffDrain();
}
};
} catch (error) {
@ -1063,13 +1128,34 @@ const PersistenceManager = () => {
}
}, 2000) : null; // Poll every 2 seconds for Safari and Firefox/Librewolf without BroadcastChannel
const originalSetItem = localStorage.setItem;
const originalRemoveItem = localStorage.removeItem;
const originalClear = localStorage.clear;
// Patch Storage.prototype, NOT the localStorage instance. On Firefox,
// `localStorage` is a LegacyPlatformObject with a named-property setter:
// assigning `localStorage.setItem = fn` is interpreted as
// `setItem("setItem", fn.toString())` and stores the function as a
// localStorage entry — leaving the original prototype method in place,
// so writes are never intercepted and zero sync requests fire. Patching
// the prototype (a plain object, no named-property handler) works on
// every engine. The `this === localStorage` guard keeps sessionStorage
// writes on the unmodified path.
const originalSetItem = Storage.prototype.setItem;
const originalRemoveItem = Storage.prototype.removeItem;
const originalClear = Storage.prototype.clear;
localStorage.setItem = function (key: string, value: string) {
// Past versions of this code attempted `localStorage.setItem = fn` and
// accidentally seeded junk entries on Firefox users. Drop them once.
for (const junkKey of ['setItem', 'removeItem', 'clear']) {
const v = localStorage.getItem(junkKey);
if (v && v.startsWith('function')) {
originalRemoveItem.call(localStorage, junkKey);
prevValuesRefLocal.current.delete(junkKey);
}
}
Storage.prototype.setItem = function (this: Storage, key: string, value: string) {
if (this !== localStorage) return originalSetItem.call(this, key, value);
if (!isLocalStorageAvailable) return;
syncDiag.setItemIntercepted++;
const oldVal = prevValuesRefLocal.current.get(key) ?? localStorage.getItem(key);
originalSetItem.call(localStorage, key, value);
prevValuesRefLocal.current.set(key, value);
@ -1080,13 +1166,19 @@ const PersistenceManager = () => {
// Defer JSON-diff cost off the synchronous write path.
if (!isProfileDataLoadingRef.current) {
pendingDiffs.push({ key, oldVal, newVal: value });
syncDiag.diffsQueued++;
scheduleDiffDrain();
} else {
syncDiag.skipGateSet++;
syncDiag.lastSkippedKey = key;
}
} as any;
localStorage.removeItem = function (key: string) {
Storage.prototype.removeItem = function (this: Storage, key: string) {
if (this !== localStorage) return originalRemoveItem.call(this, key);
if (!isLocalStorageAvailable) return;
syncDiag.removeItemIntercepted++;
const oldVal = prevValuesRefLocal.current.get(key) ?? null;
originalRemoveItem.call(localStorage, key);
prevValuesRefLocal.current.delete(key);
@ -1097,11 +1189,16 @@ const PersistenceManager = () => {
// Defer downstream sync work into the microtask drain.
if (!isProfileDataLoadingRef.current) {
pendingDiffs.push({ key, oldVal, newVal: null });
syncDiag.diffsQueued++;
scheduleDiffDrain();
} else {
syncDiag.skipGateRemove++;
syncDiag.lastSkippedKey = key;
}
} as any;
localStorage.clear = function () {
Storage.prototype.clear = function (this: Storage) {
if (this !== localStorage) return originalClear.call(this);
if (!isLocalStorageAvailable) return;
// Snapshot keys (with their previous values) before wiping localStorage.
@ -1127,16 +1224,14 @@ const PersistenceManager = () => {
prevValuesRefLocal.current.clear();
} as any;
// Same rule as the BroadcastChannel handler: refresh prev only, never
// enqueue sync ops. See the channel.onmessage comment above for the
// race that re-syncing here re-introduces.
const storageListener = (e: StorageEvent) => {
if (!e.key) return;
// Skip sync during profile data loading (but allow on watch routes)
if (isProfileDataLoadingRef.current) return;
if (e.newValue === null) {
processRemove(e.key);
prevValuesRefLocal.current.delete(e.key);
} else {
processSet(e.key, e.oldValue, e.newValue);
prevValuesRefLocal.current.set(e.key, e.newValue);
}
};
@ -1155,6 +1250,27 @@ const PersistenceManager = () => {
const flushPendingOpsSync = () => {
if (isProfileDataLoadingRef.current) return;
if ((window as unknown as { __forceClearInProgress?: boolean }).__forceClearInProgress) return;
// Synchronously drain pendingDiffs FIRST. The microtask scheduled by
// the wrapped setItem/removeItem may not have run yet on Firefox: its
// unload sequencing can fire pagehide before the microtask checkpoint
// when the user refreshes immediately after a write (e.g., F5 right
// after submitting a VIP key). Without this explicit drain, in-flight
// diffs would never reach generalOpsRef and the outbox + keepalive
// path below would have nothing to flush — losing the user's write
// across reload. Chrome reliably drains microtasks before pagehide,
// which is why this manifests as "Firefox-only data loss".
if (pendingDiffs.length) {
const batch = pendingDiffs.splice(0);
for (const entry of batch) {
if (entry.newVal === null) {
processRemove(entry.key);
} else {
processSet(entry.key, entry.oldVal, entry.newVal);
}
}
}
if (generalFlushTimeoutRef.current) {
clearTimeout(generalFlushTimeoutRef.current);
generalFlushTimeoutRef.current = null;
@ -1180,6 +1296,61 @@ const PersistenceManager = () => {
const authToken = localStorage.getItem('auth_token');
if (!authToken) return;
// Persist a recovery outbox to localStorage BEFORE the keepalive fetch.
// fetch keepalive is best-effort: on Firefox the Authorization header
// triggers a CORS preflight that the browser may drop on unload, and
// both engines cap inflight keepalive bytes. If the request never lands
// server-side, the next page load would otherwise wipe localStorage and
// restore stale backend state — losing the user's write. ProfileContext
// .replayOutboxIfAny reads this on boot and POSTs before the wipe runs.
//
// We MERGE with any existing outbox for the same user/profile rather
// than overwriting. Without merge, a chain of partial failures (replay
// 5xx → next-session writes → next unload) would silently drop the
// older un-replayed ops every cycle. A hard cap on op count prevents
// unbounded growth across many failed cycles; oldest ops are dropped
// first since newer ops reflect later state and ops are idempotent.
try {
const MAX_OUTBOX_OPS = 10000;
let mergedOps: Array<Record<string, unknown>> = pending;
try {
const existingRaw = localStorage.getItem(SYNC_OUTBOX_STORAGE_KEY);
if (existingRaw) {
const parsed = JSON.parse(existingRaw) as {
userType?: string;
profileId?: string;
ops?: unknown;
} | null;
if (parsed
&& parsed.userType === userInfo.type
&& parsed.profileId === userInfo.profileId
&& Array.isArray(parsed.ops)
&& parsed.ops.length > 0) {
mergedOps = [
...(parsed.ops as Array<Record<string, unknown>>),
...pending
];
}
}
} catch { /* noop */ }
if (mergedOps.length > MAX_OUTBOX_OPS) {
mergedOps = mergedOps.slice(mergedOps.length - MAX_OUTBOX_OPS);
}
const outboxPayload = {
userType: userInfo.type,
profileId: userInfo.profileId,
userId: userInfo.id || undefined,
ops: mergedOps,
ts: Date.now()
};
localStorage.setItem(SYNC_OUTBOX_STORAGE_KEY, JSON.stringify(outboxPayload));
} catch (outboxErr) {
// Quota exceeded or other write error — proceed with keepalive anyway.
console.warn('[outbox] failed to persist outbox at unload:', outboxErr);
}
try {
for (let index = 0; index < pending.length; index += MAX_SYNC_OPS_PER_REQUEST) {
const batch = pending.slice(index, index + MAX_SYNC_OPS_PER_REQUEST);
@ -1223,9 +1394,9 @@ const PersistenceManager = () => {
if (browserPollingInterval) clearInterval(browserPollingInterval);
try { channel?.close(); } catch { /* noop */ }
channel = null;
localStorage.setItem = originalSetItem as any;
localStorage.removeItem = originalRemoveItem as any;
localStorage.clear = originalClear as any;
Storage.prototype.setItem = originalSetItem;
Storage.prototype.removeItem = originalRemoveItem;
Storage.prototype.clear = originalClear;
};
}, [isWatchRoute]); // Add isWatchRoute as dependency
@ -1571,6 +1742,20 @@ const AppWithIntro: React.FC = () => {
});
}, []);
// Idle prefetch (Milestone 6): preload high-traffic route chunks shortly
// after mount via requestIdleCallback (setTimeout fallback for Safari/FF).
useEffect(() => {
const IDLE_PREFETCH = ['/movies', '/tv-shows', '/anime', '/search'];
// @ts-expect-error - requestIdleCallback not in all TS DOM lib versions
const ric = window.requestIdleCallback || ((cb: () => void) => setTimeout(cb, 1500));
ric(() => {
for (const path of IDLE_PREFETCH) {
const entry = ROUTES.find(r => matchPath(r.path, path));
entry?.loader({ silent: true }).catch(() => {/* swallow — best-effort prefetch */});
}
}, { timeout: 3000 });
}, []);
return (
<div className="min-h-screen bg-black text-white relative overflow-hidden">
{/* Intro overlay — le site charge derrière */}
@ -1602,83 +1787,27 @@ const AppWithIntro: React.FC = () => {
<DefaultProfileNudge />
<ProfileGate>
<Routes>
{/* Eager — landing page, kept in main bundle */}
<Route path="/" element={<Home />} />
<Route path="/search" element={<Search />} />
<Route path="/movies" element={<Movies />} />
<Route path="/anime" element={<Anime />} />
<Route path="/tv-shows" element={<TVShows />} />
<Route path="/collections" element={<Collections />} />
<Route path="/collection/:id" element={<CollectionDetails />} />
<Route path="/movie/:id" element={<MovieDetails />} />
<Route path="/tv/:id" element={<TVDetails />} />
<Route path="/download/:type/:id" element={<DownloadPage />} />
<Route path="/debrid" element={<DebridPage />} />
<Route path="/genre/:mediaType/:genreId" element={<GenrePage />} />
<Route path="/roulette" element={<RoulettePage />} />
<Route path="/provider/:providerId" element={<ProviderContent />} />
<Route path="/provider/:providerId/:type" element={<ProviderCatalogPage />} />
<Route path="/provider/:providerId/:type/:genreId" element={<ProviderCatalogPage />} />
<Route path="/auth" element={<DiscordAuth />} />
<Route path="/auth/google" element={<GoogleAuth />} />
<Route path="/oauth/authorize" element={<OAuthAuthorizePage />} />
<Route path="/create-account" element={<CreateAccount />} />
{/* Routes spéciales avec props ou logique conditionnelle */}
<Route path="/login-bip39" element={<LoginBip39 />} />
<Route path="/create-account" element={<CreateAccount />} />
<Route path="/link-bip39" element={<LoginBip39 mode="link" />} />
<Route path="/link-bip39/create" element={<CreateAccount mode="link" />} />
<Route path="/person/:id" element={<PersonDetails />} />
<Route path="/profile" element={<PrivateRoute><Profile /></PrivateRoute>} />
<Route path="/alerts" element={<AlertsPage />} />
<Route path="/live-tv" element={<LiveTV />} />
<Route path="/watch/movie/:tmdbid" element={<WatchMovie />} />
<Route path="/watch/tv/:tmdbid/s/:season/e/:episode" element={<WatchTv />} />
<Route path="/watch/anime/:id/season/:season/episode/:episode" element={<WatchAnime />} />
{/* Watch Party Routes */}
<Route path="/watchparty/create" element={<WatchPartyCreate />} />
<Route path="/watchparty/room/:roomId" element={<WatchPartyRoom />} />
<Route path="/watchparty/join" element={<WatchPartyJoin />} />
<Route path="/watchparty/join/:code" element={<WatchPartyJoin />} />
<Route path="/watchparty/list" element={<WatchPartyList />} />
<Route path="/suggestion" element={<SuggestionPage />} />
<Route path="/extension" element={<ExtensionPage />} />
<Route path="/app" element={<AppDownloadPage />} />
<Route path="/list/:shareCode" element={<SharedListPage />} />
<Route path="/list-catalog" element={<SharedListsCatalogPage />} />
<Route path="/dmca" element={<DMCA />} />
<Route path="/admin" element={<AdminPage />} />
<Route path={APRIL_FOOLS_ADMIN_PATH} element={isAprilFoolsAdminRouteEnabled ? <AprilFoolsAdminPage /> : <Navigate to="/" replace />} />
<Route path="/profile-selection" element={<ProfileSelection />} />
<Route path="/profile-management" element={<ProfileManagement />} />
{/* Wishboard / Greenlight Routes */}
<Route path="/wishboard" element={<WishboardPage />} />
<Route path="/wishboard/new" element={<WishboardNewRequest />} />
<Route path="/wishboard/my-requests" element={<WishboardUserRequests />} />
<Route path="/wishboard/submit-link" element={<SubmitLinkPage />} />
{/* VIP Route */}
<Route path="/vip" element={<VipPage />} />
<Route path="/vip/don" element={<VipDonatePage />} />
<Route path="/vip/invoices" element={<VipInvoicesPage />} />
<Route path="/vip/invoice/:publicId" element={<VipInvoicePage />} />
<Route path="/vip/cadeau/:giftToken" element={<VipGiftPage />} />
{/* What is Movix Route */}
<Route path="/about" element={<WhatIsMovixPage />} />
<Route path="/help/*" element={<HelpRouter />} />
<Route path="/privacy" element={<Privacy />} />
<Route path="/terms-of-service" element={<TermsOfService />} />
<Route path="/terms" element={<Navigate to="/terms-of-service" replace />} />
{/* CinéGraph Route */}
<Route path="/cinegraph" element={<CineGraphPage />} />
{/* Settings Route */}
<Route path="/settings" element={<SettingsPage />} />
{/* Top 10 Route */}
<Route path="/top10" element={<Top10Page />} />
{/* France.tv Routes */}
<Route path="/ftv" element={<FranceTVBrowse />} />
<Route path="/ftv/info/:encoded" element={<FranceTVInfo />} />
<Route path="/ftv/watch/:encoded" element={<FranceTVPlayer />} />
{/* Wrapped Route */}
<Route path="/wrapped" element={<WrappedPage />} />
<Route path="/wrapped/:year" element={<WrappedPage />} />
{/* Route catch-all pour la page 404 */}
<Route path="/profile-selection" element={<ProfileSelection />} />
<Route
path={APRIL_FOOLS_ADMIN_PATH}
element={isAprilFoolsAdminRouteEnabled
? <AprilFoolsAdminPage />
: <Navigate to="/" replace />}
/>
{/* Toutes les autres routes — depuis le registry */}
{ROUTES.map(renderRouteEntry)}
{/* 404 — eager (frequently entered cold) */}
<Route path="*" element={<NotFound />} />
</Routes>
</ProfileGate>
@ -1709,7 +1838,7 @@ const EmbedBlockPage = () => (
{i18n.t('embed.message')}
</p>
<a
href="https://movix.cash"
href="https://movix.tax"
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-block bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded transition sm:py-3 sm:px-6 lg:py-4 lg:px-8"
@ -1738,6 +1867,19 @@ const MaintenancePage = ({ onContinue }: { onContinue: () => void }) => (
</div>
);
// Wraps the tree in a <MotionConfig> tied to the Mode léger / animation prefs.
// When `transitions` is disabled (manually or because Mode léger is on),
// framer-motion treats EVERY animation as if `prefers-reduced-motion: reduce`
// were set — initial/animate/exit are skipped on transform/opacity for free.
const AnimationMotionConfig: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { effectivePrefs } = useLightMode();
return (
<MotionConfig reducedMotion={effectivePrefs.transitions ? 'user' : 'always'}>
{children}
</MotionConfig>
);
};
function App() {
const [forceContinue, setForceContinue] = React.useState(false);
@ -1763,6 +1905,8 @@ function App() {
return (
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<TooltipProvider delayDuration={300}>
<LightModeProvider>
<AnimationMotionConfig>
<SearchProvider>
<AdFreePopupProvider>
<AuthProvider>
@ -1773,6 +1917,7 @@ function App() {
<IntroProvider>
<IOSHomeScreenHandler />
<AppWithIntro />
<TopProgressBar />
<Toaster position="bottom-right" richColors />
<DnsBlockBanner />
</IntroProvider>
@ -1783,6 +1928,8 @@ function App() {
</AuthProvider>
</AdFreePopupProvider>
</SearchProvider>
</AnimationMotionConfig>
</LightModeProvider>
</TooltipProvider>
</BrowserRouter>
);

View file

@ -1,6 +1,6 @@
import React, { useState, useCallback, useEffect } from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { Link } from "react-router-dom";
import { PrefetchLink as Link } from '@/routing/PrefetchLink';
import { Play, ShieldAlert, Settings, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useAdFreePopup } from "../context/AdFreePopupContext";

View file

@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { PrefetchLink as Link } from '@/routing/PrefetchLink';
import { motion, AnimatePresence } from 'framer-motion';
import {
Search, Trash2, Loader2, Film, Tv, RefreshCw, MessageSquare, AlertCircle, Sparkles,

View file

@ -8,6 +8,7 @@ import {
Link2,
ListOrdered,
MessageSquare,
Plug,
ShieldCheck,
Sparkles,
Sprout,
@ -18,6 +19,7 @@ import AdminComments from './AdminComments';
import AdminHelpFeedback from './AdminHelpFeedback';
import AdminLinkSubmissions from './Greenlight/AdminLinkSubmissions';
import AdminWishboard from './Greenlight/AdminWishboard';
import AdminOAuthApps from './AdminOAuthApps';
import AdminReports from './AdminReports';
import AdminSharedLists from './AdminSharedLists';
import StreamingLinksManager from './StreamingLinksManager';
@ -30,6 +32,7 @@ type AdminSection =
| 'links'
| 'vip-keys'
| 'vip-invoices'
| 'oauth-apps'
| 'wishboard'
| 'link-submissions'
| 'comments'
@ -80,6 +83,14 @@ const AdminDashboard: React.FC<AdminDashboardProps> = ({ role }) => {
accent: 'text-yellow-300',
highlight: '234 179 8'
},
{
id: 'oauth-apps',
title: t('adminOauthApps.cardTitle'),
description: t('adminOauthApps.cardDesc'),
icon: Plug,
accent: 'text-purple-300',
highlight: '168 85 247'
},
{
id: 'wishboard',
title: t('admin.wishboardGreenlight'),
@ -305,6 +316,16 @@ const AdminDashboard: React.FC<AdminDashboardProps> = ({ role }) => {
<AdminHelpFeedback />
</div>
)}
{activeSection === 'oauth-apps' && role === 'admin' && (
<div>
<h2 className="mb-6 flex items-center gap-3 text-2xl font-bold text-white">
<Plug className="h-6 w-6 text-purple-300" />
{t('adminOauthApps.cardTitle')}
</h2>
<AdminOAuthApps />
</div>
)}
</AnimatedBorderCard>
)}
</div>

View file

@ -1,252 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { verifyAdminCode, isAdminAuthenticated, logoutAdmin } from '../services/adminService';
import { Lock, Unlock, LogOut } from 'lucide-react';
import { checkDiscordMembership } from '../utils/discord';
import { DISCORD_CONFIG } from '../config/discord';
interface AdminLoginProps {
onAdminStatusChange?: (isAdmin: boolean) => void;
}
const AdminLogin: React.FC<AdminLoginProps> = ({ onAdminStatusChange }) => {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [adminCode, setAdminCode] = useState('');
const [isAdmin, setIsAdmin] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isLoggingOut, setIsLoggingOut] = useState(false);
const [error, setError] = useState('');
// Check if user is already authenticated as admin
useEffect(() => {
// Ignorer la vérification si on est en train de se déconnecter
if (isLoggingOut) return;
const checkAdminStatus = async () => {
console.log("Vérification du statut admin au chargement");
// Check if authenticated via Discord
const isDiscordAuth = localStorage.getItem('discord_auth') === 'true';
if (isDiscordAuth) {
try {
const discordUser = JSON.parse(localStorage.getItem('discord_user') || '{}');
// Si l'utilisateur est déjà identifié comme admin via Discord, conserver son statut
if (discordUser.isAdmin) {
console.log("User is already admin via Discord role (cached)");
setIsAdmin(true);
if (onAdminStatusChange) {
onAdminStatusChange(true);
}
return;
}
// Check if we need to refresh the role info
const lastCheck = parseInt(localStorage.getItem('discord_last_check') || '0');
const now = Date.now();
const needsRefresh = now - lastCheck > (DISCORD_CONFIG.CACHE_DURATION * 1000);
if (needsRefresh) {
console.log("Refreshing Discord roles...");
const accessToken = localStorage.getItem('discord_token');
if (accessToken) {
const membershipData = await checkDiscordMembership(accessToken);
// Ne pas modifier l'état si on est rate limited et qu'on n'a pas de données valides
if (membershipData.isRateLimited && !membershipData.isAdmin) {
console.log("Rate limited, preserving current admin status");
return;
}
const isDiscordAdmin = membershipData.isAdmin;
// Update the user info in localStorage
const updatedUser = {
...discordUser,
roles: membershipData.roles,
isAdmin: isDiscordAdmin
};
localStorage.setItem('discord_user', JSON.stringify(updatedUser));
localStorage.setItem('discord_last_check', now.toString());
if (isDiscordAdmin) {
console.log("User is admin via Discord role (fresh check)");
setIsAdmin(true);
if (onAdminStatusChange) {
onAdminStatusChange(true);
}
return;
}
}
}
} catch (error) {
console.error("Error checking Discord admin status:", error);
// En cas d'erreur, on conserve le statut admin actuel si l'utilisateur l'était déjà
if (isAdmin) {
console.log("Error during Discord check, preserving admin status");
return;
}
}
}
// Fallback to traditional admin authentication
const adminStatus = await isAdminAuthenticated();
console.log(`Statut admin traditionnel: ${adminStatus}`);
setIsAdmin(adminStatus);
if (onAdminStatusChange) {
onAdminStatusChange(adminStatus);
}
};
checkAdminStatus();
}, [onAdminStatusChange, isLoggingOut, isAdmin]);
const handleAdminLogin = async () => {
if (!adminCode.trim()) {
setError(t('admin.enterAdminCode'));
return;
}
setIsLoading(true);
setError('');
try {
console.log(`Tentative de connexion avec le code: ${adminCode}`);
const isValid = await verifyAdminCode(adminCode);
console.log(`Résultat de la vérification: ${isValid}`);
if (isValid) {
setIsAdmin(true);
setIsOpen(false);
setAdminCode('');
if (onAdminStatusChange) {
onAdminStatusChange(true);
}
} else {
setError(t('admin.invalidAdminCode'));
}
} catch (err) {
console.error('Erreur complète:', err);
setError(t('admin.codeVerificationError'));
} finally {
setIsLoading(false);
}
};
const handleLogout = async () => {
setIsLoggingOut(true);
try {
await logoutAdmin();
// Attendre un court instant pour s'assurer que le localStorage est bien mis à jour
setTimeout(() => {
setIsAdmin(false);
if (onAdminStatusChange) {
onAdminStatusChange(false);
}
setIsLoggingOut(false);
}, 100);
} catch (error) {
console.error('Erreur lors de la déconnexion:', error);
setIsLoggingOut(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleAdminLogin();
}
};
// Vérifier si on doit afficher le bouton Admin basé sur les rôles Discord
const shouldShowAdminButton = () => {
// Si déjà authentifié comme admin, on affiche toujours le bouton
if (isAdmin) return true;
// Vérifier l'authentification Discord
const isDiscordAuth = localStorage.getItem('discord_auth') === 'true';
if (isDiscordAuth) {
try {
const discordUser = JSON.parse(localStorage.getItem('discord_user') || '{}');
return discordUser.isAdmin || false;
} catch {
return false;
}
}
// Si l'utilisateur n'est pas connecté via Discord, ne pas afficher le bouton
return false;
};
// Si on ne doit pas afficher le bouton, retourner null
if (!shouldShowAdminButton()) {
return null;
}
return (
<div className="relative">
{isAdmin ? (
<button
onClick={handleLogout}
disabled={isLoggingOut}
className="flex items-center space-x-1 bg-green-600 hover:bg-green-700 px-3 py-1 rounded-md text-white text-sm transition-colors duration-200 disabled:opacity-70"
>
{isLoggingOut ? (
<>
<div className="animate-spin h-3 w-3 border-2 border-white border-t-transparent rounded-full mr-1"></div>
<span>{t('admin.loggingOut')}</span>
</>
) : (
<>
<Unlock size={14} />
<span>{t('admin.title')}</span>
<LogOut size={14} />
</>
)}
</button>
) : (
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center space-x-1 bg-gray-700 hover:bg-gray-600 px-3 py-1 rounded-md text-white text-sm transition-colors duration-200"
>
<Lock size={14} />
<span>{t('admin.title')}</span>
</button>
)}
{isOpen && !isAdmin && (
<div className="absolute top-10 right-0 mt-2 bg-gray-800 border border-gray-700 rounded-md shadow-lg p-4 w-64 z-50">
<h3 className="text-white font-medium mb-2">{t('admin.adminLogin')}</h3>
<div className="space-y-3">
<div>
<input
type="password"
value={adminCode}
onChange={(e) => setAdminCode(e.target.value)}
onKeyPress={handleKeyPress}
placeholder={t('admin.adminCode')}
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
autoFocus
/>
</div>
{error && <p className="text-red-500 text-xs">{error}</p>}
<button
onClick={handleAdminLogin}
disabled={isLoading || !adminCode.trim()}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800 disabled:opacity-70 px-3 py-2 rounded-md text-white transition-colors duration-200 flex items-center justify-center"
>
{isLoading ? (
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent"></div>
) : (
t('admin.verify')
)}
</button>
</div>
</div>
)}
</div>
);
};
export default AdminLogin;

File diff suppressed because it is too large Load diff

View file

@ -1,557 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Comment, COMMENT_LENGTH_LIMITS } from '../types/Comment';
import { ThumbsUp, ThumbsDown, Reply, Trash2, Send, Clock } from 'lucide-react';
import { likeComment, dislikeComment, deleteComment, addComment, reactWithEmoji } from '../services/commentService';
import { format } from 'date-fns';
import { fr } from 'date-fns/locale';
import EmojiPicker from './EmojiPicker';
import ReactionBar from './ReactionBar';
import { addReplyNotification } from '../services/notificationService';
import ReactMarkdown from 'react-markdown';
import { safeRemarkGfm } from '../utils/markdownPlugins';
import remarkEmoji from 'remark-emoji';
import MarkdownToolbar from './MarkdownToolbar';
// Définition de la limite de caractères pour les réponses
const MAX_REPLY_LENGTH = COMMENT_LENGTH_LIMITS.REPLY;
const markdownComponents = {
p: ({ children }: any) => <p className="mb-1 last:mb-0">{children}</p>,
strong: ({ children }: any) => <strong className="font-bold text-white">{children}</strong>,
em: ({ children }: any) => <em className="italic">{children}</em>,
code: ({ children, className }: any) => {
const isBlock = className?.includes('language-');
return isBlock ? (
<pre className="bg-gray-900/50 rounded p-2 my-1 overflow-x-auto text-xs">
<code className={className}>{children}</code>
</pre>
) : (
<code className="bg-gray-900/50 text-blue-300 px-1 py-0.5 rounded text-[0.85em]">{children}</code>
);
},
pre: ({ children }: any) => <>{children}</>,
a: ({ href, children }: any) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-blue-400 hover:underline break-all">
{children}
</a>
),
ul: ({ children }: any) => <ul className="list-disc list-inside ml-2 my-1">{children}</ul>,
ol: ({ children }: any) => <ol className="list-decimal list-inside ml-2 my-1">{children}</ol>,
blockquote: ({ children }: any) => (
<blockquote className="border-l-2 border-gray-500 pl-2 my-1 text-gray-400 italic">{children}</blockquote>
),
del: ({ children }: any) => <del className="line-through text-gray-500">{children}</del>,
// Bloquer les images et headings dans les commentaires
img: () => null,
h1: ({ children }: any) => <p className="font-bold text-white mb-1">{children}</p>,
h2: ({ children }: any) => <p className="font-bold text-white mb-1">{children}</p>,
h3: ({ children }: any) => <p className="font-bold text-white mb-1">{children}</p>,
h4: ({ children }: any) => <p className="font-bold text-white mb-1">{children}</p>,
h5: ({ children }: any) => <p className="font-bold text-white mb-1">{children}</p>,
h6: ({ children }: any) => <p className="font-bold text-white mb-1">{children}</p>,
};
const remarkPlugins = safeRemarkGfm ? [safeRemarkGfm, remarkEmoji] : [remarkEmoji];
const REPLY_COOLDOWN_TIME = 15; // Cooldown plus court pour les réponses (15 secondes)
interface CommentItemProps {
comment: Comment;
currentUserId: string | null;
contentId: string;
contentType: 'movie' | 'series';
refreshComments: () => void;
isAdmin?: boolean;
}
const CommentItem: React.FC<CommentItemProps> = ({
comment,
currentUserId,
contentId,
contentType,
refreshComments,
isAdmin = false
}) => {
const { t } = useTranslation();
const [isReplying, setIsReplying] = useState(false);
const [replyContent, setReplyContent] = useState('');
const [isExpanded, setIsExpanded] = useState(false);
const [likeAnimation, setLikeAnimation] = useState(false);
const [dislikeAnimation, setDislikeAnimation] = useState(false);
const [replyCooldown, setReplyCooldown] = useState(0);
const [isLoadingReply, setIsLoadingReply] = useState(false);
const replyTextareaRef = useRef<HTMLTextAreaElement>(null);
// Gérer le cooldown des réponses
useEffect(() => {
if (replyCooldown <= 0) return;
const timer = setInterval(() => {
setReplyCooldown(prev => Math.max(0, prev - 1));
}, 1000);
return () => clearInterval(timer);
}, [replyCooldown]);
// Vérification du cooldown au chargement
useEffect(() => {
const lastReplyTime = localStorage.getItem('lastReplyTime');
if (lastReplyTime && !isAdmin) {
const timeElapsed = Math.floor((Date.now() - parseInt(lastReplyTime)) / 1000);
const remainingTime = REPLY_COOLDOWN_TIME - timeElapsed;
if (remainingTime > 0) {
setReplyCooldown(remainingTime);
}
}
}, [isAdmin]);
const startReplyCooldown = () => {
// Les admins n'ont pas de cooldown
if (isAdmin) return;
setReplyCooldown(REPLY_COOLDOWN_TIME);
localStorage.setItem('lastReplyTime', Date.now().toString());
};
const handleLike = async () => {
if (!currentUserId) return;
try {
// Animation effect
setLikeAnimation(true);
setTimeout(() => setLikeAnimation(false), 500);
await likeComment(comment.id, currentUserId);
refreshComments();
} catch (error) {
console.error('Error liking comment:', error);
}
};
const handleDislike = async () => {
if (!currentUserId) return;
try {
// Animation effect
setDislikeAnimation(true);
setTimeout(() => setDislikeAnimation(false), 500);
await dislikeComment(comment.id, currentUserId);
refreshComments();
} catch (error) {
console.error('Error disliking comment:', error);
}
};
const handleDelete = async () => {
if (window.confirm(t('comments.deleteConfirm'))) {
try {
await deleteComment(comment.id, currentUserId || undefined, isAdmin);
refreshComments();
} catch (error) {
console.error('Error deleting comment:', error);
}
}
};
const handleReply = async () => {
if (!currentUserId || !replyContent.trim() || (replyCooldown > 0 && !isAdmin) || isLoadingReply) return;
try {
setIsLoadingReply(true);
// Get user details from localStorage
let username = t('comments.defaultUser');
let userAvatar = 'https://as2.ftcdn.net/v2/jpg/05/89/93/27/1000_F_589932782_vQAEAZhHnq1QCGu5ikwrYaQD0Mmurm0N.webp';
const isDiscordAuth = localStorage.getItem('discord_auth') === 'true';
const isGoogleAuth = localStorage.getItem('google_auth') === 'true';
if (isDiscordAuth) {
const userInfoStr = localStorage.getItem('discord_user');
if (userInfoStr) {
const userInfo = JSON.parse(userInfoStr);
username = userInfo.username;
userAvatar = userInfo.avatar;
}
} else if (isGoogleAuth) {
const userInfoStr = localStorage.getItem('google_user');
if (userInfoStr) {
const userInfo = JSON.parse(userInfoStr);
username = userInfo.name;
userAvatar = userInfo.picture;
}
}
// Ajouter le commentaire
const replyId = await addComment(
contentId,
contentType,
currentUserId,
username,
userAvatar,
replyContent,
comment.id,
isAdmin // Transmettre le statut admin
);
// Démarrer le cooldown pour les réponses si pas admin
startReplyCooldown();
setReplyContent('');
setIsReplying(false);
// Mettre à jour les commentaires
refreshComments();
// Ajouter une notification
await addReplyNotification(
comment.id,
replyId,
replyContent,
currentUserId,
username
);
} catch (error) {
console.error('Error adding reply:', error);
} finally {
setIsLoadingReply(false);
}
};
const handleEmojiInsert = (emoji: string) => {
setReplyContent(prev => {
if (prev.length + emoji.length <= MAX_REPLY_LENGTH) {
return prev + emoji;
}
return prev;
});
};
const handleReaction = async (emoji: string) => {
if (!currentUserId) return;
try {
await reactWithEmoji(comment.id, emoji, currentUserId);
refreshComments();
} catch (error) {
console.error('Error reacting to comment:', error);
}
};
const formatDate = (date: Date) => {
try {
// Format différent pour les petits écrans (détecté via CSS media query)
const isMobile = window.innerWidth < 640;
if (isMobile) {
return format(date, 'dd/MM/yy HH:mm', { locale: fr });
}
return format(date, 'dd MMMM yyyy à HH:mm', { locale: fr });
} catch (error) {
return t('comments.unknownDate');
}
};
const formatCooldownTime = (seconds: number) => {
if (seconds < 60) {
return `${seconds}s`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds < 10 ? '0' : ''}${remainingSeconds}`;
};
const isLikedByCurrentUser = currentUserId && comment.likedBy && comment.likedBy.includes(currentUserId);
const isDislikedByCurrentUser = currentUserId && comment.dislikedBy && comment.dislikedBy.includes(currentUserId);
// Un utilisateur peut supprimer son propre commentaire ou si c'est un admin
const canDeleteComment = (currentUserId && currentUserId === comment.userId) || isAdmin;
return (
<div className="bg-gray-800 p-4 rounded-lg mb-3">
<div className="flex items-start space-x-3">
<img
src={comment.userAvatar || 'https://as2.ftcdn.net/v2/jpg/05/89/93/27/1000_F_589932782_vQAEAZhHnq1QCGu5ikwrYaQD0Mmurm0N.webp'}
alt={comment.username}
className="w-10 h-10 rounded-full object-cover shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between flex-wrap">
<div className="flex items-center">
<h3 className="font-medium text-white mr-2">{comment.username}</h3>
{comment.isAdmin && (
<span className="bg-red-600 text-white text-xs px-2 py-0.5 rounded-full mr-2 font-medium">
ADMIN
</span>
)}
</div>
<span className="text-xs text-gray-400">{formatDate(comment.createdAt)}</span>
</div>
<div className="mt-1 text-gray-300 break-words max-w-full overflow-hidden text-sm sm:text-base overflow-x-hidden prose-invert">
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}>
{comment.content}
</ReactMarkdown>
</div>
<ReactionBar
reactions={comment.reactions || []}
onReactionClick={handleReaction}
currentUserId={currentUserId}
/>
<div className="flex items-center mt-3 space-x-2 sm:space-x-4 flex-wrap">
<button
onClick={handleLike}
className={`flex items-center transition-all duration-300 ${
isLikedByCurrentUser
? 'text-blue-500'
: 'text-gray-400 hover:text-blue-500'
} ${likeAnimation ? 'animate-like' : ''} text-xs sm:text-sm`}
>
<ThumbsUp
size={16}
className={`mr-1 transform transition-transform duration-300 ${likeAnimation ? 'scale-150' : ''} ${isLikedByCurrentUser ? 'fill-current' : ''}`}
/>
<span className={`${likeAnimation ? 'animate-bounce' : ''}`}>{comment.likes}</span>
</button>
<button
onClick={handleDislike}
className={`flex items-center transition-all duration-300 ${
isDislikedByCurrentUser
? 'text-red-500'
: 'text-gray-400 hover:text-red-500'
} ${dislikeAnimation ? 'animate-dislike' : ''} text-xs sm:text-sm`}
>
<ThumbsDown
size={16}
className={`mr-1 transform transition-transform duration-300 ${dislikeAnimation ? 'scale-150' : ''} ${isDislikedByCurrentUser ? 'fill-current' : ''}`}
/>
<span className={`${dislikeAnimation ? 'animate-bounce' : ''}`}>{comment.dislikes || 0}</span>
</button>
{currentUserId && (
<button
onClick={() => setIsReplying(!isReplying)}
disabled={replyCooldown > 0}
className={`flex items-center text-gray-400 hover:text-blue-500 transition hover:scale-105 ${replyCooldown > 0 ? 'opacity-50 cursor-not-allowed' : ''} text-xs sm:text-sm`}
>
<Reply size={16} className="mr-1" />
<span>{replyCooldown > 0 ? `(${formatCooldownTime(replyCooldown)})` : t('comments.reply')}</span>
</button>
)}
{canDeleteComment && (
<button
onClick={handleDelete}
className="flex items-center text-gray-400 hover:text-red-500 transition hover:scale-105 text-xs sm:text-sm"
>
<Trash2 size={16} className="mr-1" />
<span>{t('common.delete')}</span>
</button>
)}
</div>
{isReplying && (
<div className="mt-3 ml-10">
<div className="relative bg-gray-700 rounded-lg">
<textarea
ref={replyTextareaRef}
value={replyContent}
onChange={(e) => setReplyContent(e.target.value)}
placeholder={replyCooldown > 0 && !isAdmin ? t('comments.replyCooldownMessage', { time: formatCooldownTime(replyCooldown) }) : t('comments.writeReply')}
disabled={replyCooldown > 0 && !isAdmin}
className={`w-full bg-gray-700 text-white p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none min-h-[80px] ${replyCooldown > 0 && !isAdmin ? 'opacity-70 cursor-not-allowed' : ''}`}
maxLength={MAX_REPLY_LENGTH}
/>
<div className="absolute right-2 bottom-2 flex items-center space-x-2">
<EmojiPicker onEmojiSelect={handleEmojiInsert} />
</div>
</div>
<MarkdownToolbar
textareaRef={replyTextareaRef}
value={replyContent}
onChange={setReplyContent}
maxLength={MAX_REPLY_LENGTH}
/>
<div className="flex justify-between mt-2">
{replyCooldown > 0 && !isAdmin && (
<div className="flex items-center text-yellow-500 text-sm">
<Clock size={14} className="mr-1" />
<span>{t('comments.waitLabel')} {formatCooldownTime(replyCooldown)}</span>
</div>
)}
<div className="flex items-center space-x-2 ml-auto">
<span className={`text-xs ${replyContent.length >= MAX_REPLY_LENGTH ? 'text-red-500' : 'text-gray-400'}`}>
{replyContent.length}/{MAX_REPLY_LENGTH}
</span>
<button
onClick={handleReply}
disabled={replyCooldown > 0 && !isAdmin || !replyContent.trim() || isLoadingReply}
className={`bg-blue-600 text-white px-3 py-1.5 rounded-lg flex items-center space-x-1 text-sm ${
(replyCooldown > 0 && !isAdmin) || !replyContent.trim() || isLoadingReply ? 'opacity-50 cursor-not-allowed' : 'hover:bg-blue-700'
}`}
>
<Send size={14} />
<span>{t('comments.reply')}</span>
</button>
<button
onClick={() => setIsReplying(false)}
className="bg-gray-600 text-white px-3 py-1.5 rounded-lg text-sm hover:bg-gray-700"
>
{t('common.cancel')}
</button>
</div>
</div>
</div>
)}
{comment.replies && comment.replies.length > 0 && (
<div className="mt-4">
{!isExpanded && (
<button
onClick={() => setIsExpanded(true)}
className="text-blue-500 text-sm hover:underline"
>
{t('comments.viewReplies', { count: comment.replies.length })}
</button>
)}
{isExpanded && (
<>
<button
onClick={() => setIsExpanded(false)}
className="text-blue-500 text-sm mb-2 hover:underline"
>
{t('comments.hideReplies')}
</button>
<div className="pl-4 border-l-2 border-gray-700">
{comment.replies.map(reply => {
const isReplyLikedByCurrentUser = currentUserId && reply.likedBy && reply.likedBy.includes(currentUserId);
const isReplyDislikedByCurrentUser = currentUserId && reply.dislikedBy && reply.dislikedBy.includes(currentUserId);
return (
<div key={reply.id} className="mt-3">
<div className="flex items-start space-x-3">
<img
src={reply.userAvatar || 'https://as2.ftcdn.net/v2/jpg/05/89/93/27/1000_F_589932782_vQAEAZhHnq1QCGu5ikwrYaQD0Mmurm0N.webp'}
alt={reply.username}
className="w-8 h-8 rounded-full object-cover shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between flex-wrap">
<div className="flex items-center">
<h3 className="font-medium text-white text-sm mr-2">{reply.username}</h3>
{reply.isAdmin && (
<span className="bg-red-600 text-white text-xs px-1.5 py-0.5 rounded-full mr-2 font-medium text-[10px]">
ADMIN
</span>
)}
</div>
<span className="text-xs text-gray-400 inline-block">{formatDate(reply.createdAt)}</span>
</div>
<div className="mt-1 text-gray-300 break-words max-w-full overflow-hidden text-xs sm:text-sm overflow-x-hidden prose-invert">
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}>
{reply.content}
</ReactMarkdown>
</div>
<ReactionBar
reactions={reply.reactions || []}
onReactionClick={async (emoji) => {
if (!currentUserId) return;
await reactWithEmoji(reply.id, emoji, currentUserId);
refreshComments();
}}
currentUserId={currentUserId}
/>
<div className="flex items-center mt-2 space-x-2 sm:space-x-4 flex-wrap">
<button
onClick={async () => {
if (!currentUserId) return;
// Animation for replies
const button = document.getElementById(`like-${reply.id}`);
if (button) {
button.classList.add('scale-150');
setTimeout(() => button.classList.remove('scale-150'), 300);
}
await likeComment(reply.id, currentUserId);
refreshComments();
}}
className={`flex items-center transition-all duration-300 ${
isReplyLikedByCurrentUser
? 'text-blue-500'
: 'text-gray-400 hover:text-blue-500'
} transition text-xs`}
>
<ThumbsUp
id={`like-${reply.id}`}
size={14}
className={`mr-1 transform transition-transform duration-300 ${isReplyLikedByCurrentUser ? 'fill-current' : ''}`}
/>
<span>{reply.likes}</span>
</button>
<button
onClick={async () => {
if (!currentUserId) return;
// Animation for replies
const button = document.getElementById(`dislike-${reply.id}`);
if (button) {
button.classList.add('scale-150');
setTimeout(() => button.classList.remove('scale-150'), 300);
}
await dislikeComment(reply.id, currentUserId);
refreshComments();
}}
className={`flex items-center transition-all duration-300 ${
isReplyDislikedByCurrentUser
? 'text-red-500'
: 'text-gray-400 hover:text-red-500'
} transition text-xs`}
>
<ThumbsDown
id={`dislike-${reply.id}`}
size={14}
className={`mr-1 transform transition-transform duration-300 ${isReplyDislikedByCurrentUser ? 'fill-current' : ''}`}
/>
<span>{reply.dislikes || 0}</span>
</button>
{/* Bouton de suppression pour les réponses - visible si l'utilisateur est l'auteur de la réponse ou un admin */}
{(currentUserId && (currentUserId === reply.userId || isAdmin)) && (
<button
onClick={async () => {
if (window.confirm(t('comments.deleteReplyConfirm'))) {
await deleteComment(reply.id, currentUserId || undefined, isAdmin);
refreshComments();
}
}}
className="flex items-center text-gray-400 hover:text-red-500 transition hover:scale-105 text-xs"
>
<Trash2 size={14} className="mr-1" />
<span>{t('common.delete')}</span>
</button>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
</>
)}
</div>
)}
</div>
</div>
</div>
);
};
export default CommentItem;

View file

@ -1,423 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { getComments, addComment } from '../services/commentService';
import { Comment, COMMENT_LENGTH_LIMITS } from '../types/Comment';
import CommentItem from './CommentItem';
import { Send, Clock } from 'lucide-react';
import EmojiPicker from './EmojiPicker';
import AdminLogin from './AdminLogin';
import { isUserVip } from '../utils/authUtils';
interface CommentSectionProps {
contentId: string;
contentType: 'movie' | 'series';
}
const MAX_COMMENT_LENGTH = COMMENT_LENGTH_LIMITS.COMMENT;
const COOLDOWN_TIME = 30; // Cooldown en secondes
const CommentSection: React.FC<CommentSectionProps> = ({ contentId, contentType }) => {
const { t } = useTranslation();
const [comments, setComments] = useState<Comment[]>([]);
const [newComment, setNewComment] = useState('');
const [loading, setLoading] = useState(true);
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isAdmin, setIsAdmin] = useState(false);
const [cooldownRemaining, setCooldownRemaining] = useState(0);
const [isLoadingAddComment, setIsLoadingAddComment] = useState(false);
// Fonction pour vérifier l'authentification - memoized pour éviter les recalculs inutiles
const checkAuthStatus = useCallback(() => {
const isDiscordAuth = localStorage.getItem('discord_auth') === 'true';
const isGoogleAuth = localStorage.getItem('google_auth') === 'true';
const isVipUser = isUserVip();
const isBip39Auth = localStorage.getItem('bip39_auth') === 'true';
// VIP via access_code
let isVipAuth = false;
let vipUser = null;
// BIP39 authentication
let isBip39User = false;
let bip39User = null;
const authStr = localStorage.getItem('auth');
if (authStr) {
try {
const authObj = JSON.parse(authStr);
if (authObj && authObj.userProfile) {
if (authObj.userProfile.provider === 'access_code') {
isVipAuth = true;
vipUser = authObj.userProfile;
} else if (authObj.userProfile.provider === 'bip39') {
isBip39User = true;
bip39User = authObj.userProfile;
}
}
} catch (error) {
console.error('Error parsing auth data:', error);
}
}
setIsAuthenticated(isDiscordAuth || isGoogleAuth || isVipAuth || isVipUser || isBip39Auth || isBip39User);
// Prioritize Discord, then Google, then BIP39, then VIP for user identity
if (isDiscordAuth) {
try {
const userInfoStr = localStorage.getItem('discord_user');
if (userInfoStr) {
const userInfo = JSON.parse(userInfoStr);
setCurrentUserId(userInfo?.id || 'discord_user');
}
} catch (error) {
console.error('Error parsing Discord user data:', error);
}
} else if (isGoogleAuth) {
try {
const userInfoStr = localStorage.getItem('google_user');
if (userInfoStr) {
const userInfo = JSON.parse(userInfoStr);
setCurrentUserId(userInfo?.id || 'google_user');
}
} catch (error) {
console.error('Error parsing Google user data:', error);
}
} else if (isBip39Auth || (isBip39User && bip39User)) {
setCurrentUserId(bip39User?.id || 'bip39_user');
} else if (isVipAuth && vipUser) {
setCurrentUserId(vipUser.id || 'vip_user');
} else if (isVipUser) {
// Fallback for VIP without specific auth
const guestId = localStorage.getItem('guest_uuid') || 'anonymous_vip';
setCurrentUserId(guestId);
}
}, []);
// Chargement initial des commentaires
useEffect(() => {
const fetchInitialData = async () => {
setLoading(true);
try {
const fetchedComments = await getComments(contentId, contentType);
setComments(fetchedComments);
} catch (error) {
console.error('Error loading comments:', error);
} finally {
setLoading(false);
}
};
fetchInitialData();
checkAuthStatus();
}, [contentId, contentType, checkAuthStatus]);
// Gérer le cooldown
useEffect(() => {
if (cooldownRemaining <= 0) return;
const timer = setInterval(() => {
setCooldownRemaining(prev => Math.max(0, prev - 1));
}, 1000);
return () => clearInterval(timer);
}, [cooldownRemaining]);
// Charge les commentaires de manière optimisée
const refreshComments = useCallback(async () => {
try {
const fetchedComments = await getComments(contentId, contentType);
setComments(fetchedComments);
} catch (error) {
console.error('Error refreshing comments:', error);
}
}, [contentId, contentType]);
const startCooldown = () => {
// Les admins n'ont pas de cooldown
if (isAdmin) return;
setCooldownRemaining(COOLDOWN_TIME);
localStorage.setItem('lastCommentTime', Date.now().toString());
};
// Vérification du cooldown au chargement
useEffect(() => {
const lastCommentTime = localStorage.getItem('lastCommentTime');
if (lastCommentTime && !isAdmin) {
const timeElapsed = Math.floor((Date.now() - parseInt(lastCommentTime)) / 1000);
const remainingTime = COOLDOWN_TIME - timeElapsed;
if (remainingTime > 0) {
setCooldownRemaining(remainingTime);
}
}
}, [isAdmin]);
const handleAdminStatusChange = (status: boolean) => {
setIsAdmin(status);
// Si l'utilisateur est devenu admin, on annule le cooldown
if (status) {
setCooldownRemaining(0);
}
};
const handleAddComment = async () => {
if (!isAuthenticated || !newComment.trim() || (cooldownRemaining > 0 && !isAdmin) || isLoadingAddComment) return;
try {
setIsLoadingAddComment(true);
// Get user details from localStorage
let username = t('comments.defaultUser');
let userAvatar = 'https://as2.ftcdn.net/v2/jpg/05/89/93/27/1000_F_589932782_vQAEAZhHnq1QCGu5ikwrYaQD0Mmurm0N.webp';
let userId = currentUserId || 'anonymous_user';
const isDiscordAuth = localStorage.getItem('discord_auth') === 'true';
const isGoogleAuth = localStorage.getItem('google_auth') === 'true';
const isVipUser = isUserVip();
const isBip39Auth = localStorage.getItem('bip39_auth') === 'true';
// VIP via access_code and BIP39 authentication
let isVipAuth = false;
let vipUser = null;
let isBip39User = false;
let bip39User = null;
const authStr = localStorage.getItem('auth');
if (authStr) {
try {
const authObj = JSON.parse(authStr);
if (authObj && authObj.userProfile) {
if (authObj.userProfile.provider === 'access_code') {
isVipAuth = true;
vipUser = authObj.userProfile;
} else if (authObj.userProfile.provider === 'bip39') {
isBip39User = true;
bip39User = authObj.userProfile;
}
}
} catch (error) {
console.error('Error parsing auth data:', error);
}
}
if (isDiscordAuth) {
try {
const userInfoStr = localStorage.getItem('discord_user');
if (userInfoStr) {
const userInfo = JSON.parse(userInfoStr);
if (userInfo) {
username = userInfo.username || 'Discord User';
userAvatar = (typeof userInfo.avatar === 'string' && userInfo.avatar.trim() !== '') ? userInfo.avatar : userAvatar;
userId = userInfo.id || userId;
}
}
} catch (error) {
console.error('Error parsing Discord user data:', error);
}
} else if (isGoogleAuth) {
try {
const userInfoStr = localStorage.getItem('google_user');
if (userInfoStr) {
const userInfo = JSON.parse(userInfoStr);
if (userInfo) {
username = userInfo.name || 'Google User';
userAvatar = (typeof userInfo.picture === 'string' && userInfo.picture.trim() !== '') ? userInfo.picture : userAvatar;
userId = userInfo.id || userId;
}
}
} catch (error) {
console.error('Error parsing Google user data:', error);
}
} else if (isBip39Auth || (isBip39User && bip39User)) {
username = bip39User?.username || t('comments.defaultUserBip39');
userAvatar = (typeof bip39User?.avatar === 'string' && bip39User.avatar.trim() !== '') ? bip39User.avatar : userAvatar;
userId = bip39User?.id || 'bip39_user';
} else if (isVipAuth && vipUser) {
username = vipUser.username || 'VIP User';
userAvatar = (typeof vipUser.avatar === 'string' && vipUser.avatar.trim() !== '') ? vipUser.avatar : userAvatar;
userId = vipUser.id || 'vip_user';
} else if (isVipUser) {
username = 'VIP User';
userId = localStorage.getItem('guest_uuid') || 'anonymous_vip';
}
// Ajouter le commentaire
const commentId = await addComment(
contentId,
contentType,
userId,
username,
userAvatar,
newComment,
undefined, // parentId
isAdmin // Indiquer si l'utilisateur est admin
);
// Démarrer le cooldown si pas admin
startCooldown();
// Ajouter optimistiquement le commentaire au state sans recharger
const newCommentObject: Comment = {
id: commentId,
contentId,
contentType,
userId,
username,
userAvatar,
content: newComment,
createdAt: new Date(),
likes: 0,
likedBy: [],
dislikes: 0,
dislikedBy: [],
reactions: [],
replies: [],
isAdmin: isAdmin
};
setComments(prev => [newCommentObject, ...prev]);
setNewComment('');
} catch (error) {
console.error('Error adding comment:', error);
// Si erreur, on recharge tous les commentaires
refreshComments();
} finally {
setIsLoadingAddComment(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleAddComment();
}
};
const handleEmojiInsert = (emoji: string) => {
setNewComment(prev => {
if (prev.length + emoji.length <= MAX_COMMENT_LENGTH) {
return prev + emoji;
}
return prev;
});
};
// Formatter le temps restant
const formatCooldownTime = (seconds: number) => {
if (seconds < 60) {
return `${seconds} ${t('time.seconds', { count: seconds })}`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds < 10 ? '0' : ''}${remainingSeconds}`;
};
return (
<div className="bg-gray-900 p-4 rounded-lg mt-8">
<div className="flex flex-wrap justify-between items-center mb-4">
<h2 className="text-xl font-bold mb-2 sm:mb-0">{t('comments.title')}</h2>
<AdminLogin onAdminStatusChange={handleAdminStatusChange} />
</div>
{isAuthenticated ? (
<div className="mb-6 flex items-start space-x-3">
<img
src={(() => {
if (localStorage.getItem('discord_auth') === 'true') {
const userInfo = JSON.parse(localStorage.getItem('discord_user') || '{}');
return userInfo.avatar || 'https://as2.ftcdn.net/v2/jpg/05/89/93/27/1000_F_589932782_vQAEAZhHnq1QCGu5ikwrYaQD0Mmurm0N.webp';
} else if (localStorage.getItem('google_auth') === 'true') {
const userInfo = JSON.parse(localStorage.getItem('google_user') || '{}');
return userInfo.picture || 'https://as2.ftcdn.net/v2/jpg/05/89/93/27/1000_F_589932782_vQAEAZhHnq1QCGu5ikwrYaQD0Mmurm0N.webp';
} else {
// VIP via access_code
const authStr = localStorage.getItem('auth');
if (authStr) {
try {
const authObj = JSON.parse(authStr);
if (authObj.userProfile && authObj.userProfile.provider === 'access_code') {
return authObj.userProfile.avatar || 'https://as2.ftcdn.net/v2/jpg/05/89/93/27/1000_F_589932782_vQAEAZhHnq1QCGu5ikwrYaQD0Mmurm0N.webp';
}
} catch {}
}
}
return 'https://as2.ftcdn.net/v2/jpg/05/89/93/27/1000_F_589932782_vQAEAZhHnq1QCGu5ikwrYaQD0Mmurm0N.webp';
})()}
alt={t('common.avatar')}
className="w-10 h-10 rounded-full object-cover"
/>
<div className="flex-1">
<div className="relative flex bg-gray-800 rounded-lg">
<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={cooldownRemaining > 0 && !isAdmin ? t('comments.cooldownMessage', { time: formatCooldownTime(cooldownRemaining) }) : t('comments.addComment')}
disabled={cooldownRemaining > 0 && !isAdmin}
className={`w-full bg-gray-800 text-white p-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none min-h-[80px] ${cooldownRemaining > 0 && !isAdmin ? 'opacity-70 cursor-not-allowed' : ''}`}
maxLength={MAX_COMMENT_LENGTH}
/>
<div className="absolute right-2 bottom-2 flex items-center space-x-2">
<EmojiPicker onEmojiSelect={handleEmojiInsert} />
</div>
</div>
<div className="flex justify-between mt-2">
{cooldownRemaining > 0 && !isAdmin && (
<div className="flex items-center text-yellow-500 text-sm">
<Clock size={16} className="mr-1" />
<span>{t('comments.waitLabel')} {formatCooldownTime(cooldownRemaining)}</span>
</div>
)}
<div className="flex items-center space-x-2 ml-auto">
<span className={`text-xs ${newComment.length >= MAX_COMMENT_LENGTH ? 'text-red-500' : 'text-gray-400'}`}>
{newComment.length}/{MAX_COMMENT_LENGTH}
</span>
<button
onClick={handleAddComment}
disabled={cooldownRemaining > 0 && !isAdmin || !newComment.trim() || isLoadingAddComment}
className={`bg-blue-600 text-white px-4 py-2 rounded-lg flex items-center space-x-1 ${
(cooldownRemaining > 0 && !isAdmin) || !newComment.trim() || isLoadingAddComment ? 'opacity-50 cursor-not-allowed' : 'hover:bg-blue-700'
}`}
>
<Send size={16} />
<span>{t('comments.comment')}</span>
</button>
</div>
</div>
</div>
</div>
) : (
<div className="mb-6 p-4 bg-gray-800 rounded-lg text-center">
<p className="text-gray-300">{t('comments.loginToComment')}</p>
</div>
)}
{loading ? (
<div className="flex justify-center items-center py-6">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500"></div>
</div>
) : comments.length > 0 ? (
<div>
{comments.map(comment => (
<CommentItem
key={comment.id}
comment={comment}
currentUserId={currentUserId}
contentId={contentId}
contentType={contentType}
refreshComments={refreshComments}
isAdmin={isAdmin}
/>
))}
</div>
) : (
<div className="text-center py-6 text-gray-400">
<p>{t('comments.noComments')}</p>
</div>
)}
</div>
);
};
export default CommentSection;

View file

@ -1,14 +1,14 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { motion, AnimatePresence } from 'framer-motion';
import { Heart, MessageCircle, Trash2, Send, X, AlertTriangle, Info, ExternalLink, Popcorn, Flag } from 'lucide-react';
import { Link } from 'react-router-dom';
import { PrefetchLink as Link } from '@/routing/PrefetchLink';
import axios from 'axios';
import { toast } from 'sonner';
import { getVipHeaders } from '../utils/vipUtils';
import ReactMarkdown from 'react-markdown';
import { safeRemarkGfm } from '../utils/markdownPlugins';
import { useSafeRemarkGfm } from '../utils/markdownPlugins';
import remarkEmoji from 'remark-emoji';
import MarkdownToolbar from './MarkdownToolbar';
@ -50,8 +50,6 @@ const mdComponents = {
h6: ({ children }: any) => <p className="font-bold text-white mb-1">{children}</p>,
};
const mdPlugins = safeRemarkGfm ? [safeRemarkGfm, remarkEmoji] : [remarkEmoji];
declare global {
interface Window {
turnstile?: {
@ -281,6 +279,12 @@ const CommentItem = React.memo<CommentItemProps>((props) => {
setReportModal,
} = props;
const safeRemarkGfm = useSafeRemarkGfm();
const mdPlugins = useMemo(
() => (safeRemarkGfm ? [safeRemarkGfm, remarkEmoji] : [remarkEmoji]),
[safeRemarkGfm],
);
return (
<motion.div
key={comment.id}
@ -1484,7 +1488,8 @@ const CommentsSection: React.FC<CommentsSectionProps> = ({ contentType, contentI
`${MAIN_API}/api/comments/${commentId}`,
{
content: editContent,
isSpoiler: editIsSpoiler
isSpoiler: editIsSpoiler,
profileId
},
{ headers: { Authorization: `Bearer ${token}` } }
);
@ -1516,7 +1521,8 @@ const CommentsSection: React.FC<CommentsSectionProps> = ({ contentType, contentI
`${MAIN_API}/api/comments/replies/${replyId}`,
{
content: editContent,
isSpoiler: editIsSpoiler
isSpoiler: editIsSpoiler,
profileId
},
{ headers: { Authorization: `Bearer ${token}` } }
);

View file

@ -1,5 +1,5 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { PrefetchLink as Link } from '@/routing/PrefetchLink';
import { motion } from 'framer-motion';
import { ChevronLeft, ChevronRight, ImageOff } from 'lucide-react';
import ContentRowSkeleton from './skeletons/ContentRowSkeleton';

Some files were not shown because too many files have changed in this diff Show more