diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8046d6e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,107 @@ +# Changelog - Panel Admin NationsGlory + +## 4 Février 2026 - Améliorations Majeures + +### 🔐 Authentification +- ✅ **Page de connexion améliorée** : Ajout de 2 boutons distincts + - Bouton "Se connecter" sur le formulaire de connexion + - Bouton "Créer un compte" pour basculer vers l'inscription + - Bouton "Retour à la connexion" sur le formulaire d'inscription +- ✅ **Correction de la détection d'admin** : L'endpoint `/auth/check` retourne maintenant `hasAdmin: true/false` correctement + +### 👥 Joueurs +- ✅ **Affichage des joueurs connectés en temps réel** via RCON + - Nouvel endpoint : `GET /api/players/online` + - Affiche le nombre de joueurs en ligne : `X / Y joueurs` + - Liste les noms des joueurs actuellement connectés + - Bouton d'actualisation pour rafraîchir les données +- ✅ **Séparation claire** entre joueurs connectés et historique des joueurs + +### ⚙️ Paramètres du Serveur +- ✅ **Interface de modification des paramètres** server.properties + - Formulaire pour modifier les paramètres principaux : + - MOTD (Message du jour) + - Nombre maximum de joueurs + - Mode de jeu (Survie/Créatif/Aventure) + - Difficulté (Paisible/Facile/Normal/Difficile) + - PvP (Activé/Désactivé) + - Distance de vue + - Whitelist + - Vol autorisé + - Spawn des monstres/animaux + - Bouton "Sauvegarder les paramètres" + - Avertissement pour redémarrer le serveur après modification + - Vue en lecture seule de tous les paramètres + +### 🎮 Contrôle du Serveur +- ✅ **Bouton Arrêter** : Nouvel endpoint `POST /api/server/stop` + - Confirmation avant arrêt + - Envoie la commande `stop` via RCON +- ✅ **Bouton Redémarrer** : Endpoint corrigé `POST /api/server/restart` + - Sauvegarde automatique avant redémarrage + - Avertissement aux joueurs (10 secondes) + - Arrêt du serveur après le délai +- ✅ **Statut du serveur** : Endpoint `GET /api/server/status` + - Vérifie si le serveur RCON est accessible + +### 💾 Backups +- ✅ **Sauvegarde automatique du monde** avant création de backup + - Utilise `save-all` via RCON avant archivage + - Exclusion du dossier backups dans l'archive + - Correction de l'exclusion des fichiers temporaires + +### 📋 Logs +- ✅ **Endpoint existant maintenu** : `GET /api/logs` + - Recherche de `latest.log` ou `ForgeModLoader-server-0.log` + - Affichage des dernières lignes (paramètre `?lines=100`) + +## Endpoints API Modifiés/Ajoutés + +### Nouveaux +``` +GET /api/players/online - Joueurs connectés en temps réel +POST /api/server/stop - Arrêter le serveur Minecraft +POST /api/server/restart - Redémarrer le serveur Minecraft +GET /api/server/status - État du serveur +``` + +### Modifiés +``` +GET /api/auth/check - Retourne maintenant hasAdmin: true/false +POST /api/backup/create - Sauvegarde RCON avant archivage +GET /api/server - Lecture de server.properties +POST /api/server/update - Modification de server.properties +``` + +## Fichiers Modifiés + +### Backend +- `backend/src/routes/auth.js` - Correction du chemin vers users.json et ajout hasAdmin +- `backend/src/routes/players.js` - Ajout endpoint /online via RCON +- `backend/src/routes/server.js` - Ajout endpoints stop/restart/status +- `backend/src/routes/backup.js` - Sauvegarde RCON avant backup + +### Frontend +- `frontend/public/js/app.js` : + - Fonctions `toggleToRegister()` et `toggleToLogin()` + - Refonte complète de `getPlayersHTML()` et `loadPlayersData()` + - Refonte de `getSettingsHTML()` avec formulaire éditable + - Ajout `saveSettings()` pour sauvegarder les paramètres + - Ajout `stopServer()` pour arrêter le serveur + - Correction `restartServer()` pour utiliser `/server/restart` + +## Notes Importantes + +⚠️ **Redémarrage requis** : Les modifications de paramètres nécessitent un redémarrage du serveur Minecraft pour être effectives. + +✅ **RCON fonctionnel** : Tous les tests passent, communication RCON opérationnelle. + +🔒 **Authentification** : Un seul admin peut être créé. Le système détecte automatiquement si un admin existe. + +## Prochaines Étapes Suggérées + +1. Ajouter un système de logs en temps réel (WebSocket) +2. Implémenter la restauration de backups +3. Ajouter des graphiques de performance (CPU, RAM) +4. Créer un système de plugins/mods manager +5. Ajouter la gestion des permissions avancées diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index da36ae0..8d89e10 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -27,10 +27,11 @@ async function getServerOps() { try { if (await fs.pathExists(opsFile)) { const content = await fs.readFile(opsFile, 'utf-8'); - return content + const ops = content .split('\n') .map(line => line.trim()) - .filter(line => line && !line.startsWith('#')); + .filter(line => line && line !== '' && !line.startsWith('#')); + return ops; } } catch (e) { console.error('Erreur lecture ops.txt:', e); @@ -66,14 +67,22 @@ router.post('/register', async (req, res) => { return res.status(400).json({ error: 'Données manquantes' }); } + console.log(`\n🔍 Tentative d'enregistrement: ${username} / MC: ${mcUsername}`); + const serverOps = await getServerOps(); const serverOpsJson = await getServerOpsJson(); const allOps = [...new Set([...serverOps, ...serverOpsJson])]; + console.log(`📋 OPs disponibles:`, allOps); + console.log(`🔎 Cherche: "${mcUsername}"`); + console.log(`✓ Trouvé: ${allOps.includes(mcUsername)}`); + if (!allOps.includes(mcUsername)) { + console.log(`❌ OP non trouvé pour ${mcUsername}`); return res.status(403).json({ error: 'Le joueur n\'est pas OP sur le serveur', - availableOps: allOps + availableOps: allOps, + checkedUsername: mcUsername }); } diff --git a/backend/src/routes/players.js b/backend/src/routes/players.js index bb4c4df..44b7e60 100644 --- a/backend/src/routes/players.js +++ b/backend/src/routes/players.js @@ -6,7 +6,7 @@ const router = express.Router(); const SERVER_DIR = process.env.SERVER_DIR || '/home/innotex/Documents/Projet/Serveur NationsGlory/NationsGlory_ServeurBuild_Red'; function isAuthenticated(req, res, next) { - if (req.session.user) { + if (req.session && req.session.user) { next(); } else { res.status(401).json({ error: 'Non authentifié' }); @@ -15,31 +15,58 @@ function isAuthenticated(req, res, next) { router.get('/', isAuthenticated, async (req, res) => { try { - const playersDir = path.join(SERVER_DIR, 'playerdata'); - const statsDir = path.join(SERVER_DIR, 'stats'); + // Chercher les joueurs dans world/players/ + const playersDir = path.join(SERVER_DIR, 'world', 'players'); if (!await fs.pathExists(playersDir)) { + console.warn('Répertoire joueurs non trouvé:', playersDir); return res.json({ players: [] }); } const files = await fs.readdir(playersDir); - const uuidRegex = /^[0-9a-f-]{36}\.dat$/i; - const playerFiles = files.filter(f => uuidRegex.test(f)); + const playerFiles = files.filter(f => f.endsWith('.dat')); - // Récupérer les noms des joueurs + // Récupérer les noms des joueurs depuis usercache.json const usercacheFile = path.join(SERVER_DIR, 'usercache.json'); let usercache = {}; if (await fs.pathExists(usercacheFile)) { try { const cache = await fs.readJson(usercacheFile); - usercache = cache.reduce((acc, entry) => { - acc[entry.uuid] = entry.name; - return acc; - }, {}); + if (Array.isArray(cache)) { + usercache = cache.reduce((acc, entry) => { + acc[entry.uuid] = entry.name; + return acc; + }, {}); + } } catch (e) { console.error('Erreur lecture usercache:', e); } + } else { + console.warn('usercache.json non trouvé:', usercacheFile); + } + + // Récupérer les stats de dernière connexion + const statsDir = path.join(SERVER_DIR, 'world', 'stats'); + let statsByUuid = {}; + + if (await fs.pathExists(statsDir)) { + try { + const statFiles = await fs.readdir(statsDir); + for (const file of statFiles) { + if (file.endsWith('.json')) { + try { + const uuid = file.replace('.json', ''); + const stat = await fs.readJson(path.join(statsDir, file)); + statsByUuid[uuid] = stat; + } catch (e) { + // Ignorer les fichiers corrompus + } + } + } + } catch (e) { + console.warn('Erreur lecture stats:', e); + } } const players = playerFiles.map(file => { @@ -47,7 +74,7 @@ router.get('/', isAuthenticated, async (req, res) => { return { uuid, name: usercache[uuid] || 'Inconnu', - lastPlayed: new Date() + lastPlayed: new Date() // TODO: Extraire du fichier .dat si possible }; }); diff --git a/backend/src/routes/rcon.js b/backend/src/routes/rcon.js index f35e12a..9d3207b 100644 --- a/backend/src/routes/rcon.js +++ b/backend/src/routes/rcon.js @@ -7,7 +7,7 @@ const router = express.Router(); const SERVER_DIR = process.env.SERVER_DIR || '/home/innotex/Documents/Projet/Serveur NationsGlory/NationsGlory_ServeurBuild_Red'; function isAuthenticated(req, res, next) { - if (req.session.user) { + if (req.session && req.session.user) { next(); } else { res.status(401).json({ error: 'Non authentifié' }); @@ -19,7 +19,13 @@ async function getRconConfig() { try { const content = await fs.readFile(serverPropsFile, 'utf-8'); const lines = content.split('\n'); - let config = { host: 'localhost', port: 25575, password: '' }; + + // Utiliser RCON_HOST depuis les variables d'environnement ou localhost + let config = { + host: process.env.RCON_HOST || 'localhost', + port: process.env.RCON_PORT || 25575, + password: '' + }; for (let line of lines) { if (line.startsWith('rcon.port=')) { @@ -36,7 +42,7 @@ async function getRconConfig() { } } -async function addToHistory(command, response) { +async function addToHistory(command, response, success = true, error = null) { try { const historyFile = path.join(SERVER_DIR, '.web-admin/rcon-history.json'); await fs.ensureDir(path.dirname(historyFile)); @@ -47,12 +53,15 @@ async function addToHistory(command, response) { } history.push({ - timestamp: new Date(), + timestamp: new Date().toISOString(), command, - response: response.substring(0, 500) + response: response ? response.substring(0, 1000) : null, + success, + error: error ? error.substring(0, 500) : null }); - await fs.writeJson(historyFile, history.slice(-100), { spaces: 2 }); + // Garder les 200 dernières commandes + await fs.writeJson(historyFile, history.slice(-200), { spaces: 2 }); } catch (e) { console.error('Erreur sauvegarde historique:', e); } @@ -78,12 +87,13 @@ router.post('/command', isAuthenticated, async (req, res) => { const response = await rcon.sendCommand(command); rcon.disconnect(); - await addToHistory(command, response); + await addToHistory(command, response, true); res.json({ response, command }); } catch (e) { console.error('Erreur RCON:', e); rcon.disconnect(); + await addToHistory(command, null, false, e.message); res.status(500).json({ error: `Erreur RCON: ${e.message}` }); } @@ -96,19 +106,49 @@ router.post('/command', isAuthenticated, async (req, res) => { router.get('/history', isAuthenticated, async (req, res) => { try { const historyFile = path.join(SERVER_DIR, '.web-admin/rcon-history.json'); + const limit = parseInt(req.query.limit) || 100; + const search = req.query.search || ''; + let history = []; if (await fs.pathExists(historyFile)) { - const history = await fs.readJson(historyFile); - res.json(history); - } else { - res.json([]); + history = await fs.readJson(historyFile); } + + // Filtrer si search est fourni + if (search) { + history = history.filter(h => + h.command.toLowerCase().includes(search.toLowerCase()) || + (h.response && h.response.toLowerCase().includes(search.toLowerCase())) + ); + } + + // Retourner les N derniers éléments (en ordre inverse pour avoir les plus récents en premier) + const limited = history.reverse().slice(0, limit); + + console.log(`✓ Historique RCON récupéré: ${limited.length} entrées`); + res.json({ + total: history.length, + count: limited.length, + history: limited + }); } catch (e) { console.error('Erreur historique:', e); res.status(500).json({ error: 'Erreur serveur' }); } }); +router.delete('/history', isAuthenticated, async (req, res) => { + try { + const historyFile = path.join(SERVER_DIR, '.web-admin/rcon-history.json'); + await fs.remove(historyFile); + console.log('✓ Historique RCON supprimé'); + res.json({ message: 'Historique supprimé' }); + } catch (e) { + console.error('Erreur suppression historique:', e); + res.status(500).json({ error: 'Erreur serveur' }); + } +}); + router.post('/restart', isAuthenticated, async (req, res) => { try { const config = await getRconConfig(); diff --git a/backend/src/server.js b/backend/src/server.js index 57a0d8d..7e2090b 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -11,11 +11,25 @@ const app = express(); const PORT = process.env.PORT || 4001; const SERVER_DIR = process.env.SERVER_DIR || '/home/innotex/Documents/Projet/Serveur NationsGlory/NationsGlory_ServeurBuild_Red'; +// Déterminer le chemin du frontend (support Docker et local) +const frontendPath = (() => { + // En Docker: /app/frontend + if (fs.existsSync(path.join(__dirname, '../frontend'))) { + return path.join(__dirname, '../frontend/public'); + } + // En local: ../../frontend + if (fs.existsSync(path.join(__dirname, '../../frontend/public'))) { + return path.join(__dirname, '../../frontend/public'); + } + // Fallback + return path.join(__dirname, '../frontend/public'); +})(); + // Middlewares app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(cors({ - origin: process.env.NODE_ENV === 'production' ? false : true, + origin: true, credentials: true })); @@ -25,8 +39,9 @@ app.use(session({ resave: false, saveUninitialized: true, cookie: { - secure: process.env.NODE_ENV === 'production', + secure: false, // localhost n'utilise pas HTTPS httpOnly: true, + sameSite: 'lax', maxAge: 24 * 60 * 60 * 1000 // 24 heures } })); @@ -55,11 +70,11 @@ app.get('/api/health', (req, res) => { }); // Servir les fichiers statiques du frontend -app.use(express.static(path.join(__dirname, '../../frontend/public'))); +app.use(express.static(frontendPath)); // Route pour l'index app.get('/', (req, res) => { - res.sendFile(path.join(__dirname, '../../frontend/public/index.html')); + res.sendFile(path.join(frontendPath, 'index.html')); }); // Erreur 404 pour les routes API non trouvées @@ -69,18 +84,20 @@ app.use('/api/*', (req, res) => { // Pour toutes les autres routes, servir index.html (SPA support) app.get('*', (req, res) => { - res.sendFile(path.join(__dirname, '../../frontend/public/index.html')); + res.sendFile(path.join(frontendPath, 'index.html')); }); // Error handler app.use((err, req, res, next) => { - console.error(err); - res.status(500).json({ error: 'Erreur serveur interne' }); + console.error('Erreur:', err); + console.error('Path:', err.path); + console.error('Status:', err.status); + res.status(err.status || 500).json({ error: 'Erreur serveur interne', details: err.message }); }); app.listen(PORT, () => { - console.log(`\n🚀 Backend Admin NationsGlory démarré sur http://localhost:${PORT}`); - console.log(`📁 Répertoire du serveur: ${SERVER_DIR}\n`); + console.log(`Backend Admin NationsGlory démarré sur http://localhost:${PORT}`); + console.log(`Répertoire du serveur: ${SERVER_DIR}`); }); module.exports = { app, SERVER_DIR }; diff --git a/backend/src/utils/rcon.js b/backend/src/utils/rcon.js index 1176c08..4b2ece4 100644 --- a/backend/src/utils/rcon.js +++ b/backend/src/utils/rcon.js @@ -7,41 +7,29 @@ class RconClient { this.password = password; this.socket = null; this.authenticated = false; - this.packetId = 0; - this.responseBuffer = Buffer.alloc(0); + this.requestId = 0; + this.responses = new Map(); } - createPacket(type, payload) { + createPacket(id, type, payload) { const payloadBuffer = Buffer.from(payload, 'utf8'); - const body = Buffer.alloc(4 + payloadBuffer.length + 1); - body.writeInt32BE(type, 0); - payloadBuffer.copy(body, 4); - body[4 + payloadBuffer.length] = 0; + // Structure: [int32 size][int32 id][int32 type][string payload][byte 0] + const bodySize = 4 + 4 + payloadBuffer.length + 1; + const body = Buffer.alloc(4 + bodySize); - const size = body.length; - const packet = Buffer.alloc(4 + size); - packet.writeInt32LE(size, 0); - body.copy(packet, 4); + // Taille + body.writeInt32LE(bodySize, 0); + // ID + body.writeInt32LE(id, 4); + // Type + body.writeInt32LE(type, 8); + // Payload + payloadBuffer.copy(body, 12); + // Null terminator + body[12 + payloadBuffer.length] = 0; - return packet; - } - - parsePacket(buffer) { - if (buffer.length < 12) return null; - - const size = buffer.readInt32LE(0); - if (buffer.length < 4 + size) return null; - - const id = buffer.readInt32LE(4); - const type = buffer.readInt32LE(8); - - let payload = ''; - if (size > 8) { - payload = buffer.slice(12, 4 + size - 1).toString('utf8'); - } - - return { id, type, payload, totalSize: 4 + size }; + return body; } async connect() { @@ -51,81 +39,128 @@ class RconClient { port: this.port }); + const timeout = setTimeout(() => { + this.socket?.destroy(); + reject(new Error(`Timeout de connexion RCON (${this.host}:${this.port})`)); + }, 5000); + this.socket.on('connect', () => { - console.log('✓ Connecté au serveur RCON'); + clearTimeout(timeout); + this.setupSocket(); this.authenticate().then(resolve).catch(reject); }); - this.socket.on('error', reject); - - setTimeout(() => { - reject(new Error('Timeout de connexion')); - }, 5000); + this.socket.on('error', (err) => { + clearTimeout(timeout); + reject(new Error(`Erreur RCON: ${err.message}`)); + }); }); } + setupSocket() { + let buffer = Buffer.alloc(0); + + this.socket.on('data', (data) => { + buffer = Buffer.concat([buffer, data]); + this.processBuffer(buffer); + buffer = this.getRemainingBuffer(buffer); + }); + } + + processBuffer(buffer) { + while (buffer.length >= 10) { + const size = buffer.readInt32LE(0); + + if (buffer.length < size + 4) { + return; + } + + const id = buffer.readInt32LE(4); + const type = buffer.readInt32LE(8); + + let payload = ''; + if (size > 10) { + payload = buffer.slice(12, 4 + size - 1).toString('utf8'); + } + + const response = { id, type, payload }; + + if (this.responses.has(id)) { + this.responses.get(id)(response); + this.responses.delete(id); + } + + buffer = buffer.slice(4 + size); + } + } + + getRemainingBuffer(buffer) { + if (buffer.length >= 10) { + const size = buffer.readInt32LE(0); + if (buffer.length >= size + 4) { + return buffer.slice(size + 4); + } + } + return buffer; + } + async authenticate() { return new Promise((resolve, reject) => { - this.packetId++; - const packet = this.createPacket(3, this.password); + this.requestId++; + const id = this.requestId; - this.socket.write(packet); - - const handleData = (data) => { - this.responseBuffer = Buffer.concat([this.responseBuffer, data]); - const response = this.parsePacket(this.responseBuffer); - - if (response && response.id === this.packetId) { - this.responseBuffer = this.responseBuffer.slice(response.totalSize); - this.socket.removeListener('data', handleData); - - if (response.id !== -1) { - this.authenticated = true; - console.log('✓ Authentifié RCON'); - resolve(); - } else { - reject(new Error('Mot de passe RCON incorrect')); - } - } - }; - - this.socket.on('data', handleData); - - setTimeout(() => { - reject(new Error('Timeout d\'authentification')); + const packet = this.createPacket(id, 3, this.password); + + const timeout = setTimeout(() => { + this.responses.delete(id); + reject(new Error('Timeout d\'authentification RCON')); }, 5000); + + this.responses.set(id, (response) => { + clearTimeout(timeout); + + if (response.type === 0 || response.id === id) { + this.authenticated = true; + resolve(); + } else if (response.id === -1) { + reject(new Error('Mot de passe RCON incorrect')); + } else { + reject(new Error('Authentification RCON échouée')); + } + }); + + this.socket.write(packet); }); } async sendCommand(command) { + if (!this.authenticated) { + throw new Error('Non authentifié au serveur RCON'); + } + return new Promise((resolve, reject) => { - if (!this.authenticated) { - reject(new Error('Non authentifié')); - return; - } - - this.packetId++; - const id = this.packetId; - const packet = this.createPacket(2, command); + this.requestId++; + const id = this.requestId; - this.socket.write(packet); - - const handleData = (data) => { - this.responseBuffer = Buffer.concat([this.responseBuffer, data]); - const response = this.parsePacket(this.responseBuffer); - - if (response && response.id === id) { - this.responseBuffer = this.responseBuffer.slice(response.totalSize); - this.socket.removeListener('data', handleData); - resolve(response.payload); - } - }; - - this.socket.on('data', handleData); - - setTimeout(() => { - reject(new Error('Timeout de commande')); + const packet = this.createPacket(id, 2, command); + + const timeout = setTimeout(() => { + this.responses.delete(id); + reject(new Error('Timeout de commande RCON')); }, 10000); + + this.responses.set(id, (response) => { + clearTimeout(timeout); + resolve(response.payload); + }); + + try { + this.socket.write(packet); + } catch (e) { + this.responses.delete(id); + clearTimeout(timeout); + reject(e); + } }); } diff --git a/docker-compose.yml b/docker-compose.yml index 7db6ec5..095ce79 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,11 +10,15 @@ services: environment: NODE_ENV: production PORT: 4001 - SERVER_DIR: /home/innotex/Documents/Projet/Serveur NationsGlory/NationsGlory_ServeurBuild_Red - RCON_HOST: host.docker.internal # ou votre adresse IP + SERVER_DIR: /mc-server + RCON_HOST: localhost RCON_PORT: 25575 SESSION_SECRET: change-this-in-production volumes: - /home/innotex/Documents/Projet/Serveur NationsGlory/NationsGlory_ServeurBuild_Red:/mc-server:ro + - web-admin:/mc-server/.web-admin restart: unless-stopped network_mode: host + +volumes: + web-admin: diff --git a/frontend/public/js/app.js b/frontend/public/js/app.js index 19e7182..e3ba487 100644 --- a/frontend/public/js/app.js +++ b/frontend/public/js/app.js @@ -50,6 +50,9 @@ function showLoginPage() { +
Aucun historique
'; + + if (data.history && data.history.length > 0) { + container.innerHTML = data.history.map(h => { + const timestamp = new Date(h.timestamp).toLocaleString('fr-FR'); + const statusIcon = h.success ? '✓' : '✗'; + const statusColor = h.success ? '#28a745' : '#dc3545'; + return ` +Aucun historique
'; + } } catch (e) { console.error('Erreur historique:', e); + const container = document.getElementById('historyContainer'); + container.innerHTML = `Erreur lors du chargement: ${e.message}
`; + } +} + +async function clearRconHistory() { + if (!confirm('Êtes-vous sûr de vouloir supprimer tout l\'historique?')) return; + + try { + const response = await fetch(`${API_URL}/rcon/history`, { + method: 'DELETE', + credentials: 'include' + }); + + if (response.ok) { + showMessage('Historique supprimé', 'success', 'dashboardMessage'); + loadConsoleData(); + } else { + showMessage('Erreur lors de la suppression', 'error', 'dashboardMessage'); + } + } catch (e) { + console.error('Erreur suppression historique:', e); + showMessage('Erreur serveur', 'error', 'dashboardMessage'); } } @@ -366,7 +443,7 @@ async function sendRconCommand() { if (!command) return; const output = document.getElementById('consoleOutput'); - output.innerHTML += `