MovixOpenSource/API/Mainapi/routes/oauth.js
Movix 8657a856f8 feat: live TV FCTV, fournisseur OAuth, sources J1F/Cinestream, modération IA & durcissement
feat: live TV FCTV, fournisseur OAuth, sources J1F/Cinestream, modération IA & durcissement

Gros lot de travail synchronisé depuis le repo de dev. Les grands axes :
migration de domaine (movix.date + miroirs), deux nouvelles sources films/séries
(1jour1film, Cinestream) et une n ouvelle source livetv, un fournisseur OAuth « Se connecter avec Movix », une
modération de contenu par IA revue, plus un lot de durcissement mémoire/réseau,
sécurité et perf côté API et front.

## 🌐 Domaines & miroirs

- Domaine canonique `movix.chat`/`movix.golf`/`movix.tax` → **movix.date** partout
  (front, allow-lists CORS/domaine, Discord RPC). Miroir `movix.health` →
  **movix.online** (Footer, Header, pages d'aide CaMarchePlus/Dns/Miroirs). Lien
  Telegram du footer → `mysticsaba.com`. ~10 clés i18n mises à jour.

## 🎬 Sources de streaming (VOD)

- **J1F (1jour1film)** — nouvelle source de bout en bout (`routes/j1f.js`, monté sur
  `/api/j1f`). Résout le domaine tournant via une page `/go/`, décode les blobs base64
  `J1F_SRV`/`j1fEpsData` (Dooplay/WP), ne garde que les players uniques, ignore les
  agrégateurs (frembed/vidsrc/videasy). Cache stale-while-revalidate dédié. Players 1J1F
  lus via l'extension/userscript.
- **Cinestream** — nouvelle source films (`routes/cinestream.js`, scrape Next.js
  `cinestream.info`, match par id TMDB autoritaire). Wiflix bascule ses **films** sur
  Cinestream (bot-shield flemmix pénible sur les films) ; la **TV** reste sur flemmix.
- **Wiflix** — `flemmix.farm` → **`flemmix.golf`** ; handshake cookie
  (`makeWiflixSearchRequest`, `h_check=25`) ; sélecteurs de template corrigés.
- **Cpasmal** — axios+proxy → `makeCpasmalRequest` (CycleTLS JA3) car Cloudflare 403 ;
  recherche GET → POST form (le WAF bloque le GET).
- **Fstream** — cookie anti-bot `fsschal` ; source épisodes JS statique
  (`/static/series/<id>.js`) avant l'AJAX `episodes_p.php` ; fallback `get_seasons.php`.
- **FranceTV** — `fetchSeasonEpisodesViaDeepPage` (pagination JSON « Afficher plus »).
- **Coflix** — `coflix.date` → `coflix.trade` ; logs 429 silencés en double.
- **proxyManager** — cinq helpers de contournement Cloudflare exportés
  (`makeCinestreamRequest`, `make1j1fRequest`, `makeCpasmalRequest`, handshake Wiflix
  `makeWiflixSearchRequest`/`wiflixHandshake`, `classifyCloudflare429` worker-vs-site) ;
  CycleTLS + rotation ProxyScrape, ne throw jamais sur statut HTTP. Coflix/LecteurVideo
  quittent les Cloudflare Workers pour ProxyScrape.

## 📺 Live TV — remplacement de Sosplay/Vavoo par FCTV33

- Source sport **Sosplay → FCTV33** (API RBTV, protobuf brut) réécrite de bout en bout
  dans `liveTvRoutes.js`, **Vavoo retiré** (manifest/catalogue/stream) — tout passe par
  `/api/livetv/*`.
- Pipeline neuf : décodeur protobuf maison, signature `sfver` (bs keys + MD5),
  auto-découverte des domaines API/player tournants (`hubu.ru/fctvlink` + Nuxt SSR /
  `params` rot47, cache 30 min), scraper de matchs (`scrapeFctvMatches` :
  équipes/logos, ligue, pays, statut live, tri live-first), résolution de flux (HLS
  natif d'abord, embed en secours), token CDN (XOR keystream + base64, gate
  `rb-session`).
- Route `GET /api/livetv/fctv/playlist` : proxy m3u8 intelligent (résout le token,
  réécrit les segments, retry sur token périmé, modes `raw` extension / `proxy` VIP).
- **Matchs plus réservés aux VIP** (seul `iptv_` le reste) : flux natif proxifié pour
  les VIP, stub `_fctvLocal` résolu côté client par
  l'extension/userscript (`RESOLVE_FCTV`), fallback embed sinon.
- Front `LiveTV.tsx` : `matches` → `fctv`, UI accordéon par match (logos, badges
  ligue/pays, score/timer live, favori), disclaimer, fix icône `music`. Rotations
  d'hôtes : `witv.team`→`witv.football`, `livetv876.me`→`livetv901.me`.

## 🧩 Extensions · Userscript · Discord RPC

- **FCTV en lecture native** : handlers `SETUP_FCTV_HEADERS`/`RESOLVE_FCTV` (règle DNR
  Referer/Origin/UA sur `/cfall/s*` + résolveur local IP-bindé : protobuf-lite + rot47
  + token XOR).
- **Vavoo retiré** , `cinepulse.lol` → `purstream.mx`.
- Fix extracteur **Sibnet** (`Range: bytes=0-0`) ; fix bug userscript `matchToRegex`
  (patterns ancrés `^…$` cassant `/cfall/s` → dé-ancrés) ; override dev
  `localhost:25565`.
- Bumps de version (Chrome 1.3.3→1.3.8, Firefox 1.5.1→1.5.5, userscript 1.4.0→1.4.7) ;
  PreMid nouveau `clientId` Discord + `regExp` élargi (1.0.3→1.0.4).

## 🔑 OAuth (fournisseur « Se connecter avec Movix ») & codes d'accès

- Refonte du flux d'autorisation OAuth (`OAuthAuthorizePage.tsx`) + routes
  (`routes/authRoutes.js`, `routes/oauth.js`) et stockage des clients OAuth étoffé
  (`utils/oauthClientsDb.js`, `routes/adminOauthApps.js` pour la gestion admin des apps).
- `AccessCodeForm.tsx` retouché en cohérence avec le nouveau flux ; `checkVip.js` ajusté.
- Session centralisée : `accountAuth.ts` expose `clearStoredAuthSession()` /
  `broadcastAuthChange()` (liste `AUTH_KEYS` unifiée), désormais utilisés partout à la
  déconnexion.

## 💬 Modération de contenu (IA)

- Modèle OpenRouter `google/gemini-2.5-flash-lite` → `deepseek/deepseek-v4-flash`
  (`commentsRoutes.js`, `sharedListsRoutes.js`) ; fonctions/logs renommés
  `moderateContentWithGemini` → `moderateContentWithAI` (découplés du nom du modèle).
- Prompt durci (critères INSULTES / DEMANDE_AJOUT resserrés) pour éviter les faux
  positifs sur questions, signalements de bug et plaintes de contenu manquant.
- `AdminComments.tsx` : libellé « par Gemini » → clé i18n générique.

## 🧊 Hydracker / Darkiworld

- **Sortie du debrid** : AllDebrid/RealDebrid supprimés (`utils/hydrackerLive.js` :
  branche provider retirée ; vars `ALLDEBRID_*`/`REALDEBRID_*` sorties du `.env`) —
  hydracker renvoie directement `rawUrl`/`directDL`. Liens servis depuis les snapshots
  SQLite locaux (`utils/darkiworldSqlite.js`, `darkino-backups/`).
- **Anti-scraping** sur `/decode/:id` (`routes/darkiworld.js`) : watchlist + alerte
  webhook Discord (`DISCORD_SCRAPER_WEBHOOK`) et empoisonnement/blocage de réponse pour
  les IP listées (`SCRAPER_BLOCKED_IPS`).
- **Mémoire** : cache d'agents proxy borné (`utils/lruMap.js`) qui détruit les sockets
  des agents évincés.

## 🛠️ Admin — propriété des liens & équipe

- **Propriété des liens** : les liens streaming gagnent l'attribution `added_by` et
  l'ownership est appliquée des deux côtés (uploader = ses liens, admin = tous). GET
  public masqué (`stripLinkOwner`), `GET /admin/streaming-links/:type/:id` authentifié
  conserve le tag ; `DELETE /admin/links` par URL unique ; `PUT /admin/links` masse
  admin-only.
- **Équipe** : `GET/POST/DELETE /admin/admins` + `GET /admin/team/history` (audit),
  identité via `adminIdentity.js` (option `preferProfile` → premier profil Movix). UI
  `AdminTeam.tsx`.
- **Privilèges** (`middleware/auth.js`) : `isAdmin` rejette `role !== 'admin'` (403) —
  les uploaders ne passent plus les routes admin-only. Migration
  `add_role_to_admins.sql`.

## 🎁 Wrapped v2

- Cartes de partage : logique canvas extraite (`utils/wrappedCanvas.ts`,
  `utils/wrappedShareCards.ts`), formats de partage supplémentaires + preview.
- Nouvelles slides : jour record, répartition par jour de semaine, champion de rewatch,
  âge ciné (année de sortie médiane pondérée par le temps de visionnage).
- Perf : données depuis le payload backend (zéro re-fetch TMDB), trailer lazy,
  percentile non bloquant (réchauffe en arrière-plan, lock Redis `NX`), shuffle seedé.

## 🔐 Auth · Profils · VIP

- **Pseudo forcé** : `validateProfileName` (1–32 chars, strip control/zero-width, +
  `maxLength={32}` côté UI Profile/WatchParty) ; les pseudos OAuth trop longs →
  `requiresUsernameChange` + modale bloquante `RequireUsernameChange.tsx`.
- **Sync durci** (`ProfileContext.tsx`, `syncPolicy.js`) : anti-replay cross-user,
  filtrage des ops à clés non-syncables (`access_code`/`access_code_expires` exclues),
  « wipe guard » qui préserve le local sur profil vide inattendu, dédupe des
  `loadProfiles()` concurrents. Logout centralisé (voir OAuth).
- **VIP** : `is_vip` préservé au chargement de profil (corrige un downgrade VIP
  inter-onglets qui relançait la popup pub) ; `vipDonations.js` poll de secours
  `payment-status.php` (via `ipn_token`, colonne `paygate_ipn_token`) → plus d'invoices
  bloquées.

## ▶️ Lecteur (HLS)

- Basse latence opt-in : `isLowLatencyEnabled('movies')` (scope movies/livetv,
  `lowLatencyPref.ts`) au lieu du mode forcé ; `FloatingPlayer` aligné.
- Sous-titres cinep : wrapper m3u8 synthétique pour les URI `.vtt`/`.srt` directes,
  `isSubtitleLoadError` (une piste cassée ne tue plus la vidéo), rendu `<i>/<b>/<u>`.
- Menu sources regroupé par langue (VF/VFQ/VFF/VOSTFR).

## 📡 Cast (AirPlay / Chromecast)

- Retrait du contournement proxy darkibox (`buildApiProxyUrl`) → cast direct de l'URL ;
  `runtime.ts` supprime `API_PROXY_BASE_URL`/`buildApiProxyUrl` devenus morts.
- Paramètre `streamType` (`BUFFERED` par défaut) propagé jusqu'à
  `loadMediaOnCastWithFallback`.

## 📢 Pubs

- Modes de popup (`adPopupMode.ts`) : `normal`, `auto`, `click-anywhere` ; toggle « +18 »
  séparé (`adAdultMode.ts`). `SCRIPT_AD_MODE_ENABLED` → `false`, URL du script changée.

## 🔎 Recherche & priorité des sources

- `searchUtils.ts` : le terme original contourne le filtre « trop générique » (fix
  zéro-résultat sur titres courts : Baki, Gantz).
- `animeSignals.ts` : détection donghua (CN/TW/HK ou `zh` + Animation, hors C-drama).
- `sourceAutoSelect.ts` : `VFF`/`VFQ` normalisés en `vf` (sinon reléguées derrière
  VOSTFR). `sourcePriorityPrefs.ts` : schéma v2 → v3 (nouvel ordre par défaut + `j1f`,
  migration non destructive des ordres personnalisés). `extractionPrefs.ts` : +daddylive.

## 🔒 Sécurité

- Sanitisation d'URL avant rendu en `href` : `CommentsSection.tsx` (liens markdown
  `http(s)://`) et `Greenlight/AdminLinkSubmissions.tsx` — bloque les `javascript:`.

