Files
OpenParty/plugins/AdminPanel/AdminPanelPlugin.js

704 lines
30 KiB
JavaScript

/**
* Admin Panel Plugin for OpenParty
* Provides a secure web interface for server management
*/
const express = require('express');
const session = require('express-session');
const bcrypt = require('bcrypt');
const { exec } = require('child_process');
const archiver = require('archiver'); // Moved to top
const fs = require('fs');
const path = require('path'); // Standard library
const Plugin = require('../../core/classes/Plugin'); // Adjusted path
const Logger = require('../../core/utils/logger'); // Adjusted path
class AdminPanelPlugin extends Plugin {
constructor() {
super('AdminPanelPlugin', 'Secure admin panel for server management');
this.logger = new Logger('AdminPanel');
this.sessionSecret = process.env.SESSION_SECRET || 'openparty-secure-session';
// Initialize admin password (logger might not be fully initialized with manifest name yet)
const plainPassword = process.env.ADMIN_PASSWORD || 'admin123';
console.log('[AdminPanelPlugin] Initializing admin password...'); // Use console.log before logger is guaranteed
console.log(`[AdminPanelPlugin] Using default password: ${!process.env.ADMIN_PASSWORD}`);
// Hash the admin password
try {
this.adminPassword = bcrypt.hashSync(plainPassword, 10);
this.logger.info('Admin password hashed successfully');
} catch (error) {
this.logger.error('Failed to hash admin password:', error);
throw error;
}
this.backupInterval = 24 * 60 * 60 * 1000; // 24 hours
this.startTime = Date.now();
this.stats = {
activeUsers: 0,
totalSongs: 0,
lastBackup: null,
activePlugins: 0
};
this.app = null; // To store the Express app instance
// Initialize stats update interval
setInterval(() => this.updateStats(), 30000); // Update every 30 seconds
}
initroute(app) {
this.app = app; // Store the Express app instance
this.logger.info('Initializing admin panel routes...');
// Body parsing middleware (assuming express.json and express.urlencoded are global)
// If not, they might need to be added here or in Core.js globally.
// For this integration, we'll assume they are handled globally.
// Session middleware - specific to the admin panel
app.use(session({
secret: this.sessionSecret,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production', // Only use secure cookies in production
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
}));
// Serve static files
app.use('/panel', express.static(path.join(__dirname, 'panel/public')));
this.logger.info(`Serving static files from: ${path.join(__dirname, 'panel/public')}`);
// Make sure the directory exists
if (!fs.existsSync(path.join(__dirname, 'panel/public'))) {
this.logger.error(`Static files directory does not exist: ${path.join(__dirname, 'panel/public')}`);
fs.mkdirSync(path.join(__dirname, 'panel/public'), { recursive: true });
this.logger.info(`Created static files directory: ${path.join(__dirname, 'panel/public')}`);
}
// Authentication middleware
const requireAuth = (req, res, next) => {
if (req.session.authenticated) {
next();
} else {
res.redirect('/panel/login');
}
};
// TODO: Consider implementing rate limiting for login attempts to prevent brute-force attacks.
// Example: using a middleware like 'express-rate-limit'.
// Login route
app.get('/panel/login', (req, res) => {
res.sendFile(path.join(__dirname, 'panel/public/login.html'));
});
app.post('/panel/login', async (req, res) => {
try {
this.logger.info('Login attempt received');
this.logger.info('Request body:', req.body);
const { password } = req.body;
if (!password) {
this.logger.warn('No password provided');
return res.redirect('/panel/login?error=1');
}
this.logger.info('Comparing passwords...');
const match = await bcrypt.compare(password, this.adminPassword);
this.logger.info(`Password match result: ${match}`);
if (match) {
req.session.authenticated = true;
this.logger.info('Login successful');
res.redirect('/panel/dashboard');
} else {
this.logger.warn('Invalid password attempt');
res.redirect('/panel/login?error=1');
}
} catch (error) {
this.logger.error(`Login error: ${error.message}`);
this.logger.error(error.stack);
res.redirect('/panel/login?error=1');
}
});
// Dashboard
app.get('/panel/dashboard', requireAuth, (req, res) => {
res.sendFile(path.join(__dirname, 'panel/public/dashboard.html'));
});
// Plugin management
app.get('/panel/api/plugins', requireAuth, (req, res) => {
const pluginManager = req.app.get('pluginManager');
const pluginsMap = pluginManager.getPlugins();
const pluginsArray = Array.from(pluginsMap.values()).map(plugin => ({
name: plugin.name,
description: plugin.description,
enabled: plugin.isEnabled()
}));
res.json(pluginsArray);
});
app.post('/panel/api/plugins/toggle', requireAuth, (req, res) => {
try {
const pluginManager = req.app.get('pluginManager');
const { pluginName } = req.body; // Changed from 'name' to 'pluginName'
if (!pluginName) {
return res.status(400).json({ success: false, message: 'Plugin name (pluginName) is required' });
}
const plugin = pluginManager.getPlugin(pluginName); // Use getPlugin for direct access
if (!plugin) {
return res.status(404).json({ success: false, message: `Plugin ${pluginName} not found` });
}
let newStatusMessage;
if (plugin.isEnabled()) {
plugin.disable();
newStatusMessage = `Plugin ${pluginName} has been disabled`;
this.logger.info(newStatusMessage);
} else {
plugin.enable();
newStatusMessage = `Plugin ${pluginName} has been enabled`;
this.logger.info(newStatusMessage);
}
res.json({ success: true, message: newStatusMessage, enabled: plugin.isEnabled() });
} catch (error) {
this.logger.error(`Error toggling plugin: ${error.message}`);
res.status(500).json({ success: false, message: error.message });
}
});
// Server status endpoint
app.get('/panel/api/status', requireAuth, (req, res) => {
const settings = require('../../settings.json');
let currentVersion = 'N/A';
try {
const packageJsonPath = path.join(process.cwd(), 'package.json');
if (fs.existsSync(packageJsonPath)) {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
currentVersion = packageJson.version;
}
} catch (e) { this.logger.error("Failed to read package.json version for status API", e); }
res.json({
maintenance: settings.server.serverstatus.isMaintenance,
uptime: Math.floor((Date.now() - this.startTime) / 1000),
version: currentVersion
});
});
// Server stats endpoint
app.get('/panel/api/stats', requireAuth, (req, res) => {
res.json(this.stats);
});
// Server management endpoints
app.post('/panel/api/update', requireAuth, async (req, res) => {
try {
const { stdout, stderr } = await new Promise((resolve, reject) => {
exec('git pull && npm install', (error, stdout, stderr) => {
if (error) reject(error);
else resolve({ stdout, stderr });
});
});
this.logger.info('Update successful');
res.json({ message: 'Update successful', output: stdout, details: stderr });
} catch (error) {
this.logger.error(`Update error: ${error.message}`);
res.status(500).json({ error: error.message });
}
});
app.post('/panel/api/restart', requireAuth, (req, res) => {
res.json({ message: 'Server restarting...' });
process.exit(42); // Trigger restart through PM2
});
app.post('/panel/api/reload-plugins', requireAuth, (req, res) => {
try {
const pluginManager = req.app.get('pluginManager');
pluginManager.loadPlugins(); // No longer needs settings.modules
this.logger.info('Plugins reloaded via API.');
res.json({ message: 'Plugins reloaded successfully' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Savedata management
app.get('/panel/api/savedata', requireAuth, (req, res) => {
try {
const savedataPath = path.join(process.cwd(), 'database/data');
if (!fs.existsSync(savedataPath)) {
this.logger.error(`Savedata directory does not exist: ${savedataPath}`);
return res.status(404).json({ error: 'Savedata directory not found' });
}
const files = fs.readdirSync(savedataPath)
.filter(file => file.endsWith('.json'))
.map(file => {
const filePath = path.join(savedataPath, file);
const stats = fs.statSync(filePath);
return {
name: file,
size: stats.size,
modified: stats.mtime
};
});
res.json(files);
} catch (error) {
this.logger.error(`Error getting savedata: ${error.message}`);
res.status(500).json({ error: error.message });
}
});
app.post('/panel/api/savedata/:type', requireAuth, (req, res) => {
const { type } = req.params;
const { data } = req.body;
try {
fs.writeFileSync(
path.join(process.cwd(), 'database/data', `${type}.json`),
JSON.stringify(data, null, 2)
);
// Commit changes to Git
exec(`git add . && git commit -m "Update ${type} savedata" && git push`, {
cwd: process.cwd()
}, (error) => {
if (error) {
this.logger.error(`Git error: ${error.message}`);
}
});
res.json({ message: 'Savedata updated successfully' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Backup system
this.setupAutomaticBackup();
// Get backups list
app.get('/panel/api/backups', requireAuth, (req, res) => {
try {
const backupsPath = path.join(process.cwd(), 'backups');
if (!fs.existsSync(backupsPath)) {
this.logger.info(`Backups directory does not exist, creating: ${backupsPath}`);
fs.mkdirSync(backupsPath, { recursive: true });
return res.json([]);
}
const backups = fs.readdirSync(backupsPath)
.filter(dir => {
const dirPath = path.join(backupsPath, dir);
return fs.statSync(dirPath).isDirectory();
})
.map(dir => {
const dirPath = path.join(backupsPath, dir);
const stats = fs.statSync(dirPath);
// Calculate total size of backup
let totalSize = 0;
const dataPath = path.join(dirPath, 'data');
if (fs.existsSync(dataPath)) {
const calculateDirSize = (dirPath) => {
let size = 0;
const files = fs.readdirSync(dirPath);
for (const file of files) {
const filePath = path.join(dirPath, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
size += calculateDirSize(filePath);
} else {
size += stat.size;
}
}
return size;
};
totalSize = calculateDirSize(dataPath);
}
return {
filename: dir,
date: new Date(dir.replace(/-/g, ':')),
size: totalSize,
type: 'Auto'
};
});
// Sort by date (newest first)
backups.sort((a, b) => b.date - a.date);
res.json(backups);
} catch (error) {
this.logger.error(`Error getting backups: ${error.message}`);
res.status(500).json({ error: error.message });
}
});
// Create backup
app.post('/panel/api/backup', requireAuth, (req, res) => {
this.createBackup()
.then(() => res.json({ success: true, message: 'Backup created successfully' }))
.catch(error => res.status(500).json({ success: false, error: error.message }));
});
// Download backup
app.get('/panel/api/backups/download/:filename', requireAuth, (req, res) => {
try {
const { filename } = req.params;
if (!filename) {
return res.status(400).json({ success: false, message: 'Backup filename is required' });
}
const backupPath = path.join(process.cwd(), 'backups', filename);
if (!fs.existsSync(backupPath)) {
this.logger.error(`Backup not found: ${backupPath}`);
return res.status(404).json({ success: false, message: 'Backup not found' });
}
// Create a zip file of the backup
const zipFilename = `${filename}.zip`;
const zipPath = path.join(process.cwd(), 'backups', zipFilename);
// Create a write stream for the zip file
const output = fs.createWriteStream(zipPath);
const archive = archiver('zip', {
zlib: { level: 9 } // Maximum compression
});
// Listen for all archive data to be written
output.on('close', () => {
this.logger.info(`Backup archive created: ${zipPath} (${archive.pointer()} bytes)`);
// Send the zip file
res.download(zipPath, zipFilename, (err) => {
if (err) {
this.logger.error(`Error sending backup: ${err.message}`);
}
// Delete the temporary zip file after sending
fs.unlink(zipPath, (unlinkErr) => {
if (unlinkErr) {
this.logger.error(`Error deleting temporary zip file: ${unlinkErr.message}`);
}
});
});
});
// Handle errors
archive.on('error', (err) => {
this.logger.error(`Error creating backup archive: ${err.message}`);
res.status(500).json({ success: false, message: `Error creating backup archive: ${err.message}` });
});
// Pipe archive data to the output file
archive.pipe(output);
// Add the backup directory to the archive
archive.directory(backupPath, false);
// Finalize the archive
archive.finalize();
} catch (error) {
this.logger.error(`Error downloading backup: ${error.message}`);
res.status(500).json({ success: false, message: error.message });
}
});
// Delete backup
app.delete('/panel/api/backups/delete/:filename', requireAuth, (req, res) => {
try {
const { filename } = req.params;
if (!filename) {
return res.status(400).json({ success: false, message: 'Backup filename is required' });
}
const backupPath = path.join(process.cwd(), 'backups', filename);
if (!fs.existsSync(backupPath)) {
this.logger.error(`Backup not found: ${backupPath}`);
return res.status(404).json({ success: false, message: 'Backup not found' });
}
// Delete the backup directory recursively
fs.rmSync(backupPath, { recursive: true, force: true });
this.logger.info(`Backup deleted: ${filename}`);
res.json({ success: true, message: 'Backup deleted successfully' });
} catch (error) {
this.logger.error(`Error deleting backup: ${error.message}`);
res.status(500).json({ success: false, message: error.message });
}
});
// Check for updates
app.get('/panel/api/check-updates', requireAuth, async (req, res) => {
try {
// Get current version from package.json
const packagePath = path.join(process.cwd(), 'package.json');
let currentVersion = '1.0.0';
if (fs.existsSync(packagePath)) {
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
currentVersion = packageJson.version || '1.0.0';
}
// For demo purposes, we'll simulate checking for updates
// In a real implementation, you would fetch from a remote repository
const hasUpdate = Math.random() > 0.5; // Randomly determine if update is available
if (hasUpdate) {
// Simulate a newer version
const newVersion = currentVersion.split('.')
.map((part, index) => index === 2 ? parseInt(part) + 1 : part)
.join('.');
res.json({
available: true,
currentVersion,
version: newVersion,
changelog: 'Bug fixes and performance improvements.'
});
} else {
res.json({
available: false,
currentVersion
});
}
} catch (error) {
this.logger.error(`Error checking for updates: ${error.message}`);
res.status(500).json({ error: error.message });
}
});
// Maintenance mode
app.post('/panel/api/maintenance', requireAuth, (req, res) => {
try {
const settingsPath = path.join(process.cwd(), 'settings.json');
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
// Toggle maintenance mode
settings.server.serverstatus.isMaintenance = !settings.server.serverstatus.isMaintenance;
// Write updated settings back to file
fs.writeFileSync(
settingsPath,
JSON.stringify(settings, null, 2)
);
res.json({
success: true,
enabled: settings.server.serverstatus.isMaintenance
});
} catch (error) {
this.logger.error(`Error toggling maintenance mode: ${error.message}`);
res.status(500).json({
success: false,
error: error.message
});
}
});
// Settings endpoint
app.get('/panel/api/settings', requireAuth, (req, res) => {
try {
const settingsPath = path.join(process.cwd(), 'settings.json'); // process.cwd() is the root
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
// Return a sanitized version of settings (remove sensitive data if needed)
res.json({
server: {
port: settings.server.port,
isPublic: settings.server.isPublic,
enableSSL: settings.server.enableSSL,
domain: settings.server.domain,
modName: settings.server.modName,
maintenance: settings.server.serverstatus.isMaintenance,
channel: settings.server.serverstatus.channel
},
// Get plugin info from PluginManager and manifests
plugins: [] // Placeholder, will be populated below
});
const pluginManager = req.app.get('pluginManager');
if (pluginManager) {
const pluginsMap = pluginManager.getPlugins();
res.locals.plugins = Array.from(pluginsMap.values()).map(p => ({
name: p.manifest.name,
description: p.manifest.description,
execution: p.manifest.execution,
enabled: p.isEnabled(),
version: p.manifest.version
}));
}
res.json(res.locals); // Send the combined data
} catch (error) {
this.logger.error(`Error getting settings: ${error.message}`);
res.status(500).json({ error: error.message });
}
});
app.post('/panel/api/settings', requireAuth, (req, res) => {
try {
const { server } = req.body;
if (!server) {
return res.status(400).json({ success: false, message: 'Server settings are required' });
}
const settingsPath = path.join(process.cwd(), 'settings.json');
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
// Update only allowed settings
if (server.port !== undefined) settings.server.port = server.port;
if (server.isPublic !== undefined) settings.server.isPublic = server.isPublic;
if (server.enableSSL !== undefined) settings.server.enableSSL = server.enableSSL;
if (server.domain !== undefined) settings.server.domain = server.domain;
if (server.modName !== undefined) settings.server.modName = server.modName;
if (server.maintenance !== undefined) settings.server.serverstatus.isMaintenance = server.maintenance;
if (server.channel !== undefined) settings.server.serverstatus.channel = server.channel;
// Write updated settings back to file
fs.writeFileSync(
settingsPath,
JSON.stringify(settings, null, 2)
);
res.json({
success: true,
message: 'Settings updated successfully'
});
} catch (error) {
this.logger.error(`Error updating settings: ${error.message}`);
res.status(500).json({
success: false,
error: error.message
});
}
});
// Logs endpoint
app.get('/panel/api/logs', requireAuth, (req, res) => {
try {
const { level = 'all', limit = 100 } = req.query;
const logsDir = path.join(process.cwd(), 'logs');
// Create logs directory if it doesn't exist
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true });
}
// For demo purposes, generate some sample logs if no log file exists
const logFile = path.join(logsDir, 'server.log');
if (!fs.existsSync(logFile)) {
const sampleLogs = [
'[INFO] Server started successfully',
'[INFO] Loaded 3 plugins',
'[WARNING] Plugin XYZ is using deprecated API',
'[ERROR] Failed to connect to database',
'[INFO] User logged in: admin',
'[DEBUG] Processing request: GET /api/songs',
'[INFO] Request completed in 120ms'
];
fs.writeFileSync(logFile, sampleLogs.join('\n'));
}
// Read log file
let logs = fs.readFileSync(logFile, 'utf8').split('\n').filter(Boolean);
// Filter by level if specified
if (level !== 'all') {
const levelUpper = level.toUpperCase();
logs = logs.filter(log => log.includes(`[${levelUpper}]`));
}
// Limit number of logs
logs = logs.slice(-parseInt(limit));
res.json({
logs,
total: logs.length
});
} catch (error) {
this.logger.error(`Error getting logs: ${error.message}`);
res.status(500).json({ error: error.message });
}
});
this.logger.info('Admin panel routes initialized');
}
async updateStats() {
try {
// Update active users (example: count connected clients)
this.stats.activeUsers = Object.keys(this.app?.io?.sockets?.sockets || global.io?.sockets?.sockets || {}).length; // Prefer app.io if available
// Update total songs
const songPath = path.join(process.cwd(), 'database/data/songs.json');
if (fs.existsSync(songPath)) {
const songs = JSON.parse(fs.readFileSync(songPath, 'utf8'));
this.stats.totalSongs = Object.keys(songs).length;
} else { this.stats.totalSongs = 0; }
// Update active plugins count
const pluginManager = this.app?.get('pluginManager');
if (pluginManager) {
this.stats.activePlugins = Array.from(pluginManager.getPlugins().values()).filter(p => p.isEnabled()).length;
}
} catch (error) {
this.logger.error(`Stats update error: ${error.message}`);
}
}
async createBackup() {
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupDir = path.join(process.cwd(), 'backups', timestamp);
fs.mkdirSync(backupDir, { recursive: true });
// Backup database and savedata
const dataDir = path.join(process.cwd(), 'database/data');
fs.cpSync(dataDir, path.join(backupDir, 'data'), { recursive: true });
// Create Git tag for the backup
await new Promise((resolve, reject) => {
exec(`git tag backup-${timestamp} && git push origin backup-${timestamp}`, {
cwd: process.cwd()
}, (error, stdout, stderr) => {
if (error) {
this.logger.error(`Git tagging/pushing error during backup: ${error.message}`);
this.logger.error(`Git stderr: ${stderr}`);
reject(error);
} else {
this.logger.info(`Git tag and push successful for backup-${timestamp}`);
this.logger.info(`Git stdout: ${stdout}`);
resolve();
}
});
});
// Update last backup timestamp
this.stats.lastBackup = timestamp;
this.logger.info(`Backup created successfully: ${timestamp}`);
} catch (error) {
this.logger.error(`Backup creation failed: ${error.message}`);
throw error;
}
}
setupAutomaticBackup() {
setInterval(() => {
this.createBackup().catch(error => {
this.logger.error(`Automatic backup failed: ${error.message}`);
});
}, this.backupInterval);
}
}
module.exports = new AdminPanelPlugin();