mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-08-09 12:57:38 +00:00
Gros lot de travail synchronisé depuis le repo privé. Cette mise à jour regroupe la refonte du lecteur et du Cast, de nouvelles sources VOD/Live TV, le durcissement de l’API et des proxies, ainsi qu’une nouvelle version de l’application mobile. Lecteur, sources et Live TV - Refonte du lecteur HLS avec sélection et mémorisation de la qualité, détection des résolutions, scan des liens, choix des pistes audio et fallback contrôlé de 1080p vers 720p. - Ajoute une garde contre les boucles de changement de source, notamment lors des erreurs vidéo et des réponses 429. - Revoit SeekStreaming de bout en bout : validation des embeds, prise en charge de plusieurs candidats, regroupement par serveur, règles de headers persistantes et cache d’extraction raccourci. - Ajoute SwiftFlow pour les films et séries, avec lecteurs VF/VOSTFR, cache stale-while-revalidate, déduplication des requêtes et limite partagée côté API. - Adapte Coflix à son nouveau domaine et à son nouveau thème, ajoute un kill switch, développe les wrappers 1jour1film et actualise le domaine Wiflix. - Ajoute NorthLive et Vavoo à la Live TV, regroupe les variantes d’une même chaîne et permet de choisir le serveur ou la qualité. - Enrichit les Top 10 avec des filtres par période et par algorithme, ainsi que de nouvelles métriques de visionnage. Cast et application mobile - Unifie le contrôle Cast entre le Web Cast SDK et le pont Android : état distant, lecture, pause, seek, arrêt et reprise après reconnexion. - Ajoute sur Android un relais média LAN sécurisé, limité au Chromecast et au réseau Wi-Fi sélectionnés, avec sessions opaques et service foreground. - Prépare les sources avant diffusion et prend en charge les playlists HLS TS/fMP4, les flux progressifs, les sous-titres VTT, SeekStreaming et les segments MPEG-TS encapsulés dans des PNG. - Renforce le proxy média local : support Range, validation des redirections, liaison DNS/socket au réseau Cast et meilleure gestion des headers Fsvid. - Sécurise le bridge WebView avec contrôle de l’origine top-frame, capacités propres à chaque document et invalidation lors des navigations. - Ajoute le Picture-in-Picture Android avec lecture/pause et seek de 10 secondes depuis la fenêtre flottante. - Maintient désormais l’écran allumé uniquement pendant une lecture active. - Publie l’application Android 2.5.7, build 16, avec le nouvel APK et ses métadonnées d’intégrité. API, base de données et paiements - Remplace 13 migrations SQL isolées par un manifeste de schéma MySQL centralisé, additif et capable de détecter les divergences de structure. - Ajoute le workflow db:init avec prévisualisation, dry-run, verrou MySQL, confirmations pour les tables Wrapped et couverture de tests dédiée. - Rend configurables les limites du pool MySQL et fiabilise les advisory locks. - Revoit entièrement PayGate : références de callback séparées, nonces, validation des wallets et transactions, états monotones, déduplication des paiements et protection contre les courses ou réutilisations de txid. - Renforce l’annulation, la réconciliation et la livraison idempotente des factures VIP, avec réponses no-store sur les routes sensibles. - Distingue les JWT invalides des indisponibilités MySQL afin de renvoyer correctement 401 ou 503 sans provoquer de déconnexions inutiles. - Utilise ZZAPI/Claude Haiku pour la modération, avec OpenRouter/DeepSeek en solution de secours. - Améliore la gestion HLS et Range dans proxiesembed. Frontend, profils et infrastructure - Protège la sélection et l’hydratation des profils contre les réponses concurrentes, les intentions obsolètes et l’effacement accidentel du stockage local. - Rend VITE_SITE_URL obligatoire et l’injecte dans le runtime, les données structurées, le service worker, robots.txt et sitemap.xml. - Optimise les probes réseau du service worker avec cache court et single-flight. - Charge dynamiquement les traductions anglaises, plusieurs routes secondaires et les données Search/TMDB uniquement lorsqu’elles deviennent nécessaires. - Ajoute des caches mémoire/session pour les images et contenus TMDB et réduit les animations, calculs et rendus hors écran. - Ajoute la page publique des frais, les coins carrés persistants, une nouvelle intro Movix, les avertissements de publicités adultes et de nombreux textes Cast, qualité, Live TV et sources. - Met à jour les extensions Chrome 1.3.12 et Firefox 1.5.9, ainsi que le userscript, avec la nouvelle extraction SeekStreaming et la préparation Cast. - Ajoute une large couverture de tests Kotlin et JavaScript pour le Cast, le proxy média, le PiP, le bridge, la base de données, PayGate et le lecteur. - Ajoute bootstrap-icons et patch-package, régénère les lockfiles npm et actualise la documentation d’installation et d’initialisation MySQL. Co-authored-by: GPT-5.6 Sol <noreply@openai.com>
421 lines
12 KiB
JavaScript
421 lines
12 KiB
JavaScript
const crypto = require('crypto');
|
||
const { getPool } = require('../mysqlPool');
|
||
const { ensureTableGroup } = require('../db/runtimeEnsure');
|
||
|
||
const AUTHORIZATION_REQUEST_TTL_MS = 10 * 60 * 1000;
|
||
const AUTHORIZATION_CODE_TTL_MS = 10 * 60 * 1000;
|
||
const ACCESS_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||
|
||
function createOAuthStorageError(message, statusCode = 400, oauthError = 'invalid_request') {
|
||
const error = new Error(message);
|
||
error.statusCode = statusCode;
|
||
error.oauthError = oauthError;
|
||
return error;
|
||
}
|
||
|
||
function toBase64Url(buffer) {
|
||
return buffer
|
||
.toString('base64')
|
||
.replace(/\+/g, '-')
|
||
.replace(/\//g, '_')
|
||
.replace(/=+$/g, '');
|
||
}
|
||
|
||
function generateOpaqueToken(prefix) {
|
||
return `${prefix}_${toBase64Url(crypto.randomBytes(32))}`;
|
||
}
|
||
|
||
function hashOpaqueToken(value) {
|
||
return crypto.createHash('sha256').update(String(value || '')).digest('hex');
|
||
}
|
||
|
||
function serializeScopes(scopes) {
|
||
return JSON.stringify(Array.isArray(scopes) ? scopes : []);
|
||
}
|
||
|
||
function normalizeAuthorizationRequest(payload = {}) {
|
||
const clientId = String(payload.clientId || payload.client_id || '').trim();
|
||
const redirectUri = String(payload.redirectUri || payload.redirect_uri || '').trim();
|
||
const responseType = String(payload.responseType || payload.response_type || 'code').trim() || 'code';
|
||
const state = typeof payload.state === 'string'
|
||
? payload.state
|
||
: payload.state != null
|
||
? String(payload.state)
|
||
: '';
|
||
const scopes = Array.isArray(payload.scopes)
|
||
? payload.scopes.map((scope) => String(scope || '').trim()).filter(Boolean).sort()
|
||
: [];
|
||
const codeChallenge = typeof payload.codeChallenge === 'string' && payload.codeChallenge.trim()
|
||
? payload.codeChallenge.trim()
|
||
: null;
|
||
const codeChallengeMethod = typeof payload.codeChallengeMethod === 'string' && payload.codeChallengeMethod.trim()
|
||
? payload.codeChallengeMethod.trim().toUpperCase()
|
||
: null;
|
||
|
||
return {
|
||
clientId,
|
||
redirectUri,
|
||
responseType,
|
||
scopes,
|
||
state,
|
||
codeChallenge,
|
||
codeChallengeMethod,
|
||
};
|
||
}
|
||
|
||
function hashAuthorizationRequest(payload = {}) {
|
||
const normalized = normalizeAuthorizationRequest(payload);
|
||
return crypto.createHash('sha256').update(JSON.stringify(normalized)).digest('hex');
|
||
}
|
||
|
||
function parseScopes(rawScopes) {
|
||
if (!rawScopes) {
|
||
return [];
|
||
}
|
||
|
||
try {
|
||
const parsed = JSON.parse(rawScopes);
|
||
return Array.isArray(parsed)
|
||
? parsed.map((scope) => String(scope || '').trim()).filter(Boolean)
|
||
: [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function normalizeDate(value) {
|
||
if (value == null) {
|
||
return null;
|
||
}
|
||
|
||
const date = value instanceof Date ? value : new Date(value);
|
||
return Number.isFinite(date.getTime()) ? date : null;
|
||
}
|
||
|
||
function mapAuthorizationCodeRow(row) {
|
||
if (!row) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
codeHash: row.code_hash,
|
||
clientId: row.client_id,
|
||
userId: row.user_id,
|
||
userType: row.user_type,
|
||
sessionId: row.session_id,
|
||
scopes: parseScopes(row.scopes),
|
||
redirectUri: row.redirect_uri,
|
||
codeChallenge: row.code_challenge || null,
|
||
codeChallengeMethod: row.code_challenge_method || null,
|
||
expiresAt: normalizeDate(row.expires_at),
|
||
usedAt: normalizeDate(row.used_at),
|
||
createdAt: normalizeDate(row.created_at),
|
||
};
|
||
}
|
||
|
||
function mapAuthorizationRequestRow(row) {
|
||
if (!row) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
requestHash: row.request_hash,
|
||
clientId: row.client_id,
|
||
redirectUri: row.redirect_uri,
|
||
responseType: row.response_type,
|
||
scopes: parseScopes(row.scopes),
|
||
state: row.state || '',
|
||
codeChallenge: row.code_challenge || null,
|
||
codeChallengeMethod: row.code_challenge_method || null,
|
||
expiresAt: normalizeDate(row.expires_at),
|
||
consumedAt: normalizeDate(row.consumed_at),
|
||
decision: row.decision || null,
|
||
createdAt: normalizeDate(row.created_at),
|
||
lastSeenAt: normalizeDate(row.last_seen_at),
|
||
};
|
||
}
|
||
|
||
function mapAccessTokenRow(row) {
|
||
if (!row) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
tokenHash: row.token_hash,
|
||
clientId: row.client_id,
|
||
userId: row.user_id,
|
||
userType: row.user_type,
|
||
sessionId: row.session_id,
|
||
scopes: parseScopes(row.scopes),
|
||
expiresAt: normalizeDate(row.expires_at),
|
||
revokedAt: normalizeDate(row.revoked_at),
|
||
createdAt: normalizeDate(row.created_at),
|
||
lastUsedAt: normalizeDate(row.last_used_at),
|
||
authorizationCodeHash: row.authorization_code_hash || null,
|
||
};
|
||
}
|
||
|
||
async function ensureOAuthStorage(pool = getPool()) {
|
||
if (!pool) {
|
||
throw new Error('MySQL pool not ready for OAuth storage');
|
||
}
|
||
await ensureTableGroup(pool, 'oauth');
|
||
}
|
||
|
||
async function registerAuthorizationRequest(pool, payload) {
|
||
if (!pool || typeof pool.execute !== 'function') {
|
||
throw new Error('MySQL pool not ready for OAuth authorization request registration');
|
||
}
|
||
|
||
const normalized = normalizeAuthorizationRequest(payload);
|
||
const requestHash = hashAuthorizationRequest(normalized);
|
||
const expiresAt = new Date(Date.now() + AUTHORIZATION_REQUEST_TTL_MS);
|
||
|
||
await pool.execute(
|
||
`INSERT INTO oauth_authorization_requests
|
||
(request_hash, client_id, redirect_uri, response_type, scopes, state, code_challenge, code_challenge_method, expires_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
ON DUPLICATE KEY UPDATE
|
||
client_id = VALUES(client_id),
|
||
redirect_uri = VALUES(redirect_uri),
|
||
response_type = VALUES(response_type),
|
||
scopes = VALUES(scopes),
|
||
state = VALUES(state),
|
||
code_challenge = VALUES(code_challenge),
|
||
code_challenge_method = VALUES(code_challenge_method),
|
||
expires_at = VALUES(expires_at),
|
||
last_seen_at = NOW()`,
|
||
[
|
||
requestHash,
|
||
normalized.clientId,
|
||
normalized.redirectUri,
|
||
normalized.responseType,
|
||
serializeScopes(normalized.scopes),
|
||
normalized.state,
|
||
normalized.codeChallenge,
|
||
normalized.codeChallengeMethod,
|
||
expiresAt,
|
||
]
|
||
);
|
||
|
||
return getAuthorizationRequestRecord(pool, normalized);
|
||
}
|
||
|
||
async function getAuthorizationRequestRecord(pool, payload) {
|
||
if (!pool || !payload) {
|
||
return null;
|
||
}
|
||
|
||
const requestHash = hashAuthorizationRequest(payload);
|
||
const [rows] = await pool.execute(
|
||
'SELECT * FROM oauth_authorization_requests WHERE request_hash = ? LIMIT 1',
|
||
[requestHash]
|
||
);
|
||
|
||
return mapAuthorizationRequestRow(rows[0] || null);
|
||
}
|
||
|
||
async function claimAuthorizationRequest(connection, payload, decision) {
|
||
if (!connection || typeof connection.execute !== 'function') {
|
||
throw new Error('MySQL connection not ready for OAuth authorization request claim');
|
||
}
|
||
|
||
const normalized = normalizeAuthorizationRequest(payload);
|
||
const requestHash = hashAuthorizationRequest(normalized);
|
||
const [rows] = await connection.execute(
|
||
'SELECT * FROM oauth_authorization_requests WHERE request_hash = ? LIMIT 1 FOR UPDATE',
|
||
[requestHash]
|
||
);
|
||
|
||
const requestRecord = mapAuthorizationRequestRow(rows[0] || null);
|
||
if (!requestRecord) {
|
||
throw createOAuthStorageError('Demande OAuth introuvable', 400, 'invalid_request');
|
||
}
|
||
|
||
if (!requestRecord.expiresAt || requestRecord.expiresAt.getTime() <= Date.now()) {
|
||
throw createOAuthStorageError('Cette demande OAuth a expiré', 400, 'invalid_request');
|
||
}
|
||
|
||
if (requestRecord.consumedAt) {
|
||
throw createOAuthStorageError('Cette demande OAuth a déjà été utilisée. Regénérez une nouvelle demande depuis Movix Translate.', 400, 'invalid_grant');
|
||
}
|
||
|
||
const [updateResult] = await connection.execute(
|
||
'UPDATE oauth_authorization_requests SET consumed_at = NOW(), decision = ?, last_seen_at = NOW() WHERE request_hash = ? AND consumed_at IS NULL',
|
||
[decision, requestHash]
|
||
);
|
||
|
||
if (!updateResult || updateResult.affectedRows !== 1) {
|
||
throw createOAuthStorageError('Cette demande OAuth a déjà été utilisée. Regénérez une nouvelle demande depuis Movix Translate.', 400, 'invalid_grant');
|
||
}
|
||
|
||
return requestRecord;
|
||
}
|
||
|
||
async function createAuthorizationCode(pool, payload) {
|
||
if (!pool) {
|
||
throw new Error('MySQL pool not ready for OAuth authorization code creation');
|
||
}
|
||
|
||
const code = generateOpaqueToken('movix_code');
|
||
const codeHash = hashOpaqueToken(code);
|
||
const expiresAt = new Date(Date.now() + AUTHORIZATION_CODE_TTL_MS);
|
||
|
||
await pool.execute(
|
||
`INSERT INTO oauth_authorization_codes
|
||
(code_hash, client_id, user_id, user_type, session_id, scopes, redirect_uri, code_challenge, code_challenge_method, expires_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
[
|
||
codeHash,
|
||
payload.clientId,
|
||
payload.userId,
|
||
payload.userType,
|
||
payload.sessionId || null,
|
||
serializeScopes(payload.scopes),
|
||
payload.redirectUri,
|
||
payload.codeChallenge || null,
|
||
payload.codeChallengeMethod || null,
|
||
expiresAt,
|
||
]
|
||
);
|
||
|
||
return {
|
||
code,
|
||
codeHash,
|
||
expiresAt,
|
||
};
|
||
}
|
||
|
||
async function exchangeAuthorizationCode(pool, payload) {
|
||
if (!pool) {
|
||
throw new Error('MySQL pool not ready for OAuth token exchange');
|
||
}
|
||
|
||
const connection = await pool.getConnection();
|
||
try {
|
||
await connection.beginTransaction();
|
||
|
||
const codeHash = hashOpaqueToken(payload.code);
|
||
const [rows] = await connection.execute(
|
||
'SELECT * FROM oauth_authorization_codes WHERE code_hash = ? LIMIT 1 FOR UPDATE',
|
||
[codeHash]
|
||
);
|
||
|
||
const authorizationCode = mapAuthorizationCodeRow(rows[0] || null);
|
||
if (!authorizationCode) {
|
||
throw createOAuthStorageError('Code d’autorisation invalide', 400, 'invalid_grant');
|
||
}
|
||
|
||
if (authorizationCode.clientId !== payload.clientId) {
|
||
throw createOAuthStorageError('Client OAuth invalide pour ce code', 400, 'invalid_grant');
|
||
}
|
||
|
||
if (authorizationCode.redirectUri !== payload.redirectUri) {
|
||
throw createOAuthStorageError('redirect_uri invalide pour ce code', 400, 'invalid_grant');
|
||
}
|
||
|
||
if (authorizationCode.usedAt) {
|
||
throw createOAuthStorageError('Code d’autorisation déjà utilisé', 400, 'invalid_grant');
|
||
}
|
||
|
||
if (!authorizationCode.expiresAt || authorizationCode.expiresAt.getTime() <= Date.now()) {
|
||
throw createOAuthStorageError('Code d’autorisation expiré', 400, 'invalid_grant');
|
||
}
|
||
|
||
await connection.execute(
|
||
'UPDATE oauth_authorization_codes SET used_at = NOW() WHERE code_hash = ? AND used_at IS NULL',
|
||
[authorizationCode.codeHash]
|
||
);
|
||
|
||
const accessToken = generateOpaqueToken('movix_oat');
|
||
const tokenHash = hashOpaqueToken(accessToken);
|
||
const expiresAt = new Date(Date.now() + ACCESS_TOKEN_TTL_MS);
|
||
|
||
await connection.execute(
|
||
`INSERT INTO oauth_access_tokens
|
||
(token_hash, client_id, user_id, user_type, session_id, scopes, expires_at, authorization_code_hash, created_at, last_used_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())`,
|
||
[
|
||
tokenHash,
|
||
authorizationCode.clientId,
|
||
authorizationCode.userId,
|
||
authorizationCode.userType,
|
||
authorizationCode.sessionId || null,
|
||
serializeScopes(authorizationCode.scopes),
|
||
expiresAt,
|
||
authorizationCode.codeHash,
|
||
]
|
||
);
|
||
|
||
await connection.commit();
|
||
|
||
return {
|
||
accessToken,
|
||
accessTokenHash: tokenHash,
|
||
expiresAt,
|
||
scopes: authorizationCode.scopes,
|
||
clientId: authorizationCode.clientId,
|
||
userId: authorizationCode.userId,
|
||
userType: authorizationCode.userType,
|
||
sessionId: authorizationCode.sessionId,
|
||
authorizationCode,
|
||
};
|
||
} catch (error) {
|
||
await connection.rollback();
|
||
throw error;
|
||
} finally {
|
||
connection.release();
|
||
}
|
||
}
|
||
|
||
async function getAccessTokenRecord(pool, accessToken, options = {}) {
|
||
if (!pool || !accessToken) {
|
||
return null;
|
||
}
|
||
|
||
const tokenHash = hashOpaqueToken(accessToken);
|
||
const [rows] = await pool.execute(
|
||
'SELECT * FROM oauth_access_tokens WHERE token_hash = ? LIMIT 1',
|
||
[tokenHash]
|
||
);
|
||
|
||
const record = mapAccessTokenRow(rows[0] || null);
|
||
if (!record) {
|
||
return null;
|
||
}
|
||
|
||
if (record.revokedAt) {
|
||
return null;
|
||
}
|
||
|
||
if (!record.expiresAt || record.expiresAt.getTime() <= Date.now()) {
|
||
return null;
|
||
}
|
||
|
||
if (options.touch === true) {
|
||
pool.execute(
|
||
'UPDATE oauth_access_tokens SET last_used_at = NOW() WHERE token_hash = ?',
|
||
[tokenHash]
|
||
).catch((error) => {
|
||
console.error('[OAuth Storage] Failed to update token usage:', error.message || error);
|
||
});
|
||
}
|
||
|
||
return record;
|
||
}
|
||
|
||
module.exports = {
|
||
AUTHORIZATION_REQUEST_TTL_MS,
|
||
AUTHORIZATION_CODE_TTL_MS,
|
||
ACCESS_TOKEN_TTL_MS,
|
||
createOAuthStorageError,
|
||
hashOpaqueToken,
|
||
hashAuthorizationRequest,
|
||
ensureOAuthStorage,
|
||
registerAuthorizationRequest,
|
||
getAuthorizationRequestRecord,
|
||
claimAuthorizationRequest,
|
||
createAuthorizationCode,
|
||
exchangeAuthorizationCode,
|
||
getAccessTokenRecord,
|
||
};
|