From 2045dbd2c9d6c0cd05819babe0faa147c591d5d9 Mon Sep 17 00:00:00 2001 From: Movix <178902026+MysticSaba-max@users.noreply.github.com> Date: Thu, 14 May 2026 17:26:13 +0200 Subject: [PATCH] =?UTF-8?q?Nouvelle=20adresse=20movix=20+=20nouvelles=20fo?= =?UTF-8?q?nctionalit=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modification du Oauth : Pouvoir manager les apps depuis le panel admin Correction de l'affichage des images des admins Majs des urls extension, userscript et premid Correction d'un crash sur les commentaires Ajout d'un light mode sur le frontend --- API/Mainapi/README.md | 2 +- API/Mainapi/app.js | 25 + .../exportscripts/add_oauth_apps_tables.sql | 112 ++ API/Mainapi/liveTvRoutes.js | 4 +- API/Mainapi/middleware/cors.js | 2 + API/Mainapi/middleware/security.js | 2 + .../oauth-icons/movix-mcp-1778761296022.jpg | Bin 0 -> 34262 bytes API/Mainapi/routes/adminOauthApps.js | 604 +++++++++ .../routes/downloadLinksLeaderboard.js | 24 +- API/Mainapi/routes/oauth.js | 1117 ++++++++++++++++- API/Mainapi/routes/purstream.js | 2 +- API/Mainapi/routes/sync.js | 1 + API/Mainapi/utils/adminIdentity.js | 69 + API/Mainapi/utils/oauthClients.js | 140 ++- API/Mainapi/utils/oauthClientsDb.js | 344 +++++ API/Mainapi/utils/syncPolicy.js | 4 +- API/Mainapi/wishboardRoutes.js | 21 +- extension/Chrome/background.js | 2 +- extension/Chrome/manifest.json | 4 +- extension/Chrome/popup.html | 4 +- extension/Firefox/background.js | 2 +- extension/Firefox/popup.html | 4 +- src/App.tsx | 22 +- src/components/AdminDashboard.tsx | 21 + src/components/AdminOAuthApps.tsx | 1073 ++++++++++++++++ src/components/CommentsSection.tsx | 12 +- src/components/HeroSlider.tsx | 9 +- src/components/MarkdownToolbar.tsx | 11 +- src/components/RedirectPopup.tsx | 2 +- src/components/ui/checkbox.tsx | 63 + src/components/ui/confirm-dialog.tsx | 72 ++ src/components/ui/square-background.tsx | 6 +- src/components/ui/switch.tsx | 62 + src/context/LightModeContext.tsx | 104 +- src/i18n/locales/en.json | 306 ++++- src/i18n/locales/fr.json | 306 ++++- src/main.tsx | 1 + src/pages/OAuthAuthorizePage.tsx | 274 +++- src/pages/SettingsPage.tsx | 251 +++- src/pages/TVDetails.tsx | 128 +- src/pages/Watch/WatchAnime.tsx | 97 +- src/pages/WatchPartyRoom.tsx | 16 +- src/pages/WrappedPage.tsx | 2 +- src/styles/light-mode.css | 107 +- src/utils/bgPreferences.ts | 11 +- src/utils/markdownPlugins.ts | 51 +- userscript/movix.user.js | 29 +- 47 files changed, 5153 insertions(+), 372 deletions(-) create mode 100644 API/Mainapi/exportscripts/add_oauth_apps_tables.sql create mode 100644 API/Mainapi/public/oauth-icons/movix-mcp-1778761296022.jpg create mode 100644 API/Mainapi/routes/adminOauthApps.js create mode 100644 API/Mainapi/utils/adminIdentity.js create mode 100644 API/Mainapi/utils/oauthClientsDb.js create mode 100644 src/components/AdminOAuthApps.tsx create mode 100644 src/components/ui/checkbox.tsx create mode 100644 src/components/ui/confirm-dialog.tsx create mode 100644 src/components/ui/switch.tsx diff --git a/API/Mainapi/README.md b/API/Mainapi/README.md index e63e65e..19ab697 100644 --- a/API/Mainapi/README.md +++ b/API/Mainapi/README.md @@ -65,7 +65,7 @@ Le fichier `API/Mainapi/.env.example` est la référence complète. En pratique, - cache et coordination : `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD`, `NUM_WORKERS` - scraping / proxy : `PROXY_SERVER_URL`, `CF_PROXY_403_URL`, `BYPASS403_SERVER_URL`, `SOCKS5_PROXIES`, `HTTP_PROXIES` - anti-abuse / forms : `TURNSTILE_SECRET_KEY`, `TURNSTILE_INVISIBLE_SECRETKEY` -- paiement / VIP : variables `VIP_*`, `BTC_EXPLORER_API`, `LTC_EXPLORER_API` +- paiement / VIP : variables `VIP_*`, `BLOCKCYPHER_TOKEN` Certaines intégrations sont très spécifiques à des sources données, par exemple les cookies `DARKIWORLD_*`, `FSTREAM_LOGIN_*` ou `XTREAM_*`. diff --git a/API/Mainapi/app.js b/API/Mainapi/app.js index 08de642..e8f8df8 100644 --- a/API/Mainapi/app.js +++ b/API/Mainapi/app.js @@ -260,6 +260,20 @@ app.use(jsonParseErrorHandler); app.use(express.urlencoded({ extended: true, limit: "5mb" })); // Reduced from 1000mb to prevent abuse +// 8. Serve uploaded OAuth app icons (`public/oauth-icons/`). +// Le panel admin upload ici, OAuthAuthorizePage lit `/oauth-icons/`. +const { ICON_DIR: OAUTH_ICON_DIR } = require('./utils/oauthClientsDb'); +app.use( + '/oauth-icons', + express.static(OAUTH_ICON_DIR, { + fallthrough: false, + maxAge: '7d', + setHeaders: (res) => { + res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); + }, + }), +); + // ========================================================================== // Configure route modules with dependencies from extracted utilities // ========================================================================== @@ -459,6 +473,7 @@ app.use('/api/profiles', require('./routes/profiles')); app.use('/api/help', require('./routes/helpFeedback')); app.use('/api/auth', require('./routes/authRoutes')); app.use('/api/oauth', oauthRouter); +app.use('/api/admin/oauth-apps', require('./routes/adminOauthApps')); app.use('/api/sessions', require('./routes/sessions')); app.use('/api', require('./routes/debrid')); app.use('/proxy', require('./routes/proxy')); @@ -533,6 +548,16 @@ const appReady = (async () => { await ensureOAuthStorage(pool); console.log('OAuth tables initialized successfully'); + // OAuth client config (table oauth_clients + stats + grants VIP). + // ensureTables et migrateLegacyJsonIfNeeded sont protégés par le lock + // mais idempotents — sûr sur restart cluster. reloadCache hydrate + // le cache in-process de CE worker (chaque worker a le sien). + const oauthClientsDb = require('./utils/oauthClientsDb'); + await oauthClientsDb.ensureTables(); + await oauthClientsDb.migrateLegacyJsonIfNeeded(); + await oauthClientsDb.reloadCache(); + console.log('OAuth client tables initialized successfully'); + // Initialize Wishboard routes const { createWishboardRouter } = require("./wishboardRoutes"); const wishboardRouter = createWishboardRouter(pool, redis); diff --git a/API/Mainapi/exportscripts/add_oauth_apps_tables.sql b/API/Mainapi/exportscripts/add_oauth_apps_tables.sql new file mode 100644 index 0000000..80239ce --- /dev/null +++ b/API/Mainapi/exportscripts/add_oauth_apps_tables.sql @@ -0,0 +1,112 @@ +-- Migration : passage du fichier `data/oauth-clients.json` à 3 tables MySQL. +-- - oauth_clients : config des apps (remplace le JSON) +-- - oauth_app_stats : compteur d'appels par app + type d'event +-- - oauth_vip_grants : historique des grants VIP émis par chaque app +-- +-- Idempotent grâce à `CREATE TABLE IF NOT EXISTS`. +-- Lance avec : `mysql -u -p movix < add_oauth_apps_tables.sql` +-- ou via le script `routes/admin.js` au démarrage (auto-migrate). + +CREATE TABLE IF NOT EXISTS oauth_clients ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + + -- Identifiant public visible dans le query OAuth (?client_id=...). + client_id VARCHAR(128) NOT NULL UNIQUE, + + -- Nom affiché sur la page d'autorisation et dans le panel admin. + client_name VARCHAR(200) NOT NULL, + + description TEXT NULL, + homepage_url VARCHAR(500) NULL, + + -- JSON arrays — sérialisation gérée côté Node. + redirect_uris JSON NOT NULL, + allowed_scopes JSON NOT NULL, + + -- Type de client : public (PKCE obligatoire, pas de secret) ou + -- confidentiel (client_secret nécessaire). + public_client TINYINT(1) NOT NULL DEFAULT 1, + require_pkce TINYINT(1) NOT NULL DEFAULT 1, + -- Secret en clair (uniquement si publicClient = 0). + client_secret VARCHAR(256) NULL, + + -- Nom de fichier de l'icône (relatif à `public/oauth-icons/`). + -- Ex : "movix-mcp-1234567890.png". NULL = pas d'icône custom. + icon_filename VARCHAR(200) NULL, + + -- Compteur de jours VIP que l'app peut distribuer via /api/oauth/vip/grant. + -- Décrément à chaque grant ; admin peut alimenter via le panel. + vip_days_balance INT NOT NULL DEFAULT 0, + + -- Désactivation soft (cache l'app de la list mais garde l'historique). + is_active TINYINT(1) NOT NULL DEFAULT 1, + + created_at BIGINT UNSIGNED NOT NULL, + updated_at BIGINT UNSIGNED NOT NULL, + + PRIMARY KEY (id), + KEY idx_client_id (client_id), + KEY idx_is_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Stats : un event = une ligne. Permet de grapher l'usage par app. +-- Cleanup périodique : `DELETE FROM oauth_app_stats WHERE created_at < (now - 90j)`. +CREATE TABLE IF NOT EXISTS oauth_app_stats ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + + -- Référence vers oauth_clients.client_id (pas la PK numérique, pour + -- survivre à une suppression). + client_id VARCHAR(128) NOT NULL, + + -- Type d'event : 'authorize' (page d'auth affichée), 'authorize_granted' + -- (user a cliqué Autoriser), 'authorize_denied', 'token' (échange code → + -- token), 'api_call' (toute requête OAuth authentifiée), 'vip_grant'. + event_type VARCHAR(32) NOT NULL, + + -- User concerné (si applicable). Format `userType:userId`. + user_id VARCHAR(160) NULL, + + -- Métadonnées libres (path, status, scope demandé, etc.) en JSON. + metadata JSON NULL, + + created_at BIGINT UNSIGNED NOT NULL, + + PRIMARY KEY (id), + KEY idx_client_event (client_id, event_type, created_at), + KEY idx_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Historique des grants VIP. Chaque ligne = un grant fait par une app +-- à un user. Sert d'audit + sert à recréer une access_key si l'user +-- perd la sienne. +CREATE TABLE IF NOT EXISTS oauth_vip_grants ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + + client_id VARCHAR(128) NOT NULL, + + -- User qui reçoit le VIP. Format `userType:userId`. + user_id VARCHAR(160) NOT NULL, + user_type VARCHAR(16) NOT NULL, + user_id_only VARCHAR(128) NOT NULL, + + -- Jours grantés (décrémenté de oauth_clients.vip_days_balance). + days_granted INT UNSIGNED NOT NULL, + + -- Access key générée (référence vers access_keys.key_value). + access_key_value VARCHAR(128) NOT NULL, + + -- Date de validité de la clé + expires_at DATETIME NOT NULL, + + -- Audit + granted_at BIGINT UNSIGNED NOT NULL, + + -- Si l'admin révoque le grant : on flag (mais on n'efface pas la clé + -- automatiquement — l'admin doit le faire séparément). + revoked_at BIGINT UNSIGNED NULL, + + PRIMARY KEY (id), + KEY idx_client_id (client_id, granted_at), + KEY idx_user_id (user_id, granted_at), + KEY idx_access_key (access_key_value) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/API/Mainapi/liveTvRoutes.js b/API/Mainapi/liveTvRoutes.js index f7c4520..e715542 100644 --- a/API/Mainapi/liveTvRoutes.js +++ b/API/Mainapi/liveTvRoutes.js @@ -4326,8 +4326,8 @@ router.delete("/cache", async (req, res) => { const XTREAM_URL = (process.env.XTREAM_URL || "").replace(/\/+$/, ""); const XTREAM_USER = process.env.XTREAM_USER || ""; const XTREAM_PASS = process.env.XTREAM_PASS || ""; -const IPTV_IMAGE_PROXY = "https://proxy.movix.cash/proxy"; -const IPTV_STREAM_PROXY = "https://proxiesembed.movix.cash/proxy"; +const IPTV_IMAGE_PROXY = "https://proxy.movix.tax/proxy"; +const IPTV_STREAM_PROXY = "https://proxiesembed.movix.tax/proxy"; // Cache catégories IPTV en mémoire (change rarement) let iptvCategoriesCache = null; diff --git a/API/Mainapi/middleware/cors.js b/API/Mainapi/middleware/cors.js index fe5578f..675a200 100644 --- a/API/Mainapi/middleware/cors.js +++ b/API/Mainapi/middleware/cors.js @@ -7,6 +7,8 @@ const cors = require("cors"); const { getOAuthAllowedCorsOrigins } = require('../utils/oauthClients'); const STATIC_ALLOWED_DOMAINS = [ + 'movix.tax', + 'movix.cash', 'movix.blog', 'movix.rodeo', 'movix.club', diff --git a/API/Mainapi/middleware/security.js b/API/Mainapi/middleware/security.js index 459e41e..803f223 100644 --- a/API/Mainapi/middleware/security.js +++ b/API/Mainapi/middleware/security.js @@ -40,6 +40,8 @@ function domainRestriction(req, res, next) { const allowedDomains = [ 'localhost:3000', + 'movix.tax', + 'movix.cash', 'movix.blog', 'movix.rodeo', 'movix.club', diff --git a/API/Mainapi/public/oauth-icons/movix-mcp-1778761296022.jpg b/API/Mainapi/public/oauth-icons/movix-mcp-1778761296022.jpg new file mode 100644 index 0000000000000000000000000000000000000000..106666f07a2c87fcb4c57985b8d1d4e75992c4e3 GIT binary patch literal 34262 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 JCFIBK{{!E}hRpx~ literal 0 HcmV?d00001 diff --git a/API/Mainapi/routes/adminOauthApps.js b/API/Mainapi/routes/adminOauthApps.js new file mode 100644 index 0000000..1e0cb88 --- /dev/null +++ b/API/Mainapi/routes/adminOauthApps.js @@ -0,0 +1,604 @@ +/** + * Routes admin pour gérer les applications OAuth Movix. + * Mount : `app.use('/api/admin/oauth-apps', adminOauthAppsRouter)`. + * + * Toutes les routes sont protégées par `isAdmin` (table `admins`). + * Source de vérité : la table `oauth_clients` (alimentée au boot par + * `oauthClientsDb.reloadCache()`). Toute mutation appelle `reloadCache()` + * en fin de requête pour rafraîchir le cache du worker courant. + * + * Note multi-worker : chaque worker a son propre cache in-process. Une + * mutation depuis le worker A ne rafraîchit pas le cache du worker B + * immédiatement. C'est acceptable car : + * 1) les opérations admin sont rares ; + * 2) le cache est rechargé au boot ; + * 3) une lecture stale max 1 requête. + * Si besoin d'invalidation cross-worker → publier un message Redis. + */ + +const express = require('express'); +const crypto = require('crypto'); +const path = require('path'); +const fs = require('fs'); +const fsp = require('fs').promises; +const rateLimit = require('express-rate-limit'); +const { ipKeyGenerator } = require('express-rate-limit'); + +const { isAdmin } = require('../middleware/auth'); +const { getPool } = require('../mysqlPool'); +const oauthClientsDb = require('../utils/oauthClientsDb'); +const { KNOWN_OAUTH_SCOPES } = require('../utils/oauthClients'); +const { createRedisRateLimitStore } = require('../utils/redisRateLimitStore'); + +const router = express.Router(); + +const ALLOWED_ICON_MIME = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/webp': 'webp', +}; +const MAX_ICON_SIZE_BYTES = 256 * 1024; // 256 KB + +const CLIENT_ID_RE = /^[a-z0-9][a-z0-9-]{1,64}$/; + +// Petit rate-limiter pour les routes admin OAuth (anti-bruteforce sur les secrets). +const adminOauthAppsLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 60, + store: createRedisRateLimitStore({ prefix: 'rate-limit:admin:oauth-apps:' }), + passOnStoreError: true, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req) => + req.headers['cf-connecting-ip'] + || req.headers['x-forwarded-for']?.split(',')[0].trim() + || ipKeyGenerator(req.ip), + validate: { xForwardedForHeader: false, ip: false }, +}); + +router.use(adminOauthAppsLimiter); +router.use(isAdmin); + +// ─── helpers ──────────────────────────────────────────────────────────── + +function badRequest(res, message) { + return res.status(400).json({ success: false, error: message }); +} + +function notFound(res, message = 'Application OAuth introuvable') { + return res.status(404).json({ success: false, error: message }); +} + +function serverError(res, error, message = 'Erreur serveur') { + console.error('[adminOauthApps]', message, error?.message || error); + return res.status(500).json({ success: false, error: message }); +} + +function sanitizeClientId(raw) { + const value = String(raw || '').trim().toLowerCase(); + return CLIENT_ID_RE.test(value) ? value : null; +} + +function sanitizeClientName(raw) { + if (typeof raw !== 'string') return null; + const value = raw.trim().slice(0, 200); + return value.length >= 2 ? value : null; +} + +function sanitizeDescription(raw) { + if (raw == null || raw === '') return null; + if (typeof raw !== 'string') return null; + return raw.trim().slice(0, 2000) || null; +} + +function sanitizeHttpUrl(raw) { + if (raw == null || raw === '') return null; + if (typeof raw !== 'string') return null; + try { + const url = new URL(raw.trim()); + // HTTPS only : le homepageUrl est rendu en lien cliquable sur la page + // d'autorisation OAuth (boundary de confiance pour l'utilisateur). + // Pas d'exception loopback ici — c'est pour le marketing, pas pour OAuth. + if (url.protocol !== 'https:') return null; + url.hash = ''; + return url.toString(); + } catch { + return null; + } +} + +function sanitizeRedirectUris(rawArray) { + if (!Array.isArray(rawArray)) return null; + const result = []; + for (const raw of rawArray) { + if (typeof raw !== 'string') continue; + try { + const url = new URL(raw.trim()); + const host = url.hostname.toLowerCase(); + const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '::1' || host.endsWith('.localhost'); + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLoopback)) { + continue; + } + url.hash = ''; + result.push(url.toString()); + } catch { /* skip */ } + } + return result.length > 0 ? Array.from(new Set(result)) : null; +} + +function sanitizeScopes(rawArray) { + if (!Array.isArray(rawArray)) return null; + const result = Array.from(new Set( + rawArray + .map((s) => String(s || '').trim()) + .filter((s) => KNOWN_OAUTH_SCOPES.includes(s)), + )); + return result.length > 0 ? result : null; +} + +function generateClientSecret() { + // 64 chars hex = 256 bits — assez pour un secret OAuth. + return crypto.randomBytes(32).toString('hex'); +} + +function serializeAppRow(row) { + return { + id: Number(row.id), + clientId: row.client_id, + clientName: row.client_name, + description: row.description || null, + homepageUrl: row.homepage_url || null, + redirectUris: safeJsonParse(row.redirect_uris, []), + allowedScopes: safeJsonParse(row.allowed_scopes, []), + publicClient: row.public_client === 1 || row.public_client === true, + requirePkce: row.require_pkce === 1 || row.require_pkce === true, + hasClientSecret: !!row.client_secret, + iconFilename: row.icon_filename || null, + iconUrl: row.icon_filename ? `/oauth-icons/${row.icon_filename}` : null, + vipDaysBalance: Number(row.vip_days_balance || 0), + isActive: row.is_active === 1 || row.is_active === true, + createdAt: Number(row.created_at || 0), + updatedAt: Number(row.updated_at || 0), + }; +} + +function safeJsonParse(raw, fallback) { + if (typeof raw !== 'string' || !raw.trim()) { + return Array.isArray(raw) ? raw : fallback; + } + try { + return JSON.parse(raw); + } catch { + return fallback; + } +} + +async function fetchAppByClientId(pool, clientId) { + const [rows] = await pool.execute( + 'SELECT * FROM oauth_clients WHERE client_id = ? LIMIT 1', + [clientId], + ); + return rows[0] || null; +} + +async function removeIconFile(filename) { + if (!filename) return; + const target = path.join(oauthClientsDb.ICON_DIR, path.basename(filename)); + try { + await fsp.unlink(target); + } catch { + /* swallow: déjà absent */ + } +} + +// ─── routes ───────────────────────────────────────────────────────────── + +router.get('/scopes', (req, res) => { + res.json({ success: true, scopes: [...KNOWN_OAUTH_SCOPES] }); +}); + +router.get('/', async (req, res) => { + try { + const pool = getPool(); + const includeInactive = req.query?.inactive === '1' || req.query?.inactive === 'true'; + const whereSql = includeInactive ? '' : 'WHERE is_active = 1'; + const [rows] = await pool.execute( + `SELECT * FROM oauth_clients ${whereSql} ORDER BY created_at DESC`, + ); + + // Stats compactes par app (30 derniers jours) pour l'affichage en liste. + const since = Date.now() - 30 * 24 * 60 * 60 * 1000; + const [statsRows] = await pool.execute( + `SELECT client_id, event_type, COUNT(*) AS n + FROM oauth_app_stats + WHERE created_at >= ? + GROUP BY client_id, event_type`, + [since], + ); + const statsByClient = new Map(); + for (const r of statsRows) { + if (!statsByClient.has(r.client_id)) statsByClient.set(r.client_id, {}); + statsByClient.get(r.client_id)[r.event_type] = Number(r.n); + } + + const apps = rows.map((row) => ({ + ...serializeAppRow(row), + stats30d: statsByClient.get(row.client_id) || {}, + })); + return res.json({ success: true, apps }); + } catch (err) { + return serverError(res, err, 'Impossible de lister les applications'); + } +}); + +router.get('/:clientId', async (req, res) => { + try { + const clientId = sanitizeClientId(req.params.clientId); + if (!clientId) return badRequest(res, 'clientId invalide'); + const pool = getPool(); + const row = await fetchAppByClientId(pool, clientId); + if (!row) return notFound(res); + return res.json({ success: true, app: serializeAppRow(row) }); + } catch (err) { + return serverError(res, err, 'Impossible de charger l\'application'); + } +}); + +router.post('/', async (req, res) => { + try { + const clientId = sanitizeClientId(req.body?.clientId); + if (!clientId) return badRequest(res, 'clientId invalide (a-z, 0-9, -, 2 à 65 caractères)'); + const clientName = sanitizeClientName(req.body?.clientName); + if (!clientName) return badRequest(res, 'clientName requis (≥2 caractères)'); + const redirectUris = sanitizeRedirectUris(req.body?.redirectUris); + if (!redirectUris) return badRequest(res, 'redirectUris requis (≥1 URI HTTPS ou loopback http)'); + const allowedScopes = sanitizeScopes(req.body?.allowedScopes); + if (!allowedScopes) return badRequest(res, 'allowedScopes requis (≥1 scope connu)'); + const description = sanitizeDescription(req.body?.description); + const homepageUrl = sanitizeHttpUrl(req.body?.homepageUrl); + const publicClient = req.body?.publicClient !== false; + const requirePkce = publicClient ? true : req.body?.requirePkce === true; + const generatedSecret = !publicClient ? generateClientSecret() : null; + + const pool = getPool(); + const existing = await fetchAppByClientId(pool, clientId); + if (existing) return badRequest(res, 'Cet clientId existe déjà'); + + const now = Date.now(); + await pool.execute( + `INSERT INTO oauth_clients + (client_id, client_name, description, homepage_url, redirect_uris, + allowed_scopes, public_client, require_pkce, client_secret, + is_active, vip_days_balance, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, ?, ?)`, + [ + clientId, + clientName, + description, + homepageUrl, + JSON.stringify(redirectUris), + JSON.stringify(allowedScopes), + publicClient ? 1 : 0, + requirePkce ? 1 : 0, + generatedSecret, + now, + now, + ], + ); + + await oauthClientsDb.reloadCache(); + const row = await fetchAppByClientId(pool, clientId); + const serialized = serializeAppRow(row); + // Le secret n'est exposé qu'UNE fois (à la création) — l'admin doit le copier. + return res.json({ + success: true, + app: serialized, + clientSecret: generatedSecret, // null pour les clients publics + }); + } catch (err) { + return serverError(res, err, 'Impossible de créer l\'application'); + } +}); + +router.put('/:clientId', async (req, res) => { + try { + const clientId = sanitizeClientId(req.params.clientId); + if (!clientId) return badRequest(res, 'clientId invalide'); + + const pool = getPool(); + const existing = await fetchAppByClientId(pool, clientId); + if (!existing) return notFound(res); + + const updates = []; + const params = []; + + if (req.body?.clientName !== undefined) { + const v = sanitizeClientName(req.body.clientName); + if (!v) return badRequest(res, 'clientName invalide'); + updates.push('client_name = ?'); params.push(v); + } + if (req.body?.description !== undefined) { + updates.push('description = ?'); params.push(sanitizeDescription(req.body.description)); + } + if (req.body?.homepageUrl !== undefined) { + updates.push('homepage_url = ?'); params.push(sanitizeHttpUrl(req.body.homepageUrl)); + } + if (req.body?.redirectUris !== undefined) { + const v = sanitizeRedirectUris(req.body.redirectUris); + if (!v) return badRequest(res, 'redirectUris invalide (≥1 URI HTTPS ou loopback http)'); + updates.push('redirect_uris = ?'); params.push(JSON.stringify(v)); + } + if (req.body?.allowedScopes !== undefined) { + const v = sanitizeScopes(req.body.allowedScopes); + if (!v) return badRequest(res, 'allowedScopes invalide'); + updates.push('allowed_scopes = ?'); params.push(JSON.stringify(v)); + } + if (req.body?.publicClient !== undefined) { + const becomesPublic = req.body.publicClient === true; + updates.push('public_client = ?'); params.push(becomesPublic ? 1 : 0); + if (becomesPublic) { + // Switch confidential → public : on force pkce et on supprime le secret. + updates.push('require_pkce = 1'); + updates.push('client_secret = NULL'); + } + } + if (req.body?.requirePkce !== undefined) { + updates.push('require_pkce = ?'); params.push(req.body.requirePkce === true ? 1 : 0); + } + if (req.body?.isActive !== undefined) { + updates.push('is_active = ?'); params.push(req.body.isActive === true ? 1 : 0); + } + + if (updates.length === 0) return badRequest(res, 'Aucun champ à mettre à jour'); + + updates.push('updated_at = ?'); params.push(Date.now()); + params.push(clientId); + + await pool.execute( + `UPDATE oauth_clients SET ${updates.join(', ')} WHERE client_id = ?`, + params, + ); + + await oauthClientsDb.reloadCache(); + const row = await fetchAppByClientId(pool, clientId); + return res.json({ success: true, app: serializeAppRow(row) }); + } catch (err) { + return serverError(res, err, 'Impossible de mettre à jour l\'application'); + } +}); + +router.post('/:clientId/regenerate-secret', async (req, res) => { + try { + const clientId = sanitizeClientId(req.params.clientId); + if (!clientId) return badRequest(res, 'clientId invalide'); + const pool = getPool(); + const existing = await fetchAppByClientId(pool, clientId); + if (!existing) return notFound(res); + if (existing.public_client === 1 || existing.public_client === true) { + return badRequest(res, 'Les clients publics n\'utilisent pas de clientSecret'); + } + const newSecret = generateClientSecret(); + await pool.execute( + 'UPDATE oauth_clients SET client_secret = ?, updated_at = ? WHERE client_id = ?', + [newSecret, Date.now(), clientId], + ); + await oauthClientsDb.reloadCache(); + return res.json({ success: true, clientSecret: newSecret }); + } catch (err) { + return serverError(res, err, 'Impossible de régénérer le secret'); + } +}); + +router.delete('/:clientId', async (req, res) => { + try { + const clientId = sanitizeClientId(req.params.clientId); + if (!clientId) return badRequest(res, 'clientId invalide'); + const pool = getPool(); + const existing = await fetchAppByClientId(pool, clientId); + if (!existing) return notFound(res); + // Hard delete : on supprime la ligne ; les stats et grants restent + // (FK absente volontairement — historique d'audit). + await pool.execute('DELETE FROM oauth_clients WHERE client_id = ?', [clientId]); + // Cleanup icône si présente. + if (existing.icon_filename) { + await removeIconFile(existing.icon_filename); + } + await oauthClientsDb.reloadCache(); + return res.json({ success: true }); + } catch (err) { + return serverError(res, err, 'Impossible de supprimer l\'application'); + } +}); + +// Upload icône : JSON body { mimeType, dataBase64 } +// On évite multer pour ne pas ajouter une dépendance ; les icônes sont +// petites (< 256KB) donc base64 dans le body JSON est OK. +router.post('/:clientId/icon', async (req, res) => { + try { + const clientId = sanitizeClientId(req.params.clientId); + if (!clientId) return badRequest(res, 'clientId invalide'); + const mimeType = String(req.body?.mimeType || '').trim().toLowerCase(); + const ext = ALLOWED_ICON_MIME[mimeType]; + if (!ext) return badRequest(res, 'mimeType non supporté (png / jpeg / webp)'); + const dataBase64 = String(req.body?.dataBase64 || ''); + if (!dataBase64) return badRequest(res, 'dataBase64 requis'); + + let buffer; + try { + buffer = Buffer.from(dataBase64, 'base64'); + } catch { + return badRequest(res, 'dataBase64 invalide'); + } + if (buffer.length === 0) return badRequest(res, 'Fichier vide'); + if (buffer.length > MAX_ICON_SIZE_BYTES) { + return badRequest(res, `Fichier trop gros (max ${Math.round(MAX_ICON_SIZE_BYTES / 1024)} KB)`); + } + + // Vérification rapide du magic number pour bloquer un PNG renommé en .jpg etc. + const isPng = buffer.length >= 8 && buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47; + const isJpeg = buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff; + const isWebp = buffer.length >= 12 + && buffer.slice(0, 4).toString('ascii') === 'RIFF' + && buffer.slice(8, 12).toString('ascii') === 'WEBP'; + if ((ext === 'png' && !isPng) || (ext === 'jpg' && !isJpeg) || (ext === 'webp' && !isWebp)) { + return badRequest(res, 'Le contenu ne correspond pas au mimeType déclaré'); + } + + const pool = getPool(); + const existing = await fetchAppByClientId(pool, clientId); + if (!existing) return notFound(res); + + // Ensure dir exists (sécurité : ICON_DIR géré par ensureTables au boot). + if (!fs.existsSync(oauthClientsDb.ICON_DIR)) { + fs.mkdirSync(oauthClientsDb.ICON_DIR, { recursive: true, mode: 0o755 }); + } + + const filename = `${clientId}-${Date.now()}.${ext}`; + const targetPath = path.join(oauthClientsDb.ICON_DIR, filename); + await fsp.writeFile(targetPath, buffer, { mode: 0o644 }); + + // Cleanup ancienne icône avant d'enregistrer la nouvelle. + const previousFilename = existing.icon_filename; + await pool.execute( + 'UPDATE oauth_clients SET icon_filename = ?, updated_at = ? WHERE client_id = ?', + [filename, Date.now(), clientId], + ); + if (previousFilename && previousFilename !== filename) { + await removeIconFile(previousFilename); + } + + await oauthClientsDb.reloadCache(); + return res.json({ + success: true, + iconFilename: filename, + iconUrl: `/oauth-icons/${filename}`, + }); + } catch (err) { + return serverError(res, err, 'Impossible d\'uploader l\'icône'); + } +}); + +router.delete('/:clientId/icon', async (req, res) => { + try { + const clientId = sanitizeClientId(req.params.clientId); + if (!clientId) return badRequest(res, 'clientId invalide'); + const pool = getPool(); + const existing = await fetchAppByClientId(pool, clientId); + if (!existing) return notFound(res); + if (existing.icon_filename) { + await removeIconFile(existing.icon_filename); + await pool.execute( + 'UPDATE oauth_clients SET icon_filename = NULL, updated_at = ? WHERE client_id = ?', + [Date.now(), clientId], + ); + await oauthClientsDb.reloadCache(); + } + return res.json({ success: true }); + } catch (err) { + return serverError(res, err, 'Impossible de supprimer l\'icône'); + } +}); + +// Alimente le compteur de jours VIP que l'app peut distribuer. +// Body : { delta: number } → positif (ajoute) ou négatif (retire, sans descendre sous 0). +router.post('/:clientId/vip-balance', async (req, res) => { + try { + const clientId = sanitizeClientId(req.params.clientId); + if (!clientId) return badRequest(res, 'clientId invalide'); + const delta = Number(req.body?.delta); + if (!Number.isInteger(delta) || delta === 0) { + return badRequest(res, 'delta doit être un entier non nul'); + } + if (Math.abs(delta) > 100000) { + return badRequest(res, 'delta trop grand'); + } + + const pool = getPool(); + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [rows] = await conn.execute( + 'SELECT id, vip_days_balance FROM oauth_clients WHERE client_id = ? FOR UPDATE', + [clientId], + ); + if (rows.length === 0) { + await conn.rollback(); + return notFound(res); + } + const current = Number(rows[0].vip_days_balance || 0); + const next = Math.max(0, current + delta); // clamp à 0 pour éviter un balance négatif + await conn.execute( + 'UPDATE oauth_clients SET vip_days_balance = ?, updated_at = ? WHERE id = ?', + [next, Date.now(), rows[0].id], + ); + await conn.commit(); + await oauthClientsDb.reloadCache(); + return res.json({ + success: true, + previousBalance: current, + newBalance: next, + deltaApplied: next - current, + }); + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } + } catch (err) { + return serverError(res, err, 'Impossible de mettre à jour le balance VIP'); + } +}); + +router.get('/:clientId/stats', async (req, res) => { + try { + const clientId = sanitizeClientId(req.params.clientId); + if (!clientId) return badRequest(res, 'clientId invalide'); + const sinceDays = Math.min(Math.max(Number(req.query?.sinceDays) || 30, 1), 365); + const sinceMs = Date.now() - sinceDays * 24 * 60 * 60 * 1000; + const stats = await oauthClientsDb.getStats(clientId, sinceMs); + if (!stats) return serverError(res, null, 'DB indisponible'); + return res.json({ success: true, sinceDays, ...stats }); + } catch (err) { + return serverError(res, err, 'Impossible de récupérer les stats'); + } +}); + +router.get('/:clientId/grants', async (req, res) => { + try { + const clientId = sanitizeClientId(req.params.clientId); + if (!clientId) return badRequest(res, 'clientId invalide'); + const limit = Math.min(Math.max(Number(req.query?.limit) || 50, 1), 500); + const pool = getPool(); + const [rows] = await pool.execute( + `SELECT id, client_id, user_id, user_type, user_id_only, days_granted, + access_key_value, expires_at, granted_at, revoked_at + FROM oauth_vip_grants + WHERE client_id = ? + ORDER BY granted_at DESC + LIMIT ?`, + [clientId, limit], + ); + const grants = rows.map((row) => ({ + id: Number(row.id), + clientId: row.client_id, + userId: row.user_id, + userType: row.user_type, + userIdOnly: row.user_id_only, + daysGranted: Number(row.days_granted), + // accessKey n'est PAS retournée — c'est un secret porté à l'user. + // On expose juste les 4 derniers chars pour identifier. + accessKeyHint: typeof row.access_key_value === 'string' && row.access_key_value.length > 4 + ? `…${row.access_key_value.slice(-4)}` + : null, + expiresAt: row.expires_at, + grantedAt: Number(row.granted_at), + revokedAt: row.revoked_at ? Number(row.revoked_at) : null, + })); + return res.json({ success: true, grants }); + } catch (err) { + return serverError(res, err, 'Impossible de récupérer les grants'); + } +}); + +module.exports = router; diff --git a/API/Mainapi/routes/downloadLinksLeaderboard.js b/API/Mainapi/routes/downloadLinksLeaderboard.js index 3d2318c..5856021 100644 --- a/API/Mainapi/routes/downloadLinksLeaderboard.js +++ b/API/Mainapi/routes/downloadLinksLeaderboard.js @@ -1,23 +1,8 @@ const express = require('express'); const router = express.Router(); -const path = require('path'); -const fsp = require('fs').promises; const { getPool } = require('../mysqlPool'); const { isUploaderOrAdmin } = require('../middleware/auth'); - -async function getUserData(userId, userType) { - try { - const safeUserId = String(userId).replace(/[^a-zA-Z0-9_\-]/g, ''); - const safeUserType = userType === 'bip39' ? 'bip39' : 'oauth'; - const userPath = path.join(__dirname, '..', 'data', 'users', safeUserType, `${safeUserId}.json`); - const data = JSON.parse(await fsp.readFile(userPath, 'utf8')); - if (data.profiles && data.profiles.length > 0) { - const p = data.profiles[0]; - return { username: p.name || 'Admin', avatar: p.avatar || null }; - } - } catch { /* fall through */ } - return { username: 'Admin', avatar: null }; -} +const { resolveAdminIdentity } = require('../utils/adminIdentity'); router.get('/admin/leaderboard', isUploaderOrAdmin, async (req, res) => { try { @@ -75,14 +60,13 @@ router.get('/admin/leaderboard', isUploaderOrAdmin, async (req, res) => { } const leaderboard = await Promise.all(rows.map(async (row) => { - const userType = row.admin_auth_type === 'bip-39' ? 'bip39' : 'oauth'; - const u = await getUserData(row.admin_id, userType); + const identity = await resolveAdminIdentity(row.admin_id, row.admin_auth_type); return { admin_id: row.admin_id, admin_auth_type: row.admin_auth_type, role: adminRoles[row.admin_id] || 'admin', - username: u.username, - avatar: u.avatar, + username: identity.username, + avatar: identity.avatar, score: Number(row.score), last_action_at: row.last_action_at, }; diff --git a/API/Mainapi/routes/oauth.js b/API/Mainapi/routes/oauth.js index cd835c4..fca5a65 100644 --- a/API/Mainapi/routes/oauth.js +++ b/API/Mainapi/routes/oauth.js @@ -1,12 +1,13 @@ const express = require('express'); const crypto = require('crypto'); const rateLimit = require('express-rate-limit'); +const { ipKeyGenerator } = require('express-rate-limit'); +const { createRedisRateLimitStore } = require('../utils/redisRateLimitStore'); const { getAuthIfValid } = require('../middleware/auth'); const { getPool } = require('../mysqlPool'); const { getOAuthClient, - loadOAuthClients, getOAuthClientPublicMetadata, resolveClientRedirectUri, normalizeRequestedScopes, @@ -22,7 +23,8 @@ const { ACCESS_TOKEN_TTL_MS, createOAuthStorageError, } = require('../utils/oauthStorage'); -const { readUserData, writeUserData } = require('./sync'); +const { readUserData, writeUserData, readProfileData, writeProfileData, withProfileSyncLock } = require('./sync'); +const { recordEvent: recordOAuthAppEvent, grantVip: grantVipFromAppBalance } = require('../utils/oauthClientsDb'); const { verifyAccessKey } = require('../checkVip'); const { ensureSafeProfileId, getProfileFilePath } = require('../utils/syncPolicy'); const { v4: uuidv4 } = require('uuid'); @@ -36,14 +38,23 @@ const { const router = express.Router(); -const oauthRateLimitKey = (req) => req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for']?.split(',')[0].trim() || req.ip; +// express-rate-limit v8 exige `ipKeyGenerator()` dans le fallback IPv6 +// pour éviter qu'un user IPv6 contourne la limite. Sans ça : `ValidationError` +// au boot (warning, mais bruit dans les logs). +const oauthRateLimitKey = (req) => + req.headers['cf-connecting-ip'] + || req.headers['x-forwarded-for']?.split(',')[0].trim() + || ipKeyGenerator(req.ip); const oauthPreviewLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, keyGenerator: oauthRateLimitKey, + store: createRedisRateLimitStore({ prefix: 'rate-limit:oauth:preview:' }), + passOnStoreError: true, standardHeaders: true, legacyHeaders: false, + validate: { xForwardedForHeader: false, ip: false }, message: { error: 'too_many_requests', error_description: 'Trop de requêtes OAuth, réessayez dans un instant.' }, }); @@ -51,8 +62,11 @@ const oauthTokenLimiter = rateLimit({ windowMs: 60 * 1000, max: 15, keyGenerator: oauthRateLimitKey, + store: createRedisRateLimitStore({ prefix: 'rate-limit:oauth:token:' }), + passOnStoreError: true, standardHeaders: true, legacyHeaders: false, + validate: { xForwardedForHeader: false, ip: false }, message: { error: 'too_many_requests', error_description: 'Trop de requêtes de token, réessayez dans un instant.' }, }); @@ -60,6 +74,20 @@ const OAUTH_SCOPE_IMPLICATIONS = { 'profile.list': ['profile.read'], 'profile.manage': ['profile.read', 'profile.list'], 'vip.manage': ['vip.read'], + // Toute action d'écriture implique le read correspondant. + 'favorites.add': ['favorites.read'], + 'favorites.remove': ['favorites.read'], + 'lists.create': ['lists.read'], + 'lists.rename': ['lists.read'], + 'lists.delete': ['lists.read'], + 'lists.add-item': ['lists.read'], + 'lists.remove-item': ['lists.read'], + 'watchlist.add': ['watchlist.read'], + 'watchlist.remove': ['watchlist.read'], + 'history.add': ['history.read'], + 'history.remove': ['history.read'], + 'alerts.manage': ['alerts.read'], + 'ratings.manage': ['ratings.read'], }; const OAUTH_DEBUG_ENABLED = process.env.MOVIX_OAUTH_DEBUG === 'true'; @@ -135,20 +163,20 @@ function parseAuthorizeRequest(rawValues = {}) { throw createOAuthStorageError('state doit contenir entre 8 et 512 caractères', 400, 'invalid_request'); } - const availableClients = loadOAuthClients(); - const fallbackClient = !clientId && availableClients.length === 1 ? availableClients[0] : null; - const client = getOAuthClient(clientId) || fallbackClient; - + // SECURITY (audit P2) : `client_id` est strictement requis (RFC 6749 §4.1.1). + // L'ancien fallback "1 seul client enregistré → on le devine" pouvait être + // exploité dès qu'un opérateur retirait le client de dev — une page tierce + // pouvait construire un /authorize sans connaître l'id, et le faire passer + // pour le client enregistré. if (!clientId) { - if (!client) { - throw createOAuthStorageError('client_id requis', 400, 'invalid_request'); - } + throw createOAuthStorageError('client_id requis', 400, 'invalid_request'); } if (responseType !== 'code') { throw createOAuthStorageError('Seul response_type=code est supporté', 400, 'unsupported_response_type'); } + const client = getOAuthClient(clientId); if (!client) { throw createOAuthStorageError('Client OAuth inconnu', 400, 'invalid_client'); } @@ -258,28 +286,109 @@ function buildUserIdentity(userType, userId, userData) { }; } -async function buildVipIdentity(userData) { - const accessKey = typeof userData?.access_code === 'string' ? userData.access_code.trim() : ''; - if (accessKey) { - const verified = await verifyAccessKey(accessKey); - return { - active: verified.vip === true, - expiresAt: verified.expiresAt || null, - duration: verified.duration || null, - }; +// Le frontend sérialise les valeurs (JSON.stringify) avant de les envoyer +// au /api/sync. Du coup `is_vip` peut être stocké comme `"true"` (avec +// guillemets) ou `true` (boolean) selon le path. On accepte les deux. +function extractStringField(source, key) { + if (!source) return ''; + const raw = source[key]; + if (typeof raw !== 'string') return ''; + const trimmed = raw.trim(); + if (!trimmed) return ''; + // Tentative de parse JSON (cas où le frontend a fait JSON.stringify). + try { + const parsed = JSON.parse(trimmed); + return typeof parsed === 'string' ? parsed.trim() : trimmed; + } catch { + return trimmed; + } +} + +function extractBooleanField(source, key) { + if (!source) return false; + const raw = source[key]; + if (raw === true) return true; + if (typeof raw !== 'string') return false; + const trimmed = raw.trim(); + if (trimmed === 'true' || trimmed === '"true"') return true; + try { + return JSON.parse(trimmed) === true; + } catch { + return false; + } +} + +async function buildVipIdentity(userData, profileData) { + // Le frontend stocke `access_code`, `is_vip`, `access_code_expires` dans le + // PROFILE data via /api/sync (pas dans le user data global). On lit d'abord + // le profile data, fallback sur userData pour compat. + const sources = [ + { name: 'profileData', src: profileData }, + { name: 'userData', src: userData }, + ].filter((s) => s.src); + + // Debug : indique ce que chaque source contient pour le VIP, sans surfacer + // la valeur réelle de la clé d'accès. + if (OAUTH_DEBUG_ENABLED) { + const inspect = sources.map(({ name, src }) => ({ + name, + hasIsVip: 'is_vip' in (src || {}), + isVipRaw: typeof src?.is_vip, + hasAccessCode: 'access_code' in (src || {}), + accessCodeLen: typeof src?.access_code === 'string' ? src.access_code.length : 0, + keysSample: Object.keys(src || {}).filter((k) => /vip|access/i.test(k)), + })); + logOauthDebug('buildVipIdentity sources', inspect); } - return { - active: userData?.is_vip === true || userData?.is_vip === 'true', - expiresAt: typeof userData?.access_code_expires === 'string' ? userData.access_code_expires : null, - duration: null, - }; + for (const { src } of sources) { + const accessKey = extractStringField(src, 'access_code'); + if (accessKey) { + const verified = await verifyAccessKey(accessKey); + if (OAUTH_DEBUG_ENABLED) { + logOauthDebug('buildVipIdentity verify', { vip: verified.vip, reason: verified.reason }); + } + return { + active: verified.vip === true, + expiresAt: verified.expiresAt || null, + duration: verified.duration || null, + }; + } + } + + // SECURITY (audit P0) : aucun fallback sur le flag `is_vip`. Cette clé est + // syncable via /api/sync donc librement écrivable par n'importe quel user + // → élévation VIP gratuite si on lui faisait confiance. + // La seule source d'autorité est `verifyAccessKey()` contre la table MySQL + // `access_keys`. Sans `access_code` valide, le compte n'est pas VIP. + if (OAUTH_DEBUG_ENABLED) { + logOauthDebug('buildVipIdentity no access_code → non-VIP', {}); + } + return { active: false, expiresAt: null, duration: null }; } async function getOauthAccountPayload(record) { const userData = await readUserData(record.userType, record.userId); const identity = buildUserIdentity(record.userType, record.userId, userData); - const vip = await buildVipIdentity(userData); + + // Charge le profile data du profil par défaut pour y chercher `access_code`, + // `is_vip`, etc. Erreurs silencieuses : si pas de profil, on tombera sur + // userData seul. + let profileData = null; + try { + const profiles = Array.isArray(userData?.profiles) ? userData.profiles : []; + const defaultProfile = profiles.find((p) => p && p.isDefault) || profiles[0]; + if (defaultProfile && defaultProfile.id) { + profileData = await readProfileData(record.userType, record.userId, defaultProfile.id); + } + } catch (err) { + // Silently ignore — fallback to userData-only VIP check. + if (OAUTH_DEBUG_ENABLED) { + logOauthDebug('buildVipIdentity profile load failed', { error: err?.message }); + } + } + + const vip = await buildVipIdentity(userData, profileData); return { record, @@ -326,6 +435,15 @@ async function getOauthTokenAuth(req, requiredScopes = []) { throw error; } + // Stats fire-and-forget : on n'attend pas l'INSERT pour répondre. + // Une erreur DB ne doit pas faire échouer l'appel API. + recordOAuthAppEvent( + tokenRecord.clientId, + 'api_call', + `${tokenRecord.userType}:${tokenRecord.userId}`, + { path: req.path, method: req.method }, + ).catch(() => { /* swallow */ }); + return tokenRecord; } @@ -442,6 +560,12 @@ router.post('/authorize/decision', oauthPreviewLimiter, async (req, res) => { if (!approve) { await connection.commit(); + recordOAuthAppEvent( + authorizeRequest.clientId, + 'authorize_denied', + `${auth.userType}:${auth.userId}`, + { scopes: authorizeRequest.scopes }, + ).catch(() => { /* swallow */ }); return res.json({ success: true, approved: false, @@ -466,6 +590,13 @@ router.post('/authorize/decision', oauthPreviewLimiter, async (req, res) => { await connection.commit(); + recordOAuthAppEvent( + authorizeRequest.clientId, + 'authorize_granted', + `${auth.userType}:${auth.userId}`, + { scopes: authorizeRequest.scopes }, + ).catch(() => { /* swallow */ }); + return res.json({ success: true, approved: true, @@ -585,6 +716,13 @@ router.post('/token', oauthTokenLimiter, async (req, res) => { redirectUri, }); + recordOAuthAppEvent( + clientId, + 'token_issued', + tokenPayload.userType && tokenPayload.userId ? `${tokenPayload.userType}:${tokenPayload.userId}` : null, + { scopes: tokenPayload.scopes }, + ).catch(() => { /* swallow */ }); + return res.json({ access_token: tokenPayload.accessToken, token_type: 'Bearer', @@ -1028,4 +1166,933 @@ router.delete('/profiles/:profileId', async (req, res) => { } }); +// ──────────────────────────────────────────────────────────────────────────── +// FAVORITES (favorites.read / favorites.manage) +// +// Wrappers OAuth autour du système de sync. Les favoris vivent côté frontend +// dans les clés localStorage `favorite_movie` (films) et `favorites_tv` +// (séries). On les manipule directement dans le profile data côté serveur. +// +// Format d'un item : +// { id: number, type: 'movie' | 'tv', title: string, poster_path: string, addedAt: ISO } +// ──────────────────────────────────────────────────────────────────────────── + +const FAVORITES_KEYS = { + movie: 'favorite_movie', + tv: 'favorites_tv', +}; + +function parseFavoriteArray(rawValue) { + if (typeof rawValue !== 'string' || !rawValue.trim()) return []; + try { + const parsed = JSON.parse(rawValue); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function isValidFavoriteItem(item) { + return ( + item && + typeof item === 'object' && + Number.isInteger(item.id) && + item.id > 0 && + (item.type === 'movie' || item.type === 'tv') && + typeof item.title === 'string' + ); +} + +// Résout le profileId à utiliser. SECURITY (audit P1) : si un profileId est +// fourni explicitement, on vérifie qu'il appartient au compte du token — +// sinon n'importe quel MCP / app autorisé pourrait créer des profils-fantômes +// (`profiles///.json`) qui polluent le disque sans +// jamais apparaître dans la liste de profils côté UI. +async function resolveFavoritesProfileId(tokenRecord, explicitProfileId) { + const userData = await readUserData(tokenRecord.userType, tokenRecord.userId); + const profiles = Array.isArray(userData?.profiles) ? userData.profiles : []; + if (profiles.length === 0) { + throw createOAuthStorageError('Aucun profil disponible pour ce compte', 404, 'not_found'); + } + if (explicitProfileId) { + const safeId = ensureSafeProfileId(explicitProfileId); + if (!profiles.some((p) => p && p.id === safeId)) { + throw createOAuthStorageError('Profil introuvable pour ce compte', 404, 'not_found'); + } + return safeId; + } + const defaultProfile = profiles.find((p) => p && p.isDefault) || profiles[0]; + return defaultProfile.id; +} + +// GET /api/oauth/favorites?profileId= +router.get('/favorites', async (req, res) => { + try { + const tokenRecord = await getOauthTokenAuth(req, ['favorites.read']); + const profileId = await resolveFavoritesProfileId(tokenRecord, req.query?.profileId); + const profileData = await readProfileData(tokenRecord.userType, tokenRecord.userId, profileId); + + const movies = parseFavoriteArray(profileData[FAVORITES_KEYS.movie]); + const tv = parseFavoriteArray(profileData[FAVORITES_KEYS.tv]); + + return res.json({ + success: true, + profileId, + movies: movies.filter(isValidFavoriteItem), + tv: tv.filter(isValidFavoriteItem), + }); + } catch (error) { + return sendOauthJsonError( + res, + error.statusCode || 401, + error.oauthError || 'invalid_token', + error.message || 'Impossible de récupérer les favoris' + ); + } +}); + +// POST /api/oauth/favorites +// Body : { tmdb_id, media_type, title, poster_path?, profileId? } +router.post('/favorites', async (req, res) => { + try { + const tokenRecord = await getOauthTokenAuth(req, ['favorites.add']); + + const tmdbId = Number(req.body?.tmdb_id); + const mediaType = String(req.body?.media_type || '').trim(); + const title = typeof req.body?.title === 'string' ? req.body.title.trim().slice(0, 300) : ''; + const posterPath = typeof req.body?.poster_path === 'string' ? req.body.poster_path.trim().slice(0, 200) : ''; + + if (!Number.isInteger(tmdbId) || tmdbId <= 0 || tmdbId > 10_000_000) { + return sendOauthJsonError(res, 400, 'invalid_request', 'tmdb_id invalide'); + } + if (mediaType !== 'movie' && mediaType !== 'tv') { + return sendOauthJsonError(res, 400, 'invalid_request', 'media_type doit être "movie" ou "tv"'); + } + if (!title) { + return sendOauthJsonError(res, 400, 'invalid_request', 'title requis'); + } + // poster_path doit être soit vide, soit un chemin TMDB plausible. + if (posterPath && !posterPath.startsWith('/')) { + return sendOauthJsonError(res, 400, 'invalid_request', 'poster_path invalide'); + } + + const profileId = await resolveFavoritesProfileId(tokenRecord, req.body?.profileId); + const result = await withProfileMutation(tokenRecord, profileId, (profileData) => { + const key = FAVORITES_KEYS[mediaType]; + const current = parseFavoriteArray(profileData[key]).filter(isValidFavoriteItem); + + // Déduplication : on retire d'abord toute occurrence du même id puis on + // pousse en tête (le frontend Movix met les ajouts récents en haut). + const filtered = current.filter((item) => item.id !== tmdbId); + const newItem = { + id: tmdbId, + type: mediaType, + title, + poster_path: posterPath || '', + addedAt: new Date().toISOString(), + }; + const next = [newItem, ...filtered]; + + profileData[key] = JSON.stringify(next); + return { item: newItem, count: next.length }; + }); + + return res.status(200).json({ success: true, profileId, ...result }); + } catch (error) { + return sendOauthJsonError( + res, + error.statusCode || 401, + error.oauthError || 'invalid_token', + error.message || 'Impossible d\'ajouter le favori' + ); + } +}); + +// DELETE /api/oauth/favorites/:mediaType/:tmdbId?profileId= +router.delete('/favorites/:mediaType/:tmdbId', async (req, res) => { + try { + const tokenRecord = await getOauthTokenAuth(req, ['favorites.remove']); + + const mediaType = String(req.params.mediaType || '').trim(); + if (mediaType !== 'movie' && mediaType !== 'tv') { + return sendOauthJsonError(res, 400, 'invalid_request', 'mediaType doit être "movie" ou "tv"'); + } + const tmdbId = Number(req.params.tmdbId); + if (!Number.isInteger(tmdbId) || tmdbId <= 0 || tmdbId > 10_000_000) { + return sendOauthJsonError(res, 400, 'invalid_request', 'tmdbId invalide'); + } + + const profileId = await resolveFavoritesProfileId(tokenRecord, req.query?.profileId); + const result = await withProfileMutation(tokenRecord, profileId, (profileData) => { + const key = FAVORITES_KEYS[mediaType]; + const current = parseFavoriteArray(profileData[key]).filter(isValidFavoriteItem); + const next = current.filter((item) => item.id !== tmdbId); + + // Pas dans la liste — idempotent, on réécrit la même valeur. + profileData[key] = JSON.stringify(next); + return { removed: next.length < current.length, count: next.length }; + }); + + return res.json({ success: true, profileId, ...result }); + } catch (error) { + return sendOauthJsonError( + res, + error.statusCode || 401, + error.oauthError || 'invalid_token', + error.message || 'Impossible de retirer ce favori' + ); + } +}); + +// ──────────────────────────────────────────────────────────────────────────── +// LISTS (lists.read / lists.manage) + WATCHLIST (watchlist.read / watchlist.manage) +// +// Couvre : +// - Custom lists (clé localStorage `custom_lists`) : listes nommées +// contenant des items films/séries. → scope `lists.*` +// - Watchlist unifiée : → scope `watchlist.*` +// * media_type "movie" → `watchlist_movie` +// * media_type "tv" → `watchlist_tv` +// * media_type "live-tv" → `live_tv_favorite_channels` +// * media_type "shared-list" → `shared_list_favorites` +// +// Toutes les routes manipulent le PROFILE data (par défaut le profil par défaut +// du compte, ou celui fourni en query/body `profileId`). +// ──────────────────────────────────────────────────────────────────────────── + +const WATCHLIST_KEYS = { + movie: 'watchlist_movie', + tv: 'watchlist_tv', + 'live-tv': 'live_tv_favorite_channels', + 'shared-list': 'shared_list_favorites', +}; +const WATCHLIST_MEDIA_TYPES = Object.keys(WATCHLIST_KEYS); + +function parseJsonArray(rawValue) { + if (typeof rawValue !== 'string' || !rawValue.trim()) return []; + try { + const parsed = JSON.parse(rawValue); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function isValidListId(value) { + return typeof value === 'string' && /^[a-zA-Z0-9_-]{1,64}$/.test(value); +} + +function sanitizeListName(value) { + if (typeof value !== 'string') return ''; + return value.trim().slice(0, 80).replace(/[\x00-\x1f\x7f]/g, ''); +} + +function sanitizeWatchlistItem(input) { + if (!input || typeof input !== 'object') return null; + const id = Number(input.id ?? input.tmdb_id); + if (!Number.isInteger(id) || id <= 0 || id > 10_000_000) { + // Les chaînes live-tv et shared-list peuvent avoir un id non-numérique. + if (typeof input.id !== 'string' && typeof input.tmdb_id !== 'string') return null; + } + return { + id: typeof input.id === 'string' ? input.id.slice(0, 128) : id, + title: typeof input.title === 'string' ? input.title.slice(0, 300) : '', + poster_path: typeof input.poster_path === 'string' ? input.poster_path.slice(0, 200) : '', + addedAt: new Date().toISOString(), + }; +} + +async function resolveLibraryProfileId(tokenRecord, explicitProfileId) { + // SECURITY (audit P1) : valide l'ownership du profileId si fourni. + const userData = await readUserData(tokenRecord.userType, tokenRecord.userId); + const profiles = Array.isArray(userData?.profiles) ? userData.profiles : []; + if (profiles.length === 0) { + throw createOAuthStorageError('Aucun profil disponible pour ce compte', 404, 'not_found'); + } + if (explicitProfileId) { + const safeId = ensureSafeProfileId(explicitProfileId); + if (!profiles.some((p) => p && p.id === safeId)) { + throw createOAuthStorageError('Profil introuvable pour ce compte', 404, 'not_found'); + } + return safeId; + } + const defaultProfile = profiles.find((p) => p && p.isDefault) || profiles[0]; + return defaultProfile.id; +} + +// SECURITY (audit P1) : helper qui acquiert le lock MySQL sur le couple +// (userType, userId, profileId), lit le profile data, appelle `fn` qui +// modifie en place, écrit, et libère le lock. Garantit qu'aucune écriture +// concurrente (sync ou autre route OAuth) ne perd notre modif (lost-update). +async function withProfileMutation(tokenRecord, profileId, fn) { + return withProfileSyncLock(tokenRecord.userType, tokenRecord.userId, profileId, async () => { + const profileData = await readProfileData(tokenRecord.userType, tokenRecord.userId, profileId); + const result = await fn(profileData); + const success = await writeProfileData(tokenRecord.userType, tokenRecord.userId, profileId, profileData); + if (!success) { + throw createOAuthStorageError('Écriture profile data échouée', 500, 'server_error'); + } + return result; + }); +} + +// ─── Custom Lists ──────────────────────────────────────────────────────── + +// GET /api/oauth/lists +router.get('/lists', async (req, res) => { + try { + const tokenRecord = await getOauthTokenAuth(req, ['lists.read']); + const profileId = await resolveLibraryProfileId(tokenRecord, req.query?.profileId); + const profileData = await readProfileData(tokenRecord.userType, tokenRecord.userId, profileId); + const lists = parseJsonArray(profileData.custom_lists); + return res.json({ success: true, profileId, lists }); + } catch (error) { + return sendOauthJsonError( + res, + error.statusCode || 401, + error.oauthError || 'invalid_token', + error.message || 'Impossible de récupérer les listes' + ); + } +}); + +// POST /api/oauth/lists body: { name, profileId? } +router.post('/lists', async (req, res) => { + try { + const tokenRecord = await getOauthTokenAuth(req, ['lists.create']); + const name = sanitizeListName(req.body?.name); + if (!name) return sendOauthJsonError(res, 400, 'invalid_request', 'name requis'); + + const profileId = await resolveLibraryProfileId(tokenRecord, req.body?.profileId); + const result = await withProfileMutation(tokenRecord, profileId, (profileData) => { + const lists = parseJsonArray(profileData.custom_lists); + + if (lists.length >= 100) { + throw createOAuthStorageError('Maximum 100 listes par profil', 400, 'invalid_request'); + } + + const newList = { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + name, + items: [], + createdAt: new Date().toISOString(), + }; + const next = [...lists, newList]; + profileData.custom_lists = JSON.stringify(next); + return { list: newList }; + }); + + return res.status(201).json({ success: true, profileId, ...result }); + } catch (error) { + return sendOauthJsonError( + res, + error.statusCode || 401, + error.oauthError || 'invalid_token', + error.message || 'Impossible de créer la liste' + ); + } +}); + +// PUT /api/oauth/lists/:listId body: { name, profileId? } +router.put('/lists/:listId', async (req, res) => { + try { + const tokenRecord = await getOauthTokenAuth(req, ['lists.rename']); + const listId = req.params.listId; + if (!isValidListId(listId)) return sendOauthJsonError(res, 400, 'invalid_request', 'listId invalide'); + const name = sanitizeListName(req.body?.name); + if (!name) return sendOauthJsonError(res, 400, 'invalid_request', 'name requis'); + + const profileId = await resolveLibraryProfileId(tokenRecord, req.body?.profileId); + const result = await withProfileMutation(tokenRecord, profileId, (profileData) => { + const lists = parseJsonArray(profileData.custom_lists); + const idx = lists.findIndex((l) => l && l.id === listId); + if (idx === -1) throw createOAuthStorageError('Liste introuvable', 404, 'not_found'); + + lists[idx] = { ...lists[idx], name }; + profileData.custom_lists = JSON.stringify(lists); + return { list: lists[idx] }; + }); + + return res.json({ success: true, profileId, ...result }); + } catch (error) { + return sendOauthJsonError( + res, + error.statusCode || 401, + error.oauthError || 'invalid_token', + error.message || 'Impossible de renommer la liste' + ); + } +}); + +// DELETE /api/oauth/lists/:listId +router.delete('/lists/:listId', async (req, res) => { + try { + const tokenRecord = await getOauthTokenAuth(req, ['lists.delete']); + const listId = req.params.listId; + if (!isValidListId(listId)) return sendOauthJsonError(res, 400, 'invalid_request', 'listId invalide'); + + const profileId = await resolveLibraryProfileId(tokenRecord, req.query?.profileId); + const result = await withProfileMutation(tokenRecord, profileId, (profileData) => { + const lists = parseJsonArray(profileData.custom_lists); + const next = lists.filter((l) => l && l.id !== listId); + // Idempotent : réécrit même si rien retiré. + profileData.custom_lists = JSON.stringify(next); + return { removed: next.length < lists.length }; + }); + + return res.json({ success: true, profileId, ...result }); + } catch (error) { + return sendOauthJsonError( + res, + error.statusCode || 401, + error.oauthError || 'invalid_token', + error.message || 'Impossible de supprimer cette liste' + ); + } +}); + +// POST /api/oauth/lists/:listId/items body: { tmdb_id, media_type, title, poster_path, profileId? } +router.post('/lists/:listId/items', async (req, res) => { + try { + const tokenRecord = await getOauthTokenAuth(req, ['lists.add-item']); + const listId = req.params.listId; + if (!isValidListId(listId)) return sendOauthJsonError(res, 400, 'invalid_request', 'listId invalide'); + + const mediaType = String(req.body?.media_type || '').trim(); + if (mediaType !== 'movie' && mediaType !== 'tv') { + return sendOauthJsonError(res, 400, 'invalid_request', 'media_type doit être "movie" ou "tv"'); + } + const tmdbId = Number(req.body?.tmdb_id); + if (!Number.isInteger(tmdbId) || tmdbId <= 0 || tmdbId > 10_000_000) { + return sendOauthJsonError(res, 400, 'invalid_request', 'tmdb_id invalide'); + } + const title = typeof req.body?.title === 'string' ? req.body.title.trim().slice(0, 300) : ''; + const posterPath = typeof req.body?.poster_path === 'string' ? req.body.poster_path.trim().slice(0, 200) : ''; + if (!title) return sendOauthJsonError(res, 400, 'invalid_request', 'title requis'); + if (posterPath && !posterPath.startsWith('/')) { + return sendOauthJsonError(res, 400, 'invalid_request', 'poster_path invalide'); + } + + const profileId = await resolveLibraryProfileId(tokenRecord, req.body?.profileId); + const result = await withProfileMutation(tokenRecord, profileId, (profileData) => { + const lists = parseJsonArray(profileData.custom_lists); + const idx = lists.findIndex((l) => l && l.id === listId); + if (idx === -1) throw createOAuthStorageError('Liste introuvable', 404, 'not_found'); + + const items = Array.isArray(lists[idx].items) ? lists[idx].items : []; + if (items.some((it) => it && it.id === tmdbId && it.type === mediaType)) { + // Déjà dans la liste — write redondant acceptable, pas de modif des données. + return { list: lists[idx], added: false }; + } + if (items.length >= 500) { + throw createOAuthStorageError('Maximum 500 items par liste', 400, 'invalid_request'); + } + const newItem = { id: tmdbId, type: mediaType, title, poster_path: posterPath, addedAt: new Date().toISOString() }; + lists[idx] = { ...lists[idx], items: [newItem, ...items] }; + profileData.custom_lists = JSON.stringify(lists); + return { list: lists[idx], added: true }; + }); + + return res.json({ success: true, profileId, ...result }); + } catch (error) { + return sendOauthJsonError( + res, + error.statusCode || 401, + error.oauthError || 'invalid_token', + error.message || 'Impossible d\'ajouter cet item' + ); + } +}); + +// DELETE /api/oauth/lists/:listId/items/:mediaType/:itemId +router.delete('/lists/:listId/items/:mediaType/:itemId', async (req, res) => { + try { + const tokenRecord = await getOauthTokenAuth(req, ['lists.remove-item']); + const listId = req.params.listId; + if (!isValidListId(listId)) return sendOauthJsonError(res, 400, 'invalid_request', 'listId invalide'); + const mediaType = String(req.params.mediaType || '').trim(); + if (mediaType !== 'movie' && mediaType !== 'tv') { + return sendOauthJsonError(res, 400, 'invalid_request', 'mediaType doit être "movie" ou "tv"'); + } + const tmdbId = Number(req.params.itemId); + if (!Number.isInteger(tmdbId) || tmdbId <= 0 || tmdbId > 10_000_000) { + return sendOauthJsonError(res, 400, 'invalid_request', 'itemId invalide'); + } + + const profileId = await resolveLibraryProfileId(tokenRecord, req.query?.profileId); + const result = await withProfileMutation(tokenRecord, profileId, (profileData) => { + const lists = parseJsonArray(profileData.custom_lists); + const idx = lists.findIndex((l) => l && l.id === listId); + if (idx === -1) throw createOAuthStorageError('Liste introuvable', 404, 'not_found'); + + const items = Array.isArray(lists[idx].items) ? lists[idx].items : []; + const nextItems = items.filter((it) => !(it && it.id === tmdbId && it.type === mediaType)); + // Idempotent : réécrit même si item absent. + lists[idx] = { ...lists[idx], items: nextItems }; + profileData.custom_lists = JSON.stringify(lists); + return { list: lists[idx], removed: nextItems.length < items.length }; + }); + + return res.json({ success: true, profileId, ...result }); + } catch (error) { + return sendOauthJsonError( + res, + error.statusCode || 401, + error.oauthError || 'invalid_token', + error.message || 'Impossible de retirer cet item' + ); + } +}); + +// ─── Watchlist unifiée ─────────────────────────────────────────────────── + +// GET /api/oauth/watchlist +router.get('/watchlist', async (req, res) => { + try { + const tokenRecord = await getOauthTokenAuth(req, ['watchlist.read']); + const profileId = await resolveLibraryProfileId(tokenRecord, req.query?.profileId); + const profileData = await readProfileData(tokenRecord.userType, tokenRecord.userId, profileId); + const out = {}; + for (const [type, key] of Object.entries(WATCHLIST_KEYS)) { + out[type] = parseJsonArray(profileData[key]); + } + return res.json({ success: true, profileId, watchlist: out }); + } catch (error) { + return sendOauthJsonError( + res, + error.statusCode || 401, + error.oauthError || 'invalid_token', + error.message || 'Impossible de récupérer la watchlist' + ); + } +}); + +// POST /api/oauth/watchlist body: { id, media_type, title?, poster_path?, profileId? } +router.post('/watchlist', async (req, res) => { + try { + const tokenRecord = await getOauthTokenAuth(req, ['watchlist.add']); + const mediaType = String(req.body?.media_type || '').trim(); + if (!WATCHLIST_MEDIA_TYPES.includes(mediaType)) { + return sendOauthJsonError( + res, + 400, + 'invalid_request', + `media_type doit être un de : ${WATCHLIST_MEDIA_TYPES.join(', ')}` + ); + } + const item = sanitizeWatchlistItem(req.body); + if (!item || (typeof item.id !== 'number' && typeof item.id !== 'string')) { + return sendOauthJsonError(res, 400, 'invalid_request', 'id invalide'); + } + item.type = mediaType; + + const profileId = await resolveLibraryProfileId(tokenRecord, req.body?.profileId); + const result = await withProfileMutation(tokenRecord, profileId, (profileData) => { + const key = WATCHLIST_KEYS[mediaType]; + const current = parseJsonArray(profileData[key]); + const filtered = current.filter((it) => !(it && it.id === item.id)); + const next = [item, ...filtered]; + profileData[key] = JSON.stringify(next); + return { item, count: next.length }; + }); + + return res.json({ success: true, profileId, ...result }); + } catch (error) { + return sendOauthJsonError( + res, + error.statusCode || 401, + error.oauthError || 'invalid_token', + error.message || 'Impossible d\'ajouter à la watchlist' + ); + } +}); + +// DELETE /api/oauth/watchlist/:mediaType/:itemId +router.delete('/watchlist/:mediaType/:itemId', async (req, res) => { + try { + const tokenRecord = await getOauthTokenAuth(req, ['watchlist.remove']); + const mediaType = String(req.params.mediaType || '').trim(); + if (!WATCHLIST_MEDIA_TYPES.includes(mediaType)) { + return sendOauthJsonError( + res, + 400, + 'invalid_request', + `mediaType doit être un de : ${WATCHLIST_MEDIA_TYPES.join(', ')}` + ); + } + const rawId = req.params.itemId; + let parsedId = Number(rawId); + if (!Number.isInteger(parsedId) || parsedId <= 0) { + // Pour live-tv et shared-list, l'id peut être une string. + parsedId = String(rawId); + } + + const profileId = await resolveLibraryProfileId(tokenRecord, req.query?.profileId); + const result = await withProfileMutation(tokenRecord, profileId, (profileData) => { + const key = WATCHLIST_KEYS[mediaType]; + const current = parseJsonArray(profileData[key]); + const next = current.filter((it) => !(it && String(it.id) === String(parsedId))); + // Idempotent : réécrit même si item absent. + profileData[key] = JSON.stringify(next); + return { removed: next.length < current.length, count: next.length }; + }); + + return res.json({ success: true, profileId, ...result }); + } catch (error) { + return sendOauthJsonError( + res, + error.statusCode || 401, + error.oauthError || 'invalid_token', + error.message || 'Impossible de retirer cet item' + ); + } +}); + +// ──────────────────────────────────────────────────────────────────────────── +// HISTORY (history.read / history.add / history.remove) +// CONTINUE WATCHING (continue-watching.read) +// +// Couvre : +// - `watched_movie` + `watched_tv` (clés localStorage côté frontend) → +// liste unifiée des films et séries marqués comme vus. +// - `continueWatching` (objet `{ movies, tv }`) → reprise en cours. +// +// Toutes les routes manipulent le PROFILE data (par défaut le profil par +// défaut, ou `profileId` fourni en query/body). +// ──────────────────────────────────────────────────────────────────────────── + +const HISTORY_KEYS = { + movie: 'watched_movie', + tv: 'watched_tv', +}; + +function parseContinueWatching(rawValue) { + if (typeof rawValue !== 'string' || !rawValue.trim()) { + return { movies: [], tv: [] }; + } + try { + const parsed = JSON.parse(rawValue); + return { + movies: Array.isArray(parsed?.movies) ? parsed.movies : [], + tv: Array.isArray(parsed?.tv) ? parsed.tv : [], + }; + } catch { + return { movies: [], tv: [] }; + } +} + +// GET /api/oauth/history +router.get('/history', async (req, res) => { + try { + const tokenRecord = await getOauthTokenAuth(req, ['history.read']); + const profileId = await resolveLibraryProfileId(tokenRecord, req.query?.profileId); + const profileData = await readProfileData(tokenRecord.userType, tokenRecord.userId, profileId); + + const movies = parseJsonArray(profileData[HISTORY_KEYS.movie]).filter(isValidFavoriteItem); + const tv = parseJsonArray(profileData[HISTORY_KEYS.tv]).filter(isValidFavoriteItem); + return res.json({ success: true, profileId, movies, tv }); + } catch (error) { + return sendOauthJsonError( + res, + error.statusCode || 401, + error.oauthError || 'invalid_token', + error.message || 'Impossible de récupérer l\'historique' + ); + } +}); + +// POST /api/oauth/history body: { tmdb_id, media_type, title, poster_path?, profileId? } +router.post('/history', async (req, res) => { + try { + const tokenRecord = await getOauthTokenAuth(req, ['history.add']); + + const tmdbId = Number(req.body?.tmdb_id); + const mediaType = String(req.body?.media_type || '').trim(); + const title = typeof req.body?.title === 'string' ? req.body.title.trim().slice(0, 300) : ''; + const posterPath = typeof req.body?.poster_path === 'string' ? req.body.poster_path.trim().slice(0, 200) : ''; + + if (!Number.isInteger(tmdbId) || tmdbId <= 0 || tmdbId > 10_000_000) { + return sendOauthJsonError(res, 400, 'invalid_request', 'tmdb_id invalide'); + } + if (mediaType !== 'movie' && mediaType !== 'tv') { + return sendOauthJsonError(res, 400, 'invalid_request', 'media_type doit être "movie" ou "tv"'); + } + if (!title) return sendOauthJsonError(res, 400, 'invalid_request', 'title requis'); + if (posterPath && !posterPath.startsWith('/')) { + return sendOauthJsonError(res, 400, 'invalid_request', 'poster_path invalide'); + } + + const profileId = await resolveLibraryProfileId(tokenRecord, req.body?.profileId); + const result = await withProfileMutation(tokenRecord, profileId, (profileData) => { + const key = HISTORY_KEYS[mediaType]; + const current = parseJsonArray(profileData[key]).filter(isValidFavoriteItem); + + const filtered = current.filter((it) => it.id !== tmdbId); + const newItem = { + id: tmdbId, + type: mediaType, + title, + poster_path: posterPath || '', + addedAt: new Date().toISOString(), + }; + const next = [newItem, ...filtered]; + profileData[key] = JSON.stringify(next); + return { item: newItem, count: next.length }; + }); + + return res.json({ success: true, profileId, ...result }); + } catch (error) { + return sendOauthJsonError( + res, + error.statusCode || 401, + error.oauthError || 'invalid_token', + error.message || 'Impossible de marquer comme vu' + ); + } +}); + +// DELETE /api/oauth/history/:mediaType/:tmdbId +router.delete('/history/:mediaType/:tmdbId', async (req, res) => { + try { + const tokenRecord = await getOauthTokenAuth(req, ['history.remove']); + + const mediaType = String(req.params.mediaType || '').trim(); + if (mediaType !== 'movie' && mediaType !== 'tv') { + return sendOauthJsonError(res, 400, 'invalid_request', 'mediaType doit être "movie" ou "tv"'); + } + const tmdbId = Number(req.params.tmdbId); + if (!Number.isInteger(tmdbId) || tmdbId <= 0 || tmdbId > 10_000_000) { + return sendOauthJsonError(res, 400, 'invalid_request', 'tmdbId invalide'); + } + + const profileId = await resolveLibraryProfileId(tokenRecord, req.query?.profileId); + const result = await withProfileMutation(tokenRecord, profileId, (profileData) => { + const key = HISTORY_KEYS[mediaType]; + const current = parseJsonArray(profileData[key]).filter(isValidFavoriteItem); + const next = current.filter((it) => it.id !== tmdbId); + // Idempotent : réécrit même si item absent. + profileData[key] = JSON.stringify(next); + return { removed: next.length < current.length, count: next.length }; + }); + + return res.json({ success: true, profileId, ...result }); + } catch (error) { + return sendOauthJsonError( + res, + error.statusCode || 401, + error.oauthError || 'invalid_token', + error.message || 'Impossible de retirer cet item' + ); + } +}); + +// GET /api/oauth/continue-watching +router.get('/continue-watching', async (req, res) => { + try { + const tokenRecord = await getOauthTokenAuth(req, ['continue-watching.read']); + const profileId = await resolveLibraryProfileId(tokenRecord, req.query?.profileId); + const profileData = await readProfileData(tokenRecord.userType, tokenRecord.userId, profileId); + const cw = parseContinueWatching(profileData.continueWatching); + return res.json({ success: true, profileId, continueWatching: cw }); + } catch (error) { + return sendOauthJsonError( + res, + error.statusCode || 401, + error.oauthError || 'invalid_token', + error.message || 'Impossible de récupérer la reprise en cours' + ); + } +}); + +// ──────────────────────────────────────────────────────────────────────────── +// ALERTES — episodeReleaseAlerts (notifications nouvelles saisons / sorties) +// +// Stockées dans le profile data sous la clé `episodeReleaseAlerts` comme +// array d'objets `{ id, type, title, ...}`. On expose 3 routes : +// GET /alerts → liste +// POST /alerts → souscrit body { tmdb_id, media_type, title? } +// DELETE /alerts/:type/:id → désabonne +// ──────────────────────────────────────────────────────────────────────────── + +router.get('/alerts', async (req, res) => { + try { + const tokenRecord = await getOauthTokenAuth(req, ['alerts.read']); + const profileId = await resolveLibraryProfileId(tokenRecord, req.query?.profileId); + const profileData = await readProfileData(tokenRecord.userType, tokenRecord.userId, profileId); + const alerts = parseJsonArray(profileData.episodeReleaseAlerts); + return res.json({ success: true, profileId, alerts }); + } catch (error) { + return sendOauthJsonError(res, error.statusCode || 401, error.oauthError || 'invalid_token', error.message || 'Impossible de récupérer les alertes'); + } +}); + +router.post('/alerts', async (req, res) => { + try { + const tokenRecord = await getOauthTokenAuth(req, ['alerts.manage']); + const tmdbId = Number(req.body?.tmdb_id); + const mediaType = String(req.body?.media_type || '').trim(); + const title = typeof req.body?.title === 'string' ? req.body.title.trim().slice(0, 300) : ''; + if (!Number.isInteger(tmdbId) || tmdbId <= 0 || tmdbId > 10_000_000) return sendOauthJsonError(res, 400, 'invalid_request', 'tmdb_id invalide'); + if (mediaType !== 'movie' && mediaType !== 'tv') return sendOauthJsonError(res, 400, 'invalid_request', 'media_type doit être "movie" ou "tv"'); + + const profileId = await resolveLibraryProfileId(tokenRecord, req.body?.profileId); + const result = await withProfileMutation(tokenRecord, profileId, (profileData) => { + const current = parseJsonArray(profileData.episodeReleaseAlerts).filter((it) => it && typeof it === 'object'); + const filtered = current.filter((it) => !(it.id === tmdbId && it.type === mediaType)); + const newItem = { id: tmdbId, type: mediaType, title, addedAt: new Date().toISOString() }; + profileData.episodeReleaseAlerts = JSON.stringify([newItem, ...filtered]); + return { item: newItem }; + }); + + return res.json({ success: true, profileId, ...result }); + } catch (error) { + return sendOauthJsonError(res, error.statusCode || 401, error.oauthError || 'invalid_token', error.message || 'Impossible de souscrire à l\'alerte'); + } +}); + +router.delete('/alerts/:mediaType/:tmdbId', async (req, res) => { + try { + const tokenRecord = await getOauthTokenAuth(req, ['alerts.manage']); + const mediaType = String(req.params.mediaType || '').trim(); + if (mediaType !== 'movie' && mediaType !== 'tv') return sendOauthJsonError(res, 400, 'invalid_request', 'mediaType invalide'); + const tmdbId = Number(req.params.tmdbId); + if (!Number.isInteger(tmdbId) || tmdbId <= 0 || tmdbId > 10_000_000) return sendOauthJsonError(res, 400, 'invalid_request', 'tmdbId invalide'); + const profileId = await resolveLibraryProfileId(tokenRecord, req.query?.profileId); + const result = await withProfileMutation(tokenRecord, profileId, (profileData) => { + const current = parseJsonArray(profileData.episodeReleaseAlerts).filter((it) => it && typeof it === 'object'); + const next = current.filter((it) => !(it.id === tmdbId && it.type === mediaType)); + // Idempotent : réécrit même si alerte absente. + profileData.episodeReleaseAlerts = JSON.stringify(next); + return { removed: next.length < current.length }; + }); + return res.json({ success: true, profileId, ...result }); + } catch (error) { + return sendOauthJsonError(res, error.statusCode || 401, error.oauthError || 'invalid_token', error.message || 'Impossible de retirer cette alerte'); + } +}); + +// ──────────────────────────────────────────────────────────────────────────── +// RATINGS — notes personnelles (1-10) + texte facultatif +// +// Stockés dans le profile data sous la clé `user_ratings` (créée par cette PR +// — pas de clé localStorage frontend existante, donc on l'introduit). +// Schema : array d'objets { id, type, rating, note?, addedAt } +// ──────────────────────────────────────────────────────────────────────────── + +router.get('/ratings', async (req, res) => { + try { + const tokenRecord = await getOauthTokenAuth(req, ['ratings.read']); + const profileId = await resolveLibraryProfileId(tokenRecord, req.query?.profileId); + const profileData = await readProfileData(tokenRecord.userType, tokenRecord.userId, profileId); + const ratings = parseJsonArray(profileData.user_ratings); + return res.json({ success: true, profileId, ratings }); + } catch (error) { + return sendOauthJsonError(res, error.statusCode || 401, error.oauthError || 'invalid_token', error.message || 'Impossible de récupérer les notes'); + } +}); + +router.post('/ratings', async (req, res) => { + try { + const tokenRecord = await getOauthTokenAuth(req, ['ratings.manage']); + const tmdbId = Number(req.body?.tmdb_id); + const mediaType = String(req.body?.media_type || '').trim(); + const rating = Number(req.body?.rating); + const note = typeof req.body?.note === 'string' ? req.body.note.trim().slice(0, 2000) : ''; + if (!Number.isInteger(tmdbId) || tmdbId <= 0 || tmdbId > 10_000_000) return sendOauthJsonError(res, 400, 'invalid_request', 'tmdb_id invalide'); + if (mediaType !== 'movie' && mediaType !== 'tv') return sendOauthJsonError(res, 400, 'invalid_request', 'media_type invalide'); + if (!Number.isFinite(rating) || rating < 1 || rating > 10) return sendOauthJsonError(res, 400, 'invalid_request', 'rating doit être entre 1 et 10'); + + const profileId = await resolveLibraryProfileId(tokenRecord, req.body?.profileId); + const result = await withProfileMutation(tokenRecord, profileId, (profileData) => { + const current = parseJsonArray(profileData.user_ratings).filter((it) => it && typeof it === 'object'); + const filtered = current.filter((it) => !(it.id === tmdbId && it.type === mediaType)); + const newItem = { id: tmdbId, type: mediaType, rating: Math.round(rating * 10) / 10, note, addedAt: new Date().toISOString() }; + profileData.user_ratings = JSON.stringify([newItem, ...filtered]); + return { item: newItem }; + }); + + return res.json({ success: true, profileId, ...result }); + } catch (error) { + return sendOauthJsonError(res, error.statusCode || 401, error.oauthError || 'invalid_token', error.message || 'Impossible d\'enregistrer la note'); + } +}); + +router.delete('/ratings/:mediaType/:tmdbId', async (req, res) => { + try { + const tokenRecord = await getOauthTokenAuth(req, ['ratings.manage']); + const mediaType = String(req.params.mediaType || '').trim(); + if (mediaType !== 'movie' && mediaType !== 'tv') return sendOauthJsonError(res, 400, 'invalid_request', 'mediaType invalide'); + const tmdbId = Number(req.params.tmdbId); + if (!Number.isInteger(tmdbId) || tmdbId <= 0 || tmdbId > 10_000_000) return sendOauthJsonError(res, 400, 'invalid_request', 'tmdbId invalide'); + const profileId = await resolveLibraryProfileId(tokenRecord, req.query?.profileId); + const result = await withProfileMutation(tokenRecord, profileId, (profileData) => { + const current = parseJsonArray(profileData.user_ratings).filter((it) => it && typeof it === 'object'); + const next = current.filter((it) => !(it.id === tmdbId && it.type === mediaType)); + // Idempotent : réécrit même si note absente. + profileData.user_ratings = JSON.stringify(next); + return { removed: next.length < current.length }; + }); + return res.json({ success: true, profileId, ...result }); + } catch (error) { + return sendOauthJsonError(res, error.statusCode || 401, error.oauthError || 'invalid_token', error.message || 'Impossible de retirer la note'); + } +}); + +// ─── VIP grant : l'app distribue des jours VIP depuis son balance admin-alimenté ──── +// Scope requis : `vip.grant` (séparé de `vip.manage` qui parle DU vip de l'user lui-même). +// Cible TOUJOURS le porteur du token — pas de userId arbitraire dans le body. +// Permettre à l'app de cibler n'importe quel userId polluait l'audit log +// (`oauth_vip_grants.user_id_only`) puisqu'aucune ré-vérification ne valide +// que la cible a réellement consenti à recevoir un grant via cette app. +// L'access_key retournée appartient à l'app, qui la transmet à son utilisateur +// final ; le binding "user X a reçu cette clé" reste sous la responsabilité +// de l'app (et est traçable via le user du token utilisé). +router.post('/vip/grant', async (req, res) => { + try { + const tokenRecord = await getOauthTokenAuth(req, ['vip.grant']); + const days = Number(req.body?.days); + if (!Number.isInteger(days) || days <= 0 || days > 365) { + return sendOauthJsonError(res, 400, 'invalid_request', 'days doit être un entier entre 1 et 365'); + } + + const targetUserType = String(tokenRecord.userType || '').trim(); + const targetUserId = String(tokenRecord.userId || '').trim(); + if (!targetUserType || !targetUserId) { + return sendOauthJsonError(res, 401, 'invalid_token', 'Token OAuth incomplet'); + } + if (targetUserType !== 'oauth' && targetUserType !== 'bip39') { + return sendOauthJsonError(res, 401, 'invalid_token', 'userType du token invalide'); + } + + const grant = await grantVipFromAppBalance({ + clientId: tokenRecord.clientId, + userType: targetUserType, + userId: targetUserId, + days, + }); + + recordOAuthAppEvent( + tokenRecord.clientId, + 'vip_grant', + `${targetUserType}:${targetUserId}`, + { daysGranted: days, expiresAt: grant.expiresAt }, + ).catch(() => { /* swallow */ }); + + return res.json({ + success: true, + accessKey: grant.accessKey, + expiresAt: grant.expiresAt, + daysGranted: grant.daysGranted, + remainingBalance: grant.remainingBalance, + }); + } catch (error) { + return sendOauthJsonError( + res, + error.statusCode || 400, + error.oauthError || 'invalid_request', + error.message || 'Impossible d\'attribuer des jours VIP', + ); + } +}); + module.exports = router; diff --git a/API/Mainapi/routes/purstream.js b/API/Mainapi/routes/purstream.js index 9ebf404..42b6821 100644 --- a/API/Mainapi/routes/purstream.js +++ b/API/Mainapi/routes/purstream.js @@ -42,7 +42,7 @@ function configure(deps) { /** Wrap une URL m3u8 dans le proxy cinep si VIP et PROXY_SERVER_URL configuré */ function wrapSourceUrl(url, isVip) { if (isVip && PROXY_SERVER_URL && url) { - // PROXY_SERVER_URL = "https://proxy.movix.cash/proxy" → on veut la base sans /proxy + // PROXY_SERVER_URL = "https://proxy.movix.tax/proxy" → on veut la base sans /proxy const serverBase = PROXY_SERVER_URL.replace(/\/proxy\/?$/, '').replace(/\/+$/, ''); return `${serverBase}/cinep-proxy?url=${encodeURIComponent(url)}`; } diff --git a/API/Mainapi/routes/sync.js b/API/Mainapi/routes/sync.js index d49253c..04050bc 100644 --- a/API/Mainapi/routes/sync.js +++ b/API/Mainapi/routes/sync.js @@ -675,4 +675,5 @@ module.exports.readUserData = readUserData; module.exports.writeUserData = writeUserData; module.exports.readProfileData = readProfileData; module.exports.writeProfileData = writeProfileData; +module.exports.withProfileSyncLock = withProfileSyncLock; module.exports.USERS_DIR = USERS_DIR; diff --git a/API/Mainapi/utils/adminIdentity.js b/API/Mainapi/utils/adminIdentity.js new file mode 100644 index 0000000..556b931 --- /dev/null +++ b/API/Mainapi/utils/adminIdentity.js @@ -0,0 +1,69 @@ +/** + * Résout l'identité affichable d'un admin/uploader (nom + avatar) à partir + * de son `userId` + `authType` (`'oauth'` ou `'bip-39'` / `'bip39'`). + * + * Priorité : + * 1) `auth.userProfile.username` + `auth.userProfile.avatar` du provider + * OAuth (Discord/Google) — le "vrai" nom de la personne, pas le profil + * Movix interne (qui est souvent "Profil" + un avatar Disney random). + * 2) Le profil Movix `isDefault` ou le premier profil — pour les comptes + * BIP-39 qui n'ont pas d'identité OAuth. + * 3) Fallback `{ username: 'Admin', avatar: null }`. + * + * Utilisé par les leaderboards Wishboard et Download-links pour éviter + * d'afficher "Admin" partout au lieu des vrais noms. + */ + +const { readUserData } = require('../routes/sync'); + +const DEFAULT = Object.freeze({ username: 'Admin', avatar: null }); + +function safeParseJson(raw) { + if (typeof raw !== 'string' || !raw.trim()) return null; + try { return JSON.parse(raw); } catch { return null; } +} + +/** + * @param {string} userId + * @param {string} authType — 'oauth', 'bip39' ou 'bip-39' (DB legacy) + * @returns {Promise<{ username: string, avatar: string | null }>} + */ +async function resolveAdminIdentity(userId, authType) { + if (!userId) return { ...DEFAULT }; + + const userType = authType === 'bip-39' || authType === 'bip39' ? 'bip39' : 'oauth'; + + let data; + try { + data = await readUserData(userType, userId); + } catch { + return { ...DEFAULT }; + } + + if (!data || typeof data !== 'object') return { ...DEFAULT }; + + // 1) OAuth : nom + avatar du provider (Discord/Google). + const auth = safeParseJson(data.auth); + if (auth?.userProfile?.username) { + return { + username: String(auth.userProfile.username), + avatar: auth.userProfile.avatar ? String(auth.userProfile.avatar) : null, + }; + } + + // 2) BIP-39 ou OAuth sans `auth.userProfile` : profil Movix par défaut. + const profiles = Array.isArray(data.profiles) ? data.profiles : []; + const defaultProfile = profiles.find((p) => p && p.isDefault) || profiles[0]; + if (defaultProfile?.name) { + return { + username: String(defaultProfile.name), + avatar: defaultProfile.avatar ? String(defaultProfile.avatar) : null, + }; + } + + return { ...DEFAULT }; +} + +module.exports = { + resolveAdminIdentity, +}; diff --git a/API/Mainapi/utils/oauthClients.js b/API/Mainapi/utils/oauthClients.js index 65b5a48..43dbf16 100644 --- a/API/Mainapi/utils/oauthClients.js +++ b/API/Mainapi/utils/oauthClients.js @@ -1,17 +1,63 @@ -const fs = require('fs'); -const path = require('path'); +/** + * Source de vérité = la table `oauth_clients` (cache en mémoire alimenté + * au boot par `oauthClientsDb.reloadCache()`). On garde l'API synchrone + * historique (`loadOAuthClients()`, `getOAuthClient()`) pour ne pas avoir + * à toucher aux 30+ call sites. + * + * L'env `MOVIX_OAUTH_CLIENTS_JSON` reste supportée en surcouche (dev local + * uniquement) ; le fichier `data/oauth-clients.json` n'est plus lu une fois + * la migration vers DB effectuée (il est archivé en `.migrated`). + */ + +const { getCachedClients } = require('./oauthClientsDb'); -const OAUTH_CLIENTS_FILE = path.join(__dirname, '..', 'data', 'oauth-clients.json'); const OAUTH_CLIENTS_ENV = 'MOVIX_OAUTH_CLIENTS_JSON'; -const KNOWN_OAUTH_SCOPES = ['profile.read', 'profile.list', 'profile.manage', 'vip.read', 'vip.manage']; +const KNOWN_OAUTH_SCOPES = [ + // Compte / profils + 'profile.read', + 'profile.list', + 'profile.manage', + // VIP + 'vip.read', + 'vip.manage', + // Émission de jours VIP par l'app (depuis son balance admin-alimenté). + 'vip.grant', + // Favoris (1 read + 2 write granulaires) + 'favorites.read', + 'favorites.add', + 'favorites.remove', + // Listes personnalisées (1 read + 5 write granulaires) + 'lists.read', + 'lists.create', + 'lists.rename', + 'lists.delete', + 'lists.add-item', + 'lists.remove-item', + // Watchlist (1 read + 2 write granulaires) + 'watchlist.read', + 'watchlist.add', + 'watchlist.remove', + // Historique (films/séries marqués comme vus) + 'history.read', + 'history.add', + 'history.remove', + // Continue watching (reprise en cours) + 'continue-watching.read', + // Notifications / alertes nouvelles saisons + 'alerts.read', + 'alerts.manage', + // Notes personnelles (1-10) + texte facultatif + 'ratings.read', + 'ratings.manage', +]; const DEFAULT_SCOPE = 'profile.read'; const OAUTH_DEBUG_ENABLED = process.env.MOVIX_OAUTH_DEBUG === 'true'; -let cache = { - fileMtimeMs: -1, - envRaw: null, - clients: [], -}; +// Préfixe public servant les icônes d'apps (relatif à l'API : `/oauth-icons/`). +// Si tu sers via un CDN, set OAUTH_ICON_PUBLIC_BASE_URL. +const OAUTH_ICON_PUBLIC_BASE_URL = ( + process.env.OAUTH_ICON_PUBLIC_BASE_URL || '/oauth-icons' +).replace(/\/+$/, ''); function safeJsonParse(rawValue, fallback) { if (typeof rawValue !== 'string' || !rawValue.trim()) { @@ -128,6 +174,17 @@ function normalizeScopes(rawScopes) { ); } +function buildIconUrl(iconFilename) { + if (typeof iconFilename !== 'string' || !iconFilename.trim()) { + return null; + } + // L'iconFilename est juste le basename — pas de path traversal possible + // (validé au moment du upload côté route admin). + const safeName = iconFilename.trim().replace(/[^a-zA-Z0-9._-]/g, ''); + if (!safeName) return null; + return `${OAUTH_ICON_PUBLIC_BASE_URL}/${safeName}`; +} + function normalizeClient(rawClient) { if (!rawClient || typeof rawClient !== 'object' || Array.isArray(rawClient)) { return null; @@ -152,7 +209,10 @@ function normalizeClient(rawClient) { const requirePkce = rawClient.requirePkce === true || publicClient; const allowedScopes = normalizeScopes(rawClient.allowedScopes); const homepageUrl = normalizeHttpUrl(rawClient.homepageUrl); + // Compat ascendante : l'ancien JSON avait `logoUrl` (URL absolue), la + // nouvelle DB a `iconFilename` (basename). On expose les deux. const logoUrl = normalizeHttpUrl(rawClient.logoUrl); + const iconUrl = buildIconUrl(rawClient.iconFilename) || logoUrl; const description = typeof rawClient.description === 'string' && rawClient.description.trim() ? rawClient.description.trim() : null; @@ -167,69 +227,30 @@ function normalizeClient(rawClient) { allowedScopes: allowedScopes.length > 0 ? allowedScopes : [DEFAULT_SCOPE], homepageUrl, logoUrl, + iconUrl, + iconFilename: typeof rawClient.iconFilename === 'string' ? rawClient.iconFilename : null, description, + vipDaysBalance: Number.isFinite(rawClient.vipDaysBalance) ? Number(rawClient.vipDaysBalance) : 0, }; } -function readClientsFile() { - try { - if (!fs.existsSync(OAUTH_CLIENTS_FILE)) { - return []; - } - - const fileContent = fs.readFileSync(OAUTH_CLIENTS_FILE, 'utf8'); - const parsed = safeJsonParse(fileContent, []); - return Array.isArray(parsed) ? parsed : []; - } catch (error) { - console.error('[OAuth Clients] Failed to read oauth-clients.json:', error.message || error); - return []; - } -} - -function getClientsFileMtimeMs() { - try { - if (!fs.existsSync(OAUTH_CLIENTS_FILE)) { - return -1; - } - - return fs.statSync(OAUTH_CLIENTS_FILE).mtimeMs || -1; - } catch { - return -1; - } -} - function loadOAuthClients() { + // Source 1: env var (override dev/test). const envRaw = process.env[OAUTH_CLIENTS_ENV] || ''; - const fileMtimeMs = getClientsFileMtimeMs(); + const fromEnv = envRaw ? safeJsonParse(envRaw, []) : []; - if (cache.envRaw === envRaw && cache.fileMtimeMs === fileMtimeMs) { - return cache.clients; - } - - const fromEnv = safeJsonParse(envRaw, []); - const fromFile = readClientsFile(); - const mergedSources = [ - ...(Array.isArray(fromEnv) ? fromEnv : []), - ...(Array.isArray(fromFile) ? fromFile : []), - ]; + // Source 2: DB cache (source de vérité prod). + const fromDb = getCachedClients() || []; const byClientId = new Map(); - mergedSources.forEach((entry) => { + // L'env override la DB (utile pour les tests E2E qui injectent un client éphémère). + [...(Array.isArray(fromDb) ? fromDb : []), ...(Array.isArray(fromEnv) ? fromEnv : [])].forEach((entry) => { const normalized = normalizeClient(entry); - if (!normalized) { - return; - } - + if (!normalized) return; byClientId.set(normalized.clientId, normalized); }); - cache = { - envRaw, - fileMtimeMs, - clients: Array.from(byClientId.values()), - }; - - return cache.clients; + return Array.from(byClientId.values()); } function getOAuthClient(clientId) { @@ -252,6 +273,7 @@ function getOAuthClientPublicMetadata(client) { description: client.description, homepageUrl: client.homepageUrl, logoUrl: client.logoUrl, + iconUrl: client.iconUrl, publicClient: client.publicClient, requirePkce: client.requirePkce, allowedScopes: [...client.allowedScopes], diff --git a/API/Mainapi/utils/oauthClientsDb.js b/API/Mainapi/utils/oauthClientsDb.js new file mode 100644 index 0000000..4b13075 --- /dev/null +++ b/API/Mainapi/utils/oauthClientsDb.js @@ -0,0 +1,344 @@ +/** + * Stockage DB des clients OAuth + stats + grants VIP. Remplace le fichier + * `data/oauth-clients.json` (déprécié — migration auto au boot). + * + * Les autres modules continuent d'appeler `loadOAuthClients()` (sync) de + * `oauthClients.js`, qui lit depuis le cache pré-warmé par les fonctions + * async ci-dessous. + */ + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { getPool } = require('../mysqlPool'); + +const SCHEMA_PATH = path.join(__dirname, '..', 'exportscripts', 'add_oauth_apps_tables.sql'); +const LEGACY_JSON_PATH = path.join(__dirname, '..', 'data', 'oauth-clients.json'); +const ICON_DIR = path.join(__dirname, '..', 'public', 'oauth-icons'); + +// Cache en mémoire : refresh par invalidate() ou refresh périodique. +let memCache = { + loadedAt: 0, + clients: [], +}; + +const KNOWN_OAUTH_SCOPES_SET = new Set([ + 'profile.read', + 'profile.list', + 'profile.manage', + 'vip.read', + 'vip.manage', + 'vip.grant', + 'favorites.read', + 'favorites.add', + 'favorites.remove', + 'lists.read', + 'lists.create', + 'lists.rename', + 'lists.delete', + 'lists.add-item', + 'lists.remove-item', + 'watchlist.read', + 'watchlist.add', + 'watchlist.remove', + 'history.read', + 'history.add', + 'history.remove', + 'continue-watching.read', + 'alerts.read', + 'alerts.manage', + 'ratings.read', + 'ratings.manage', +]); + +/** Strip les commentaires `-- …` ligne par ligne avant le split. + * Note : ne gère pas `/* … *\/` mais le schéma n'en utilise pas. */ +function stripSqlLineComments(sqlText) { + return sqlText + .split('\n') + .filter((line) => !line.trim().startsWith('--')) + .join('\n'); +} + +/** Crée les tables si elles n'existent pas (idempotent). */ +async function ensureTables() { + const pool = getPool(); + if (!pool) throw new Error('MySQL pool not ready'); + if (!fs.existsSync(SCHEMA_PATH)) return; + // On strip d'abord TOUS les commentaires ligne `-- …` puis on split sur `;`. + // Sans le strip, le premier statement embarquait le header de commentaires + // du fichier et était filtré par `!startsWith('--')` → aucune table créée + // et le INSERT migrate plantait sur "Table 'oauth_clients' doesn't exist". + const sql = stripSqlLineComments(fs.readFileSync(SCHEMA_PATH, 'utf-8')); + const statements = sql + .split(';') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + for (const stmt of statements) { + await pool.query(stmt); + } + // Crée aussi le dossier oauth-icons s'il n'existe pas. + if (!fs.existsSync(ICON_DIR)) { + fs.mkdirSync(ICON_DIR, { recursive: true, mode: 0o755 }); + } +} + +/** Import unique du JSON legacy vers DB. Idempotent : skip si déjà importé. */ +async function migrateLegacyJsonIfNeeded() { + const pool = getPool(); + if (!pool) return; + if (!fs.existsSync(LEGACY_JSON_PATH)) return; + + const [rows] = await pool.execute('SELECT COUNT(*) AS n FROM oauth_clients'); + const existing = Number(rows[0]?.n || 0); + if (existing > 0) { + // Migration déjà faite : on archive le JSON et on continue. + try { + const archivePath = LEGACY_JSON_PATH + '.migrated'; + if (!fs.existsSync(archivePath)) { + fs.renameSync(LEGACY_JSON_PATH, archivePath); + console.log('[OAuth Clients DB] Archived legacy JSON to', archivePath); + } + } catch (err) { + console.warn('[OAuth Clients DB] Could not archive legacy JSON:', err.message); + } + return; + } + + try { + const content = fs.readFileSync(LEGACY_JSON_PATH, 'utf-8'); + const parsed = JSON.parse(content); + if (!Array.isArray(parsed)) return; + const now = Date.now(); + for (const entry of parsed) { + if (!entry || typeof entry !== 'object') continue; + const clientId = String(entry.clientId || '').trim(); + const clientName = String(entry.clientName || '').trim(); + if (!clientId || !clientName) continue; + const redirectUris = Array.isArray(entry.redirectUris) ? entry.redirectUris : []; + const allowedScopes = Array.isArray(entry.allowedScopes) ? entry.allowedScopes : []; + const description = entry.description ? String(entry.description) : null; + const homepageUrl = entry.homepageUrl ? String(entry.homepageUrl) : null; + const publicClient = entry.publicClient === false ? 0 : 1; + const requirePkce = entry.requirePkce === false ? 0 : 1; + const clientSecret = entry.clientSecret ? String(entry.clientSecret) : null; + await pool.execute( + `INSERT INTO oauth_clients + (client_id, client_name, description, homepage_url, redirect_uris, + allowed_scopes, public_client, require_pkce, client_secret, + is_active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?) + ON DUPLICATE KEY UPDATE updated_at = VALUES(updated_at)`, + [ + clientId, + clientName, + description, + homepageUrl, + JSON.stringify(redirectUris), + JSON.stringify(allowedScopes), + publicClient, + requirePkce, + clientSecret, + now, + now, + ], + ); + } + console.log('[OAuth Clients DB] Migrated', parsed.length, 'client(s) from JSON to MySQL'); + // Archive le JSON + try { + fs.renameSync(LEGACY_JSON_PATH, LEGACY_JSON_PATH + '.migrated'); + } catch { + /* ignore */ + } + } catch (err) { + console.error('[OAuth Clients DB] Legacy migration failed:', err.message); + } +} + +function safeParseJson(raw, fallback) { + if (typeof raw !== 'string' || !raw.trim()) { + return Array.isArray(raw) ? raw : fallback; + } + try { + return JSON.parse(raw); + } catch { + return fallback; + } +} + +function rowToClient(row) { + return { + id: Number(row.id), + clientId: row.client_id, + clientName: row.client_name, + description: row.description || null, + homepageUrl: row.homepage_url || null, + redirectUris: safeParseJson(row.redirect_uris, []).filter((u) => typeof u === 'string'), + allowedScopes: safeParseJson(row.allowed_scopes, []).filter((s) => typeof s === 'string' && KNOWN_OAUTH_SCOPES_SET.has(s)), + publicClient: row.public_client === 1 || row.public_client === true, + requirePkce: row.require_pkce === 1 || row.require_pkce === true, + clientSecret: row.client_secret || null, + iconFilename: row.icon_filename || null, + vipDaysBalance: Number(row.vip_days_balance || 0), + isActive: row.is_active === 1 || row.is_active === true, + createdAt: Number(row.created_at || 0), + updatedAt: Number(row.updated_at || 0), + }; +} + +/** Charge tous les clients actifs depuis la DB. À appeler au boot + après chaque modif. */ +async function reloadCache() { + const pool = getPool(); + if (!pool) return; + const [rows] = await pool.execute('SELECT * FROM oauth_clients WHERE is_active = 1 ORDER BY id ASC'); + memCache = { + loadedAt: Date.now(), + clients: rows.map(rowToClient), + }; +} + +function getCachedClients() { + return memCache.clients; +} + +function invalidateCache() { + memCache = { loadedAt: 0, clients: [] }; +} + +// ─── Stats helpers ─────────────────────────────────────────────────────── + +async function recordEvent(clientId, eventType, userId, metadata) { + const pool = getPool(); + if (!pool) return; + try { + await pool.execute( + `INSERT INTO oauth_app_stats (client_id, event_type, user_id, metadata, created_at) + VALUES (?, ?, ?, ?, ?)`, + [ + String(clientId), + String(eventType).slice(0, 32), + userId ? String(userId).slice(0, 160) : null, + metadata ? JSON.stringify(metadata) : null, + Date.now(), + ], + ); + } catch (err) { + console.warn('[OAuth stats] recordEvent failed:', err.message); + } +} + +async function getStats(clientId, sinceMs) { + const pool = getPool(); + if (!pool) return null; + const since = Number(sinceMs) || Date.now() - 30 * 24 * 60 * 60 * 1000; + const [byType] = await pool.execute( + `SELECT event_type, COUNT(*) AS n + FROM oauth_app_stats + WHERE client_id = ? AND created_at >= ? + GROUP BY event_type`, + [clientId, since], + ); + const [byDay] = await pool.execute( + `SELECT FROM_UNIXTIME(FLOOR(created_at/1000), '%Y-%m-%d') AS day, + COUNT(*) AS n + FROM oauth_app_stats + WHERE client_id = ? AND created_at >= ? + GROUP BY day + ORDER BY day ASC`, + [clientId, since], + ); + const [uniqueUsers] = await pool.execute( + `SELECT COUNT(DISTINCT user_id) AS n + FROM oauth_app_stats + WHERE client_id = ? AND created_at >= ? AND user_id IS NOT NULL`, + [clientId, since], + ); + return { + sinceMs: since, + byType, + byDay, + uniqueUsers: Number(uniqueUsers[0]?.n || 0), + }; +} + +// ─── VIP grants helpers ────────────────────────────────────────────────── + +function generateAccessKeyValue() { + // 32 chars base32-like uppercase (lisible). + return crypto.randomBytes(20).toString('hex').toUpperCase(); +} + +/** + * Décrémente atomiquement le balance et émet une access_key valide N jours. + * Throw si balance insuffisant. + */ +async function grantVip({ clientId, userType, userId, days }) { + if (!clientId || !userType || !userId || !Number.isInteger(days) || days <= 0 || days > 365) { + throw new Error('Paramètres grant invalides'); + } + const pool = getPool(); + if (!pool) throw new Error('DB indisponible'); + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + // Lock + check balance + const [rows] = await conn.execute( + 'SELECT id, vip_days_balance FROM oauth_clients WHERE client_id = ? FOR UPDATE', + [clientId], + ); + if (rows.length === 0) throw new Error('Client OAuth introuvable'); + const balance = Number(rows[0].vip_days_balance || 0); + if (balance < days) { + throw new Error(`Solde VIP insuffisant : ${balance} jour(s) disponible(s), ${days} demandé(s)`); + } + // Décrément + await conn.execute( + 'UPDATE oauth_clients SET vip_days_balance = vip_days_balance - ?, updated_at = ? WHERE id = ?', + [days, Date.now(), rows[0].id], + ); + // Génère access_key + const keyValue = generateAccessKeyValue(); + const expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000); + const expiresAtSql = expiresAt.toISOString().slice(0, 19).replace('T', ' '); + await conn.execute( + `INSERT INTO access_keys (key_value, active, expires_at, duree_validite) + VALUES (?, 1, ?, ?)`, + [keyValue, expiresAtSql, `${days}d`], + ); + // Audit + const userIdComposite = `${userType}:${userId}`; + await conn.execute( + `INSERT INTO oauth_vip_grants + (client_id, user_id, user_type, user_id_only, days_granted, + access_key_value, expires_at, granted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [clientId, userIdComposite, userType, userId, days, keyValue, expiresAtSql, Date.now()], + ); + await conn.commit(); + return { + accessKey: keyValue, + expiresAt: expiresAt.toISOString(), + daysGranted: days, + remainingBalance: balance - days, + }; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } +} + +module.exports = { + ensureTables, + migrateLegacyJsonIfNeeded, + reloadCache, + getCachedClients, + invalidateCache, + recordEvent, + getStats, + grantVip, + ICON_DIR, + KNOWN_OAUTH_SCOPES_SET, +}; diff --git a/API/Mainapi/utils/syncPolicy.js b/API/Mainapi/utils/syncPolicy.js index ec7b5bc..c41692d 100644 --- a/API/Mainapi/utils/syncPolicy.js +++ b/API/Mainapi/utils/syncPolicy.js @@ -35,7 +35,9 @@ const SYNCABLE_EXACT_KEYS = new Set([ 'subtitleStyle', 'support_popup_seen', 'user_language', - 'is_vip', + // SECURITY (audit P0) : `is_vip` retiré du sync — c'est juste un cache UI + // côté frontend qui doit être recalculé via /api/check-vip à chaque session. + // Le laisser syncable permettait à n'importe qui de forger son statut VIP. 'watched_movie', 'watched_tv', 'watchPartyNickname' diff --git a/API/Mainapi/wishboardRoutes.js b/API/Mainapi/wishboardRoutes.js index 8c24931..b973be0 100644 --- a/API/Mainapi/wishboardRoutes.js +++ b/API/Mainapi/wishboardRoutes.js @@ -12,6 +12,7 @@ const path = require('path'); const { verifyAccessKey } = require('./checkVip'); const { searchTmdb } = require('./utils/tmdbCache'); const { verifyTurnstileFromRequest } = require('./utils/turnstile'); +const { resolveAdminIdentity } = require('./utils/adminIdentity'); const TURNSTILE_INVISIBLE_SECRETKEY = process.env.TURNSTILE_INVISIBLE_SECRETKEY; const TMDB_API_URL = 'https://api.themoviedb.org/3'; @@ -945,24 +946,18 @@ function createWishboardRouter(mysqlPool, redis) { } } - // Resolve user data (username, avatar) for each admin + // Resolve user data (username, avatar) for each admin via the + // shared helper — prefers OAuth provider identity over the + // generic Movix profile, so we display "Maxou DM" instead of + // "Admin" / "Profil". const leaderboard = await Promise.all(rows.map(async (row) => { - let userData = { username: 'Admin', avatar: null }; - try { - const userType = row.admin_auth_type === 'bip-39' ? 'bip39' : 'oauth'; - const basicData = await getUserData(row.admin_id, userType); - if (basicData.username) userData.username = basicData.username; - if (basicData.avatar) userData.avatar = basicData.avatar; - } catch (err) { - // Keep defaults - } - + const identity = await resolveAdminIdentity(row.admin_id, row.admin_auth_type); return { admin_id: row.admin_id, admin_auth_type: row.admin_auth_type, role: adminRoles[row.admin_id] || 'admin', - username: userData.username, - avatar: userData.avatar, + username: identity.username, + avatar: identity.avatar, greenlight_count: row.greenlight_count, last_greenlight_at: row.last_greenlight_at }; diff --git a/extension/Chrome/background.js b/extension/Chrome/background.js index 5709751..6672828 100644 --- a/extension/Chrome/background.js +++ b/extension/Chrome/background.js @@ -113,7 +113,7 @@ async function setupRules() { "localhost", "127.0.0.1", "movix.cash", - "movix.cash", + "movix.tax", "movix.club", ], resourceTypes: [ diff --git a/extension/Chrome/manifest.json b/extension/Chrome/manifest.json index fe863f5..849cf90 100644 --- a/extension/Chrome/manifest.json +++ b/extension/Chrome/manifest.json @@ -38,8 +38,8 @@ "*://localhost/*", "*://movix.cash/*", "*://*.movix.cash/*", - "*://movix.cash/*", - "*://*.movix.cash/*", + "*://movix.tax/*", + "*://*.movix.tax/*", "*://movix.club/*", "*://*.movix.club/*" ] diff --git a/extension/Chrome/popup.html b/extension/Chrome/popup.html index c963560..46f3569 100644 --- a/extension/Chrome/popup.html +++ b/extension/Chrome/popup.html @@ -424,7 +424,7 @@

