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>
603 lines
22 KiB
JavaScript
603 lines
22 KiB
JavaScript
/**
|
|
* 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
|
|
* `reloadCacheAndBroadcast()` en fin de requête : recharge le cache du
|
|
* worker courant ET publie sur Redis (canal `oauth:clients:changed`) pour
|
|
* que tous les autres workers du cluster rechargent aussi.
|
|
*
|
|
* Sans ce broadcast, un worker dont le cache est stale rejette
|
|
* indéfiniment les requêtes OAuth d'un client modifié après le boot
|
|
* (ex. une redirect_uri ajoutée) — symptôme intermittent selon le worker
|
|
* qui reçoit la requête.
|
|
*/
|
|
|
|
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.reloadCacheAndBroadcast();
|
|
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.reloadCacheAndBroadcast();
|
|
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.reloadCacheAndBroadcast();
|
|
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.reloadCacheAndBroadcast();
|
|
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.reloadCacheAndBroadcast();
|
|
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.reloadCacheAndBroadcast();
|
|
}
|
|
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.reloadCacheAndBroadcast();
|
|
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;
|