const express = require('express'); const fs = require('fs').promises; const path = require('path'); const paths = require('../utils/paths'); const serverManager = require('../utils/shared-server-manager'); const ConfigManager = require('../utils/config-manager'); const ConfigParser = require('../utils/config-parser'); const appConfig = require('../utils/app-config'); const router = express.Router(); // Create global config manager instance const configManager = new ConfigManager(); // Initialize server manager with socket.io when available let io = null; function setSocketIO(socketInstance) { io = socketInstance; // Attach server manager events to socket.io serverManager.on('log', (logEntry) => { if (io) { io.emit('server:log', logEntry); } }); serverManager.on('stats', (stats) => { if (io) { io.emit('server:stats', stats); } }); serverManager.on('status', (status) => { if (io) { io.emit('server:status', status); } }); serverManager.on('exit', (exitInfo) => { if (io) { // Broadcast status immediately when server exits serverManager.getServerStatus().then(status => { io.emit('server:status', status); }); } }); } // Server status endpoint router.get('/server/status', async (req, res) => { try { const status = await serverManager.getServerStatus(); // For all running servers, get player list from debug.txt let playerList = []; if (status.isRunning) { const playerData = await serverManager.getExternalServerPlayerData(); playerList = playerData.players; // Also update the server stats with current player count serverManager.serverStats.players = playerData.count; // Emit player list via WebSocket if available if (io) { io.emit('server:players', playerList); } } const isExternal = serverManager.serverProcess?.external || false; res.json({ ...status, players: playerList.length, // Override with actual player count playerList: playerList, // Add simple string status for UI statusText: status.isRunning ? 'running' : 'stopped', // Include external server information isExternal: isExternal }); } catch (error) { console.error('API: Server status error:', error); res.status(500).json({ error: error.message, statusText: 'stopped', isRunning: false, isReady: false, playerList: [] }); } }); // Start server router.post('/server/start', async (req, res) => { try { const { worldName } = req.body; console.log('Server start requested with world:', worldName); const result = await serverManager.startServer(worldName); res.json(result); } catch (error) { console.error('Server start error:', error); res.status(500).json({ success: false, error: error.message }); } }); // Stop server router.post('/server/stop', async (req, res) => { try { const { force = false } = req.body; const result = await serverManager.stopServer(force); res.json(result); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); // Restart server router.post('/server/restart', async (req, res) => { try { const { worldName } = req.body; const result = await serverManager.restartServer(worldName); res.json(result); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); // Send command to server router.post('/server/command', async (req, res) => { try { const { command } = req.body; if (!command || typeof command !== 'string') { return res.status(400).json({ error: 'Command is required' }); } const result = await serverManager.sendCommand(command.trim()); res.json(result); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); // Get server logs router.get('/server/logs', async (req, res) => { try { const { lines = 500, format = 'text' } = req.query; const logs = serverManager.getLogs(parseInt(lines)); if (format === 'json') { res.json(logs); } else { // Return as downloadable text file const logText = logs.map(log => `[${log.timestamp}] ${log.type.toUpperCase()}: ${log.content}` ).join('\n'); res.setHeader('Content-Disposition', 'attachment; filename=server-logs.txt'); res.setHeader('Content-Type', 'text/plain'); res.send(logText); } } catch (error) { res.status(500).json({ error: error.message }); } }); // Get server info router.get('/server/info', async (req, res) => { try { const info = await serverManager.getServerInfo(); res.json(info); } catch (error) { res.status(500).json({ error: error.message }); } }); // Configuration endpoints // Get all configuration sections router.get('/config/sections', async (req, res) => { try { const sections = configManager.getAllSettings(); res.json(sections); } catch (error) { res.status(500).json({ error: error.message }); } }); // Get current configuration router.get('/config', async (req, res) => { try { // Use the new configuration schema approach instead of ConfigManager const configSchema = { System: { data_directory: { type: 'string', default: '', description: 'Luanti data directory path (leave empty for auto-detection)' } }, Server: { port: { type: 'number', default: 30000, description: 'Port for server to listen on' }, server_name: { type: 'string', default: 'Luanti Server', description: 'Name of the server' }, server_description: { type: 'string', default: 'A Luanti server', description: 'Server description' }, server_announce: { type: 'boolean', default: false, description: 'Announce server to server list' }, max_users: { type: 'number', default: 20, description: 'Maximum number of users' } }, World: { creative_mode: { type: 'boolean', default: false, description: 'Enable creative mode by default' }, enable_damage: { type: 'boolean', default: true, description: 'Enable player damage by default' }, enable_pvp: { type: 'boolean', default: true, description: 'Enable player vs player combat by default' }, default_game: { type: 'string', default: 'minetest_game', description: 'Default game to use for new worlds' }, time_speed: { type: 'number', default: 72, description: 'Time speed (72 = normal, higher = faster)' } }, Security: { disallow_empty_password: { type: 'boolean', default: false, description: 'Disallow empty passwords' }, strict_protocol_version_checking: { type: 'boolean', default: false, description: 'Strict protocol version checking' } }, Performance: { dedicated_server_step: { type: 'number', default: 0.1, description: 'Server step time in seconds' }, num_emerge_threads: { type: 'number', default: 1, description: 'Number of emerge threads' }, server_map_save_interval: { type: 'number', default: 15.3, description: 'Map save interval in seconds' }, max_block_send_distance: { type: 'number', default: 12, description: 'Maximum block send distance' } }, Network: { server_address: { type: 'string', default: '', description: 'IP address to bind to (empty for all interfaces)' }, server_dedicated: { type: 'boolean', default: false, description: 'Run as dedicated server' } }, Advanced: { max_simultaneous_block_sends_per_client: { type: 'number', default: 40, description: 'Maximum simultaneous block sends per client' } } }; // Load both Luanti config and app config const luantiConfig = await ConfigParser.parseConfig(paths.configFile); await appConfig.load(); // Combine configs for display const combinedConfig = { ...luantiConfig, data_directory: appConfig.getDataDirectory() }; // Organize schema into sections with proper structure for frontend const sections = {}; for (const [sectionName, sectionFields] of Object.entries(configSchema)) { sections[sectionName] = { description: sectionName + ' configuration settings', settings: sectionFields }; } res.json({ current: combinedConfig, sections: sections, schema: configSchema }); } catch (error) { console.error('Error getting config via API:', error); res.status(500).json({ error: error.message }); } }); // Update configuration router.post('/config', async (req, res) => { try { const { settings } = req.body; if (!settings || typeof settings !== 'object') { return res.status(400).json({ error: 'Settings object is required' }); } // Validate all settings const validationErrors = []; const validatedSettings = {}; for (const [key, value] of Object.entries(settings)) { const validation = configManager.validateSetting(key, value); if (validation.valid) { validatedSettings[key] = validation.value; } else { validationErrors.push({ key, error: validation.error }); } } if (validationErrors.length > 0) { return res.status(400).json({ error: 'Validation failed', details: validationErrors }); } const result = await configManager.updateSettings(validatedSettings); res.json(result); } catch (error) { res.status(500).json({ error: error.message }); } }); // Update single configuration setting router.put('/config/:key', async (req, res) => { try { const { key } = req.params; const { value } = req.body; const validation = configManager.validateSetting(key, value); if (!validation.valid) { return res.status(400).json({ error: validation.error }); } const result = await configManager.updateSetting(key, validation.value); res.json(result); } catch (error) { res.status(500).json({ error: error.message }); } }); // Reset configuration section to defaults router.post('/config/reset/:section?', async (req, res) => { try { const { section } = req.params; const result = await configManager.resetToDefaults(section); res.json(result); } catch (error) { res.status(500).json({ error: error.message }); } }); // Get setting information router.get('/config/setting/:key', async (req, res) => { try { const { key } = req.params; const info = configManager.getSettingInfo(key); if (!info) { return res.status(404).json({ error: 'Setting not found' }); } res.json(info); } catch (error) { res.status(500).json({ error: error.message }); } }); // Worlds endpoints (basic) router.get('/worlds', async (req, res) => { try { await fs.mkdir(paths.worldsDir, { recursive: true }); const worldDirs = await fs.readdir(paths.worldsDir); const worlds = []; for (const worldDir of worldDirs) { try { const worldPath = paths.getWorldPath(worldDir); const stats = await fs.stat(worldPath); if (stats.isDirectory()) { // Try to read world.mt for display name let displayName = worldDir; try { const worldConfig = await fs.readFile( path.join(worldPath, 'world.mt'), 'utf8' ); const nameMatch = worldConfig.match(/world_name\s*=\s*(.+)/); if (nameMatch) { displayName = nameMatch[1].trim().replace(/^["']|["']$/g, ''); } } catch {} worlds.push({ name: worldDir, displayName: displayName, path: worldPath, lastModified: stats.mtime }); } } catch (error) { // Skip invalid world directories } } // Sort by last modified worlds.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified)); res.json(worlds); } catch (error) { res.status(500).json({ error: error.message }); } }); // ContentDB package info endpoint for validation router.post('/contentdb/package-info', async (req, res) => { try { const { author, name } = req.body; if (!author || !name) { return res.status(400).json({ error: 'Author and name are required' }); } const ContentDBClient = require('../utils/contentdb'); const contentdb = new ContentDBClient(); // Get package info from ContentDB const packageInfo = await contentdb.getPackage(author, name); res.json({ type: packageInfo.type || 'mod', title: packageInfo.title || name, author: packageInfo.author || author, name: packageInfo.name || name, short_description: packageInfo.short_description || '' }); } catch (error) { console.error('Error getting package info:', error); // If it's a 404 error, return that specifically if (error.message === 'Package not found') { return res.status(404).json({ error: 'Package not found on ContentDB' }); } // For other errors, return a generic error but don't fail completely res.status(200).json({ error: 'Could not verify package information', type: 'mod', // Default to mod type fallback: true }); } }); module.exports = { router, setSocketIO, serverManager, configManager };