mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-07-27 05:02:08 +00:00
1031 lines
33 KiB
JavaScript
1031 lines
33 KiB
JavaScript
const express = require('express');
|
||
const crypto = require('crypto');
|
||
const rateLimit = require('express-rate-limit');
|
||
|
||
const { getAuthIfValid } = require('../middleware/auth');
|
||
const { getPool } = require('../mysqlPool');
|
||
const {
|
||
getOAuthClient,
|
||
loadOAuthClients,
|
||
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 } = require('./sync');
|
||
const { verifyAccessKey } = require('../checkVip');
|
||
const { ensureSafeProfileId, getProfileFilePath } = require('../utils/syncPolicy');
|
||
const { v4: uuidv4 } = require('uuid');
|
||
const {
|
||
createVipInvoice,
|
||
fetchInvoiceByPublicId,
|
||
listUserVipInvoices,
|
||
refreshInvoiceStatus,
|
||
serializePublicInvoice,
|
||
} = require('../utils/vipDonations');
|
||
|
||
const router = express.Router();
|
||
|
||
const oauthRateLimitKey = (req) => req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for']?.split(',')[0].trim() || req.ip;
|
||
|
||
const oauthPreviewLimiter = rateLimit({
|
||
windowMs: 60 * 1000,
|
||
max: 30,
|
||
keyGenerator: oauthRateLimitKey,
|
||
standardHeaders: true,
|
||
legacyHeaders: 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,
|
||
standardHeaders: true,
|
||
legacyHeaders: 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'],
|
||
};
|
||
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');
|
||
}
|
||
|
||
const availableClients = loadOAuthClients();
|
||
const fallbackClient = !clientId && availableClients.length === 1 ? availableClients[0] : null;
|
||
const client = getOAuthClient(clientId) || fallbackClient;
|
||
|
||
if (!clientId) {
|
||
if (!client) {
|
||
throw createOAuthStorageError('client_id requis', 400, 'invalid_request');
|
||
}
|
||
}
|
||
|
||
if (responseType !== 'code') {
|
||
throw createOAuthStorageError('Seul response_type=code est supporté', 400, 'unsupported_response_type');
|
||
}
|
||
|
||
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,
|
||
};
|
||
}
|
||
|
||
async function buildVipIdentity(userData) {
|
||
const accessKey = typeof userData?.access_code === 'string' ? userData.access_code.trim() : '';
|
||
if (accessKey) {
|
||
const verified = await verifyAccessKey(accessKey);
|
||
return {
|
||
active: verified.vip === true,
|
||
expiresAt: verified.expiresAt || null,
|
||
duration: verified.duration || null,
|
||
};
|
||
}
|
||
|
||
return {
|
||
active: userData?.is_vip === true || userData?.is_vip === 'true',
|
||
expiresAt: typeof userData?.access_code_expires === 'string' ? userData.access_code_expires : null,
|
||
duration: null,
|
||
};
|
||
}
|
||
|
||
async function getOauthAccountPayload(record) {
|
||
const userData = await readUserData(record.userType, record.userId);
|
||
const identity = buildUserIdentity(record.userType, record.userId, userData);
|
||
const vip = await buildVipIdentity(userData);
|
||
|
||
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;
|
||
}
|
||
|
||
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();
|
||
return res.json({
|
||
success: true,
|
||
approved: false,
|
||
redirectTo: buildRedirectUri(authorizeRequest.redirectUri, {
|
||
error: 'access_denied',
|
||
error_description: 'L’utilisateur a refusé la demande d’accè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();
|
||
|
||
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 d’autorisation 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,
|
||
});
|
||
|
||
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 l’invoice 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 name = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
|
||
const avatar = typeof req.body?.avatar === 'string' ? req.body.avatar.trim() : '';
|
||
|
||
if (!name || !avatar) {
|
||
return sendOauthJsonError(res, 400, 'invalid_request', 'name et avatar sont requis');
|
||
}
|
||
|
||
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,
|
||
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 name = typeof req.body?.name === 'string' ? req.body.name.trim() : '';
|
||
const avatar = typeof req.body?.avatar === 'string' ? req.body.avatar.trim() : '';
|
||
const ageRestriction = req.body?.ageRestriction;
|
||
|
||
if (!name && !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/');
|
||
}
|
||
|
||
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 (name) profiles[profileIndex].name = name;
|
||
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'
|
||
);
|
||
}
|
||
});
|
||
|
||
module.exports = router;
|