## ⚙️ Infra & perf

- **server.js** : recyclage périodique des workers (12h) + `[memstats]` + hook
  `SIGUSR2` → heap-snapshot.
- **app.js** : monte `/api/j1f`, retire la route statique `/oauth-icons`, passe
  `makeCpasmalRequest` au router Cpasmal, `coflix.date` → `coflix.trade`.
- **lazyWithRetry.ts / main.tsx / ErrorBoundary.tsx** : récupération chunk-load réécrite
  (retry + reload budgété), garde `removeChild/insertBefore`, écran soft « Mise à jour »
  au lieu du crash-report.
- **UI** : `TopProgressBar` écrit `transform` directement sur un ref (compositor-only,
  plus de re-render) ; `blur-text` relâche `will-change` après l'animation.
- **proxiesembed/server.py** : `curl_cffi` (JA3) pour `cinep-proxy` + retry SOCKS5 sur
  403 ; lookup clé VIP réaligné sur `checkVip.js` ; log WARNING par défaut ; SOCKS5
  forcé IPv4 ; wrapper VTT/SRT pour hls.js ; re-label `application/zip` → `video/mp2t` ;
  `cinepulse.lol` → `purstream.mx`.

## 🧹 Nettoyage & suppressions

- **Front** : carte promo « Loadix » sur Download (remplace l'encart beta),
  « 1jour1film » ajouté à « C'est quoi Movix », fallback Darkino/Nightflix gutté en
  no-op (`api.movix.chat` décommissionné), classes CSS mortes retirées, fix
  `fetchPriority` → `fetchpriority`.
- **Backend** : scrapers/utilitaires morts retirés — `routes/dragiv.js`,
  `routes/topstream.js`, `routes/proxy.js`, `utils/cloudflareSession.js`,
  `utils/hydrackerBatch.js`, `utils/hydrackerQueue.js'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 18:22:13 +02:00

2120 lines
81 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
getOAuthClientPublicMetadata,
resolveClientRedirectUri,
normalizeRequestedScopes,
} = require('../utils/oauthClients');
const {
ensureOAuthStorage,
createAuthorizationCode,
exchangeAuthorizationCode,
getAccessTokenRecord,
registerAuthorizationRequest,
claimAuthorizationRequest,
AUTHORIZATION_CODE_TTL_MS,
ACCESS_TOKEN_TTL_MS,
createOAuthStorageError,
} = require('../utils/oauthStorage');
const { readUserData, writeUserData, readProfileData, writeProfileData, withProfileSyncLock } = require('./sync');
const { recordEvent: recordOAuthAppEvent, grantVip: grantVipFromAppBalance } = require('../utils/oauthClientsDb');
const { verifyAccessKey } = require('../checkVip');
const { ensureSafeProfileId, getProfileFilePath, validateProfileName, SyncPolicyError } = require('../utils/syncPolicy');
const { v4: uuidv4 } = require('uuid');
const {
createVipInvoice,
fetchInvoiceByPublicId,
listUserVipInvoices,
refreshInvoiceStatus,
serializePublicInvoice,
} = require('../utils/vipDonations');
const router = express.Router();
// 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.' },
});
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.' },
});
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';
function logOauthDebug(message, payload) {
if (!OAUTH_DEBUG_ENABLED) {
return;
}
if (typeof payload === 'undefined') {
console.log(`[OAuth Debug] ${message}`);
return;
}
console.log(`[OAuth Debug] ${message}`, payload);
}
function sendOauthJsonError(res, statusCode, oauthError, description) {
return res.status(statusCode).json({
error: oauthError,
error_description: description,
});
}
function getAuthorizationBearerToken(req) {
const authHeader = req.headers.authorization || req.headers.Authorization || '';
if (!authHeader || typeof authHeader !== 'string' || !authHeader.toLowerCase().startsWith('bearer ')) {
return null;
}
return authHeader.slice(7).trim() || null;
}
function normalizeCodeChallengeMethod(rawValue) {
const method = String(rawValue || '').trim().toUpperCase();
if (!method) {
return null;
}
if (method === 'S256') {
return method;
}
throw createOAuthStorageError('Méthode PKCE non supportée', 400, 'invalid_request');
}
function normalizeCodeChallenge(rawValue, method, required) {
const value = typeof rawValue === 'string' ? rawValue.trim() : '';
if (!value) {
if (required) {
throw createOAuthStorageError('code_challenge requis pour ce client', 400, 'invalid_request');
}
return null;
}
if (!method) {
throw createOAuthStorageError('code_challenge_method requis quand code_challenge est fourni', 400, 'invalid_request');
}
if (value.length < 43 || value.length > 128) {
throw createOAuthStorageError('code_challenge invalide', 400, 'invalid_request');
}
return value;
}
function parseAuthorizeRequest(rawValues = {}) {
const clientId = String(rawValues.client_id || rawValues.clientId || '').trim();
const responseType = String(rawValues.response_type || rawValues.responseType || '').trim() || 'code';
const redirectUriInput = rawValues.redirect_uri || rawValues.redirectUri || '';
const state = typeof rawValues.state === 'string' ? rawValues.state : rawValues.state != null ? String(rawValues.state) : '';
if (state && (state.length < 8 || state.length > 512)) {
throw createOAuthStorageError('state doit contenir entre 8 et 512 caractères', 400, 'invalid_request');
}
// 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) {
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');
}
const resolvedClientId = client.clientId;
const redirectUri = resolveClientRedirectUri(client, redirectUriInput);
const scopes = normalizeRequestedScopes(rawValues.scope, client);
const codeChallengeMethod = normalizeCodeChallengeMethod(rawValues.code_challenge_method || rawValues.codeChallengeMethod);
const codeChallenge = normalizeCodeChallenge(rawValues.code_challenge || rawValues.codeChallenge, codeChallengeMethod, client.requirePkce);
if ((codeChallenge || client.requirePkce) && !codeChallengeMethod) {
throw createOAuthStorageError('code_challenge_method requis pour ce client', 400, 'invalid_request');
}
return {
client,
clientId: resolvedClientId,
redirectUri,
scopes,
state,
responseType,
codeChallenge,
codeChallengeMethod,
};
}
function buildRedirectUri(redirectUri, queryParams) {
const url = new URL(redirectUri);
Object.entries(queryParams).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') {
return;
}
url.searchParams.set(key, String(value));
});
return url.toString();
}
function hasScope(record, scope) {
const directScopes = Array.isArray(record?.scopes) ? record.scopes : [];
if (directScopes.includes(scope)) {
return true;
}
return directScopes.some((grantedScope) => {
const impliedScopes = OAUTH_SCOPE_IMPLICATIONS[grantedScope] || [];
return impliedScopes.includes(scope);
});
}
function parseStoredAuth(rawValue) {
if (typeof rawValue !== 'string' || !rawValue.trim()) {
return null;
}
try {
const parsed = JSON.parse(rawValue);
return parsed && typeof parsed === 'object' ? parsed : null;
} catch {
return null;
}
}
function getDefaultProfile(profiles) {
if (!Array.isArray(profiles) || profiles.length === 0) {
return null;
}
return profiles.find((profile) => profile && profile.isDefault) || profiles[0] || null;
}
function sanitizeOauthString(value, maxLength) {
if (typeof value !== 'string') return null;
return value.trim().slice(0, maxLength).replace(/[<>"']/g, '') || null;
}
function sanitizeOauthAvatarUrl(value) {
if (typeof value !== 'string' || !value.trim()) return null;
const trimmed = value.trim();
if (trimmed.startsWith('/avatars/')) return trimmed;
try {
const parsed = new URL(trimmed);
if (parsed.protocol === 'https:') return parsed.toString();
} catch { /* invalid URL */ }
return null;
}
function buildUserIdentity(userType, userId, userData) {
const storedAuth = parseStoredAuth(userData?.auth);
const storedProfile = storedAuth?.userProfile && typeof storedAuth.userProfile === 'object'
? storedAuth.userProfile
: null;
const defaultProfile = getDefaultProfile(userData?.profiles);
const fallbackName = userType === 'bip39'
? `Utilisateur-${String(userId).slice(0, 8)}`
: `Movix-${String(userId).slice(0, 8)}`;
const fallbackAvatar = 'https://as2.ftcdn.net/v2/jpg/05/89/93/27/1000_F_589932782_vQAEAZhHnq1QCGu5ikwrYaQD0Mmurm0N.webp';
const rawUsername = storedProfile?.username || defaultProfile?.name || fallbackName;
const rawAvatar = storedProfile?.avatar || defaultProfile?.avatar || fallbackAvatar;
return {
id: String(userId),
username: sanitizeOauthString(rawUsername, 100) || fallbackName,
avatar: sanitizeOauthAvatarUrl(rawAvatar) || fallbackAvatar,
provider: storedProfile?.provider || storedAuth?.provider || userType,
};
}
// 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);
}
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);
// 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,
userData,
identity,
vip,
};
}
function getRequestBaseUrl(req) {
const forwardedProto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim();
const forwardedHost = String(req.headers['x-forwarded-host'] || '').split(',')[0].trim();
const protocol = forwardedProto || req.protocol || 'https';
const host = forwardedHost || req.get('host') || '';
return host ? `${protocol}://${host}`.replace(/\/+$/, '') : '';
}
function canAccessVipInvoice(invoice, tokenRecord) {
if (!invoice || !tokenRecord) {
return false;
}
return String(invoice.created_by_user_id || '') === String(tokenRecord.userId)
&& String(invoice.created_by_user_type || '') === String(tokenRecord.userType);
}
async function getOauthTokenAuth(req, requiredScopes = []) {
const accessToken = getAuthorizationBearerToken(req);
if (!accessToken) {
throw createOAuthStorageError('Token OAuth requis', 401, 'invalid_token');
}
const pool = getPool();
const tokenRecord = await getAccessTokenRecord(pool, accessToken, { touch: true });
if (!tokenRecord) {
throw createOAuthStorageError('Token OAuth invalide ou expiré', 401, 'invalid_token');
}
const missingScopes = requiredScopes.filter((scope) => !hasScope(tokenRecord, scope));
if (missingScopes.length > 0) {
const error = createOAuthStorageError(`Permission OAuth manquante: ${missingScopes.join(', ')}`, 403, 'insufficient_scope');
error.missingScopes = missingScopes;
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;
}
router.get('/authorize/preview', oauthPreviewLimiter, async (req, res) => {
try {
logOauthDebug('Received /authorize/preview request', {
url: req.originalUrl,
clientId: req.query?.client_id || req.query?.clientId || null,
redirectUri: req.query?.redirect_uri || req.query?.redirectUri || null,
responseType: req.query?.response_type || req.query?.responseType || null,
scope: req.query?.scope || null,
state: req.query?.state || null,
codeChallengeMethod: req.query?.code_challenge_method || req.query?.codeChallengeMethod || null,
hasCodeChallenge: Boolean(req.query?.code_challenge || req.query?.codeChallenge),
origin: req.get('origin') || null,
referer: req.get('referer') || null,
userAgent: req.get('user-agent') || null,
});
const pool = getPool();
await ensureOAuthStorage(pool);
const authorizeRequest = parseAuthorizeRequest(req.query || {});
logOauthDebug('Parsed authorize preview request', {
clientId: authorizeRequest.clientId,
redirectUri: authorizeRequest.redirectUri,
scopes: authorizeRequest.scopes,
responseType: authorizeRequest.responseType,
statePresent: Boolean(authorizeRequest.state),
codeChallengeMethod: authorizeRequest.codeChallengeMethod,
hasCodeChallenge: Boolean(authorizeRequest.codeChallenge),
clientRedirectUris: authorizeRequest.client.redirectUris,
});
const requestRecord = await registerAuthorizationRequest(pool, authorizeRequest);
if (requestRecord?.consumedAt) {
logOauthDebug('Authorize preview request already consumed', {
clientId: authorizeRequest.clientId,
redirectUri: authorizeRequest.redirectUri,
});
return res.status(400).json({
success: false,
error: 'invalid_grant',
error_description: 'Cette demande OAuth a déjà été utilisée. Regénérez une nouvelle demande depuis Movix Translate.',
client: getOAuthClientPublicMetadata(authorizeRequest.client),
});
}
return res.json({
success: true,
client: getOAuthClientPublicMetadata(authorizeRequest.client),
request: {
clientId: authorizeRequest.clientId,
redirectUri: authorizeRequest.redirectUri,
scopes: authorizeRequest.scopes,
state: authorizeRequest.state,
requiresPkce: authorizeRequest.client.requirePkce,
codeChallengeMethod: authorizeRequest.codeChallengeMethod,
codeChallengeProvided: Boolean(authorizeRequest.codeChallenge),
codeExpiresInMs: AUTHORIZATION_CODE_TTL_MS,
accessTokenExpiresInMs: ACCESS_TOKEN_TTL_MS,
},
});
} catch (error) {
logOauthDebug('Authorize preview failed', {
message: error.message,
statusCode: error.statusCode || 400,
oauthError: error.oauthError || 'invalid_request',
clientId: req.query?.client_id || req.query?.clientId || null,
redirectUri: req.query?.redirect_uri || req.query?.redirectUri || null,
scope: req.query?.scope || null,
});
let clientMetadata = null;
try {
const clientId = String(req.query?.client_id || req.query?.clientId || '').trim();
const client = getOAuthClient(clientId);
if (client) {
clientMetadata = getOAuthClientPublicMetadata(client);
}
} catch {
// Ignore client resolution errors in error handler.
}
return res.status(error.statusCode || 400).json({
success: false,
error: error.oauthError || 'invalid_request',
error_description: error.message || 'Requête OAuth invalide',
...(clientMetadata ? { client: clientMetadata } : {}),
});
}
});
router.post('/authorize/decision', oauthPreviewLimiter, async (req, res) => {
let connection = null;
try {
const auth = await getAuthIfValid(req);
if (!auth) {
return sendOauthJsonError(res, 401, 'unauthorized', 'Authentification Movix requise');
}
const authorizeRequest = parseAuthorizeRequest(req.body || {});
const approve = req.body?.approve === true || req.body?.approve === 'true' || req.body?.decision === 'approve';
const pool = getPool();
await ensureOAuthStorage(pool);
await registerAuthorizationRequest(pool, authorizeRequest);
connection = await pool.getConnection();
await connection.beginTransaction();
await claimAuthorizationRequest(connection, authorizeRequest, approve ? 'approved' : 'rejected');
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,
redirectTo: buildRedirectUri(authorizeRequest.redirectUri, {
error: 'access_denied',
error_description: 'Lutilisateur a refusé la demande daccès',
state: authorizeRequest.state,
}),
});
}
const authorizationCode = await createAuthorizationCode(connection, {
clientId: authorizeRequest.clientId,
userId: auth.userId,
userType: auth.userType,
sessionId: auth.sessionId || null,
scopes: authorizeRequest.scopes,
redirectUri: authorizeRequest.redirectUri,
codeChallenge: authorizeRequest.codeChallenge,
codeChallengeMethod: authorizeRequest.codeChallengeMethod,
});
await connection.commit();
recordOAuthAppEvent(
authorizeRequest.clientId,
'authorize_granted',
`${auth.userType}:${auth.userId}`,
{ scopes: authorizeRequest.scopes },
).catch(() => { /* swallow */ });
return res.json({
success: true,
approved: true,
redirectTo: buildRedirectUri(authorizeRequest.redirectUri, {
code: authorizationCode.code,
state: authorizeRequest.state,
}),
expiresAt: authorizationCode.expiresAt,
});
} catch (error) {
if (connection) {
try {
await connection.rollback();
} catch {
// Ignore rollback failures.
}
}
return sendOauthJsonError(
res,
error.statusCode || 400,
error.oauthError || 'invalid_request',
error.message || 'Impossible de traiter la décision OAuth'
);
} finally {
if (connection) {
connection.release();
}
}
});
router.post('/token', oauthTokenLimiter, async (req, res) => {
try {
const grantType = String(req.body?.grant_type || '').trim();
if (grantType !== 'authorization_code') {
return sendOauthJsonError(res, 400, 'unsupported_grant_type', 'Seul authorization_code est supporté');
}
const clientId = String(req.body?.client_id || '').trim();
const code = String(req.body?.code || '').trim();
if (!clientId || !code) {
return sendOauthJsonError(res, 400, 'invalid_request', 'client_id et code sont requis');
}
const client = getOAuthClient(clientId);
if (!client) {
return sendOauthJsonError(res, 401, 'invalid_client', 'Client OAuth inconnu');
}
const redirectUri = resolveClientRedirectUri(client, req.body?.redirect_uri || '');
const clientSecret = typeof req.body?.client_secret === 'string' ? req.body.client_secret : '';
const codeVerifier = typeof req.body?.code_verifier === 'string' ? req.body.code_verifier.trim() : '';
const pool = getPool();
await ensureOAuthStorage(pool);
const codeHash = crypto.createHash('sha256').update(code).digest('hex');
const [rows] = await pool.execute(
'SELECT * FROM oauth_authorization_codes WHERE code_hash = ? LIMIT 1',
[codeHash]
);
if (!rows.length) {
return sendOauthJsonError(res, 400, 'invalid_grant', 'Code dautorisation invalide');
}
const storedChallenge = rows[0].code_challenge || null;
const storedChallengeMethod = String(rows[0].code_challenge_method || '').trim().toUpperCase() || null;
const verifyPkce = () => {
if (!storedChallenge || storedChallengeMethod !== 'S256') {
return false;
}
if (!codeVerifier || codeVerifier.length < 43 || codeVerifier.length > 128) {
return false;
}
const digest = crypto.createHash('sha256').update(codeVerifier).digest('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
try {
return crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(storedChallenge));
} catch {
return false;
}
};
const secretIsValid = (() => {
if (!client.clientSecret || !clientSecret) {
return false;
}
const provided = Buffer.from(clientSecret);
const expected = Buffer.from(client.clientSecret);
if (provided.length !== expected.length) {
return false;
}
return crypto.timingSafeEqual(provided, expected);
})();
const pkceIsValid = verifyPkce();
if (client.publicClient || client.requirePkce) {
if (!pkceIsValid) {
return sendOauthJsonError(res, 400, 'invalid_grant', 'code_verifier invalide');
}
} else if (!secretIsValid && !pkceIsValid) {
return sendOauthJsonError(res, 401, 'invalid_client', 'client_secret ou PKCE invalide');
}
const tokenPayload = await exchangeAuthorizationCode(pool, {
clientId,
code,
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',
expires_in: Math.floor(ACCESS_TOKEN_TTL_MS / 1000),
scope: tokenPayload.scopes.join(' '),
});
} catch (error) {
return sendOauthJsonError(
res,
error.statusCode || 400,
error.oauthError || 'invalid_request',
error.message || 'Impossible déchanger le code OAuth'
);
}
});
router.get('/userinfo', async (req, res) => {
try {
const tokenRecord = await getOauthTokenAuth(req);
const accountPayload = await getOauthAccountPayload(tokenRecord);
const hasProfileRead = hasScope(tokenRecord, 'profile.read');
const hasProfileList = hasScope(tokenRecord, 'profile.list');
const hasProfileManage = hasScope(tokenRecord, 'profile.manage');
const hasVipRead = hasScope(tokenRecord, 'vip.read');
const hasVipManage = hasScope(tokenRecord, 'vip.manage');
const response = {
sub: `${tokenRecord.userType}:${tokenRecord.userId}`,
user_type: tokenRecord.userType,
user_id: tokenRecord.userId,
client_id: tokenRecord.clientId,
scope: tokenRecord.scopes.join(' '),
scopes: [...tokenRecord.scopes],
permissions: {
profileRead: hasProfileRead,
profileList: hasProfileList,
profileManage: hasProfileManage,
vipRead: hasVipRead,
vipManage: hasVipManage,
},
capabilities: {
canReadProfile: hasProfileRead,
canListProfiles: hasProfileList || hasProfileManage,
canManageProfiles: hasProfileManage,
canReadVip: hasVipRead || hasVipManage,
canManageVip: hasVipManage,
},
};
if (hasProfileRead) {
response.preferred_username = accountPayload.identity.username;
response.picture = accountPayload.identity.avatar;
response.name = accountPayload.identity.username;
response.avatar = accountPayload.identity.avatar;
response.profile = {
username: accountPayload.identity.username,
avatar: accountPayload.identity.avatar,
};
}
if (hasProfileList || hasProfileManage) {
const profiles = accountPayload.userData?.profiles || [];
response.profiles = profiles.map((p) => ({
id: p.id,
name: p.name,
avatar: p.avatar,
ageRestriction: p.ageRestriction || 0,
isDefault: p.isDefault || false,
}));
}
if (hasVipRead || hasVipManage) {
response.vip = accountPayload.vip;
}
return res.json(response);
} catch (error) {
return sendOauthJsonError(
res,
error.statusCode || 401,
error.oauthError || 'invalid_token',
error.message || 'Impossible de récupérer le profil OAuth'
);
}
});
router.get('/vip/status', async (req, res) => {
try {
const tokenRecord = await getOauthTokenAuth(req, ['vip.read']);
const accountPayload = await getOauthAccountPayload(tokenRecord);
return res.json({
success: true,
vip: accountPayload.vip,
});
} catch (error) {
return sendOauthJsonError(
res,
error.statusCode || 401,
error.oauthError || 'invalid_token',
error.message || 'Impossible de récupérer le statut VIP'
);
}
});
router.get('/vip/invoices', async (req, res) => {
try {
const tokenRecord = await getOauthTokenAuth(req, ['vip.manage']);
const pool = getPool();
const invoices = await listUserVipInvoices(pool, tokenRecord, {
limit: req.query?.limit,
});
return res.json({
success: true,
invoices,
});
} catch (error) {
return sendOauthJsonError(
res,
error.statusCode || 401,
error.oauthError || 'invalid_token',
error.message || 'Impossible de récupérer les invoices VIP'
);
}
});
router.post('/vip/invoices', async (req, res) => {
try {
const tokenRecord = await getOauthTokenAuth(req, ['vip.manage']);
const pool = getPool();
const invoice = await createVipInvoice(
pool,
{
packEur: req.body?.pack_eur || req.body?.packEur,
paymentMethod: req.body?.payment_method || req.body?.paymentMethod,
coin: req.body?.coin,
recipientMode: req.body?.recipient_mode || req.body?.recipientMode,
payerEmail: req.body?.payer_email || req.body?.payerEmail,
},
{
auth: tokenRecord,
ipAddress: req.ip || req.connection?.remoteAddress || null,
callbackBaseUrl: getRequestBaseUrl(req),
}
);
return res.status(201).json({
success: true,
invoice: serializePublicInvoice(invoice),
});
} catch (error) {
return sendOauthJsonError(
res,
error.statusCode || 400,
error.oauthError || 'invalid_request',
error.message || 'Impossible de créer linvoice VIP'
);
}
});
router.get('/vip/invoices/:publicId', async (req, res) => {
try {
const tokenRecord = await getOauthTokenAuth(req, ['vip.manage']);
const pool = getPool();
const invoice = await fetchInvoiceByPublicId(pool, req.params.publicId);
if (!canAccessVipInvoice(invoice, tokenRecord)) {
return sendOauthJsonError(res, 404, 'not_found', 'Invoice introuvable');
}
return res.json({
success: true,
invoice: serializePublicInvoice(invoice),
});
} catch (error) {
return sendOauthJsonError(
res,
error.statusCode || 401,
error.oauthError || 'invalid_token',
error.message || 'Impossible de récupérer cette invoice VIP'
);
}
});
router.post('/vip/invoices/:publicId/check', async (req, res) => {
try {
const tokenRecord = await getOauthTokenAuth(req, ['vip.manage']);
const pool = getPool();
const invoice = await fetchInvoiceByPublicId(pool, req.params.publicId);
if (!canAccessVipInvoice(invoice, tokenRecord)) {
return sendOauthJsonError(res, 404, 'not_found', 'Invoice introuvable');
}
const refreshedInvoice = await refreshInvoiceStatus(pool, invoice, {
actorType: 'oauth_app',
actorId: tokenRecord.clientId,
});
return res.json({
success: true,
invoice: serializePublicInvoice(refreshedInvoice),
});
} catch (error) {
return sendOauthJsonError(
res,
error.statusCode || 502,
error.oauthError || 'server_error',
error.message || 'Vérification de paiement indisponible temporairement'
);
}
});
// ---------------------------------------------------------------------------
// Profile endpoints (scopes: profile.list, profile.manage)
// ---------------------------------------------------------------------------
function serializeProfile(profile) {
return {
id: profile.id,
name: profile.name,
avatar: profile.avatar,
ageRestriction: profile.ageRestriction || 0,
isDefault: profile.isDefault || false,
createdAt: profile.createdAt || null,
};
}
router.get('/profiles', async (req, res) => {
try {
const tokenRecord = await getOauthTokenAuth(req, ['profile.list']);
const userData = await readUserData(tokenRecord.userType, tokenRecord.userId);
const profiles = (userData.profiles || []).map(serializeProfile);
return res.json({ success: true, profiles });
} catch (error) {
return sendOauthJsonError(
res,
error.statusCode || 401,
error.oauthError || 'invalid_token',
error.message || 'Impossible de récupérer les profils'
);
}
});
router.get('/profiles/:profileId', async (req, res) => {
try {
const tokenRecord = await getOauthTokenAuth(req, ['profile.list']);
const profileId = ensureSafeProfileId(req.params.profileId);
const userData = await readUserData(tokenRecord.userType, tokenRecord.userId);
const profiles = userData.profiles || [];
const profile = profiles.find((p) => p.id === profileId);
if (!profile) {
return sendOauthJsonError(res, 404, 'not_found', 'Profil introuvable');
}
return res.json({ success: true, profile: serializeProfile(profile) });
} catch (error) {
return sendOauthJsonError(
res,
error.statusCode || 401,
error.oauthError || 'invalid_token',
error.message || 'Impossible de récupérer ce profil'
);
}
});
router.post('/profiles', async (req, res) => {
try {
const tokenRecord = await getOauthTokenAuth(req, ['profile.manage']);
const rawName = typeof req.body?.name === 'string' ? req.body.name : '';
const avatar = typeof req.body?.avatar === 'string' ? req.body.avatar.trim() : '';
if (!rawName || !avatar) {
return sendOauthJsonError(res, 400, 'invalid_request', 'name et avatar sont requis');
}
let safeName;
try {
safeName = validateProfileName(rawName);
} catch (e) {
if (e instanceof SyncPolicyError) {
return sendOauthJsonError(res, e.status, 'invalid_request', e.message);
}
throw e;
}
if (!avatar.startsWith('/avatars/')) {
return sendOauthJsonError(res, 400, 'invalid_request', 'avatar doit commencer par /avatars/');
}
const validAgeRestrictions = [0, 7, 12, 16, 18];
const ageRestriction = validAgeRestrictions.includes(Number(req.body?.ageRestriction))
? Number(req.body.ageRestriction)
: 0;
const userData = await readUserData(tokenRecord.userType, tokenRecord.userId);
const profiles = userData.profiles || [];
if (profiles.length >= 5) {
return sendOauthJsonError(res, 400, 'invalid_request', 'Maximum 5 profils autorisés');
}
const newProfile = {
id: uuidv4(),
name: safeName,
avatar,
ageRestriction,
createdAt: new Date().toISOString(),
isDefault: profiles.length === 0,
};
userData.profiles = [...profiles, newProfile];
userData.lastUpdated = Date.now();
const success = await writeUserData(tokenRecord.userType, tokenRecord.userId, userData);
if (!success) {
return sendOauthJsonError(res, 500, 'server_error', 'Impossible de créer le profil');
}
return res.status(201).json({ success: true, profile: serializeProfile(newProfile) });
} catch (error) {
return sendOauthJsonError(
res,
error.statusCode || 401,
error.oauthError || 'invalid_token',
error.message || 'Impossible de créer le profil'
);
}
});
router.put('/profiles/:profileId', async (req, res) => {
try {
const tokenRecord = await getOauthTokenAuth(req, ['profile.manage']);
const profileId = ensureSafeProfileId(req.params.profileId);
const rawName = typeof req.body?.name === 'string' ? req.body.name : '';
const avatar = typeof req.body?.avatar === 'string' ? req.body.avatar.trim() : '';
const ageRestriction = req.body?.ageRestriction;
if (!rawName && !avatar && ageRestriction === undefined) {
return sendOauthJsonError(res, 400, 'invalid_request', 'name, avatar ou ageRestriction requis');
}
if (avatar && !avatar.startsWith('/avatars/')) {
return sendOauthJsonError(res, 400, 'invalid_request', 'avatar doit commencer par /avatars/');
}
let safeName = null;
if (rawName) {
try {
safeName = validateProfileName(rawName);
} catch (e) {
if (e instanceof SyncPolicyError) {
return sendOauthJsonError(res, e.status, 'invalid_request', e.message);
}
throw e;
}
}
const userData = await readUserData(tokenRecord.userType, tokenRecord.userId);
const profiles = userData.profiles || [];
const profileIndex = profiles.findIndex((p) => p.id === profileId);
if (profileIndex === -1) {
return sendOauthJsonError(res, 404, 'not_found', 'Profil introuvable');
}
if (safeName) profiles[profileIndex].name = safeName;
if (avatar) profiles[profileIndex].avatar = avatar;
if (ageRestriction !== undefined) {
const validAgeRestrictions = [0, 7, 12, 16, 18];
profiles[profileIndex].ageRestriction = validAgeRestrictions.includes(Number(ageRestriction))
? Number(ageRestriction)
: 0;
}
userData.profiles = profiles;
userData.lastUpdated = Date.now();
const success = await writeUserData(tokenRecord.userType, tokenRecord.userId, userData);
if (!success) {
return sendOauthJsonError(res, 500, 'server_error', 'Impossible de mettre à jour le profil');
}
return res.json({ success: true, profile: serializeProfile(profiles[profileIndex]) });
} catch (error) {
return sendOauthJsonError(
res,
error.statusCode || 401,
error.oauthError || 'invalid_token',
error.message || 'Impossible de mettre à jour ce profil'
);
}
});
router.delete('/profiles/:profileId', async (req, res) => {
try {
const tokenRecord = await getOauthTokenAuth(req, ['profile.manage']);
const profileId = ensureSafeProfileId(req.params.profileId);
const { USERS_DIR } = require('./sync');
const userData = await readUserData(tokenRecord.userType, tokenRecord.userId);
const profiles = userData.profiles || [];
const profileIndex = profiles.findIndex((p) => p.id === profileId);
if (profileIndex === -1) {
return sendOauthJsonError(res, 404, 'not_found', 'Profil introuvable');
}
const isLastProfile = profiles.length <= 1;
const wasDefault = profiles[profileIndex].isDefault;
profiles.splice(profileIndex, 1);
if (isLastProfile) {
const newDefaultProfile = {
id: uuidv4(),
name: 'Profil',
avatar: '/avatars/disney/disney_avatar_1.png',
createdAt: new Date().toISOString(),
isDefault: true,
};
profiles.push(newDefaultProfile);
} else if (wasDefault && profiles.length > 0) {
profiles[0].isDefault = true;
}
userData.profiles = profiles;
userData.lastUpdated = Date.now();
const success = await writeUserData(tokenRecord.userType, tokenRecord.userId, userData);
if (!success) {
return sendOauthJsonError(res, 500, 'server_error', 'Impossible de supprimer le profil');
}
// Supprimer le fichier de données du profil
const fsp = require('fs').promises;
const profilePath = getProfileFilePath(USERS_DIR, tokenRecord.userType, tokenRecord.userId, profileId);
try { await fsp.unlink(profilePath); } catch { /* Fichier inexistant */ }
// Supprimer les votes associés
try {
const pool = getPool();
if (pool) {
await pool.execute(
'DELETE FROM likes WHERE user_id = ? AND user_type = ? AND profile_id = ?',
[tokenRecord.userId, tokenRecord.userType, profileId]
);
}
} catch { /* Ignorer */ }
return res.json({
success: true,
newDefaultProfile: isLastProfile ? serializeProfile(profiles[profiles.length - 1]) : null,
});
} catch (error) {
return sendOauthJsonError(
res,
error.statusCode || 401,
error.oauthError || 'invalid_token',
error.message || 'Impossible de supprimer ce profil'
);
}
});
// ────────────────────────────────────────────────────────────────────────────
// 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/<userType>/<userId>/<random>.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=<optional>
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=<optional>
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;