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 0000000..106666f Binary files /dev/null and b/API/Mainapi/public/oauth-icons/movix-mcp-1778761296022.jpg differ 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')} + /> + + +