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:
534
routes/api.js
Normal file
534
routes/api.js
Normal 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
262
routes/auth.js
Normal 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
316
routes/config.js
Normal 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
529
routes/contentdb.js
Normal 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
365
routes/extensions.js
Normal 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
318
routes/mods.js
Normal 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
473
routes/server.js
Normal 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
118
routes/users.js
Normal 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
411
routes/worlds.js
Normal 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;
|
Reference in New Issue
Block a user