Files
LuHost/routes/api.js
Nathan Schneider 3aed09b60f 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>
2025-08-23 17:32:37 -06:00

534 lines
14 KiB
JavaScript

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
};