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>
442 lines
13 KiB
JavaScript
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; |