@@ -456,7 +456,7 @@ @@ -456,7 +456,7 @@ ); +// Wraps the tree in a tied to the Mode léger / animation prefs. +// When `transitions` is disabled (manually or because Mode léger is on), +// framer-motion treats EVERY animation as if `prefers-reduced-motion: reduce` +// were set — initial/animate/exit are skipped on transform/opacity for free. +const AnimationMotionConfig: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { effectivePrefs } = useLightMode(); + return ( + + {children} + + ); +}; + function App() { const [forceContinue, setForceContinue] = React.useState(false); @@ -1891,6 +1905,8 @@ function App() { return ( + + @@ -1912,6 +1928,8 @@ function App() { + + ); diff --git a/src/components/AdminDashboard.tsx b/src/components/AdminDashboard.tsx index 74d0adb..332deaa 100644 --- a/src/components/AdminDashboard.tsx +++ b/src/components/AdminDashboard.tsx @@ -8,6 +8,7 @@ import { Link2, ListOrdered, MessageSquare, + Plug, ShieldCheck, Sparkles, Sprout, @@ -18,6 +19,7 @@ import AdminComments from './AdminComments'; import AdminHelpFeedback from './AdminHelpFeedback'; import AdminLinkSubmissions from './Greenlight/AdminLinkSubmissions'; import AdminWishboard from './Greenlight/AdminWishboard'; +import AdminOAuthApps from './AdminOAuthApps'; import AdminReports from './AdminReports'; import AdminSharedLists from './AdminSharedLists'; import StreamingLinksManager from './StreamingLinksManager'; @@ -30,6 +32,7 @@ type AdminSection = | 'links' | 'vip-keys' | 'vip-invoices' + | 'oauth-apps' | 'wishboard' | 'link-submissions' | 'comments' @@ -80,6 +83,14 @@ const AdminDashboard: React.FC = ({ role }) => { accent: 'text-yellow-300', highlight: '234 179 8' }, + { + id: 'oauth-apps', + title: t('adminOauthApps.cardTitle'), + description: t('adminOauthApps.cardDesc'), + icon: Plug, + accent: 'text-purple-300', + highlight: '168 85 247' + }, { id: 'wishboard', title: t('admin.wishboardGreenlight'), @@ -305,6 +316,16 @@ const AdminDashboard: React.FC = ({ role }) => { )} + + {activeSection === 'oauth-apps' && role === 'admin' && ( +
+

