Files
LuHost/utils/config-manager.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

442 lines
13 KiB
JavaScript

const fs = require('fs').promises;
const path = require('path');
const paths = require('./paths');
class ConfigManager {
constructor() {
this.configPath = paths.configFile;
this.configSections = this.getConfigSections();
}
getConfigSections() {
return {
'Server': {
description: 'Basic server settings',
settings: {
'server_name': {
type: 'string',
default: 'Luanti Server',
description: 'Name of the server as displayed in the server list'
},
'server_description': {
type: 'text',
default: 'A Luanti server powered by the web interface',
description: 'Server description shown to players'
},
'port': {
type: 'number',
default: 30000,
min: 1024,
max: 65535,
description: 'Port for the game server'
},
'max_users': {
type: 'number',
default: 15,
min: 1,
max: 1000,
description: 'Maximum number of players'
},
'motd': {
type: 'text',
default: 'Welcome to the server!',
description: 'Message of the day shown to connecting players'
},
'server_announce': {
type: 'boolean',
default: false,
description: 'Announce server to the public server list'
},
'serverlist_url': {
type: 'string',
default: 'servers.minetest.net',
description: 'Server list URL for announcements'
}
}
},
'World': {
description: 'World and gameplay settings',
note: 'Many world settings can also be configured per-world in /worlds',
settings: {
'default_game': {
type: 'string',
default: 'minetest_game',
description: 'Default game/subgame to use for new worlds'
},
'creative_mode': {
type: 'boolean',
default: false,
description: 'Enable creative mode by default'
},
'enable_damage': {
type: 'boolean',
default: true,
description: 'Enable player damage and health'
},
'enable_pvp': {
type: 'boolean',
default: true,
description: 'Enable player vs player combat'
},
'disable_fire': {
type: 'boolean',
default: false,
description: 'Disable fire spreading and burning'
},
'time_speed': {
type: 'number',
default: 72,
min: 1,
max: 1000,
description: 'Time speed (72 = 1 real day = 20 minutes game time)'
}
}
},
'Performance': {
description: 'Server performance and limits',
settings: {
'dedicated_server_step': {
type: 'number',
default: 0.09,
min: 0.01,
max: 1.0,
step: 0.01,
description: 'Time step for dedicated server (seconds)'
},
'max_block_generate_distance': {
type: 'number',
default: 8,
min: 1,
max: 50,
description: 'Maximum distance for generating new blocks'
},
'max_block_send_distance': {
type: 'number',
default: 12,
min: 1,
max: 50,
description: 'Maximum distance for sending blocks to clients'
},
'active_block_range': {
type: 'number',
default: 4,
min: 1,
max: 20,
description: 'Blocks within this distance are kept active'
},
'max_simultaneous_block_sends_per_client': {
type: 'number',
default: 40,
min: 1,
max: 200,
description: 'Max blocks sent to each client per step'
}
}
},
'Security': {
description: 'Security and authentication settings',
settings: {
'disallow_empty_password': {
type: 'boolean',
default: false,
description: 'Require non-empty passwords for players'
},
'enable_rollback_recording': {
type: 'boolean',
default: true,
description: 'Record player actions for rollback'
},
'kick_msg_crash': {
type: 'string',
default: 'This server has experienced an internal error. You will now be disconnected.',
description: 'Message shown to players when server crashes'
},
'ask_reconnect_on_crash': {
type: 'boolean',
default: true,
description: 'Ask players to reconnect after server crashes'
}
}
},
'Network': {
description: 'Network and connection settings',
settings: {
'enable_ipv6': {
type: 'boolean',
default: true,
description: 'Enable IPv6 support'
},
'ipv6_server': {
type: 'boolean',
default: false,
description: 'Use IPv6 for server socket'
},
'max_packets_per_iteration': {
type: 'number',
default: 1024,
min: 1,
max: 10000,
description: 'Maximum packets processed per network iteration'
}
}
},
'Advanced': {
description: 'Advanced server settings',
settings: {
'enable_mod_channels': {
type: 'boolean',
default: false,
description: 'Enable mod channels for mod communication'
},
'csm_restriction_flags': {
type: 'number',
default: 62,
description: 'Client-side mod restriction flags (bitmask)'
},
'csm_restriction_noderange': {
type: 'number',
default: 0,
description: 'Limit client-side mod node range'
}
}
}
};
}
async readConfig() {
try {
const content = await fs.readFile(this.configPath, 'utf8');
return this.parseConfig(content);
} catch (error) {
if (error.code === 'ENOENT') {
// Config file doesn't exist, return empty config
return {};
}
throw error;
}
}
parseConfig(content) {
const config = {};
const lines = content.split('\n');
for (const line of lines) {
const trimmed = line.trim();
// Skip empty lines and comments
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
// Parse key = value pairs
const equalIndex = trimmed.indexOf('=');
if (equalIndex > 0) {
const key = trimmed.substring(0, equalIndex).trim();
const value = trimmed.substring(equalIndex + 1).trim();
config[key] = this.parseValue(value);
}
}
return config;
}
parseValue(value) {
// Remove quotes if present
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
// Try to parse as number
if (!isNaN(value) && !isNaN(parseFloat(value))) {
return parseFloat(value);
}
// Parse boolean
if (value.toLowerCase() === 'true') return true;
if (value.toLowerCase() === 'false') return false;
// Return as string
return value;
}
async writeConfig(config) {
const lines = ['# Minetest configuration file', '# Generated by HostBlock', ''];
// Group settings by section
const usedKeys = new Set();
for (const [sectionName, section] of Object.entries(this.configSections)) {
let hasValues = false;
const sectionLines = [];
sectionLines.push(`# ${section.description}`);
if (section.note) {
sectionLines.push(`# ${section.note}`);
}
for (const [key, setting] of Object.entries(section.settings)) {
if (config.hasOwnProperty(key)) {
const value = config[key];
const formattedValue = this.formatValue(value, setting.type);
sectionLines.push(`${key} = ${formattedValue}`);
usedKeys.add(key);
hasValues = true;
}
}
if (hasValues) {
lines.push(...sectionLines);
lines.push('');
}
}
// Add any unknown settings at the end
const unknownSettings = Object.keys(config).filter(key => !usedKeys.has(key));
if (unknownSettings.length > 0) {
lines.push('# Other settings');
for (const key of unknownSettings) {
const value = config[key];
lines.push(`${key} = ${this.formatValue(value)}`);
}
lines.push('');
}
const content = lines.join('\n');
// Create backup of existing config
try {
await fs.access(this.configPath);
const backupPath = `${this.configPath}.backup.${Date.now()}`;
await fs.copyFile(this.configPath, backupPath);
} catch (error) {
// Original config doesn't exist, no backup needed
}
// Write new config
await fs.writeFile(this.configPath, content, 'utf8');
return { success: true, message: 'Configuration saved successfully' };
}
formatValue(value, type = null) {
if (type === 'string' || type === 'text') {
// Quote strings that contain spaces or special characters
if (typeof value === 'string' && (value.includes(' ') || value.includes('#'))) {
return `"${value}"`;
}
}
return String(value);
}
async updateSetting(key, value) {
const config = await this.readConfig();
config[key] = value;
return await this.writeConfig(config);
}
async updateSettings(settings) {
const config = await this.readConfig();
for (const [key, value] of Object.entries(settings)) {
config[key] = value;
}
return await this.writeConfig(config);
}
async resetToDefaults(section = null) {
const config = await this.readConfig();
if (section && this.configSections[section]) {
// Reset specific section
for (const [key, setting] of Object.entries(this.configSections[section].settings)) {
if (setting.default !== undefined) {
config[key] = setting.default;
}
}
} else {
// Reset all sections
for (const section of Object.values(this.configSections)) {
for (const [key, setting] of Object.entries(section.settings)) {
if (setting.default !== undefined) {
config[key] = setting.default;
}
}
}
}
return await this.writeConfig(config);
}
validateSetting(key, value) {
// Find the setting definition
let settingDef = null;
for (const section of Object.values(this.configSections)) {
if (section.settings[key]) {
settingDef = section.settings[key];
break;
}
}
if (!settingDef) {
// Unknown setting, allow any value
return { valid: true, value };
}
// Type validation
switch (settingDef.type) {
case 'boolean':
if (typeof value === 'string') {
if (value.toLowerCase() === 'true') return { valid: true, value: true };
if (value.toLowerCase() === 'false') return { valid: true, value: false };
return { valid: false, error: 'Must be true or false' };
}
if (typeof value === 'boolean') {
return { valid: true, value };
}
return { valid: false, error: 'Must be a boolean value' };
case 'number':
const num = Number(value);
if (isNaN(num)) {
return { valid: false, error: 'Must be a number' };
}
if (settingDef.min !== undefined && num < settingDef.min) {
return { valid: false, error: `Must be at least ${settingDef.min}` };
}
if (settingDef.max !== undefined && num > settingDef.max) {
return { valid: false, error: `Must be at most ${settingDef.max}` };
}
return { valid: true, value: num };
case 'string':
case 'text':
return { valid: true, value: String(value) };
default:
return { valid: true, value };
}
}
getSettingInfo(key) {
for (const [sectionName, section] of Object.entries(this.configSections)) {
if (section.settings[key]) {
return {
section: sectionName,
...section.settings[key]
};
}
}
return null;
}
getAllSettings() {
return this.configSections;
}
}
module.exports = ConfigManager;