/** * [Subsonics Chopin - Backend] - Interface d'administration générale * Toutes les routes Socket.IO "/ADMIN/*" : vue d'ensemble, logs, comptes, serveurs Discord, players, WARP, YouTube, * métriques et maintenance. Contrat : réponse {ok:true, data} ou {ok:false, error, message} via l'ack * (ou socket.emit(name, env) sans ack). * * - attach({ io, socket, socketUser }) : enregistre les routes pour un socket authentifié, SANS condition : * les droits sont revérifiés à chaque appel sur l'utilisateur relu dans users.json (jamais un objet périmé). * - init(io) : poussées globales vers les rooms "ADMIN" et "ADMIN_LOGS" (appelé une seule fois). */ const os = require('os') const fs = require('fs') const path = require('path') const { LogType } = require('loguix') const users = require('./auth/User') const session = require('./auth/Session') const players = require('../player/Player') const discordBot = require('../discord/Bot') const serverSettings = require('../discord/ServerSettings') const playlists = require('../playlists/PlaylistManager') const guildPlaylists = require('../playlists/GuildPlaylists') const warp = require('../utils/WarpProxy') const SafeMetric = require('../utils/SafeMetric') const LogReader = require('../utils/LogReader') const maintenance = require('../utils/Maintenance') const configuration = require('../utils/Database/Configuration') const { getDatabase } = require('../utils/Database/Database') const { __glob } = require('../utils/GlobalVars') const youtube = require('../youtube') const alog = new LogType("Admin") const ADMIN_ROOM = "ADMIN" const LOGS_ROOM = "ADMIN_LOGS" const OVERVIEW_INTERVAL = 10 * 1000 const WARP_CHECK_TIMEOUT = 10 * 1000 const SNOWFLAKE = /^\d{5,25}$/ // YouTube : attente max de la version de yt-dlp avant de répondre (le sondage continue en arrière-plan) const YOUTUBE_VERSION_WAIT = 3000 // Import depuis un navigateur : sous le délai de 60 s du client const YOUTUBE_BROWSER_TIMEOUT = 50 * 1000 const YOUTUBE_RECENT = 50 const COOKIES_MAX_LENGTH = 1000000 const COOKIE_IMPORT_SOURCES = ["paste", "file"] const YOUTUBE_BROWSERS = ["firefox", "chrome", "chromium", "edge", "brave", "opera", "vivaldi", "safari"] // Navigateurs Chromium : base de cookies verrouillée / chiffrée sous Windows const CHROMIUM_BROWSERS = ["chrome", "chromium", "edge", "brave", "opera", "vivaldi"] const YOUTUBE_TEST_ROUTES = ["auto", "warp", "direct"] const YOUTUBE_TEST_IDENTITIES = ["auto", "anon", "cookies"] const YOUTUBE_SETTINGS_MAX_KEYS = 20 // Test des identifiants Spotify (jeton client credentials) const SPOTIFY_TEST_TIMEOUT = 15 * 1000 // Changement des processus yt-dlp actifs (Youtube.js ne prévient pas le player) : sondage léger const YTDLP_PROCESS_POLL = 2000 // Jamais "config" : contient le token du bot et les secrets const DATABASES = ["Users", "Playlists", "GuildPlaylists", "history", "media", "previous", "server_settings"] // Playlists partagées : qui peut les modifier (tous les membres / modérateurs) const SHARED_PLAYLISTS_EDITORS = ["members", "mods"] const PLAYER_ACTIONS = ["pause", "resume", "skip", "previous", "stop", "leave", "loop", "shuffle", "clear"] const LOG_LEVELS = ["INFO", "WARN", "ERROR", "FATAL"] const USER_FILTERS = ["all", "online", "admin", "banned", "deleted"] let io = null let initialized = false let browserImportPending = false class AdminError extends Error { /** * @param {"FORBIDDEN"|"INVALID_PARAMS"|"NOT_FOUND"|"SELF_ACTION"|"TARGET_PROTECTED"|"UNAVAILABLE"|"INTERNAL"} code * @param {string} [message] */ constructor(code, message) { super(message || code) this.code = code } } // UTILITAIRES function safeNum(value) { const n = Number(value) return Number.isFinite(n) ? n : 0 } function toIso(value) { if (value === null || value === undefined || value === "") return null const date = value instanceof Date ? value : new Date(value) return Number.isNaN(date.getTime()) ? null : date.toISOString() } function asObject(payload) { return payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {} } function requireId(value, name = "userId") { if (typeof value !== "string" || !SNOWFLAKE.test(value)) { throw new AdminError("INVALID_PARAMS", `Paramètre "${name}" invalide`) } return value } function requireBool(value, name = "value") { if (typeof value !== "boolean") { throw new AdminError("INVALID_PARAMS", `Paramètre "${name}" invalide (booléen attendu)`) } return value } function requireString(value, name, min, max) { if (typeof value !== "string") throw new AdminError("INVALID_PARAMS", `Paramètre "${name}" invalide (texte attendu)`) const text = value.trim() if (text.length < min || text.length > max) { throw new AdminError("INVALID_PARAMS", `Paramètre "${name}" invalide (${min} à ${max} caractères)`) } return text } function optionalString(value, name, max) { if (value === undefined || value === null) return null if (typeof value !== "string") throw new AdminError("INVALID_PARAMS", `Paramètre "${name}" invalide (texte attendu)`) const text = value.trim() if (text.length > max) throw new AdminError("INVALID_PARAMS", `Paramètre "${name}" trop long (${max} caractères max)`) return text === "" ? null : text } function optionalInt(value, name, min, max, fallback) { if (value === undefined || value === null) return fallback const n = Number(value) if (!Number.isInteger(n) || n < min || n > max) { throw new AdminError("INVALID_PARAMS", `Paramètre "${name}" invalide (entier entre ${min} et ${max})`) } return n } /** * Les anciens endpoints acceptent un userId en chaîne, les nouveaux un objet {userId} */ function userIdFrom(payload) { if (typeof payload === "string") return requireId(payload) return requireId(asObject(payload).userId) } function describeUser(user) { return `${user?.identity?.username ?? "inconnu"} (${user?.identity?.id ?? "?"})` } /** * Journalise chaque action mutante */ function audit(me, action, target = "") { alog.warn(`[ADMIN] ${me.identity.username} -> ${action}${target ? " " + target : ""}`) } function roomSize(room) { return io?.sockets.adapter.rooms.get(room)?.size || 0 } function getClient() { return discordBot.getClient() } function getYoutubeProcesses() { try { const map = require('../player/Method/Youtube').activeProcesses return map instanceof Map ? map : null } catch (e) { return null } } function getMediaBase() { try { return require('../discord/MediaBase') } catch (e) { return null } } function dbData(name, fallback) { const data = getDatabase(name)?.data return data === undefined || data === null ? fallback : data } /** * Coalesce les appels : fn est exécutée au plus une fois par délai */ function debounce(fn, delay) { let timer = null return () => { if (timer) return timer = setTimeout(async () => { timer = null try { await fn() } catch (e) { alog.error(`Poussée admin impossible : ${e?.stack || e}`) } }, delay) timer.unref?.() } } // TYPES SÉRIALISÉS function userLite(identity) { return { id: identity?.id ?? null, username: identity?.username ?? null, global_name: identity?.global_name ?? null, avatar: identity?.avatar ?? null } } function songLite(song) { if (!song || typeof song !== "object") return null const duration = Number(song.duration) return { id: song.id ?? null, title: song.title ?? null, author: song.author ?? null, authorId: song.authorId ?? null, url: song.url ?? null, thumbnail: song.thumbnail ?? null, duration: Number.isFinite(duration) && duration > 0 ? duration : null, readduration: song.readduration ?? null, type: song.type ?? null } } /** * Index des sockets authentifiés par utilisateur (socket.data.userId posé par Server.js à la connexion) * @returns {Map} */ function socketIndex() { const byUser = new Map() if (!io) return byUser for (const socket of io.sockets.sockets.values()) { const userId = socket.data?.userId if (!userId) continue if (!byUser.has(userId)) byUser.set(userId, []) byUser.get(userId).push(socket) } return byUser } function socketGuildId(socket) { const guilds = discordBot.getGuilds() for (const room of socket.rooms) { if (guilds.has(room)) return room } return null } /** * Données partagées pour construire plusieurs résumés sans tout recalculer */ function buildContext() { const guildsByUser = new Map() for (const [guildId, guild] of discordBot.getGuilds()) { for (const memberId of guild.allMembers || []) { if (!guildsByUser.has(memberId)) guildsByUser.set(memberId, []) guildsByUser.get(memberId).push(guildId) } } const mediaCounts = new Map() const media = dbData("media", []) if (Array.isArray(media)) { for (const m of media) mediaCounts.set(m?.userId, (mediaCounts.get(m?.userId) || 0) + 1) } const registered = new Set(users.getUsers().filter(u => !u.labels?.includes("DELETED")).map(u => u.identity.id)) return { sockets: socketIndex(), guildsByUser, playlists: dbData("Playlists", {}), history: dbData("history", {}), mediaCounts, registered } } function userSummary(user, ctx = buildContext()) { const id = user.identity.id const labels = Array.isArray(user.labels) ? [...user.labels] : [] const sockets = ctx.sockets.get(id) || [] const updated = labels.find(l => l.startsWith("UPDATED[")) let currentGuildId = null for (const socket of sockets) { currentGuildId = socketGuildId(socket) if (currentGuildId) break } const playlistsOfUser = ctx.playlists?.[id] const historyOfUser = ctx.history?.[id] return { ...userLite(user.identity), labels, isAdmin: labels.includes("ADMIN"), isFullBanned: labels.includes("BAN"), isDeleted: labels.includes("DELETED"), modGuilds: labels.filter(l => l.startsWith("MOD_")).map(l => l.slice(4)), bannedGuilds: labels.filter(l => l.startsWith("BAN_")).map(l => l.slice(4)), banReason: user.banInfo?.reason ?? null, bannedAt: toIso(user.banInfo?.at), lastIdentityUpdate: updated ? toIso(updated.slice("UPDATED[".length, -1)) : null, hasDiscordAuth: !!user.auth, tokenCount: Array.isArray(user.tokens) ? user.tokens.length : 0, online: sockets.length > 0, socketCount: sockets.length, currentGuildId, guildIds: ctx.guildsByUser.get(id) || [], playlistCount: Array.isArray(playlistsOfUser) ? playlistsOfUser.length : 0, historyCount: Array.isArray(historyOfUser) ? historyOfUser.length : 0, mediaCount: ctx.mediaCounts.get(id) || 0 } } function isConnectedPlayer(player) { if (!player) return false try { if (typeof player.isConnected === "function") return !!player.isConnected() } catch (e) { return false } return !!player.connection } function connectedPlayers() { return players.getAllPlayers().filter(isConnectedPlayer) } /** * Statut simplifié d'un état de player (v2 : state.status ; ancien format : playerState) */ function playerStatus(state) { if (state?.status) return state.status if (state?.playing) return "playing" if (state?.paused) return "paused" return "idle" } /** * PlayerSummary = état v2 du player (next tronqué à 10) + informations d'administration */ function playerSummary(player, { full = false } = {}) { let state try { state = player.getState() } catch (e) { alog.error(`Lecture de l'état du player ${player?.guildId} impossible : ${e?.stack || e}`) return null } if (!state) return null const guildId = state.guildId || player.guildId const guild = discordBot.getGuilds().get(guildId) const channelId = state.connection?.channelId ?? state.channelId ?? player.channelId ?? null let listeners = 0 try { const channel = channelId ? discordBot.getChannel(guildId, channelId) : null listeners = channel?.members ? channel.members.filter(m => !m.user?.bot).size : 0 } catch (e) { listeners = 0 } const proc = getYoutubeProcesses()?.get(guildId) const next = Array.isArray(state.next) ? state.next : [] const previous = Array.isArray(state.previous) ? state.previous : [] // Player.lastError n'est jamais effacé : une erreur sur une autre musique que celle en cours de lecture est résolue const lastErrorStale = !!(state.lastError && state.status === "playing" && state.current?.id && state.lastError.songId !== state.current.id) return { ...state, guildId, lastErrorStale, next: full ? next : next.slice(0, 10), nextCount: Number.isFinite(state.nextCount) ? state.nextCount : next.length, // Résumé : 20 derniers titres ; détail complet : tout l'historique renvoyé par getState() previous: full ? previous : previous.slice(0, 20), guildName: guild?.name ?? null, guildIcon: guild?.icon ?? null, listeners, webListeners: roomSize(guildId), ytdlp: proc ? { pid: proc.pid ?? null, route: proc.route ?? null, startedAt: toIso(proc.startedAt) } : null } } function playersList() { return connectedPlayers().map(p => playerSummary(p)).filter(Boolean) } function guildStats(guildId) { return { commands: safeNum(SafeMetric.get("numberOfCommands_" + guildId)), musicPlayed: safeNum(SafeMetric.get("numberOfMusicPlayed_" + guildId)), secondsPlayed: safeNum(SafeMetric.get("numberOfSecondsPlayed_" + guildId)) } } /** * Copie sérialisable d'une guilde (jamais l'objet partagé de Bot.js, jamais allMembers) */ function guildSummary(guildData, ctx = buildContext()) { const id = guildData.id const discordGuild = getClient().guilds.cache.get(id) let missingPermissions = [] try { if (discordGuild?.members?.me && typeof discordBot.checkRequiredPermission === "function") { missingPermissions = discordBot.checkRequiredPermission(discordGuild.members.me) } } catch (e) { missingPermissions = [] } let owner = { id: guildData.owner ?? null } const ownerMember = discordGuild?.members?.cache.get(guildData.owner) if (ownerMember?.user) { owner = { id: ownerMember.user.id, username: ownerMember.user.username ?? null, global_name: ownerMember.user.globalName ?? null, avatar: ownerMember.user.avatar ?? null } } // Utilisateurs web distincts dans la room de la guilde const webUsers = new Set() for (const socketId of io?.sockets.adapter.rooms.get(id) || []) { const userId = io.sockets.sockets.get(socketId)?.data?.userId if (userId) webUsers.add(userId) } const members = guildData.allMembers || [] const secureRole = serverSettings.getSecureRole(id) const player = players.AllPlayers.get(id) return { id, name: guildData.name ?? null, icon: guildData.icon ?? null, banner: guildData.banner ?? null, description: guildData.description ?? null, owner, memberCount: members.length, registeredMembers: members.filter(m => ctx.registered.has(m)).length, webUsersOnline: webUsers.size, joinedAt: toIso(guildData.joinedAt), createdAt: toIso(guildData.createdAt), features: Array.isArray(guildData.features) ? [...guildData.features] : [], secureRole: secureRole ? { id: secureRole.id ?? null, name: secureRole.name ?? null } : null, missingPermissions, isMediaGuild: !!id && id === configuration.getMediaGuildId(), player: isConnectedPlayer(player) ? playerSummary(player) : null, stats: guildStats(id), sharedPlaylists: sharedPlaylistsSummary(id) } } /** * Option des playlists partagées d'un serveur + nombre de playlists enregistrées (même masquées) */ function sharedPlaylistsSummary(guildId) { const settings = typeof serverSettings.getSharedPlaylists === "function" ? serverSettings.getSharedPlaylists(guildId) : { enabled: false, editors: "members" } return { enabled: !!settings?.enabled, editors: SHARED_PLAYLISTS_EDITORS.includes(settings?.editors) ? settings.editors : "members", count: guildPlaylists.countPlaylists(guildId) } } /** * Résumé d'une playlist pour les listes d'administration (jamais la liste des titres) */ function playlistLite(p) { return { playlistId: p?.playlistId != null ? String(p.playlistId) : null, title: p?.title ?? null, type: p?.type ?? null, // "google" : synchronisée depuis le compte Google du propriétaire source: p?.source ?? null, url: typeof p?.url === "string" ? p.url : null, thumbnail: typeof p?.thumbnail === "string" ? p.thumbnail : null, duration: safeNum(p?.duration), syncedAt: toIso(p?.syncedAt), createdAt: toIso(p?.createdAt), updatedAt: toIso(p?.updatedAt), songCount: Array.isArray(p?.songs) ? p.songs.length : 0 } } function guildsList(search) { const ctx = buildContext() let list = [...discordBot.getGuilds().values()] if (search) { const s = search.toLowerCase() list = list.filter(g => (g.name || "").toLowerCase().includes(s) || g.id.includes(s)) } return list.map(g => guildSummary(g, ctx)).sort((a, b) => (a.name || "").localeCompare(b.name || "")) } function onlineUsersPayload() { const sockets = socketIndex() const online = [] for (const [userId, list] of sockets) { const user = users.getUserById(userId) if (!user) continue let guildId = null for (const socket of list) { guildId = socketGuildId(socket) if (guildId) break } online.push({ ...userLite(user.identity), socketCount: list.length, guildId }) } const registered = users.getUsers().filter(u => !u.labels?.includes("DELETED")).length return { online, counts: { registered, online: online.length } } } // MÉTRIQUES function metricsData() { const all = SafeMetric.getAll() const raw = all.map(m => ({ name: m.name, description: m.description ?? m.name, value: safeNum(m.value), invalid: !Number.isFinite(Number(m.value)) })) const byGuildMap = new Map() const ensureGuild = (guildId) => { if (!byGuildMap.has(guildId)) { byGuildMap.set(guildId, { guildId, guildName: discordBot.getGuilds().get(guildId)?.name ?? null, commands: 0, musicPlayed: 0, secondsPlayed: 0 }) } return byGuildMap.get(guildId) } const byUser = [] let commands = 0 for (const m of raw) { if (m.name === "numberOfCommands") commands = m.value else if (m.name.startsWith("numberOfCommands_")) ensureGuild(m.name.slice("numberOfCommands_".length)).commands = m.value else if (m.name.startsWith("numberOfMusicPlayed_")) ensureGuild(m.name.slice("numberOfMusicPlayed_".length)).musicPlayed = m.value else if (m.name.startsWith("numberOfSecondsPlayed_")) ensureGuild(m.name.slice("numberOfSecondsPlayed_".length)).secondsPlayed = m.value else if (m.name.startsWith("userCommand_")) byUser.push({ key: m.name.slice("userCommand_".length), commands: m.value }) } const byGuild = [...byGuildMap.values()] return { global: { commands, musicPlayed: byGuild.reduce((acc, g) => acc + g.musicPlayed, 0), secondsPlayed: byGuild.reduce((acc, g) => acc + g.secondsPlayed, 0) }, byGuild: byGuild.sort((a, b) => b.commands - a.commands), byUser: byUser.sort((a, b) => b.commands - a.commands), raw } } // YOUTUBE /** * Erreurs typées du module youtube -> AdminError (leurs messages sont déjà en français) */ function youtubeError(e) { if (e instanceof AdminError) return e switch (e?.code) { case "INVALID_COOKIES": case "INVALID_PARAMS": return new AdminError("INVALID_PARAMS", e.message) case "BUSY": return new AdminError("UNAVAILABLE", e.message) default: return e } } /** * Relance le sondage de la version de yt-dlp si son cache a expiré, sans bloquer plus de quelques secondes * (getVersion() répond directement depuis son cache : 6 h si connue, 1 min en cas d'échec) */ async function refreshYtdlpVersion() { let timer try { await Promise.race([ youtube.ytdlp.getVersion().catch(() => null), new Promise((resolve) => { timer = setTimeout(resolve, YOUTUBE_VERSION_WAIT) timer.unref?.() }) ]) } finally { clearTimeout(timer) } } /** * État complet de la section YouTube : youtube.getStatus() + tentatives récentes (avec le nom du serveur) */ function youtubeStatus() { const guilds = discordBot.getGuilds() const recent = youtube.diagnostics.getRecent(YOUTUBE_RECENT).map(entry => ({ ...entry, guildName: entry.guildId ? (guilds.get(entry.guildId)?.name ?? null) : null })) return { ...youtube.getStatus(), recent } } /** * Résumé yt-dlp de la vue d'ensemble (clés present / updatedAt conservées pour les anciens clients) */ async function youtubeOverview() { const activeProcesses = getYoutubeProcesses()?.size ?? 0 try { await refreshYtdlpVersion() const status = youtube.getStatus() const cookies = status.cookies || {} const summary = status.diagnostics || {} return { version: status.ytdlp?.version ?? null, source: status.ytdlp?.source ?? null, updating: !!status.ytdlp?.updating, cookies: { present: !!cookies.present, loggedIn: !!cookies.loggedIn, valid: !!cookies.valid, invalidatedAt: cookies.invalidatedAt ?? null, invalidReason: cookies.invalidReason ?? null, importedAt: cookies.importedAt ?? null, updatedAt: cookies.importedAt ?? null, earliestAuthExpiry: cookies.earliestAuthExpiry ?? null, lastCheck: cookies.lastCheck ? { at: cookies.lastCheck.at ?? null, ok: !!cookies.lastCheck.ok } : null }, cookiesMode: status.settings?.cookiesMode?.value ?? null, activeProcesses, lastFailure: summary.lastFailure ? { at: summary.lastFailure.at ?? null, cls: summary.lastFailure.cls ?? null } : null, recentFailureRate: summary.recentFailureRate ?? null, recentCount: safeNum(summary.recentCount) } } catch (e) { alog.error(`État YouTube illisible pour la vue d'ensemble : ${e?.stack || e}`) return { version: null, cookies: { present: false, updatedAt: null }, activeProcesses } } } /** * Lance la vérification en direct des cookies sans l'attendre (le résultat arrive par "/ADMIN/YOUTUBE/UPDATE") * @returns {boolean} true si une vérification a été lancée */ function startCookieCheck() { if (!youtube.cookies.getStatus().loggedIn) return false youtube.check.checkCookies().catch((e) => alog.warn(`Vérification des cookies YouTube impossible : ${e?.message || e}`)) return true } /** * Description d'une modification de réglages pour l'audit (URL du fournisseur de PO token masquée) */ function describeSettingsChange(before, after) { const format = (key, value) => key === "potProviderUrl" ? (youtube.settings.maskUrl(value) || '""') : JSON.stringify(value) return Object.keys(after) .filter(key => before[key] !== after[key]) .map(key => `${key}: ${format(key, before[key])} -> ${format(key, after[key])}`) .join(", ") } function isContainer() { try { return fs.existsSync("/.dockerenv") || fs.existsSync("/run/.containerenv") } catch (e) { return false } } /** * Message d'échec de --cookies-from-browser (dernière ligne ERROR nettoyée + explication) */ function browserFailureMessage(browser, result) { const line = String(result?.stderr || "").split(/\r?\n/).map(l => l.trim()).reverse().find(l => /^ERROR:/i.test(l)) let detail if (result?.timedOut) detail = "délai dépassé" else if (line) detail = youtube.errors.sanitize(line.replace(/^ERROR:\s*/i, "")).slice(0, 300) else detail = `aucun cookie exporté (code ${result?.code ?? "?"})` let hint = "Cette méthode ne fonctionne que si le bot tourne sur la même machine et la même session que le navigateur." if (CHROMIUM_BROWSERS.includes(browser) && process.platform === "win32") { hint += " Sous Windows, Chrome et ses dérivés verrouillent et chiffrent leurs cookies : fermez complètement le navigateur, ou préférez Firefox." } return `Lecture des cookies de ${browser} impossible : ${detail}. ${hint}` } /** * Importe les cookies d'un navigateur installé sur la machine du bot (dev / local uniquement) : * yt-dlp --cookies-from-browser les exporte dans un fichier temporaire, aussitôt importé puis supprimé. */ async function importFromBrowser(browser, profile, by) { if (isContainer()) { throw new AdminError("UNAVAILABLE", "Le bot tourne dans un conteneur (Docker) : il n'a accès à aucun navigateur. Utilisez l'assistant de connexion (export d'un fichier cookies.txt).") } if (browserImportPending) throw new AdminError("UNAVAILABLE", "Un import depuis un navigateur est déjà en cours") browserImportPending = true // Préfixe des copies de cookies du module youtube : nettoyé au démarrage en cas de crash let dir = null try { dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "chopin-ytc-")) const file = path.join(dir, "cookies.txt") const spec = profile ? `${browser}:${profile}` : browser const args = [ ...youtube.ytdlp.baseArgs({ proxy: null }), "--cookies-from-browser", spec, "--cookies", file, "--skip-download", "--", youtube.settings.get().testUrl ] // yt-dlp écrit le fichier de cookies en se fermant, même si l'extraction de la vidéo échoue const result = await youtube.ytdlp.run(args, { timeoutMs: YOUTUBE_BROWSER_TIMEOUT }) let text = null try { text = await fs.promises.readFile(file, "utf-8") } catch (e) { text = null } if (!text || !text.trim()) throw new AdminError("UNAVAILABLE", browserFailureMessage(browser, result)) try { return youtube.cookies.importCookies(text, { source: "browser:" + browser, by }) } catch (e) { if (e?.code === "INVALID_COOKIES") { throw new AdminError("INVALID_PARAMS", `${e.message} (êtes-vous connecté à YouTube dans ${browser}${profile ? `, profil « ${profile} »` : ""} ?)`) } throw e } } finally { browserImportPending = false // Le fichier temporaire contient TOUS les cookies du navigateur : supprimé dans tous les cas if (dir) await fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {}) } } function adminIdentity(me) { return { id: me.identity.id, username: me.identity.username } } // CONFIGURATION ET INTÉGRATIONS // Dernier test des identifiants Spotify (oublié dès qu'ils changent) let spotifyLastTest = null function websiteBase() { return String(configuration.getWebsiteLink() || "").replace(/\/+$/, "") } /** * Comptes Google liés, lus dans data/google.json (lecture seule : jamais de jeton renvoyé) */ function googleAccountsInfo() { const data = dbData("google", null) if (!data || typeof data !== "object" || Array.isArray(data)) return { available: false, linked: 0, invalid: 0 } let linked = 0 let invalid = 0 for (const record of Object.values(data)) { if (!record || typeof record !== "object" || !record.tokens) continue linked++ if (record.invalid) invalid++ } return { available: true, linked, invalid } } function youtubeCookiesSummary() { try { const cookies = youtube.cookies.getStatus() return { present: !!cookies.present, loggedIn: !!cookies.loggedIn, valid: !!cookies.valid, invalidReason: cookies.invalidReason ?? null, importedAt: cookies.importedAt ?? null } } catch (e) { return null } } /** * État des intégrations pour la section Configuration (aucun secret, seulement des indicateurs) */ function integrationsStatus() { const website = websiteBase() const google = googleAccountsInfo() const warpStatus = warp.getStatus() const client = getClient() return { website: website || null, discord: { ready: !!discordBot.isReady(), tag: client?.user?.tag ?? null, // À déclarer dans le portail développeur Discord (OAuth2 > Redirects) redirectUri: website ? website + "/redirect" : null }, google: { configured: !!(configuration.getYoutubeApiClientId() && configuration.getYoutubeApiClientSecret() && website), clientIdSet: !!configuration.getYoutubeApiClientId(), clientSecretSet: !!configuration.getYoutubeApiClientSecret(), // URI de redirection autorisée à déclarer dans la console Google Cloud redirectUri: website ? website + "/oauth2callback" : null, accountsAvailable: google.available, linkedUsers: google.linked, invalidUsers: google.invalid }, spotify: { configured: !!(configuration.getSpotifyClientId() && configuration.getSpotifyClientSecret()), lastTest: spotifyLastTest }, warp: { configured: !!warpStatus.configured, healthy: !!warpStatus.healthy, url: warpStatus.url ?? null, routes: warpStatus.routes || [] }, youtube: { cookies: youtubeCookiesSummary() }, media: mediaStatus(), report: { channelSet: !!configuration.getReportChannel(), contactSet: !!configuration.getReportContact() } } } function configPayload() { const config = configuration.getMasked() return { config, restartRequired: Object.values(config).some(entry => entry.pendingRestart), integrations: integrationsStatus() } } /** * Demande un jeton client credentials à Spotify avec les identifiants enregistrés * (instance dédiée : le jeton en cache de SpotifyInformation n'est pas touché) */ async function testSpotify() { const clientId = configuration.getSpotifyClientId() const clientSecret = configuration.getSpotifyClientSecret() if (!clientId || !clientSecret) throw new AdminError("UNAVAILABLE", "Identifiants Spotify non configurés") const SpotifyWebApi = require('spotify-web-api-node') const api = new SpotifyWebApi({ clientId, clientSecret }) const startedAt = Date.now() let timer let result try { const data = await Promise.race([ api.clientCredentialsGrant(), new Promise((resolve, reject) => { timer = setTimeout(() => reject(new Error("délai dépassé")), SPOTIFY_TEST_TIMEOUT) timer.unref?.() }) ]) const expiresIn = Number(data?.body?.expires_in) result = { ok: true, message: "Jeton obtenu : identifiants valides", expiresIn: Number.isFinite(expiresIn) ? expiresIn : null } } catch (e) { const status = Number(e?.statusCode) let detail = String(e?.body?.error_description || e?.body?.error || e?.message || e) // Par précaution : jamais le secret dans un message renvoyé ou journalisé detail = detail.split(clientSecret).join("***").slice(0, 200) result = { ok: false, message: status === 400 || status === 401 ? `Identifiants refusés par Spotify (${detail})` : `Spotify injoignable : ${detail}` } } finally { clearTimeout(timer) } spotifyLastTest = { ...result, at: new Date().toISOString(), ms: Date.now() - startedAt } return spotifyLastTest } // VUE D'ENSEMBLE async function databasesInfo(withEntries = false) { const result = [] for (const name of DATABASES) { const db = getDatabase(name) if (!db?.path) continue const info = { name, sizeBytes: 0, modifiedAt: null } try { const stat = await fs.promises.stat(db.path) info.sizeBytes = stat.size info.modifiedAt = stat.mtime.toISOString() } catch (e) { // Fichier absent : taille 0 } if (withEntries) { const data = db.data info.entries = Array.isArray(data) ? data.length : (data && typeof data === "object" ? Object.keys(data).length : 0) } result.push(info) } return result } function packageName() { try { return JSON.parse(fs.readFileSync(__glob.PACKAGEINFO, "utf-8")).name || "chopin-backend" } catch (e) { return "chopin-backend" } } function mediaStatus() { const configured = !!(configuration.getMediaGuildId() && configuration.getMediaChannelId()) let connected = false const mediaBase = getMediaBase() if (typeof mediaBase?.isConnected === "function") { connected = !!mediaBase.isConnected() } else if (configured) { // À défaut d'accesseur : le salon multimédia est-il visible par le bot ? try { connected = !!discordBot.getChannel(configuration.getMediaGuildId(), configuration.getMediaChannelId()) } catch (e) { connected = false } } return { configured, connected } } let cachedPackageName = null async function buildOverview() { const client = getClient() const uptime = process.uptime() const memory = process.memoryUsage() // Joueurs const playerCounts = { connected: 0, playing: 0, paused: 0, idle: 0 } for (const player of connectedPlayers()) { playerCounts.connected++ let status = "idle" try { status = playerStatus(player.getState()) } catch (e) { status = "idle" } if (status === "playing") playerCounts.playing++ else if (status === "paused") playerCounts.paused++ else playerCounts.idle++ } // Comptes const allUsers = users.getUsers() const userCounts = { registered: 0, admins: 0, fullBanned: 0, deleted: 0 } for (const user of allUsers) { const labels = user.labels || [] if (labels.includes("DELETED")) { userCounts.deleted++ continue } userCounts.registered++ if (labels.includes("ADMIN")) userCounts.admins++ if (labels.includes("BAN")) userCounts.fullBanned++ } // Web const sockets = socketIndex() let adminsOnline = 0 for (const userId of sockets.keys()) { if (users.getUserById(userId)?.isAdmin()) adminsOnline++ } // Discord let voiceConnections = 0 try { voiceConnections = require('@discordjs/voice').getVoiceConnections().size } catch (e) { voiceConnections = 0 } const activity = client?.user?.presence?.activities?.[0] const metrics = metricsData() if (!cachedPackageName) cachedPackageName = packageName() return { app: { name: cachedPackageName, version: __glob.VERSION, node: process.version, platform: process.platform, pid: process.pid, startedAt: new Date(Date.now() - uptime * 1000).toISOString(), uptime: Math.floor(uptime), pm2: process.env.pm_id !== undefined }, memory: { rss: memory.rss, heapUsed: memory.heapUsed, heapTotal: memory.heapTotal, external: memory.external }, system: { loadavg: os.loadavg().map(safeNum), totalmem: os.totalmem(), freemem: os.freemem(), cpus: os.cpus().length, hostname: os.hostname() }, discord: { ready: !!discordBot.isReady(), id: client?.user?.id ?? null, tag: client?.user?.tag ?? null, avatar: client?.user ? client.user.displayAvatarURL() : null, ping: safeNum(client?.ws?.ping), readyAt: toIso(client?.readyAt), guildCount: client?.guilds?.cache?.size ?? 0, voiceConnections, activity: activity ? (activity.state || activity.name || null) : null }, web: { sockets: io ? io.sockets.sockets.size : 0, usersOnline: sockets.size, pendingSessions: typeof session.getSessionCount === "function" ? session.getSessionCount() : 0, adminsOnline }, players: playerCounts, users: userCounts, warp: warp.getStatus(), ytdlp: await youtubeOverview(), media: mediaStatus(), databases: await databasesInfo(false), stats: metrics.global, logs: { current: LogReader.getCurrentName(), interrupted: await LogReader.isInterrupted() } } } // SYNCHRONISATION DES SOCKETS /** * Applique les droits actuels de l'utilisateur à tous ses sockets (après chaque mutation de labels) : * room ADMIN, sortie des guildes bannies, déconnexion si banni globalement ou supprimé. */ function syncUserSockets(userId) { if (!io || !userId) return 0 const user = users.getUserById(userId) const guilds = discordBot.getGuilds() let affected = 0 for (const socket of [...io.sockets.sockets.values()]) { if (socket.data?.userId !== userId) continue affected++ if (!user || user.labels.includes("DELETED") || user.isFullBanned()) { socket.emit("AUTH_ERROR", user?.isFullBanned() ? "Vous êtes banni du serveur" : "Votre compte a été supprimé") socket.disconnect(true) continue } if (user.isAdmin()) { socket.join(ADMIN_ROOM) } else { socket.leave(ADMIN_ROOM) socket.leave(LOGS_ROOM) } for (const room of [...socket.rooms]) { if (guilds.has(room) && user.isBanned(room)) socket.leave(room) } } process.emit("USERS_UPDATE") return affected } /** * Déconnecte toutes les sessions d'un utilisateur * @returns {number} nombre de sockets déconnectés */ function disconnectUser(userId, message) { if (!io) return 0 let count = 0 for (const socket of [...io.sockets.sockets.values()]) { if (socket.data?.userId !== userId) continue socket.emit("AUTH_ERROR", message) socket.disconnect(true) count++ } return count } // ACTIONS SUR LES COMPTES function getTarget(userId) { const target = users.getUserById(userId) if (!target) throw new AdminError("NOT_FOUND", "Utilisateur introuvable") return target } function freshSummary(userId) { const user = users.getUserById(userId) if (!user) throw new AdminError("NOT_FOUND", "Utilisateur introuvable") return userSummary(user) } async function setAdminValue(me, userId, value) { if (userId === me.identity.id) throw new AdminError("SELF_ACTION", "Vous ne pouvez pas modifier vos propres droits") const target = getTarget(userId) if (target.labels.includes("DELETED")) throw new AdminError("INVALID_PARAMS", "Ce compte est supprimé") const state = await users.setAdmin(userId, value) if (state === null) throw new AdminError("INTERNAL", "Modification impossible") audit(me, value ? "promotion admin" : "retrait admin", describeUser(target)) syncUserSockets(userId) return freshSummary(userId) } async function setFullBanValue(me, userId, value, reason) { if (userId === me.identity.id) throw new AdminError("SELF_ACTION", "Vous ne pouvez pas vous bannir vous-même") const target = getTarget(userId) if (value && target.isAdmin()) throw new AdminError("TARGET_PROTECTED", "Un administrateur ne peut pas être banni") const state = await users.setFullBan(userId, value, reason || undefined) if (state === null) throw new AdminError("INTERNAL", "Modification impossible") audit(me, value ? "bannissement global" : "levée du bannissement global", describeUser(target) + (reason ? ` - raison : ${reason}` : "")) syncUserSockets(userId) return freshSummary(userId) } async function deleteUserAccount(me, userId, purge) { if (userId === me.identity.id) throw new AdminError("SELF_ACTION", "Vous ne pouvez pas supprimer votre propre compte ici") const target = getTarget(userId) if (target.isAdmin()) throw new AdminError("TARGET_PROTECTED", "Un administrateur ne peut pas être supprimé") // Déconnexion d'abord : les sockets ne doivent plus agir pendant la suppression disconnectUser(userId, "Votre compte a été supprimé") const purged = await users.deleteAccount(userId, { purge }) if (purged === null) throw new AdminError("NOT_FOUND", "Utilisateur introuvable") audit(me, purge ? "suppression du compte (avec purge)" : "suppression du compte", describeUser(target)) syncUserSockets(userId) return { deleted: true, purged } } // ACTIONS SUR LES PLAYERS function getConnectedPlayer(guildId) { const player = players.AllPlayers.get(guildId) if (!isConnectedPlayer(player)) throw new AdminError("NOT_FOUND", "Aucun player connecté sur ce serveur") return player } /** * @param {boolean} [value] loop / shuffle : état voulu (idempotent) ; absent = bascule (ancien client) */ async function runPlayerAction(player, action, value) { const guildId = player.guildId switch (action) { case "pause": case "resume": { const value = action === "pause" let result if (typeof player.setPaused === "function") { result = await player.setPaused(value) } else { // Ancienne API : pause() bascule, on vérifie l'état avant pour rester idempotent const status = player.player?.state?.status const paused = status === "paused" || status === "autopaused" if (paused !== value) await player.pause() result = { ok: true, paused: value } } if (result && result.ok === false) throw new AdminError("UNAVAILABLE", result.reason || "Action impossible") break } case "skip": case "previous": { const result = await player[action]() if (!result || result === "no_music") throw new AdminError("UNAVAILABLE", "no_music") break } case "stop": if (typeof player.stop === "function") { await player.stop() } else { player.loop = false player.queue?.clearNext?.() player.player?.stop(true) } break case "leave": await player.leave("admin") return null case "loop": if (value === undefined || !!player.loop !== value) await player.setLoop() break case "shuffle": if (value === undefined || !!player.queue?.shuffle !== value) await player.setShuffle() break case "clear": player.queue.clearNext() break } const current = players.AllPlayers.get(guildId) return isConnectedPlayer(current) ? playerSummary(current) : null } // LOGS TEMPS RÉEL // Démarrage du suivi en cours : un second abonnement l'attend au lieu de renvoyer un tampon encore vide let tailStarting = null async function ensureTail() { if (tailStarting) return tailStarting if (LogReader.isTailing()) return tailStarting = LogReader.startTail(({ name, lines }) => { if (!io || roomSize(LOGS_ROOM) === 0) return io.to(LOGS_ROOM).emit("/ADMIN/LOGS/APPEND", { name, lines }) }).finally(() => { tailStarting = null }) return tailStarting } /** * Offset du début de la première ligne complète d'une lecture commençant à `start` * (LogReader renvoie l'offset demandé et ignore la ligne partielle : le client en a besoin pour * « Charger les lignes plus anciennes » sans perdre la ligne à cheval sur deux blocs) */ async function firstLineOffset(name, start, end) { if (!(start > 0)) return 0 const handle = await fs.promises.open(path.join(__glob.LOGS, name), "r") try { const buffer = Buffer.alloc(64 * 1024) // On part de l'octet précédent : s'il s'agit d'un saut de ligne, start est déjà un début de ligne let position = start - 1 while (position < end) { const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, end - position), position) if (!bytesRead) break const index = buffer.subarray(0, bytesRead).indexOf(0x0a) if (index !== -1) return position + index + 1 position += bytesRead } return end } finally { await handle.close() } } // ENREGISTREMENT DES ROUTES /** * Enregistre toutes les routes d'administration pour un socket authentifié. * @param {{io?: import('socket.io').Server, socket: import('socket.io').Socket, socketUser: function|Object}} options * socketUser : fonction renvoyant l'utilisateur courant du socket (ou l'objet lui-même) */ function attach({ io: ioInstance, socket, socketUser }) { if (!io && ioInstance) io = ioInstance function getMe() { const current = typeof socketUser === "function" ? socketUser() : socketUser const id = current?.identity?.id || socket.data?.userId return id ? users.getUserById(id) : null } /** * Route d'administration : droits relus à chaque appel, réponse par enveloppe {ok, data|error} */ function AdminRequest(name, handler) { socket.on(name, async (payload, ack) => { if (typeof payload === "function" && ack === undefined) { ack = payload payload = undefined } const reply = (env) => { if (typeof ack === "function") ack(env) else socket.emit(name, env) } const me = getMe() if (!me || !me.isAdmin() || me.isFullBanned() || me.labels.includes("DELETED")) { alog.warn(`[ADMIN] Accès refusé à ${name} pour ${describeUser(me)} - Socket : ${socket.id}`) socket.leave(ADMIN_ROOM) socket.leave(LOGS_ROOM) return reply({ ok: false, error: "FORBIDDEN", message: "Accès réservé aux administrateurs" }) } try { const data = await handler(payload, me) reply({ ok: true, data: data === undefined ? null : data }) } catch (e) { if (e instanceof AdminError) { return reply({ ok: false, error: e.code, message: e.message }) } alog.error(`${name} : ${e?.stack || e}`) reply({ ok: false, error: "INTERNAL", message: String(e?.message || e) }) } }) } // 1. VUE D'ENSEMBLE AdminRequest("/ADMIN/OVERVIEW", async () => buildOverview()) // 2. LOGS AdminRequest("/ADMIN/LOGS/LIST", async () => LogReader.listLogs()) // Ancien nom : renvoie la liste, sans le contenu (l'ancienne version lisait tout en synchrone) AdminRequest("/ADMIN/LOGS", async () => LogReader.listLogs()) AdminRequest("/ADMIN/LOGS/READ", async (payload) => { const data = asObject(payload) if (!LogReader.isValidName(data.name)) throw new AdminError("INVALID_PARAMS", "Nom de fichier invalide") const offset = optionalInt(data.offset, "offset", 0, Number.MAX_SAFE_INTEGER, undefined) const limit = optionalInt(data.limit, "limit", 1, 1048576, 262144) let levels if (data.levels !== undefined && data.levels !== null) { if (!Array.isArray(data.levels) || data.levels.some(l => typeof l !== "string" || !LOG_LEVELS.includes(l.toUpperCase()))) { throw new AdminError("INVALID_PARAMS", "Paramètre \"levels\" invalide") } levels = data.levels.map(l => l.toUpperCase()) } const type = optionalString(data.type, "type", 64) const search = optionalString(data.search, "search", 200) const result = await LogReader.readLog(data.name, { offset, limit, levels, type, search }) if (!result) throw new AdminError("NOT_FOUND", "Fichier de logs introuvable") let lineStart = result.offset try { lineStart = await firstLineOffset(data.name, result.offset, result.nextOffset) } catch (e) { lineStart = result.offset } return { ...result, firstLineOffset: lineStart } }) AdminRequest("/ADMIN/LOGS/SUBSCRIBE", async () => { socket.join(LOGS_ROOM) await ensureTail() return { name: LogReader.getCurrentName(), lines: LogReader.getBuffer(300) } }) AdminRequest("/ADMIN/LOGS/UNSUBSCRIBE", async () => { socket.leave(LOGS_ROOM) return true }) AdminRequest("/ADMIN/LOGS/DELETE", async (payload, me) => { const name = asObject(payload).name if (!LogReader.isValidName(name)) throw new AdminError("INVALID_PARAMS", "Nom de fichier invalide") const result = await LogReader.deleteLog(name) if (result === "protected") throw new AdminError("TARGET_PROTECTED", "Le fichier de logs courant ne peut pas être supprimé") if (result === "not_found") throw new AdminError("NOT_FOUND", "Fichier de logs introuvable") audit(me, "suppression du fichier de logs", name) return { deleted: name } }) // 3. COMPTES AdminRequest("/ADMIN/USERS/LIST", async (payload) => { const data = asObject(payload) const search = optionalString(data.search, "search", 100) const filter = data.filter === undefined || data.filter === null ? "all" : data.filter if (!USER_FILTERS.includes(filter)) throw new AdminError("INVALID_PARAMS", "Paramètre \"filter\" invalide") const page = optionalInt(data.page, "page", 1, 100000, 1) const pageSize = optionalInt(data.pageSize, "pageSize", 1, 200, 50) const ctx = buildContext() let list = users.getUsers().map(u => userSummary(u, ctx)) if (search) { const s = search.toLowerCase() list = list.filter(u => (u.id || "").includes(s) || (u.username || "").toLowerCase().includes(s) || (u.global_name || "").toLowerCase().includes(s)) } switch (filter) { case "online": list = list.filter(u => u.online); break case "admin": list = list.filter(u => u.isAdmin); break case "banned": list = list.filter(u => u.isFullBanned || u.bannedGuilds.length > 0); break case "deleted": list = list.filter(u => u.isDeleted); break } // En ligne d'abord, puis ordre alphabétique list.sort((a, b) => (b.online - a.online) || (a.global_name || a.username || "").localeCompare(b.global_name || b.username || "")) const start = (page - 1) * pageSize return { total: list.length, page, pageSize, items: list.slice(start, start + pageSize) } }) AdminRequest("/ADMIN/USERS/GET", async (payload) => { const userId = userIdFrom(payload) const user = getTarget(userId) const ctx = buildContext() const summary = userSummary(user, ctx) const guilds = [] for (const guildId of summary.guildIds) { const guild = discordBot.getGuilds().get(guildId) if (!guild) continue guilds.push({ id: guildId, name: guild.name ?? null, icon: guild.icon ?? null, isOwner: guild.owner === userId, isMod: user.isMod(guildId), isBanned: user.isBanned(guildId) }) } const sockets = (ctx.sockets.get(userId) || []).map(s => ({ socketId: s.id, address: s.handshake?.headers?.["x-forwarded-for"]?.split(",")[0]?.trim() || s.handshake?.address || null, userAgent: s.handshake?.headers?.["user-agent"] ?? null, connectedAt: toIso(s.handshake?.issued), guildId: socketGuildId(s) })) const userPlaylists = Array.isArray(ctx.playlists?.[userId]) ? ctx.playlists[userId] : [] const userHistory = Array.isArray(ctx.history?.[userId]) ? ctx.history[userId] : [] const media = dbData("media", []) return { ...summary, // Identité Discord complète (jamais auth ni tokens) identity: { ...user.identity }, guilds, sockets, playlists: userPlaylists.map(p => ({ playlistId: p?.playlistId != null ? String(p.playlistId) : null, title: p?.title ?? null, type: p?.type ?? null, songCount: Array.isArray(p?.songs) ? p.songs.length : 0 })), history: userHistory.map(songLite).filter(Boolean), media: (Array.isArray(media) ? media : []).filter(m => m?.userId === userId).map(m => ({ id: m.id ?? null, name: m.name ?? null, size: safeNum(m.size), createdAt: toIso(m.createdAt), url: m.url ?? null })) } }) AdminRequest("/ADMIN/USERS/SET_ADMIN", async (payload, me) => { const data = asObject(payload) const userId = requireId(data.userId) const value = requireBool(data.value) return setAdminValue(me, userId, value) }) // Ancien nom : bascule, userId en chaîne AdminRequest("/ADMIN/USERS/SWITCH_ADMIN", async (payload, me) => { const userId = userIdFrom(payload) const target = getTarget(userId) return setAdminValue(me, userId, !target.isAdmin()) }) AdminRequest("/ADMIN/USERS/SET_FULL_BAN", async (payload, me) => { const data = asObject(payload) const userId = requireId(data.userId) const value = requireBool(data.value) const reason = optionalString(data.reason, "reason", 500) return setFullBanValue(me, userId, value, reason) }) // Ancien nom : bascule, userId en chaîne AdminRequest("/ADMIN/USERS/FULL_BAN", async (payload, me) => { const userId = userIdFrom(payload) const target = getTarget(userId) return setFullBanValue(me, userId, !target.isFullBanned(), null) }) AdminRequest("/ADMIN/USERS/SET_GUILD_BAN", async (payload, me) => { const data = asObject(payload) const userId = requireId(data.userId) const guildId = requireId(data.guildId, "guildId") const value = requireBool(data.value) if (userId === me.identity.id) throw new AdminError("SELF_ACTION", "Vous ne pouvez pas vous bannir vous-même") const target = getTarget(userId) if (!discordBot.getGuilds().has(guildId)) throw new AdminError("NOT_FOUND", "Serveur introuvable") if (value && target.isAdmin()) throw new AdminError("TARGET_PROTECTED", "Un administrateur ne peut pas être banni") const state = await users.setGuildBan(userId, guildId, value) if (state === null) throw new AdminError("INTERNAL", "Modification impossible") audit(me, value ? "bannissement du serveur" : "levée du bannissement du serveur", `${describeUser(target)} sur ${guildId}`) if (value && io) io.in("user:" + userId).socketsLeave(guildId) syncUserSockets(userId) return freshSummary(userId) }) AdminRequest("/ADMIN/USERS/SET_GUILD_MOD", async (payload, me) => { const data = asObject(payload) const userId = requireId(data.userId) const guildId = requireId(data.guildId, "guildId") const value = requireBool(data.value) if (userId === me.identity.id) throw new AdminError("SELF_ACTION", "Vous ne pouvez pas modifier vos propres droits") const target = getTarget(userId) if (target.labels.includes("DELETED")) throw new AdminError("INVALID_PARAMS", "Ce compte est supprimé") if (!discordBot.getGuilds().has(guildId)) throw new AdminError("NOT_FOUND", "Serveur introuvable") if (value && (target.isFullBanned() || target.labels.includes("BAN_" + guildId))) { throw new AdminError("INVALID_PARAMS", "Cet utilisateur est banni : levez d'abord le bannissement") } const state = await users.setGuildMod(userId, guildId, value) if (state === null) throw new AdminError("INTERNAL", "Modification impossible") audit(me, value ? "nomination modérateur" : "retrait modérateur", `${describeUser(target)} sur ${guildId}`) syncUserSockets(userId) return freshSummary(userId) }) AdminRequest("/ADMIN/USERS/DISCONNECT", async (payload, me) => { const data = asObject(payload) const userId = requireId(data.userId) const revokeTokens = data.revokeTokens === undefined ? false : requireBool(data.revokeTokens, "revokeTokens") const message = optionalString(data.message, "message", 200) if (userId === me.identity.id) throw new AdminError("SELF_ACTION", "Utilisez la déconnexion de votre compte") const target = getTarget(userId) const disconnectedSockets = disconnectUser(userId, message || "Vous avez été déconnecté par un administrateur") const revokedTokens = revokeTokens ? users.revokeTokens(userId) : 0 audit(me, revokeTokens ? "déconnexion et révocation des sessions" : "déconnexion", `${describeUser(target)} (${disconnectedSockets} socket(s), ${revokedTokens} token(s))`) process.emit("USERS_UPDATE") return { disconnectedSockets, revokedTokens } }) AdminRequest("/ADMIN/USERS/REFRESH", async (payload, me) => { const userId = userIdFrom(payload) const target = getTarget(userId) if (target.labels.includes("DELETED")) throw new AdminError("INVALID_PARAMS", "Ce compte est supprimé") if (!target.auth) throw new AdminError("UNAVAILABLE", "Aucune authentification Discord pour ce compte") const identity = await users.updateIdentity(userId) if (!identity) throw new AdminError("UNAVAILABLE", "L'API Discord n'a pas répondu correctement") users.getUserById(userId)?.justUpdated() audit(me, "rafraîchissement de l'identité", describeUser(target)) process.emit("USERS_UPDATE") return freshSummary(userId) }) AdminRequest("/ADMIN/USERS/DELETE", async (payload, me) => { const userId = userIdFrom(payload) const data = asObject(payload) // Purge uniquement sur demande explicite (l'ancien format "id" seul ne purge plus rien) const purge = data.purge === undefined || data.purge === null ? false : requireBool(data.purge, "purge") return deleteUserAccount(me, userId, purge) }) // 4. SERVEURS DISCORD AdminRequest("/ADMIN/GUILDS/LIST", async (payload) => { const search = optionalString(asObject(payload).search, "search", 100) return guildsList(search) }) AdminRequest("/ADMIN/GUILDS/GET", async (payload) => { const guildId = requireId(asObject(payload).guildId, "guildId") const guildData = discordBot.getGuilds().get(guildId) if (!guildData) throw new AdminError("NOT_FOUND", "Serveur introuvable") const ctx = buildContext() const summary = guildSummary(guildData, ctx) const discordGuild = getClient().guilds.cache.get(guildId) const roles = discordBot.getGuildRoles(guildId).sort((a, b) => b.position - a.position) const members = [] for (const memberId of guildData.allMembers || []) { const user = users.getUserById(memberId) if (!user || user.labels.includes("DELETED")) continue members.push({ ...userLite(user.identity), isOwner: guildData.owner === memberId, isMod: user.isMod(guildId), isBanned: user.isBanned(guildId), isAdmin: user.isAdmin(), online: ctx.sockets.has(memberId) }) } const voiceChannels = [] if (discordGuild) { for (const channel of discordGuild.channels.cache.values()) { if (typeof channel.isVoiceBased !== "function" || !channel.isVoiceBased()) continue voiceChannels.push({ id: channel.id, name: channel.name, members: channel.members ? channel.members.filter(m => !m.user?.bot).size : 0 }) } } const metrics = SafeMetric.getGuildMetrics(guildId).map(m => ({ name: m.name, description: m.description ?? m.name, value: safeNum(m.value), invalid: !Number.isFinite(Number(m.value)) })) return { ...summary, roles, members, voiceChannels, metrics } }) AdminRequest("/ADMIN/GUILDS/LEAVE", async (payload, me) => { const data = asObject(payload) const guildId = requireId(data.guildId, "guildId") if (typeof data.confirmName !== "string") throw new AdminError("INVALID_PARAMS", "Confirmation du nom du serveur requise") const discordGuild = getClient().guilds.cache.get(guildId) const guildData = discordBot.getGuilds().get(guildId) if (!discordGuild && !guildData) throw new AdminError("NOT_FOUND", "Serveur introuvable") if (guildId === configuration.getMediaGuildId()) { throw new AdminError("TARGET_PROTECTED", "Le serveur de la base multimédia ne peut pas être quitté") } const name = discordGuild?.name ?? guildData?.name if (data.confirmName.trim() !== String(name ?? "").trim()) throw new AdminError("INVALID_PARAMS", "Le nom de confirmation ne correspond pas au nom du serveur") if (!discordGuild) throw new AdminError("NOT_FOUND", "Le bot n'est plus sur ce serveur") const player = players.AllPlayers.get(guildId) if (isConnectedPlayer(player)) { try { await player.leave("admin") } catch (e) { alog.error(`Impossible de quitter le salon vocal de ${guildId} : ${e?.message || e}`) } } if (typeof discordBot.clearAutoLeaveTimer === "function") discordBot.clearAutoLeaveTimer(guildId) try { await discordGuild.leave() } catch (e) { throw new AdminError("UNAVAILABLE", `Discord a refusé le départ du serveur : ${e?.message || e}`) } audit(me, "départ du serveur", `${name} (${guildId})`) // guildDelete retire aussi l'entrée, on le fait tout de suite pour une réponse cohérente discordBot.getGuilds().delete(guildId) if (io) { io.to(guildId).emit("/GUILD/UPDATE") io.in(guildId).socketsLeave(guildId) } process.emit("USERS_UPDATE") process.emit("GUILDS_UPDATE") return { left: true, guildId } }) AdminRequest("/ADMIN/GUILDS/SET_SECURE_ROLE", async (payload, me) => { const data = asObject(payload) const guildId = requireId(data.guildId, "guildId") if (!discordBot.getGuilds().has(guildId)) throw new AdminError("NOT_FOUND", "Serveur introuvable") let role = null if (data.roleId !== null && data.roleId !== undefined) { const roleId = requireId(data.roleId, "roleId") const found = discordBot.getGuildRoles(guildId).find(r => r.id === roleId) if (!found) throw new AdminError("INVALID_PARAMS", "Rôle introuvable sur ce serveur") // @everyone équivaut à aucun rôle role = found.name === "@everyone" ? null : { id: found.id, name: found.name, color: found.color, position: found.position } } serverSettings.setSecureRole(guildId, role) audit(me, "rôle sécurisé", `${guildId} -> ${role ? role.name + " (" + role.id + ")" : "aucun"}`) process.emit("GUILDS_UPDATE") return { secureRole: role ? { id: role.id, name: role.name } : null } }) // Playlists partagées d'un serveur : {guildId, enabled?, editors?} (désactiver masque sans rien supprimer) AdminRequest("/ADMIN/GUILDS/SET_SHARED_PLAYLISTS", async (payload, me) => { const data = asObject(payload) const guildId = requireId(data.guildId, "guildId") if (!discordBot.getGuilds().has(guildId)) throw new AdminError("NOT_FOUND", "Serveur introuvable") const patch = {} if (data.enabled !== undefined) patch.enabled = requireBool(data.enabled, "enabled") if (data.editors !== undefined) { if (!SHARED_PLAYLISTS_EDITORS.includes(data.editors)) throw new AdminError("INVALID_PARAMS", "Paramètre \"editors\" invalide (members ou mods)") patch.editors = data.editors } if (Object.keys(patch).length === 0) throw new AdminError("INVALID_PARAMS", "Aucune modification demandée (enabled ou editors)") const settings = serverSettings.setSharedPlaylists(guildId, patch) if (!settings) throw new AdminError("INVALID_PARAMS", "Réglage des playlists partagées invalide") audit(me, "playlists partagées", `${guildId} -> ${settings.enabled ? "activées" : "désactivées"}, modification : ${settings.editors}`) process.emit("GUILDS_UPDATE") // Les membres présents sur ce serveur rechargent leurs playlists partagées process.emit("GUILD_PLAYLISTS_UPDATE", guildId) return { sharedPlaylists: sharedPlaylistsSummary(guildId) } }) AdminRequest("/ADMIN/GUILDS/REFRESH", async (payload, me) => { if (typeof discordBot.refreshGuilds !== "function") throw new AdminError("UNAVAILABLE", "Rafraîchissement indisponible") await discordBot.refreshGuilds() audit(me, "rafraîchissement des serveurs") process.emit("USERS_UPDATE") return guildsList(null) }) // 5. PLAYERS AdminRequest("/ADMIN/PLAYERS/LIST", async () => playersList()) AdminRequest("/ADMIN/PLAYERS/GET", async (payload) => { const guildId = requireId(asObject(payload).guildId, "guildId") const player = getConnectedPlayer(guildId) const summary = playerSummary(player, { full: true }) if (!summary) throw new AdminError("UNAVAILABLE", "État du player illisible") return summary }) AdminRequest("/ADMIN/PLAYERS/ACTION", async (payload, me) => { const data = asObject(payload) const guildId = requireId(data.guildId, "guildId") if (!PLAYER_ACTIONS.includes(data.action)) throw new AdminError("INVALID_PARAMS", "Action inconnue") // loop / shuffle : valeur explicite facultative (sinon bascule) let value if ((data.action === "loop" || data.action === "shuffle") && data.value !== undefined && data.value !== null) { value = requireBool(data.value) } const player = getConnectedPlayer(guildId) const guildName = discordBot.getGuilds().get(guildId)?.name ?? guildId const result = await runPlayerAction(player, data.action, value) // Journalisé après coup : une action refusée (UNAVAILABLE...) n'apparaît pas comme effectuée audit(me, `player ${data.action}${value === undefined ? "" : (value ? " (activé)" : " (désactivé)")}`, `sur ${guildName} (${guildId})`) return result }) // Ancien nom (corrigé) : états bruts des players connectés AdminRequest("/ADMIN/PLAYER/GETALLSTATE", async () => { const states = [] for (const player of connectedPlayers()) { try { states.push(player.getState()) } catch (e) { alog.error(`Lecture de l'état du player ${player.guildId} impossible : ${e?.message || e}`) } } return states }) // 6. WARP AdminRequest("/ADMIN/WARP/STATUS", async () => warp.getStatus()) AdminRequest("/ADMIN/WARP/CHECK", async () => { if (!warp.getStatus().configured) throw new AdminError("UNAVAILABLE", "Aucun proxy WARP configuré") let timer try { await Promise.race([ warp.checkHealth(), new Promise((resolve, reject) => { timer = setTimeout(() => reject(new AdminError("UNAVAILABLE", "Délai de vérification dépassé")), WARP_CHECK_TIMEOUT) }) ]) } finally { clearTimeout(timer) } return warp.getStatus() }) AdminRequest("/ADMIN/WARP/CONFIG", async (payload, me) => { const data = asObject(payload) const patch = {} for (const key of ["enabled", "url", "fallbackDirect"]) { if (data[key] !== undefined) patch[key] = data[key] } if (Object.keys(patch).length === 0) throw new AdminError("INVALID_PARAMS", "Aucune modification demandée") let result try { // Champs verrouillés par l'environnement, URL invalide... : INVALID_PARAMS result = await warp.setConfig(patch, { check: false }) } catch (e) { if (e?.code === "INVALID_PARAMS") throw new AdminError("INVALID_PARAMS", e.message) throw e } // Jamais l'URL brute dans le journal (identifiants éventuels) : setConfig masque déjà les URL if (result.changes.length) audit(me, "configuration WARP", result.changes.join(", ")) // Contrôle immédiat du proxy, borné pour répondre avant le délai du client if (warp.getStatus().configured) { let timer try { await Promise.race([ warp.checkHealth(), new Promise((resolve) => { timer = setTimeout(resolve, WARP_CHECK_TIMEOUT) timer.unref?.() }) ]) } finally { clearTimeout(timer) } } return { status: warp.getStatus(), changes: result.changes } }) // 7. MÉTRIQUES AdminRequest("/ADMIN/METRICS", async () => metricsData()) AdminRequest("/ADMIN/METRICS/REPAIR", async (payload, me) => { const repaired = SafeMetric.repair() audit(me, "réparation des métriques", `${repaired} valeur(s) corrigée(s)`) return { repaired } }) AdminRequest("/ADMIN/METRICS/DELETE", async (payload, me) => { const name = requireString(asObject(payload).name, "name", 1, 200) if (!SafeMetric.remove(name)) throw new AdminError("NOT_FOUND", "Métrique introuvable") audit(me, "suppression de la métrique", name) return { deleted: name } }) // 8. MAINTENANCE AdminRequest("/ADMIN/MAINTENANCE/RESTART", async (payload, me) => { // Une chaîne seule reste acceptée (ancien client) const reason = requireString(typeof payload === "string" ? payload : asObject(payload).reason, "reason", 3, 200) if (maintenance.isRestarting()) throw new AdminError("UNAVAILABLE", "Un redémarrage est déjà en cours") audit(me, "redémarrage", `raison : ${reason}`) const at = new Date().toISOString() // Après la réponse : annonce "/MAINTENANCE" (via l'événement MAINTENANCE), départ des salons vocaux puis arrêt setTimeout(() => maintenance.restart(reason), 50) return { restarting: true, at } }) AdminRequest("/ADMIN/MAINTENANCE/ANNOUNCE", async (payload, me) => { const data = asObject(payload) const message = requireString(data.message, "message", 1, 500) const level = data.level === undefined || data.level === null ? "info" : data.level if (level !== "info" && level !== "warn") throw new AdminError("INVALID_PARAMS", "Paramètre \"level\" invalide (info ou warn)") if (io) io.emit("/ANNOUNCE", { message, level, at: new Date().toISOString() }) audit(me, "annonce globale", `[${level}] ${message}`) return true }) AdminRequest("/ADMIN/DATABASES", async () => databasesInfo(true)) AdminRequest("/ADMIN/PLAYLISTS/LIST", async (payload) => { const data = asObject(payload) const userId = data.userId === undefined || data.userId === null ? null : requireId(data.userId) const all = dbData("Playlists", {}) const result = [] for (const ownerId of Object.keys(all)) { if (userId && ownerId !== userId) continue const list = Array.isArray(all[ownerId]) ? all[ownerId] : [] if (list.length === 0 && !userId) continue const owner = users.getUserById(ownerId) result.push({ userId: ownerId, username: owner?.identity?.username ?? null, // Identité légère pour l'affichage (jamais auth ni tokens) owner: owner ? { ...userLite(owner.identity), isAdmin: owner.labels?.includes("ADMIN") ?? false, isDeleted: owner.labels?.includes("DELETED") ?? false } : null, playlists: list.map(playlistLite) }) } return result }) // Playlists partagées des serveurs : {guildId?}. Serveurs ayant des playlists (même désactivées ou quittés // par le bot) ou l'option activée AdminRequest("/ADMIN/PLAYLISTS/GUILDS", async (payload) => { const data = asObject(payload) const filter = data.guildId === undefined || data.guildId === null ? null : requireId(data.guildId, "guildId") const botGuilds = discordBot.getGuilds() const ids = new Set(guildPlaylists.listGuildIds()) if (typeof serverSettings.getSharedPlaylists === "function") { for (const guildId of botGuilds.keys()) { if (serverSettings.getSharedPlaylists(guildId)?.enabled) ids.add(guildId) } } if (filter) { ids.clear() ids.add(filter) } const result = [] for (const guildId of ids) { const list = guildPlaylists.getPlaylists(guildId) const shared = sharedPlaylistsSummary(guildId) if (!filter && list.length === 0 && !shared.enabled) continue const guild = botGuilds.get(guildId) result.push({ guildId, guildName: guild?.name ?? null, guildIcon: guild?.icon ?? null, settings: { enabled: shared.enabled, editors: shared.editors }, playlists: list.map(p => ({ ...playlistLite(p), createdBy: p?.createdBy ? userLite(p.createdBy) : null })) }) } return result.sort((a, b) => (a.guildName || a.guildId).localeCompare(b.guildName || b.guildId)) }) // {userId, playlistId} : playlist personnelle ; {guildId, playlistId} : playlist partagée d'un serveur AdminRequest("/ADMIN/PLAYLISTS/DELETE", async (payload, me) => { const data = asObject(payload) const playlistId = requireString(typeof data.playlistId === "number" ? String(data.playlistId) : data.playlistId, "playlistId", 1, 64) if (data.guildId !== undefined && data.guildId !== null) { const guildId = requireId(data.guildId, "guildId") const playlist = guildPlaylists.getPlaylist(guildId, playlistId) if (!playlist) throw new AdminError("NOT_FOUND", "Playlist introuvable") if (!guildPlaylists.deletePlaylist(guildId, playlist.playlistId)) throw new AdminError("NOT_FOUND", "Playlist introuvable") audit(me, "suppression de la playlist partagée", `"${playlist.title}" (${playlistId}) du serveur ${guildId}`) process.emit("GUILDS_UPDATE") process.emit("GUILD_PLAYLISTS_UPDATE", guildId) return { deleted: true } } const userId = requireId(data.userId) const list = dbData("Playlists", {})[userId] const playlist = Array.isArray(list) ? list.find(p => p?.playlistId != null && String(p.playlistId) === playlistId) : null if (!playlist) throw new AdminError("NOT_FOUND", "Playlist introuvable") if (playlists.removePlaylist(userId, playlist.playlistId) === false) throw new AdminError("NOT_FOUND", "Playlist introuvable") audit(me, "suppression de la playlist", `"${playlist.title}" (${playlistId}) de ${userId}`) // Le propriétaire connecté recharge ses playlists process.emit("PLAYLISTS_UPDATE", userId) return { deleted: true } }) // 9. YOUTUBE (cookies, yt-dlp, réglages, diagnostics) // Jamais de contenu de cookie dans les réponses ni dans les logs : seulement des noms, des compteurs et des dates AdminRequest("/ADMIN/YOUTUBE/STATUS", async () => { await refreshYtdlpVersion() return youtubeStatus() }) AdminRequest("/ADMIN/YOUTUBE/COOKIES/IMPORT", async (payload, me) => { const data = asObject(payload) if (typeof data.content !== "string" || !data.content.trim() || data.content.length > COOKIES_MAX_LENGTH) { throw new AdminError("INVALID_PARAMS", `Contenu des cookies invalide (1 à ${COOKIES_MAX_LENGTH} caractères)`) } const source = data.source === undefined || data.source === null ? "paste" : data.source if (!COOKIE_IMPORT_SOURCES.includes(source)) throw new AdminError("INVALID_PARAMS", "Paramètre \"source\" invalide (paste ou file)") let result try { result = youtube.cookies.importCookies(data.content, { source, by: adminIdentity(me) }) } catch (e) { throw youtubeError(e) } audit(me, "import des cookies YouTube", `(${source === "file" ? "fichier" : "collage"}, ${result.status.count} cookie(s), ${result.status.loggedIn ? "compte connecté" : "aucun compte connecté"})`) // Vérification en direct en arrière-plan : le client reçoit le résultat par la poussée const checking = startCookieCheck() return { status: youtubeStatus(), warnings: result.warnings, checking } }) AdminRequest("/ADMIN/YOUTUBE/COOKIES/BROWSER", async (payload, me) => { const data = asObject(payload) if (!YOUTUBE_BROWSERS.includes(data.browser)) throw new AdminError("INVALID_PARAMS", "Navigateur inconnu") const profile = optionalString(data.profile, "profile", 200) // Syntaxe de yt-dlp : NAVIGATEUR[:PROFIL][::CONTENEUR] (pas de retour à la ligne, pas de "::") if (profile && (/[\r\n\0]/.test(profile) || profile.includes("::"))) throw new AdminError("INVALID_PARAMS", "Paramètre \"profile\" invalide") let result try { result = await importFromBrowser(data.browser, profile, adminIdentity(me)) } catch (e) { throw youtubeError(e) } audit(me, "import des cookies YouTube", `(navigateur ${data.browser}${profile ? ", profil personnalisé" : ""}, ${result.status.count} cookie(s), ${result.status.loggedIn ? "compte connecté" : "aucun compte connecté"})`) const checking = startCookieCheck() return { status: youtubeStatus(), warnings: result.warnings, checking } }) AdminRequest("/ADMIN/YOUTUBE/COOKIES/CHECK", async (payload, me) => { if (!youtube.cookies.getStatus().present) throw new AdminError("UNAVAILABLE", "Aucun cookie YouTube enregistré") let check try { check = await youtube.check.checkCookies() } catch (e) { throw youtubeError(e) } // L'état des cookies est déjà dans "status" const { status: _cookieStatus, ...result } = check audit(me, "vérification des cookies YouTube", `${result.ok ? "réussie" : "échouée"} : ${result.message}`) return { result, status: youtubeStatus() } }) AdminRequest("/ADMIN/YOUTUBE/COOKIES/DELETE", async (payload, me) => { const before = youtube.cookies.getStatus() try { youtube.cookies.deleteCookies() } catch (e) { throw new AdminError("INTERNAL", `Suppression des cookies impossible : ${e?.message || e}`) } audit(me, "suppression des cookies YouTube", before.present ? `(${before.count} cookie(s))` : "(aucun cookie enregistré)") return youtubeStatus() }) AdminRequest("/ADMIN/YOUTUBE/SETTINGS", async (payload, me) => { const patch = asObject(payload).patch if (!patch || typeof patch !== "object" || Array.isArray(patch)) throw new AdminError("INVALID_PARAMS", "Paramètre \"patch\" invalide (objet attendu)") const keys = Object.keys(patch) if (keys.length === 0) throw new AdminError("INVALID_PARAMS", "Aucun réglage à modifier") if (keys.length > YOUTUBE_SETTINGS_MAX_KEYS) throw new AdminError("INVALID_PARAMS", "Trop de réglages dans la requête") const before = youtube.settings.get() try { // Clés inconnues, valeurs invalides et clés verrouillées par l'environnement : INVALID_PARAMS youtube.settings.update(patch, adminIdentity(me)) } catch (e) { throw youtubeError(e) } const changes = describeSettingsChange(before, youtube.settings.get()) if (changes) audit(me, "réglages YouTube", changes) return youtubeStatus() }) AdminRequest("/ADMIN/YOUTUBE/TEST", async (payload) => { const data = asObject(payload) const url = optionalString(data.url, "url", 500) if (url) { let parsed = null try { parsed = new URL(url) } catch (e) { parsed = null } if (!parsed || !/^https?:$/.test(parsed.protocol) || /\s/.test(url)) throw new AdminError("INVALID_PARAMS", "Paramètre \"url\" invalide (lien http(s) attendu)") } const route = data.route === undefined || data.route === null ? "auto" : data.route if (!YOUTUBE_TEST_ROUTES.includes(route)) throw new AdminError("INVALID_PARAMS", "Paramètre \"route\" invalide (auto, warp ou direct)") const identity = data.identity === undefined || data.identity === null ? "auto" : data.identity if (!YOUTUBE_TEST_IDENTITIES.includes(identity)) throw new AdminError("INVALID_PARAMS", "Paramètre \"identity\" invalide (auto, anon ou cookies)") // Vide ou absent : clients des réglages const clients = optionalString(data.clients, "clients", 200) try { return await youtube.check.testPlayback({ url: url || undefined, route, identity, clients: clients ?? undefined }) } catch (e) { throw youtubeError(e) } }) AdminRequest("/ADMIN/YOUTUBE/UPDATE", async (payload, me) => { let result try { // Une mise à jour déjà en cours (automatique) est simplement attendue result = await youtube.ytdlp.update({ reason: `manuelle, par ${me.identity.username}` }) } catch (e) { throw youtubeError(e) } audit(me, "mise à jour de yt-dlp", result?.message || "") return { result, status: youtubeStatus() } }) AdminRequest("/ADMIN/YOUTUBE/DIAGNOSTICS/CLEAR", async (payload, me) => { youtube.diagnostics.clear() audit(me, "effacement des diagnostics YouTube") return youtubeStatus() }) // 10. CONFIGURATION (config.json) ET INTÉGRATIONS // Les secrets ne sont jamais renvoyés ni journalisés : seulement {set, length} AdminRequest("/ADMIN/CONFIG/GET", async () => configPayload()) AdminRequest("/ADMIN/CONFIG/SET", async (payload, me) => { const data = asObject(payload) if (typeof data.key !== "string" || !configuration.getKeys().includes(data.key)) { throw new AdminError("INVALID_PARAMS", "Clé de configuration inconnue") } if (data.value === undefined || data.value === null) throw new AdminError("INVALID_PARAMS", "Paramètre \"value\" manquant") let result try { result = configuration.setValue(data.key, data.value) } catch (e) { if (e?.code === "INVALID_PARAMS") throw new AdminError("INVALID_PARAMS", e.message) throw e } if (result.changed) { if (data.key.startsWith("api.spotify.")) spotifyLastTest = null // Clé seulement, jamais la valeur audit(me, "modification de la configuration", data.key + (result.requiresRestart ? " (redémarrage requis)" : "")) // Serveur de la base multimédia : protégé dans la liste des serveurs if (data.key === "media.guildId") process.emit("GUILDS_UPDATE") } return { ...configPayload(), result } }) AdminRequest("/ADMIN/CONFIG/SPOTIFY/TEST", async (payload, me) => { const result = await testSpotify() audit(me, "test des identifiants Spotify", result.ok ? "réussi" : "échoué") return { result, integrations: integrationsStatus() } }) } // POUSSÉES GLOBALES /** * Initialisation unique : poussées périodiques et événementielles vers les rooms ADMIN / ADMIN_LOGS * @param {import('socket.io').Server} ioInstance * @param {Object} [deps] réservé (dépendances supplémentaires) */ function init(ioInstance, deps = {}) { if (initialized) return initialized = true io = ioInstance LogReader.init() // Vue d'ensemble toutes les 10 s, seulement si un admin regarde setInterval(async () => { if (roomSize(ADMIN_ROOM) === 0) return try { io.to(ADMIN_ROOM).emit("/ADMIN/OVERVIEW/UPDATE", await buildOverview()) } catch (e) { alog.error(`Vue d'ensemble admin impossible : ${e?.stack || e}`) } }, OVERVIEW_INTERVAL).unref() // Players : chaque changement d'état (coalescé à 250 ms) const pushPlayers = debounce(() => { if (roomSize(ADMIN_ROOM) === 0) return io.to(ADMIN_ROOM).emit("/ADMIN/PLAYERS/UPDATE", playersList()) }, 250) process.on("PLAYER_STATE", pushPlayers) process.on("PLAYERS_UPDATE", pushPlayers) // Processus yt-dlp (PID / route affichés dans les players et la section WARP) : Youtube.js ne déclenche // pas d'état player à leur démarrage ou à leur fin, on compare donc leur signature toutes les 2 s let ytdlpSignature = "" setInterval(() => { if (roomSize(ADMIN_ROOM) === 0) return const map = getYoutubeProcesses() const signature = map ? [...map.entries()].map(([guildId, proc]) => `${guildId}:${proc?.pid}:${proc?.route}`).sort().join("|") : "" if (signature === ytdlpSignature) return ytdlpSignature = signature pushPlayers() }, YTDLP_PROCESS_POLL).unref() // Utilisateurs connectés const pushUsers = debounce(() => { if (roomSize(ADMIN_ROOM) === 0) return io.to(ADMIN_ROOM).emit("/ADMIN/USERS/UPDATE", onlineUsersPayload()) }, 300) process.on("USERS_UPDATE", pushUsers) // Serveurs Discord const pushGuilds = debounce(() => { if (roomSize(ADMIN_ROOM) === 0) return io.to(ADMIN_ROOM).emit("/ADMIN/GUILDS/UPDATE", guildsList(null)) }, 500) process.on("GUILDS_UPDATE", pushGuilds) // Playlists modifiées (personnelles ou partagées) : la section Playlists ouverte se relit d'elle-même const changedScopes = new Set() const pushPlaylists = debounce(() => { const scopes = [...changedScopes] changedScopes.clear() if (roomSize(ADMIN_ROOM) === 0) return io.to(ADMIN_ROOM).emit("/ADMIN/PLAYLISTS/UPDATE", { scopes }) }, 800) process.on("PLAYLISTS_UPDATE", () => { changedScopes.add("personal"); pushPlaylists() }) process.on("GUILD_PLAYLISTS_UPDATE", () => { changedScopes.add("guild"); pushPlaylists() }) // WARP : changement d'état du proxy process.on("WARP_UPDATE", () => { if (roomSize(ADMIN_ROOM) === 0) return try { io.to(ADMIN_ROOM).emit("/ADMIN/WARP/UPDATE", warp.getStatus()) } catch (e) { alog.error(`Poussée WARP impossible : ${e?.message || e}`) } }) // YouTube : cookies, réglages, mises à jour de yt-dlp, tentatives de lecture (coalescé à 500 ms) const pushYoutube = debounce(() => { if (roomSize(ADMIN_ROOM) === 0) return io.to(ADMIN_ROOM).emit("/ADMIN/YOUTUBE/UPDATE", youtubeStatus()) }, 500) process.on("YOUTUBE_UPDATE", pushYoutube) // Configuration modifiée (par un autre admin) : version masquée, jamais de secret const pushConfig = debounce(() => { if (roomSize(ADMIN_ROOM) === 0) return io.to(ADMIN_ROOM).emit("/ADMIN/CONFIG/UPDATE", configPayload()) }, 300) process.on("CONFIG_UPDATE", pushConfig) // Maintenance (redémarrage depuis l'admin ou la commande /restart) : prévient tous les clients process.on("MAINTENANCE", (payload) => { try { io.emit("/MAINTENANCE", { type: payload?.type || "restart", reason: payload?.reason ?? null, at: payload?.at || new Date().toISOString() }) } catch (e) { alog.error(`Annonce de maintenance impossible : ${e?.message || e}`) } }) // Plus personne n'écoute les logs : on arrête la surveillance du fichier io.of("/").adapter.on("delete-room", (room) => { if (room === LOGS_ROOM) LogReader.stopTail() }) alog.log("Module d'administration initialisé") } module.exports = { init, attach, AdminError, syncUserSockets, disconnectUser, ADMIN_ROOM, LOGS_ROOM }