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>
This commit is contained in:
Nathan Schneider
2025-08-23 17:32:37 -06:00
commit 3aed09b60f
47 changed files with 12878 additions and 0 deletions

127
utils/app-config.js Normal file
View File

@@ -0,0 +1,127 @@
const fs = require('fs').promises;
const fsSync = require('fs');
const path = require('path');
const os = require('os');
class AppConfig {
constructor() {
this.configDir = path.join(os.homedir(), '.luhost');
this.configFile = path.join(this.configDir, 'config.json');
this.defaultConfig = {
dataDirectory: this.getDefaultDataDirectory(),
serverPort: 3000,
debugMode: false
};
this.config = null;
}
getDefaultDataDirectory() {
const homeDir = os.homedir();
const possibleDirs = [
path.join(homeDir, '.luanti'),
path.join(homeDir, '.minetest')
];
// Use the first one that exists, or default to .minetest
for (const dir of possibleDirs) {
if (fsSync.existsSync(dir)) {
return dir;
}
}
return path.join(homeDir, '.minetest');
}
async load() {
try {
// Ensure config directory exists
if (!fsSync.existsSync(this.configDir)) {
await fs.mkdir(this.configDir, { recursive: true });
}
// Try to read existing config
try {
const configData = await fs.readFile(this.configFile, 'utf8');
this.config = { ...this.defaultConfig, ...JSON.parse(configData) };
} catch (error) {
if (error.code === 'ENOENT') {
// Config file doesn't exist, create it with defaults
this.config = { ...this.defaultConfig };
await this.save();
} else {
throw error;
}
}
return this.config;
} catch (error) {
console.error('Failed to load app config:', error);
// Fall back to defaults if config loading fails
this.config = { ...this.defaultConfig };
return this.config;
}
}
async save() {
try {
if (!fsSync.existsSync(this.configDir)) {
await fs.mkdir(this.configDir, { recursive: true });
}
await fs.writeFile(this.configFile, JSON.stringify(this.config, null, 2), 'utf8');
} catch (error) {
console.error('Failed to save app config:', error);
throw error;
}
}
get(key) {
return this.config ? this.config[key] : this.defaultConfig[key];
}
set(key, value) {
if (!this.config) {
this.config = { ...this.defaultConfig };
}
this.config[key] = value;
}
async update(updates) {
if (!this.config) {
this.config = { ...this.defaultConfig };
}
Object.assign(this.config, updates);
await this.save();
}
getDataDirectory() {
return this.get('dataDirectory');
}
async setDataDirectory(dataDir) {
const resolvedPath = path.resolve(dataDir);
// Validate that the directory exists or can be created
try {
await fs.access(resolvedPath);
} catch (error) {
if (error.code === 'ENOENT') {
// Try to create the directory
try {
await fs.mkdir(resolvedPath, { recursive: true });
} catch (createError) {
throw new Error(`Cannot create data directory: ${createError.message}`);
}
} else {
throw new Error(`Cannot access data directory: ${error.message}`);
}
}
this.set('dataDirectory', resolvedPath);
await this.save();
return resolvedPath;
}
}
module.exports = new AppConfig();

288
utils/auth.js Normal file
View File

