Sync: merge upstream movixcorp/MovixOpenSource (20 commits - iOS fixes, app icon, runtime fixes, extensions update)
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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_*`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
112
API/Mainapi/exportscripts/add_oauth_apps_tables.sql
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ function domainRestriction(req, res, next) {
|
|||
|
||||
const allowedDomains = [
|
||||
'localhost:3000',
|
||||
'movix.tax',
|
||||
'movix.cash',
|
||||
'movix.blog',
|
||||
'movix.rodeo',
|
||||
'movix.club',
|
||||
|
|
|
|||
BIN
API/Mainapi/public/oauth-icons/movix-mcp-1778761296022.jpg
Normal file
|
After Width: | Height: | Size: 34 KiB |
604
API/Mainapi/routes/adminOauthApps.js
Normal 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;
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
|
|
|
|||
|
|
@ -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"',
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
69
API/Mainapi/utils/adminIdentity.js
Normal 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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
461
API/Mainapi/utils/hydrackerBatch.js
Normal 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
|
||||
};
|
||||
529
API/Mainapi/utils/hydrackerQueue.js
Normal 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
|
||||
};
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
344
API/Mainapi/utils/oauthClientsDb.js
Normal 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,
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/"
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
After Width: | Height: | Size: 116 KiB |
88
app/ios/.gitignore
vendored
Normal 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
|
|
@ -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)
|
||||
5
app/ios/Movix/AppDelegate.h
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#import <RCTAppDelegate.h>
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
@interface AppDelegate : RCTAppDelegate
|
||||
@end
|
||||
27
app/ios/Movix/AppDelegate.mm
Normal 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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
BIN
app/ios/Movix/Images.xcassets/AppIcon.appiconset/icon-1024.png
Normal file
|
After Width: | Height: | Size: 1 MiB |
BIN
app/ios/Movix/Images.xcassets/AppIcon.appiconset/icon-120.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
app/ios/Movix/Images.xcassets/AppIcon.appiconset/icon-152.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
app/ios/Movix/Images.xcassets/AppIcon.appiconset/icon-167.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
app/ios/Movix/Images.xcassets/AppIcon.appiconset/icon-180.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
app/ios/Movix/Images.xcassets/AppIcon.appiconset/icon-20.png
Normal file
|
After Width: | Height: | Size: 1 KiB |
BIN
app/ios/Movix/Images.xcassets/AppIcon.appiconset/icon-29.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
app/ios/Movix/Images.xcassets/AppIcon.appiconset/icon-40.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
app/ios/Movix/Images.xcassets/AppIcon.appiconset/icon-58.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
app/ios/Movix/Images.xcassets/AppIcon.appiconset/icon-60.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
app/ios/Movix/Images.xcassets/AppIcon.appiconset/icon-76.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
app/ios/Movix/Images.xcassets/AppIcon.appiconset/icon-80.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
app/ios/Movix/Images.xcassets/AppIcon.appiconset/icon-87.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
6
app/ios/Movix/Images.xcassets/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
21
app/ios/Movix/Images.xcassets/MovixLogo.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
app/ios/Movix/Images.xcassets/MovixLogo.imageset/movix512.png
vendored
Normal file
|
After Width: | Height: | Size: 116 KiB |
|
|
@ -57,5 +57,9 @@
|
|||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>tg</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
43
app/ios/Movix/LaunchScreen.storyboard
Normal 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>
|
||||
37
app/ios/Movix/PrivacyInfo.xcprivacy
Normal 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>
|
||||
4
app/ios/Movix/UpdateModule.h
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
#import <React/RCTBridgeModule.h>
|
||||
|
||||
@interface UpdateModule : NSObject <RCTBridgeModule>
|
||||
@end
|
||||
21
app/ios/Movix/UpdateModule.m
Normal 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
|
|
@ -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]));
|
||||
}
|
||||
}
|
||||
672
app/ios/MovixApp.xcodeproj/project.pbxproj
Normal 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 */;
|
||||
}
|
||||
|
|
@ -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>
|
||||
10
app/ios/MovixApp.xcworkspace/contents.xcworkspacedata
generated
Normal 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>
|
||||
24
app/ios/MovixAppTests/Info.plist
Normal 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>
|
||||
66
app/ios/MovixAppTests/MovixAppTests.m
Normal 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
|
||||
|
|
@ -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
26
app/ios/char_traits_fix.h
Normal 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; }
|
||||
};
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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/*"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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"/>
|
||||
|
|
|
|||
12
index.html
|
|
@ -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
32
package.json
|
|
@ -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": {
|
||||
|
|
|
|||
178
public/sw.js
|
|
@ -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, ''');
|
||||
}
|
||||
|
||||
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 {}
|
||||
});
|
||||
|
|
|
|||
481
src/App.tsx
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
1073
src/components/AdminOAuthApps.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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}` } }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||