MovixOpenSource/API/Mainapi/routes/adminOauthApps.js
Movix 2045dbd2c9 Nouvelle adresse movix + nouvelles fonctionalités
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
2026-05-14 17:26:13 +02:00

604 lines
22 KiB
JavaScript

/**
* Routes admin pour gérer les applications OAuth Movix.
* Mount : `app.use('/api/admin/oauth-apps', adminOauthAppsRouter)`.
*
* Toutes les routes sont protégées par `isAdmin` (table `admins`).
* Source de vérité : la table `oauth_clients` (alimentée au boot par
* `oauthClientsDb.reloadCache()`). Toute mutation appelle `reloadCache()`
* en fin de requête pour rafraîchir le cache du worker courant.
*
* Note multi-worker : chaque worker a son propre cache in-process. Une
* mutation depuis le worker A ne rafraîchit pas le cache du worker B
* immédiatement. C'est acceptable car :
* 1) les opérations admin sont rares ;
* 2) le cache est rechargé au boot ;
* 3) une lecture stale max 1 requête.
* Si besoin d'invalidation cross-worker → publier un message Redis.
*/
const express = require('express');
const crypto = require('crypto');
const path = require('path');
const fs = require('fs');
const fsp = require('fs').promises;
const rateLimit = require('express-rate-limit');
const { ipKeyGenerator } = require('express-rate-limit');
const { isAdmin } = require('../middleware/auth');
const { getPool } = require('../mysqlPool');
const oauthClientsDb = require('../utils/oauthClientsDb');
const { KNOWN_OAUTH_SCOPES } = require('../utils/oauthClients');
const { createRedisRateLimitStore } = require('../utils/redisRateLimitStore');
const router = express.Router();
const ALLOWED_ICON_MIME = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/webp': 'webp',
};
const MAX_ICON_SIZE_BYTES = 256 * 1024; // 256 KB
const CLIENT_ID_RE = /^[a-z0-9][a-z0-9-]{1,64}$/;
// Petit rate-limiter pour les routes admin OAuth (anti-bruteforce sur les secrets).
const adminOauthAppsLimiter = rateLimit({
windowMs: 60 * 1000,
max: 60,
store: createRedisRateLimitStore({ prefix: 'rate-limit:admin:oauth-apps:' }),
passOnStoreError: true,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) =>
req.headers['cf-connecting-ip']
|| req.headers['x-forwarded-for']?.split(',')[0].trim()
|| ipKeyGenerator(req.ip),
validate: { xForwardedForHeader: false, ip: false },
});
router.use(adminOauthAppsLimiter);
router.use(isAdmin);
// ─── helpers ────────────────────────────────────────────────────────────
function badRequest(res, message) {
return res.status(400).json({ success: false, error: message });
}
function notFound(res, message = 'Application OAuth introuvable') {
return res.status(404).json({ success: false, error: message });
}
function serverError(res, error, message = 'Erreur serveur') {
console.error('[adminOauthApps]', message, error?.message || error);
return res.status(500).json({ success: false, error: message });
}
function sanitizeClientId(raw) {
const value = String(raw || '').trim().toLowerCase();
return CLIENT_ID_RE.test(value) ? value : null;
}
function sanitizeClientName(raw) {
if (typeof raw !== 'string') return null;
const value = raw.trim().slice(0, 200);
return value.length >= 2 ? value : null;
}
function sanitizeDescription(raw) {
if (raw == null || raw === '') return null;
if (typeof raw !== 'string') return null;
return raw.trim().slice(0, 2000) || null;
}
function sanitizeHttpUrl(raw) {
if (raw == null || raw === '') return null;
if (typeof raw !== 'string') return null;
try {
const url = new URL(raw.trim());
// HTTPS only : le homepageUrl est rendu en lien cliquable sur la page
// d'autorisation OAuth (boundary de confiance pour l'utilisateur).
// Pas d'exception loopback ici — c'est pour le marketing, pas pour OAuth.
if (url.protocol !== 'https:') return null;
url.hash = '';
return url.toString();
} catch {
return null;
}
}
function sanitizeRedirectUris(rawArray) {
if (!Array.isArray(rawArray)) return null;
const result = [];
for (const raw of rawArray) {
if (typeof raw !== 'string') continue;
try {
const url = new URL(raw.trim());
const host = url.hostname.toLowerCase();
const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '::1' || host.endsWith('.localhost');
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLoopback)) {
continue;
}
url.hash = '';
result.push(url.toString());
} catch { /* skip */ }
}
return result.length > 0 ? Array.from(new Set(result)) : null;
}
function sanitizeScopes(rawArray) {
if (!Array.isArray(rawArray)) return null;
const result = Array.from(new Set(
rawArray
.map((s) => String(s || '').trim())
.filter((s) => KNOWN_OAUTH_SCOPES.includes(s)),
));
return result.length > 0 ? result : null;
}
function generateClientSecret() {
// 64 chars hex = 256 bits — assez pour un secret OAuth.
return crypto.randomBytes(32).toString('hex');
}
function serializeAppRow(row) {
return {
id: Number(row.id),
clientId: row.client_id,
clientName: row.client_name,
description: row.description || null,
homepageUrl: row.homepage_url || null,
redirectUris: safeJsonParse(row.redirect_uris, []),
allowedScopes: safeJsonParse(row.allowed_scopes, []),
publicClient: row.public_client === 1 || row.public_client === true,
requirePkce: row.require_pkce === 1 || row.require_pkce === true,
hasClientSecret: !!row.client_secret,
iconFilename: row.icon_filename || null,
iconUrl: row.icon_filename ? `/oauth-icons/${row.icon_filename}` : null,
vipDaysBalance: Number(row.vip_days_balance || 0),
isActive: row.is_active === 1 || row.is_active === true,
createdAt: Number(row.created_at || 0),
updatedAt: Number(row.updated_at || 0),
};
}
function safeJsonParse(raw, fallback) {
if (typeof raw !== 'string' || !raw.trim()) {
return Array.isArray(raw) ? raw : fallback;
}
try {
return JSON.parse(raw);
} catch {
return fallback;
}
}
async function fetchAppByClientId(pool, clientId) {
const [rows] = await pool.execute(
'SELECT * FROM oauth_clients WHERE client_id = ? LIMIT 1',
[clientId],
);
return rows[0] || null;
}
async function removeIconFile(filename) {
if (!filename) return;
const target = path.join(oauthClientsDb.ICON_DIR, path.basename(filename));
try {
await fsp.unlink(target);
} catch {
/* swallow: déjà absent */
}
}
// ─── routes ─────────────────────────────────────────────────────────────
router.get('/scopes', (req, res) => {
res.json({ success: true, scopes: [...KNOWN_OAUTH_SCOPES] });
});
router.get('/', async (req, res) => {
try {
const pool = getPool();
const includeInactive = req.query?.inactive === '1' || req.query?.inactive === 'true';
const whereSql = includeInactive ? '' : 'WHERE is_active = 1';
const [rows] = await pool.execute(
`SELECT * FROM oauth_clients ${whereSql} ORDER BY created_at DESC`,
);
// Stats compactes par app (30 derniers jours) pour l'affichage en liste.
const since = Date.now() - 30 * 24 * 60 * 60 * 1000;
const [statsRows] = await pool.execute(
`SELECT client_id, event_type, COUNT(*) AS n
FROM oauth_app_stats
WHERE created_at >= ?
GROUP BY client_id, event_type`,
[since],
);
const statsByClient = new Map();
for (const r of statsRows) {
if (!statsByClient.has(r.client_id)) statsByClient.set(r.client_id, {});
statsByClient.get(r.client_id)[r.event_type] = Number(r.n);
}
const apps = rows.map((row) => ({
...serializeAppRow(row),
stats30d: statsByClient.get(row.client_id) || {},
}));
return res.json({ success: true, apps });
} catch (err) {
return serverError(res, err, 'Impossible de lister les applications');
}
});
router.get('/:clientId', async (req, res) => {
try {
const clientId = sanitizeClientId(req.params.clientId);
if (!clientId) return badRequest(res, 'clientId invalide');
const pool = getPool();
const row = await fetchAppByClientId(pool, clientId);
if (!row) return notFound(res);
return res.json({ success: true, app: serializeAppRow(row) });
} catch (err) {
return serverError(res, err, 'Impossible de charger l\'application');
}
});
router.post('/', async (req, res) => {
try {
const clientId = sanitizeClientId(req.body?.clientId);
if (!clientId) return badRequest(res, 'clientId invalide (a-z, 0-9, -, 2 à 65 caractères)');
const clientName = sanitizeClientName(req.body?.clientName);
if (!clientName) return badRequest(res, 'clientName requis (≥2 caractères)');
const redirectUris = sanitizeRedirectUris(req.body?.redirectUris);
if (!redirectUris) return badRequest(res, 'redirectUris requis (≥1 URI HTTPS ou loopback http)');
const allowedScopes = sanitizeScopes(req.body?.allowedScopes);
if (!allowedScopes) return badRequest(res, 'allowedScopes requis (≥1 scope connu)');
const description = sanitizeDescription(req.body?.description);
const homepageUrl = sanitizeHttpUrl(req.body?.homepageUrl);
const publicClient = req.body?.publicClient !== false;
const requirePkce = publicClient ? true : req.body?.requirePkce === true;
const generatedSecret = !publicClient ? generateClientSecret() : null;
const pool = getPool();
const existing = await fetchAppByClientId(pool, clientId);
if (existing) return badRequest(res, 'Cet clientId existe déjà');
const now = Date.now();
await pool.execute(
`INSERT INTO oauth_clients
(client_id, client_name, description, homepage_url, redirect_uris,
allowed_scopes, public_client, require_pkce, client_secret,
is_active, vip_days_balance, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, ?, ?)`,
[
clientId,
clientName,
description,
homepageUrl,
JSON.stringify(redirectUris),
JSON.stringify(allowedScopes),
publicClient ? 1 : 0,
requirePkce ? 1 : 0,
generatedSecret,
now,
now,
],
);
await oauthClientsDb.reloadCache();
const row = await fetchAppByClientId(pool, clientId);
const serialized = serializeAppRow(row);
// Le secret n'est exposé qu'UNE fois (à la création) — l'admin doit le copier.
return res.json({
success: true,
app: serialized,
clientSecret: generatedSecret, // null pour les clients publics
});
} catch (err) {
return serverError(res, err, 'Impossible de créer l\'application');
}
});
router.put('/:clientId', async (req, res) => {
try {
const clientId = sanitizeClientId(req.params.clientId);
if (!clientId) return badRequest(res, 'clientId invalide');
const pool = getPool();
const existing = await fetchAppByClientId(pool, clientId);
if (!existing) return notFound(res);
const updates = [];
const params = [];
if (req.body?.clientName !== undefined) {
const v = sanitizeClientName(req.body.clientName);
if (!v) return badRequest(res, 'clientName invalide');
updates.push('client_name = ?'); params.push(v);
}
if (req.body?.description !== undefined) {
updates.push('description = ?'); params.push(sanitizeDescription(req.body.description));
}
if (req.body?.homepageUrl !== undefined) {
updates.push('homepage_url = ?'); params.push(sanitizeHttpUrl(req.body.homepageUrl));
}
if (req.body?.redirectUris !== undefined) {
const v = sanitizeRedirectUris(req.body.redirectUris);
if (!v) return badRequest(res, 'redirectUris invalide (≥1 URI HTTPS ou loopback http)');
updates.push('redirect_uris = ?'); params.push(JSON.stringify(v));
}
if (req.body?.allowedScopes !== undefined) {
const v = sanitizeScopes(req.body.allowedScopes);
if (!v) return badRequest(res, 'allowedScopes invalide');
updates.push('allowed_scopes = ?'); params.push(JSON.stringify(v));
}
if (req.body?.publicClient !== undefined) {
const becomesPublic = req.body.publicClient === true;
updates.push('public_client = ?'); params.push(becomesPublic ? 1 : 0);
if (becomesPublic) {
// Switch confidential → public : on force pkce et on supprime le secret.
updates.push('require_pkce = 1');
updates.push('client_secret = NULL');
}
}
if (req.body?.requirePkce !== undefined) {
updates.push('require_pkce = ?'); params.push(req.body.requirePkce === true ? 1 : 0);
}
if (req.body?.isActive !== undefined) {
updates.push('is_active = ?'); params.push(req.body.isActive === true ? 1 : 0);
}
if (updates.length === 0) return badRequest(res, 'Aucun champ à mettre à jour');
updates.push('updated_at = ?'); params.push(Date.now());
params.push(clientId);
await pool.execute(
`UPDATE oauth_clients SET ${updates.join(', ')} WHERE client_id = ?`,
params,
);
await oauthClientsDb.reloadCache();
const row = await fetchAppByClientId(pool, clientId);
return res.json({ success: true, app: serializeAppRow(row) });
} catch (err) {
return serverError(res, err, 'Impossible de mettre à jour l\'application');
}
});
router.post('/:clientId/regenerate-secret', async (req, res) => {
try {
const clientId = sanitizeClientId(req.params.clientId);
if (!clientId) return badRequest(res, 'clientId invalide');
const pool = getPool();
const existing = await fetchAppByClientId(pool, clientId);
if (!existing) return notFound(res);
if (existing.public_client === 1 || existing.public_client === true) {
return badRequest(res, 'Les clients publics n\'utilisent pas de clientSecret');
}
const newSecret = generateClientSecret();
await pool.execute(
'UPDATE oauth_clients SET client_secret = ?, updated_at = ? WHERE client_id = ?',
[newSecret, Date.now(), clientId],
);
await oauthClientsDb.reloadCache();
return res.json({ success: true, clientSecret: newSecret });
} catch (err) {
return serverError(res, err, 'Impossible de régénérer le secret');
}
});
router.delete('/:clientId', async (req, res) => {
try {
const clientId = sanitizeClientId(req.params.clientId);
if (!clientId) return badRequest(res, 'clientId invalide');
const pool = getPool();
const existing = await fetchAppByClientId(pool, clientId);
if (!existing) return notFound(res);
// Hard delete : on supprime la ligne ; les stats et grants restent
// (FK absente volontairement — historique d'audit).
await pool.execute('DELETE FROM oauth_clients WHERE client_id = ?', [clientId]);
// Cleanup icône si présente.
if (existing.icon_filename) {
await removeIconFile(existing.icon_filename);
}
await oauthClientsDb.reloadCache();
return res.json({ success: true });
} catch (err) {
return serverError(res, err, 'Impossible de supprimer l\'application');
}
});
// Upload icône : JSON body { mimeType, dataBase64 }
// On évite multer pour ne pas ajouter une dépendance ; les icônes sont
// petites (< 256KB) donc base64 dans le body JSON est OK.
router.post('/:clientId/icon', async (req, res) => {
try {
const clientId = sanitizeClientId(req.params.clientId);
if (!clientId) return badRequest(res, 'clientId invalide');
const mimeType = String(req.body?.mimeType || '').trim().toLowerCase();
const ext = ALLOWED_ICON_MIME[mimeType];
if (!ext) return badRequest(res, 'mimeType non supporté (png / jpeg / webp)');
const dataBase64 = String(req.body?.dataBase64 || '');
if (!dataBase64) return badRequest(res, 'dataBase64 requis');
let buffer;
try {
buffer = Buffer.from(dataBase64, 'base64');
} catch {
return badRequest(res, 'dataBase64 invalide');
}
if (buffer.length === 0) return badRequest(res, 'Fichier vide');
if (buffer.length > MAX_ICON_SIZE_BYTES) {
return badRequest(res, `Fichier trop gros (max ${Math.round(MAX_ICON_SIZE_BYTES / 1024)} KB)`);
}
// Vérification rapide du magic number pour bloquer un PNG renommé en .jpg etc.
const isPng = buffer.length >= 8 && buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47;
const isJpeg = buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
const isWebp = buffer.length >= 12
&& buffer.slice(0, 4).toString('ascii') === 'RIFF'
&& buffer.slice(8, 12).toString('ascii') === 'WEBP';
if ((ext === 'png' && !isPng) || (ext === 'jpg' && !isJpeg) || (ext === 'webp' && !isWebp)) {
return badRequest(res, 'Le contenu ne correspond pas au mimeType déclaré');
}
const pool = getPool();
const existing = await fetchAppByClientId(pool, clientId);
if (!existing) return notFound(res);
// Ensure dir exists (sécurité : ICON_DIR géré par ensureTables au boot).
if (!fs.existsSync(oauthClientsDb.ICON_DIR)) {
fs.mkdirSync(oauthClientsDb.ICON_DIR, { recursive: true, mode: 0o755 });
}
const filename = `${clientId}-${Date.now()}.${ext}`;
const targetPath = path.join(oauthClientsDb.ICON_DIR, filename);
await fsp.writeFile(targetPath, buffer, { mode: 0o644 });
// Cleanup ancienne icône avant d'enregistrer la nouvelle.
const previousFilename = existing.icon_filename;
await pool.execute(
'UPDATE oauth_clients SET icon_filename = ?, updated_at = ? WHERE client_id = ?',
[filename, Date.now(), clientId],
);
if (previousFilename && previousFilename !== filename) {
await removeIconFile(previousFilename);
}
await oauthClientsDb.reloadCache();
return res.json({
success: true,
iconFilename: filename,
iconUrl: `/oauth-icons/${filename}`,
});
} catch (err) {
return serverError(res, err, 'Impossible d\'uploader l\'icône');
}
});
router.delete('/:clientId/icon', async (req, res) => {
try {
const clientId = sanitizeClientId(req.params.clientId);
if (!clientId) return badRequest(res, 'clientId invalide');
const pool = getPool();
const existing = await fetchAppByClientId(pool, clientId);
if (!existing) return notFound(res);
if (existing.icon_filename) {
await removeIconFile(existing.icon_filename);
await pool.execute(
'UPDATE oauth_clients SET icon_filename = NULL, updated_at = ? WHERE client_id = ?',
[Date.now(), clientId],
);
await oauthClientsDb.reloadCache();
}
return res.json({ success: true });
} catch (err) {
return serverError(res, err, 'Impossible de supprimer l\'icône');
}
});
// Alimente le compteur de jours VIP que l'app peut distribuer.
// Body : { delta: number } → positif (ajoute) ou négatif (retire, sans descendre sous 0).
router.post('/:clientId/vip-balance', async (req, res) => {
try {
const clientId = sanitizeClientId(req.params.clientId);
if (!clientId) return badRequest(res, 'clientId invalide');
const delta = Number(req.body?.delta);
if (!Number.isInteger(delta) || delta === 0) {
return badRequest(res, 'delta doit être un entier non nul');
}
if (Math.abs(delta) > 100000) {
return badRequest(res, 'delta trop grand');
}
const pool = getPool();
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [rows] = await conn.execute(
'SELECT id, vip_days_balance FROM oauth_clients WHERE client_id = ? FOR UPDATE',
[clientId],
);
if (rows.length === 0) {
await conn.rollback();
return notFound(res);
}
const current = Number(rows[0].vip_days_balance || 0);
const next = Math.max(0, current + delta); // clamp à 0 pour éviter un balance négatif
await conn.execute(
'UPDATE oauth_clients SET vip_days_balance = ?, updated_at = ? WHERE id = ?',
[next, Date.now(), rows[0].id],
);
await conn.commit();
await oauthClientsDb.reloadCache();
return res.json({
success: true,
previousBalance: current,
newBalance: next,
deltaApplied: next - current,
});
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
} catch (err) {
return serverError(res, err, 'Impossible de mettre à jour le balance VIP');
}
});
router.get('/:clientId/stats', async (req, res) => {
try {
const clientId = sanitizeClientId(req.params.clientId);
if (!clientId) return badRequest(res, 'clientId invalide');
const sinceDays = Math.min(Math.max(Number(req.query?.sinceDays) || 30, 1), 365);
const sinceMs = Date.now() - sinceDays * 24 * 60 * 60 * 1000;
const stats = await oauthClientsDb.getStats(clientId, sinceMs);
if (!stats) return serverError(res, null, 'DB indisponible');
return res.json({ success: true, sinceDays, ...stats });
} catch (err) {
return serverError(res, err, 'Impossible de récupérer les stats');
}
});
router.get('/:clientId/grants', async (req, res) => {
try {
const clientId = sanitizeClientId(req.params.clientId);
if (!clientId) return badRequest(res, 'clientId invalide');
const limit = Math.min(Math.max(Number(req.query?.limit) || 50, 1), 500);
const pool = getPool();
const [rows] = await pool.execute(
`SELECT id, client_id, user_id, user_type, user_id_only, days_granted,
access_key_value, expires_at, granted_at, revoked_at
FROM oauth_vip_grants
WHERE client_id = ?
ORDER BY granted_at DESC
LIMIT ?`,
[clientId, limit],
);
const grants = rows.map((row) => ({
id: Number(row.id),
clientId: row.client_id,
userId: row.user_id,
userType: row.user_type,
userIdOnly: row.user_id_only,
daysGranted: Number(row.days_granted),
// accessKey n'est PAS retournée — c'est un secret porté à l'user.
// On expose juste les 4 derniers chars pour identifier.
accessKeyHint: typeof row.access_key_value === 'string' && row.access_key_value.length > 4
? `${row.access_key_value.slice(-4)}`
: null,
expiresAt: row.expires_at,
grantedAt: Number(row.granted_at),
revokedAt: row.revoked_at ? Number(row.revoked_at) : null,
}));
return res.json({ success: true, grants });
} catch (err) {
return serverError(res, err, 'Impossible de récupérer les grants');
}
});
module.exports = router;