@@ -0,0 +1,288 @@
const bcrypt = require('bcrypt');
const fs = require('fs').promises;
const path = require('path');
const sqlite3 = require('sqlite3');
const { promisify } = require('util');
class AuthManager {
constructor() {
this.dbPath = path.join(process.cwd(), 'users.db');
this.db = null;
this.saltRounds = 12;
}
async initialize() {
return new Promise((resolve, reject) => {
this.db = new sqlite3.Database(this.dbPath, (err) => {
if (err) {
reject(err);
return;
}
// Create users table if it doesn't exist
this.db.run(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_by INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME,
is_active BOOLEAN DEFAULT 1,
FOREIGN KEY (created_by) REFERENCES users (id)
)
`, (err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
});
}
async createUser(username, password, createdByUserId = null) {
if (!username || !password) {
throw new Error('Username and password are required');
}
// Check if this is not the first user and no creator is specified
const isFirstUser = await this.isFirstUser();
if (!isFirstUser && !createdByUserId) {
throw new Error('Only existing administrators can create new accounts');
}
// Validate username format
if (!/^[a-zA-Z0-9_-]{3,20}$/.test(username)) {
throw new Error('Username must be 3-20 characters, letters, numbers, underscore, or hyphen only');
}
// Validate password strength
if (password.length < 8) {
throw new Error('Password must be at least 8 characters long');
}
try {
const passwordHash = await bcrypt.hash(password, this.saltRounds);
return new Promise((resolve, reject) => {
const stmt = this.db.prepare(`
INSERT INTO users (username, password_hash, created_by)
VALUES (?, ?, ?)
`);
stmt.run([username, passwordHash, createdByUserId], function(err) {
if (err) {
if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') {
reject(new Error('Username already exists'));
} else {
reject(err);
}
return;
}
resolve({
id: this.lastID,
username: username,
created_at: new Date().toISOString()
});
});
stmt.finalize();
});
} catch (error) {
throw new Error('Failed to create user: ' + error.message);
}
}
async authenticateUser(username, password) {
if (!username || !password) {
throw new Error('Username and password are required');
}
return new Promise((resolve, reject) => {
this.db.get(
'SELECT * FROM users WHERE username = ? AND is_active = 1',
[username],
async (err, user) => {
if (err) {
reject(err);
return;
}
if (!user) {
reject(new Error('Invalid username or password'));
return;
}
try {
const passwordMatch = await bcrypt.compare(password, user.password_hash);
if (!passwordMatch) {
reject(new Error('Invalid username or password'));
return;
}
// Update last login
this.db.run(
'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?',
[user.id]
);
// Return user info (without password hash)
resolve({
id: user.id,
username: user.username,
created_at: user.created_at,
last_login: user.last_login
});
} catch (bcryptError) {
reject(bcryptError);
}
}
);
});
}
async getUserById(id) {
return new Promise((resolve, reject) => {
this.db.get(
'SELECT id, username, created_at, last_login FROM users WHERE id = ? AND is_active = 1',
[id],
(err, user) => {
if (err) {
reject(err);
return;
}
resolve(user || null);
}
);
});
}
async getUserByUsername(username) {
return new Promise((resolve, reject) => {
this.db.get(
'SELECT id, username, created_at, last_login FROM users WHERE username = ? AND is_active = 1',
[username],
(err, user) => {
if (err) {
reject(err);
return;
}
resolve(user || null);
}
);
});
}
async getAllUsers() {
return new Promise((resolve, reject) => {
this.db.all(
'SELECT id, username, created_at, last_login FROM users WHERE is_active = 1 ORDER BY created_at DESC',
[],
(err, users) => {
if (err) {
reject(err);
return;
}
resolve(users || []);
}
);
});
}
async deleteUser(id) {
return new Promise((resolve, reject) => {
this.db.run(
'UPDATE users SET is_active = 0 WHERE id = ?',
[id],
function(err) {
if (err) {
reject(err);
return;
}
resolve(this.changes > 0);
}
);
});
}
async changePassword(id, currentPassword, newPassword) {
if (!currentPassword || !newPassword) {
throw new Error('Current password and new password are required');
}
if (newPassword.length < 8) {
throw new Error('New password must be at least 8 characters long');
}
return new Promise((resolve, reject) => {
this.db.get(
'SELECT password_hash FROM users WHERE id = ? AND is_active = 1',
[id],
async (err, user) => {
if (err) {
reject(err);
return;
}
if (!user) {
reject(new Error('User not found'));
return;
}
try {
const passwordMatch = await bcrypt.compare(currentPassword, user.password_hash);
if (!passwordMatch) {
reject(new Error('Current password is incorrect'));
return;
}
const newPasswordHash = await bcrypt.hash(newPassword, this.saltRounds);
this.db.run(
'UPDATE users SET password_hash = ? WHERE id = ?',
[newPasswordHash, id],
function(err) {
if (err) {
reject(err);
return;
}
resolve(this.changes > 0);
}
);
} catch (bcryptError) {
reject(bcryptError);
}
}
);
});
}
async isFirstUser() {
return new Promise((resolve, reject) => {
this.db.get(
'SELECT COUNT(*) as count FROM users WHERE is_active = 1',
[],
(err, result) => {
if (err) {
reject(err);
return;
}
resolve(result.count === 0);
}
);
});
}
close() {
if (this.db) {
this.db.close();
}
}
}
module.exports = AuthManager;

442
utils/config-manager.js Normal file
View File

@@ -0,0 +1,442 @@
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;

125
utils/config-parser.js Normal file
View File

@@ -0,0 +1,125 @@
const fs = require('fs').promises;
const path = require('path');
class ConfigParser {
static async parseConfig(filePath) {
try {
const content = await fs.readFile(filePath, 'utf8');
const config = {};
const lines = content.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const equalIndex = trimmed.indexOf('=');
if (equalIndex === -1) continue;
const key = trimmed.substring(0, equalIndex).trim();
const value = trimmed.substring(equalIndex + 1).trim();
config[key] = value;
}
return config;
} catch (error) {
if (error.code === 'ENOENT') {
return {};
}
throw error;
}
}
static async writeConfig(filePath, config) {
const lines = [];
for (const [key, value] of Object.entries(config)) {
if (value !== undefined && value !== null) {
lines.push(`${key} = ${value}`);
}
}
await fs.writeFile(filePath, lines.join('\n') + '\n', 'utf8');
}
static async parseModConfig(filePath) {
const config = await this.parseConfig(filePath);
if (config.depends) {
config.depends = config.depends.split(',').map(dep => dep.trim()).filter(Boolean);
} else {
config.depends = [];
}
if (config.optional_depends) {
config.optional_depends = config.optional_depends.split(',').map(dep => dep.trim()).filter(Boolean);
} else {
config.optional_depends = [];
}
return config;
}
static async writeModConfig(filePath, config) {
const configCopy = { ...config };
if (Array.isArray(configCopy.depends)) {
configCopy.depends = configCopy.depends.join(', ');
}
if (Array.isArray(configCopy.optional_depends)) {
configCopy.optional_depends = configCopy.optional_depends.join(', ');
}
await this.writeConfig(filePath, configCopy);
}
static async parseWorldConfig(filePath) {
const config = await this.parseConfig(filePath);
const booleanFields = ['creative_mode', 'enable_damage', 'enable_pvp', 'server_announce'];
for (const field of booleanFields) {
if (config[field] !== undefined) {
config[field] = config[field] === 'true';
}
}
return config;
}
static async writeWorldConfig(filePath, config) {
const configCopy = { ...config };
const booleanFields = ['creative_mode', 'enable_damage', 'enable_pvp', 'server_announce'];
for (const field of booleanFields) {
if (typeof configCopy[field] === 'boolean') {
configCopy[field] = configCopy[field].toString();
}
}
await this.writeConfig(filePath, configCopy);
}
static async parseGameConfig(filePath) {
const config = await this.parseConfig(filePath);
// Parse common game config fields
if (config.name) {
config.name = config.name.trim();
}
if (config.title) {
config.title = config.title.trim();
}
if (config.description) {
config.description = config.description.trim();
}
if (config.author) {
config.author = config.author.trim();
}
return config;
}
}
module.exports = ConfigParser;

202
utils/contentdb-url.js Normal file
View File

@@ -0,0 +1,202 @@
/**
* ContentDB URL Parser and Validator
* Handles parsing and validation of ContentDB package URLs
*/
class ContentDBUrlParser {
/**
* Parse a ContentDB URL to extract author and package name
* @param {string} url - The URL to parse
* @returns {Object} - {author, name, isValid, originalUrl}
*/
static parseUrl(url) {
if (!url || typeof url !== 'string') {
return {
author: null,
name: null,
isValid: false,
originalUrl: url,
error: 'URL is required'
};
}
// Clean up the URL
let cleanUrl = url.trim();
// Remove protocol
cleanUrl = cleanUrl.replace(/^https?:\/\//, '');
// Remove trailing slash
cleanUrl = cleanUrl.replace(/\/$/, '');
// Define patterns to match
const patterns = [
// Full ContentDB URL: content.luanti.org/packages/author/name
/^content\.luanti\.org\/packages\/([^\/\s]+)\/([^\/\s]+)$/,
// Alternative domain patterns (if any)
/^(?:www\.)?content\.luanti\.org\/packages\/([^\/\s]+)\/([^\/\s]+)$/,
// Direct author/name format
/^([^\/\s]+)\/([^\/\s]+)$/
];
// Try each pattern
for (const pattern of patterns) {
const match = cleanUrl.match(pattern);
if (match) {
const author = match[1];
const name = match[2];
// Validate author and name format
if (this.isValidIdentifier(author) && this.isValidIdentifier(name)) {
return {
author: author,
name: name,
isValid: true,
originalUrl: url,
cleanUrl: cleanUrl,
fullUrl: `https://content.luanti.org/packages/${author}/${name}/`
};
} else {
return {
author: null,
name: null,
isValid: false,
originalUrl: url,
error: 'Invalid author or package name format'
};
}
}
}
return {
author: null,
name: null,
isValid: false,
originalUrl: url,
error: 'URL format not recognized'
};
}
/**
* Validate an identifier (author or package name)
* @param {string} identifier - The identifier to validate
* @returns {boolean} - Whether the identifier is valid
*/
static isValidIdentifier(identifier) {
if (!identifier || typeof identifier !== 'string') {
return false;
}
// ContentDB identifiers should be alphanumeric with underscores and hyphens
// Length should be reasonable (3-50 characters)
return /^[a-zA-Z0-9_-]{3,50}$/.test(identifier);
}
/**
* Generate various URL formats for a package
* @param {string} author - Package author
* @param {string} name - Package name
* @returns {Object} - Object containing different URL formats
*/
static generateUrls(author, name) {
if (!this.isValidIdentifier(author) || !this.isValidIdentifier(name)) {
throw new Error('Invalid author or package name');
}
return {
web: `https://content.luanti.org/packages/${author}/${name}/`,
api: `https://content.luanti.org/api/packages/${author}/${name}/`,
releases: `https://content.luanti.org/api/packages/${author}/${name}/releases/`,
direct: `${author}/${name}`
};
}
/**
* Validate multiple URL formats and suggest corrections
* @param {string} url - The URL to validate
* @returns {Object} - Validation result with suggestions
*/
static validateWithSuggestions(url) {
const result = this.parseUrl(url);
if (result.isValid) {
return {
...result,
suggestions: []
};
}
// Generate suggestions for common mistakes
const suggestions = [];
if (url.includes('minetest.') || url.includes('minetest/')) {
suggestions.push('Did you mean content.luanti.org instead of minetest?');
}
if (url.includes('://content.luanti.org') && !url.includes('/packages/')) {
suggestions.push('Make sure the URL includes /packages/author/name/');
}
if (url.includes(' ')) {
suggestions.push('Remove spaces from the URL');
}
// Check if it looks like a partial URL
if (url.includes('/') && !url.includes('content.luanti.org')) {
suggestions.push('Try the full URL: https://content.luanti.org/packages/author/name/');
}
return {
...result,
suggestions
};
}
/**
* Extract package information from various URL formats
* @param {string} url - The URL to extract from
* @returns {Promise<Object>} - Package information if available
*/
static async extractPackageInfo(url) {
const parsed = this.parseUrl(url);
if (!parsed.isValid) {
throw new Error(parsed.error || 'Invalid URL format');
}
return {
author: parsed.author,
name: parsed.name,
identifier: `${parsed.author}/${parsed.name}`,
urls: this.generateUrls(parsed.author, parsed.name)
};
}
/**
* Check if a URL is a ContentDB package URL
* @param {string} url - The URL to check
* @returns {boolean} - Whether it's a ContentDB package URL
*/
static isContentDBUrl(url) {
return this.parseUrl(url).isValid;
}
/**
* Normalize a URL to standard format
* @param {string} url - The URL to normalize
* @returns {string} - Normalized URL
*/
static normalizeUrl(url) {
const parsed = this.parseUrl(url);
if (!parsed.isValid) {
throw new Error(parsed.error || 'Invalid URL format');
}
return parsed.fullUrl;
}
}
module.exports = ContentDBUrlParser;

