mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-07-26 16:32:07 +00:00
Modification du Oauth : Pouvoir manager les apps depuis le panel admin Correction de l'affichage des images des admins Majs des urls extension, userscript et premid Correction d'un crash sur les commentaires Ajout d'un light mode sur le frontend
456 lines
13 KiB
JavaScript
456 lines
13 KiB
JavaScript
/**
|
|
* 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_ENV = 'MOVIX_OAUTH_CLIENTS_JSON';
|
|
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';
|
|
|
|
// Préfixe public servant les icônes d'apps (relatif à l'API : `/oauth-icons/<filename>`).
|
|
// 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()) {
|
|
return fallback;
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(rawValue);
|
|
} catch (error) {
|
|
console.error('[OAuth Clients] Invalid JSON payload:', error.message || error);
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function normalizeHttpUrl(rawValue) {
|
|
if (typeof rawValue !== 'string' || !rawValue.trim()) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const parsed = new URL(rawValue.trim());
|
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
return null;
|
|
}
|
|
|
|
parsed.hash = '';
|
|
return parsed.toString();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isLoopbackHostname(hostname) {
|
|
const normalizedHostname = String(hostname || '').toLowerCase();
|
|
return normalizedHostname === 'localhost'
|
|
|| normalizedHostname === '127.0.0.1'
|
|
|| normalizedHostname === '::1'
|
|
|| normalizedHostname === '[::1]'
|
|
|| normalizedHostname.endsWith('.localhost');
|
|
}
|
|
|
|
function normalizeRedirectUri(rawValue) {
|
|
if (typeof rawValue !== 'string' || !rawValue.trim()) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const parsed = new URL(rawValue.trim());
|
|
const isLoopbackHost = isLoopbackHostname(parsed.hostname);
|
|
|
|
if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && isLoopbackHost)) {
|
|
return null;
|
|
}
|
|
|
|
parsed.hash = '';
|
|
return parsed.toString();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function normalizePathname(pathname) {
|
|
const normalizedPathname = String(pathname || '/').trim() || '/';
|
|
return normalizedPathname.replace(/\/+$/g, '') || '/';
|
|
}
|
|
|
|
function getLoopbackRedirectPaths(client) {
|
|
if (!client || !Array.isArray(client.redirectUris)) {
|
|
return [];
|
|
}
|
|
|
|
return uniqueStrings(
|
|
client.redirectUris
|
|
.map((redirectUri) => {
|
|
try {
|
|
const parsed = new URL(redirectUri);
|
|
return isLoopbackHostname(parsed.hostname) ? normalizePathname(parsed.pathname) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
})
|
|
.filter(Boolean)
|
|
);
|
|
}
|
|
|
|
function uniqueStrings(values) {
|
|
return Array.from(new Set(values.filter(Boolean)));
|
|
}
|
|
|
|
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 normalizeScopes(rawScopes) {
|
|
const sourceValues = Array.isArray(rawScopes)
|
|
? rawScopes
|
|
: typeof rawScopes === 'string'
|
|
? rawScopes.split(/\s+/)
|
|
: [];
|
|
|
|
return uniqueStrings(
|
|
sourceValues
|
|
.map((scope) => String(scope || '').trim())
|
|
.filter((scope) => KNOWN_OAUTH_SCOPES.includes(scope))
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const clientId = String(rawClient.clientId || '').trim();
|
|
const clientName = String(rawClient.clientName || '').trim();
|
|
const redirectUris = uniqueStrings(
|
|
(Array.isArray(rawClient.redirectUris) ? rawClient.redirectUris : [])
|
|
.map(normalizeRedirectUri)
|
|
.filter(Boolean)
|
|
);
|
|
|
|
if (!clientId || !clientName || redirectUris.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const clientSecret = typeof rawClient.clientSecret === 'string' && rawClient.clientSecret.trim()
|
|
? rawClient.clientSecret.trim()
|
|
: null;
|
|
const publicClient = rawClient.publicClient === true || !clientSecret;
|
|
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;
|
|
|
|
return {
|
|
clientId,
|
|
clientName,
|
|
clientSecret,
|
|
publicClient,
|
|
requirePkce,
|
|
redirectUris,
|
|
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 loadOAuthClients() {
|
|
// Source 1: env var (override dev/test).
|
|
const envRaw = process.env[OAUTH_CLIENTS_ENV] || '';
|
|
const fromEnv = envRaw ? safeJsonParse(envRaw, []) : [];
|
|
|
|
// Source 2: DB cache (source de vérité prod).
|
|
const fromDb = getCachedClients() || [];
|
|
|
|
const byClientId = new Map();
|
|
// 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;
|
|
byClientId.set(normalized.clientId, normalized);
|
|
});
|
|
|
|
return Array.from(byClientId.values());
|
|
}
|
|
|
|
function getOAuthClient(clientId) {
|
|
const normalizedClientId = String(clientId || '').trim();
|
|
if (!normalizedClientId) {
|
|
return null;
|
|
}
|
|
|
|
return loadOAuthClients().find((client) => client.clientId === normalizedClientId) || null;
|
|
}
|
|
|
|
function getOAuthClientPublicMetadata(client) {
|
|
if (!client) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
clientId: client.clientId,
|
|
clientName: client.clientName,
|
|
description: client.description,
|
|
homepageUrl: client.homepageUrl,
|
|
logoUrl: client.logoUrl,
|
|
iconUrl: client.iconUrl,
|
|
publicClient: client.publicClient,
|
|
requirePkce: client.requirePkce,
|
|
allowedScopes: [...client.allowedScopes],
|
|
redirectOrigins: uniqueStrings(
|
|
client.redirectUris
|
|
.map((redirectUri) => {
|
|
try {
|
|
return new URL(redirectUri).origin;
|
|
} catch {
|
|
return null;
|
|
}
|
|
})
|
|
.filter(Boolean)
|
|
),
|
|
};
|
|
}
|
|
|
|
function resolveClientRedirectUri(client, requestedRedirectUri) {
|
|
if (!client) {
|
|
const error = new Error('Client OAuth introuvable');
|
|
error.statusCode = 400;
|
|
error.oauthError = 'invalid_client';
|
|
throw error;
|
|
}
|
|
|
|
if (!requestedRedirectUri) {
|
|
if (client.redirectUris.length === 1) {
|
|
logOauthDebug('redirect_uri absent, using the only registered redirect URI', {
|
|
clientId: client.clientId,
|
|
redirectUri: client.redirectUris[0],
|
|
});
|
|
return client.redirectUris[0];
|
|
}
|
|
|
|
const loopbackPaths = getLoopbackRedirectPaths(client);
|
|
const allLoopback = client.redirectUris.every((uri) => {
|
|
try {
|
|
return isLoopbackHostname(new URL(uri).hostname);
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
|
|
if (allLoopback && loopbackPaths.length === 1) {
|
|
logOauthDebug('redirect_uri absent, all registered URIs are loopback with same path, using first', {
|
|
clientId: client.clientId,
|
|
redirectUri: client.redirectUris[0],
|
|
loopbackPath: loopbackPaths[0],
|
|
});
|
|
return client.redirectUris[0];
|
|
}
|
|
}
|
|
|
|
const normalizedRedirectUri = normalizeRedirectUri(requestedRedirectUri);
|
|
logOauthDebug('Validating redirect_uri', {
|
|
clientId: client.clientId,
|
|
requestedRedirectUri,
|
|
normalizedRedirectUri,
|
|
registeredRedirectUris: client.redirectUris,
|
|
loopbackRedirectPaths: getLoopbackRedirectPaths(client),
|
|
});
|
|
|
|
if (!normalizedRedirectUri) {
|
|
logOauthDebug('redirect_uri rejected because normalization failed', {
|
|
clientId: client.clientId,
|
|
requestedRedirectUri,
|
|
});
|
|
const error = new Error('redirect_uri non autorisée');
|
|
error.statusCode = 400;
|
|
error.oauthError = 'invalid_request';
|
|
throw error;
|
|
}
|
|
|
|
if (client.redirectUris.includes(normalizedRedirectUri)) {
|
|
logOauthDebug('redirect_uri matched an exact registered URI', {
|
|
clientId: client.clientId,
|
|
normalizedRedirectUri,
|
|
});
|
|
return normalizedRedirectUri;
|
|
}
|
|
|
|
let isLoopbackPathAllowed = false;
|
|
try {
|
|
const parsedRequestedRedirectUri = new URL(normalizedRedirectUri);
|
|
if (isLoopbackHostname(parsedRequestedRedirectUri.hostname)) {
|
|
const requestedPathname = normalizePathname(parsedRequestedRedirectUri.pathname);
|
|
const loopbackRedirectPaths = getLoopbackRedirectPaths(client);
|
|
|
|
if (loopbackRedirectPaths.includes(requestedPathname)) {
|
|
isLoopbackPathAllowed = true;
|
|
logOauthDebug('redirect_uri accepted via loopback path match', {
|
|
clientId: client.clientId,
|
|
requestedRedirectUri: normalizedRedirectUri,
|
|
requestedPathname,
|
|
loopbackRedirectPaths,
|
|
});
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore malformed URLs already rejected above.
|
|
}
|
|
|
|
if (isLoopbackPathAllowed) {
|
|
return normalizedRedirectUri;
|
|
}
|
|
|
|
if (!client.redirectUris.includes(normalizedRedirectUri)) {
|
|
logOauthDebug('redirect_uri rejected after validation', {
|
|
clientId: client.clientId,
|
|
requestedRedirectUri: normalizedRedirectUri,
|
|
registeredRedirectUris: client.redirectUris,
|
|
});
|
|
const error = new Error('redirect_uri non autorisée');
|
|
error.statusCode = 400;
|
|
error.oauthError = 'invalid_request';
|
|
throw error;
|
|
}
|
|
|
|
return normalizedRedirectUri;
|
|
}
|
|
|
|
function normalizeRequestedScopes(requestedScopes, client) {
|
|
if (!client) {
|
|
const error = new Error('Client OAuth introuvable');
|
|
error.statusCode = 400;
|
|
error.oauthError = 'invalid_client';
|
|
throw error;
|
|
}
|
|
|
|
const scopes = normalizeScopes(requestedScopes);
|
|
const normalizedScopes = scopes.length > 0
|
|
? scopes
|
|
: client.allowedScopes.includes(DEFAULT_SCOPE)
|
|
? [DEFAULT_SCOPE]
|
|
: client.allowedScopes.slice(0, 1);
|
|
|
|
if (normalizedScopes.length === 0) {
|
|
const error = new Error('Aucune permission OAuth disponible pour ce client');
|
|
error.statusCode = 400;
|
|
error.oauthError = 'invalid_scope';
|
|
throw error;
|
|
}
|
|
|
|
const invalidScopes = normalizedScopes.filter((scope) => !client.allowedScopes.includes(scope));
|
|
if (invalidScopes.length > 0) {
|
|
const error = new Error(`Scopes non autorisées: ${invalidScopes.join(', ')}`);
|
|
error.statusCode = 400;
|
|
error.oauthError = 'invalid_scope';
|
|
throw error;
|
|
}
|
|
|
|
return normalizedScopes;
|
|
}
|
|
|
|
function getOAuthAllowedCorsOrigins() {
|
|
const origins = new Set();
|
|
|
|
loadOAuthClients().forEach((client) => {
|
|
client.redirectUris.forEach((redirectUri) => {
|
|
try {
|
|
origins.add(new URL(redirectUri).origin);
|
|
} catch {
|
|
// Ignore malformed values already filtered during normalization.
|
|
}
|
|
});
|
|
});
|
|
|
|
return Array.from(origins);
|
|
}
|
|
|
|
module.exports = {
|
|
KNOWN_OAUTH_SCOPES,
|
|
DEFAULT_SCOPE,
|
|
loadOAuthClients,
|
|
getOAuthClient,
|
|
getOAuthClientPublicMetadata,
|
|
resolveClientRedirectUri,
|
|
normalizeRequestedScopes,
|
|
getOAuthAllowedCorsOrigins,
|
|
};
|