MovixOpenSource/API/Mainapi/utils/vipPaygate.js
Movix 3a7ab5ad42 feat: synchronise le repo privé — Cast, sources, API et mobile 2.5.7
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>
2026-08-02 12:09:37 +02:00

525 lines
17 KiB
JavaScript

'use strict';
const crypto = require('crypto');
const axios = require('axios');
const PAYGATE_API_BASE = 'https://api.paygate.to';
const PAYGATE_DEFAULT_CHECKOUT_HOST = 'checkout.paygate.to';
const EVM_ADDRESS_RE = /^0x[a-fA-F0-9]{40}$/;
const EVM_TX_RE = /^0x[a-fA-F0-9]{64}$/;
const STABLE_PAYOUT_COINS = new Set([
'polygon_usdc',
'polygon_usdt',
'bep20_usdc',
'bep20_usdt',
'erc20_usdc',
'erc20_usdt'
]);
const NATIVE_PAYOUT_PATHS = Object.freeze({
polygon_pol: 'polygon/pol',
eth: 'eth',
bep20_bnb: 'bep20/bnb'
});
class PaygateProtocolError extends Error {
constructor(message, code, statusCode = 400, upstreamStatus = null) {
super(message);
this.name = 'PaygateProtocolError';
this.code = code;
this.statusCode = statusCode;
this.upstreamStatus = upstreamStatus;
}
}
function fail(message, code, statusCode = 400, upstreamStatus = null) {
throw new PaygateProtocolError(message, code, statusCode, upstreamStatus);
}
function readScalarString(value, fieldName) {
if (typeof value !== 'string') {
fail(`${fieldName} PayGate invalide`, 'PAYGATE_FIELD_INVALID');
}
const normalized = value.trim();
if (!normalized) {
fail(`${fieldName} PayGate manquant`, 'PAYGATE_FIELD_INVALID');
}
return normalized;
}
function normalizePaygateOpaqueValue(value, fieldName = 'valeur opaque') {
if (typeof value !== 'string' || !value.trim()) {
fail(`${fieldName} PayGate invalide`, 'PAYGATE_OPAQUE_VALUE_INVALID');
}
const raw = value.trim();
if (!raw.includes('%')) {
return raw;
}
let decoded;
try {
decoded = decodeURIComponent(raw);
} catch {
fail(`${fieldName} PayGate mal encodé`, 'PAYGATE_OPAQUE_VALUE_INVALID');
}
if (/%[0-9a-fA-F]{2}/.test(decoded)) {
fail(`${fieldName} PayGate doublement encodé`, 'PAYGATE_OPAQUE_VALUE_INVALID');
}
return decoded;
}
function setOpaqueQueryParam(searchParams, key, value) {
searchParams.set(key, normalizePaygateOpaqueValue(value, key));
}
function safeComparePaygateNonce(expected, actual) {
if (typeof expected !== 'string' || typeof actual !== 'string') {
return false;
}
const expectedDigest = crypto.createHash('sha256').update(expected, 'utf8').digest();
const actualDigest = crypto.createHash('sha256').update(actual, 'utf8').digest();
const equalDigest = crypto.timingSafeEqual(expectedDigest, actualDigest);
return equalDigest && Buffer.byteLength(expected) === Buffer.byteLength(actual);
}
function readOptionalScalarString(value, fieldName) {
if (value === undefined || value === null) return null;
return readScalarString(value, fieldName);
}
function normalizeCallbackBaseUrl(value, { requireHttps = false } = {}) {
const raw = readScalarString(value, 'callback base URL');
let url;
try {
url = new URL(raw);
} catch {
fail('Callback base URL PayGate invalide', 'PAYGATE_CALLBACK_BASE_INVALID');
}
if (!['http:', 'https:'].includes(url.protocol)) {
fail('Protocole callback PayGate invalide', 'PAYGATE_CALLBACK_BASE_INVALID');
}
if (requireHttps && url.protocol !== 'https:') {
fail('HTTPS requis pour le callback PayGate', 'PAYGATE_CALLBACK_HTTPS_REQUIRED');
}
if (
url.username
|| url.password
|| url.pathname !== '/'
|| url.search
|| url.hash
) {
fail('Callback base URL PayGate non canonique', 'PAYGATE_CALLBACK_BASE_INVALID');
}
return url.origin;
}
function parseCallbackQueryEntries(rawQuery) {
if (!rawQuery) return [];
const entries = [];
const keys = new Set();
for (const component of rawQuery.split('&')) {
const separatorIndex = component.indexOf('=');
const rawKey = separatorIndex === -1 ? component : component.slice(0, separatorIndex);
const rawValue = separatorIndex === -1 ? '' : component.slice(separatorIndex + 1);
let key;
try {
key = readScalarString(decodeURIComponent(rawKey), 'clé callback_url');
} catch (error) {
if (error instanceof PaygateProtocolError) throw error;
fail('callback_url PayGate invalide', 'PAYGATE_CALLBACK_URL_INVALID');
}
if (keys.has(key)) {
fail('callback_url PayGate ambiguë', 'PAYGATE_CALLBACK_URL_INVALID');
}
keys.add(key);
entries.push([key, normalizePaygateOpaqueValue(rawValue, `valeur ${key}`)]);
}
return entries;
}
function canonicalizeCallbackUrl(value) {
const raw = readScalarString(value, 'callback_url');
let url;
try {
url = new URL(raw);
} catch {
fail('callback_url PayGate invalide', 'PAYGATE_CALLBACK_URL_INVALID');
}
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) {
fail('callback_url PayGate invalide', 'PAYGATE_CALLBACK_URL_INVALID');
}
const sortedEntries = parseCallbackQueryEntries(url.search.slice(1))
.sort(([leftKey, leftValue], [rightKey, rightValue]) => (
leftKey.localeCompare(rightKey) || leftValue.localeCompare(rightValue)
));
url.search = sortedEntries
.map(([key, itemValue]) => `${encodeURIComponent(key)}=${encodeURIComponent(itemValue)}`)
.join('&');
return url.toString();
}
function buildPaygateCallbackUrl({ baseUrl, reference, nonce, requireHttps = false }) {
const normalizedBaseUrl = normalizeCallbackBaseUrl(baseUrl, { requireHttps });
const normalizedReference = readScalarString(reference, 'reference');
const normalizedNonce = readScalarString(nonce, 'nonce');
if (!/^[a-f0-9]{48}$/i.test(normalizedReference)) {
fail('Référence callback PayGate invalide', 'PAYGATE_CALLBACK_REFERENCE_INVALID');
}
if (!/^[a-f0-9]{48}$/i.test(normalizedNonce)) {
fail('Nonce callback PayGate invalide', 'PAYGATE_CALLBACK_NONCE_INVALID');
}
const url = new URL('/api/vip/paygate/callback', normalizedBaseUrl);
url.searchParams.set('reference', normalizedReference.toLowerCase());
url.searchParams.set('nonce', normalizedNonce.toLowerCase());
return url.toString();
}
function normalizeCheckoutHost(value = PAYGATE_DEFAULT_CHECKOUT_HOST) {
const raw = readScalarString(value, 'domaine checkout')
.replace(/^https:\/\//i, '');
if (/[/@?#]/.test(raw)) {
fail('Domaine checkout PayGate invalide', 'PAYGATE_CHECKOUT_HOST_INVALID');
}
let url;
try {
url = new URL(`https://${raw}`);
} catch {
fail('Domaine checkout PayGate invalide', 'PAYGATE_CHECKOUT_HOST_INVALID');
}
return url.host;
}
function buildPaygateCheckoutUrl({
checkoutAddress,
amountEur,
payerEmail,
branding = {},
checkoutHost = PAYGATE_DEFAULT_CHECKOUT_HOST
}) {
const numericAmount = Number(amountEur);
if (!Number.isFinite(numericAmount) || numericAmount <= 0) {
fail('Montant checkout PayGate invalide', 'PAYGATE_AMOUNT_INVALID');
}
const email = readScalarString(payerEmail, 'email');
const url = new URL(`https://${normalizeCheckoutHost(checkoutHost)}/pay.php`);
setOpaqueQueryParam(url.searchParams, 'address', checkoutAddress);
url.searchParams.set('amount', numericAmount.toFixed(2));
url.searchParams.set('email', email);
url.searchParams.set('currency', 'EUR');
for (const key of ['logo', 'background', 'theme', 'button']) {
if (typeof branding[key] === 'string' && branding[key].trim()) {
url.searchParams.set(key, branding[key].trim());
}
}
return url.toString();
}
function parsePaygateWalletResponse(payload, expectedCallbackUrl) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
fail('Réponse wallet PayGate invalide', 'PAYGATE_WALLET_RESPONSE_INVALID', 502);
}
const checkoutAddress = normalizePaygateOpaqueValue(payload.address_in, 'address_in');
const temporaryWalletAddress = readScalarString(
payload.polygon_address_in,
'polygon_address_in'
);
if (!EVM_ADDRESS_RE.test(temporaryWalletAddress)) {
fail('Adresse temporaire PayGate invalide', 'PAYGATE_WALLET_ADDRESS_INVALID', 502);
}
const callbackUrl = canonicalizeCallbackUrl(payload.callback_url);
const expected = canonicalizeCallbackUrl(expectedCallbackUrl);
if (callbackUrl !== expected) {
fail('Callback wallet PayGate incohérent', 'PAYGATE_WALLET_CALLBACK_MISMATCH', 502);
}
return {
checkoutAddress,
temporaryWalletAddress,
callbackUrl,
ipnToken: normalizePaygateOpaqueValue(payload.ipn_token, 'ipn_token')
};
}
function parsePositiveNumber(value, fieldName) {
if ((typeof value !== 'string' && typeof value !== 'number') || value === '') {
fail(`${fieldName} PayGate invalide`, 'PAYGATE_AMOUNT_INVALID');
}
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
fail(`${fieldName} PayGate invalide`, 'PAYGATE_AMOUNT_INVALID');
}
return parsed;
}
function normalizePayoutCoin(value) {
const coin = readScalarString(value, 'coin').toLowerCase();
if (!STABLE_PAYOUT_COINS.has(coin) && !Object.hasOwn(NATIVE_PAYOUT_PATHS, coin)) {
fail('Coin PayGate non supporté', 'PAYGATE_COIN_UNSUPPORTED');
}
return coin;
}
function parsePaygateCallbackPayload(payload) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
fail('Callback PayGate invalide', 'PAYGATE_CALLBACK_INVALID');
}
const reference = readOptionalScalarString(payload.reference, 'reference');
const publicId = readOptionalScalarString(
payload.publicId ?? payload.public_id,
'publicId'
);
if ((reference && publicId) || (!reference && !publicId)) {
fail('Identifiant callback PayGate invalide', 'PAYGATE_CALLBACK_IDENTIFIER_INVALID');
}
if (reference && !/^[a-f0-9]{48}$/i.test(reference)) {
fail('Référence callback PayGate invalide', 'PAYGATE_CALLBACK_REFERENCE_INVALID');
}
if (publicId && !/^inv_[a-f0-9]{24}$/i.test(publicId)) {
fail('publicId callback PayGate invalide', 'PAYGATE_CALLBACK_PUBLIC_ID_INVALID');
}
const nonce = readScalarString(payload.nonce, 'nonce');
if (!/^[a-f0-9]{48}$/i.test(nonce)) {
fail('Nonce callback PayGate invalide', 'PAYGATE_CALLBACK_NONCE_INVALID');
}
const addressIn = readScalarString(payload.address_in, 'address_in');
const txidIn = readScalarString(payload.txid_in, 'txid_in');
const txidOut = readScalarString(payload.txid_out, 'txid_out');
if (!EVM_ADDRESS_RE.test(addressIn)) {
fail('Adresse callback PayGate invalide', 'PAYGATE_CALLBACK_ADDRESS_INVALID');
}
if (!EVM_TX_RE.test(txidIn) || !EVM_TX_RE.test(txidOut)) {
fail('Transaction callback PayGate invalide', 'PAYGATE_CALLBACK_TX_INVALID');
}
const normalizedTxidIn = txidIn.toLowerCase();
const normalizedTxidOut = txidOut.toLowerCase();
if (normalizedTxidIn === normalizedTxidOut) {
fail('Transactions callback PayGate identiques', 'PAYGATE_CALLBACK_TX_DUPLICATE');
}
return {
reference: reference ? reference.toLowerCase() : null,
publicId: publicId || null,
nonce: nonce.toLowerCase(),
coin: normalizePayoutCoin(payload.coin),
valueCoin: parsePositiveNumber(payload.value_coin, 'value_coin'),
valueForwardedCoin: parsePositiveNumber(
payload.value_forwarded_coin,
'value_forwarded_coin'
),
txidIn: normalizedTxidIn,
txidOut: normalizedTxidOut,
addressIn: addressIn.toLowerCase()
};
}
function parsePaygatePaymentStatus(payload) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
fail('Statut PayGate invalide', 'PAYGATE_STATUS_INVALID', 502);
}
const status = readScalarString(payload.status, 'status').toLowerCase();
if (status === 'unpaid') {
return { status: 'unpaid' };
}
if (status !== 'paid') {
fail('Statut PayGate inconnu', 'PAYGATE_STATUS_INVALID', 502);
}
const txidOut = readScalarString(payload.txid_out, 'txid_out');
if (!EVM_TX_RE.test(txidOut)) {
fail('Transaction statut PayGate invalide', 'PAYGATE_STATUS_TX_INVALID', 502);
}
return {
status: 'paid',
coin: normalizePayoutCoin(payload.coin),
valueCoin: parsePositiveNumber(payload.value_coin, 'value_coin'),
txidOut: txidOut.toLowerCase()
};
}
function roundUsd(value, decimalPlaces = 8) {
return Number(Number(value).toFixed(decimalPlaces));
}
function normalizeRoundedUsd(value, {
code,
message,
storageDecimalPlaces = null
}) {
let numericValue;
try {
numericValue = Number(value);
} catch {
fail(message, code, 502);
}
const roundedValue = roundUsd(numericValue);
if (!Number.isFinite(roundedValue) || roundedValue <= 0) {
fail(message, code, 502);
}
if (
storageDecimalPlaces !== null
&& roundUsd(roundedValue, storageDecimalPlaces) <= 0
) {
fail(message, code, 502);
}
return roundedValue;
}
function normalizePaygateInvoiceUsd(value) {
return normalizeRoundedUsd(value, {
code: 'PAYGATE_CONVERT_INVALID',
message: 'Conversion PayGate invalide',
storageDecimalPlaces: 2
});
}
async function convertPaygatePaymentToUsd(payment, { httpClient = axios, timeoutMs = 15000 } = {}) {
const coin = normalizePayoutCoin(payment?.coin);
const valueCoin = parsePositiveNumber(payment?.valueCoin, 'value_coin');
if (STABLE_PAYOUT_COINS.has(coin)) {
return normalizeRoundedUsd(valueCoin, {
code: 'PAYGATE_CONVERSION_INVALID',
message: 'Conversion PayGate invalide'
});
}
let response;
try {
response = await httpClient.get(
`${PAYGATE_API_BASE}/crypto/${NATIVE_PAYOUT_PATHS[coin]}/info.php`,
{ timeout: timeoutMs }
);
} catch (error) {
fail(
'Prix natif PayGate indisponible',
'PAYGATE_NATIVE_PRICE_UNAVAILABLE',
503,
error?.response?.status || null
);
}
let usdPrice;
try {
usdPrice = parsePositiveNumber(response?.data?.prices?.USD, 'prix USD');
} catch {
fail('Prix natif PayGate invalide', 'PAYGATE_NATIVE_PRICE_INVALID', 502);
}
const usdValue = valueCoin * usdPrice;
if (!Number.isFinite(usdValue) || usdValue <= 0) {
fail('Conversion native PayGate invalide', 'PAYGATE_NATIVE_CONVERSION_INVALID', 502);
}
return normalizeRoundedUsd(usdValue, {
code: 'PAYGATE_NATIVE_CONVERSION_INVALID',
message: 'Conversion native PayGate invalide'
});
}
function createPaygateClient({ httpClient = axios, timeoutMs = 15000 } = {}) {
return {
async convertEurToUsd(amountEur) {
const amount = parsePositiveNumber(amountEur, 'montant EUR');
let response;
try {
response = await httpClient.get(`${PAYGATE_API_BASE}/control/convert.php`, {
params: { value: amount.toFixed(2), from: 'eur' },
timeout: timeoutMs
});
} catch (error) {
fail(
'Conversion PayGate indisponible',
'PAYGATE_CONVERT_UNAVAILABLE',
503,
error?.response?.status || null
);
}
if (String(response?.data?.status || '').toLowerCase() !== 'success') {
fail('Réponse conversion PayGate invalide', 'PAYGATE_CONVERT_INVALID', 502);
}
return normalizePaygateInvoiceUsd(
parsePositiveNumber(response.data.value_coin, 'value_coin')
);
},
async createWallet({ settlementWallet, callbackUrl }) {
const wallet = readScalarString(settlementWallet, 'settlement wallet');
if (!EVM_ADDRESS_RE.test(wallet)) {
fail('Settlement wallet PayGate invalide', 'PAYGATE_SETTLEMENT_WALLET_INVALID', 500);
}
let response;
try {
response = await httpClient.get(`${PAYGATE_API_BASE}/control/wallet.php`, {
params: { address: wallet, callback: callbackUrl },
timeout: timeoutMs
});
} catch (error) {
fail(
'Création wallet PayGate indisponible',
'PAYGATE_WALLET_UNAVAILABLE',
503,
error?.response?.status || null
);
}
return parsePaygateWalletResponse(response?.data, callbackUrl);
},
async fetchPaymentStatus(ipnToken) {
const normalizedToken = normalizePaygateOpaqueValue(ipnToken, 'ipn_token');
let response;
try {
response = await httpClient.get(`${PAYGATE_API_BASE}/control/payment-status.php`, {
params: { ipn_token: normalizedToken },
timeout: timeoutMs
});
} catch (error) {
fail(
'Statut PayGate indisponible',
'PAYGATE_STATUS_UNAVAILABLE',
503,
error?.response?.status || null
);
}
return parsePaygatePaymentStatus(response?.data);
},
convertPaymentToUsd(payment) {
return convertPaygatePaymentToUsd(payment, { httpClient, timeoutMs });
}
};
}
module.exports = {
PAYGATE_API_BASE,
PAYGATE_DEFAULT_CHECKOUT_HOST,
STABLE_PAYOUT_COINS,
NATIVE_PAYOUT_PATHS,
EVM_ADDRESS_RE,
EVM_TX_RE,
PaygateProtocolError,
normalizePaygateOpaqueValue,
setOpaqueQueryParam,
safeComparePaygateNonce,
normalizeCallbackBaseUrl,
canonicalizeCallbackUrl,
buildPaygateCallbackUrl,
buildPaygateCheckoutUrl,
parsePaygateWalletResponse,
parsePaygateCallbackPayload,
parsePaygatePaymentStatus,
normalizePaygateInvoiceUsd,
convertPaygatePaymentToUsd,
createPaygateClient
};