+ + {t('adminOauthApps.cardTitle')} +

+ +
+ )} )} diff --git a/src/components/AdminOAuthApps.tsx b/src/components/AdminOAuthApps.tsx new file mode 100644 index 0000000..ae628dc --- /dev/null +++ b/src/components/AdminOAuthApps.tsx @@ -0,0 +1,1073 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import axios, { AxiosError } from 'axios'; +import { AnimatePresence, motion } from 'framer-motion'; +import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; +import { + ArrowUpFromLine, + BarChart3, + Coins, + Copy, + Image as ImageIcon, + KeyRound, + Pencil, + Plus, + RefreshCw, + ShieldCheck, + Trash2, + Upload, + X, +} from 'lucide-react'; + +import { Badge } from './ui/badge'; +import { Button } from './ui/button'; +import { Checkbox } from './ui/checkbox'; +import ConfirmDialog from './ui/confirm-dialog'; +import { Input } from './ui/input'; +import ReusableModal from './ui/reusable-modal'; +import { Switch } from './ui/switch'; +import { Textarea } from './ui/textarea'; + +interface OAuthAppRow { + id: number; + clientId: string; + clientName: string; + description: string | null; + homepageUrl: string | null; + redirectUris: string[]; + allowedScopes: string[]; + publicClient: boolean; + requirePkce: boolean; + hasClientSecret: boolean; + iconFilename: string | null; + iconUrl: string | null; + vipDaysBalance: number; + isActive: boolean; + createdAt: number; + updatedAt: number; + stats30d?: Record; +} + +interface AppStats { + sinceMs: number; + byType: { event_type: string; n: number }[]; + byDay: { day: string; n: number }[]; + uniqueUsers: number; +} + +interface AppGrant { + id: number; + clientId: string; + userId: string; + userType: string; + userIdOnly: string; + daysGranted: number; + accessKeyHint: string | null; + expiresAt: string | null; + grantedAt: number; + revokedAt: number | null; +} + +const API_URL = import.meta.env.VITE_MAIN_API; + +function getRequestErrorMessage(error: unknown): string { + if (axios.isAxiosError(error)) { + const ax = error as AxiosError<{ error?: string }>; + return ax.response?.data?.error || ax.message; + } + if (error instanceof Error) return error.message; + return ''; +} + +function fmtDate(ms: number | null): string { + if (!ms) return '—'; + try { + return new Date(ms).toLocaleString(); + } catch { + return '—'; + } +} + +const AdminOAuthApps: React.FC = () => { + const { t } = useTranslation(); + const [apps, setApps] = useState([]); + const [scopes, setScopes] = useState([]); + const [loading, setLoading] = useState(true); + const [includeInactive, setIncludeInactive] = useState(false); + const [search, setSearch] = useState(''); + + const [createOpen, setCreateOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [statsFor, setStatsFor] = useState(null); + const [statsData, setStatsData] = useState(null); + const [grantsData, setGrantsData] = useState([]); + const [statsLoading, setStatsLoading] = useState(false); + const [deleteCandidate, setDeleteCandidate] = useState(null); + const [deleting, setDeleting] = useState(false); + + const getAuth = () => ({ Authorization: `Bearer ${localStorage.getItem('auth_token')}` }); + + const loadApps = useCallback(async () => { + setLoading(true); + try { + const res = await axios.get(`${API_URL}/api/admin/oauth-apps`, { + params: { inactive: includeInactive ? '1' : undefined }, + headers: getAuth(), + }); + setApps(res.data?.apps || []); + } catch (err) { + toast.error(getRequestErrorMessage(err) || t('common.error')); + } finally { + setLoading(false); + } + }, [includeInactive, t]); + + const loadScopes = useCallback(async () => { + try { + const res = await axios.get(`${API_URL}/api/admin/oauth-apps/scopes`, { headers: getAuth() }); + setScopes(res.data?.scopes || []); + } catch (err) { + toast.error(getRequestErrorMessage(err) || t('common.error')); + } + }, [t]); + + useEffect(() => { loadApps(); loadScopes(); }, [loadApps, loadScopes]); + + const filtered = useMemo(() => { + if (!search.trim()) return apps; + const q = search.toLowerCase(); + return apps.filter((a) => + a.clientId.toLowerCase().includes(q) + || a.clientName.toLowerCase().includes(q) + || (a.description?.toLowerCase().includes(q) ?? false), + ); + }, [apps, search]); + + const handleDeleteConfirmed = async () => { + if (!deleteCandidate) return; + setDeleting(true); + try { + await axios.delete(`${API_URL}/api/admin/oauth-apps/${deleteCandidate.clientId}`, { headers: getAuth() }); + toast.success(t('adminOauthApps.deleted')); + setDeleteCandidate(null); + loadApps(); + } catch (err) { + toast.error(getRequestErrorMessage(err) || t('common.error')); + } finally { + setDeleting(false); + } + }; + + const handleToggleActive = async (app: OAuthAppRow) => { + try { + await axios.put( + `${API_URL}/api/admin/oauth-apps/${app.clientId}`, + { isActive: !app.isActive }, + { headers: getAuth() }, + ); + toast.success(app.isActive ? t('adminOauthApps.disabled') : t('adminOauthApps.enabled')); + loadApps(); + } catch (err) { + toast.error(getRequestErrorMessage(err) || t('common.error')); + } + }; + + const handleShowStats = async (app: OAuthAppRow) => { + setStatsFor(app); + setStatsLoading(true); + setStatsData(null); + setGrantsData([]); + try { + const [s, g] = await Promise.all([ + axios.get(`${API_URL}/api/admin/oauth-apps/${app.clientId}/stats`, { headers: getAuth() }), + axios.get(`${API_URL}/api/admin/oauth-apps/${app.clientId}/grants`, { headers: getAuth() }), + ]); + setStatsData({ + sinceMs: s.data?.sinceMs, + byType: s.data?.byType || [], + byDay: s.data?.byDay || [], + uniqueUsers: s.data?.uniqueUsers || 0, + }); + setGrantsData(g.data?.grants || []); + } catch (err) { + toast.error(getRequestErrorMessage(err) || t('common.error')); + } finally { + setStatsLoading(false); + } + }; + + return ( +
+
+
+ setSearch(e.target.value)} + placeholder={t('adminOauthApps.searchPlaceholder')} + className="max-w-md" + /> + +
+
+ + +
+
+ + {loading ? ( +
{t('common.loading')}
+ ) : filtered.length === 0 ? ( +
+ {t('adminOauthApps.empty')} +
+ ) : ( +
+ {filtered.map((app) => ( + setEditing(app)} + onStats={() => handleShowStats(app)} + onDelete={() => setDeleteCandidate(app)} + onToggleActive={() => handleToggleActive(app)} + onChanged={loadApps} + /> + ))} +
+ )} + + {createOpen && ( + setCreateOpen(false)} + onCreated={() => { setCreateOpen(false); loadApps(); }} + /> + )} + + {editing && ( + setEditing(null)} + onSaved={() => { setEditing(null); loadApps(); }} + /> + )} + + {statsFor && ( + { setStatsFor(null); setStatsData(null); setGrantsData([]); }} + /> + )} + + !deleting && setDeleteCandidate(null)} + /> +
+ ); +}; + +// ─── Card ─────────────────────────────────────────────────────────────── + +interface AppCardProps { + app: OAuthAppRow; + onEdit: () => void; + onStats: () => void; + onDelete: () => void; + onToggleActive: () => void; + onChanged: () => void; +} + +const AppCard: React.FC = ({ app, onEdit, onStats, onDelete, onToggleActive, onChanged }) => { + const { t } = useTranslation(); + const fileInputRef = useRef(null); + const [balanceDelta, setBalanceDelta] = useState(30); + const [busy, setBusy] = useState(false); + const [iconHover, setIconHover] = useState(false); + // Drag & drop state. On compte les enter/leave (dragCounter) parce qu'au + // passage de la souris entre 2 child elements, dragenter et dragleave se + // déclenchent en cascade ; un simple booléen flickerait. + const [isDragging, setIsDragging] = useState(false); + const dragCounter = useRef(0); + const [iconDeleteOpen, setIconDeleteOpen] = useState(false); + const [regenOpen, setRegenOpen] = useState(false); + + const getAuth = () => ({ Authorization: `Bearer ${localStorage.getItem('auth_token')}` }); + + const handleIconUpload = async (file: File) => { + if (file.size > 256 * 1024) { + toast.error(t('adminOauthApps.iconTooLarge')); + return; + } + if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) { + toast.error(t('adminOauthApps.iconTypeInvalid')); + return; + } + setBusy(true); + try { + const reader = new FileReader(); + const dataBase64: string = await new Promise((resolve, reject) => { + reader.onload = () => { + const result = reader.result as string; + const comma = result.indexOf(','); + resolve(comma >= 0 ? result.slice(comma + 1) : result); + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); + await axios.post( + `${API_URL}/api/admin/oauth-apps/${app.clientId}/icon`, + { mimeType: file.type, dataBase64 }, + { headers: getAuth() }, + ); + toast.success(t('adminOauthApps.iconUploaded')); + onChanged(); + } catch (err) { + toast.error(getRequestErrorMessage(err) || t('common.error')); + } finally { + setBusy(false); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + }; + + const handleIconDeleteConfirmed = async () => { + setBusy(true); + try { + await axios.delete(`${API_URL}/api/admin/oauth-apps/${app.clientId}/icon`, { headers: getAuth() }); + toast.success(t('adminOauthApps.iconRemoved')); + setIconDeleteOpen(false); + onChanged(); + } catch (err) { + toast.error(getRequestErrorMessage(err) || t('common.error')); + } finally { + setBusy(false); + } + }; + + const handleBalanceUpdate = async () => { + if (!Number.isInteger(balanceDelta) || balanceDelta === 0) { + toast.error(t('adminOauthApps.balanceDeltaInvalid')); + return; + } + setBusy(true); + try { + const res = await axios.post( + `${API_URL}/api/admin/oauth-apps/${app.clientId}/vip-balance`, + { delta: balanceDelta }, + { headers: getAuth() }, + ); + toast.success(t('adminOauthApps.balanceUpdated', { newBalance: res.data?.newBalance })); + onChanged(); + } catch (err) { + toast.error(getRequestErrorMessage(err) || t('common.error')); + } finally { + setBusy(false); + } + }; + + const handleRegenerateSecretConfirmed = async () => { + setBusy(true); + try { + const res = await axios.post( + `${API_URL}/api/admin/oauth-apps/${app.clientId}/regenerate-secret`, + {}, + { headers: getAuth() }, + ); + const newSecret = res.data?.clientSecret; + if (newSecret) { + await navigator.clipboard.writeText(newSecret); + toast.success(t('adminOauthApps.secretCopied')); + } + setRegenOpen(false); + onChanged(); + } catch (err) { + toast.error(getRequestErrorMessage(err) || t('common.error')); + } finally { + setBusy(false); + } + }; + + // ─── Drag & drop handlers (sur toute la carte) ───────────────────────── + const handleDragEnter = (e: React.DragEvent) => { + if (busy) return; + if (!e.dataTransfer?.types?.includes('Files')) return; + e.preventDefault(); + dragCounter.current += 1; + setIsDragging(true); + }; + + const handleDragOver = (e: React.DragEvent) => { + if (busy) return; + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; + }; + + const handleDragLeave = (e: React.DragEvent) => { + if (busy) return; + e.preventDefault(); + dragCounter.current = Math.max(0, dragCounter.current - 1); + if (dragCounter.current === 0) setIsDragging(false); + }; + + const handleDrop = (e: React.DragEvent) => { + if (busy) return; + e.preventDefault(); + dragCounter.current = 0; + setIsDragging(false); + const file = e.dataTransfer?.files?.[0]; + if (file) handleIconUpload(file); + }; + + const totalCalls30d = useMemo(() => { + const stats = app.stats30d || {}; + return Object.values(stats).reduce((sum, n) => sum + n, 0); + }, [app.stats30d]); + + return ( + + {/* Overlay drop : couvre toute la carte avec une animation drop-zone. */} + + {isDragging && ( + + + + + {t('adminOauthApps.dropToUpload')} + + + + )} + + +
+
setIconHover(true)} + onMouseLeave={() => setIconHover(false)} + > + {app.iconUrl ? ( + {app.clientName} + ) : ( +
+ +
+ )} + {/* Overlay hover : clic = upload */} + + {/* Petite croix rouge en haut à droite : visible au hover si une icône existe. */} + + {app.iconUrl && iconHover && !busy && ( + { e.stopPropagation(); setIconDeleteOpen(true); }} + className="absolute -right-1.5 -top-1.5 z-20 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-white shadow-lg ring-2 ring-black/60 transition-colors hover:bg-red-400" + title={t('adminOauthApps.removeIcon')} + aria-label={t('adminOauthApps.removeIcon')} + > + + + )} + + { + const f = e.target.files?.[0]; + if (f) handleIconUpload(f); + }} + /> +
+
+
+

