mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-07-26 20:42:08 +00:00
feat: live TV FCTV, fournisseur OAuth, sources J1F/Cinestream, modération IA & durcissement Gros lot de travail synchronisé depuis le repo de dev. Les grands axes : migration de domaine (movix.date + miroirs), deux nouvelles sources films/séries (1jour1film, Cinestream) et une n ouvelle source livetv, un fournisseur OAuth « Se connecter avec Movix », une modération de contenu par IA revue, plus un lot de durcissement mémoire/réseau, sécurité et perf côté API et front. ## 🌐 Domaines & miroirs - Domaine canonique `movix.chat`/`movix.golf`/`movix.tax` → **movix.date** partout (front, allow-lists CORS/domaine, Discord RPC). Miroir `movix.health` → **movix.online** (Footer, Header, pages d'aide CaMarchePlus/Dns/Miroirs). Lien Telegram du footer → `mysticsaba.com`. ~10 clés i18n mises à jour. ## 🎬 Sources de streaming (VOD) - **J1F (1jour1film)** — nouvelle source de bout en bout (`routes/j1f.js`, monté sur `/api/j1f`). Résout le domaine tournant via une page `/go/`, décode les blobs base64 `J1F_SRV`/`j1fEpsData` (Dooplay/WP), ne garde que les players uniques, ignore les agrégateurs (frembed/vidsrc/videasy). Cache stale-while-revalidate dédié. Players 1J1F lus via l'extension/userscript. - **Cinestream** — nouvelle source films (`routes/cinestream.js`, scrape Next.js `cinestream.info`, match par id TMDB autoritaire). Wiflix bascule ses **films** sur Cinestream (bot-shield flemmix pénible sur les films) ; la **TV** reste sur flemmix. - **Wiflix** — `flemmix.farm` → **`flemmix.golf`** ; handshake cookie (`makeWiflixSearchRequest`, `h_check=25`) ; sélecteurs de template corrigés. - **Cpasmal** — axios+proxy → `makeCpasmalRequest` (CycleTLS JA3) car Cloudflare 403 ; recherche GET → POST form (le WAF bloque le GET). - **Fstream** — cookie anti-bot `fsschal` ; source épisodes JS statique (`/static/series/<id>.js`) avant l'AJAX `episodes_p.php` ; fallback `get_seasons.php`. - **FranceTV** — `fetchSeasonEpisodesViaDeepPage` (pagination JSON « Afficher plus »). - **Coflix** — `coflix.date` → `coflix.trade` ; logs 429 silencés en double. - **proxyManager** — cinq helpers de contournement Cloudflare exportés (`makeCinestreamRequest`, `make1j1fRequest`, `makeCpasmalRequest`, handshake Wiflix `makeWiflixSearchRequest`/`wiflixHandshake`, `classifyCloudflare429` worker-vs-site) ; CycleTLS + rotation ProxyScrape, ne throw jamais sur statut HTTP. Coflix/LecteurVideo quittent les Cloudflare Workers pour ProxyScrape. ## 📺 Live TV — remplacement de Sosplay/Vavoo par FCTV33 - Source sport **Sosplay → FCTV33** (API RBTV, protobuf brut) réécrite de bout en bout dans `liveTvRoutes.js`, **Vavoo retiré** (manifest/catalogue/stream) — tout passe par `/api/livetv/*`. - Pipeline neuf : décodeur protobuf maison, signature `sfver` (bs keys + MD5), auto-découverte des domaines API/player tournants (`hubu.ru/fctvlink` + Nuxt SSR / `params` rot47, cache 30 min), scraper de matchs (`scrapeFctvMatches` : équipes/logos, ligue, pays, statut live, tri live-first), résolution de flux (HLS natif d'abord, embed en secours), token CDN (XOR keystream + base64, gate `rb-session`). - Route `GET /api/livetv/fctv/playlist` : proxy m3u8 intelligent (résout le token, réécrit les segments, retry sur token périmé, modes `raw` extension / `proxy` VIP). - **Matchs plus réservés aux VIP** (seul `iptv_` le reste) : flux natif proxifié pour les VIP, stub `_fctvLocal` résolu côté client par l'extension/userscript (`RESOLVE_FCTV`), fallback embed sinon. - Front `LiveTV.tsx` : `matches` → `fctv`, UI accordéon par match (logos, badges ligue/pays, score/timer live, favori), disclaimer, fix icône `music`. Rotations d'hôtes : `witv.team`→`witv.football`, `livetv876.me`→`livetv901.me`. ## 🧩 Extensions · Userscript · Discord RPC - **FCTV en lecture native** : handlers `SETUP_FCTV_HEADERS`/`RESOLVE_FCTV` (règle DNR Referer/Origin/UA sur `/cfall/s*` + résolveur local IP-bindé : protobuf-lite + rot47 + token XOR). - **Vavoo retiré** , `cinepulse.lol` → `purstream.mx`. - Fix extracteur **Sibnet** (`Range: bytes=0-0`) ; fix bug userscript `matchToRegex` (patterns ancrés `^…$` cassant `/cfall/s` → dé-ancrés) ; override dev `localhost:25565`. - Bumps de version (Chrome 1.3.3→1.3.8, Firefox 1.5.1→1.5.5, userscript 1.4.0→1.4.7) ; PreMid nouveau `clientId` Discord + `regExp` élargi (1.0.3→1.0.4). ## 🔑 OAuth (fournisseur « Se connecter avec Movix ») & codes d'accès - Refonte du flux d'autorisation OAuth (`OAuthAuthorizePage.tsx`) + routes (`routes/authRoutes.js`, `routes/oauth.js`) et stockage des clients OAuth étoffé (`utils/oauthClientsDb.js`, `routes/adminOauthApps.js` pour la gestion admin des apps). - `AccessCodeForm.tsx` retouché en cohérence avec le nouveau flux ; `checkVip.js` ajusté. - Session centralisée : `accountAuth.ts` expose `clearStoredAuthSession()` / `broadcastAuthChange()` (liste `AUTH_KEYS` unifiée), désormais utilisés partout à la déconnexion. ## 💬 Modération de contenu (IA) - Modèle OpenRouter `google/gemini-2.5-flash-lite` → `deepseek/deepseek-v4-flash` (`commentsRoutes.js`, `sharedListsRoutes.js`) ; fonctions/logs renommés `moderateContentWithGemini` → `moderateContentWithAI` (découplés du nom du modèle). - Prompt durci (critères INSULTES / DEMANDE_AJOUT resserrés) pour éviter les faux positifs sur questions, signalements de bug et plaintes de contenu manquant. - `AdminComments.tsx` : libellé « par Gemini » → clé i18n générique. ## 🧊 Hydracker / Darkiworld - **Sortie du debrid** : AllDebrid/RealDebrid supprimés (`utils/hydrackerLive.js` : branche provider retirée ; vars `ALLDEBRID_*`/`REALDEBRID_*` sorties du `.env`) — hydracker renvoie directement `rawUrl`/`directDL`. Liens servis depuis les snapshots SQLite locaux (`utils/darkiworldSqlite.js`, `darkino-backups/`). - **Anti-scraping** sur `/decode/:id` (`routes/darkiworld.js`) : watchlist + alerte webhook Discord (`DISCORD_SCRAPER_WEBHOOK`) et empoisonnement/blocage de réponse pour les IP listées (`SCRAPER_BLOCKED_IPS`). - **Mémoire** : cache d'agents proxy borné (`utils/lruMap.js`) qui détruit les sockets des agents évincés. ## 🛠️ Admin — propriété des liens & équipe - **Propriété des liens** : les liens streaming gagnent l'attribution `added_by` et l'ownership est appliquée des deux côtés (uploader = ses liens, admin = tous). GET public masqué (`stripLinkOwner`), `GET /admin/streaming-links/:type/:id` authentifié conserve le tag ; `DELETE /admin/links` par URL unique ; `PUT /admin/links` masse admin-only. - **Équipe** : `GET/POST/DELETE /admin/admins` + `GET /admin/team/history` (audit), identité via `adminIdentity.js` (option `preferProfile` → premier profil Movix). UI `AdminTeam.tsx`. - **Privilèges** (`middleware/auth.js`) : `isAdmin` rejette `role !== 'admin'` (403) — les uploaders ne passent plus les routes admin-only. Migration `add_role_to_admins.sql`. ## 🎁 Wrapped v2 - Cartes de partage : logique canvas extraite (`utils/wrappedCanvas.ts`, `utils/wrappedShareCards.ts`), formats de partage supplémentaires + preview. - Nouvelles slides : jour record, répartition par jour de semaine, champion de rewatch, âge ciné (année de sortie médiane pondérée par le temps de visionnage). - Perf : données depuis le payload backend (zéro re-fetch TMDB), trailer lazy, percentile non bloquant (réchauffe en arrière-plan, lock Redis `NX`), shuffle seedé. ## 🔐 Auth · Profils · VIP - **Pseudo forcé** : `validateProfileName` (1–32 chars, strip control/zero-width, + `maxLength={32}` côté UI Profile/WatchParty) ; les pseudos OAuth trop longs → `requiresUsernameChange` + modale bloquante `RequireUsernameChange.tsx`. - **Sync durci** (`ProfileContext.tsx`, `syncPolicy.js`) : anti-replay cross-user, filtrage des ops à clés non-syncables (`access_code`/`access_code_expires` exclues), « wipe guard » qui préserve le local sur profil vide inattendu, dédupe des `loadProfiles()` concurrents. Logout centralisé (voir OAuth). - **VIP** : `is_vip` préservé au chargement de profil (corrige un downgrade VIP inter-onglets qui relançait la popup pub) ; `vipDonations.js` poll de secours `payment-status.php` (via `ipn_token`, colonne `paygate_ipn_token`) → plus d'invoices bloquées. ## ▶️ Lecteur (HLS) - Basse latence opt-in : `isLowLatencyEnabled('movies')` (scope movies/livetv, `lowLatencyPref.ts`) au lieu du mode forcé ; `FloatingPlayer` aligné. - Sous-titres cinep : wrapper m3u8 synthétique pour les URI `.vtt`/`.srt` directes, `isSubtitleLoadError` (une piste cassée ne tue plus la vidéo), rendu `<i>/<b>/<u>`. - Menu sources regroupé par langue (VF/VFQ/VFF/VOSTFR). ## 📡 Cast (AirPlay / Chromecast) - Retrait du contournement proxy darkibox (`buildApiProxyUrl`) → cast direct de l'URL ; `runtime.ts` supprime `API_PROXY_BASE_URL`/`buildApiProxyUrl` devenus morts. - Paramètre `streamType` (`BUFFERED` par défaut) propagé jusqu'à `loadMediaOnCastWithFallback`. ## 📢 Pubs - Modes de popup (`adPopupMode.ts`) : `normal`, `auto`, `click-anywhere` ; toggle « +18 » séparé (`adAdultMode.ts`). `SCRIPT_AD_MODE_ENABLED` → `false`, URL du script changée. ## 🔎 Recherche & priorité des sources - `searchUtils.ts` : le terme original contourne le filtre « trop générique » (fix zéro-résultat sur titres courts : Baki, Gantz). - `animeSignals.ts` : détection donghua (CN/TW/HK ou `zh` + Animation, hors C-drama). - `sourceAutoSelect.ts` : `VFF`/`VFQ` normalisés en `vf` (sinon reléguées derrière VOSTFR). `sourcePriorityPrefs.ts` : schéma v2 → v3 (nouvel ordre par défaut + `j1f`, migration non destructive des ordres personnalisés). `extractionPrefs.ts` : +daddylive. ## 🔒 Sécurité - Sanitisation d'URL avant rendu en `href` : `CommentsSection.tsx` (liens markdown `http(s)://`) et `Greenlight/AdminLinkSubmissions.tsx` — bloque les `javascript:`. ## ⚙️ Infra & perf - **server.js** : recyclage périodique des workers (12h) + `[memstats]` + hook `SIGUSR2` → heap-snapshot. - **app.js** : monte `/api/j1f`, retire la route statique `/oauth-icons`, passe `makeCpasmalRequest` au router Cpasmal, `coflix.date` → `coflix.trade`. - **lazyWithRetry.ts / main.tsx / ErrorBoundary.tsx** : récupération chunk-load réécrite (retry + reload budgété), garde `removeChild/insertBefore`, écran soft « Mise à jour » au lieu du crash-report. - **UI** : `TopProgressBar` écrit `transform` directement sur un ref (compositor-only, plus de re-render) ; `blur-text` relâche `will-change` après l'animation. - **proxiesembed/server.py** : `curl_cffi` (JA3) pour `cinep-proxy` + retry SOCKS5 sur 403 ; lookup clé VIP réaligné sur `checkVip.js` ; log WARNING par défaut ; SOCKS5 forcé IPv4 ; wrapper VTT/SRT pour hls.js ; re-label `application/zip` → `video/mp2t` ; `cinepulse.lol` → `purstream.mx`. ## 🧹 Nettoyage & suppressions - **Front** : carte promo « Loadix » sur Download (remplace l'encart beta), « 1jour1film » ajouté à « C'est quoi Movix », fallback Darkino/Nightflix gutté en no-op (`api.movix.chat` décommissionné), classes CSS mortes retirées, fix `fetchPriority` → `fetchpriority`. - **Backend** : scrapers/utilitaires morts retirés — `routes/dragiv.js`, `routes/topstream.js`, `routes/proxy.js`, `utils/cloudflareSession.js`, `utils/hydrackerBatch.js`, `utils/hydrackerQueue.js'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
402 lines
14 KiB
JavaScript
402 lines
14 KiB
JavaScript
/**
|
|
* 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 { redis } = require('../config/redis');
|
|
|
|
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: [] };
|
|
}
|
|
|
|
// ─── Cross-worker cache invalidation (Redis pub/sub) ─────────────────────
|
|
// Movix runs in Node cluster mode (server.js): each worker has its own
|
|
// in-process `memCache`. A DB mutation only reloads the worker that handled
|
|
// it — other workers keep serving stale OAuth clients until they reboot.
|
|
// After every mutation we publish on a Redis channel so all workers reload.
|
|
// Without this, a redirect_uri added to a client after boot is rejected
|
|
// intermittently, depending on which worker the request is routed to.
|
|
|
|
const CLIENTS_CHANGED_CHANNEL = 'oauth:clients:changed';
|
|
let cacheSubscriber = null;
|
|
|
|
/** Notify every cluster worker that the oauth_clients table changed. */
|
|
async function publishClientsChanged() {
|
|
try {
|
|
await redis.publish(CLIENTS_CHANGED_CHANNEL, String(process.pid));
|
|
} catch (err) {
|
|
// The local reloadCache() already ran; cross-worker propagation is
|
|
// best-effort and self-heals at the next mutation or worker restart.
|
|
console.warn('[OAuth Clients DB] publishClientsChanged failed:', err.message);
|
|
}
|
|
}
|
|
|
|
/** Reload this worker's cache, then broadcast so every other worker reloads. */
|
|
async function reloadCacheAndBroadcast() {
|
|
await reloadCache();
|
|
await publishClientsChanged();
|
|
}
|
|
|
|
/**
|
|
* Start the per-worker cache subscriber. Call once per worker at boot.
|
|
* Uses a dedicated connection — in ioredis a connection in subscriber mode
|
|
* cannot run normal commands. ioredis auto-reconnects and re-subscribes.
|
|
*/
|
|
function startClientsCacheSubscriber() {
|
|
if (cacheSubscriber) return;
|
|
cacheSubscriber = redis.duplicate();
|
|
cacheSubscriber.on('error', (err) => {
|
|
console.error('[OAuth Clients DB] cache subscriber error:', err.message);
|
|
});
|
|
cacheSubscriber.on('message', (channel, message) => {
|
|
if (channel !== CLIENTS_CHANGED_CHANNEL) return;
|
|
reloadCache()
|
|
.then(() => console.log(`[OAuth Clients DB] cache reloaded (pub/sub from pid ${message})`))
|
|
.catch((err) => console.error('[OAuth Clients DB] reloadCache after pub/sub failed:', err.message));
|
|
});
|
|
cacheSubscriber.subscribe(CLIENTS_CHANGED_CHANNEL, (err) => {
|
|
if (err) {
|
|
console.error('[OAuth Clients DB] subscribe failed:', err.message);
|
|
return;
|
|
}
|
|
console.log('[OAuth Clients DB] cache subscriber ready on', CLIENTS_CHANGED_CHANNEL);
|
|
});
|
|
}
|
|
|
|
// ─── 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,
|
|
reloadCacheAndBroadcast,
|
|
publishClientsChanged,
|
|
startClientsCacheSubscriber,
|
|
getCachedClients,
|
|
invalidateCache,
|
|
recordEvent,
|
|
getStats,
|
|
grantVip,
|
|
ICON_DIR,
|
|
KNOWN_OAUTH_SCOPES_SET,
|
|
};
|