Initial commit: LuHost - Luanti Server Management Web Interface
A modern web interface for Luanti (Minetest) server management with ContentDB integration. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
534
routes/api.js
Normal file
534
routes/api.js
Normal file
@@ -0,0 +1,534 @@
|
||||
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;
|
||||
console.log('API: serverManager.serverProcess =', serverManager.serverProcess);
|
||||
console.log('API: isExternal =', isExternal);
|
||||
|
||||
console.log('API endpoint returning status:', {
|
||||
isRunning: status.isRunning,
|
||||
players: playerList.length, // Use the actual detected player count
|
||||
playerNames: playerList.map(p => p.name),
|
||||
statusText: status.isRunning ? 'running' : 'stopped',
|
||||
isExternal: isExternal
|
||||
});
|
||||
|
||||
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
|
||||
};
|
Reference in New Issue
Block a user