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

534
routes/api.js Normal file
View File

@@ -0,0 +1,534 @@
const express = require('express');
const fs = require('fs').promises;
const path = require('path');
const paths = require('../utils/paths');
const serverManager = require('../utils/shared-server-manager');
const ConfigManager = require('../utils/config-manager');
const ConfigParser = require('../utils/config-parser');
const appConfig = require('../utils/app-config');
const router = express.Router();
// Create global config manager instance
const configManager = new ConfigManager();
// Initialize server manager with socket.io when available
let io = null;
function setSocketIO(socketInstance) {
io = socketInstance;
// Attach server manager events to socket.io
serverManager.on('log', (logEntry) => {
if (io) {
io.emit('server:log', logEntry);
}
});
serverManager.on('stats', (stats) => {
if (io) {
io.emit('server:stats', stats);
}
});
serverManager.on('status', (status) => {
if (io) {
io.emit('server:status', status);
}
});
serverManager.on('exit', (exitInfo) => {
if (io) {
// Broadcast status immediately when server exits
serverManager.getServerStatus().then(status => {
io.emit('server:status', status);
});
}
});
}
// Server status endpoint
router.get('/server/status', async (req, res) => {
try {
const status = await serverManager.getServerStatus();
// For all running servers, get player list from debug.txt
let playerList = [];
if (status.isRunning) {
const playerData = await serverManager.getExternalServerPlayerData();
playerList = playerData.players;
// Also update the server stats with current player count
serverManager.serverStats.players = playerData.count;
// Emit player list via WebSocket if available
if (io) {
io.emit('server:players', playerList);
}
}
const isExternal = serverManager.serverProcess?.external || false;
console.log('API: serverManager.serverProcess =', serverManager.serverProcess);
console.log('API: isExternal =', isExternal);
console.log('API endpoint returning status:', {
isRunning: status.isRunning,
players: playerList.length, // Use the actual detected player count
playerNames: playerList.map(p => p.name),
statusText: status.isRunning ? 'running' : 'stopped',
isExternal: isExternal
});
res.json({
...status,
players: playerList.length, // Override with actual player count
playerList: playerList,
// Add simple string status for UI
statusText: status.isRunning ? 'running' : 'stopped',
// Include external server information
isExternal: isExternal
});
} catch (error) {
console.error('API: Server status error:', error);
res.status(500).json({
error: error.message,
statusText: 'stopped',
isRunning: false,
isReady: false,
playerList: []
});
}
});
// Start server
router.post('/server/start', async (req, res) => {
try {
const { worldName } = req.body;
console.log('Server start requested with world:', worldName);
const result = await serverManager.startServer(worldName);
res.json(result);
} catch (error) {
console.error('Server start error:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// Stop server
router.post('/server/stop', async (req, res) => {
try {
const { force = false } = req.body;
const result = await serverManager.stopServer(force);
res.json(result);
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
// Restart server
router.post('/server/restart', async (req, res) => {
try {
const { worldName } = req.body;
const result = await serverManager.restartServer(worldName);
res.json(result);
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
// Send command to server
router.post('/server/command', async (req, res) => {
try {
const { command } = req.body;
if (!command || typeof command !== 'string') {
return res.status(400).json({ error: 'Command is required' });
}
const result = await serverManager.sendCommand(command.trim());
res.json(result);
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
// Get server logs
router.get('/server/logs', async (req, res) => {
try {
const { lines = 500, format = 'text' } = req.query;
const logs = serverManager.getLogs(parseInt(lines));
if (format === 'json') {
res.json(logs);
} else {
// Return as downloadable text file
const logText = logs.map(log =>
`[${log.timestamp}] ${log.type.toUpperCase()}: ${log.content}`
).join('\n');
res.setHeader('Content-Disposition', 'attachment; filename=server-logs.txt');
res.setHeader('Content-Type', 'text/plain');
res.send(logText);
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get server info
router.get('/server/info', async (req, res) => {
try {
const info = await serverManager.getServerInfo();
res.json(info);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Configuration endpoints
// Get all configuration sections
router.get('/config/sections', async (req, res) => {
try {
const sections = configManager.getAllSettings();
res.json(sections);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get current configuration
router.get('/config', async (req, res) => {
try {
// Use the new configuration schema approach instead of ConfigManager
const configSchema = {
System: {
data_directory: {
type: 'string',
default: '',
description: 'Luanti data directory path (leave empty for auto-detection)'
}
},
Server: {
port: {
type: 'number',
default: 30000,
description: 'Port for server to listen on'
},
server_name: {
type: 'string',
default: 'Luanti Server',
description: 'Name of the server'
},
server_description: {
type: 'string',
default: 'A Luanti server',
description: 'Server description'
},
server_announce: {
type: 'boolean',
default: false,
description: 'Announce server to server list'
},
max_users: {
type: 'number',
default: 20,
description: 'Maximum number of users'
}
},
World: {
creative_mode: {
type: 'boolean',
default: false,
description: 'Enable creative mode by default'
},
enable_damage: {
type: 'boolean',
default: true,
description: 'Enable player damage by default'
},
enable_pvp: {
type: 'boolean',
default: true,
description: 'Enable player vs player combat by default'
},
default_game: {
type: 'string',
default: 'minetest_game',
description: 'Default game to use for new worlds'
},
time_speed: {
type: 'number',
default: 72,
description: 'Time speed (72 = normal, higher = faster)'
}
},
Security: {
disallow_empty_password: {
type: 'boolean',
default: false,
description: 'Disallow empty passwords'
},
strict_protocol_version_checking: {
type: 'boolean',
default: false,
description: 'Strict protocol version checking'
}
},
Performance: {
dedicated_server_step: {
type: 'number',
default: 0.1,
description: 'Server step time in seconds'
},
num_emerge_threads: {
type: 'number',
default: 1,
description: 'Number of emerge threads'
},
server_map_save_interval: {
type: 'number',
default: 15.3,
description: 'Map save interval in seconds'
},
max_block_send_distance: {
type: 'number',
default: 12,
description: 'Maximum block send distance'
}
},
Network: {
server_address: {
type: 'string',
default: '',
description: 'IP address to bind to (empty for all interfaces)'
},
server_dedicated: {
type: 'boolean',
default: false,
description: 'Run as dedicated server'
}
},
Advanced: {
max_simultaneous_block_sends_per_client: {
type: 'number',
default: 40,
description: 'Maximum simultaneous block sends per client'
}
}
};
// Load both Luanti config and app config
const luantiConfig = await ConfigParser.parseConfig(paths.configFile);
await appConfig.load();
// Combine configs for display
const combinedConfig = {
...luantiConfig,
data_directory: appConfig.getDataDirectory()
};
// Organize schema into sections with proper structure for frontend
const sections = {};
for (const [sectionName, sectionFields] of Object.entries(configSchema)) {
sections[sectionName] = {
description: sectionName + ' configuration settings',
settings: sectionFields
};
}
res.json({
current: combinedConfig,
sections: sections,
schema: configSchema
});
} catch (error) {
console.error('Error getting config via API:', error);
res.status(500).json({ error: error.message });
}
});
// Update configuration
router.post('/config', async (req, res) => {
try {
const { settings } = req.body;
if (!settings || typeof settings !== 'object') {
return res.status(400).json({ error: 'Settings object is required' });
}
// Validate all settings
const validationErrors = [];
const validatedSettings = {};
for (const [key, value] of Object.entries(settings)) {
const validation = configManager.validateSetting(key, value);
if (validation.valid) {
validatedSettings[key] = validation.value;
} else {
validationErrors.push({ key, error: validation.error });
}
}
if (validationErrors.length > 0) {
return res.status(400).json({
error: 'Validation failed',
details: validationErrors
});
}
const result = await configManager.updateSettings(validatedSettings);
res.json(result);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Update single configuration setting
router.put('/config/:key', async (req, res) => {
try {
const { key } = req.params;
const { value } = req.body;
const validation = configManager.validateSetting(key, value);
if (!validation.valid) {
return res.status(400).json({ error: validation.error });
}
const result = await configManager.updateSetting(key, validation.value);
res.json(result);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Reset configuration section to defaults
router.post('/config/reset/:section?', async (req, res) => {
try {
const { section } = req.params;
const result = await configManager.resetToDefaults(section);
res.json(result);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get setting information
router.get('/config/setting/:key', async (req, res) => {
try {
const { key } = req.params;
const info = configManager.getSettingInfo(key);
if (!info) {
return res.status(404).json({ error: 'Setting not found' });
}
res.json(info);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Worlds endpoints (basic)
router.get('/worlds', async (req, res) => {
try {
await fs.mkdir(paths.worldsDir, { recursive: true });
const worldDirs = await fs.readdir(paths.worldsDir);
const worlds = [];
for (const worldDir of worldDirs) {
try {
const worldPath = paths.getWorldPath(worldDir);
const stats = await fs.stat(worldPath);
if (stats.isDirectory()) {
// Try to read world.mt for display name
let displayName = worldDir;
try {
const worldConfig = await fs.readFile(
path.join(worldPath, 'world.mt'),
'utf8'
);
const nameMatch = worldConfig.match(/world_name\s*=\s*(.+)/);
if (nameMatch) {
displayName = nameMatch[1].trim().replace(/^["']|["']$/g, '');
}
} catch {}
worlds.push({
name: worldDir,
displayName: displayName,
path: worldPath,
lastModified: stats.mtime
});
}
} catch (error) {
// Skip invalid world directories
}
}
// Sort by last modified
worlds.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
res.json(worlds);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ContentDB package info endpoint for validation
router.post('/contentdb/package-info', async (req, res) => {
try {
const { author, name } = req.body;
if (!author || !name) {
return res.status(400).json({ error: 'Author and name are required' });
}
const ContentDBClient = require('../utils/contentdb');
const contentdb = new ContentDBClient();
// Get package info from ContentDB
const packageInfo = await contentdb.getPackage(author, name);
res.json({
type: packageInfo.type || 'mod',
title: packageInfo.title || name,
author: packageInfo.author || author,
name: packageInfo.name || name,
short_description: packageInfo.short_description || ''
});
} catch (error) {
console.error('Error getting package info:', error);
// If it's a 404 error, return that specifically
if (error.message === 'Package not found') {
return res.status(404).json({ error: 'Package not found on ContentDB' });
}
// For other errors, return a generic error but don't fail completely
res.status(200).json({
error: 'Could not verify package information',
type: 'mod', // Default to mod type
fallback: true
});
}
});
module.exports = {
router,
setSocketIO,
serverManager,
configManager
};

262
routes/auth.js Normal file
View File

@@ -0,0 +1,262 @@
const express = require('express');
const AuthManager = require('../utils/auth');
const { redirectIfAuthenticated } = require('../middleware/auth');
const securityLogger = require('../utils/security-logger');
const router = express.Router();
const authManager = new AuthManager();
// Initialize auth manager
authManager.initialize().catch(console.error);
// Login page
router.get('/login', redirectIfAuthenticated, async (req, res) => {
try {
const isFirstUser = await authManager.isFirstUser();
if (isFirstUser) {
// No users exist yet - redirect to registration
return res.redirect('/register');
}
const redirectUrl = req.query.redirect || '/';
res.render('auth/login', {
title: 'Login',
redirectUrl: redirectUrl,
currentPage: 'login'
});
} catch (error) {
console.error('Error checking first user on login:', error);
const redirectUrl = req.query.redirect || '/';
res.render('auth/login', {
title: 'Login',
redirectUrl: redirectUrl,
currentPage: 'login'
});
}
});
// Register page (only for first user)
router.get('/register', redirectIfAuthenticated, async (req, res) => {
try {
const isFirstUser = await authManager.isFirstUser();
if (!isFirstUser) {
return res.status(403).render('error', {
error: 'Registration Not Available',
message: 'New accounts can only be created by existing administrators. Please contact an admin to create your account.'
});
}
res.render('auth/register', {
title: 'Setup Administrator Account',
isFirstUser: isFirstUser,
currentPage: 'register'
});
} catch (error) {
console.error('Error checking first user:', error);
res.status(500).render('error', {
error: 'Failed to load registration page',
message: error.message
});
}
});
// Process login
router.post('/login', redirectIfAuthenticated, async (req, res) => {
try {
const { username, password, redirect } = req.body;
if (!username || !password) {
return res.render('auth/login', {
title: 'Login',
error: 'Username and password are required',
redirectUrl: redirect || '/',
currentPage: 'login',
formData: { username }
});
}
const user = await authManager.authenticateUser(username, password);
// Log successful authentication
await securityLogger.logAuthSuccess(req, username);
// Create session
req.session.user = user;
// Redirect to intended page or dashboard
const redirectUrl = redirect && redirect !== '/login' ? redirect : '/';
res.redirect(redirectUrl);
} catch (error) {
console.error('Login error:', error);
// Log failed authentication
await securityLogger.logAuthFailure(req, username, error.message);
res.render('auth/login', {
title: 'Login',
error: error.message,
redirectUrl: req.body.redirect || '/',
currentPage: 'login',
formData: { username: req.body.username }
});
}
});
// Process registration (only for first user)
router.post('/register', redirectIfAuthenticated, async (req, res) => {
try {
const isFirstUser = await authManager.isFirstUser();
if (!isFirstUser) {
return res.status(403).render('error', {
error: 'Registration Not Available',
message: 'New accounts can only be created by existing administrators.'
});
}
const { username, password, confirmPassword } = req.body;
// Validate inputs
if (!username || !password || !confirmPassword) {
return res.render('auth/register', {
title: 'Setup Administrator Account',
error: 'All fields are required',
isFirstUser: true,
currentPage: 'register',
formData: { username }
});
}
if (password !== confirmPassword) {
return res.render('auth/register', {
title: 'Setup Administrator Account',
error: 'Passwords do not match',
isFirstUser: true,
currentPage: 'register',
formData: { username }
});
}
const user = await authManager.createUser(username, password);
// Create session for new user
req.session.user = {
id: user.id,
username: user.username,
created_at: user.created_at
};
// Redirect to dashboard
res.redirect('/?registered=true');
} catch (error) {
console.error('Registration error:', error);
res.render('auth/register', {
title: 'Register',
error: error.message,
isFirstUser: await authManager.isFirstUser(),
currentPage: 'register',
formData: {
username: req.body.username
}
});
}
});
// Logout
router.post('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
console.error('Logout error:', err);
return res.status(500).json({ error: 'Failed to logout' });
}
if (req.headers.accept && req.headers.accept.includes('application/json')) {
res.json({ message: 'Logged out successfully' });
} else {
res.redirect('/login?message=You have been logged out');
}
});
});
// Get logout (for convenience)
router.get('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
console.error('Logout error:', err);
}
res.redirect('/login?message=You have been logged out');
});
});
// User profile page
router.get('/profile', async (req, res) => {
if (!req.session || !req.session.user) {
return res.redirect('/login');
}
try {
const user = await authManager.getUserById(req.session.user.id);
if (!user) {
req.session.destroy();
return res.redirect('/login?error=User not found');
}
res.render('auth/profile', {
title: 'Profile',
user: user,
currentPage: 'profile'
});
} catch (error) {
console.error('Profile error:', error);
res.status(500).render('error', {
error: 'Failed to load profile',
message: error.message
});
}
});
// Change password
router.post('/change-password', async (req, res) => {
if (!req.session || !req.session.user) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
const { currentPassword, newPassword, confirmPassword } = req.body;
if (!currentPassword || !newPassword || !confirmPassword) {
throw new Error('All fields are required');
}
if (newPassword !== confirmPassword) {
throw new Error('New passwords do not match');
}
await authManager.changePassword(req.session.user.id, currentPassword, newPassword);
if (req.headers.accept && req.headers.accept.includes('application/json')) {
res.json({ message: 'Password changed successfully' });
} else {
res.redirect('/profile?success=Password changed successfully');
}
} catch (error) {
console.error('Change password error:', error);
if (req.headers.accept && req.headers.accept.includes('application/json')) {
res.status(400).json({ error: error.message });
} else {
res.redirect('/profile?error=' + encodeURIComponent(error.message));
}
}
});
module.exports = router;

316
routes/config.js Normal file
View File

@@ -0,0 +1,316 @@
const express = require('express');
const fs = require('fs').promises;
const paths = require('../utils/paths');
const ConfigParser = require('../utils/config-parser');
const appConfig = require('../utils/app-config');
const router = express.Router();
// Configuration schema
const configSchema = {
system: {
data_directory: {
type: 'string',
default: '',
description: 'Luanti data directory path (leave empty for auto-detection)',
section: 'System Settings'
}
},
server: {
port: {
type: 'number',
default: 30000,
description: 'Port for server to listen on'
},
server_name: {
type: 'string',
default: 'Luanti Server',
description: 'Name of the server'
},
server_description: {
type: 'string',
default: 'A Luanti server',
description: 'Server description'
},
server_address: {
type: 'string',
default: '',
description: 'IP address to bind to (empty for all interfaces)'
},
server_announce: {
type: 'boolean',
default: false,
description: 'Announce server to server list'
},
server_dedicated: {
type: 'boolean',
default: false,
description: 'Run as dedicated server'
},
max_users: {
type: 'number',
default: 20,
description: 'Maximum number of users'
}
},
gameplay: {
creative_mode: {
type: 'boolean',
default: false,
description: 'Enable creative mode by default'
},
enable_damage: {
type: 'boolean',
default: true,
description: 'Enable player damage by default'
},
enable_pvp: {
type: 'boolean',
default: true,
description: 'Enable player vs player combat by default'
},
default_game: {
type: 'string',
default: 'minetest_game',
description: 'Default game to use for new worlds'
},
time_speed: {
type: 'number',
default: 72,
description: 'Time speed (72 = normal, higher = faster)'
}
},
security: {
disallow_empty_password: {
type: 'boolean',
default: false,
description: 'Disallow empty passwords'
},
'secure.enable_security': {
type: 'boolean',
default: true,
description: 'Enable security features'
},
strict_protocol_version_checking: {
type: 'boolean',
default: false,
description: 'Strict protocol version checking'
}
},
performance: {
dedicated_server_step: {
type: 'number',
default: 0.1,
description: 'Server step time in seconds'
},
num_emerge_threads: {
type: 'number',
default: 1,
description: 'Number of emerge threads'
},
server_map_save_interval: {
type: 'number',
default: 15.3,
description: 'Map save interval in seconds'
},
max_block_send_distance: {
type: 'number',
default: 12,
description: 'Maximum block send distance'
},
max_simultaneous_block_sends_per_client: {
type: 'number',
default: 40,
description: 'Maximum simultaneous block sends per client'
}
}
};
// Configuration page
router.get('/', async (req, res) => {
try {
paths.ensureDirectories();
// Load both Luanti config and app config
const luantiConfig = await ConfigParser.parseConfig(paths.configFile);
await appConfig.load();
// Combine configs for display
const combinedConfig = {
...luantiConfig,
data_directory: appConfig.getDataDirectory()
};
res.render('config/index', {
title: 'Server Configuration',
config: combinedConfig,
schema: configSchema,
currentPage: 'config',
currentDataDirectory: appConfig.getDataDirectory(),
defaultDataDirectory: appConfig.getDefaultDataDirectory()
});
} catch (error) {
console.error('Error getting config:', error);
res.status(500).render('error', {
error: 'Failed to load configuration',
message: error.message
});
}
});
// Update configuration
router.post('/update', async (req, res) => {
try {
const updates = req.body;
// Handle data directory change separately
if (updates.data_directory !== undefined) {
const newDataDir = updates.data_directory.trim();
if (newDataDir && newDataDir !== appConfig.getDataDirectory()) {
try {
await appConfig.setDataDirectory(newDataDir);
// Update paths to use new directory
await paths.initialize();
} catch (error) {
throw new Error(`Failed to update data directory: ${error.message}`);
}
}
delete updates.data_directory; // Remove from Luanti config updates
}
// Read current Luanti config
const currentConfig = await ConfigParser.parseConfig(paths.configFile);
// Process form data and convert types for Luanti config
const processedUpdates = {};
for (const [key, value] of Object.entries(updates)) {
if (key === '_csrf' || key === 'returnUrl') continue; // Skip CSRF and utility fields
// Find the field in schema to determine type
let fieldType = 'string';
let fieldFound = false;
for (const section of Object.values(configSchema)) {
if (section[key]) {
fieldType = section[key].type;
fieldFound = true;
break;
}
}
// Convert value based on type
if (fieldType === 'boolean') {
processedUpdates[key] = value === 'on' || value === 'true';
} else if (fieldType === 'number') {
const numValue = parseFloat(value);
if (!isNaN(numValue)) {
processedUpdates[key] = numValue;
}
} else {
// String or unknown type
if (value !== '') {
processedUpdates[key] = value;
}
}
}
// Merge with current Luanti config
const updatedConfig = { ...currentConfig, ...processedUpdates };
// Write updated Luanti config
await ConfigParser.writeConfig(paths.configFile, updatedConfig);
const returnUrl = req.body.returnUrl || '/config';
res.redirect(`${returnUrl}?updated=true`);
} catch (error) {
console.error('Error updating config:', error);
const returnUrl = req.body.returnUrl || '/config';
res.redirect(`${returnUrl}?error=${encodeURIComponent(error.message)}`);
}
});
// Reset configuration to defaults
router.post('/reset', async (req, res) => {
try {
const defaultConfig = {};
// Build default configuration from schema
for (const [sectionName, section] of Object.entries(configSchema)) {
for (const [key, field] of Object.entries(section)) {
if (field.default !== undefined) {
defaultConfig[key] = field.default;
}
}
}
await ConfigParser.writeConfig(paths.configFile, defaultConfig);
res.redirect('/config?reset=true');
} catch (error) {
console.error('Error resetting config:', error);
res.redirect(`/config?error=${encodeURIComponent(error.message)}`);
}
});
// Export current configuration as file
router.get('/export', async (req, res) => {
try {
const config = await ConfigParser.parseConfig(paths.configFile);
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', 'attachment; filename=minetest.conf');
const configLines = [];
for (const [key, value] of Object.entries(config)) {
if (value !== undefined && value !== null) {
configLines.push(`${key} = ${value}`);
}
}
res.send(configLines.join('\n'));
} catch (error) {
console.error('Error exporting config:', error);
res.status(500).render('error', {
error: 'Failed to export configuration',
message: error.message
});
}
});
// Get configuration schema (API)
router.get('/api/schema', (req, res) => {
res.json(configSchema);
});
// Get current configuration (API)
router.get('/api/current', async (req, res) => {
try {
const config = await ConfigParser.parseConfig(paths.configFile);
res.json(config);
} catch (error) {
console.error('Error getting config:', error);
res.status(500).json({ error: 'Failed to get configuration' });
}
});
// Update configuration (API)
router.put('/api/update', async (req, res) => {
try {
const updates = req.body;
const currentConfig = await ConfigParser.parseConfig(paths.configFile);
const updatedConfig = { ...currentConfig, ...updates };
await ConfigParser.writeConfig(paths.configFile, updatedConfig);
res.json({ message: 'Configuration updated successfully' });
} catch (error) {
console.error('Error updating config:', error);
res.status(500).json({ error: 'Failed to update configuration' });
}
});
module.exports = router;

529
routes/contentdb.js Normal file
View File

@@ -0,0 +1,529 @@
const express = require('express');
const fs = require('fs').promises;
const path = require('path');
const paths = require('../utils/paths');
const ContentDBClient = require('../utils/contentdb');
const ContentDBUrlParser = require('../utils/contentdb-url');
const PackageRegistry = require('../utils/package-registry');
const router = express.Router();
const contentdb = new ContentDBClient();
const packageRegistry = new PackageRegistry();
// Initialize package registry
packageRegistry.init().catch(console.error);
// ContentDB browse page
router.get('/', async (req, res) => {
try {
const {
q = '',
type = '',
sort = 'score',
order = 'desc',
page = '1'
} = req.query;
const limit = 20;
const offset = (parseInt(page) - 1) * limit;
const packages = await contentdb.searchPackages(q, type, sort, order, limit, offset);
const totalPages = Math.ceil((packages.length || 0) / limit);
const currentPage = parseInt(page);
res.render('contentdb/index', {
title: 'ContentDB Browser',
packages: packages || [],
search: {
query: q,
type: type,
sort: sort,
order: order
},
pagination: {
current: currentPage,
total: totalPages,
hasNext: currentPage < totalPages,
hasPrev: currentPage > 1
},
currentPage: 'contentdb'
});
} catch (error) {
console.error('Error browsing ContentDB:', error);
res.status(500).render('error', {
error: 'Failed to browse ContentDB',
message: error.message
});
}
});
// Popular packages
router.get('/popular', async (req, res) => {
try {
const type = req.query.type || '';
const packages = await contentdb.getPopularPackages(type, 20);
res.render('contentdb/popular', {
title: 'Popular Content',
packages: packages || [],
type: type,
currentPage: 'contentdb'
});
} catch (error) {
console.error('Error getting popular packages:', error);
res.status(500).render('error', {
error: 'Failed to load popular content',
message: error.message
});
}
});
// Recent packages
router.get('/recent', async (req, res) => {
try {
const type = req.query.type || '';
const packages = await contentdb.getRecentPackages(type, 20);
res.render('contentdb/recent', {
title: 'Recent Content',
packages: packages || [],
type: type,
currentPage: 'contentdb'
});
} catch (error) {
console.error('Error getting recent packages:', error);
res.status(500).render('error', {
error: 'Failed to load recent content',
message: error.message
});
}
});
// Package details
router.get('/package/:author/:name', async (req, res) => {
try {
const { author, name } = req.params;
const [packageInfo, releases] = await Promise.all([
contentdb.getPackage(author, name),
contentdb.getPackageReleases(author, name)
]);
let dependencies = null;
try {
dependencies = await contentdb.getPackageDependencies(author, name);
} catch (depError) {
console.warn('Could not get dependencies:', depError.message);
}
res.render('contentdb/package', {
title: `${packageInfo.title || packageInfo.name}`,
package: packageInfo,
releases: releases || [],
dependencies: dependencies,
currentPage: 'contentdb'
});
} catch (error) {
if (error.message.includes('not found')) {
return res.status(404).render('error', {
error: 'Package not found',
message: 'The requested package could not be found on ContentDB.'
});
}
console.error('Error getting package details:', error);
res.status(500).render('error', {
error: 'Failed to load package details',
message: error.message
});
}
});
// Install package
router.post('/install/:author/:name', async (req, res) => {
try {
const { author, name } = req.params;
const { version, installDeps = false } = req.body;
// Get package info to determine type
const packageInfo = await contentdb.getPackage(author, name);
const packageType = packageInfo.type || 'mod';
// Determine target path based on package type
let targetPath;
let locationDescription;
if (packageType === 'game') {
// VALIDATION: Games always go to games directory - cannot be installed to worlds
// This prevents user confusion and maintains proper Luanti architecture where:
// - Games are global and shared across all worlds
// - Worlds are created with a specific game and cannot change games later
// - Installing a game to a world would break the world or have no effect
if (req.body.installTo === 'world') {
return res.status(400).json({
error: 'Games cannot be installed to specific worlds. Games are installed globally and shared across all worlds. To use this game, create a new world and select this game during world creation.',
type: 'invalid_installation_target',
packageType: 'game'
});
}
targetPath = paths.getGamePath(name);
locationDescription = 'games directory';
} else if (packageType === 'txp') {
// Texture packs go to textures directory
targetPath = path.join(paths.texturesDir, name);
locationDescription = 'textures directory';
} else {
// Mods can go to global or world-specific location
if (req.body.installTo === 'world' && req.body.worldName) {
if (!paths.isValidWorldName(req.body.worldName)) {
return res.status(400).json({ error: 'Invalid world name' });
}
targetPath = path.join(paths.getWorldModsPath(req.body.worldName), name);
locationDescription = `world "${req.body.worldName}"`;
} else {
targetPath = path.join(paths.modsDir, name);
locationDescription = 'global directory';
}
}
// Check if already installed
try {
await fs.access(targetPath);
return res.status(409).json({ error: 'Package already installed at this location' });
} catch {}
let result;
if (installDeps === 'on' && packageType === 'mod') {
// Install with dependencies (only for mods)
const basePath = req.body.installTo === 'world'
? paths.getWorldModsPath(req.body.worldName)
: paths.modsDir;
result = await contentdb.installPackageWithDeps(author, name, basePath, true);
} else {
// Install just the package
result = await contentdb.downloadPackage(author, name, targetPath, version);
}
const location = packageType === 'game' ? 'games' :
packageType === 'txp' ? 'textures' :
(req.body.installTo === 'world' ? req.body.worldName : 'global');
res.json({
success: true,
message: `Package ${name} installed successfully to ${location}`,
result: result
});
} catch (error) {
console.error('Error installing package:', error);
res.status(500).json({
success: false,
error: 'Failed to install package: ' + error.message
});
}
});
// Check for updates
router.get('/updates', async (req, res) => {
try {
// Get installed packages from registry
const installedPackages = await packageRegistry.getAllInstallations();
const updates = [];
for (const pkg of installedPackages) {
try {
// Get latest release info from ContentDB
const releases = await contentdb.getPackageReleases(pkg.author, pkg.name);
if (releases && releases.length > 0) {
const latestRelease = releases[0];
// Simple version comparison - if release IDs differ, consider it an update
const hasUpdate = pkg.release_id !== latestRelease.id;
if (hasUpdate) {
const packageInfo = await contentdb.getPackage(pkg.author, pkg.name);
updates.push({
installed: pkg,
latest: {
package: packageInfo,
release: latestRelease
},
hasUpdate: true
});
}
}
} catch (error) {
console.warn(`Could not check updates for ${pkg.author}/${pkg.name}:`, error.message);
// Skip packages that can't be checked
}
}
res.render('contentdb/updates', {
title: 'Available Updates',
updates: updates,
installedCount: installedPackages.length,
updateCount: updates.length,
currentPage: 'contentdb'
});
} catch (error) {
console.error('Error checking for updates:', error);
res.status(500).render('error', {
error: 'Failed to check for updates',
message: error.message
});
}
});
// View installed packages
router.get('/installed', async (req, res) => {
try {
const { location } = req.query;
const packages = await packageRegistry.getInstalledPackages(location);
const stats = await packageRegistry.getStatistics();
res.render('contentdb/installed', {
title: 'Installed Packages',
packages: packages,
statistics: stats,
selectedLocation: location || 'all',
currentPage: 'contentdb'
});
} catch (error) {
console.error('Error getting installed packages:', error);
res.status(500).render('error', {
error: 'Failed to load installed packages',
message: error.message
});
}
});
// Install package from URL
router.post('/install-url', async (req, res) => {
try {
const { packageUrl, installLocation, worldName, installDeps } = req.body;
if (!packageUrl) {
return res.status(400).json({
success: false,
error: 'Package URL is required'
});
}
// Parse and validate URL
const parsed = ContentDBUrlParser.parseUrl(packageUrl);
if (!parsed.isValid) {
return res.status(400).json({
success: false,
error: parsed.error || 'Invalid URL format'
});
}
const { author, name } = parsed;
// Get package info to determine type
const packageInfo = await contentdb.getPackage(author, name);
const packageType = packageInfo.type || 'mod';
// Determine target path based on package type
let targetPath;
let locationDescription;
if (packageType === 'game') {
// VALIDATION: Games always go to games directory - cannot be installed to worlds
// This prevents user confusion and maintains proper Luanti architecture where:
// - Games are global and shared across all worlds
// - Worlds are created with a specific game and cannot change games later
// - Installing a game to a world would break the world or have no effect
if (installLocation === 'world') {
return res.status(400).json({
success: false,
error: 'Games cannot be installed to specific worlds. Games are installed globally and shared across all worlds. To use this game, create a new world and select this game during world creation.',
type: 'invalid_installation_target',
packageType: 'game'
});
}
await fs.mkdir(paths.gamesDir, { recursive: true });
targetPath = paths.getGamePath(name);
locationDescription = 'games directory';
} else if (packageType === 'txp') {
// Texture packs go to textures directory
await fs.mkdir(paths.texturesDir, { recursive: true });
targetPath = path.join(paths.texturesDir, name);
locationDescription = 'textures directory';
} else {
// Mods can go to global or world-specific location
if (installLocation === 'world') {
if (!worldName) {
return res.status(400).json({
success: false,
error: 'World name is required when installing to specific world'
});
}
if (!paths.isValidWorldName(worldName)) {
return res.status(400).json({
success: false,
error: 'Invalid world name'
});
}
// Ensure worldmods directory exists
const worldModsPath = paths.getWorldModsPath(worldName);
await fs.mkdir(worldModsPath, { recursive: true });
targetPath = path.join(worldModsPath, name);
locationDescription = `world "${worldName}"`;
} else {
// Global installation
await fs.mkdir(paths.modsDir, { recursive: true });
targetPath = path.join(paths.modsDir, name);
locationDescription = 'global directory';
}
}
// Check if already installed at this location
let installLocationKey;
if (packageType === 'game') {
installLocationKey = 'games';
} else if (packageType === 'txp') {
installLocationKey = 'textures';
} else {
installLocationKey = installLocation === 'world' ? `world:${worldName}` : 'global';
}
const isInstalled = await packageRegistry.isPackageInstalled(author, name, installLocationKey);
if (isInstalled) {
return res.status(409).json({
success: false,
error: `Package "${name}" is already installed in ${locationDescription}`
});
}
// Install the package
let installResult;
if (installDeps === 'on' && packageType === 'mod') {
// Install with dependencies (only for mods)
const basePath = installLocation === 'world'
? paths.getWorldModsPath(worldName)
: paths.modsDir;
installResult = await contentdb.installPackageWithDeps(author, name, basePath, true);
if (installResult.errors && installResult.errors.length > 0) {
console.warn('Installation completed with errors:', installResult.errors);
}
} else {
// Install just the main package
installResult = await contentdb.downloadPackage(author, name, targetPath);
}
// Record installation in registry
try {
// Handle different installResult structures
const packageInfo = installResult.main ? installResult.main.package : installResult.package;
const releaseInfo = installResult.main ? installResult.main.release : installResult.release;
await packageRegistry.recordInstallation({
author: author,
name: name,
version: releaseInfo?.title || 'latest',
releaseId: releaseInfo?.id,
installLocation: installLocationKey,
installPath: targetPath,
contentdbUrl: parsed.fullUrl,
packageType: packageInfo?.type || 'mod',
title: packageInfo?.title || name,
shortDescription: packageInfo?.short_description || '',
dependencies: packageInfo?.hard_dependencies || []
});
// Record dependencies if installed
if (installDeps === 'on' && installResult.dependencies) {
for (const dep of installResult.dependencies) {
const depInfo = dep.package;
const depRelease = dep.release;
const depPath = path.join(
installLocation === 'world' ? paths.getWorldModsPath(worldName) : paths.modsDir,
depInfo.name
);
await packageRegistry.recordInstallation({
author: depInfo.author,
name: depInfo.name,
version: depRelease?.title || 'latest',
releaseId: depRelease?.id,
installLocation: installLocationKey,
installPath: depPath,
contentdbUrl: `https://content.luanti.org/packages/${depInfo.author}/${depInfo.name}/`,
packageType: depInfo.type || 'mod',
title: depInfo.title || depInfo.name,
shortDescription: depInfo.short_description || '',
dependencies: depInfo.hard_dependencies || []
});
}
}
} catch (registryError) {
console.warn('Failed to record installation in registry:', registryError);
// Continue anyway, installation was successful
}
// Create success response
let message = `Successfully installed "${name}" to ${locationDescription}`;
if (installDeps === 'on' && installResult.dependencies) {
const depCount = installResult.dependencies.length;
if (depCount > 0) {
message += ` with ${depCount} dependenc${depCount === 1 ? 'y' : 'ies'}`;
}
}
res.json({
success: true,
message: message,
package: {
author: author,
name: name,
location: locationDescription
},
installResult: installResult
});
} catch (error) {
console.error('Error installing package from URL:', error);
res.status(500).json({
success: false,
error: 'Installation failed: ' + error.message
});
}
});
// API endpoint for search (AJAX)
router.get('/api/search', async (req, res) => {
try {
const {
q = '',
type = '',
sort = 'score',
order = 'desc',
limit = '10'
} = req.query;
const packages = await contentdb.searchPackages(q, type, sort, order, parseInt(limit), 0);
res.json({
packages: packages || [],
query: q,
type: type
});
} catch (error) {
console.error('Error searching ContentDB:', error);
res.status(500).json({ error: error.message });
}
});
module.exports = router;

365
routes/extensions.js Normal file
View File

@@ -0,0 +1,365 @@
const express = require('express');
const fs = require('fs').promises;
const path = require('path');
const paths = require('../utils/paths');
const ConfigParser = require('../utils/config-parser');
const ContentDBClient = require('../utils/contentdb');
const ContentDBUrlParser = require('../utils/contentdb-url');
const PackageRegistry = require('../utils/package-registry');
const router = express.Router();
const contentdb = new ContentDBClient();
const packageRegistry = new PackageRegistry();
// Initialize package registry
packageRegistry.init().catch(console.error);
// Main Extensions page - shows installed content and installer
router.get('/', async (req, res) => {
try {
paths.ensureDirectories();
// Get installed packages from registry (games, mods, texture packs)
const allRegistryPackages = await packageRegistry.getAllInstallations();
const statistics = await packageRegistry.getStatistics();
// Filter registry packages to only include those that actually exist on disk
const installedPackages = [];
for (const pkg of allRegistryPackages) {
let packagePath;
if (pkg.package_type === 'game') {
packagePath = paths.getGamePath(pkg.name);
} else if (pkg.package_type === 'mod') {
packagePath = paths.getModPath(pkg.name);
} else {
// For other types, assume they exist (texture packs, etc.)
installedPackages.push(pkg);
continue;
}
// Only include if the package directory actually exists
try {
const stats = await fs.stat(packagePath);
if (stats.isDirectory()) {
installedPackages.push(pkg);
}
} catch (error) {
// Package directory doesn't exist, don't include it
console.log(`Package ${pkg.name} (${pkg.package_type}) not found at ${packagePath}, excluding from installed list`);
}
}
// Get local mods (not from ContentDB)
let localMods = [];
try {
const modDirs = await fs.readdir(paths.modsDir);
for (const modDir of modDirs) {
try {
const modPath = paths.getModPath(modDir);
const configPath = paths.getModConfigPath(modDir);
const stats = await fs.stat(modPath);
if (!stats.isDirectory()) continue;
// Check if this mod is already in the registry (from ContentDB)
const isFromContentDB = installedPackages.some(pkg =>
pkg.name === modDir && pkg.install_location === 'global'
);
if (!isFromContentDB) {
const config = await ConfigParser.parseModConfig(configPath);
localMods.push({
name: modDir,
title: config.title || modDir,
description: config.description || '',
author: config.author || 'Local',
type: 'mod',
location: 'global',
source: 'local',
path: modPath,
lastModified: stats.mtime
});
}
} catch (modError) {
console.error(`Error reading mod ${modDir}:`, modError);
}
}
} catch (dirError) {
console.warn('Could not read mods directory:', dirError);
}
// Get installed games from all locations (only those NOT already in ContentDB registry)
let localGames = [];
try {
const allInstalledGames = await paths.getInstalledGames();
for (const game of allInstalledGames) {
// Check if this game is already in the ContentDB registry
const isFromContentDB = installedPackages.some(pkg =>
(pkg.name === game.name || pkg.name === game.directoryName) && pkg.package_type === 'game'
);
if (!isFromContentDB) {
localGames.push({
name: game.name,
title: game.title,
description: game.description,
author: game.author || 'Unknown',
type: 'game',
location: 'games',
source: game.isSystemGame ? 'system' : 'local',
path: game.path,
lastModified: null // We don't have this info from the paths util
});
}
}
} catch (dirError) {
console.warn('Could not read games:', dirError);
}
// Combine all content (ContentDB packages already include games)
const allContent = [
...installedPackages.map(pkg => ({ ...pkg, source: 'contentdb' })),
...localMods,
...localGames
];
// Sort by type (games first, then mods, then texture packs) and name
const sortOrder = { game: 1, mod: 2, txp: 3 };
allContent.sort((a, b) => {
const typeA = sortOrder[a.package_type || a.type] || 4;
const typeB = sortOrder[b.package_type || b.type] || 4;
if (typeA !== typeB) return typeA - typeB;
return (a.title || a.name).localeCompare(b.title || b.name);
});
res.render('extensions/index', {
title: 'Extensions',
allContent: allContent,
statistics: {
...statistics,
games: installedPackages.filter(pkg => pkg.package_type === 'game').length + localGames.length,
local_mods: localMods.length
},
currentPage: 'extensions'
});
} catch (error) {
console.error('Error loading extensions:', error);
res.status(500).render('error', {
error: 'Failed to load extensions',
message: error.message
});
}
});
// Install package from URL (same as ContentDB)
router.post('/install-url', async (req, res) => {
try {
const { packageUrl, installLocation, worldName, installDeps } = req.body;
if (!packageUrl) {
return res.status(400).json({
success: false,
error: 'Package URL is required'
});
}
// Parse and validate URL
const parsed = ContentDBUrlParser.parseUrl(packageUrl);
if (!parsed.isValid) {
return res.status(400).json({
success: false,
error: parsed.error || 'Invalid URL format'
});
}
const { author, name } = parsed;
// Get package info to determine type
const packageInfo = await contentdb.getPackage(author, name);
const packageType = packageInfo.type || 'mod';
// Determine target path based on package type
let targetPath;
let locationDescription;
if (packageType === 'game') {
await fs.mkdir(paths.gamesDir, { recursive: true });
targetPath = paths.getGamePath(name);
locationDescription = 'games directory';
} else if (packageType === 'txp') {
await fs.mkdir(paths.texturesDir, { recursive: true });
targetPath = path.join(paths.texturesDir, name);
locationDescription = 'textures directory';
} else {
if (installLocation === 'world') {
if (!worldName) {
return res.status(400).json({
success: false,
error: 'World name is required when installing to specific world'
});
}
if (!paths.isValidWorldName(worldName)) {
return res.status(400).json({
success: false,
error: 'Invalid world name'
});
}
const worldModsPath = paths.getWorldModsPath(worldName);
await fs.mkdir(worldModsPath, { recursive: true });
targetPath = path.join(worldModsPath, name);
locationDescription = `world "${worldName}"`;
} else {
await fs.mkdir(paths.modsDir, { recursive: true });
targetPath = path.join(paths.modsDir, name);
locationDescription = 'global directory';
}
}
// Check if already installed
let installLocationKey;
if (packageType === 'game') {
installLocationKey = 'games';
} else if (packageType === 'txp') {
installLocationKey = 'textures';
} else {
installLocationKey = installLocation === 'world' ? `world:${worldName}` : 'global';
}
const isInstalled = await packageRegistry.isPackageInstalled(author, name, installLocationKey);
if (isInstalled) {
return res.status(409).json({
success: false,
error: `Package "${name}" is already installed in ${locationDescription}`
});
}
// Install the package
let installResult;
if (installDeps === 'on' && packageType === 'mod') {
const basePath = installLocation === 'world'
? paths.getWorldModsPath(worldName)
: paths.modsDir;
installResult = await contentdb.installPackageWithDeps(author, name, basePath, true);
if (installResult.errors && installResult.errors.length > 0) {
console.warn('Installation completed with errors:', installResult.errors);
}
} else {
installResult = await contentdb.downloadPackage(author, name, targetPath);
}
// Record installation in registry
try {
const packageInfo = installResult.main ? installResult.main.package : installResult.package;
const releaseInfo = installResult.main ? installResult.main.release : installResult.release;
await packageRegistry.recordInstallation({
author: author,
name: name,
version: releaseInfo?.title || 'latest',
releaseId: releaseInfo?.id,
installLocation: installLocationKey,
installPath: targetPath,
contentdbUrl: parsed.fullUrl,
packageType: packageInfo?.type || 'mod',
title: packageInfo?.title || name,
shortDescription: packageInfo?.short_description || '',
dependencies: packageInfo?.hard_dependencies || []
});
// Record dependencies if installed
if (installDeps === 'on' && installResult.dependencies) {
for (const dep of installResult.dependencies) {
const depInfo = dep.package;
const depRelease = dep.release;
const depPath = path.join(
installLocation === 'world' ? paths.getWorldModsPath(worldName) : paths.modsDir,
depInfo.name
);
await packageRegistry.recordInstallation({
author: depInfo.author,
name: depInfo.name,
version: depRelease?.title || 'latest',
releaseId: depRelease?.id,
installLocation: installLocationKey,
installPath: depPath,
contentdbUrl: `https://content.luanti.org/packages/${depInfo.author}/${depInfo.name}/`,
packageType: depInfo.type || 'mod',
title: depInfo.title || depInfo.name,
shortDescription: depInfo.short_description || '',
dependencies: depInfo.hard_dependencies || []
});
}
}
} catch (registryError) {
console.warn('Failed to record installation in registry:', registryError);
}
// Create success response
let message = `Successfully installed "${name}" to ${locationDescription}`;
if (installDeps === 'on' && installResult.dependencies) {
const depCount = installResult.dependencies.length;
if (depCount > 0) {
message += ` with ${depCount} dependenc${depCount === 1 ? 'y' : 'ies'}`;
}
}
res.json({
success: true,
message: message,
package: {
author: author,
name: name,
location: locationDescription
},
installResult: installResult
});
} catch (error) {
console.error('Error installing package from URL:', error);
res.status(500).json({
success: false,
error: 'Installation failed: ' + error.message
});
}
});
// API endpoint for search (AJAX)
router.get('/api/search', async (req, res) => {
try {
const {
q = '',
type = '',
sort = 'score',
order = 'desc',
limit = '10'
} = req.query;
const packages = await contentdb.searchPackages(q, type, sort, order, parseInt(limit), 0);
res.json({
packages: packages || [],
query: q,
type: type
});
} catch (error) {
console.error('Error searching ContentDB:', error);
res.status(500).json({ error: error.message });
}
});
module.exports = router;

318
routes/mods.js Normal file
View File

@@ -0,0 +1,318 @@
const express = require('express');
const fs = require('fs').promises;
const path = require('path');
const paths = require('../utils/paths');
const ConfigParser = require('../utils/config-parser');
const router = express.Router();
// Mods listing page
router.get('/', async (req, res) => {
try {
paths.ensureDirectories();
let globalMods = [];
let worlds = [];
// Get global mods
try {
const modDirs = await fs.readdir(paths.modsDir);
for (const modDir of modDirs) {
try {
const modPath = paths.getModPath(modDir);
const configPath = paths.getModConfigPath(modDir);
const stats = await fs.stat(modPath);
if (!stats.isDirectory()) continue;
const config = await ConfigParser.parseModConfig(configPath);
globalMods.push({
name: modDir,
title: config.title || modDir,
description: config.description || '',
author: config.author || '',
depends: config.depends || [],
optional_depends: config.optional_depends || [],
min_minetest_version: config.min_minetest_version || '',
max_minetest_version: config.max_minetest_version || '',
location: 'global',
path: modPath,
lastModified: stats.mtime
});
} catch (modError) {
console.error(`Error reading mod ${modDir}:`, modError);
}
}
} catch (dirError) {}
// Get worlds for dropdown
try {
const worldDirs = await fs.readdir(paths.worldsDir);
for (const worldDir of worldDirs) {
try {
const worldPath = paths.getWorldPath(worldDir);
const configPath = paths.getWorldConfigPath(worldDir);
const stats = await fs.stat(worldPath);
if (stats.isDirectory()) {
const config = await ConfigParser.parseWorldConfig(configPath);
worlds.push({
name: worldDir,
displayName: config.server_name || worldDir
});
}
} catch {}
}
} catch {}
const selectedWorld = req.query.world;
let worldMods = [];
if (selectedWorld && paths.isValidWorldName(selectedWorld)) {
try {
const worldModsPath = paths.getWorldModsPath(selectedWorld);
const modDirs = await fs.readdir(worldModsPath);
for (const modDir of modDirs) {
try {
const modPath = path.join(worldModsPath, modDir);
const configPath = path.join(modPath, 'mod.conf');
const stats = await fs.stat(modPath);
if (!stats.isDirectory()) continue;
const config = await ConfigParser.parseModConfig(configPath);
worldMods.push({
name: modDir,
title: config.title || modDir,
description: config.description || '',
author: config.author || '',
depends: config.depends || [],
optional_depends: config.optional_depends || [],
location: 'world',
enabled: true,
path: modPath,
lastModified: stats.mtime
});
} catch (modError) {
console.error(`Error reading world mod ${modDir}:`, modError);
}
}
} catch (dirError) {}
}
res.render('mods/index', {
title: 'Mod Management',
globalMods: globalMods,
worldMods: worldMods,
worlds: worlds,
selectedWorld: selectedWorld,
currentPage: 'mods'
});
} catch (error) {
console.error('Error getting mods:', error);
res.status(500).render('error', {
error: 'Failed to load mods',
message: error.message
});
}
});
// Install mod to world
router.post('/install/:worldName/:modName', async (req, res) => {
try {
const { worldName, modName } = req.params;
if (!paths.isValidWorldName(worldName) || !paths.isValidModName(modName)) {
return res.status(400).json({ error: 'Invalid world or mod name' });
}
const worldPath = paths.getWorldPath(worldName);
const globalModPath = paths.getModPath(modName);
const worldModsPath = paths.getWorldModsPath(worldName);
const targetModPath = path.join(worldModsPath, modName);
try {
await fs.access(worldPath);
} catch {
return res.status(404).json({ error: 'World not found' });
}
try {
await fs.access(globalModPath);
} catch {
return res.status(404).json({ error: 'Mod not found' });
}
try {
await fs.access(targetModPath);
return res.status(409).json({ error: 'Mod already installed in world' });
} catch {}
await fs.mkdir(worldModsPath, { recursive: true });
await fs.cp(globalModPath, targetModPath, { recursive: true });
res.redirect(`/mods?world=${worldName}&installed=${modName}`);
} catch (error) {
console.error('Error installing mod to world:', error);
res.status(500).json({ error: 'Failed to install mod to world' });
}
});
// Remove mod from world
router.post('/remove/:worldName/:modName', async (req, res) => {
try {
const { worldName, modName } = req.params;
if (!paths.isValidWorldName(worldName) || !paths.isValidModName(modName)) {
return res.status(400).json({ error: 'Invalid world or mod name' });
}
const worldModsPath = paths.getWorldModsPath(worldName);
const modPath = path.join(worldModsPath, modName);
try {
await fs.access(modPath);
} catch {
return res.status(404).json({ error: 'Mod not found in world' });
}
await fs.rm(modPath, { recursive: true, force: true });
res.redirect(`/mods?world=${worldName}&removed=${modName}`);
} catch (error) {
console.error('Error removing mod from world:', error);
res.status(500).json({ error: 'Failed to remove mod from world' });
}
});
// Delete global mod
router.post('/delete/:modName', async (req, res) => {
try {
const { modName } = req.params;
if (!paths.isValidModName(modName)) {
return res.status(400).json({ error: 'Invalid mod name' });
}
const modPath = paths.getModPath(modName);
try {
await fs.access(modPath);
} catch {
return res.status(404).json({ error: 'Mod not found' });
}
await fs.rm(modPath, { recursive: true, force: true });
res.redirect(`/mods?deleted=${modName}`);
} catch (error) {
console.error('Error deleting mod:', error);
res.status(500).json({ error: 'Failed to delete mod' });
}
});
// Mod details page
router.get('/:modName', async (req, res) => {
try {
const { modName } = req.params;
if (!paths.isValidModName(modName)) {
return res.status(400).render('error', {
error: 'Invalid mod name'
});
}
const modPath = paths.getModPath(modName);
const configPath = paths.getModConfigPath(modName);
try {
await fs.access(modPath);
} catch {
return res.status(404).render('error', {
error: 'Mod not found'
});
}
const config = await ConfigParser.parseModConfig(configPath);
const stats = await fs.stat(modPath);
// Get mod files info
let fileCount = 0;
let totalSize = 0;
async function countFiles(dirPath) {
try {
const items = await fs.readdir(dirPath);
for (const item of items) {
const itemPath = path.join(dirPath, item);
const itemStats = await fs.stat(itemPath);
if (itemStats.isDirectory()) {
await countFiles(itemPath);
} else {
fileCount++;
totalSize += itemStats.size;
}
}
} catch {}
}
await countFiles(modPath);
// Get worlds where this mod is installed
const installedWorlds = [];
try {
const worldDirs = await fs.readdir(paths.worldsDir);
for (const worldDir of worldDirs) {
try {
const worldModPath = path.join(paths.getWorldModsPath(worldDir), modName);
await fs.access(worldModPath);
const worldConfigPath = paths.getWorldConfigPath(worldDir);
const worldConfig = await ConfigParser.parseWorldConfig(worldConfigPath);
installedWorlds.push({
name: worldDir,
displayName: worldConfig.server_name || worldDir
});
} catch {}
}
} catch {}
const modDetails = {
name: modName,
title: config.title || modName,
description: config.description || '',
author: config.author || '',
depends: config.depends || [],
optional_depends: config.optional_depends || [],
min_minetest_version: config.min_minetest_version || '',
max_minetest_version: config.max_minetest_version || '',
location: 'global',
path: modPath,
fileCount,
totalSize,
created: stats.birthtime,
lastModified: stats.mtime,
installedWorlds: installedWorlds,
config: config
};
res.render('mods/details', {
title: `Mod: ${modDetails.title}`,
mod: modDetails,
currentPage: 'mods'
});
} catch (error) {
console.error('Error getting mod details:', error);
res.status(500).render('error', {
error: 'Failed to load mod details',
message: error.message
});
}
});
module.exports = router;

473
routes/server.js Normal file
View File

@@ -0,0 +1,473 @@
const express = require('express');
const fs = require('fs').promises;
const { spawn } = require('child_process');
const chokidar = require('chokidar');
const paths = require('../utils/paths');
const ConfigParser = require('../utils/config-parser');
const router = express.Router();
// Security function to validate configuration overrides
function validateConfigOverrides(configOverrides) {
if (!configOverrides || typeof configOverrides !== 'object') {
return {};
}
const sanitized = {};
// Whitelist of allowed configuration parameters
const allowedConfigKeys = [
'port', 'bind', 'name', 'motd', 'max_users', 'password', 'default_game',
'enable_damage', 'creative_mode', 'enable_rollback_recording', 'disallow_empty_password',
'server_announce', 'serverlist_url', 'enable_pvp', 'time_speed', 'day_night_ratio',
'max_simultaneous_block_sends_per_client', 'max_block_send_distance',
'max_block_generate_distance', 'secure', 'enable_client_modding', 'csm_restriction_flags',
'csm_restriction_noderange', 'player_transfer_distance', 'max_packets_per_iteration',
'dedicated_server_step', 'ignore_world_load_errors', 'remote_media'
];
for (const [key, value] of Object.entries(configOverrides)) {
// Validate key
if (!allowedConfigKeys.includes(key) || !/^[a-z_]+$/.test(key)) {
continue; // Skip invalid keys
}
// Validate and sanitize value
let sanitizedValue = String(value).trim();
// Remove control characters
sanitizedValue = sanitizedValue.replace(/[\x00-\x1F\x7F]/g, '');
// Limit length
if (sanitizedValue.length > 200) {
continue; // Skip overly long values
}
// Type-specific validation
if (['port', 'max_users', 'time_speed', 'max_simultaneous_block_sends_per_client',
'max_block_send_distance', 'max_block_generate_distance', 'csm_restriction_noderange',
'player_transfer_distance', 'max_packets_per_iteration', 'dedicated_server_step'].includes(key)) {
const numValue = parseInt(sanitizedValue, 10);
if (!isNaN(numValue) && numValue >= 0 && numValue <= 65535) {
sanitized[key] = numValue.toString();
}
} else if (['enable_damage', 'creative_mode', 'enable_rollback_recording', 'disallow_empty_password',
'server_announce', 'enable_pvp', 'secure', 'enable_client_modding', 'ignore_world_load_errors'].includes(key)) {
if (['true', 'false'].includes(sanitizedValue.toLowerCase())) {
sanitized[key] = sanitizedValue.toLowerCase();
}
} else if (['bind', 'name', 'motd', 'password', 'default_game', 'serverlist_url'].includes(key)) {
// String values - ensure they don't contain shell metacharacters
if (!/[;&|`$(){}[\]<>\\]/.test(sanitizedValue)) {
sanitized[key] = sanitizedValue;
}
} else {
// Floating point values
const floatValue = parseFloat(sanitizedValue);
if (!isNaN(floatValue) && isFinite(floatValue)) {
sanitized[key] = floatValue.toString();
}
}
}
return sanitized;
}
// Global server state
let serverProcess = null;
let serverStatus = 'stopped';
let serverLogs = [];
let logWatcher = null;
// Server management page
router.get('/', async (req, res) => {
try {
paths.ensureDirectories();
// Get available worlds for dropdown
let worlds = [];
try {
const worldDirs = await fs.readdir(paths.worldsDir);
for (const worldDir of worldDirs) {
try {
const worldPath = paths.getWorldPath(worldDir);
const configPath = paths.getWorldConfigPath(worldDir);
const stats = await fs.stat(worldPath);
if (stats.isDirectory()) {
const config = await ConfigParser.parseWorldConfig(configPath);
worlds.push({
name: worldDir,
displayName: config.server_name || worldDir
});
}
} catch {}
}
} catch {}
// Get recent logs
let recentLogs = [];
try {
const logContent = await fs.readFile(paths.debugFile, 'utf8');
const lines = logContent.split('\n').filter(line => line.trim());
recentLogs = lines.slice(-50); // Last 50 lines
} catch {
// Debug file might not exist
}
const serverInfo = {
status: serverStatus,
pid: serverProcess ? serverProcess.pid : null,
uptime: serverProcess ? Date.now() - serverProcess.startTime : 0,
logs: [...recentLogs, ...serverLogs.map(log => log.message || log)].slice(-100)
};
res.render('server/index', {
title: 'Server Management',
server: serverInfo,
worlds: worlds,
currentPage: 'server',
scripts: ['server.js']
});
} catch (error) {
console.error('Error loading server page:', error);
res.status(500).render('error', {
error: 'Failed to load server management',
message: error.message
});
}
});
// Get server status (API)
router.get('/api/status', (req, res) => {
res.json({
status: serverStatus,
pid: serverProcess ? serverProcess.pid : null,
uptime: serverProcess ? Date.now() - serverProcess.startTime : 0
});
});
// Get server logs (API)
router.get('/api/logs', async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 100;
const offset = parseInt(req.query.offset) || 0;
let fileLogs = [];
try {
const logContent = await fs.readFile(paths.debugFile, 'utf8');
const lines = logContent.split('\n').filter(line => line.trim());
fileLogs = lines.slice(-1000);
} catch {}
const allLogs = [...fileLogs, ...serverLogs.map(log => log.message || log)];
const paginatedLogs = allLogs.slice(offset, offset + limit);
res.json({
logs: paginatedLogs,
total: allLogs.length,
offset,
limit
});
} catch (error) {
console.error('Error getting logs:', error);
res.status(500).json({ error: 'Failed to get logs' });
}
});
// Start server
router.post('/start', async (req, res) => {
try {
if (serverProcess && serverStatus === 'running') {
return res.status(409).json({ error: 'Server is already running' });
}
const { worldName, configOverrides } = req.body;
if (!worldName || !paths.isValidWorldName(worldName)) {
if (req.headers.accept && req.headers.accept.includes('application/json')) {
return res.status(400).json({ error: 'Valid world name required' });
} else {
return res.redirect('/server?error=Valid+world+name+required');
}
}
const worldPath = paths.getWorldPath(worldName);
try {
await fs.access(worldPath);
} catch {
if (req.headers.accept && req.headers.accept.includes('application/json')) {
return res.status(404).json({ error: 'World not found' });
} else {
return res.redirect('/server?error=World+not+found');
}
}
const args = [
'--server',
'--world', worldPath,
'--logfile', paths.debugFile
];
if (configOverrides) {
const sanitizedOverrides = validateConfigOverrides(configOverrides);
for (const [key, value] of Object.entries(sanitizedOverrides)) {
args.push(`--${key}`, value);
}
}
serverProcess = spawn('luanti', args, {
stdio: ['pipe', 'pipe', 'pipe'],
detached: false
});
serverProcess.startTime = Date.now();
serverStatus = 'starting';
serverLogs = [];
// Get Socket.IO instance from main app
const { io } = require('../app');
serverProcess.stdout.on('data', (data) => {
const logLine = data.toString().trim();
if (logLine) {
const logEntry = {
timestamp: new Date().toISOString(),
level: 'info',
message: logLine
};
serverLogs.push(logEntry);
if (serverLogs.length > 1000) {
serverLogs = serverLogs.slice(-1000);
}
if (io) {
io.emit('serverLog', logEntry);
}
}
});
serverProcess.stderr.on('data', (data) => {
const logLine = data.toString().trim();
if (logLine) {
const logEntry = {
timestamp: new Date().toISOString(),
level: 'error',
message: logLine
};
serverLogs.push(logEntry);
if (serverLogs.length > 1000) {
serverLogs = serverLogs.slice(-1000);
}
if (io) {
io.emit('serverLog', logEntry);
}
}
});
serverProcess.on('spawn', () => {
serverStatus = 'running';
console.log('Luanti server started');
if (io) {
io.emit('serverStatus', {
status: serverStatus,
pid: serverProcess.pid,
uptime: Date.now() - serverProcess.startTime
});
}
});
serverProcess.on('error', (error) => {
console.error('Server error:', error);
serverStatus = 'error';
const logEntry = {
timestamp: new Date().toISOString(),
level: 'error',
message: `Server error: ${error.message}`
};
serverLogs.push(logEntry);
if (io) {
io.emit('serverLog', logEntry);
io.emit('serverStatus', {
status: serverStatus,
pid: null,
uptime: 0
});
}
});
serverProcess.on('exit', (code, signal) => {
console.log(`Server exited with code ${code}, signal ${signal}`);
serverStatus = 'stopped';
const logEntry = {
timestamp: new Date().toISOString(),
level: 'info',
message: `Server stopped (code: ${code}, signal: ${signal})`
};
serverLogs.push(logEntry);
serverProcess = null;
if (io) {
io.emit('serverLog', logEntry);
io.emit('serverStatus', {
status: serverStatus,
pid: null,
uptime: 0
});
}
});
// Watch debug log file
if (logWatcher) {
logWatcher.close();
}
logWatcher = chokidar.watch(paths.debugFile, { persistent: true });
logWatcher.on('change', async () => {
try {
const logContent = await fs.readFile(paths.debugFile, 'utf8');
const lines = logContent.split('\n');
const newLines = lines.slice(-10);
for (const line of newLines) {
if (line.trim() && !serverLogs.some(log => (log.message || log) === line.trim())) {
const logEntry = {
timestamp: new Date().toISOString(),
level: 'info',
message: line.trim()
};
serverLogs.push(logEntry);
if (io) {
io.emit('serverLog', logEntry);
}
}
}
if (serverLogs.length > 1000) {
serverLogs = serverLogs.slice(-1000);
}
} catch {}
});
if (req.headers.accept && req.headers.accept.includes('application/json')) {
res.json({ message: 'Server starting', pid: serverProcess.pid });
} else {
res.redirect('/server?started=true');
}
} catch (error) {
console.error('Error starting server:', error);
if (req.headers.accept && req.headers.accept.includes('application/json')) {
res.status(500).json({ error: 'Failed to start server' });
} else {
res.redirect(`/server?error=${encodeURIComponent(error.message)}`);
}
}
});
// Stop server
router.post('/stop', (req, res) => {
try {
if (!serverProcess || serverStatus !== 'running') {
if (req.headers.accept && req.headers.accept.includes('application/json')) {
return res.status(409).json({ error: 'Server is not running' });
} else {
return res.redirect('/server?error=Server+is+not+running');
}
}
serverStatus = 'stopping';
serverProcess.kill('SIGTERM');
setTimeout(() => {
if (serverProcess && serverStatus === 'stopping') {
serverProcess.kill('SIGKILL');
}
}, 10000);
if (logWatcher) {
logWatcher.close();
logWatcher = null;
}
const { io } = require('../app');
if (io) {
io.emit('serverStatus', {
status: serverStatus,
pid: serverProcess ? serverProcess.pid : null,
uptime: serverProcess ? Date.now() - serverProcess.startTime : 0
});
}
if (req.headers.accept && req.headers.accept.includes('application/json')) {
res.json({ message: 'Server stopping' });
} else {
res.redirect('/server?stopped=true');
}
} catch (error) {
console.error('Error stopping server:', error);
if (req.headers.accept && req.headers.accept.includes('application/json')) {
res.status(500).json({ error: 'Failed to stop server' });
} else {
res.redirect(`/server?error=${encodeURIComponent(error.message)}`);
}
}
});
// Send command to server
router.post('/command', (req, res) => {
try {
if (!serverProcess || serverStatus !== 'running') {
return res.status(409).json({ error: 'Server is not running' });
}
const { command } = req.body;
if (!command) {
return res.status(400).json({ error: 'Command required' });
}
// Validate and sanitize the command using ServerManager's validation
const ServerManager = require('../utils/server-manager');
const serverManager = new ServerManager();
try {
const sanitizedCommand = serverManager.validateServerCommand(command);
serverProcess.stdin.write(sanitizedCommand + '\n');
const logEntry = {
timestamp: new Date().toISOString(),
level: 'command',
message: `> ${sanitizedCommand}`
};
serverLogs.push(logEntry);
const { io } = require('../app');
if (io) {
io.emit('serverLog', logEntry);
}
res.json({ message: 'Command sent successfully' });
} catch (validationError) {
return res.status(400).json({ error: validationError.message });
}
} catch (error) {
console.error('Error sending command:', error);
res.status(500).json({ error: 'Failed to send command' });
}
});
// Export server state for use in main app
router.getServerState = () => ({
process: serverProcess,
status: serverStatus,
logs: serverLogs
});
module.exports = router;

118
routes/users.js Normal file
View File

@@ -0,0 +1,118 @@
const express = require('express');
const AuthManager = require('../utils/auth');
const { requireAuth } = require('../middleware/auth');
const router = express.Router();
const authManager = new AuthManager();
// Initialize auth manager
authManager.initialize().catch(console.error);
// All routes require authentication
router.use(requireAuth);
// User management page
router.get('/', async (req, res) => {
try {
const users = await authManager.getAllUsers();
res.render('users/index', {
title: 'User Management',
users: users,
currentPage: 'users'
});
} catch (error) {
console.error('Error getting users:', error);
res.status(500).render('error', {
error: 'Failed to load users',
message: error.message
});
}
});
// Create new user page
router.get('/new', (req, res) => {
res.render('users/new', {
title: 'Create New User',
currentPage: 'users'
});
});
// Process user creation
router.post('/create', async (req, res) => {
try {
const { username, password, confirmPassword } = req.body;
const createdByUserId = req.session.user.id;
// Validate inputs
if (!username || !password || !confirmPassword) {
return res.render('users/new', {
title: 'Create New User',
error: 'All fields are required',
currentPage: 'users',
formData: { username }
});
}
if (password !== confirmPassword) {
return res.render('users/new', {
title: 'Create New User',
error: 'Passwords do not match',
currentPage: 'users',
formData: { username }
});
}
const user = await authManager.createUser(username, password, createdByUserId);
res.redirect('/users?created=' + encodeURIComponent(username));
} catch (error) {
console.error('User creation error:', error);
res.render('users/new', {
title: 'Create New User',
error: error.message,
currentPage: 'users',
formData: {
username: req.body.username
}
});
}
});
// Delete user
router.post('/delete/:userId', async (req, res) => {
try {
const { userId } = req.params;
const currentUserId = req.session.user.id;
// Prevent self-deletion
if (parseInt(userId) === currentUserId) {
return res.status(400).json({ error: 'Cannot delete your own account' });
}
const deleted = await authManager.deleteUser(userId);
if (deleted) {
if (req.headers.accept && req.headers.accept.includes('application/json')) {
res.json({ message: 'User deleted successfully' });
} else {
res.redirect('/users?deleted=true');
}
} else {
res.status(404).json({ error: 'User not found' });
}
} catch (error) {
console.error('Error deleting user:', error);
if (req.headers.accept && req.headers.accept.includes('application/json')) {
res.status(500).json({ error: 'Failed to delete user' });
} else {
res.redirect('/users?error=' + encodeURIComponent(error.message));
}
}
});
module.exports = router;

411
routes/worlds.js Normal file
View File

@@ -0,0 +1,411 @@
const express = require('express');
const fs = require('fs').promises;
const path = require('path');
const sqlite3 = require('sqlite3');
const { promisify } = require('util');
const paths = require('../utils/paths');
const ConfigParser = require('../utils/config-parser');
const router = express.Router();
// Worlds listing page
router.get('/', async (req, res) => {
try {
paths.ensureDirectories();
let worlds = [];
try {
const worldDirs = await fs.readdir(paths.worldsDir);
for (const worldDir of worldDirs) {
const worldPath = paths.getWorldPath(worldDir);
const configPath = paths.getWorldConfigPath(worldDir);
try {
const stats = await fs.stat(worldPath);
if (!stats.isDirectory()) continue;
const config = await ConfigParser.parseWorldConfig(configPath);
let playerCount = 0;
try {
const playersDbPath = path.join(worldPath, 'players.sqlite');
const db = new sqlite3.Database(playersDbPath);
const all = promisify(db.all.bind(db));
const result = await all('SELECT COUNT(*) as count FROM players');
playerCount = result[0]?.count || 0;
db.close();
} catch (dbError) {}
worlds.push({
name: worldDir,
displayName: config.server_name || worldDir,
description: config.server_description || '',
gameid: config.gameid || 'minetest_game',
creativeMode: config.creative_mode || false,
enableDamage: config.enable_damage !== false,
enablePvp: config.enable_pvp !== false,
playerCount,
lastModified: stats.mtime,
size: stats.size
});
} catch (worldError) {
console.error(`Error reading world ${worldDir}:`, worldError);
}
}
} catch (dirError) {}
res.render('worlds/index', {
title: 'Worlds',
worlds: worlds,
currentPage: 'worlds'
});
} catch (error) {
console.error('Error getting worlds:', error);
res.status(500).render('error', {
error: 'Failed to load worlds',
message: error.message
});
}
});
// New world page
router.get('/new', async (req, res) => {
try {
const games = await paths.getInstalledGames();
res.render('worlds/new', {
title: 'Create World',
currentPage: 'worlds',
games: games
});
} catch (error) {
console.error('Error getting games for new world:', error);
res.render('worlds/new', {
title: 'Create World',
currentPage: 'worlds',
games: [
{ name: 'minetest_game', title: 'Minetest Game (Default)', description: '' },
{ name: 'minimal', title: 'Minimal', description: '' }
],
error: 'Could not load installed games, showing defaults only.'
});
}
});
// Create world
router.post('/create', async (req, res) => {
console.log('=== WORLD CREATION STARTED ===');
console.log('Request body:', req.body);
try {
const { name, gameid } = req.body;
console.log('Extracted name:', name, 'gameid:', gameid);
if (!paths.isValidWorldName(name)) {
return res.status(400).render('worlds/new', {
title: 'Create World',
currentPage: 'worlds',
error: 'Invalid world name. Only letters, numbers, underscore and hyphen allowed.',
formData: req.body
});
}
const worldPath = paths.getWorldPath(name);
try {
await fs.access(worldPath);
return res.status(409).render('worlds/new', {
title: 'Create World',
currentPage: 'worlds',
error: 'World already exists',
formData: req.body
});
} catch {}
console.log('Starting world creation for:', name, 'with gameid:', gameid);
// Create the world directory - Luanti will initialize it when the server starts
await fs.mkdir(worldPath, { recursive: true });
console.log('Created world directory:', worldPath);
// Create a basic world.mt file with the correct game ID
const worldConfig = `enable_damage = true
creative_mode = false
mod_storage_backend = sqlite3
auth_backend = sqlite3
player_backend = sqlite3
backend = sqlite3
gameid = ${gameid || 'minetest_game'}
world_name = ${name}
`;
const worldConfigPath = path.join(worldPath, 'world.mt');
await fs.writeFile(worldConfigPath, worldConfig, 'utf8');
console.log('Created world.mt with gameid:', gameid || 'minetest_game');
// Create essential database files with proper schema
const sqlite3 = require('sqlite3');
// Create players database with correct schema
const playersDbPath = path.join(worldPath, 'players.sqlite');
await new Promise((resolve, reject) => {
const playersDb = new sqlite3.Database(playersDbPath, (err) => {
if (err) reject(err);
else {
playersDb.serialize(() => {
playersDb.exec(`CREATE TABLE IF NOT EXISTS player (
name TEXT PRIMARY KEY,
pitch REAL,
yaw REAL,
posX REAL,
posY REAL,
posZ REAL,
hp INTEGER,
breath INTEGER,
creation_date INTEGER,
modification_date INTEGER,
privs TEXT
)`, (err) => {
if (err) {
console.error('Error creating player table:', err);
reject(err);
} else {
console.log('Created player table in players.sqlite');
playersDb.close((closeErr) => {
if (closeErr) reject(closeErr);
else resolve();
});
}
});
});
}
});
});
// Create other essential databases
const mapDbPath = path.join(worldPath, 'map.sqlite');
await new Promise((resolve, reject) => {
const mapDb = new sqlite3.Database(mapDbPath, (err) => {
if (err) reject(err);
else {
mapDb.serialize(() => {
mapDb.exec(`CREATE TABLE IF NOT EXISTS blocks (
x INTEGER,
y INTEGER,
z INTEGER,
data BLOB NOT NULL,
PRIMARY KEY (x, z, y)
)`, (err) => {
if (err) {
console.error('Error creating blocks table:', err);
reject(err);
} else {
console.log('Created blocks table in map.sqlite');
mapDb.close((closeErr) => {
if (closeErr) reject(closeErr);
else resolve();
});
}
});
});
}
});
});
const modStorageDbPath = path.join(worldPath, 'mod_storage.sqlite');
await new Promise((resolve, reject) => {
const modDb = new sqlite3.Database(modStorageDbPath, (err) => {
if (err) reject(err);
else {
modDb.serialize(() => {
modDb.exec(`CREATE TABLE IF NOT EXISTS entries (
modname TEXT NOT NULL,
key BLOB NOT NULL,
value BLOB NOT NULL,
PRIMARY KEY (modname, key)
)`, (err) => {
if (err) {
console.error('Error creating entries table:', err);
reject(err);
} else {
console.log('Created entries table in mod_storage.sqlite');
modDb.close((closeErr) => {
if (closeErr) reject(closeErr);
else resolve();
});
}
});
});
}
});
});
console.log('Created essential database files with proper schema');
res.redirect('/worlds?created=' + encodeURIComponent(name));
} catch (error) {
console.error('Error creating world:', error);
res.status(500).render('worlds/new', {
title: 'Create World',
currentPage: 'worlds',
error: 'Failed to create world: ' + error.message,
formData: req.body
});
}
});
// World details page
router.get('/:worldName', async (req, res) => {
try {
const { worldName } = req.params;
if (!paths.isValidWorldName(worldName)) {
return res.status(400).render('error', {
error: 'Invalid world name'
});
}
const worldPath = paths.getWorldPath(worldName);
const configPath = paths.getWorldConfigPath(worldName);
try {
await fs.access(worldPath);
} catch {
return res.status(404).render('error', {
error: 'World not found'
});
}
const config = await ConfigParser.parseWorldConfig(configPath);
const stats = await fs.stat(worldPath);
let worldSize = 0;
try {
const mapDbPath = path.join(worldPath, 'map.sqlite');
const mapStats = await fs.stat(mapDbPath);
worldSize = mapStats.size;
} catch {}
let enabledMods = [];
try {
const worldModsPath = paths.getWorldModsPath(worldName);
const modDirs = await fs.readdir(worldModsPath);
for (const modDir of modDirs) {
const modConfigPath = path.join(worldModsPath, modDir, 'mod.conf');
try {
const modConfig = await ConfigParser.parseModConfig(modConfigPath);
enabledMods.push({
name: modDir,
title: modConfig.title || modDir,
description: modConfig.description || '',
author: modConfig.author || '',
location: 'world'
});
} catch {}
}
} catch {}
const worldDetails = {
name: worldName,
displayName: config.server_name || worldName,
description: config.server_description || '',
gameid: config.gameid || 'minetest_game',
creativeMode: config.creative_mode || false,
enableDamage: config.enable_damage !== false,
enablePvp: config.enable_pvp !== false,
serverAnnounce: config.server_announce || false,
worldSize,
created: stats.birthtime,
lastModified: stats.mtime,
enabledMods,
config: config
};
res.render('worlds/details', {
title: `World: ${worldDetails.displayName}`,
world: worldDetails,
currentPage: 'worlds'
});
} catch (error) {
console.error('Error getting world details:', error);
res.status(500).render('error', {
error: 'Failed to load world details',
message: error.message
});
}
});
// Update world
router.post('/:worldName/update', async (req, res) => {
try {
const { worldName } = req.params;
const updates = req.body;
if (!paths.isValidWorldName(worldName)) {
return res.status(400).json({ error: 'Invalid world name' });
}
const worldPath = paths.getWorldPath(worldName);
const configPath = paths.getWorldConfigPath(worldName);
try {
await fs.access(worldPath);
} catch {
return res.status(404).json({ error: 'World not found' });
}
const currentConfig = await ConfigParser.parseWorldConfig(configPath);
// Convert form data
const updatedConfig = {
...currentConfig,
server_name: updates.displayName || currentConfig.server_name,
server_description: updates.description || currentConfig.server_description,
creative_mode: updates.creativeMode === 'on',
enable_damage: updates.enableDamage !== 'off',
enable_pvp: updates.enablePvp !== 'off',
server_announce: updates.serverAnnounce === 'on'
};
await ConfigParser.writeWorldConfig(configPath, updatedConfig);
res.redirect(`/worlds/${worldName}?updated=true`);
} catch (error) {
console.error('Error updating world:', error);
res.status(500).json({ error: 'Failed to update world' });
}
});
// Delete world
router.post('/:worldName/delete', async (req, res) => {
try {
const { worldName } = req.params;
if (!paths.isValidWorldName(worldName)) {
return res.status(400).json({ error: 'Invalid world name' });
}
const worldPath = paths.getWorldPath(worldName);
try {
await fs.access(worldPath);
} catch {
return res.status(404).json({ error: 'World not found' });
}
// Deletion confirmed by frontend confirmation dialog
await fs.rm(worldPath, { recursive: true, force: true });
res.redirect('/worlds?deleted=' + encodeURIComponent(worldName));
} catch (error) {
console.error('Error deleting world:', error);
res.status(500).json({ error: 'Failed to delete world' });
}
});
module.exports = router;