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;