{app.clientName}

+ {!app.isActive && ( + + {t('adminOauthApps.inactive')} + + )} + {app.publicClient ? ( + PKCE + ) : ( + Confidential + )} +
+
+ {app.clientId} + +
+ {app.description && ( +

{app.description}

+ )} +
+
+ +
+
+
+ + {t('adminOauthApps.callsLast30d')} +
+
{totalCalls30d}
+
+
+
+ + {t('adminOauthApps.vipBalance')} +
+
{app.vipDaysBalance}
+
+
+ +
+ setBalanceDelta(Number(e.target.value))} + className="h-8 w-24" + /> + + + {t('adminOauthApps.balanceHint')} + +
+ +
+ {app.allowedScopes.slice(0, 6).map((s) => ( + {s} + ))} + {app.allowedScopes.length > 6 && ( + +{app.allowedScopes.length - 6} + )} +
+ +
+ + + {!app.publicClient && ( + + )} + + +
+ + !busy && setIconDeleteOpen(false)} + /> + + !busy && setRegenOpen(false)} + /> +
+ ); +}; + +// ─── Create modal ─────────────────────────────────────────────────────── + +interface CreateAppModalProps { + scopes: string[]; + onClose: () => void; + onCreated: () => void; +} + +const CreateAppModal: React.FC = ({ scopes, onClose, onCreated }) => { + const { t } = useTranslation(); + const [clientId, setClientId] = useState(''); + const [clientName, setClientName] = useState(''); + const [description, setDescription] = useState(''); + const [homepageUrl, setHomepageUrl] = useState(''); + const [redirectUris, setRedirectUris] = useState(''); + const [selectedScopes, setSelectedScopes] = useState>(new Set()); + const [publicClient, setPublicClient] = useState(true); + const [busy, setBusy] = useState(false); + const [createdSecret, setCreatedSecret] = useState(null); + + const getAuth = () => ({ Authorization: `Bearer ${localStorage.getItem('auth_token')}` }); + + const handleCreate = async () => { + setBusy(true); + try { + const res = await axios.post( + `${API_URL}/api/admin/oauth-apps`, + { + clientId: clientId.trim().toLowerCase(), + clientName: clientName.trim(), + description: description.trim() || undefined, + homepageUrl: homepageUrl.trim() || undefined, + redirectUris: redirectUris.split('\n').map((u) => u.trim()).filter(Boolean), + allowedScopes: Array.from(selectedScopes), + publicClient, + requirePkce: publicClient, + }, + { headers: getAuth() }, + ); + if (res.data?.clientSecret) { + setCreatedSecret(res.data.clientSecret); + } else { + toast.success(t('adminOauthApps.created')); + onCreated(); + } + } catch (err) { + toast.error(getRequestErrorMessage(err) || t('common.error')); + } finally { + setBusy(false); + } + }; + + return ( + + {createdSecret ? ( +
+
+

{t('adminOauthApps.secretNotShownAgain')}

+
+
+ {createdSecret} + +
+
+ +
+
+ ) : ( +
+ + setClientId(e.target.value)} + placeholder="mon-app" + className="font-mono" + /> + + + setClientName(e.target.value)} + placeholder={t('adminOauthApps.clientNamePlaceholder')} + /> + + +