Files
LuHost/routes/config.js
Nathan Schneider 2d3b1166fe Fix server management issues and improve overall stability
Major server management fixes:
- Replace Flatpak-specific pkill with universal process tree termination using pstree + process.kill()
- Fix signal format errors (SIGTERM/SIGKILL instead of TERM/KILL strings)
- Add 5-second cooldown after server stop to prevent race conditions with external detection
- Enable Stop Server button for external servers in UI
- Implement proper timeout handling with process tree killing

ContentDB improvements:
- Fix download retry logic and "closed" error by preventing concurrent zip extraction
- Implement smart root directory detection and stripping during package extraction
- Add game-specific timeout handling (8s for VoxeLibre vs 3s for simple games)

World creation fixes:
- Make world creation asynchronous to prevent browser hangs
- Add WebSocket notifications for world creation completion status

Other improvements:
- Remove excessive debug logging
- Improve error handling and user feedback throughout the application
- Clean up temporary files and unnecessary logging

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 19:17:38 -06:00

367 lines
10 KiB
JavaScript

const express = require('express');
const fs = require('fs').promises;
const paths = require('../utils/paths');
const ConfigParser = require('../utils/config-parser');
const appConfig = require('../utils/app-config');
const PackageRegistry = require('../utils/package-registry');
const router = express.Router();
// Configuration schema
const configSchema = {
system: {
data_directory: {
type: 'string',
default: '',
description: 'Luanti data directory path (leave empty for auto-detection)',
section: 'System Settings'
}
},
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_address: {
type: 'string',
default: '',
description: 'IP address to bind to (empty for all interfaces)'
},
server_announce: {
type: 'boolean',
default: false,
description: 'Announce server to server list'
},
server_dedicated: {
type: 'boolean',
default: false,
description: 'Run as dedicated server'
},
max_users: {
type: 'number',
default: 20,
description: 'Maximum number of users'
}
},
gameplay: {
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'
},
'secure.enable_security': {
type: 'boolean',
default: true,
description: 'Enable security features'
},
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'
},
max_simultaneous_block_sends_per_client: {
type: 'number',
default: 40,
description: 'Maximum simultaneous block sends per client'
}
}
};
// Configuration page
router.get('/', async (req, res) => {
try {
paths.ensureDirectories();
// 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()
};
res.render('config/index', {
title: 'Server Configuration',
config: combinedConfig,
schema: configSchema,
currentPage: 'config',
currentDataDirectory: appConfig.getDataDirectory(),
defaultDataDirectory: appConfig.getDefaultDataDirectory()
});
} catch (error) {
console.error('Error getting config:', error);
res.status(500).render('error', {
error: 'Failed to load configuration',
message: error.message
});
}
});
// Update configuration
router.post('/update', async (req, res) => {
try {
const updates = req.body;
// Handle data directory change separately
if (updates.data_directory !== undefined) {
const newDataDir = updates.data_directory.trim();
if (newDataDir && newDataDir !== appConfig.getDataDirectory()) {
try {
await appConfig.setDataDirectory(newDataDir);
// Force reload paths to use new directory
await paths.forceReload();
// Reinitialize package registry to use new directory
const packageRegistry = new PackageRegistry();
await packageRegistry.reinitialize();
} catch (error) {
throw new Error(`Failed to update data directory: ${error.message}`);
}
}
delete updates.data_directory; // Remove from Luanti config updates
}
// Read current Luanti config
const currentConfig = await ConfigParser.parseConfig(paths.configFile);
// Process form data and convert types for Luanti config
const processedUpdates = {};
for (const [key, value] of Object.entries(updates)) {
if (key === '_csrf' || key === 'returnUrl') continue; // Skip CSRF and utility fields
// Find the field in schema to determine type
let fieldType = 'string';
let fieldFound = false;
for (const section of Object.values(configSchema)) {
if (section[key]) {
fieldType = section[key].type;
fieldFound = true;
break;
}
}
// Convert value based on type
if (fieldType === 'boolean') {
processedUpdates[key] = value === 'on' || value === 'true';
} else if (fieldType === 'number') {
const numValue = parseFloat(value);
if (!isNaN(numValue)) {
processedUpdates[key] = numValue;
}
} else {
// String or unknown type
if (value !== '') {
processedUpdates[key] = value;
}
}
}
// Merge with current Luanti config
const updatedConfig = { ...currentConfig, ...processedUpdates };
// Write updated Luanti config
await ConfigParser.writeConfig(paths.configFile, updatedConfig);
const returnUrl = req.body.returnUrl || '/config';
res.redirect(`${returnUrl}?updated=true`);
} catch (error) {
console.error('Error updating config:', error);
const returnUrl = req.body.returnUrl || '/config';
res.redirect(`${returnUrl}?error=${encodeURIComponent(error.message)}`);
}
});
// Reset configuration to defaults
router.post('/reset', async (req, res) => {
try {
const defaultConfig = {};
// Build default configuration from schema
for (const [sectionName, section] of Object.entries(configSchema)) {
for (const [key, field] of Object.entries(section)) {
if (field.default !== undefined) {
defaultConfig[key] = field.default;
}
}
}
await ConfigParser.writeConfig(paths.configFile, defaultConfig);
res.redirect('/config?reset=true');
} catch (error) {
console.error('Error resetting config:', error);
res.redirect(`/config?error=${encodeURIComponent(error.message)}`);
}
});
// Export current configuration as file
router.get('/export', async (req, res) => {
try {
const config = await ConfigParser.parseConfig(paths.configFile);
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', 'attachment; filename=minetest.conf');
const configLines = [];
for (const [key, value] of Object.entries(config)) {
if (value !== undefined && value !== null) {
configLines.push(`${key} = ${value}`);
}
}
res.send(configLines.join('\n'));
} catch (error) {
console.error('Error exporting config:', error);
res.status(500).render('error', {
error: 'Failed to export configuration',
message: error.message
});
}
});
// Get configuration schema (API)
router.get('/api/schema', (req, res) => {
res.json(configSchema);
});
// Get current configuration (API)
// Change data directory only
router.post('/change-data-directory', async (req, res) => {
try {
const { newDataDirectory } = req.body;
if (!newDataDirectory || !newDataDirectory.trim()) {
return res.status(400).json({
success: false,
error: 'Data directory path is required'
});
}
const newDataDir = newDataDirectory.trim();
const currentDataDir = appConfig.getDataDirectory();
if (newDataDir === currentDataDir) {
return res.json({
success: true,
message: 'Data directory is already set to this path',
dataDirectory: currentDataDir
});
}
// Validate and set new data directory
await appConfig.load();
await appConfig.setDataDirectory(newDataDir);
await appConfig.save();
// Update paths to use new directory
await paths.initialize();
res.json({
success: true,
message: 'Data directory updated successfully. Please restart the application for all changes to take effect.',
dataDirectory: newDataDir,
previousDirectory: currentDataDir
});
} catch (error) {
console.error('Error changing data directory:', error);
res.status(500).json({
success: false,
error: `Failed to change data directory: ${error.message}`
});
}
});
router.get('/api/current', async (req, res) => {
try {
const config = await ConfigParser.parseConfig(paths.configFile);
res.json(config);
} catch (error) {
console.error('Error getting config:', error);
res.status(500).json({ error: 'Failed to get configuration' });
}
});
// Update configuration (API)
router.put('/api/update', async (req, res) => {
try {
const updates = req.body;
const currentConfig = await ConfigParser.parseConfig(paths.configFile);
const updatedConfig = { ...currentConfig, ...updates };
await ConfigParser.writeConfig(paths.configFile, updatedConfig);
res.json({ message: 'Configuration updated successfully' });
} catch (error) {
console.error('Error updating config:', error);
res.status(500).json({ error: 'Failed to update configuration' });
}
});
module.exports = router;