332
utils/contentdb.js Normal file
View File

@@ -0,0 +1,332 @@
const axios = require('axios');
const fs = require('fs').promises;
const path = require('path');
const archiver = require('archiver');
const yauzl = require('yauzl');
const { promisify } = require('util');
class ContentDBClient {
constructor() {
this.baseURL = 'https://content.luanti.org/api';
this.client = axios.create({
baseURL: this.baseURL,
timeout: 30000,
headers: {
'User-Agent': 'LuHost/1.0',
'Accept': 'application/json'
},
validateStatus: (status) => {
// Only treat 200-299 as success, but don't throw on 404
return (status >= 200 && status < 300) || status === 404;
}
});
}
// Search packages (mods, games, texture packs)
async searchPackages(query = '', type = '', sort = 'score', order = 'desc', limit = 20, offset = 0) {
try {
const params = {
q: query,
type: type, // mod, game, txp (texture pack)
sort: sort, // score, name, created_at, approved_at, downloads
order: order, // asc, desc
limit: Math.min(limit, 50), // API limit
offset: offset
};
const response = await this.client.get('/packages/', { params });
return response.data;
} catch (error) {
throw new Error(`Failed to search ContentDB: ${error.message}`);
}
}
// Get package details
async getPackage(author, name) {
try {
const response = await this.client.get(`/packages/${author}/${name}/`);
// Ensure we got JSON back
if (typeof response.data !== 'object') {
throw new Error('Invalid response format from ContentDB');
}
return response.data;
} catch (error) {
if (error.response?.status === 404) {
throw new Error('Package not found');
}
// Handle cases where the response isn't JSON
if (error.message.includes('JSON') || error.message.includes('Unexpected token')) {
throw new Error('ContentDB returned invalid data format');
}
throw new Error(`Failed to get package details: ${error.message}`);
}
}
// Get package releases
async getPackageReleases(author, name) {
try {
const response = await this.client.get(`/packages/${author}/${name}/releases/`);
return response.data;
} catch (error) {
throw new Error(`Failed to get package releases: ${error.message}`);
}
}
// Download package
async downloadPackage(author, name, targetPath, version = null) {
try {
// Get package info first
const packageInfo = await this.getPackage(author, name);
// Get releases to find download URL
const releases = await this.getPackageReleases(author, name);
if (!releases || releases.length === 0) {
throw new Error('No releases found for this package');
}
// Find the specified version or use the latest
let release;
if (version) {
release = releases.find(r => r.id === version || r.title === version);
if (!release) {
throw new Error(`Version ${version} not found`);
}
} else {
// Use the first release (should be latest)
release = releases[0];
}
if (!release.url) {
throw new Error('No download URL found for this release');
}
// Construct full download URL if needed
let downloadUrl = release.url;
if (downloadUrl.startsWith('/')) {
downloadUrl = 'https://content.luanti.org' + downloadUrl;
}
// Download the package
const downloadResponse = await axios.get(downloadUrl, {
responseType: 'stream',
timeout: 120000, // 2 minutes for download
headers: {
'User-Agent': 'LuHost/1.0'
}
});
// Create target directory
await fs.mkdir(targetPath, { recursive: true });
// If it's a zip file, extract it
if (release.url.endsWith('.zip')) {
const tempZipPath = path.join(targetPath, 'temp.zip');
// Save zip file temporarily
const writer = require('fs').createWriteStream(tempZipPath);
downloadResponse.data.pipe(writer);
await new Promise((resolve, reject) => {
writer.on('finish', resolve);
writer.on('error', reject);
});
// Extract zip file
await this.extractZipFile(tempZipPath, targetPath);
// Remove temp zip file
await fs.unlink(tempZipPath);
} else {
// For non-zip files, save directly
const fileName = path.basename(release.url) || 'download';
const filePath = path.join(targetPath, fileName);
const writer = require('fs').createWriteStream(filePath);
downloadResponse.data.pipe(writer);
await new Promise((resolve, reject) => {
writer.on('finish', resolve);
writer.on('error', reject);
});
}
return {
package: packageInfo,
release: release,
downloadPath: targetPath
};
} catch (error) {
throw new Error(`Failed to download package: ${error.message}`);
}
}
// Extract zip file
async extractZipFile(zipPath, targetPath) {
return new Promise((resolve, reject) => {
yauzl.open(zipPath, { lazyEntries: true }, (err, zipfile) => {
if (err) {
reject(err);
return;
}
zipfile.readEntry();
zipfile.on('entry', async (entry) => {
const entryPath = path.join(targetPath, entry.fileName);
// Ensure the entry path is within target directory (security)
const normalizedPath = path.normalize(entryPath);
if (!normalizedPath.startsWith(path.normalize(targetPath))) {
zipfile.readEntry();
return;
}
if (/\/$/.test(entry.fileName)) {
// Directory entry
try {
await fs.mkdir(normalizedPath, { recursive: true });
zipfile.readEntry();
} catch (error) {
reject(error);
}
} else {
// File entry
zipfile.openReadStream(entry, (err, readStream) => {
if (err) {
reject(err);
return;
}
// Ensure parent directory exists
const parentDir = path.dirname(normalizedPath);
fs.mkdir(parentDir, { recursive: true })
.then(() => {
const writeStream = require('fs').createWriteStream(normalizedPath);
readStream.pipe(writeStream);
writeStream.on('finish', () => {
zipfile.readEntry();
});
writeStream.on('error', reject);
})
.catch(reject);
});
}
});
zipfile.on('end', () => {
resolve();
});
zipfile.on('error', reject);
});
});
}
// Get popular packages
async getPopularPackages(type = '', limit = 10) {
return this.searchPackages('', type, 'downloads', 'desc', limit, 0);
}
// Get recently updated packages
async getRecentPackages(type = '', limit = 10) {
return this.searchPackages('', type, 'approved_at', 'desc', limit, 0);
}
// Check for updates for installed packages
async checkForUpdates(installedPackages) {
const updates = [];
for (const pkg of installedPackages) {
try {
// Try to find the package on ContentDB
// This requires matching local package names to ContentDB packages
// which might not always be straightforward
// For now, we'll implement a basic search-based approach
const searchResults = await this.searchPackages(pkg.name, '', 'score', 'desc', 5);
if (searchResults && searchResults.length > 0) {
// Try to find exact match
const match = searchResults.find(result =>
result.name.toLowerCase() === pkg.name.toLowerCase() ||
result.title.toLowerCase() === pkg.name.toLowerCase()
);
if (match) {
const releases = await this.getPackageReleases(match.author, match.name);
if (releases && releases.length > 0) {
updates.push({
local: pkg,
remote: match,
latestRelease: releases[0],
hasUpdate: true // We could implement version comparison here
});
}
}
}
} catch (error) {
// Skip packages that can't be found or checked
console.warn(`Could not check updates for ${pkg.name}:`, error.message);
}
}
return updates;
}
// Get package dependencies
async getPackageDependencies(author, name) {
try {
const packageInfo = await this.getPackage(author, name);
return {
hard_dependencies: packageInfo.hard_dependencies || [],
optional_dependencies: packageInfo.optional_dependencies || []
};
} catch (error) {
throw new Error(`Failed to get dependencies: ${error.message}`);
}
}
// Install package with dependencies
async installPackageWithDeps(author, name, targetBasePath, resolveDeps = true) {
const installResults = {
main: null,
dependencies: [],
errors: []
};
try {
// Install main package
const mainPackagePath = path.join(targetBasePath, name);
const mainResult = await this.downloadPackage(author, name, mainPackagePath);
installResults.main = mainResult;
// Install dependencies if requested
if (resolveDeps) {
const deps = await this.getPackageDependencies(author, name);
for (const dep of deps.hard_dependencies) {
try {
const depPath = path.join(targetBasePath, dep.name);
const depResult = await this.downloadPackage(dep.author, dep.name, depPath);
installResults.dependencies.push(depResult);
} catch (error) {
installResults.errors.push(`Failed to install dependency ${dep.name}: ${error.message}`);
}
}
}
return installResults;
} catch (error) {
installResults.errors.push(error.message);
return installResults;
}
}
}
module.exports = ContentDBClient;

