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:
127
utils/app-config.js
Normal file
127
utils/app-config.js
Normal 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
288
utils/auth.js
Normal 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
442
utils/config-manager.js
Normal 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
125
utils/config-parser.js
Normal 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
202
utils/contentdb-url.js
Normal 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
332
utils/contentdb.js
Normal 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
256
utils/package-registry.js
Normal 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
194
utils/paths.js
Normal 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
206
utils/security-logger.js
Normal 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
768
utils/server-manager.js
Normal 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;
|
6
utils/shared-server-manager.js
Normal file
6
utils/shared-server-manager.js
Normal file
@@ -0,0 +1,6 @@
|
||||
const ServerManager = require('./server-manager');
|
||||
|
||||
// Create a single shared instance
|
||||
const sharedServerManager = new ServerManager();
|
||||
|
||||
module.exports = sharedServerManager;
|
Reference in New Issue
Block a user