256
utils/package-registry.js Normal file
View File

@@ -0,0 +1,256 @@
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const fs = require('fs').promises;
class PackageRegistry {
constructor(dbPath = 'data/packages.db') {
this.dbPath = dbPath;
this.db = null;
}
async init() {
// Ensure data directory exists
const dir = path.dirname(this.dbPath);
await fs.mkdir(dir, { recursive: true });
return new Promise((resolve, reject) => {
this.db = new sqlite3.Database(this.dbPath, (err) => {
if (err) {
reject(err);
} else {
this.createTables().then(resolve).catch(reject);
}
});
});
}
async createTables() {
const sql = `
CREATE TABLE IF NOT EXISTS installed_packages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
author TEXT NOT NULL,
name TEXT NOT NULL,
version TEXT,
release_id TEXT,
install_location TEXT NOT NULL, -- 'global' or 'world:worldname'
install_path TEXT NOT NULL,
installed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
contentdb_url TEXT,
package_type TEXT, -- 'mod', 'game', 'txp'
title TEXT,
short_description TEXT,
dependencies TEXT, -- JSON string of dependencies
UNIQUE(author, name, install_location)
);
CREATE INDEX IF NOT EXISTS idx_package_location ON installed_packages(install_location);
CREATE INDEX IF NOT EXISTS idx_package_name ON installed_packages(author, name);
`;
return new Promise((resolve, reject) => {
this.db.exec(sql, (err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
async recordInstallation(packageInfo) {
const {
author,
name,
version,
releaseId,
installLocation,
installPath,
contentdbUrl,
packageType,
title,
shortDescription,
dependencies = []
} = packageInfo;
const sql = `
INSERT OR REPLACE INTO installed_packages
(author, name, version, release_id, install_location, install_path,
contentdb_url, package_type, title, short_description, dependencies, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
`;
const dependenciesJson = JSON.stringify(dependencies);
return new Promise((resolve, reject) => {
this.db.run(sql, [
author, name, version, releaseId, installLocation, installPath,
contentdbUrl, packageType, title, shortDescription, dependenciesJson
], function(err) {
if (err) {
reject(err);
} else {
resolve({ id: this.lastID, changes: this.changes });
}
});
});
}
async getInstalledPackages(location = null) {
let sql = 'SELECT * FROM installed_packages';
const params = [];
if (location) {
if (location === 'global') {
sql += ' WHERE install_location = ?';
params.push('global');
} else if (location.startsWith('world:')) {
sql += ' WHERE install_location = ?';
params.push(location);
}
}
sql += ' ORDER BY installed_at DESC';
return new Promise((resolve, reject) => {
this.db.all(sql, params, (err, rows) => {
if (err) {
reject(err);
} else {
// Parse dependencies JSON for each row
const packages = rows.map(row => ({
...row,
dependencies: row.dependencies ? JSON.parse(row.dependencies) : []
}));
resolve(packages);
}
});
});
}
async getInstalledPackage(author, name, location) {
const sql = `
SELECT * FROM installed_packages
WHERE author = ? AND name = ? AND install_location = ?
`;
return new Promise((resolve, reject) => {
this.db.get(sql, [author, name, location], (err, row) => {
if (err) {
reject(err);
} else if (row) {
row.dependencies = row.dependencies ? JSON.parse(row.dependencies) : [];
resolve(row);
} else {
resolve(null);
}
});
});
}
async isPackageInstalled(author, name, location) {
const packageInfo = await this.getInstalledPackage(author, name, location);
return packageInfo !== null;
}
async removePackage(author, name, location) {
const sql = `
DELETE FROM installed_packages
WHERE author = ? AND name = ? AND install_location = ?
`;
return new Promise((resolve, reject) => {
this.db.run(sql, [author, name, location], function(err) {
if (err) {
reject(err);
} else {
resolve({ changes: this.changes });
}
});
});
}
async getPackagesByWorld(worldName) {
return this.getInstalledPackages(`world:${worldName}`);
}
async getGlobalPackages() {
return this.getInstalledPackages('global');
}
async getAllInstallations() {
return this.getInstalledPackages();
}
async updatePackageInfo(author, name, location, updates) {
const setClause = [];
const params = [];
for (const [key, value] of Object.entries(updates)) {
if (key === 'dependencies' && Array.isArray(value)) {
setClause.push(`${key} = ?`);
params.push(JSON.stringify(value));
} else {
setClause.push(`${key} = ?`);
params.push(value);
}
}
setClause.push('updated_at = CURRENT_TIMESTAMP');
const sql = `
UPDATE installed_packages
SET ${setClause.join(', ')}
WHERE author = ? AND name = ? AND install_location = ?
`;
params.push(author, name, location);
return new Promise((resolve, reject) => {
this.db.run(sql, params, function(err) {
if (err) {
reject(err);
} else {
resolve({ changes: this.changes });
}
});
});
}
async getStatistics() {
const sql = `
SELECT
COUNT(*) as total_packages,
COUNT(CASE WHEN install_location = 'global' THEN 1 END) as global_packages,
COUNT(CASE WHEN install_location LIKE 'world:%' THEN 1 END) as world_packages,
COUNT(DISTINCT CASE WHEN install_location LIKE 'world:%' THEN install_location END) as worlds_with_packages
FROM installed_packages
`;
return new Promise((resolve, reject) => {
this.db.get(sql, [], (err, row) => {
if (err) {
reject(err);
} else {
resolve(row);
}
});
});
}
async close() {
if (this.db) {
return new Promise((resolve) => {
this.db.close((err) => {
if (err) {
console.error('Error closing database:', err);
}
resolve();
});
});
}
}
}
module.exports = PackageRegistry;

194
utils/paths.js Normal file
View File

@@ -0,0 +1,194 @@
const path = require('path');
const os = require('os');
const fs = require('fs');
const appConfig = require('./app-config');
class LuantiPaths {
constructor() {
// Initialize with default, will be updated when app config loads
this.setDataDirectory(this.getDefaultDataDirectory());
}
async initialize() {
// Load app config and update data directory
await appConfig.load();
const configuredDataDir = appConfig.getDataDirectory();
this.setDataDirectory(configuredDataDir);
}
getDefaultDataDirectory() {
// Check for common Luanti data directories
const homeDir = os.homedir();
const possibleDirs = [
path.join(homeDir, '.luanti'),
path.join(homeDir, '.minetest')
];
// Use the first one that exists, or default to .minetest
for (const dir of possibleDirs) {
if (fs.existsSync(dir)) {
return dir;
}
}
return path.join(homeDir, '.minetest');
}
setDataDirectory(dataDir) {
this.minetestDir = path.resolve(dataDir);
this.worldsDir = path.join(this.minetestDir, 'worlds');
this.modsDir = path.join(this.minetestDir, 'mods');
this.gamesDir = path.join(this.minetestDir, 'games');
this.texturesDir = path.join(this.minetestDir, 'textures');
this.configFile = path.join(this.minetestDir, 'minetest.conf');
this.debugFile = path.join(this.minetestDir, 'debug.txt');
}
getDataDirectory() {
return this.minetestDir;
}
getWorldPath(worldName) {
return path.join(this.worldsDir, worldName);
}
getWorldConfigPath(worldName) {
return path.join(this.getWorldPath(worldName), 'world.mt');
}
getWorldModsPath(worldName) {
return path.join(this.getWorldPath(worldName), 'worldmods');
}
getModPath(modName) {
return path.join(this.modsDir, modName);
}
getModConfigPath(modName) {
return path.join(this.getModPath(modName), 'mod.conf');
}
getGamePath(gameName) {
return path.join(this.gamesDir, gameName);
}
getGameConfigPath(gameName) {
return path.join(this.getGamePath(gameName), 'game.conf');
}
ensureDirectories() {
const dirs = [this.minetestDir, this.worldsDir, this.modsDir, this.gamesDir, this.texturesDir];
dirs.forEach(dir => {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
});
}
isValidWorldName(name) {
if (!name || typeof name !== 'string') return false;
return /^[a-zA-Z0-9_-]+$/.test(name) && name.length >= 3 && name.length <= 50;
}
isValidModName(name) {
if (!name || typeof name !== 'string') return false;
return /^[a-zA-Z0-9_-]+$/.test(name) && name.length <= 50;
}
isPathSafe(targetPath) {
const resolvedPath = path.resolve(targetPath);
return resolvedPath.startsWith(path.resolve(this.minetestDir));
}
mapToActualGameId(directoryName) {
// Map directory names to the actual game IDs that Luanti recognizes
// For most cases, the directory name IS the game ID
const gameIdMap = {
// Only add mappings here if you're certain they're needed
// 'minetest_game': 'minetest', // This mapping was incorrect
};
return gameIdMap[directoryName] || directoryName;
}
async getInstalledGames() {
const games = [];
const possibleGameDirs = [
this.gamesDir, // User games directory
'/usr/share/luanti/games', // System games directory
'/usr/share/minetest/games', // Legacy system games directory
path.join(process.env.HOME || '/root', '.minetest/games'), // Explicit user path
path.join(process.env.HOME || '/root', '.luanti/games') // New user path
];
for (const gameDir of possibleGameDirs) {
try {
const exists = fs.existsSync(gameDir);
if (!exists) continue;
const gameDirs = fs.readdirSync(gameDir);
for (const gameName of gameDirs) {
const possibleConfigPaths = [
path.join(gameDir, gameName, 'game.conf'),
path.join(gameDir, gameName, gameName, 'game.conf') // Handle nested structure
];
for (const gameConfigPath of possibleConfigPaths) {
try {
if (fs.existsSync(gameConfigPath)) {
const ConfigParser = require('./config-parser');
const gameConfig = await ConfigParser.parseGameConfig(gameConfigPath);
// Map directory names to actual game IDs that Luanti recognizes
const actualGameId = this.mapToActualGameId(gameName);
// Check if we already have this game (avoid duplicates by game ID, title, and resolved path)
const resolvedPath = fs.realpathSync(path.dirname(gameConfigPath));
const existingGame = games.find(g =>
g.name === actualGameId ||
(g.title === (gameConfig.title || gameConfig.name || gameName) && g.resolvedPath === resolvedPath)
);
if (!existingGame) {
games.push({
name: actualGameId, // Use the ID that Luanti recognizes
directoryName: gameName, // Keep original for path resolution
title: gameConfig.title || gameConfig.name || gameName,
description: gameConfig.description || '',
author: gameConfig.author || '',
path: path.dirname(gameConfigPath),
resolvedPath: resolvedPath,
isSystemGame: !gameDir.includes(this.minetestDir)
});
}
break; // Found valid config, stop checking other paths
}
} catch (gameError) {
// Skip invalid games
console.warn(`Invalid game at ${gameConfigPath}:`, gameError.message);
}
}
}
} catch (dirError) {
// Skip directories that can't be read
continue;
}
}
// Sort games: system games first, then minetest_game first, then alphabetically
games.sort((a, b) => {
if (a.isSystemGame !== b.isSystemGame) {
return a.isSystemGame ? -1 : 1;
}
// Put minetest_game first as it's the default
if (a.name === 'minetest_game') return -1;
if (b.name === 'minetest_game') return 1;
return a.title.localeCompare(b.title);
});
return games;
}
}
module.exports = new LuantiPaths();

206
utils/security-logger.js Normal file
View File

@@ -0,0 +1,206 @@
const fs = require('fs').promises;
const path = require('path');
class SecurityLogger {
constructor() {
this.logFile = path.join(process.cwd(), 'security.log');
this.maxLogSize = 10 * 1024 * 1024; // 10MB
this.maxLogFiles = 5;
}
async log(level, event, details = {}, req = null) {
const timestamp = new Date().toISOString();
// Extract safe request information
const requestInfo = req ? {
ip: req.ip || req.connection.remoteAddress,
userAgent: req.get('User-Agent'),
method: req.method,
url: req.originalUrl || req.url,
userId: req.session?.user?.id,
username: req.session?.user?.username
} : {};
const logEntry = {
timestamp,
level,
event,
details,
request: requestInfo,
pid: process.pid
};
const logLine = JSON.stringify(logEntry) + '\n';
try {
// Check if log rotation is needed
await this.rotateLogIfNeeded();
// Append to log file
await fs.appendFile(this.logFile, logLine);
// Also log to console for development
if (process.env.NODE_ENV !== 'production') {
console.log(`[SECURITY] ${level.toUpperCase()}: ${event}`, details);
}
} catch (error) {
console.error('Failed to write security log:', error);
}
}
async rotateLogIfNeeded() {
try {
const stats = await fs.stat(this.logFile);
if (stats.size > this.maxLogSize) {
// Rotate logs
for (let i = this.maxLogFiles - 1; i > 0; i--) {
const oldFile = `${this.logFile}.${i}`;
const newFile = `${this.logFile}.${i + 1}`;
try {
await fs.rename(oldFile, newFile);
} catch (error) {
// File might not exist, continue
}
}
// Move current log to .1
await fs.rename(this.logFile, `${this.logFile}.1`);
}
} catch (error) {
// Log file might not exist yet, that's fine
}
}
// Security event logging methods
async logAuthSuccess(req, username) {
await this.log('info', 'AUTH_SUCCESS', {
username,
sessionId: req.sessionID
}, req);
}
async logAuthFailure(req, username, reason) {
await this.log('warn', 'AUTH_FAILURE', {
username,
reason,
sessionId: req.sessionID
}, req);
}
async logCommandExecution(req, command, result) {
await this.log('info', 'COMMAND_EXECUTION', {
command,
result: result ? 'success' : 'failed'
}, req);
}
async logConfigChange(req, section, changes) {
await this.log('info', 'CONFIG_CHANGE', {
section,
changes: Object.keys(changes)
}, req);
}
async logSecurityViolation(req, violationType, details) {
await this.log('error', 'SECURITY_VIOLATION', {
violationType,
details
}, req);
}
async logServerStart(req, worldName, options = {}) {
await this.log('info', 'SERVER_START', {
worldName,
options
}, req);
}
async logServerStop(req, forced = false) {
await this.log('info', 'SERVER_STOP', {
forced
}, req);
}
async logFileAccess(req, filePath, operation) {
await this.log('info', 'FILE_ACCESS', {
filePath,
operation
}, req);
}
async logSuspiciousActivity(req, activityType, details) {
await this.log('warn', 'SUSPICIOUS_ACTIVITY', {
activityType,
details
}, req);
}
async logRateLimitExceeded(req) {
await this.log('warn', 'RATE_LIMIT_EXCEEDED', {
limit: 'request_rate'
}, req);
}
async logCSRFViolation(req) {
await this.log('error', 'CSRF_VIOLATION', {
referer: req.get('Referer'),
origin: req.get('Origin')
}, req);
}
async logInputValidationFailure(req, field, value, reason) {
await this.log('warn', 'INPUT_VALIDATION_FAILURE', {
field,
valueLength: value ? value.length : 0,
reason
}, req);
}
// Read security logs (for admin interface)
async getRecentLogs(limit = 100) {
try {
const content = await fs.readFile(this.logFile, 'utf-8');
const lines = content.trim().split('\n').filter(line => line);
return lines.slice(-limit).map(line => {
try {
return JSON.parse(line);
} catch {
return { error: 'Failed to parse log line', line };
}
}).reverse(); // Most recent first
} catch (error) {
return [];
}
}
// Get security metrics
async getSecurityMetrics(hours = 24) {
const logs = await this.getRecentLogs(10000); // Large sample
const since = new Date(Date.now() - hours * 60 * 60 * 1000);
const recentLogs = logs.filter(log =>
log.timestamp && new Date(log.timestamp) > since
);
const metrics = {
totalEvents: recentLogs.length,
authFailures: recentLogs.filter(log => log.event === 'AUTH_FAILURE').length,
securityViolations: recentLogs.filter(log => log.event === 'SECURITY_VIOLATION').length,
suspiciousActivity: recentLogs.filter(log => log.event === 'SUSPICIOUS_ACTIVITY').length,
rateLimitExceeded: recentLogs.filter(log => log.event === 'RATE_LIMIT_EXCEEDED').length,
csrfViolations: recentLogs.filter(log => log.event === 'CSRF_VIOLATION').length,
commandExecutions: recentLogs.filter(log => log.event === 'COMMAND_EXECUTION').length,
configChanges: recentLogs.filter(log => log.event === 'CONFIG_CHANGE').length
};
return metrics;
}
}
// Singleton instance
const securityLogger = new SecurityLogger();
module.exports = securityLogger;

768
utils/server-manager.js Normal file
View File

@@ -0,0 +1,768 @@
const { spawn, exec } = require('child_process');
const fs = require('fs').promises;
const fsSync = require('fs');
const path = require('path');
const EventEmitter = require('events');
const paths = require('./paths');
class ServerManager extends EventEmitter {
constructor() {
super();
this.serverProcess = null;
this.isRunning = false;
this.isReady = false; // Track if server is actually ready to accept connections
this.startTime = null;
this.logBuffer = [];
this.maxLogLines = 1000;
this.serverStats = {
players: 0,
uptime: 0,
memoryUsage: 0,
cpuUsage: 0
};
this.debugFileWatcher = null;
this.lastDebugFilePosition = 0;
}
async getServerStatus() {
// Double-check if process is actually running when we think it is
if (this.isRunning && this.serverProcess && this.serverProcess.pid) {
try {
// Use kill(pid, 0) to check if process exists without sending a signal
process.kill(this.serverProcess.pid, 0);
} catch (error) {
// Process doesn't exist anymore - it was killed externally
this.addLogLine('warning', 'Server process was terminated externally');
this.isRunning = false;
this.isReady = false;
this.serverProcess = null;
this.startTime = null;
// Reset player stats when server stops
this.serverStats.players = 0;
this.serverStats.memoryUsage = 0;
this.serverStats.cpuUsage = 0;
this.emit('exit', { code: null, signal: 'external' });
// Emit status change immediately
this.emit('status', {
isRunning: this.isRunning,
isReady: this.isReady,
uptime: 0,
startTime: null,
players: 0,
memoryUsage: 0,
cpuUsage: 0,
processId: null
});
}
}
// Always check for externally running Luanti servers if we don't have a running one
if (!this.isRunning) {
const externalServer = await this.detectExternalLuantiServer();
if (externalServer) {
this.isRunning = true;
this.isReady = true;
this.startTime = externalServer.startTime;
// Try to get player data from debug log for external servers
const playerData = await this.getExternalServerPlayerData();
this.serverStats.players = playerData.count;
this.addLogLine('info', `Detected external Luanti server (PID: ${externalServer.pid}, World: ${externalServer.world})`);
// Create a mock server process object for tracking
this.serverProcess = { pid: externalServer.pid, external: true };
console.log('ServerManager: Set serverProcess.external = true');
// Start monitoring debug file for external server
this.startDebugFileMonitoring();
}
}
return {
isRunning: this.isRunning,
isReady: this.isReady,
uptime: this.isRunning && this.startTime ? Date.now() - this.startTime : 0,
startTime: this.startTime,
players: this.serverStats.players,
memoryUsage: this.serverStats.memoryUsage,
cpuUsage: this.serverStats.cpuUsage,
processId: this.serverProcess?.pid || null
};
}
async startServer(worldName = null) {
if (this.isRunning) {
throw new Error('Server is already running');
}
try {
// Ensure minetest directory exists
paths.ensureDirectories();
// Build command arguments
const args = [
'--server',
'--config', paths.configFile
];
if (worldName && worldName.trim() !== '') {
if (!paths.isValidWorldName(worldName)) {
throw new Error('Invalid world name');
}
// Check if world exists
const worldPath = paths.getWorldPath(worldName);
try {
await fs.access(worldPath);
} catch (error) {
throw new Error(`World "${worldName}" does not exist. Please create it first in the Worlds section.`);
}
// Read the world's game configuration
const worldConfigPath = path.join(worldPath, 'world.mt');
try {
const worldConfig = await fs.readFile(worldConfigPath, 'utf8');
const gameMatch = worldConfig.match(/gameid\s*=\s*(.+)/);
if (gameMatch) {
const gameId = gameMatch[1].trim();
args.push('--gameid', gameId);
this.addLogLine('info', `Using game: ${gameId} for world: ${worldName}`);
}
} catch (error) {
this.addLogLine('warning', `Could not read world config, using default game: ${error.message}`);
}
args.push('--world', worldPath);
} else {
// If no world specified, we need to create a default world or let the server create one
this.addLogLine('info', 'Starting server without specifying a world. Server will use default world settings.');
}
// Check if minetest/luanti executable exists
const executable = await this.findMinetestExecutable();
this.serverProcess = spawn(executable, args, {
cwd: paths.minetestDir,
stdio: ['pipe', 'pipe', 'pipe']
});
this.isRunning = true;
this.isReady = false; // Server started but not ready yet
this.startTime = Date.now();
// Handle process events
this.serverProcess.on('error', (error) => {
this.emit('error', error);
this.isRunning = false;
this.isReady = false;
this.serverProcess = null;
});
this.serverProcess.on('exit', (code, signal) => {
this.emit('exit', { code, signal });
this.isRunning = false;
this.isReady = false;
this.serverProcess = null;
this.startTime = null;
this.stopDebugFileMonitoring();
});
// Handle output streams
this.serverProcess.stdout.on('data', (data) => {
const lines = data.toString().split('\n').filter(line => line.trim());
lines.forEach(line => this.addLogLine('stdout', line));
this.parseServerStats(data.toString());
});
this.serverProcess.stderr.on('data', (data) => {
const lines = data.toString().split('\n').filter(line => line.trim());
lines.forEach(line => this.addLogLine('stderr', line));
});
this.emit('started', { pid: this.serverProcess.pid });
// Start monitoring debug.txt file for server ready messages
this.startDebugFileMonitoring();
return {
success: true,
pid: this.serverProcess.pid,
message: `Server started successfully with PID ${this.serverProcess.pid}`
};
} catch (error) {
this.isRunning = false;
this.isReady = false;
this.serverProcess = null;
throw error;
}
}
async stopServer(force = false) {
if (!this.isRunning || !this.serverProcess) {
throw new Error('Server is not running');
}
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
if (this.serverProcess && this.isRunning) {
// Force kill if graceful shutdown failed
this.serverProcess.kill('SIGKILL');
resolve({ success: true, message: 'Server force-stopped' });
}
}, 10000); // 10 second timeout
this.serverProcess.on('exit', () => {
clearTimeout(timeout);
resolve({ success: true, message: 'Server stopped gracefully' });
});
// Try graceful shutdown first
if (force) {
this.serverProcess.kill('SIGTERM');
} else {
// Send shutdown command to server
try {
this.serverProcess.stdin.write('/shutdown\n');
} catch (error) {
// If stdin fails, use SIGTERM
this.serverProcess.kill('SIGTERM');
}
}
});
}
async restartServer(worldName = null) {
if (this.isRunning) {
await this.stopServer();
// Wait a moment for clean shutdown
await new Promise(resolve => setTimeout(resolve, 2000));
}
return await this.startServer(worldName);
}
async findGamePath(gameId) {
try {
// Use the paths utility to find installed games
const games = await paths.getInstalledGames();
const game = games.find(g => g.name === gameId);
if (game) {
return game.path;
}
return null;
} catch (error) {
this.addLogLine('warning', `Error finding game path for "${gameId}": ${error.message}`);
return null;
}
}
async findMinetestExecutable() {
// Whitelist of allowed executable names to prevent command injection
const allowedExecutables = ['luanti', 'minetest', 'minetestserver'];
const foundExecutables = [];
for (const name of allowedExecutables) {
try {
// Validate executable name against whitelist
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
continue;
}
const execPath = await new Promise((resolve, reject) => {
// Use spawn instead of exec to avoid command injection
const { spawn } = require('child_process');
const whichProcess = spawn('which', [name], { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
whichProcess.stdout.on('data', (data) => {
stdout += data.toString();
});
whichProcess.stderr.on('data', (data) => {
stderr += data.toString();
});
whichProcess.on('close', (code) => {
if (code === 0) {
resolve(stdout.trim());
} else {
reject(new Error(`which command failed: ${stderr}`));
}
});
whichProcess.on('error', (error) => {
reject(error);
});
});
// Validate that the returned path is safe
if (execPath && path.isAbsolute(execPath)) {
foundExecutables.push({ name, path: execPath });
this.addLogLine('info', `Found executable: ${name} at ${execPath}`);
return execPath; // Return the full path for security
}
} catch (error) {
// Continue to next possibility
}
}
// Provide detailed error message
const errorMsg = `Minetest/Luanti executable not found. Please install Luanti or add it to your PATH.\n` +
`Searched for: ${allowedExecutables.join(', ')}\n` +
`Try: sudo apt install luanti (Ubuntu/Debian) or your system's package manager`;
this.addLogLine('error', errorMsg);
throw new Error(errorMsg);
}
addLogLine(type, content) {
const timestamp = new Date().toISOString();
const logEntry = {
timestamp,
type,
content: content.trim()
};
this.logBuffer.push(logEntry);
// Keep only the last N lines
if (this.logBuffer.length > this.maxLogLines) {
this.logBuffer = this.logBuffer.slice(-this.maxLogLines);
}
this.emit('log', logEntry);
}
parseServerStats(output) {
// Parse server output for statistics and ready state
const lines = output.split('\n');
for (const line of lines) {
// Look for player count
const playerMatch = line.match(/(\d+) players? online/i);
if (playerMatch) {
this.serverStats.players = parseInt(playerMatch[1]);
}
// Look for performance stats if available
const memMatch = line.match(/Memory usage: ([\d.]+)MB/i);
if (memMatch) {
this.serverStats.memoryUsage = parseFloat(memMatch[1]);
}
// Check if server is ready - look for common Luanti server ready messages
if (!this.isReady && this.isRunning) {
const readyIndicators = [
/Server for gameid=".*?" listening on/i,
/listening on \[::\]:\d+/i,
/listening on 0\.0\.0\.0:\d+/i,
/World at \[.*?\]/i,
/Server started/i,
/Loading environment/i
];
for (const indicator of readyIndicators) {
if (indicator.test(line)) {
this.isReady = true;
this.addLogLine('info', 'Server is now ready to accept connections');
console.log(`Server ready detected from line: ${line}`); // Debug log
// Emit status change when server becomes ready
this.emit('status', {
isRunning: this.isRunning,
isReady: this.isReady,
uptime: this.startTime ? Date.now() - this.startTime : 0,
startTime: this.startTime,
players: this.serverStats.players,
memoryUsage: this.serverStats.memoryUsage,
cpuUsage: this.serverStats.cpuUsage,
processId: this.serverProcess?.pid || null
});
break;
}
}
// Also check for error conditions that indicate startup failure
const errorIndicators = [
/ERROR\[Main\]:/i,
/FATAL ERROR/i,
/Could not find or load game/i,
/Failed to/i
];
for (const errorIndicator of errorIndicators) {
if (errorIndicator.test(line)) {
// Don't mark as ready if we see critical errors
this.addLogLine('warning', 'Server startup may have failed - check logs for errors');
break;
}
}
}
}
this.emit('stats', this.serverStats);
}
getLogs(lines = 100) {
return this.logBuffer.slice(-lines);
}
getRecentLogs(since = null) {
if (!since) {
return this.logBuffer.slice(-50);
}
const sinceTime = new Date(since);
return this.logBuffer.filter(log =>
new Date(log.timestamp) > sinceTime
);
}
async sendCommand(command) {
if (!this.isRunning || !this.serverProcess) {
throw new Error('Server is not running');
}
// Check if this is an external server
if (this.serverProcess.external) {
throw new Error('Cannot send commands to external servers. Commands can only be sent to servers started through this dashboard.');
}
// Validate and sanitize command
const sanitizedCommand = this.validateServerCommand(command);
return new Promise((resolve, reject) => {
try {
this.serverProcess.stdin.write(sanitizedCommand + '\n');
this.addLogLine('info', `Command sent: ${sanitizedCommand}`);
resolve({ success: true, message: 'Command sent successfully' });
} catch (error) {
reject(error);
}
});
}
validateServerCommand(command) {
if (!command || typeof command !== 'string') {
throw new Error('Command must be a non-empty string');
}
// Remove any control characters and limit length
const sanitized = command.replace(/[\x00-\x1F\x7F]/g, '').trim();
if (sanitized.length === 0) {
throw new Error('Command cannot be empty after sanitization');
}
if (sanitized.length > 500) {
throw new Error('Command too long (max 500 characters)');
}
// Whitelist of allowed command prefixes for safety
const allowedCommands = [
'/say', '/tell', '/kick', '/ban', '/unban', '/status', '/time', '/weather',
'/give', '/teleport', '/tp', '/spawn', '/help', '/list', '/who', '/shutdown',
'/stop', '/save-all', '/whitelist', '/op', '/deop', '/gamemode', '/difficulty',
'/seed', '/defaultgamemode', '/gamerule', '/reload', '/clear', '/experience',
'/xp', '/effect', '/enchant', '/summon', '/kill', '/scoreboard', '/team',
'/trigger', '/clone', '/execute', '/fill', '/setblock', '/testforblock',
'/blockdata', '/entitydata', '/testfor', '/stats', '/worldborder'
];
// Check if command starts with allowed prefix or is a direct server command
const isAllowed = allowedCommands.some(prefix =>
sanitized.toLowerCase().startsWith(prefix.toLowerCase())
) || /^[a-zA-Z0-9_-]+(\s+[a-zA-Z0-9_.-]+)*$/.test(sanitized);
if (!isAllowed) {
throw new Error('Command not allowed or contains invalid characters');
}
return sanitized;
}
async getServerInfo() {
try {
const configExists = await fs.access(paths.configFile).then(() => true).catch(() => false);
const debugLogExists = await fs.access(paths.debugFile).then(() => true).catch(() => false);
let configMtime = null;
if (configExists) {
const stats = await fs.stat(paths.configFile);
configMtime = stats.mtime;
}
return {
configFile: {
exists: configExists,
path: paths.configFile,
lastModified: configMtime
},
debugLog: {
exists: debugLogExists,
path: paths.debugFile
},
directories: {
minetest: paths.minetestDir,
worlds: paths.worldsDir,
mods: paths.modsDir
}
};
} catch (error) {
throw new Error(`Failed to get server info: ${error.message}`);
}
}
startDebugFileMonitoring() {
const debugFilePath = path.join(paths.minetestDir, 'debug.txt');
try {
// Get initial file size to start monitoring from the end
const stats = fsSync.existsSync(debugFilePath) ? fsSync.statSync(debugFilePath) : null;
this.lastDebugFilePosition = stats ? stats.size : 0;
// Watch for changes to debug.txt
this.debugFileWatcher = fsSync.watchFile(debugFilePath, { interval: 500 }, (current, previous) => {
if (current.mtime > previous.mtime) {
this.readDebugFileChanges(debugFilePath);
}
});
} catch (error) {
this.addLogLine('warning', `Could not monitor debug.txt: ${error.message}`);
}
}
stopDebugFileMonitoring() {
if (this.debugFileWatcher) {
const debugFilePath = path.join(paths.minetestDir, 'debug.txt');
fsSync.unwatchFile(debugFilePath);
this.debugFileWatcher = null;
}
}
async readDebugFileChanges(debugFilePath) {
try {
const stats = fsSync.statSync(debugFilePath);
if (stats.size > this.lastDebugFilePosition) {
const stream = fsSync.createReadStream(debugFilePath, {
start: this.lastDebugFilePosition,
end: stats.size - 1
});
let buffer = '';
stream.on('data', (chunk) => {
buffer += chunk.toString();
});
stream.on('end', () => {
const lines = buffer.split('\n').filter(line => line.trim());
lines.forEach(line => {
this.addLogLine('debug-file', line);
this.parseServerStats(line); // Parse each line for ready indicators
// For external servers, also update player count from new log entries
if (this.serverProcess?.external) {
this.updatePlayerCountFromLogLine(line);
}
});
});
this.lastDebugFilePosition = stats.size;
}
} catch (error) {
// Ignore errors when reading debug file changes
}
}
updatePlayerCountFromLogLine(line) {
// Update player count based on join/leave messages in log
const joinMatch = line.match(/\[Server\]: (\w+) joined the game/);
const leaveMatch = line.match(/\[Server\]: (\w+) left the game/);
if (joinMatch || leaveMatch) {
// Player joined or left - update player data
this.getExternalServerPlayerData().then(playerData => {
this.serverStats.players = playerData.count;
});
}
}
async detectExternalLuantiServer() {
try {
const { spawn } = require('child_process');
return new Promise((resolve) => {
const psProcess = spawn('ps', ['aux'], { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
psProcess.stdout.on('data', (data) => {
stdout += data.toString();
});
psProcess.on('close', (code) => {
if (code === 0) {
const lines = stdout.split('\n');
for (const line of lines) {
// Look for luanti or minetest server processes (exclude this dashboard process)
if ((line.includes('luanti') || line.includes('minetest')) &&
(line.includes('--server') || line.includes('--worldname')) &&
!line.includes('node app.js')) {
const parts = line.trim().split(/\s+/);
const pid = parseInt(parts[1]);
if (pid && !isNaN(pid)) {
// Extract world name from command line
let world = 'unknown';
const worldNameMatch = line.match(/--worldname\s+(\S+)/);
const worldPathMatch = line.match(/--world\s+(\S+)/);
if (worldNameMatch) {
world = worldNameMatch[1];
} else if (worldPathMatch) {
world = path.basename(worldPathMatch[1]);
}
// Estimate start time (this is rough, but better than nothing)
const startTime = Date.now() - 60000; // Assume started 1 minute ago
resolve({
pid: pid,
world: world,
startTime: startTime
});
return;
}
}
}
}
resolve(null);
});
psProcess.on('error', () => {
resolve(null);
});
});
} catch (error) {
return null;
}
}
async getExternalServerPlayerData() {
try {
const fs = require('fs').promises;
const debugFilePath = path.join(paths.minetestDir, 'debug.txt');
// Read the last 100 lines of debug.txt to find recent player activity
const data = await fs.readFile(debugFilePath, 'utf8');
const lines = data.split('\n').slice(-100);
// Look for recent player actions to determine who's online
const playerData = new Map(); // Map to store player name -> player info
const cutoffTime = new Date(Date.now() - 10 * 60 * 1000); // 10 minutes ago (extended from 5)
console.log('DEBUG: Looking for players active since:', cutoffTime.toISOString());
for (const line of lines.reverse()) {
// Parse timestamp from log line
const timestampMatch = line.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}):/);
if (timestampMatch) {
const logTime = new Date(timestampMatch[1]);
if (logTime < cutoffTime) break; // Stop looking at older entries
// Look for player actions with more detail
const actionPatterns = [
{ pattern: /ACTION\[Server\]: (\w+) (.+)/, type: 'action' },
{ pattern: /\[Server\]: (\w+) joined the game/, type: 'joined' },
{ pattern: /\[Server\]: (\w+) left the game/, type: 'left' }
];
for (const { pattern, type } of actionPatterns) {
const match = line.match(pattern);
if (match && match[1]) {
const playerName = match[1];
const actionDescription = match[2] || type;
console.log('DEBUG: Found potential player:', playerName, 'action:', actionDescription);
// Filter out obvious non-player names and false positives
if (!playerName.includes('Entity') &&
!playerName.includes('SAO') &&
!playerName.includes('Explosion') &&
playerName !== 'Player' && // Generic "Player" is not a real username
playerName !== 'Server' &&
playerName !== 'Main' &&
playerName.length > 2 && // Too short usernames are likely false positives
playerName.length < 20 &&
/^[a-zA-Z0-9_]+$/.test(playerName)) {
console.log('DEBUG: Player passed filters:', playerName);
// Update player data with most recent activity
if (!playerData.has(playerName) || logTime > playerData.get(playerName).lastSeen) {
let lastAction = actionDescription;
// Simplify common actions for display
if (lastAction.includes('digs ')) {
lastAction = 'Mining';
} else if (lastAction.includes('places ') || lastAction.includes('puts ')) {
lastAction = 'Building';
} else if (lastAction.includes('uses ') || lastAction.includes('activates ')) {
lastAction = 'Using items';
} else if (lastAction.includes('punched ') || lastAction.includes('damage')) {
lastAction = 'Combat';
} else if (type === 'joined') {
lastAction = 'Just joined';
} else if (type === 'left') {
lastAction = 'Left game';
} else {
lastAction = 'Active';
}
// Count activities for this player
const existingData = playerData.get(playerName) || { activityCount: 0 };
playerData.set(playerName, {
name: playerName,
lastSeen: logTime,
lastAction: lastAction,
activityCount: existingData.activityCount + 1,
online: type !== 'left' // Mark as offline if they left
});
}
} else {
console.log('DEBUG: Player filtered out:', playerName, 'reason: failed validation');
}
}
}
}
}
// Convert to array of player objects, filtering out players who left
const players = Array.from(playerData.values())
.filter(player => player.online)
.map(player => ({
name: player.name,
lastSeen: player.lastSeen,
lastAction: player.lastAction,
activityCount: player.activityCount,
online: true
}));
return {
count: players.length,
players: players
};
} catch (error) {
console.error('Error reading debug file for player data:', error);
return { count: 0, players: [] };
}
}
}
module.exports = ServerManager;

View File

@@ -0,0 +1,6 @@
const ServerManager = require('./server-manager');
// Create a single shared instance
const sharedServerManager = new ServerManager();
module.exports = sharedServerManager;