Initial commit: LuHost - Luanti Server Management Web Interface

A modern web interface for Luanti (Minetest) server management with ContentDB integration.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Nathan Schneider
2025-08-23 17:32:37 -06:00
commit 3aed09b60f
47 changed files with 12878 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
# Node.js dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Database files
*.db
*.db-journal
*.db-wal
*.db-shm
*.sqlite
*.sqlite3
# Session storage
sessions.db
users.db
# Application data
data/
*.log
security.log*
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Runtime files
*.pid
server.log*
debug.txt
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
*~
# Editor files
.vscode/
.idea/
*.swp
*.swo
*~
# Temporary files
tmp/
temp/
*.tmp
# Luanti/Minetest specific files (if running locally)
worlds/
mods/
games/
textures/
sounds/
minetest.conf
debug.txt
# Backup files
*.backup
*.bak*
backups/
# Certificate files (if using HTTPS)
*.pem
*.key
*.crt
*.csr
# Lock files
package-lock.json
yarn.lock
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# nyc test coverage
.nyc_output
# ESLint cache
.eslintcache
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Security logs
security.log*
# Application logs
luhost.log*
hostblock.log*
app.log*
error.log*
access.log*
# PM2 logs
logs/
pids/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 University of Colorado Boulder. A project of the Media Enterprise Design Lab (colorado.edu/lab/medlab/).
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+356
View File
@@ -0,0 +1,356 @@
# LuHost
A modern web interface for Luanti (formerly Minetest) server management with ContentDB integration.
This is a project of the [Media Economies Design Lab](https://medlab.host) at the University of Colorado Boulder. It is part of our ongoing work on the governance of online community spaces. Built largely with Claude Code.
## Overview
LuHost provides a comprehensive web-based dashboard for managing Luanti servers. It features real-time server monitoring, world management, mod installation via ContentDB, and extensive configuration management - all through an intuitive web interface.
## Features
### 🖥️ Server Management
- **Real-time monitoring** - Live server status, player count, and performance metrics
- **External server detection** - Automatically detects and monitors external Luanti servers
- **Process control** - Start, stop, and restart your Luanti server with one click
- **Live console** - View server logs in real-time and send commands directly
- **Player management** - View online players with activity tracking and kick functionality
- **World selection** - Choose which world to run when starting the server
### 🌍 World Management
- **World browser** - View and manage all your Luanti worlds
- **Backup creation** - One-click world backups with automatic compression
- **World deletion** - Safe world removal with confirmation dialogs
- **World statistics** - View world size, modification dates, and details
### 🧩 Extensions Management
- **ContentDB integration** - Browse and install mods and games directly from ContentDB
- **Extensions browser** - View installed mods and games with descriptions and metadata
- **Dependency handling** - Automatic resolution of mod dependencies
- **Bulk operations** - Enable, disable, or remove multiple extensions at once
- **Game management** - Install and manage different Luanti games
- **Quick install** - Install popular extensions with one click
### ⚙️ Configuration Management
- **Visual config editor** - Edit minetest.conf through an intuitive interface
- **Sectioned settings** - Organized into Server, World, Performance, Security, Network, and Advanced categories
- **Real-time validation** - Input validation with helpful error messages
- **Backup system** - Automatic configuration backups before changes
- **Raw config access** - View and download the raw configuration file
### 👥 User Management
- **Multi-user support** - Create and manage multiple admin accounts
- **Session management** - Secure authentication with session-based login
- **Role-based access** - Granular permissions for different user roles
### 🔒 Security
- **Authentication required** - All management features require login
- **Rate limiting** - Built-in protection against abuse
- **Input validation** - Comprehensive validation of all user inputs
- **Secure sessions** - HTTPOnly cookies with configurable security settings
## Architecture
- **Express.js Backend** - Robust Node.js server with EJS templating
- **Real-time Features** - Socket.IO for live updates and monitoring
- **Modern UI** - Responsive design with blocky Luanti-inspired theming
- **Security** - Comprehensive input validation, authentication, and rate limiting
## Installation
### Prerequisites
- **Node.js** (v16 or higher)
- **npm** (comes with Node.js)
- **Luanti/Minetest** installed on your system
### Quick Start
1. **Clone the repository**
```bash
git clone https://github.com/your-org/luanti-webserver.git
cd luanti-webserver
```
2. **Install dependencies**
```bash
npm install
```
3. **Start the server**
```bash
npm start
```
4. **Access the web interface**
Open your browser and navigate to `http://localhost:3000`
5. **Initial setup**
- Create your admin account on first visit
- Configure your Luanti installation path if needed
- Start managing your server!
### Verification
To verify your installation is working correctly:
1. **Check server status** - Visit the dashboard and confirm server detection
2. **Test external server detection** - If you have a Luanti server running, it should appear as "Running (External - Monitor Only)"
3. **Create a test world** - Use the Worlds section to create a new world
4. **Start managed server** - Try starting a server through LuHost with full control capabilities
### Production Deployment
For production use, consider these additional steps:
1. **Set environment variables**
```bash
export NODE_ENV=production
export SESSION_SECRET=your-secure-random-secret
export PORT=3000
```
2. **Use a process manager**
```bash
npm install -g pm2
pm2 start app.js --name luhost
```
3. **Set up reverse proxy** (optional)
Configure nginx or Apache to proxy requests to LuHost
## Configuration
### Environment Variables
- `NODE_ENV` - Set to 'production' for production deployments
- `PORT` - Port number for the web server (default: 3000)
- `SESSION_SECRET` - Secret key for session encryption
- `HTTPS` - Set to 'true' if using HTTPS in production
### Directory Structure
LuHost automatically detects your Luanti installation and creates the following structure:
```
~/.minetest/ # Default Luanti directory
├── minetest.conf # Server configuration
├── worlds/ # World files
├── mods/ # Installed mods
└── games/ # Game definitions
```
## API Reference
LuHost provides a REST API for programmatic access:
### Server Management
- `GET /api/server/status` - Get server status
- `POST /api/server/start` - Start server
- `POST /api/server/stop` - Stop server
- `POST /api/server/restart` - Restart server
- `POST /api/server/command` - Send server command
### World Management
- `GET /api/worlds` - List all worlds
- `POST /api/worlds/:name/backup` - Create world backup
- `DELETE /api/worlds/:name` - Delete world
### Player Management
- `POST /api/server/command` - Send server commands (including kick players)
### Configuration
- `GET /api/config` - Get current configuration
- `POST /api/config` - Update configuration
- `POST /api/config/reset/:section` - Reset section to defaults
### ContentDB
- `GET /api/contentdb/packages` - Browse ContentDB packages
- `GET /api/contentdb/search` - Search packages
- `POST /api/contentdb/install` - Install package
## WebSocket Events
Real-time updates are provided via WebSocket:
### Server Events
- `server:status` - Server status changes with external server detection
- `server:log` - New log entries in real-time
- `server:players` - Player list updates with activity tracking
- `server:stats` - Server performance statistics
### System Events
- `configUpdate` - Configuration changes
## Troubleshooting
### Common Issues
**Server won't start**
- Check that Luanti is properly installed (`luanti --version` or `minetest --version`)
- Verify the Luanti executable is in your PATH
- Check server logs for specific error messages
- Ensure worlds exist before trying to start server
**External server not detected**
- External servers are detected automatically if running
- Only servers started independently (not through LuHost) are marked as external
- External servers have limited control - monitoring only
**Permission errors**
- Ensure LuHost has read/write access to Luanti directories
- On Linux/Mac, you may need to adjust file permissions: `chmod -R 755 ~/.minetest`
**Port conflicts**
- Default web port is 3000, game server port is 30000
- Change ports if conflicts occur with other services
- Use `PORT=3001 npm start` to run on different port
**Player kick not working**
- Kick functionality only works on servers managed by LuHost
- External servers show disabled kick buttons with explanatory tooltips
- Ensure you have proper authentication when using server commands
**WebSocket connection issues**
- Check firewall settings
- Verify that WebSocket connections aren't blocked by proxy/firewall
### Log Files
LuHost logs important events to the console. For persistent logging:
```bash
npm start > luhost.log 2>&1
```
### Support
For issues and questions:
1. Check the troubleshooting section above
2. Review server logs for error messages
3. Verify your Luanti installation is working independently
4. Check file permissions and directory access
## Development
### Project Structure
```
├── app.js # Main application entry point
├── package.json # Dependencies and scripts
├── routes/ # Express route handlers
│ ├── auth.js # Authentication routes
│ ├── api.js # API endpoints
│ ├── server.js # Server management
│ ├── config.js # Configuration management
│ ├── worlds.js # World management
│ ├── users.js # User management
│ ├── extensions.js # Extensions (mods/games) management
│ └── contentdb.js # ContentDB integration
├── views/ # EJS templates
│ ├── layout.ejs # Base template
│ ├── dashboard.ejs # Main dashboard
│ ├── auth/ # Authentication views
│ ├── server/ # Server management views
│ ├── config/ # Configuration views
│ ├── worlds/ # World management views
│ ├── users/ # User management views
│ ├── extensions/ # Extensions management views
│ └── contentdb/ # ContentDB browser views
├── utils/ # Utility modules
│ ├── server-manager.js # Server process management
│ ├── shared-server-manager.js # Shared server manager instance
│ ├── config-manager.js # Configuration handling
│ ├── config-parser.js # Configuration file parsing
│ ├── contentdb.js # ContentDB API client
│ ├── auth.js # Authentication utilities
│ ├── paths.js # Path resolution
│ └── security-logger.js # Security event logging
├── middleware/ # Express middleware
│ ├── auth.js # Authentication middleware
│ └── security.js # Security middleware
├── public/ # Static assets
│ ├── css/ # Stylesheets
│ ├── js/ # Client-side JavaScript
│ │ ├── main.js # Global JavaScript
│ │ ├── server.js # Server management page
│ │ └── shared-status.js # Shared status updates
│ └── images/ # Images and icons
└── data/ # Application data
├── sessions.db # User sessions
├── users.db # User accounts
└── packages.db # Package registry cache
```
### Development Setup
1. **Install development dependencies**
```bash
npm install
```
2. **Run in development mode**
```bash
npm run dev
```
3. **Development features**
- Automatic server restart on file changes (via nodemon)
- Detailed error logging
- Development-friendly settings
### Key Implementation Notes
**External Server Detection**
- The system automatically detects external Luanti servers via process scanning
- External servers are monitored but have limited control capabilities
- Player data is extracted from debug.txt parsing for external servers
- UI clearly distinguishes between managed and external servers
**Real-time Features**
- WebSocket integration provides live updates without page refreshes
- Server status, player lists, and logs update automatically
- Shared server manager instance ensures consistency across pages
**Security Architecture**
- Multi-layered security with authentication, CSRF protection, and rate limiting
- Input validation and XSS protection on all user inputs
- Session-based authentication with secure cookie handling
- Comprehensive security logging for audit purposes
**Player Management**
- Intelligent player detection from server logs with activity classification
- False positive filtering (excludes entities, explosions, etc.)
- Real-time player activity tracking with kick functionality for managed servers
- Player list automatically updates as players join/leave or become active/inactive
### Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Test thoroughly with both internal and external servers
5. Ensure security features remain intact
6. Submit a pull request
### AI-Enabled Development Notes
When working on this codebase with AI assistance:
1. **Server Manager** - The core logic is in `utils/server-manager.js` with external server detection
2. **Authentication** - All routes require authentication; check `middleware/auth.js` for patterns
3. **Real-time Updates** - WebSocket events are defined in `routes/api.js` and handled in client-side JS
4. **Player Detection** - Complex logic in `getExternalServerPlayerData()` method with filtering rules
5. **Security** - Multiple layers; always validate inputs and check existing patterns
6. **Database** - SQLite databases for sessions, users, and package cache
7. **File Structure** - Follow existing patterns in routes, views, and utilities
## License
This project is licensed under the MIT License - see the LICENSE file for details.
## Acknowledgments
- **Luanti Project** - For the amazing voxel game engine
- **ContentDB** - For the mod and game distribution platform
+290
View File
@@ -0,0 +1,290 @@
const express = require('express');
const http = require('http');
const socketIo = require('socket.io');
const path = require('path');
const helmet = require('helmet');
const compression = require('compression');
const rateLimit = require('express-rate-limit');
const session = require('express-session');
const SQLiteStore = require('connect-sqlite3')(session);
const csrf = require('csurf');
const { spawn } = require('child_process');
const chokidar = require('chokidar');
const os = require('os');
// Import utilities
const paths = require('./utils/paths');
const ConfigParser = require('./utils/config-parser');
const ContentDBClient = require('./utils/contentdb');
const serverManager = require('./utils/shared-server-manager');
// Import middleware
const { requireAuth, attachUser } = require('./middleware/auth');
const { validateInput, xssProtection, additionalSecurityHeaders, validateRequestSize, pathTraversalProtection } = require('./middleware/security');
// Import routes
const authRouter = require('./routes/auth');
const usersRouter = require('./routes/users');
const worldsRouter = require('./routes/worlds');
const modsRouter = require('./routes/mods');
const serverRouter = require('./routes/server');
const configRouter = require('./routes/config');
const contentdbRouter = require('./routes/contentdb');
const extensionsRouter = require('./routes/extensions');
const { router: apiRouter, setSocketIO } = require('./routes/api');
const app = express();
const server = http.createServer(app);
const io = socketIo(server);
const PORT = process.env.PORT || 3000;
// Global server state
let serverProcess = null;
let serverStatus = 'stopped';
let logWatcher = null;
// Security and performance middleware
app.use(additionalSecurityHeaders);
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], // Allow inline styles for now
scriptSrc: ["'self'", "'unsafe-inline'"], // Allow inline scripts for now
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "ws:", "wss:"],
formAction: ["'self'"],
frameAncestors: ["'none'"]
}
}
}));
app.use(compression());
app.use(validateRequestSize);
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 1000 // limit each IP to 1000 requests per windowMs (increased for testing)
});
app.use(limiter);
// Session middleware
app.use(session({
store: new SQLiteStore({
db: 'sessions.db',
dir: '.'
}),
secret: process.env.SESSION_SECRET || 'luanti-server-manager-secret-key-change-in-production',
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production' && process.env.HTTPS === 'true',
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
}));
// Body parsing
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Security middleware
app.use(pathTraversalProtection);
app.use(validateInput);
app.use(xssProtection);
// Authentication middleware
app.use(attachUser);
// CSRF protection middleware (only for non-API routes)
const csrfProtection = csrf();
app.use((req, res, next) => {
// Skip CSRF for API routes and auth endpoints during setup
if (req.path.startsWith('/api/') ||
req.path.startsWith('/health') ||
(req.path === '/login' && req.method === 'POST') ||
(req.path === '/register' && req.method === 'POST')) {
return next();
}
return csrfProtection(req, res, next);
});
// Make CSRF token and security functions available to all templates
app.use((req, res, next) => {
res.locals.csrfToken = req.csrfToken ? req.csrfToken() : null;
res.locals.escapeHtml = require('./middleware/security').escapeHtml;
next();
});
// Static files
app.use('/static', express.static(path.join(__dirname, 'public')));
// Template engine
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
// Make utility functions available to templates
app.locals.formatDate = (date) => new Date(date).toLocaleString();
app.locals.formatFileSize = (bytes) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
app.locals.formatUptime = (uptime) => {
if (!uptime) return 'N/A';
const seconds = Math.floor(uptime / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
} else if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
} else {
return `${seconds}s`;
}
};
// Initialize API with Socket.IO
setSocketIO(io);
// Socket.IO connection handling
io.on('connection', (socket) => {
console.log('Client connected:', socket.id);
// Send current server status
socket.emit('serverStatus', {
status: serverStatus,
pid: serverProcess ? serverProcess.pid : null,
uptime: serverProcess ? Date.now() - serverProcess.startTime : 0
});
socket.on('disconnect', () => {
console.log('Client disconnected:', socket.id);
});
});
// Broadcast server status updates
function broadcastServerStatus() {
io.emit('serverStatus', {
status: serverStatus,
pid: serverProcess ? serverProcess.pid : null,
uptime: serverProcess ? Date.now() - serverProcess.startTime : 0
});
}
// Broadcast log messages
function broadcastLog(logEntry) {
io.emit('serverLog', logEntry);
}
// Authentication routes (public)
app.use('/', authRouter);
// API routes (require authentication)
app.use('/api', requireAuth, apiRouter);
// Protected routes (require authentication)
app.use('/users', requireAuth, usersRouter);
app.use('/worlds', requireAuth, worldsRouter);
app.use('/mods', requireAuth, modsRouter);
app.use('/server', requireAuth, serverRouter);
app.use('/config', requireAuth, configRouter);
app.use('/contentdb', requireAuth, contentdbRouter);
app.use('/extensions', requireAuth, extensionsRouter);
// Main dashboard route (protected)
app.get('/', requireAuth, async (req, res) => {
try {
paths.ensureDirectories();
// Get basic stats for dashboard
const fs = require('fs').promises;
let worldCount = 0;
let modCount = 0;
try {
const worldDirs = await fs.readdir(paths.worldsDir);
worldCount = worldDirs.length;
} catch {}
try {
const modDirs = await fs.readdir(paths.modsDir);
modCount = modDirs.length;
} catch {}
const stats = {
worlds: worldCount,
mods: modCount,
minetestDir: paths.minetestDir
};
const systemInfo = {
platform: os.platform(),
arch: os.arch(),
nodeVersion: process.version
};
res.render('dashboard', {
title: 'LuHost Dashboard',
stats: stats,
systemInfo: systemInfo
});
} catch (error) {
console.error('Error loading dashboard:', error);
res.status(500).render('error', {
error: 'Failed to load dashboard',
message: error.message
});
}
});
// Health check endpoint
app.get('/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
minetestDir: paths.minetestDir,
serverStatus: serverStatus
});
});
// Error handling middleware
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).render('error', {
error: 'Something went wrong!',
message: err.message
});
});
// 404 handler
app.use((req, res) => {
res.status(404).render('error', {
error: 'Page not found',
message: `The page ${req.url} does not exist.`
});
});
// Server startup
server.listen(PORT, async () => {
console.log(`LuHost Server running on http://localhost:${PORT}`);
// Initialize paths with configuration
try {
await paths.initialize();
console.log(`Luanti data directory: ${paths.minetestDir}`);
} catch (error) {
console.error('Failed to initialize paths:', error);
console.log(`Using default Luanti directory: ${paths.minetestDir}`);
}
// Ensure minetest directories exist
paths.ensureDirectories();
});
// Export for potential testing
module.exports = { app, server, io };
+61
View File
@@ -0,0 +1,61 @@
// Authentication middleware
const AuthManager = require('../utils/auth');
const authManager = new AuthManager();
// Initialize auth manager
authManager.initialize().catch(console.error);
async function requireAuth(req, res, next) {
if (req.session && req.session.user) {
// User is authenticated
return next();
} else {
// User is not authenticated - check if this is first user setup
try {
const isFirstUser = await authManager.isFirstUser();
if (isFirstUser) {
// No users exist yet - redirect to registration
if (req.headers.accept && req.headers.accept.includes('application/json')) {
return res.status(401).json({ error: 'No users configured. Please complete setup.' });
} else {
return res.redirect('/register');
}
} else {
// Users exist but this person isn't authenticated
if (req.headers.accept && req.headers.accept.includes('application/json')) {
return res.status(401).json({ error: 'Authentication required' });
} else {
return res.redirect('/login?redirect=' + encodeURIComponent(req.originalUrl));
}
}
} catch (error) {
console.error('Error checking first user in auth middleware:', error);
// Fallback to login on error
return res.redirect('/login?redirect=' + encodeURIComponent(req.originalUrl));
}
}
}
function redirectIfAuthenticated(req, res, next) {
if (req.session && req.session.user) {
// User is already authenticated, redirect to dashboard
return res.redirect('/');
} else {
// User is not authenticated, continue to login/register
return next();
}
}
function attachUser(req, res, next) {
// Make user available to templates
res.locals.user = req.session ? req.session.user : null;
res.locals.isAuthenticated = !!(req.session && req.session.user);
next();
}
module.exports = {
requireAuth,
redirectIfAuthenticated,
attachUser
};
+185
View File
@@ -0,0 +1,185 @@
// Security middleware for input validation and CSRF protection
/**
* Input validation middleware
* Validates common input patterns and sanitizes data
*/
function validateInput(req, res, next) {
// Sanitize query parameters
for (const key in req.query) {
if (typeof req.query[key] === 'string') {
// Remove control characters
req.query[key] = req.query[key].replace(/[\x00-\x1F\x7F]/g, '');
// Limit length
if (req.query[key].length > 1000) {
req.query[key] = req.query[key].substring(0, 1000);
}
}
}
// Sanitize body data for non-JSON requests
if (req.body && typeof req.body === 'object' && !Array.isArray(req.body)) {
for (const key in req.body) {
if (typeof req.body[key] === 'string') {
// Remove control characters but preserve newlines for textareas
if (key.includes('description') || key.includes('content') || key.includes('motd')) {
req.body[key] = req.body[key].replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
} else {
req.body[key] = req.body[key].replace(/[\x00-\x1F\x7F]/g, '');
}
// Limit length based on field type
const maxLength = getMaxLengthForField(key);
if (req.body[key].length > maxLength) {
req.body[key] = req.body[key].substring(0, maxLength);
}
}
}
}
next();
}
/**
* Get maximum allowed length for different field types
*/
function getMaxLengthForField(fieldName) {
const fieldLimits = {
// User authentication fields
'username': 50,
'password': 200,
'confirmPassword': 200,
'currentPassword': 200,
'newPassword': 200,
// Server/world names
'name': 100,
'worldName': 100,
'serverName': 200,
// Text content
'description': 2000,
'motd': 500,
'content': 5000,
// Commands and paths
'command': 500,
'path': 500,
// Network settings
'bind': 100,
'serverlist_url': 500,
// Default
'default': 200
};
return fieldLimits[fieldName] || fieldLimits['default'];
}
/**
* XSS protection middleware
* Escapes HTML in user input for specific fields
*/
function xssProtection(req, res, next) {
if (req.body && typeof req.body === 'object') {
// Fields that should be HTML escaped
const fieldsToEscape = ['username', 'name', 'worldName', 'serverName', 'motd'];
for (const field of fieldsToEscape) {
if (req.body[field] && typeof req.body[field] === 'string') {
req.body[field] = escapeHtml(req.body[field]);
}
}
}
next();
}
/**
* Basic HTML escape function
*/
function escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
/**
* Security headers middleware (additional to helmet)
*/
function additionalSecurityHeaders(req, res, next) {
// Prevent MIME type sniffing
res.setHeader('X-Content-Type-Options', 'nosniff');
// Prevent clickjacking
res.setHeader('X-Frame-Options', 'DENY');
// XSS protection
res.setHeader('X-XSS-Protection', '1; mode=block');
// Referrer policy
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
next();
}
/**
* Request size validation
*/
function validateRequestSize(req, res, next) {
// Check for unusually large requests that might indicate an attack
const contentLength = req.headers['content-length'];
if (contentLength && parseInt(contentLength) > 50 * 1024 * 1024) { // 50MB limit
return res.status(413).json({ error: 'Request too large' });
}
next();
}
/**
* Path traversal protection
*/
function pathTraversalProtection(req, res, next) {
// Check for path traversal attempts in various parameters
const suspiciousPatterns = ['../', '..\\', '%2e%2e%2f', '%2e%2e%5c'];
function checkForTraversal(obj, path = '') {
for (const [key, value] of Object.entries(obj)) {
if (typeof value === 'string') {
const lowerValue = value.toLowerCase();
for (const pattern of suspiciousPatterns) {
if (lowerValue.includes(pattern)) {
console.warn(`Path traversal attempt detected: ${path}${key} = ${value}`);
return true;
}
}
} else if (typeof value === 'object' && value !== null) {
if (checkForTraversal(value, `${path}${key}.`)) {
return true;
}
}
}
return false;
}
if ((req.query && checkForTraversal(req.query)) ||
(req.body && checkForTraversal(req.body))) {
return res.status(400).json({ error: 'Invalid request parameters' });
}
next();
}
module.exports = {
validateInput,
xssProtection,
additionalSecurityHeaders,
validateRequestSize,
pathTraversalProtection,
escapeHtml
};
+44
View File
@@ -0,0 +1,44 @@
{
"name": "luhost",
"version": "1.0.0",
"description": "LuHost - A modern web interface for Luanti (Minetest) server management with ContentDB integration",
"main": "app.js",
"scripts": {
"start": "node app.js",
"dev": "nodemon app.js"
},
"keywords": [
"luhost",
"luanti",
"minetest",
"server",
"management",
"web",
"contentdb",
"admin"
],
"author": "",
"license": "MIT",
"dependencies": {
"archiver": "^6.0.1",
"axios": "^1.6.2",
"bcrypt": "^5.1.1",
"chokidar": "^3.5.3",
"compression": "^1.7.4",
"connect-sqlite3": "^0.9.13",
"cors": "^2.8.5",
"csurf": "^1.11.0",
"ejs": "^3.1.9",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"express-session": "^1.17.3",
"helmet": "^7.1.0",
"multer": "^1.4.5-lts.1",
"socket.io": "^4.7.4",
"sqlite3": "^5.1.6",
"yauzl": "^2.10.0"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
}
+847
View File
@@ -0,0 +1,847 @@
/* Luanti/Minecraft-Inspired Blocky Design */
/* Using only local resources - no external fonts or CDNs */
/* Modern CSS Reset */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* CSS Variables for blocky Luanti theme */
:root {
/* Minecraft/Luanti color palette */
--stone-color: #7F7F7F;
--cobblestone-color: #5A5A5A;
--dirt-color: #8B5A2B;
--grass-color: #79C05A;
--grass-dark: #5A8F40;
--wood-color: #C4965C;
--wood-dark: #8B6A3C;
--diamond-color: #64FFDA;
--emerald-color: #00C851;
--redstone-color: #FF0000;
--lapis-color: #1976D2;
--iron-color: #C0C0C0;
--gold-color: #FFD700;
--coal-color: #2C2C2C;
--water-color: #4FC3F7;
/* Theme colors using Luanti palette */
--primary-color: var(--lapis-color);
--primary-hover: #1565C0;
--success-color: var(--emerald-color);
--success-hover: #00A142;
--danger-color: var(--redstone-color);
--danger-hover: #CC0000;
--warning-color: var(--gold-color);
--warning-hover: #E6C200;
--secondary-color: var(--stone-color);
--secondary-hover: var(--cobblestone-color);
/* Blocky backgrounds */
--bg-primary: #F5F5F5;
--bg-secondary: #E8E8E8;
--bg-accent: #DCDCDC;
--bg-dark: var(--coal-color);
--text-primary: #2C2C2C;
--text-secondary: #5A5A5A;
--text-light: #F5F5F5;
--border-color: #A0A0A0;
--border-dark: #5A5A5A;
/* Blocky shadows - more pronounced */
--shadow-block: 4px 4px 0px rgba(0, 0, 0, 0.3);
--shadow-block-hover: 2px 2px 0px rgba(0, 0, 0, 0.3);
--shadow-inset: inset 2px 2px 4px rgba(0, 0, 0, 0.2);
/* Sharp, blocky borders */
--border-width: 3px;
--radius-none: 0;
--radius-small: 2px;
}
/* Base styles with blocky font stack */
body {
font-family: 'Courier New', 'Monaco', 'Consolas', monospace;
font-weight: bold;
background: var(--bg-secondary);
color: var(--text-primary);
line-height: 1.4;
/* Create a subtle texture pattern */
background-image:
linear-gradient(45deg, rgba(0,0,0,0.02) 25%, transparent 25%),
linear-gradient(-45deg, rgba(0,0,0,0.02) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgba(0,0,0,0.02) 75%),
linear-gradient(-45deg, transparent 75%, rgba(0,0,0,0.02) 75%);
background-size: 16px 16px;
background-position: 0 0, 0 8px, 8px -8px, -8px 0px;
}
/* Container and layout */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 16px;
}
/* Blocky Header */
.header {
background: linear-gradient(135deg, var(--grass-color) 0%, var(--grass-dark) 100%);
border: var(--border-width) solid var(--border-dark);
border-bottom: 6px solid var(--dirt-color);
margin-bottom: 24px;
padding: 24px;
text-align: center;
box-shadow: var(--shadow-block);
position: relative;
}
.header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 8px;
background: linear-gradient(90deg,
var(--grass-color) 0%,
var(--grass-dark) 25%,
var(--grass-color) 50%,
var(--grass-dark) 75%,
var(--grass-color) 100%);
}
.header h1 {
font-size: 2.8rem;
font-weight: 900;
color: var(--text-light);
margin-bottom: 8px;
text-shadow: 3px 3px 0px rgba(0, 0, 0, 0.5);
letter-spacing: 2px;
text-transform: uppercase;
}
.header p {
color: var(--text-light);
font-size: 1.1rem;
font-weight: bold;
text-shadow: 2px 2px 0px rgba(0, 0, 0, 0.3);
}
/* Blocky Navigation */
.nav {
display: flex;
background: var(--stone-color);
border: var(--border-width) solid var(--border-dark);
border-bottom: 6px solid var(--cobblestone-color);
margin-bottom: 24px;
overflow: hidden;
flex-wrap: wrap;
box-shadow: var(--shadow-block);
}
.nav-item {
flex: 1;
min-width: 140px;
}
.nav-link {
display: block;
padding: 16px 20px;
text-decoration: none;
color: var(--text-light);
font-weight: bold;
text-align: center;
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.1s ease;
border-right: var(--border-width) solid var(--border-dark);
background: linear-gradient(180deg,
rgba(255, 255, 255, 0.1) 0%,
rgba(0, 0, 0, 0.1) 100%);
}
.nav-link:last-child {
border-right: none;
}
.nav-link:hover {
background: var(--cobblestone-color);
transform: translateY(2px);
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2);
}
.nav-link.active {
background: var(--primary-color);
color: var(--text-light);
box-shadow: inset 0 4px 8px rgba(0, 0, 0, 0.3);
transform: translateY(2px);
}
/* Blocky Cards */
.card {
background: var(--bg-primary);
border: var(--border-width) solid var(--border-color);
border-bottom: 6px solid var(--border-dark);
padding: 20px;
margin-bottom: 24px;
box-shadow: var(--shadow-block);
position: relative;
}
.card::before {
content: '';
position: absolute;
top: -3px;
left: -3px;
right: -3px;
height: 6px;
background: linear-gradient(90deg,
rgba(255, 255, 255, 0.3) 0%,
rgba(255, 255, 255, 0.1) 50%,
rgba(255, 255, 255, 0.3) 100%);
}
.card h2, .card h3, .card h4 {
margin-bottom: 16px;
color: var(--text-primary);
text-transform: uppercase;
letter-spacing: 1px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 3px solid var(--border-color);
}
.card-body {
padding: 0;
}
.card-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
/* Blocky Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 12px 20px;
border: var(--border-width) solid;
font-size: 0.9rem;
font-weight: bold;
text-decoration: none;
cursor: pointer;
transition: all 0.1s ease;
gap: 8px;
min-height: 48px;
text-transform: uppercase;
letter-spacing: 1px;
font-family: inherit;
box-shadow: var(--shadow-block);
position: relative;
}
.btn:hover {
transform: translateY(2px);
box-shadow: var(--shadow-block-hover);
}
.btn:active {
transform: translateY(4px);
box-shadow: none;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btn-primary {
background: var(--primary-color);
border-color: var(--primary-hover);
color: var(--text-light);
}
.btn-primary::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 30%;
background: linear-gradient(180deg,
rgba(255, 255, 255, 0.3) 0%,
rgba(255, 255, 255, 0) 100%);
}
.btn-success {
background: var(--success-color);
border-color: var(--success-hover);
color: var(--text-light);
}
.btn-danger {
background: var(--danger-color);
border-color: var(--danger-hover);
color: var(--text-light);
}
.btn-warning {
background: var(--warning-color);
border-color: var(--warning-hover);
color: var(--text-primary);
}
.btn-secondary {
background: var(--secondary-color);
border-color: var(--secondary-hover);
color: var(--text-light);
}
.btn-outline-primary {
background: var(--bg-primary);
border-color: var(--primary-color);
color: var(--primary-color);
}
.btn-outline-secondary {
background: var(--bg-primary);
border-color: var(--secondary-color);
color: var(--secondary-color);
}
.btn-sm {
padding: 8px 16px;
font-size: 0.8rem;
min-height: 36px;
}
.btn-lg {
padding: 16px 24px;
font-size: 1.1rem;
min-height: 60px;
}
.btn-block {
width: 100%;
}
.btn-group {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
/* Blocky Forms */
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: bold;
color: var(--text-primary);
text-transform: uppercase;
letter-spacing: 1px;
font-size: 0.9rem;
}
.form-control {
width: 100%;
padding: 12px 16px;
border: var(--border-width) solid var(--border-color);
border-bottom: 4px solid var(--border-dark);
font-size: 0.9rem;
font-family: inherit;
font-weight: bold;
background: var(--bg-primary);
color: var(--text-primary);
transition: all 0.1s ease;
box-shadow: var(--shadow-inset);
}
.form-control:focus {
outline: none;
border-color: var(--primary-color);
border-bottom-color: var(--primary-hover);
box-shadow: 0 0 0 4px rgba(25, 118, 210, 0.2);
}
.form-control:disabled {
background-color: var(--bg-accent);
cursor: not-allowed;
}
/* Blocky Tables */
.table-container {
overflow-x: auto;
border: var(--border-width) solid var(--border-color);
border-bottom: 6px solid var(--border-dark);
box-shadow: var(--shadow-block);
}
.table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
background: var(--bg-primary);
font-family: inherit;
font-weight: bold;
}
.table th,
.table td {
padding: 12px 16px;
text-align: left;
border-bottom: 2px solid var(--border-color);
border-right: 2px solid var(--border-color);
}
.table th:last-child,
.table td:last-child {
border-right: none;
}
.table th {
background: var(--stone-color);
color: var(--text-light);
font-weight: 900;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 1px;
text-shadow: 1px 1px 0px rgba(0, 0, 0, 0.3);
}
.table tbody tr:hover {
background-color: var(--bg-accent);
}
.table tbody tr:last-child td {
border-bottom: none;
}
/* Blocky Status Badges */
.status {
display: inline-flex;
align-items: center;
padding: 6px 12px;
border: 2px solid;
font-size: 0.8rem;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 1px;
font-family: inherit;
box-shadow: 2px 2px 0px rgba(0, 0, 0, 0.3);
}
.status-running {
background: var(--success-color);
border-color: var(--success-hover);
color: var(--text-light);
}
.status-stopped {
background: var(--danger-color);
border-color: var(--danger-hover);
color: var(--text-light);
}
.status-starting {
background: var(--warning-color);
border-color: var(--warning-hover);
color: var(--text-primary);
}
.status-error {
background: var(--danger-color);
border-color: var(--danger-hover);
color: var(--text-light);
}
/* Blocky Logs - Terminal style */
.logs, .log-container {
background: var(--bg-dark);
color: var(--emerald-color);
padding: 16px;
border: var(--border-width) solid var(--cobblestone-color);
border-bottom: 6px solid var(--coal-color);
font-family: 'Courier New', monospace;
font-size: 0.85rem;
font-weight: bold;
line-height: 1.6;
height: 400px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
box-shadow: var(--shadow-inset);
}
.log-line {
margin-bottom: 4px;
padding: 2px 0;
}
.log-line.text-muted {
color: var(--stone-color) !important;
}
/* Scrollbar styling for logs */
.logs::-webkit-scrollbar,
.log-container::-webkit-scrollbar {
width: 12px;
}
.logs::-webkit-scrollbar-track,
.log-container::-webkit-scrollbar-track {
background: var(--cobblestone-color);
border: 2px solid var(--coal-color);
}
.logs::-webkit-scrollbar-thumb,
.log-container::-webkit-scrollbar-thumb {
background: var(--stone-color);
border: 2px solid var(--border-dark);
}
.logs::-webkit-scrollbar-thumb:hover,
.log-container::-webkit-scrollbar-thumb:hover {
background: var(--iron-color);
}
/* Stats Cards - Blocky style */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: var(--wood-color);
border: var(--border-width) solid var(--wood-dark);
border-bottom: 6px solid var(--dirt-color);
padding: 20px;
text-align: center;
box-shadow: var(--shadow-block);
position: relative;
}
.stat-card::before {
content: '';
position: absolute;
top: -3px;
left: -3px;
right: -3px;
height: 4px;
background: linear-gradient(90deg,
rgba(255, 255, 255, 0.3) 0%,
rgba(255, 255, 255, 0.1) 50%,
rgba(255, 255, 255, 0.3) 100%);
}
.stat-value {
font-size: 2.4rem;
font-weight: 900;
color: var(--text-light);
margin-bottom: 8px;
text-shadow: 2px 2px 0px rgba(0, 0, 0, 0.5);
}
.stat-label {
color: var(--text-light);
font-size: 0.9rem;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 1px;
text-shadow: 1px 1px 0px rgba(0, 0, 0, 0.3);
}
/* Blocky Alerts */
.alert {
padding: 16px 20px;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 12px;
border: var(--border-width) solid;
border-bottom-width: 6px;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 1px;
font-size: 0.9rem;
box-shadow: var(--shadow-block);
}
.alert-success {
background: var(--success-color);
border-color: var(--success-hover);
color: var(--text-light);
}
.alert-danger {
background: var(--danger-color);
border-color: var(--danger-hover);
color: var(--text-light);
}
.alert-warning {
background: var(--warning-color);
border-color: var(--warning-hover);
color: var(--text-primary);
}
.alert-info {
background: var(--water-color);
border-color: var(--primary-color);
color: var(--text-light);
}
/* Grid layouts */
.grid {
display: grid;
gap: 16px;
}
.grid-2 {
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
.grid-3 {
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
.grid-4 {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.row {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.col, .col-md-4, .col-md-6, .col-md-8 {
flex: 1;
min-width: 250px;
}
/* Loading states */
.loading {
text-align: center;
padding: 48px;
color: var(--text-secondary);
}
.spinner {
display: inline-block;
width: 32px;
height: 32px;
border: 4px solid var(--border-color);
border-top-color: var(--primary-color);
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Page header */
.page-header {
margin-bottom: 32px;
}
.page-header h2 {
font-size: 2.2rem;
font-weight: 900;
color: var(--text-primary);
text-transform: uppercase;
letter-spacing: 2px;
margin-bottom: 8px;
}
.page-header p {
color: var(--text-secondary);
font-size: 1.1rem;
font-weight: bold;
}
/* Responsive design */
@media (max-width: 1024px) and (min-width: 769px) {
.nav-item {
min-width: 160px;
}
.nav-link {
padding: 12px 16px;
font-size: 0.85rem;
}
}
@media (max-width: 950px) {
.nav {
flex-direction: column;
}
.nav-item {
min-width: auto;
}
.nav-link {
border-right: none;
border-bottom: 2px solid var(--border-dark);
}
.nav-link:last-child {
border-bottom: none;
}
}
@media (max-width: 768px) {
.container {
padding: 12px;
}
.header h1 {
font-size: 2rem;
}
.card-header {
flex-direction: column;
gap: 16px;
align-items: stretch;
}
.btn-group {
flex-direction: column;
}
.row {
flex-direction: column;
}
.col, .col-md-4, .col-md-6, .col-md-8 {
min-width: auto;
}
}
/* Accessibility */
.btn:focus,
.form-control:focus,
.nav-link:focus {
outline: 4px solid var(--primary-color);
outline-offset: 2px;
}
/* Print styles */
@media print {
.nav,
.btn,
.modal {
display: none !important;
}
.card {
break-inside: avoid;
box-shadow: none;
border: 1px solid #000;
}
}
/* Additional blocky elements */
.checkbox-wrapper {
display: flex;
align-items: center;
gap: 12px;
}
.checkbox-wrapper input[type="checkbox"] {
width: 20px;
height: 20px;
border: 3px solid var(--border-color);
background: var(--bg-primary);
}
.text-muted {
color: var(--text-secondary) !important;
}
.text-center {
text-align: center;
}
/* Make everything more blocky and pronounced */
input[type="checkbox"],
input[type="radio"] {
appearance: none;
width: 20px;
height: 20px;
border: 3px solid var(--border-color);
background: var(--bg-primary);
position: relative;
cursor: pointer;
}
input[type="checkbox"]:checked::before,
input[type="radio"]:checked::before {
content: '✓';
position: absolute;
top: -2px;
left: 2px;
color: var(--success-color);
font-weight: 900;
font-size: 14px;
}
/* Navbar pills for contentdb */
.nav-pills .nav-link {
background: var(--secondary-color);
border: 3px solid var(--secondary-hover);
border-radius: 0;
margin-right: 8px;
margin-bottom: 8px;
}
.nav-pills .nav-link.active {
background: var(--primary-color);
border-color: var(--primary-hover);
}
/* Modal styling */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
padding: 16px;
}
.modal-content {
background: var(--bg-primary);
border: var(--border-width) solid var(--border-color);
border-bottom: 6px solid var(--border-dark);
padding: 32px;
width: 100%;
max-width: 600px;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow-block);
}
+496
View File
@@ -0,0 +1,496 @@
// Main JavaScript for Luanti Web Server
class LuantiWebServer {
constructor() {
this.socket = null;
this.serverStatus = 'stopped';
this.init();
}
init() {
// Initialize Socket.IO
this.initSocket();
// Initialize UI components
this.initUI();
// Initialize forms
this.initForms();
// Initialize real-time updates
this.initRealTime();
}
initSocket() {
this.socket = io();
this.socket.on('connect', () => {
console.log('Connected to server');
this.updateConnectionStatus(true);
});
this.socket.on('disconnect', () => {
console.log('Disconnected from server');
this.updateConnectionStatus(false);
});
this.socket.on('serverStatus', (status) => {
this.updateServerStatus(status);
});
this.socket.on('serverLog', (logEntry) => {
this.appendLogEntry(logEntry);
});
}
initUI() {
// Modal functionality
this.initModals();
// Tooltips
this.initTooltips();
// Auto-refresh toggles
this.initAutoRefresh();
}
initModals() {
// Generic modal handling
document.addEventListener('click', (e) => {
if (e.target.matches('[data-modal-open]')) {
const modalId = e.target.getAttribute('data-modal-open');
this.openModal(modalId);
}
if (e.target.matches('[data-modal-close]') || e.target.closest('[data-modal-close]')) {
this.closeModal();
}
if (e.target.matches('.modal')) {
this.closeModal();
}
});
// Escape key to close modal
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.closeModal();
}
});
}
openModal(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
// Focus first input in modal
const firstInput = modal.querySelector('input, textarea, select');
if (firstInput) {
setTimeout(() => firstInput.focus(), 100);
}
}
}
closeModal() {
const modals = document.querySelectorAll('.modal');
modals.forEach(modal => {
modal.style.display = 'none';
});
document.body.style.overflow = '';
}
initTooltips() {
// Simple tooltip implementation
document.querySelectorAll('[data-tooltip]').forEach(element => {
element.addEventListener('mouseenter', (e) => {
this.showTooltip(e.target, e.target.getAttribute('data-tooltip'));
});
element.addEventListener('mouseleave', () => {
this.hideTooltip();
});
});
}
showTooltip(element, text) {
const tooltip = document.createElement('div');
tooltip.className = 'tooltip';
tooltip.textContent = text;
tooltip.style.cssText = `
position: absolute;
background: #1f2937;
color: white;
padding: 0.5rem;
border-radius: 0.375rem;
font-size: 0.875rem;
z-index: 1000;
white-space: nowrap;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
`;
document.body.appendChild(tooltip);
const rect = element.getBoundingClientRect();
tooltip.style.top = rect.top - tooltip.offsetHeight - 8 + 'px';
tooltip.style.left = rect.left + (rect.width - tooltip.offsetWidth) / 2 + 'px';
}
hideTooltip() {
const tooltip = document.querySelector('.tooltip');
if (tooltip) {
tooltip.remove();
}
}
initAutoRefresh() {
const autoRefreshElements = document.querySelectorAll('[data-auto-refresh]');
autoRefreshElements.forEach(element => {
const interval = parseInt(element.getAttribute('data-auto-refresh')) || 5000;
const url = element.getAttribute('data-refresh-url') || window.location.href;
setInterval(async () => {
if (element.checked || element.getAttribute('data-auto-refresh-active') === 'true') {
await this.refreshElement(element, url);
}
}, interval);
});
}
async refreshElement(element, url) {
try {
const response = await fetch(url);
if (response.ok) {
// This would need specific implementation per element type
console.log('Auto-refresh triggered for', element);
}
} catch (error) {
console.error('Auto-refresh failed:', error);
}
}
initForms() {
// AJAX form submission
document.addEventListener('submit', async (e) => {
if (e.target.matches('[data-ajax-form]')) {
e.preventDefault();
await this.submitAjaxForm(e.target);
}
});
// Real-time form validation
this.initFormValidation();
}
async submitAjaxForm(form) {
const submitBtn = form.querySelector('[type="submit"]');
const originalText = submitBtn.textContent;
try {
submitBtn.disabled = true;
submitBtn.textContent = 'Processing...';
const formData = new FormData(form);
const response = await fetch(form.action, {
method: form.method || 'POST',
body: formData
});
if (response.ok) {
if (response.headers.get('content-type')?.includes('application/json')) {
const result = await response.json();
this.showAlert(result.message || 'Success', 'success');
} else {
// Handle redirect or page reload
window.location.reload();
}
} else {
const error = await response.text();
this.showAlert(error || 'An error occurred', 'danger');
}
} catch (error) {
this.showAlert('Network error: ' + error.message, 'danger');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = originalText;
}
}
initFormValidation() {
// Real-time validation for world/mod names
document.addEventListener('input', (e) => {
if (e.target.matches('[data-validate-name]')) {
this.validateName(e.target);
}
});
}
validateName(input) {
const value = input.value;
const isValid = /^[a-zA-Z0-9_-]+$/.test(value) && value.length <= 50;
input.setCustomValidity(isValid ? '' : 'Only letters, numbers, underscore and hyphen allowed (max 50 chars)');
// Visual feedback
input.classList.toggle('is-invalid', !isValid && value.length > 0);
input.classList.toggle('is-valid', isValid && value.length > 0);
}
initRealTime() {
// Auto-scroll logs
this.initLogAutoScroll();
}
initLogAutoScroll() {
const logsContainer = document.querySelector('.logs');
if (logsContainer) {
// Auto-scroll to bottom when new logs arrive
const observer = new MutationObserver(() => {
if (logsContainer.scrollTop + logsContainer.clientHeight >= logsContainer.scrollHeight - 100) {
logsContainer.scrollTop = logsContainer.scrollHeight;
}
});
observer.observe(logsContainer, { childList: true, subtree: true });
}
}
updateConnectionStatus(connected) {
const statusElement = document.getElementById('connection-status');
if (statusElement) {
statusElement.className = connected ? 'status status-running' : 'status status-error';
statusElement.textContent = connected ? 'Connected' : 'Disconnected';
}
}
updateServerStatus(status) {
this.serverStatus = status.status;
// Update status badge
const statusElement = document.getElementById('server-status');
if (statusElement) {
statusElement.className = `status status-${status.status}`;
statusElement.textContent = status.status.charAt(0).toUpperCase() + status.status.slice(1);
}
// Update PID
const pidElement = document.getElementById('server-pid');
if (pidElement) {
pidElement.textContent = status.pid || 'N/A';
}
// Update uptime
const uptimeElement = document.getElementById('server-uptime');
if (uptimeElement) {
uptimeElement.textContent = this.formatUptime(status.uptime);
}
// Update control buttons
this.updateServerControls(status.status);
}
updateServerControls(status) {
const startBtn = document.getElementById('server-start');
const stopBtn = document.getElementById('server-stop');
const restartBtn = document.getElementById('server-restart');
if (startBtn) startBtn.disabled = status === 'running';
if (stopBtn) stopBtn.disabled = status === 'stopped';
if (restartBtn) restartBtn.disabled = status === 'stopped';
}
appendLogEntry(logEntry) {
const logsContainer = document.querySelector('.logs');
if (!logsContainer) return;
const logElement = document.createElement('div');
logElement.className = 'log-entry';
if (typeof logEntry === 'string') {
logElement.textContent = logEntry;
} else {
logElement.innerHTML = `
<span class="log-timestamp">[${new Date(logEntry.timestamp).toLocaleTimeString()}]</span>
<span class="log-level-${logEntry.level}">${logEntry.message}</span>
`;
}
logsContainer.appendChild(logElement);
// Limit log entries to prevent memory issues
const logEntries = logsContainer.children;
if (logEntries.length > 1000) {
logEntries[0].remove();
}
}
formatUptime(uptime) {
if (!uptime) return 'N/A';
const seconds = Math.floor(uptime / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
} else if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
} else {
return `${seconds}s`;
}
}
showAlert(message, type = 'info', duration = 5000) {
const alertsContainer = document.getElementById('alerts') || this.createAlertsContainer();
const alert = document.createElement('div');
alert.className = `alert alert-${type}`;
alert.innerHTML = `
<span>${message}</span>
<button type="button" class="modal-close" style="margin-left: auto;">&times;</button>
`;
alertsContainer.appendChild(alert);
// Auto-remove alert
setTimeout(() => {
if (alert.parentNode) {
alert.remove();
}
}, duration);
// Manual close
alert.querySelector('.modal-close').addEventListener('click', () => {
alert.remove();
});
}
createAlertsContainer() {
const container = document.createElement('div');
container.id = 'alerts';
container.style.cssText = `
position: fixed;
top: 1rem;
right: 1rem;
z-index: 1000;
max-width: 400px;
`;
document.body.appendChild(container);
return container;
}
// Utility methods
async api(endpoint, options = {}) {
try {
const response = await fetch(endpoint, {
headers: {
'Content-Type': 'application/json',
...options.headers
},
...options
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return await response.json();
} else {
return await response.text();
}
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
formatDate(date) {
return new Date(date).toLocaleString();
}
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
}
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
window.luantiWebServer = new LuantiWebServer();
});
// Global utility functions
window.confirmDelete = function(itemType, itemName) {
console.log(`confirmDelete called with: itemType="${itemType}", itemName="${itemName}"`);
if (itemType === 'world') {
// Extra confirmation for world deletion - require typing the world name
const message = `WARNING: You are about to permanently delete the world "${itemName}".\n\n` +
`This will remove ALL world data including:\n` +
`• All builds and constructions\n` +
`• Player inventories and progress\n` +
`• World settings and configuration\n` +
`• All world-specific mods and data\n\n` +
`This action cannot be undone!\n\n` +
`Type the world name exactly to confirm:`;
const confirmation = prompt(message);
console.log(`User entered: "${confirmation}", expected: "${itemName}"`);
if (confirmation === null) {
console.log('User cancelled the dialog');
return false;
}
const matches = confirmation === itemName;
console.log(`Confirmation ${matches ? 'matches' : 'does not match'}`);
if (!matches && confirmation !== null) {
alert('Deletion cancelled - world name did not match exactly.');
}
return matches;
} else {
// Standard confirmation for other items
return confirm(`Are you sure you want to delete the ${itemType} "${itemName}"? This action cannot be undone.`);
}
};
window.showLoading = function(element, text = 'Loading...') {
if (typeof element === 'string') {
element = document.querySelector(element);
}
if (element) {
element.innerHTML = `
<div class="loading">
<div class="spinner"></div>
<div>${text}</div>
</div>
`;
}
};
window.hideLoading = function(element) {
if (typeof element === 'string') {
element = document.querySelector(element);
}
if (element) {
element.innerHTML = '';
}
};
+629
View File
@@ -0,0 +1,629 @@
let socket;
let autoScroll = true;
let serverRunning = false;
let isExternalServer = false;
document.addEventListener('DOMContentLoaded', function() {
// Initialize WebSocket connection for real-time updates
initializeWebSocket();
// Load initial data
loadWorlds();
updateServerStatus();
// Set up periodic status updates (every 3 seconds for better responsiveness)
setInterval(updateServerStatus, 3000);
// Add event listeners for buttons
document.getElementById('startBtn').addEventListener('click', startServer);
document.getElementById('stopBtn').addEventListener('click', stopServer);
document.getElementById('restartBtn').addEventListener('click', restartServer);
document.getElementById('downloadBtn').addEventListener('click', downloadLogs);
document.getElementById('clearBtn').addEventListener('click', clearLogs);
document.getElementById('autoScrollBtn').addEventListener('click', toggleAutoScroll);
document.getElementById('sendBtn').addEventListener('click', sendCommand);
// Add enter key handler for console input
document.getElementById('consoleInput').addEventListener('keypress', function(event) {
if (event.key === 'Enter') {
sendCommand();
}
});
});
function initializeWebSocket() {
socket = io();
socket.on('server:log', function(logEntry) {
addLogEntry(logEntry.type, logEntry.content, logEntry.timestamp);
});
socket.on('server:status', function(status) {
isExternalServer = status.isExternal || false;
updateStatusDisplay(status);
});
socket.on('server:players', function(players) {
updatePlayersList(players, isExternalServer);
});
}
async function updateServerStatus() {
try {
const response = await fetch('/api/server/status');
// Check for authentication redirect
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('text/html')) {
console.warn('Authentication required for server status');
// Silently fail for status updates, don't redirect automatically
return;
}
if (!response.ok) {
throw new Error('HTTP error! status: ' + response.status);
}
const status = await response.json();
isExternalServer = status.isExternal || false;
updateStatusDisplay(status);
} catch (error) {
console.error('Failed to update server status:', error);
}
}
async function checkServerStatus() {
try {
const response = await fetch('/api/server/status');
// Check for authentication redirect
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('text/html')) {
return null;
}
if (!response.ok) {
return null;
}
return await response.json();
} catch (error) {
console.error('Failed to check server status:', error);
return null;
}
}
function updateStatusDisplay(status) {
const statusLight = document.getElementById('statusLight');
const statusText = document.getElementById('statusText');
const startBtn = document.getElementById('startBtn');
const stopBtn = document.getElementById('stopBtn');
const restartBtn = document.getElementById('restartBtn');
const consoleInputGroup = document.getElementById('consoleInputGroup');
const wasRunning = serverRunning;
serverRunning = status.isRunning;
if (status.isRunning) {
if (status.isReady) {
// Server is running and ready to accept connections
statusLight.className = 'status-light online';
statusText.textContent = status.isExternal ? 'Running (External - Monitor Only)' : 'Running';
} else {
// Server process is running but not ready yet
statusLight.className = 'status-light starting';
statusText.textContent = 'Starting...';
}
// For external servers, disable control buttons
if (status.isExternal) {
startBtn.disabled = true;
stopBtn.disabled = true;
restartBtn.disabled = true;
consoleInputGroup.style.display = 'none';
} else {
startBtn.disabled = true;
stopBtn.disabled = false;
restartBtn.disabled = false;
consoleInputGroup.style.display = 'block';
}
} else {
statusLight.className = 'status-light offline';
statusText.textContent = 'Offline';
startBtn.disabled = false;
stopBtn.disabled = true;
restartBtn.disabled = true;
consoleInputGroup.style.display = 'none';
// Reset button states if server stopped unexpectedly
if (startBtn.textContent === '⏳ Starting...') {
startBtn.textContent = '▶️ Start Server';
}
if (restartBtn.textContent === '⏳ Restarting...') {
restartBtn.textContent = '🔄 Restart Server';
}
// Log if server stopped unexpectedly
if (wasRunning && !status.isRunning) {
addLogEntry('warning', 'Server has stopped. Check logs for details.');
}
}
// Update stats
document.getElementById('uptime').textContent = formatUptime(status.uptime);
document.getElementById('playerCount').textContent = status.players || 0;
document.getElementById('memoryUsage').textContent = status.memoryUsage ?
Math.round(status.memoryUsage) + ' MB' : '--';
// Debug: Log the status to see what we're getting
console.log('Server status update:', {
isRunning: status.isRunning,
players: status.players,
uptime: status.uptime
});
}
function formatUptime(milliseconds) {
if (!milliseconds) return '--';
const seconds = Math.floor(milliseconds / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return days + 'd ' + (hours % 24) + 'h';
if (hours > 0) return hours + 'h ' + (minutes % 60) + 'm';
if (minutes > 0) return minutes + 'm ' + (seconds % 60) + 's';
return seconds + 's';
}
async function loadWorlds() {
console.log('loadWorlds() called');
try {
const response = await fetch('/api/worlds');
// Check for authentication redirect
const contentType = response.headers.get('content-type');
console.log('Response status:', response.status, 'Content-Type:', contentType);
if (contentType && contentType.includes('text/html')) {
console.warn('Authentication required for loading worlds');
document.getElementById('worldSelect').innerHTML =
'<option value="">Please log in to load worlds</option>' +
'<option value="" disabled>───────────────────</option>' +
'<option value="" disabled>🔒 Authentication required</option>';
return;
}
if (!response.ok) {
throw new Error('HTTP error! status: ' + response.status);
}
const worlds = await response.json();
console.log('Worlds received:', worlds);
const worldSelect = document.getElementById('worldSelect');
if (worlds.length === 0) {
worldSelect.innerHTML =
'<option value="">No worlds found - server will create default world</option>' +
'<option value="" disabled>───────────────────</option>' +
'<option value="" disabled>💡 Create worlds in the Worlds section</option>';
} else {
worldSelect.innerHTML = '<option value="" disabled selected>Choose a world to run</option>';
worlds.forEach(world => {
const option = document.createElement('option');
option.value = world.name;
option.textContent = '🌍 ' + (world.displayName || world.name);
worldSelect.appendChild(option);
});
}
} catch (error) {
console.error('Failed to load worlds:', error);
document.getElementById('worldSelect').innerHTML =
'<option value="">Error loading worlds - will use defaults</option>';
}
}
async function startServer() {
const worldName = document.getElementById('worldSelect').value;
console.log('Starting server with world:', worldName);
const startBtn = document.getElementById('startBtn');
// Validate that a world is selected
if (!worldName) {
addLogEntry('error', 'Please select a world before starting the server');
return;
}
try {
startBtn.disabled = true;
startBtn.textContent = '⏳ Starting...';
const response = await fetch('/api/server/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ worldName: worldName })
});
// Check if response is HTML (redirect to login) instead of JSON
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('text/html')) {
addLogEntry('error', 'Authentication required. Please refresh the page and log in.');
startBtn.disabled = false;
startBtn.textContent = '▶️ Start Server';
// Optionally redirect to login
setTimeout(() => {
window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname);
}, 2000);
return;
}
if (!response.ok) {
const errorText = await response.text();
startBtn.disabled = false;
startBtn.textContent = '▶️ Start Server';
throw new Error('HTTP error! status: ' + response.status + ' - ' + errorText);
}
const result = await response.json();
if (result.success) {
addLogEntry('info', result.message || 'Server started successfully');
await updateServerStatus();
// Monitor for early server crash
setTimeout(async () => {
const status = await checkServerStatus();
if (status && !status.isRunning) {
addLogEntry('warning', 'Server appears to have stopped unexpectedly. Check logs for errors.');
startBtn.disabled = false;
startBtn.textContent = '▶️ Start Server';
}
}, 3000); // Check after 3 seconds
} else {
addLogEntry('error', 'Failed to start server: ' + (result.error || 'Unknown error'));
startBtn.disabled = false;
startBtn.textContent = '▶️ Start Server';
}
} catch (error) {
console.error('Server start error:', error);
addLogEntry('error', 'Failed to start server: ' + error.message);
startBtn.disabled = false;
startBtn.textContent = '▶️ Start Server';
}
}
async function stopServer() {
const stopBtn = document.getElementById('stopBtn');
try {
stopBtn.disabled = true;
stopBtn.textContent = '⏳ Stopping...';
const response = await fetch('/api/server/stop', { method: 'POST' });
// Check for authentication redirect
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('text/html')) {
addLogEntry('error', 'Authentication required. Please refresh the page and log in.');
stopBtn.disabled = false;
stopBtn.textContent = '⏹️ Stop Server';
setTimeout(() => {
window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname);
}, 2000);
return;
}
if (!response.ok) {
throw new Error('HTTP error! status: ' + response.status);
}
const result = await response.json();
if (result.success) {
addLogEntry('info', result.message || 'Server stopped successfully');
await updateServerStatus();
} else {
addLogEntry('error', 'Failed to stop server: ' + (result.error || 'Unknown error'));
}
} catch (error) {
console.error('Server stop error:', error);
addLogEntry('error', 'Failed to stop server: ' + error.message);
} finally {
stopBtn.disabled = false;
stopBtn.textContent = '⏹️ Stop Server';
}
}
async function restartServer() {
const worldName = document.getElementById('worldSelect').value;
const restartBtn = document.getElementById('restartBtn');
// Validate that a world is selected
if (!worldName) {
addLogEntry('error', 'Please select a world before restarting the server');
return;
}
try {
restartBtn.disabled = true;
restartBtn.textContent = '⏳ Restarting...';
const response = await fetch('/api/server/restart', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ worldName: worldName || null })
});
// Check for authentication redirect
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('text/html')) {
addLogEntry('error', 'Authentication required. Please refresh the page and log in.');
restartBtn.disabled = false;
restartBtn.textContent = '🔄 Restart Server';
setTimeout(() => {
window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname);
}, 2000);
return;
}
if (!response.ok) {
const errorText = await response.text();
restartBtn.disabled = false;
restartBtn.textContent = '🔄 Restart Server';
throw new Error('HTTP error! status: ' + response.status + ' - ' + errorText);
}
const result = await response.json();
if (result.success) {
addLogEntry('info', result.message || 'Server restarted successfully');
await updateServerStatus();
// Monitor for early server crash
setTimeout(async () => {
const status = await checkServerStatus();
if (status && !status.isRunning) {
addLogEntry('warning', 'Server appears to have stopped unexpectedly after restart. Check logs for errors.');
restartBtn.disabled = false;
restartBtn.textContent = '🔄 Restart Server';
}
}, 3000); // Check after 3 seconds
} else {
addLogEntry('error', 'Failed to restart server: ' + (result.error || 'Unknown error'));
}
} catch (error) {
console.error('Server restart error:', error);
addLogEntry('error', 'Failed to restart server: ' + error.message);
} finally {
restartBtn.disabled = false;
restartBtn.textContent = '🔄 Restart Server';
}
}
function addLogEntry(type, message, timestamp) {
const consoleContent = document.getElementById('consoleContent');
const logEntry = document.createElement('div');
timestamp = timestamp || new Date().toLocaleTimeString();
logEntry.className = 'log-entry ' + type;
logEntry.innerHTML = '<span class="timestamp">' + timestamp + '</span>' +
'<span class="message">' + escapeHtml(message) + '</span>';
consoleContent.appendChild(logEntry);
// Auto-scroll to bottom if enabled
if (autoScroll) {
consoleContent.scrollTop = consoleContent.scrollHeight;
}
// Limit log entries to prevent memory issues
const maxEntries = 1000;
while (consoleContent.children.length > maxEntries) {
consoleContent.removeChild(consoleContent.firstChild);
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function clearLogs() {
document.getElementById('consoleContent').innerHTML = '';
addLogEntry('info', 'Console cleared');
}
function toggleAutoScroll() {
autoScroll = !autoScroll;
document.getElementById('autoScrollText').textContent = 'Auto-scroll: ' + (autoScroll ? 'ON' : 'OFF');
}
async function sendCommand() {
const input = document.getElementById('consoleInput');
const command = input.value.trim();
if (!command) return;
try {
const response = await fetch('/api/server/command', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command })
});
// Check for authentication redirect
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('text/html')) {
addLogEntry('error', 'Authentication required. Please refresh the page and log in.');
setTimeout(() => {
window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname);
}, 2000);
return;
}
if (!response.ok) {
throw new Error('HTTP error! status: ' + response.status);
}
const result = await response.json();
if (result.success) {
addLogEntry('info', 'Command sent: ' + command);
input.value = '';
} else {
addLogEntry('error', 'Failed to send command: ' + (result.error || 'Unknown error'));
}
} catch (error) {
console.error('Send command error:', error);
addLogEntry('error', 'Failed to send command: ' + error.message);
}
}
function updatePlayersList(players, isExternal) {
const playersList = document.getElementById('playersList');
if (!players || players.length === 0) {
playersList.innerHTML = '<p class="text-muted">No players online</p>';
return;
}
// Create a table for better formatting with kick functionality
const playersHtml = '<table class="table table-sm">' +
'<thead>' +
'<tr>' +
'<th>Player</th>' +
'<th>Last Activity</th>' +
'<th>Actions</th>' +
'</tr>' +
'</thead>' +
'<tbody>' +
players.map((player, index) => {
// Format the last seen time
let lastActivity = '--';
if (player.lastSeen) {
const now = new Date();
const lastSeenTime = new Date(player.lastSeen);
const diffMinutes = Math.floor((now - lastSeenTime) / (1000 * 60));
if (diffMinutes < 1) {
lastActivity = 'Just now';
} else if (diffMinutes < 60) {
lastActivity = diffMinutes + 'm ago';
} else {
lastActivity = Math.floor(diffMinutes / 60) + 'h ago';
}
}
return '<tr>' +
'<td><strong>' + escapeHtml(player.name) + '</strong></td>' +
'<td>' +
'<small class="text-muted">' + lastActivity + '</small><br>' +
'<span class="badge badge-secondary">' + (player.lastAction || 'Active') + '</span>' +
'</td>' +
'<td>' +
'<button class="btn btn-sm btn-outline-danger kick-player-btn" data-player-name="' + escapeHtml(player.name) + '"' +
(isExternal ? ' disabled title="Cannot kick players on external servers"' : '') + '>' +
'<i class="fas fa-user-slash"></i> Kick' +
'</button>' +
'</td>' +
'</tr>';
}).join('') +
'</tbody>' +
'</table>';
playersList.innerHTML = playersHtml;
// Add event listeners for kick buttons
const kickButtons = playersList.querySelectorAll('.kick-player-btn');
kickButtons.forEach(button => {
button.addEventListener('click', function(e) {
e.preventDefault();
const playerName = this.getAttribute('data-player-name');
kickPlayer(playerName);
});
});
}
async function kickPlayer(playerName) {
console.log('kickPlayer() called for player:', playerName);
addLogEntry('info', 'Attempting to kick player: ' + playerName);
if (!confirm('Are you sure you want to kick ' + playerName + '?')) {
console.log('Kick cancelled by user');
return;
}
console.log('Sending kick request...');
try {
const response = await fetch('/api/server/command', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'same-origin',
body: JSON.stringify({
command: '/kick ' + playerName
})
});
if (!response.ok) {
if (response.status === 401) {
addLogEntry('error', 'Authentication required to kick players. Please refresh the page.');
setTimeout(() => {
window.location.reload();
}, 2000);
return;
}
throw new Error('HTTP error! status: ' + response.status);
}
const result = await response.json();
if (result.success) {
addLogEntry('success', 'Kicked player: ' + playerName);
// Refresh player list after a short delay
setTimeout(updateServerStatus, 1000);
} else {
addLogEntry('error', 'Failed to kick player: ' + (result.error || 'Unknown error'));
}
} catch (error) {
console.error('Error kicking player:', error);
addLogEntry('error', 'Error kicking player: ' + error.message);
}
}
async function downloadLogs() {
try {
const response = await fetch('/api/server/logs');
// Check for authentication redirect
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('text/html')) {
addLogEntry('error', 'Authentication required to download logs');
setTimeout(() => {
window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname);
}, 2000);
return;
}
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'server-logs-' + new Date().toISOString().split('T')[0] + '.txt';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} else {
addLogEntry('error', 'Failed to download logs: HTTP ' + response.status);
}
} catch (error) {
console.error('Download logs error:', error);
addLogEntry('error', 'Failed to download logs: ' + error.message);
}
}
+39
View File
@@ -0,0 +1,39 @@
// Shared server status functionality for all pages
async function updateServerStatus(statusElementId) {
try {
const response = await fetch('/api/server/status');
// Check for authentication redirect
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('text/html')) {
console.warn('Authentication required for server status');
// Silently fail for status updates, don't redirect automatically
return;
}
if (!response.ok) {
throw new Error('HTTP error! status: ' + response.status);
}
const status = await response.json();
updateStatusElement(statusElementId, status);
} catch (error) {
console.error('Failed to update server status:', error);
// Show error state
const statusElement = document.getElementById(statusElementId);
if (statusElement) {
statusElement.textContent = 'Error';
statusElement.className = 'status status-stopped';
}
}
}
function updateStatusElement(elementId, status) {
const statusElement = document.getElementById(elementId);
if (statusElement) {
const statusText = status.statusText || (status.isRunning ? 'running' : 'stopped');
statusElement.textContent = statusText.charAt(0).toUpperCase() + statusText.slice(1);
statusElement.className = `status status-${statusText}`;
}
}
+534
View File
@@ -0,0 +1,534 @@
const express = require('express');
const fs = require('fs').promises;
const path = require('path');
const paths = require('../utils/paths');
const serverManager = require('../utils/shared-server-manager');
const ConfigManager = require('../utils/config-manager');
const ConfigParser = require('../utils/config-parser');
const appConfig = require('../utils/app-config');
const router = express.Router();
// Create global config manager instance
const configManager = new ConfigManager();
// Initialize server manager with socket.io when available
let io = null;
function setSocketIO(socketInstance) {
io = socketInstance;
// Attach server manager events to socket.io
serverManager.on('log', (logEntry) => {
if (io) {
io.emit('server:log', logEntry);
}
});
serverManager.on('stats', (stats) => {
if (io) {
io.emit('server:stats', stats);
}
});
serverManager.on('status', (status) => {
if (io) {
io.emit('server:status', status);
}
});
serverManager.on('exit', (exitInfo) => {
if (io) {
// Broadcast status immediately when server exits
serverManager.getServerStatus().then(status => {
io.emit('server:status', status);
});
}
});
}
// Server status endpoint
router.get('/server/status', async (req, res) => {
try {
const status = await serverManager.getServerStatus();
// For all running servers, get player list from debug.txt
let playerList = [];
if (status.isRunning) {
const playerData = await serverManager.getExternalServerPlayerData();
playerList = playerData.players;
// Also update the server stats with current player count
serverManager.serverStats.players = playerData.count;
// Emit player list via WebSocket if available
if (io) {
io.emit('server:players', playerList);
}
}
const isExternal = serverManager.serverProcess?.external || false;
console.log('API: serverManager.serverProcess =', serverManager.serverProcess);
console.log('API: isExternal =', isExternal);
console.log('API endpoint returning status:', {
isRunning: status.isRunning,
players: playerList.length, // Use the actual detected player count
playerNames: playerList.map(p => p.name),
statusText: status.isRunning ? 'running' : 'stopped',
isExternal: isExternal
});
res.json({
...status,
players: playerList.length, // Override with actual player count
playerList: playerList,
// Add simple string status for UI
statusText: status.isRunning ? 'running' : 'stopped',
// Include external server information
isExternal: isExternal
});
} catch (error) {
console.error('API: Server status error:', error);
res.status(500).json({
error: error.message,
statusText: 'stopped',
isRunning: false,
isReady: false,
playerList: []
});
}
});
// Start server
router.post('/server/start', async (req, res) => {
try {
const { worldName } = req.body;
console.log('Server start requested with world:', worldName);
const result = await serverManager.startServer(worldName);
res.json(result);
} catch (error) {
console.error('Server start error:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// Stop server
router.post('/server/stop', async (req, res) => {
try {
const { force = false } = req.body;
const result = await serverManager.stopServer(force);
res.json(result);
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
// Restart server
router.post('/server/restart', async (req, res) => {
try {
const { worldName } = req.body;
const result = await serverManager.restartServer(worldName);
res.json(result);
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
// Send command to server
router.post('/server/command', async (req, res) => {
try {
const { command } = req.body;
if (!command || typeof command !== 'string') {
return res.status(400).json({ error: 'Command is required' });
}
const result = await serverManager.sendCommand(command.trim());
res.json(result);
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
// Get server logs
router.get('/server/logs', async (req, res) => {
try {
const { lines = 500, format = 'text' } = req.query;
const logs = serverManager.getLogs(parseInt(lines));
if (format === 'json') {
res.json(logs);
} else {
// Return as downloadable text file
const logText = logs.map(log =>
`[${log.timestamp}] ${log.type.toUpperCase()}: ${log.content}`
).join('\n');
res.setHeader('Content-Disposition', 'attachment; filename=server-logs.txt');
res.setHeader('Content-Type', 'text/plain');
res.send(logText);
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get server info
router.get('/server/info', async (req, res) => {
try {
const info = await serverManager.getServerInfo();
res.json(info);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Configuration endpoints
// Get all configuration sections
router.get('/config/sections', async (req, res) => {
try {
const sections = configManager.getAllSettings();
res.json(sections);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get current configuration
router.get('/config', async (req, res) => {
try {
// Use the new configuration schema approach instead of ConfigManager
const configSchema = {
System: {
data_directory: {
type: 'string',
default: '',
description: 'Luanti data directory path (leave empty for auto-detection)'
}
},
Server: {
port: {
type: 'number',
default: 30000,
description: 'Port for server to listen on'
},
server_name: {
type: 'string',
default: 'Luanti Server',
description: 'Name of the server'
},
server_description: {
type: 'string',
default: 'A Luanti server',
description: 'Server description'
},
server_announce: {
type: 'boolean',
default: false,
description: 'Announce server to server list'
},
max_users: {
type: 'number',
default: 20,
description: 'Maximum number of users'
}
},
World: {
creative_mode: {
type: 'boolean',
default: false,
description: 'Enable creative mode by default'
},
enable_damage: {
type: 'boolean',
default: true,
description: 'Enable player damage by default'
},
enable_pvp: {
type: 'boolean',
default: true,
description: 'Enable player vs player combat by default'
},
default_game: {
type: 'string',
default: 'minetest_game',
description: 'Default game to use for new worlds'
},
time_speed: {
type: 'number',
default: 72,
description: 'Time speed (72 = normal, higher = faster)'
}
},
Security: {
disallow_empty_password: {
type: 'boolean',
default: false,
description: 'Disallow empty passwords'
},
strict_protocol_version_checking: {
type: 'boolean',
default: false,
description: 'Strict protocol version checking'
}
},
Performance: {
dedicated_server_step: {
type: 'number',
default: 0.1,
description: 'Server step time in seconds'
},
num_emerge_threads: {
type: 'number',
default: 1,
description: 'Number of emerge threads'
},
server_map_save_interval: {
type: 'number',
default: 15.3,
description: 'Map save interval in seconds'
},
max_block_send_distance: {
type: 'number',
default: 12,
description: 'Maximum block send distance'
}
},
Network: {
server_address: {
type: 'string',
default: '',
description: 'IP address to bind to (empty for all interfaces)'
},
server_dedicated: {
type: 'boolean',
default: false,
description: 'Run as dedicated server'
}
},
Advanced: {
max_simultaneous_block_sends_per_client: {
type: 'number',
default: 40,
description: 'Maximum simultaneous block sends per client'
}
}
};
// Load both Luanti config and app config
const luantiConfig = await ConfigParser.parseConfig(paths.configFile);
await appConfig.load();
// Combine configs for display
const combinedConfig = {
...luantiConfig,
data_directory: appConfig.getDataDirectory()
};
// Organize schema into sections with proper structure for frontend
const sections = {};
for (const [sectionName, sectionFields] of Object.entries(configSchema)) {
sections[sectionName] = {
description: sectionName + ' configuration settings',
settings: sectionFields
};
}
res.json({
current: combinedConfig,
sections: sections,
schema: configSchema
});
} catch (error) {
console.error('Error getting config via API:', error);
res.status(500).json({ error: error.message });
}
});
// Update configuration
router.post('/config', async (req, res) => {
try {
const { settings } = req.body;
if (!settings || typeof settings !== 'object') {
return res.status(400).json({ error: 'Settings object is required' });
}
// Validate all settings
const validationErrors = [];
const validatedSettings = {};
for (const [key, value] of Object.entries(settings)) {
const validation = configManager.validateSetting(key, value);
if (validation.valid) {
validatedSettings[key] = validation.value;
} else {
validationErrors.push({ key, error: validation.error });
}
}
if (validationErrors.length > 0) {
return res.status(400).json({
error: 'Validation failed',
details: validationErrors
});
}
const result = await configManager.updateSettings(validatedSettings);
res.json(result);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Update single configuration setting
router.put('/config/:key', async (req, res) => {
try {
const { key } = req.params;
const { value } = req.body;
const validation = configManager.validateSetting(key, value);
if (!validation.valid) {
return res.status(400).json({ error: validation.error });
}
const result = await configManager.updateSetting(key, validation.value);
res.json(result);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Reset configuration section to defaults
router.post('/config/reset/:section?', async (req, res) => {
try {
const { section } = req.params;
const result = await configManager.resetToDefaults(section);
res.json(result);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get setting information
router.get('/config/setting/:key', async (req, res) => {
try {
const { key } = req.params;
const info = configManager.getSettingInfo(key);
if (!info) {
return res.status(404).json({ error: 'Setting not found' });
}
res.json(info);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Worlds endpoints (basic)
router.get('/worlds', async (req, res) => {
try {
await fs.mkdir(paths.worldsDir, { recursive: true });
const worldDirs = await fs.readdir(paths.worldsDir);
const worlds = [];
for (const worldDir of worldDirs) {
try {
const worldPath = paths.getWorldPath(worldDir);
const stats = await fs.stat(worldPath);
if (stats.isDirectory()) {
// Try to read world.mt for display name
let displayName = worldDir;
try {
const worldConfig = await fs.readFile(
path.join(worldPath, 'world.mt'),
'utf8'
);
const nameMatch = worldConfig.match(/world_name\s*=\s*(.+)/);
if (nameMatch) {
displayName = nameMatch[1].trim().replace(/^["']|["']$/g, '');
}
} catch {}
worlds.push({
name: worldDir,
displayName: displayName,
path: worldPath,
lastModified: stats.mtime
});
}
} catch (error) {
// Skip invalid world directories
}
}
// Sort by last modified
worlds.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
res.json(worlds);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ContentDB package info endpoint for validation
router.post('/contentdb/package-info', async (req, res) => {
try {
const { author, name } = req.body;
if (!author || !name) {
return res.status(400).json({ error: 'Author and name are required' });
}
const ContentDBClient = require('../utils/contentdb');
const contentdb = new ContentDBClient();
// Get package info from ContentDB
const packageInfo = await contentdb.getPackage(author, name);
res.json({
type: packageInfo.type || 'mod',
title: packageInfo.title || name,
author: packageInfo.author || author,
name: packageInfo.name || name,
short_description: packageInfo.short_description || ''
});
} catch (error) {
console.error('Error getting package info:', error);
// If it's a 404 error, return that specifically
if (error.message === 'Package not found') {
return res.status(404).json({ error: 'Package not found on ContentDB' });
}
// For other errors, return a generic error but don't fail completely
res.status(200).json({
error: 'Could not verify package information',
type: 'mod', // Default to mod type
fallback: true
});
}
});
module.exports = {
router,
setSocketIO,
serverManager,
configManager
};
+262
View File
@@ -0,0 +1,262 @@
const express = require('express');
const AuthManager = require('../utils/auth');
const { redirectIfAuthenticated } = require('../middleware/auth');
const securityLogger = require('../utils/security-logger');
const router = express.Router();
const authManager = new AuthManager();
// Initialize auth manager
authManager.initialize().catch(console.error);
// Login page
router.get('/login', redirectIfAuthenticated, async (req, res) => {
try {
const isFirstUser = await authManager.isFirstUser();
if (isFirstUser) {
// No users exist yet - redirect to registration
return res.redirect('/register');
}
const redirectUrl = req.query.redirect || '/';
res.render('auth/login', {
title: 'Login',
redirectUrl: redirectUrl,
currentPage: 'login'
});
} catch (error) {
console.error('Error checking first user on login:', error);
const redirectUrl = req.query.redirect || '/';
res.render('auth/login', {
title: 'Login',
redirectUrl: redirectUrl,
currentPage: 'login'
});
}
});
// Register page (only for first user)
router.get('/register', redirectIfAuthenticated, async (req, res) => {
try {
const isFirstUser = await authManager.isFirstUser();
if (!isFirstUser) {
return res.status(403).render('error', {
error: 'Registration Not Available',
message: 'New accounts can only be created by existing administrators. Please contact an admin to create your account.'
});
}
res.render('auth/register', {
title: 'Setup Administrator Account',
isFirstUser: isFirstUser,
currentPage: 'register'
});
} catch (error) {
console.error('Error checking first user:', error);
res.status(500).render('error', {
error: 'Failed to load registration page',
message: error.message
});
}
});
// Process login
router.post('/login', redirectIfAuthenticated, async (req, res) => {
try {
const { username, password, redirect } = req.body;
if (!username || !password) {
return res.render('auth/login', {
title: 'Login',
error: 'Username and password are required',
redirectUrl: redirect || '/',
currentPage: 'login',
formData: { username }
});
}
const user = await authManager.authenticateUser(username, password);
// Log successful authentication
await securityLogger.logAuthSuccess(req, username);
// Create session
req.session.user = user;
// Redirect to intended page or dashboard
const redirectUrl = redirect && redirect !== '/login' ? redirect : '/';
res.redirect(redirectUrl);
} catch (error) {
console.error('Login error:', error);
// Log failed authentication
await securityLogger.logAuthFailure(req, username, error.message);
res.render('auth/login', {
title: 'Login',
error: error.message,
redirectUrl: req.body.redirect || '/',
currentPage: 'login',
formData: { username: req.body.username }
});
}
});
// Process registration (only for first user)
router.post('/register', redirectIfAuthenticated, async (req, res) => {
try {
const isFirstUser = await authManager.isFirstUser();
if (!isFirstUser) {
return res.status(403).render('error', {
error: 'Registration Not Available',
message: 'New accounts can only be created by existing administrators.'
});
}
const { username, password, confirmPassword } = req.body;
// Validate inputs
if (!username || !password || !confirmPassword) {
return res.render('auth/register', {
title: 'Setup Administrator Account',
error: 'All fields are required',
isFirstUser: true,
currentPage: 'register',
formData: { username }
});
}
if (password !== confirmPassword) {
return res.render('auth/register', {
title: 'Setup Administrator Account',
error: 'Passwords do not match',
isFirstUser: true,
currentPage: 'register',
formData: { username }
});
}
const user = await authManager.createUser(username, password);
// Create session for new user
req.session.user = {
id: user.id,
username: user.username,
created_at: user.created_at
};
// Redirect to dashboard
res.redirect('/?registered=true');
} catch (error) {
console.error('Registration error:', error);
res.render('auth/register', {
title: 'Register',
error: error.message,
isFirstUser: await authManager.isFirstUser(),
currentPage: 'register',
formData: {
username: req.body.username
}
});
}
});
// Logout
router.post('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
console.error('Logout error:', err);
return res.status(500).json({ error: 'Failed to logout' });
}
if (req.headers.accept && req.headers.accept.includes('application/json')) {
res.json({ message: 'Logged out successfully' });
} else {
res.redirect('/login?message=You have been logged out');
}
});
});
// Get logout (for convenience)
router.get('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
console.error('Logout error:', err);
}
res.redirect('/login?message=You have been logged out');
});
});
// User profile page
router.get('/profile', async (req, res) => {
if (!req.session || !req.session.user) {
return res.redirect('/login');
}
try {
const user = await authManager.getUserById(req.session.user.id);
if (!user) {
req.session.destroy();
return res.redirect('/login?error=User not found');
}
res.render('auth/profile', {
title: 'Profile',
user: user,
currentPage: 'profile'
});
} catch (error) {
console.error('Profile error:', error);
res.status(500).render('error', {
error: 'Failed to load profile',
message: error.message
});
}
});
// Change password
router.post('/change-password', async (req, res) => {
if (!req.session || !req.session.user) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
const { currentPassword, newPassword, confirmPassword } = req.body;
if (!currentPassword || !newPassword || !confirmPassword) {
throw new Error('All fields are required');
}
if (newPassword !== confirmPassword) {
throw new Error('New passwords do not match');
}
await authManager.changePassword(req.session.user.id, currentPassword, newPassword);
if (req.headers.accept && req.headers.accept.includes('application/json')) {
res.json({ message: 'Password changed successfully' });
} else {
res.redirect('/profile?success=Password changed successfully');
}
} catch (error) {
console.error('Change password error:', error);
if (req.headers.accept && req.headers.accept.includes('application/json')) {
res.status(400).json({ error: error.message });
} else {
res.redirect('/profile?error=' + encodeURIComponent(error.message));
}
}
});
module.exports = router;
+316
View File
@@ -0,0 +1,316 @@
const express = require('express');
const fs = require('fs').promises;
const paths = require('../utils/paths');
const ConfigParser = require('../utils/config-parser');
const appConfig = require('../utils/app-config');
const router = express.Router();
// Configuration schema
const configSchema = {
system: {
data_directory: {
type: 'string',
default: '',
description: 'Luanti data directory path (leave empty for auto-detection)',
section: 'System Settings'
}
},
server: {
port: {
type: 'number',
default: 30000,
description: 'Port for server to listen on'
},
server_name: {
type: 'string',
default: 'Luanti Server',
description: 'Name of the server'
},
server_description: {
type: 'string',
default: 'A Luanti server',
description: 'Server description'
},
server_address: {
type: 'string',
default: '',
description: 'IP address to bind to (empty for all interfaces)'
},
server_announce: {
type: 'boolean',
default: false,
description: 'Announce server to server list'
},
server_dedicated: {
type: 'boolean',
default: false,
description: 'Run as dedicated server'
},
max_users: {
type: 'number',
default: 20,
description: 'Maximum number of users'
}
},
gameplay: {
creative_mode: {
type: 'boolean',
default: false,
description: 'Enable creative mode by default'
},
enable_damage: {
type: 'boolean',
default: true,
description: 'Enable player damage by default'
},
enable_pvp: {
type: 'boolean',
default: true,
description: 'Enable player vs player combat by default'
},
default_game: {
type: 'string',
default: 'minetest_game',
description: 'Default game to use for new worlds'
},
time_speed: {
type: 'number',
default: 72,
description: 'Time speed (72 = normal, higher = faster)'
}
},
security: {
disallow_empty_password: {
type: 'boolean',
default: false,
description: 'Disallow empty passwords'
},
'secure.enable_security': {
type: 'boolean',
default: true,
description: 'Enable security features'
},
strict_protocol_version_checking: {
type: 'boolean',
default: false,
description: 'Strict protocol version checking'
}
},
performance: {
dedicated_server_step: {
type: 'number',
default: 0.1,
description: 'Server step time in seconds'
},
num_emerge_threads: {
type: 'number',
default: 1,
description: 'Number of emerge threads'
},
server_map_save_interval: {
type: 'number',
default: 15.3,
description: 'Map save interval in seconds'
},
max_block_send_distance: {
type: 'number',
default: 12,
description: 'Maximum block send distance'
},
max_simultaneous_block_sends_per_client: {
type: 'number',
default: 40,
description: 'Maximum simultaneous block sends per client'
}
}
};
// Configuration page
router.get('/', async (req, res) => {
try {
paths.ensureDirectories();
// Load both Luanti config and app config
const luantiConfig = await ConfigParser.parseConfig(paths.configFile);
await appConfig.load();
// Combine configs for display
const combinedConfig = {
...luantiConfig,
data_directory: appConfig.getDataDirectory()
};
res.render('config/index', {
title: 'Server Configuration',
config: combinedConfig,
schema: configSchema,
currentPage: 'config',
currentDataDirectory: appConfig.getDataDirectory(),
defaultDataDirectory: appConfig.getDefaultDataDirectory()
});
} catch (error) {
console.error('Error getting config:', error);
res.status(500).render('error', {
error: 'Failed to load configuration',
message: error.message
});
}
});
// Update configuration
router.post('/update', async (req, res) => {
try {
const updates = req.body;
// Handle data directory change separately
if (updates.data_directory !== undefined) {
const newDataDir = updates.data_directory.trim();
if (newDataDir && newDataDir !== appConfig.getDataDirectory()) {
try {
await appConfig.setDataDirectory(newDataDir);
// Update paths to use new directory
await paths.initialize();
} catch (error) {
throw new Error(`Failed to update data directory: ${error.message}`);
}
}
delete updates.data_directory; // Remove from Luanti config updates
}
// Read current Luanti config
const currentConfig = await ConfigParser.parseConfig(paths.configFile);
// Process form data and convert types for Luanti config
const processedUpdates = {};
for (const [key, value] of Object.entries(updates)) {
if (key === '_csrf' || key === 'returnUrl') continue; // Skip CSRF and utility fields
// Find the field in schema to determine type
let fieldType = 'string';
let fieldFound = false;
for (const section of Object.values(configSchema)) {
if (section[key]) {
fieldType = section[key].type;
fieldFound = true;
break;
}
}
// Convert value based on type
if (fieldType === 'boolean') {
processedUpdates[key] = value === 'on' || value === 'true';
} else if (fieldType === 'number') {
const numValue = parseFloat(value);
if (!isNaN(numValue)) {
processedUpdates[key] = numValue;
}
} else {
// String or unknown type
if (value !== '') {
processedUpdates[key] = value;
}
}
}
// Merge with current Luanti config
const updatedConfig = { ...currentConfig, ...processedUpdates };
// Write updated Luanti config
await ConfigParser.writeConfig(paths.configFile, updatedConfig);
const returnUrl = req.body.returnUrl || '/config';
res.redirect(`${returnUrl}?updated=true`);
} catch (error) {
console.error('Error updating config:', error);
const returnUrl = req.body.returnUrl || '/config';
res.redirect(`${returnUrl}?error=${encodeURIComponent(error.message)}`);
}
});
// Reset configuration to defaults
router.post('/reset', async (req, res) => {
try {
const defaultConfig = {};
// Build default configuration from schema
for (const [sectionName, section] of Object.entries(configSchema)) {
for (const [key, field] of Object.entries(section)) {
if (field.default !== undefined) {
defaultConfig[key] = field.default;
}
}
}
await ConfigParser.writeConfig(paths.configFile, defaultConfig);
res.redirect('/config?reset=true');
} catch (error) {
console.error('Error resetting config:', error);
res.redirect(`/config?error=${encodeURIComponent(error.message)}`);
}
});
// Export current configuration as file
router.get('/export', async (req, res) => {
try {
const config = await ConfigParser.parseConfig(paths.configFile);
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', 'attachment; filename=minetest.conf');
const configLines = [];
for (const [key, value] of Object.entries(config)) {
if (value !== undefined && value !== null) {
configLines.push(`${key} = ${value}`);
}
}
res.send(configLines.join('\n'));
} catch (error) {
console.error('Error exporting config:', error);
res.status(500).render('error', {
error: 'Failed to export configuration',
message: error.message
});
}
});
// Get configuration schema (API)
router.get('/api/schema', (req, res) => {
res.json(configSchema);
});
// Get current configuration (API)
router.get('/api/current', async (req, res) => {
try {
const config = await ConfigParser.parseConfig(paths.configFile);
res.json(config);
} catch (error) {
console.error('Error getting config:', error);
res.status(500).json({ error: 'Failed to get configuration' });
}
});
// Update configuration (API)
router.put('/api/update', async (req, res) => {
try {
const updates = req.body;
const currentConfig = await ConfigParser.parseConfig(paths.configFile);
const updatedConfig = { ...currentConfig, ...updates };
await ConfigParser.writeConfig(paths.configFile, updatedConfig);
res.json({ message: 'Configuration updated successfully' });
} catch (error) {
console.error('Error updating config:', error);
res.status(500).json({ error: 'Failed to update configuration' });
}
});
module.exports = router;
+529
View File
@@ -0,0 +1,529 @@
const express = require('express');
const fs = require('fs').promises;
const path = require('path');
const paths = require('../utils/paths');
const ContentDBClient = require('../utils/contentdb');
const ContentDBUrlParser = require('../utils/contentdb-url');
const PackageRegistry = require('../utils/package-registry');
const router = express.Router();
const contentdb = new ContentDBClient();
const packageRegistry = new PackageRegistry();
// Initialize package registry
packageRegistry.init().catch(console.error);
// ContentDB browse page
router.get('/', async (req, res) => {
try {
const {
q = '',
type = '',
sort = 'score',
order = 'desc',
page = '1'
} = req.query;
const limit = 20;
const offset = (parseInt(page) - 1) * limit;
const packages = await contentdb.searchPackages(q, type, sort, order, limit, offset);
const totalPages = Math.ceil((packages.length || 0) / limit);
const currentPage = parseInt(page);
res.render('contentdb/index', {
title: 'ContentDB Browser',
packages: packages || [],
search: {
query: q,
type: type,
sort: sort,
order: order
},
pagination: {
current: currentPage,
total: totalPages,
hasNext: currentPage < totalPages,
hasPrev: currentPage > 1
},
currentPage: 'contentdb'
});
} catch (error) {
console.error('Error browsing ContentDB:', error);
res.status(500).render('error', {
error: 'Failed to browse ContentDB',
message: error.message
});
}
});
// Popular packages
router.get('/popular', async (req, res) => {
try {
const type = req.query.type || '';
const packages = await contentdb.getPopularPackages(type, 20);
res.render('contentdb/popular', {
title: 'Popular Content',
packages: packages || [],
type: type,
currentPage: 'contentdb'
});
} catch (error) {
console.error('Error getting popular packages:', error);
res.status(500).render('error', {
error: 'Failed to load popular content',
message: error.message
});
}
});
// Recent packages
router.get('/recent', async (req, res) => {
try {
const type = req.query.type || '';
const packages = await contentdb.getRecentPackages(type, 20);
res.render('contentdb/recent', {
title: 'Recent Content',
packages: packages || [],
type: type,
currentPage: 'contentdb'
});
} catch (error) {
console.error('Error getting recent packages:', error);
res.status(500).render('error', {
error: 'Failed to load recent content',
message: error.message
});
}
});
// Package details
router.get('/package/:author/:name', async (req, res) => {
try {
const { author, name } = req.params;
const [packageInfo, releases] = await Promise.all([
contentdb.getPackage(author, name),
contentdb.getPackageReleases(author, name)
]);
let dependencies = null;
try {
dependencies = await contentdb.getPackageDependencies(author, name);
} catch (depError) {
console.warn('Could not get dependencies:', depError.message);
}
res.render('contentdb/package', {
title: `${packageInfo.title || packageInfo.name}`,
package: packageInfo,
releases: releases || [],
dependencies: dependencies,
currentPage: 'contentdb'
});
} catch (error) {
if (error.message.includes('not found')) {
return res.status(404).render('error', {
error: 'Package not found',
message: 'The requested package could not be found on ContentDB.'
});
}
console.error('Error getting package details:', error);
res.status(500).render('error', {
error: 'Failed to load package details',
message: error.message
});
}
});
// Install package
router.post('/install/:author/:name', async (req, res) => {
try {
const { author, name } = req.params;
const { version, installDeps = false } = req.body;
// Get package info to determine type
const packageInfo = await contentdb.getPackage(author, name);
const packageType = packageInfo.type || 'mod';
// Determine target path based on package type
let targetPath;
let locationDescription;
if (packageType === 'game') {
// VALIDATION: Games always go to games directory - cannot be installed to worlds
// This prevents user confusion and maintains proper Luanti architecture where:
// - Games are global and shared across all worlds
// - Worlds are created with a specific game and cannot change games later
// - Installing a game to a world would break the world or have no effect
if (req.body.installTo === 'world') {
return res.status(400).json({
error: 'Games cannot be installed to specific worlds. Games are installed globally and shared across all worlds. To use this game, create a new world and select this game during world creation.',
type: 'invalid_installation_target',
packageType: 'game'
});
}
targetPath = paths.getGamePath(name);
locationDescription = 'games directory';
} else if (packageType === 'txp') {
// Texture packs go to textures directory
targetPath = path.join(paths.texturesDir, name);
locationDescription = 'textures directory';
} else {
// Mods can go to global or world-specific location
if (req.body.installTo === 'world' && req.body.worldName) {
if (!paths.isValidWorldName(req.body.worldName)) {
return res.status(400).json({ error: 'Invalid world name' });
}
targetPath = path.join(paths.getWorldModsPath(req.body.worldName), name);
locationDescription = `world "${req.body.worldName}"`;
} else {
targetPath = path.join(paths.modsDir, name);
locationDescription = 'global directory';
}
}
// Check if already installed
try {
await fs.access(targetPath);
return res.status(409).json({ error: 'Package already installed at this location' });
} catch {}
let result;
if (installDeps === 'on' && packageType === 'mod') {
// Install with dependencies (only for mods)
const basePath = req.body.installTo === 'world'
? paths.getWorldModsPath(req.body.worldName)
: paths.modsDir;
result = await contentdb.installPackageWithDeps(author, name, basePath, true);
} else {
// Install just the package
result = await contentdb.downloadPackage(author, name, targetPath, version);
}
const location = packageType === 'game' ? 'games' :
packageType === 'txp' ? 'textures' :
(req.body.installTo === 'world' ? req.body.worldName : 'global');
res.json({
success: true,
message: `Package ${name} installed successfully to ${location}`,
result: result
});
} catch (error) {
console.error('Error installing package:', error);
res.status(500).json({
success: false,
error: 'Failed to install package: ' + error.message
});
}
});
// Check for updates
router.get('/updates', async (req, res) => {
try {
// Get installed packages from registry
const installedPackages = await packageRegistry.getAllInstallations();
const updates = [];
for (const pkg of installedPackages) {
try {
// Get latest release info from ContentDB
const releases = await contentdb.getPackageReleases(pkg.author, pkg.name);
if (releases && releases.length > 0) {
const latestRelease = releases[0];
// Simple version comparison - if release IDs differ, consider it an update
const hasUpdate = pkg.release_id !== latestRelease.id;
if (hasUpdate) {
const packageInfo = await contentdb.getPackage(pkg.author, pkg.name);
updates.push({
installed: pkg,
latest: {
package: packageInfo,
release: latestRelease
},
hasUpdate: true
});
}
}
} catch (error) {
console.warn(`Could not check updates for ${pkg.author}/${pkg.name}:`, error.message);
// Skip packages that can't be checked
}
}
res.render('contentdb/updates', {
title: 'Available Updates',
updates: updates,
installedCount: installedPackages.length,
updateCount: updates.length,
currentPage: 'contentdb'
});
} catch (error) {
console.error('Error checking for updates:', error);
res.status(500).render('error', {
error: 'Failed to check for updates',
message: error.message
});
}
});
// View installed packages
router.get('/installed', async (req, res) => {
try {
const { location } = req.query;
const packages = await packageRegistry.getInstalledPackages(location);
const stats = await packageRegistry.getStatistics();
res.render('contentdb/installed', {
title: 'Installed Packages',
packages: packages,
statistics: stats,
selectedLocation: location || 'all',
currentPage: 'contentdb'
});
} catch (error) {
console.error('Error getting installed packages:', error);
res.status(500).render('error', {
error: 'Failed to load installed packages',
message: error.message
});
}
});
// Install package from URL
router.post('/install-url', async (req, res) => {
try {
const { packageUrl, installLocation, worldName, installDeps } = req.body;
if (!packageUrl) {
return res.status(400).json({
success: false,
error: 'Package URL is required'
});
}
// Parse and validate URL
const parsed = ContentDBUrlParser.parseUrl(packageUrl);
if (!parsed.isValid) {
return res.status(400).json({
success: false,
error: parsed.error || 'Invalid URL format'
});
}
const { author, name } = parsed;
// Get package info to determine type
const packageInfo = await contentdb.getPackage(author, name);
const packageType = packageInfo.type || 'mod';
// Determine target path based on package type
let targetPath;
let locationDescription;
if (packageType === 'game') {
// VALIDATION: Games always go to games directory - cannot be installed to worlds
// This prevents user confusion and maintains proper Luanti architecture where:
// - Games are global and shared across all worlds
// - Worlds are created with a specific game and cannot change games later
// - Installing a game to a world would break the world or have no effect
if (installLocation === 'world') {
return res.status(400).json({
success: false,
error: 'Games cannot be installed to specific worlds. Games are installed globally and shared across all worlds. To use this game, create a new world and select this game during world creation.',
type: 'invalid_installation_target',
packageType: 'game'
});
}
await fs.mkdir(paths.gamesDir, { recursive: true });
targetPath = paths.getGamePath(name);
locationDescription = 'games directory';
} else if (packageType === 'txp') {
// Texture packs go to textures directory
await fs.mkdir(paths.texturesDir, { recursive: true });
targetPath = path.join(paths.texturesDir, name);
locationDescription = 'textures directory';
} else {
// Mods can go to global or world-specific location
if (installLocation === 'world') {
if (!worldName) {
return res.status(400).json({
success: false,
error: 'World name is required when installing to specific world'
});
}
if (!paths.isValidWorldName(worldName)) {
return res.status(400).json({
success: false,
error: 'Invalid world name'
});
}
// Ensure worldmods directory exists
const worldModsPath = paths.getWorldModsPath(worldName);
await fs.mkdir(worldModsPath, { recursive: true });
targetPath = path.join(worldModsPath, name);
locationDescription = `world "${worldName}"`;
} else {
// Global installation
await fs.mkdir(paths.modsDir, { recursive: true });
targetPath = path.join(paths.modsDir, name);
locationDescription = 'global directory';
}
}
// Check if already installed at this location
let installLocationKey;
if (packageType === 'game') {
installLocationKey = 'games';
} else if (packageType === 'txp') {
installLocationKey = 'textures';
} else {
installLocationKey = installLocation === 'world' ? `world:${worldName}` : 'global';
}
const isInstalled = await packageRegistry.isPackageInstalled(author, name, installLocationKey);
if (isInstalled) {
return res.status(409).json({
success: false,
error: `Package "${name}" is already installed in ${locationDescription}`
});
}
// Install the package
let installResult;
if (installDeps === 'on' && packageType === 'mod') {
// Install with dependencies (only for mods)
const basePath = installLocation === 'world'
? paths.getWorldModsPath(worldName)
: paths.modsDir;
installResult = await contentdb.installPackageWithDeps(author, name, basePath, true);
if (installResult.errors && installResult.errors.length > 0) {
console.warn('Installation completed with errors:', installResult.errors);
}
} else {
// Install just the main package
installResult = await contentdb.downloadPackage(author, name, targetPath);
}
// Record installation in registry
try {
// Handle different installResult structures
const packageInfo = installResult.main ? installResult.main.package : installResult.package;
const releaseInfo = installResult.main ? installResult.main.release : installResult.release;
await packageRegistry.recordInstallation({
author: author,
name: name,
version: releaseInfo?.title || 'latest',
releaseId: releaseInfo?.id,
installLocation: installLocationKey,
installPath: targetPath,
contentdbUrl: parsed.fullUrl,
packageType: packageInfo?.type || 'mod',
title: packageInfo?.title || name,
shortDescription: packageInfo?.short_description || '',
dependencies: packageInfo?.hard_dependencies || []
});
// Record dependencies if installed
if (installDeps === 'on' && installResult.dependencies) {
for (const dep of installResult.dependencies) {
const depInfo = dep.package;
const depRelease = dep.release;
const depPath = path.join(
installLocation === 'world' ? paths.getWorldModsPath(worldName) : paths.modsDir,
depInfo.name
);
await packageRegistry.recordInstallation({
author: depInfo.author,
name: depInfo.name,
version: depRelease?.title || 'latest',
releaseId: depRelease?.id,
installLocation: installLocationKey,
installPath: depPath,
contentdbUrl: `https://content.luanti.org/packages/${depInfo.author}/${depInfo.name}/`,
packageType: depInfo.type || 'mod',
title: depInfo.title || depInfo.name,
shortDescription: depInfo.short_description || '',
dependencies: depInfo.hard_dependencies || []
});
}
}
} catch (registryError) {
console.warn('Failed to record installation in registry:', registryError);
// Continue anyway, installation was successful
}
// Create success response
let message = `Successfully installed "${name}" to ${locationDescription}`;
if (installDeps === 'on' && installResult.dependencies) {
const depCount = installResult.dependencies.length;
if (depCount > 0) {
message += ` with ${depCount} dependenc${depCount === 1 ? 'y' : 'ies'}`;
}
}
res.json({
success: true,
message: message,
package: {
author: author,
name: name,
location: locationDescription
},
installResult: installResult
});
} catch (error) {
console.error('Error installing package from URL:', error);
res.status(500).json({
success: false,
error: 'Installation failed: ' + error.message
});
}
});
// API endpoint for search (AJAX)
router.get('/api/search', async (req, res) => {
try {
const {
q = '',
type = '',
sort = 'score',
order = 'desc',
limit = '10'
} = req.query;
const packages = await contentdb.searchPackages(q, type, sort, order, parseInt(limit), 0);
res.json({
packages: packages || [],
query: q,
type: type
});
} catch (error) {
console.error('Error searching ContentDB:', error);
res.status(500).json({ error: error.message });
}
});
module.exports = router;
+365
View File
@@ -0,0 +1,365 @@
const express = require('express');
const fs = require('fs').promises;
const path = require('path');
const paths = require('../utils/paths');
const ConfigParser = require('../utils/config-parser');
const ContentDBClient = require('../utils/contentdb');
const ContentDBUrlParser = require('../utils/contentdb-url');
const PackageRegistry = require('../utils/package-registry');
const router = express.Router();
const contentdb = new ContentDBClient();
const packageRegistry = new PackageRegistry();
// Initialize package registry
packageRegistry.init().catch(console.error);
// Main Extensions page - shows installed content and installer
router.get('/', async (req, res) => {
try {
paths.ensureDirectories();
// Get installed packages from registry (games, mods, texture packs)
const allRegistryPackages = await packageRegistry.getAllInstallations();
const statistics = await packageRegistry.getStatistics();
// Filter registry packages to only include those that actually exist on disk
const installedPackages = [];
for (const pkg of allRegistryPackages) {
let packagePath;
if (pkg.package_type === 'game') {
packagePath = paths.getGamePath(pkg.name);
} else if (pkg.package_type === 'mod') {
packagePath = paths.getModPath(pkg.name);
} else {
// For other types, assume they exist (texture packs, etc.)
installedPackages.push(pkg);
continue;
}
// Only include if the package directory actually exists
try {
const stats = await fs.stat(packagePath);
if (stats.isDirectory()) {
installedPackages.push(pkg);
}
} catch (error) {
// Package directory doesn't exist, don't include it
console.log(`Package ${pkg.name} (${pkg.package_type}) not found at ${packagePath}, excluding from installed list`);
}
}
// Get local mods (not from ContentDB)
let localMods = [];
try {
const modDirs = await fs.readdir(paths.modsDir);
for (const modDir of modDirs) {
try {
const modPath = paths.getModPath(modDir);
const configPath = paths.getModConfigPath(modDir);
const stats = await fs.stat(modPath);
if (!stats.isDirectory()) continue;
// Check if this mod is already in the registry (from ContentDB)
const isFromContentDB = installedPackages.some(pkg =>
pkg.name === modDir && pkg.install_location === 'global'
);
if (!isFromContentDB) {
const config = await ConfigParser.parseModConfig(configPath);
localMods.push({
name: modDir,
title: config.title || modDir,
description: config.description || '',
author: config.author || 'Local',
type: 'mod',
location: 'global',
source: 'local',
path: modPath,
lastModified: stats.mtime
});
}
} catch (modError) {
console.error(`Error reading mod ${modDir}:`, modError);
}
}
} catch (dirError) {
console.warn('Could not read mods directory:', dirError);
}
// Get installed games from all locations (only those NOT already in ContentDB registry)
let localGames = [];
try {
const allInstalledGames = await paths.getInstalledGames();
for (const game of allInstalledGames) {
// Check if this game is already in the ContentDB registry
const isFromContentDB = installedPackages.some(pkg =>
(pkg.name === game.name || pkg.name === game.directoryName) && pkg.package_type === 'game'
);
if (!isFromContentDB) {
localGames.push({
name: game.name,
title: game.title,
description: game.description,
author: game.author || 'Unknown',
type: 'game',
location: 'games',
source: game.isSystemGame ? 'system' : 'local',
path: game.path,
lastModified: null // We don't have this info from the paths util
});
}
}
} catch (dirError) {
console.warn('Could not read games:', dirError);
}
// Combine all content (ContentDB packages already include games)
const allContent = [
...installedPackages.map(pkg => ({ ...pkg, source: 'contentdb' })),
...localMods,
...localGames
];
// Sort by type (games first, then mods, then texture packs) and name
const sortOrder = { game: 1, mod: 2, txp: 3 };
allContent.sort((a, b) => {
const typeA = sortOrder[a.package_type || a.type] || 4;
const typeB = sortOrder[b.package_type || b.type] || 4;
if (typeA !== typeB) return typeA - typeB;
return (a.title || a.name).localeCompare(b.title || b.name);
});
res.render('extensions/index', {
title: 'Extensions',
allContent: allContent,
statistics: {
...statistics,
games: installedPackages.filter(pkg => pkg.package_type === 'game').length + localGames.length,
local_mods: localMods.length
},
currentPage: 'extensions'
});
} catch (error) {
console.error('Error loading extensions:', error);
res.status(500).render('error', {
error: 'Failed to load extensions',
message: error.message
});
}
});
// Install package from URL (same as ContentDB)
router.post('/install-url', async (req, res) => {
try {
const { packageUrl, installLocation, worldName, installDeps } = req.body;
if (!packageUrl) {
return res.status(400).json({
success: false,
error: 'Package URL is required'
});
}
// Parse and validate URL
const parsed = ContentDBUrlParser.parseUrl(packageUrl);
if (!parsed.isValid) {
return res.status(400).json({
success: false,
error: parsed.error || 'Invalid URL format'
});
}
const { author, name } = parsed;
// Get package info to determine type
const packageInfo = await contentdb.getPackage(author, name);
const packageType = packageInfo.type || 'mod';
// Determine target path based on package type
let targetPath;
let locationDescription;
if (packageType === 'game') {
await fs.mkdir(paths.gamesDir, { recursive: true });
targetPath = paths.getGamePath(name);
locationDescription = 'games directory';
} else if (packageType === 'txp') {
await fs.mkdir(paths.texturesDir, { recursive: true });
targetPath = path.join(paths.texturesDir, name);
locationDescription = 'textures directory';
} else {
if (installLocation === 'world') {
if (!worldName) {
return res.status(400).json({
success: false,
error: 'World name is required when installing to specific world'
});
}
if (!paths.isValidWorldName(worldName)) {
return res.status(400).json({
success: false,
error: 'Invalid world name'
});
}
const worldModsPath = paths.getWorldModsPath(worldName);
await fs.mkdir(worldModsPath, { recursive: true });
targetPath = path.join(worldModsPath, name);
locationDescription = `world "${worldName}"`;
} else {
await fs.mkdir(paths.modsDir, { recursive: true });
targetPath = path.join(paths.modsDir, name);
locationDescription = 'global directory';
}
}
// Check if already installed
let installLocationKey;
if (packageType === 'game') {
installLocationKey = 'games';
} else if (packageType === 'txp') {
installLocationKey = 'textures';
} else {
installLocationKey = installLocation === 'world' ? `world:${worldName}` : 'global';
}
const isInstalled = await packageRegistry.isPackageInstalled(author, name, installLocationKey);
if (isInstalled) {
return res.status(409).json({
success: false,
error: `Package "${name}" is already installed in ${locationDescription}`
});
}
// Install the package
let installResult;
if (installDeps === 'on' && packageType === 'mod') {
const basePath = installLocation === 'world'
? paths.getWorldModsPath(worldName)
: paths.modsDir;
installResult = await contentdb.installPackageWithDeps(author, name, basePath, true);
if (installResult.errors && installResult.errors.length > 0) {
console.warn('Installation completed with errors:', installResult.errors);
}
} else {
installResult = await contentdb.downloadPackage(author, name, targetPath);
}
// Record installation in registry
try {
const packageInfo = installResult.main ? installResult.main.package : installResult.package;
const releaseInfo = installResult.main ? installResult.main.release : installResult.release;
await packageRegistry.recordInstallation({
author: author,
name: name,
version: releaseInfo?.title || 'latest',
releaseId: releaseInfo?.id,
installLocation: installLocationKey,
installPath: targetPath,
contentdbUrl: parsed.fullUrl,
packageType: packageInfo?.type || 'mod',
title: packageInfo?.title || name,
shortDescription: packageInfo?.short_description || '',
dependencies: packageInfo?.hard_dependencies || []
});
// Record dependencies if installed
if (installDeps === 'on' && installResult.dependencies) {
for (const dep of installResult.dependencies) {
const depInfo = dep.package;
const depRelease = dep.release;
const depPath = path.join(
installLocation === 'world' ? paths.getWorldModsPath(worldName) : paths.modsDir,
depInfo.name
);
await packageRegistry.recordInstallation({
author: depInfo.author,
name: depInfo.name,
version: depRelease?.title || 'latest',
releaseId: depRelease?.id,
installLocation: installLocationKey,
installPath: depPath,
contentdbUrl: `https://content.luanti.org/packages/${depInfo.author}/${depInfo.name}/`,
packageType: depInfo.type || 'mod',
title: depInfo.title || depInfo.name,
shortDescription: depInfo.short_description || '',
dependencies: depInfo.hard_dependencies || []
});
}
}
} catch (registryError) {
console.warn('Failed to record installation in registry:', registryError);
}
// Create success response
let message = `Successfully installed "${name}" to ${locationDescription}`;
if (installDeps === 'on' && installResult.dependencies) {
const depCount = installResult.dependencies.length;
if (depCount > 0) {
message += ` with ${depCount} dependenc${depCount === 1 ? 'y' : 'ies'}`;
}
}
res.json({
success: true,
message: message,
package: {
author: author,
name: name,
location: locationDescription
},
installResult: installResult
});
} catch (error) {
console.error('Error installing package from URL:', error);
res.status(500).json({
success: false,
error: 'Installation failed: ' + error.message
});
}
});
// API endpoint for search (AJAX)
router.get('/api/search', async (req, res) => {
try {
const {
q = '',
type = '',
sort = 'score',
order = 'desc',
limit = '10'
} = req.query;
const packages = await contentdb.searchPackages(q, type, sort, order, parseInt(limit), 0);
res.json({
packages: packages || [],
query: q,
type: type
});
} catch (error) {
console.error('Error searching ContentDB:', error);
res.status(500).json({ error: error.message });
}
});
module.exports = router;
+318
View File
@@ -0,0 +1,318 @@
const express = require('express');
const fs = require('fs').promises;
const path = require('path');
const paths = require('../utils/paths');
const ConfigParser = require('../utils/config-parser');
const router = express.Router();
// Mods listing page
router.get('/', async (req, res) => {
try {
paths.ensureDirectories();
let globalMods = [];
let worlds = [];
// Get global mods
try {
const modDirs = await fs.readdir(paths.modsDir);
for (const modDir of modDirs) {
try {
const modPath = paths.getModPath(modDir);
const configPath = paths.getModConfigPath(modDir);
const stats = await fs.stat(modPath);
if (!stats.isDirectory()) continue;
const config = await ConfigParser.parseModConfig(configPath);
globalMods.push({
name: modDir,
title: config.title || modDir,
description: config.description || '',
author: config.author || '',
depends: config.depends || [],
optional_depends: config.optional_depends || [],
min_minetest_version: config.min_minetest_version || '',
max_minetest_version: config.max_minetest_version || '',
location: 'global',
path: modPath,
lastModified: stats.mtime
});
} catch (modError) {
console.error(`Error reading mod ${modDir}:`, modError);
}
}
} catch (dirError) {}
// Get worlds for dropdown
try {
const worldDirs = await fs.readdir(paths.worldsDir);
for (const worldDir of worldDirs) {
try {
const worldPath = paths.getWorldPath(worldDir);
const configPath = paths.getWorldConfigPath(worldDir);
const stats = await fs.stat(worldPath);
if (stats.isDirectory()) {
const config = await ConfigParser.parseWorldConfig(configPath);
worlds.push({
name: worldDir,
displayName: config.server_name || worldDir
});
}
} catch {}
}
} catch {}
const selectedWorld = req.query.world;
let worldMods = [];
if (selectedWorld && paths.isValidWorldName(selectedWorld)) {
try {
const worldModsPath = paths.getWorldModsPath(selectedWorld);
const modDirs = await fs.readdir(worldModsPath);
for (const modDir of modDirs) {
try {
const modPath = path.join(worldModsPath, modDir);
const configPath = path.join(modPath, 'mod.conf');
const stats = await fs.stat(modPath);
if (!stats.isDirectory()) continue;
const config = await ConfigParser.parseModConfig(configPath);
worldMods.push({
name: modDir,
title: config.title || modDir,
description: config.description || '',
author: config.author || '',
depends: config.depends || [],
optional_depends: config.optional_depends || [],
location: 'world',
enabled: true,
path: modPath,
lastModified: stats.mtime
});
} catch (modError) {
console.error(`Error reading world mod ${modDir}:`, modError);
}
}
} catch (dirError) {}
}
res.render('mods/index', {
title: 'Mod Management',
globalMods: globalMods,
worldMods: worldMods,
worlds: worlds,
selectedWorld: selectedWorld,
currentPage: 'mods'
});
} catch (error) {
console.error('Error getting mods:', error);
res.status(500).render('error', {
error: 'Failed to load mods',
message: error.message
});
}
});
// Install mod to world
router.post('/install/:worldName/:modName', async (req, res) => {
try {
const { worldName, modName } = req.params;
if (!paths.isValidWorldName(worldName) || !paths.isValidModName(modName)) {
return res.status(400).json({ error: 'Invalid world or mod name' });
}
const worldPath = paths.getWorldPath(worldName);
const globalModPath = paths.getModPath(modName);
const worldModsPath = paths.getWorldModsPath(worldName);
const targetModPath = path.join(worldModsPath, modName);
try {
await fs.access(worldPath);
} catch {
return res.status(404).json({ error: 'World not found' });
}
try {
await fs.access(globalModPath);
} catch {
return res.status(404).json({ error: 'Mod not found' });
}
try {
await fs.access(targetModPath);
return res.status(409).json({ error: 'Mod already installed in world' });
} catch {}
await fs.mkdir(worldModsPath, { recursive: true });
await fs.cp(globalModPath, targetModPath, { recursive: true });
res.redirect(`/mods?world=${worldName}&installed=${modName}`);
} catch (error) {
console.error('Error installing mod to world:', error);
res.status(500).json({ error: 'Failed to install mod to world' });
}
});
// Remove mod from world
router.post('/remove/:worldName/:modName', async (req, res) => {
try {
const { worldName, modName } = req.params;
if (!paths.isValidWorldName(worldName) || !paths.isValidModName(modName)) {
return res.status(400).json({ error: 'Invalid world or mod name' });
}
const worldModsPath = paths.getWorldModsPath(worldName);
const modPath = path.join(worldModsPath, modName);
try {
await fs.access(modPath);
} catch {
return res.status(404).json({ error: 'Mod not found in world' });
}
await fs.rm(modPath, { recursive: true, force: true });
res.redirect(`/mods?world=${worldName}&removed=${modName}`);
} catch (error) {
console.error('Error removing mod from world:', error);
res.status(500).json({ error: 'Failed to remove mod from world' });
}
});
// Delete global mod
router.post('/delete/:modName', async (req, res) => {
try {
const { modName } = req.params;
if (!paths.isValidModName(modName)) {
return res.status(400).json({ error: 'Invalid mod name' });
}
const modPath = paths.getModPath(modName);
try {
await fs.access(modPath);
} catch {
return res.status(404).json({ error: 'Mod not found' });
}
await fs.rm(modPath, { recursive: true, force: true });
res.redirect(`/mods?deleted=${modName}`);
} catch (error) {
console.error('Error deleting mod:', error);
res.status(500).json({ error: 'Failed to delete mod' });
}
});
// Mod details page
router.get('/:modName', async (req, res) => {
try {
const { modName } = req.params;
if (!paths.isValidModName(modName)) {
return res.status(400).render('error', {
error: 'Invalid mod name'
});
}
const modPath = paths.getModPath(modName);
const configPath = paths.getModConfigPath(modName);
try {
await fs.access(modPath);
} catch {
return res.status(404).render('error', {
error: 'Mod not found'
});
}
const config = await ConfigParser.parseModConfig(configPath);
const stats = await fs.stat(modPath);
// Get mod files info
let fileCount = 0;
let totalSize = 0;
async function countFiles(dirPath) {
try {
const items = await fs.readdir(dirPath);
for (const item of items) {
const itemPath = path.join(dirPath, item);
const itemStats = await fs.stat(itemPath);
if (itemStats.isDirectory()) {
await countFiles(itemPath);
} else {
fileCount++;
totalSize += itemStats.size;
}
}
} catch {}
}
await countFiles(modPath);
// Get worlds where this mod is installed
const installedWorlds = [];
try {
const worldDirs = await fs.readdir(paths.worldsDir);
for (const worldDir of worldDirs) {
try {
const worldModPath = path.join(paths.getWorldModsPath(worldDir), modName);
await fs.access(worldModPath);
const worldConfigPath = paths.getWorldConfigPath(worldDir);
const worldConfig = await ConfigParser.parseWorldConfig(worldConfigPath);
installedWorlds.push({
name: worldDir,
displayName: worldConfig.server_name || worldDir
});
} catch {}
}
} catch {}
const modDetails = {
name: modName,
title: config.title || modName,
description: config.description || '',
author: config.author || '',
depends: config.depends || [],
optional_depends: config.optional_depends || [],
min_minetest_version: config.min_minetest_version || '',
max_minetest_version: config.max_minetest_version || '',
location: 'global',
path: modPath,
fileCount,
totalSize,
created: stats.birthtime,
lastModified: stats.mtime,
installedWorlds: installedWorlds,
config: config
};
res.render('mods/details', {
title: `Mod: ${modDetails.title}`,
mod: modDetails,
currentPage: 'mods'
});
} catch (error) {
console.error('Error getting mod details:', error);
res.status(500).render('error', {
error: 'Failed to load mod details',
message: error.message
});
}
});
module.exports = router;
+473
View File
@@ -0,0 +1,473 @@
const express = require('express');
const fs = require('fs').promises;
const { spawn } = require('child_process');
const chokidar = require('chokidar');
const paths = require('../utils/paths');
const ConfigParser = require('../utils/config-parser');
const router = express.Router();
// Security function to validate configuration overrides
function validateConfigOverrides(configOverrides) {
if (!configOverrides || typeof configOverrides !== 'object') {
return {};
}
const sanitized = {};
// Whitelist of allowed configuration parameters
const allowedConfigKeys = [
'port', 'bind', 'name', 'motd', 'max_users', 'password', 'default_game',
'enable_damage', 'creative_mode', 'enable_rollback_recording', 'disallow_empty_password',
'server_announce', 'serverlist_url', 'enable_pvp', 'time_speed', 'day_night_ratio',
'max_simultaneous_block_sends_per_client', 'max_block_send_distance',
'max_block_generate_distance', 'secure', 'enable_client_modding', 'csm_restriction_flags',
'csm_restriction_noderange', 'player_transfer_distance', 'max_packets_per_iteration',
'dedicated_server_step', 'ignore_world_load_errors', 'remote_media'
];
for (const [key, value] of Object.entries(configOverrides)) {
// Validate key
if (!allowedConfigKeys.includes(key) || !/^[a-z_]+$/.test(key)) {
continue; // Skip invalid keys
}
// Validate and sanitize value
let sanitizedValue = String(value).trim();
// Remove control characters
sanitizedValue = sanitizedValue.replace(/[\x00-\x1F\x7F]/g, '');
// Limit length
if (sanitizedValue.length > 200) {
continue; // Skip overly long values
}
// Type-specific validation
if (['port', 'max_users', 'time_speed', 'max_simultaneous_block_sends_per_client',
'max_block_send_distance', 'max_block_generate_distance', 'csm_restriction_noderange',
'player_transfer_distance', 'max_packets_per_iteration', 'dedicated_server_step'].includes(key)) {
const numValue = parseInt(sanitizedValue, 10);
if (!isNaN(numValue) && numValue >= 0 && numValue <= 65535) {
sanitized[key] = numValue.toString();
}
} else if (['enable_damage', 'creative_mode', 'enable_rollback_recording', 'disallow_empty_password',
'server_announce', 'enable_pvp', 'secure', 'enable_client_modding', 'ignore_world_load_errors'].includes(key)) {
if (['true', 'false'].includes(sanitizedValue.toLowerCase())) {
sanitized[key] = sanitizedValue.toLowerCase();
}
} else if (['bind', 'name', 'motd', 'password', 'default_game', 'serverlist_url'].includes(key)) {
// String values - ensure they don't contain shell metacharacters
if (!/[;&|`$(){}[\]<>\\]/.test(sanitizedValue)) {
sanitized[key] = sanitizedValue;
}
} else {
// Floating point values
const floatValue = parseFloat(sanitizedValue);
if (!isNaN(floatValue) && isFinite(floatValue)) {
sanitized[key] = floatValue.toString();
}
}
}
return sanitized;
}
// Global server state
let serverProcess = null;
let serverStatus = 'stopped';
let serverLogs = [];
let logWatcher = null;
// Server management page
router.get('/', async (req, res) => {
try {
paths.ensureDirectories();
// Get available worlds for dropdown
let worlds = [];
try {
const worldDirs = await fs.readdir(paths.worldsDir);
for (const worldDir of worldDirs) {
try {
const worldPath = paths.getWorldPath(worldDir);
const configPath = paths.getWorldConfigPath(worldDir);
const stats = await fs.stat(worldPath);
if (stats.isDirectory()) {
const config = await ConfigParser.parseWorldConfig(configPath);
worlds.push({
name: worldDir,
displayName: config.server_name || worldDir
});
}
} catch {}
}
} catch {}
// Get recent logs
let recentLogs = [];
try {
const logContent = await fs.readFile(paths.debugFile, 'utf8');
const lines = logContent.split('\n').filter(line => line.trim());
recentLogs = lines.slice(-50); // Last 50 lines
} catch {
// Debug file might not exist
}
const serverInfo = {
status: serverStatus,
pid: serverProcess ? serverProcess.pid : null,
uptime: serverProcess ? Date.now() - serverProcess.startTime : 0,
logs: [...recentLogs, ...serverLogs.map(log => log.message || log)].slice(-100)
};
res.render('server/index', {
title: 'Server Management',
server: serverInfo,
worlds: worlds,
currentPage: 'server',
scripts: ['server.js']
});
} catch (error) {
console.error('Error loading server page:', error);
res.status(500).render('error', {
error: 'Failed to load server management',
message: error.message
});
}
});
// Get server status (API)
router.get('/api/status', (req, res) => {
res.json({
status: serverStatus,
pid: serverProcess ? serverProcess.pid : null,
uptime: serverProcess ? Date.now() - serverProcess.startTime : 0
});
});
// Get server logs (API)
router.get('/api/logs', async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 100;
const offset = parseInt(req.query.offset) || 0;
let fileLogs = [];
try {
const logContent = await fs.readFile(paths.debugFile, 'utf8');
const lines = logContent.split('\n').filter(line => line.trim());
fileLogs = lines.slice(-1000);
} catch {}
const allLogs = [...fileLogs, ...serverLogs.map(log => log.message || log)];
const paginatedLogs = allLogs.slice(offset, offset + limit);
res.json({
logs: paginatedLogs,
total: allLogs.length,
offset,
limit
});
} catch (error) {
console.error('Error getting logs:', error);
res.status(500).json({ error: 'Failed to get logs' });
}
});
// Start server
router.post('/start', async (req, res) => {
try {
if (serverProcess && serverStatus === 'running') {
return res.status(409).json({ error: 'Server is already running' });
}
const { worldName, configOverrides } = req.body;
if (!worldName || !paths.isValidWorldName(worldName)) {
if (req.headers.accept && req.headers.accept.includes('application/json')) {
return res.status(400).json({ error: 'Valid world name required' });
} else {
return res.redirect('/server?error=Valid+world+name+required');
}
}
const worldPath = paths.getWorldPath(worldName);
try {
await fs.access(worldPath);
} catch {
if (req.headers.accept && req.headers.accept.includes('application/json')) {
return res.status(404).json({ error: 'World not found' });
} else {
return res.redirect('/server?error=World+not+found');
}
}
const args = [
'--server',
'--world', worldPath,
'--logfile', paths.debugFile
];
if (configOverrides) {
const sanitizedOverrides = validateConfigOverrides(configOverrides);
for (const [key, value] of Object.entries(sanitizedOverrides)) {
args.push(`--${key}`, value);
}
}
serverProcess = spawn('luanti', args, {
stdio: ['pipe', 'pipe', 'pipe'],
detached: false
});
serverProcess.startTime = Date.now();
serverStatus = 'starting';
serverLogs = [];
// Get Socket.IO instance from main app
const { io } = require('../app');
serverProcess.stdout.on('data', (data) => {
const logLine = data.toString().trim();
if (logLine) {
const logEntry = {
timestamp: new Date().toISOString(),
level: 'info',
message: logLine
};
serverLogs.push(logEntry);
if (serverLogs.length > 1000) {
serverLogs = serverLogs.slice(-1000);
}
if (io) {
io.emit('serverLog', logEntry);
}
}
});
serverProcess.stderr.on('data', (data) => {
const logLine = data.toString().trim();
if (logLine) {
const logEntry = {
timestamp: new Date().toISOString(),
level: 'error',
message: logLine
};
serverLogs.push(logEntry);
if (serverLogs.length > 1000) {
serverLogs = serverLogs.slice(-1000);
}
if (io) {
io.emit('serverLog', logEntry);
}
}
});
serverProcess.on('spawn', () => {
serverStatus = 'running';
console.log('Luanti server started');
if (io) {
io.emit('serverStatus', {
status: serverStatus,
pid: serverProcess.pid,
uptime: Date.now() - serverProcess.startTime
});
}
});
serverProcess.on('error', (error) => {
console.error('Server error:', error);
serverStatus = 'error';
const logEntry = {
timestamp: new Date().toISOString(),
level: 'error',
message: `Server error: ${error.message}`
};
serverLogs.push(logEntry);
if (io) {
io.emit('serverLog', logEntry);
io.emit('serverStatus', {
status: serverStatus,
pid: null,
uptime: 0
});
}
});
serverProcess.on('exit', (code, signal) => {
console.log(`Server exited with code ${code}, signal ${signal}`);
serverStatus = 'stopped';
const logEntry = {
timestamp: new Date().toISOString(),
level: 'info',
message: `Server stopped (code: ${code}, signal: ${signal})`
};
serverLogs.push(logEntry);
serverProcess = null;
if (io) {
io.emit('serverLog', logEntry);
io.emit('serverStatus', {
status: serverStatus,
pid: null,
uptime: 0
});
}
});
// Watch debug log file
if (logWatcher) {
logWatcher.close();
}
logWatcher = chokidar.watch(paths.debugFile, { persistent: true });
logWatcher.on('change', async () => {
try {
const logContent = await fs.readFile(paths.debugFile, 'utf8');
const lines = logContent.split('\n');
const newLines = lines.slice(-10);
for (const line of newLines) {
if (line.trim() && !serverLogs.some(log => (log.message || log) === line.trim())) {
const logEntry = {
timestamp: new Date().toISOString(),
level: 'info',
message: line.trim()
};
serverLogs.push(logEntry);
if (io) {
io.emit('serverLog', logEntry);
}
}
}
if (serverLogs.length > 1000) {
serverLogs = serverLogs.slice(-1000);
}
} catch {}
});
if (req.headers.accept && req.headers.accept.includes('application/json')) {
res.json({ message: 'Server starting', pid: serverProcess.pid });
} else {
res.redirect('/server?started=true');
}
} catch (error) {
console.error('Error starting server:', error);
if (req.headers.accept && req.headers.accept.includes('application/json')) {
res.status(500).json({ error: 'Failed to start server' });
} else {
res.redirect(`/server?error=${encodeURIComponent(error.message)}`);
}
}
});
// Stop server
router.post('/stop', (req, res) => {
try {
if (!serverProcess || serverStatus !== 'running') {
if (req.headers.accept && req.headers.accept.includes('application/json')) {
return res.status(409).json({ error: 'Server is not running' });
} else {
return res.redirect('/server?error=Server+is+not+running');
}
}
serverStatus = 'stopping';
serverProcess.kill('SIGTERM');
setTimeout(() => {
if (serverProcess && serverStatus === 'stopping') {
serverProcess.kill('SIGKILL');
}
}, 10000);
if (logWatcher) {
logWatcher.close();
logWatcher = null;
}
const { io } = require('../app');
if (io) {
io.emit('serverStatus', {
status: serverStatus,
pid: serverProcess ? serverProcess.pid : null,
uptime: serverProcess ? Date.now() - serverProcess.startTime : 0
});
}
if (req.headers.accept && req.headers.accept.includes('application/json')) {
res.json({ message: 'Server stopping' });
} else {
res.redirect('/server?stopped=true');
}
} catch (error) {
console.error('Error stopping server:', error);
if (req.headers.accept && req.headers.accept.includes('application/json')) {
res.status(500).json({ error: 'Failed to stop server' });
} else {
res.redirect(`/server?error=${encodeURIComponent(error.message)}`);
}
}
});
// Send command to server
router.post('/command', (req, res) => {
try {
if (!serverProcess || serverStatus !== 'running') {
return res.status(409).json({ error: 'Server is not running' });
}
const { command } = req.body;
if (!command) {
return res.status(400).json({ error: 'Command required' });
}
// Validate and sanitize the command using ServerManager's validation
const ServerManager = require('../utils/server-manager');
const serverManager = new ServerManager();
try {
const sanitizedCommand = serverManager.validateServerCommand(command);
serverProcess.stdin.write(sanitizedCommand + '\n');
const logEntry = {
timestamp: new Date().toISOString(),
level: 'command',
message: `> ${sanitizedCommand}`
};
serverLogs.push(logEntry);
const { io } = require('../app');
if (io) {
io.emit('serverLog', logEntry);
}
res.json({ message: 'Command sent successfully' });
} catch (validationError) {
return res.status(400).json({ error: validationError.message });
}
} catch (error) {
console.error('Error sending command:', error);
res.status(500).json({ error: 'Failed to send command' });
}
});
// Export server state for use in main app
router.getServerState = () => ({
process: serverProcess,
status: serverStatus,
logs: serverLogs
});
module.exports = router;
+118
View File
@@ -0,0 +1,118 @@
const express = require('express');
const AuthManager = require('../utils/auth');
const { requireAuth } = require('../middleware/auth');
const router = express.Router();
const authManager = new AuthManager();
// Initialize auth manager
authManager.initialize().catch(console.error);
// All routes require authentication
router.use(requireAuth);
// User management page
router.get('/', async (req, res) => {
try {
const users = await authManager.getAllUsers();
res.render('users/index', {
title: 'User Management',
users: users,
currentPage: 'users'
});
} catch (error) {
console.error('Error getting users:', error);
res.status(500).render('error', {
error: 'Failed to load users',
message: error.message
});
}
});
// Create new user page
router.get('/new', (req, res) => {
res.render('users/new', {
title: 'Create New User',
currentPage: 'users'
});
});
// Process user creation
router.post('/create', async (req, res) => {
try {
const { username, password, confirmPassword } = req.body;
const createdByUserId = req.session.user.id;
// Validate inputs
if (!username || !password || !confirmPassword) {
return res.render('users/new', {
title: 'Create New User',
error: 'All fields are required',
currentPage: 'users',
formData: { username }
});
}
if (password !== confirmPassword) {
return res.render('users/new', {
title: 'Create New User',
error: 'Passwords do not match',
currentPage: 'users',
formData: { username }
});
}
const user = await authManager.createUser(username, password, createdByUserId);
res.redirect('/users?created=' + encodeURIComponent(username));
} catch (error) {
console.error('User creation error:', error);
res.render('users/new', {
title: 'Create New User',
error: error.message,
currentPage: 'users',
formData: {
username: req.body.username
}
});
}
});
// Delete user
router.post('/delete/:userId', async (req, res) => {
try {
const { userId } = req.params;
const currentUserId = req.session.user.id;
// Prevent self-deletion
if (parseInt(userId) === currentUserId) {
return res.status(400).json({ error: 'Cannot delete your own account' });
}
const deleted = await authManager.deleteUser(userId);
if (deleted) {
if (req.headers.accept && req.headers.accept.includes('application/json')) {
res.json({ message: 'User deleted successfully' });
} else {
res.redirect('/users?deleted=true');
}
} else {
res.status(404).json({ error: 'User not found' });
}
} catch (error) {
console.error('Error deleting user:', error);
if (req.headers.accept && req.headers.accept.includes('application/json')) {
res.status(500).json({ error: 'Failed to delete user' });
} else {
res.redirect('/users?error=' + encodeURIComponent(error.message));
}
}
});
module.exports = router;
+411
View File
@@ -0,0 +1,411 @@
const express = require('express');
const fs = require('fs').promises;
const path = require('path');
const sqlite3 = require('sqlite3');
const { promisify } = require('util');
const paths = require('../utils/paths');
const ConfigParser = require('../utils/config-parser');
const router = express.Router();
// Worlds listing page
router.get('/', async (req, res) => {
try {
paths.ensureDirectories();
let worlds = [];
try {
const worldDirs = await fs.readdir(paths.worldsDir);
for (const worldDir of worldDirs) {
const worldPath = paths.getWorldPath(worldDir);
const configPath = paths.getWorldConfigPath(worldDir);
try {
const stats = await fs.stat(worldPath);
if (!stats.isDirectory()) continue;
const config = await ConfigParser.parseWorldConfig(configPath);
let playerCount = 0;
try {
const playersDbPath = path.join(worldPath, 'players.sqlite');
const db = new sqlite3.Database(playersDbPath);
const all = promisify(db.all.bind(db));
const result = await all('SELECT COUNT(*) as count FROM players');
playerCount = result[0]?.count || 0;
db.close();
} catch (dbError) {}
worlds.push({
name: worldDir,
displayName: config.server_name || worldDir,
description: config.server_description || '',
gameid: config.gameid || 'minetest_game',
creativeMode: config.creative_mode || false,
enableDamage: config.enable_damage !== false,
enablePvp: config.enable_pvp !== false,
playerCount,
lastModified: stats.mtime,
size: stats.size
});
} catch (worldError) {
console.error(`Error reading world ${worldDir}:`, worldError);
}
}
} catch (dirError) {}
res.render('worlds/index', {
title: 'Worlds',
worlds: worlds,
currentPage: 'worlds'
});
} catch (error) {
console.error('Error getting worlds:', error);
res.status(500).render('error', {
error: 'Failed to load worlds',
message: error.message
});
}
});
// New world page
router.get('/new', async (req, res) => {
try {
const games = await paths.getInstalledGames();
res.render('worlds/new', {
title: 'Create World',
currentPage: 'worlds',
games: games
});
} catch (error) {
console.error('Error getting games for new world:', error);
res.render('worlds/new', {
title: 'Create World',
currentPage: 'worlds',
games: [
{ name: 'minetest_game', title: 'Minetest Game (Default)', description: '' },
{ name: 'minimal', title: 'Minimal', description: '' }
],
error: 'Could not load installed games, showing defaults only.'
});
}
});
// Create world
router.post('/create', async (req, res) => {
console.log('=== WORLD CREATION STARTED ===');
console.log('Request body:', req.body);
try {
const { name, gameid } = req.body;
console.log('Extracted name:', name, 'gameid:', gameid);
if (!paths.isValidWorldName(name)) {
return res.status(400).render('worlds/new', {
title: 'Create World',
currentPage: 'worlds',
error: 'Invalid world name. Only letters, numbers, underscore and hyphen allowed.',
formData: req.body
});
}
const worldPath = paths.getWorldPath(name);
try {
await fs.access(worldPath);
return res.status(409).render('worlds/new', {
title: 'Create World',
currentPage: 'worlds',
error: 'World already exists',
formData: req.body
});
} catch {}
console.log('Starting world creation for:', name, 'with gameid:', gameid);
// Create the world directory - Luanti will initialize it when the server starts
await fs.mkdir(worldPath, { recursive: true });
console.log('Created world directory:', worldPath);
// Create a basic world.mt file with the correct game ID
const worldConfig = `enable_damage = true
creative_mode = false
mod_storage_backend = sqlite3
auth_backend = sqlite3
player_backend = sqlite3
backend = sqlite3
gameid = ${gameid || 'minetest_game'}
world_name = ${name}
`;
const worldConfigPath = path.join(worldPath, 'world.mt');
await fs.writeFile(worldConfigPath, worldConfig, 'utf8');
console.log('Created world.mt with gameid:', gameid || 'minetest_game');
// Create essential database files with proper schema
const sqlite3 = require('sqlite3');
// Create players database with correct schema
const playersDbPath = path.join(worldPath, 'players.sqlite');
await new Promise((resolve, reject) => {
const playersDb = new sqlite3.Database(playersDbPath, (err) => {
if (err) reject(err);
else {
playersDb.serialize(() => {
playersDb.exec(`CREATE TABLE IF NOT EXISTS player (
name TEXT PRIMARY KEY,
pitch REAL,
yaw REAL,
posX REAL,
posY REAL,
posZ REAL,
hp INTEGER,
breath INTEGER,
creation_date INTEGER,
modification_date INTEGER,
privs TEXT
)`, (err) => {
if (err) {
console.error('Error creating player table:', err);
reject(err);
} else {
console.log('Created player table in players.sqlite');
playersDb.close((closeErr) => {
if (closeErr) reject(closeErr);
else resolve();
});
}
});
});
}
});
});
// Create other essential databases
const mapDbPath = path.join(worldPath, 'map.sqlite');
await new Promise((resolve, reject) => {
const mapDb = new sqlite3.Database(mapDbPath, (err) => {
if (err) reject(err);
else {
mapDb.serialize(() => {
mapDb.exec(`CREATE TABLE IF NOT EXISTS blocks (
x INTEGER,
y INTEGER,
z INTEGER,
data BLOB NOT NULL,
PRIMARY KEY (x, z, y)
)`, (err) => {
if (err) {
console.error('Error creating blocks table:', err);
reject(err);
} else {
console.log('Created blocks table in map.sqlite');
mapDb.close((closeErr) => {
if (closeErr) reject(closeErr);
else resolve();
});
}
});
});
}
});
});
const modStorageDbPath = path.join(worldPath, 'mod_storage.sqlite');
await new Promise((resolve, reject) => {
const modDb = new sqlite3.Database(modStorageDbPath, (err) => {
if (err) reject(err);
else {
modDb.serialize(() => {
modDb.exec(`CREATE TABLE IF NOT EXISTS entries (
modname TEXT NOT NULL,
key BLOB NOT NULL,
value BLOB NOT NULL,
PRIMARY KEY (modname, key)
)`, (err) => {
if (err) {
console.error('Error creating entries table:', err);
reject(err);
} else {
console.log('Created entries table in mod_storage.sqlite');
modDb.close((closeErr) => {
if (closeErr) reject(closeErr);
else resolve();
});
}
});
});
}
});
});
console.log('Created essential database files with proper schema');
res.redirect('/worlds?created=' + encodeURIComponent(name));
} catch (error) {
console.error('Error creating world:', error);
res.status(500).render('worlds/new', {
title: 'Create World',
currentPage: 'worlds',
error: 'Failed to create world: ' + error.message,
formData: req.body
});
}
});
// World details page
router.get('/:worldName', async (req, res) => {
try {
const { worldName } = req.params;
if (!paths.isValidWorldName(worldName)) {
return res.status(400).render('error', {
error: 'Invalid world name'
});
}
const worldPath = paths.getWorldPath(worldName);
const configPath = paths.getWorldConfigPath(worldName);
try {
await fs.access(worldPath);
} catch {
return res.status(404).render('error', {
error: 'World not found'
});
}
const config = await ConfigParser.parseWorldConfig(configPath);
const stats = await fs.stat(worldPath);
let worldSize = 0;
try {
const mapDbPath = path.join(worldPath, 'map.sqlite');
const mapStats = await fs.stat(mapDbPath);
worldSize = mapStats.size;
} catch {}
let enabledMods = [];
try {
const worldModsPath = paths.getWorldModsPath(worldName);
const modDirs = await fs.readdir(worldModsPath);
for (const modDir of modDirs) {
const modConfigPath = path.join(worldModsPath, modDir, 'mod.conf');
try {
const modConfig = await ConfigParser.parseModConfig(modConfigPath);
enabledMods.push({
name: modDir,
title: modConfig.title || modDir,
description: modConfig.description || '',
author: modConfig.author || '',
location: 'world'
});
} catch {}
}
} catch {}
const worldDetails = {
name: worldName,
displayName: config.server_name || worldName,
description: config.server_description || '',
gameid: config.gameid || 'minetest_game',
creativeMode: config.creative_mode || false,
enableDamage: config.enable_damage !== false,
enablePvp: config.enable_pvp !== false,
serverAnnounce: config.server_announce || false,
worldSize,
created: stats.birthtime,
lastModified: stats.mtime,
enabledMods,
config: config
};
res.render('worlds/details', {
title: `World: ${worldDetails.displayName}`,
world: worldDetails,
currentPage: 'worlds'
});
} catch (error) {
console.error('Error getting world details:', error);
res.status(500).render('error', {
error: 'Failed to load world details',
message: error.message
});
}
});
// Update world
router.post('/:worldName/update', async (req, res) => {
try {
const { worldName } = req.params;
const updates = req.body;
if (!paths.isValidWorldName(worldName)) {
return res.status(400).json({ error: 'Invalid world name' });
}
const worldPath = paths.getWorldPath(worldName);
const configPath = paths.getWorldConfigPath(worldName);
try {
await fs.access(worldPath);
} catch {
return res.status(404).json({ error: 'World not found' });
}
const currentConfig = await ConfigParser.parseWorldConfig(configPath);
// Convert form data
const updatedConfig = {
...currentConfig,
server_name: updates.displayName || currentConfig.server_name,
server_description: updates.description || currentConfig.server_description,
creative_mode: updates.creativeMode === 'on',
enable_damage: updates.enableDamage !== 'off',
enable_pvp: updates.enablePvp !== 'off',
server_announce: updates.serverAnnounce === 'on'
};
await ConfigParser.writeWorldConfig(configPath, updatedConfig);
res.redirect(`/worlds/${worldName}?updated=true`);
} catch (error) {
console.error('Error updating world:', error);
res.status(500).json({ error: 'Failed to update world' });
}
});
// Delete world
router.post('/:worldName/delete', async (req, res) => {
try {
const { worldName } = req.params;
if (!paths.isValidWorldName(worldName)) {
return res.status(400).json({ error: 'Invalid world name' });
}
const worldPath = paths.getWorldPath(worldName);
try {
await fs.access(worldPath);
} catch {
return res.status(404).json({ error: 'World not found' });
}
// Deletion confirmed by frontend confirmation dialog
await fs.rm(worldPath, { recursive: true, force: true });
res.redirect('/worlds?deleted=' + encodeURIComponent(worldName));
} catch (error) {
console.error('Error deleting world:', error);
res.status(500).json({ error: 'Failed to delete world' });
}
});
module.exports = router;
+127
View File
@@ -0,0 +1,127 @@
const fs = require('fs').promises;
const fsSync = require('fs');
const path = require('path');
const os = require('os');
class AppConfig {
constructor() {
this.configDir = path.join(os.homedir(), '.luhost');
this.configFile = path.join(this.configDir, 'config.json');
this.defaultConfig = {
dataDirectory: this.getDefaultDataDirectory(),
serverPort: 3000,
debugMode: false
};
this.config = null;
}
getDefaultDataDirectory() {
const homeDir = os.homedir();
const possibleDirs = [
path.join(homeDir, '.luanti'),
path.join(homeDir, '.minetest')
];
// Use the first one that exists, or default to .minetest
for (const dir of possibleDirs) {
if (fsSync.existsSync(dir)) {
return dir;
}
}
return path.join(homeDir, '.minetest');
}
async load() {
try {
// Ensure config directory exists
if (!fsSync.existsSync(this.configDir)) {
await fs.mkdir(this.configDir, { recursive: true });
}
// Try to read existing config
try {
const configData = await fs.readFile(this.configFile, 'utf8');
this.config = { ...this.defaultConfig, ...JSON.parse(configData) };
} catch (error) {
if (error.code === 'ENOENT') {
// Config file doesn't exist, create it with defaults
this.config = { ...this.defaultConfig };
await this.save();
} else {
throw error;
}
}
return this.config;
} catch (error) {
console.error('Failed to load app config:', error);
// Fall back to defaults if config loading fails
this.config = { ...this.defaultConfig };
return this.config;
}
}
async save() {
try {
if (!fsSync.existsSync(this.configDir)) {
await fs.mkdir(this.configDir, { recursive: true });
}
await fs.writeFile(this.configFile, JSON.stringify(this.config, null, 2), 'utf8');
} catch (error) {
console.error('Failed to save app config:', error);
throw error;
}
}
get(key) {
return this.config ? this.config[key] : this.defaultConfig[key];
}
set(key, value) {
if (!this.config) {
this.config = { ...this.defaultConfig };
}
this.config[key] = value;
}
async update(updates) {
if (!this.config) {
this.config = { ...this.defaultConfig };
}
Object.assign(this.config, updates);
await this.save();
}
getDataDirectory() {
return this.get('dataDirectory');
}
async setDataDirectory(dataDir) {
const resolvedPath = path.resolve(dataDir);
// Validate that the directory exists or can be created
try {
await fs.access(resolvedPath);
} catch (error) {
if (error.code === 'ENOENT') {
// Try to create the directory
try {
await fs.mkdir(resolvedPath, { recursive: true });
} catch (createError) {
throw new Error(`Cannot create data directory: ${createError.message}`);
}
} else {
throw new Error(`Cannot access data directory: ${error.message}`);
}
}
this.set('dataDirectory', resolvedPath);
await this.save();
return resolvedPath;
}
}
module.exports = new AppConfig();
+288
View File
@@ -0,0 +1,288 @@
const bcrypt = require('bcrypt');
const fs = require('fs').promises;
const path = require('path');
const sqlite3 = require('sqlite3');
const { promisify } = require('util');
class AuthManager {
constructor() {
this.dbPath = path.join(process.cwd(), 'users.db');
this.db = null;
this.saltRounds = 12;
}
async initialize() {
return new Promise((resolve, reject) => {
this.db = new sqlite3.Database(this.dbPath, (err) => {
if (err) {
reject(err);
return;
}
// Create users table if it doesn't exist
this.db.run(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_by INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME,
is_active BOOLEAN DEFAULT 1,
FOREIGN KEY (created_by) REFERENCES users (id)
)
`, (err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
});
}
async createUser(username, password, createdByUserId = null) {
if (!username || !password) {
throw new Error('Username and password are required');
}
// Check if this is not the first user and no creator is specified
const isFirstUser = await this.isFirstUser();
if (!isFirstUser && !createdByUserId) {
throw new Error('Only existing administrators can create new accounts');
}
// Validate username format
if (!/^[a-zA-Z0-9_-]{3,20}$/.test(username)) {
throw new Error('Username must be 3-20 characters, letters, numbers, underscore, or hyphen only');
}
// Validate password strength
if (password.length < 8) {
throw new Error('Password must be at least 8 characters long');
}
try {
const passwordHash = await bcrypt.hash(password, this.saltRounds);
return new Promise((resolve, reject) => {
const stmt = this.db.prepare(`
INSERT INTO users (username, password_hash, created_by)
VALUES (?, ?, ?)
`);
stmt.run([username, passwordHash, createdByUserId], function(err) {
if (err) {
if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') {
reject(new Error('Username already exists'));
} else {
reject(err);
}
return;
}
resolve({
id: this.lastID,
username: username,
created_at: new Date().toISOString()
});
});
stmt.finalize();
});
} catch (error) {
throw new Error('Failed to create user: ' + error.message);
}
}
async authenticateUser(username, password) {
if (!username || !password) {
throw new Error('Username and password are required');
}
return new Promise((resolve, reject) => {
this.db.get(
'SELECT * FROM users WHERE username = ? AND is_active = 1',
[username],
async (err, user) => {
if (err) {
reject(err);
return;
}
if (!user) {
reject(new Error('Invalid username or password'));
return;
}
try {
const passwordMatch = await bcrypt.compare(password, user.password_hash);
if (!passwordMatch) {
reject(new Error('Invalid username or password'));
return;
}
// Update last login
this.db.run(
'UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?',
[user.id]
);
// Return user info (without password hash)
resolve({
id: user.id,
username: user.username,
created_at: user.created_at,
last_login: user.last_login
});
} catch (bcryptError) {
reject(bcryptError);
}
}
);
});
}
async getUserById(id) {
return new Promise((resolve, reject) => {
this.db.get(
'SELECT id, username, created_at, last_login FROM users WHERE id = ? AND is_active = 1',
[id],
(err, user) => {
if (err) {
reject(err);
return;
}
resolve(user || null);
}
);
});
}
async getUserByUsername(username) {
return new Promise((resolve, reject) => {
this.db.get(
'SELECT id, username, created_at, last_login FROM users WHERE username = ? AND is_active = 1',
[username],
(err, user) => {
if (err) {
reject(err);
return;
}
resolve(user || null);
}
);
});
}
async getAllUsers() {
return new Promise((resolve, reject) => {
this.db.all(
'SELECT id, username, created_at, last_login FROM users WHERE is_active = 1 ORDER BY created_at DESC',
[],
(err, users) => {
if (err) {
reject(err);
return;
}
resolve(users || []);
}
);
});
}
async deleteUser(id) {
return new Promise((resolve, reject) => {
this.db.run(
'UPDATE users SET is_active = 0 WHERE id = ?',
[id],
function(err) {
if (err) {
reject(err);
return;
}
resolve(this.changes > 0);
}
);
});
}
async changePassword(id, currentPassword, newPassword) {
if (!currentPassword || !newPassword) {
throw new Error('Current password and new password are required');
}
if (newPassword.length < 8) {
throw new Error('New password must be at least 8 characters long');
}
return new Promise((resolve, reject) => {
this.db.get(
'SELECT password_hash FROM users WHERE id = ? AND is_active = 1',
[id],
async (err, user) => {
if (err) {
reject(err);
return;
}
if (!user) {
reject(new Error('User not found'));
return;
}
try {
const passwordMatch = await bcrypt.compare(currentPassword, user.password_hash);
if (!passwordMatch) {
reject(new Error('Current password is incorrect'));
return;
}
const newPasswordHash = await bcrypt.hash(newPassword, this.saltRounds);
this.db.run(
'UPDATE users SET password_hash = ? WHERE id = ?',
[newPasswordHash, id],
function(err) {
if (err) {
reject(err);
return;
}
resolve(this.changes > 0);
}
);
} catch (bcryptError) {
reject(bcryptError);
}
}
);
});
}
async isFirstUser() {
return new Promise((resolve, reject) => {
this.db.get(
'SELECT COUNT(*) as count FROM users WHERE is_active = 1',
[],
(err, result) => {
if (err) {
reject(err);
return;
}
resolve(result.count === 0);
}
);
});
}
close() {
if (this.db) {
this.db.close();
}
}
}
module.exports = AuthManager;
+442
View File
@@ -0,0 +1,442 @@
const fs = require('fs').promises;
const path = require('path');
const paths = require('./paths');
class ConfigManager {
constructor() {
this.configPath = paths.configFile;
this.configSections = this.getConfigSections();
}
getConfigSections() {
return {
'Server': {
description: 'Basic server settings',
settings: {
'server_name': {
type: 'string',
default: 'Luanti Server',
description: 'Name of the server as displayed in the server list'
},
'server_description': {
type: 'text',
default: 'A Luanti server powered by the web interface',
description: 'Server description shown to players'
},
'port': {
type: 'number',
default: 30000,
min: 1024,
max: 65535,
description: 'Port for the game server'
},
'max_users': {
type: 'number',
default: 15,
min: 1,
max: 1000,
description: 'Maximum number of players'
},
'motd': {
type: 'text',
default: 'Welcome to the server!',
description: 'Message of the day shown to connecting players'
},
'server_announce': {
type: 'boolean',
default: false,
description: 'Announce server to the public server list'
},
'serverlist_url': {
type: 'string',
default: 'servers.minetest.net',
description: 'Server list URL for announcements'
}
}
},
'World': {
description: 'World and gameplay settings',
note: 'Many world settings can also be configured per-world in /worlds',
settings: {
'default_game': {
type: 'string',
default: 'minetest_game',
description: 'Default game/subgame to use for new worlds'
},
'creative_mode': {
type: 'boolean',
default: false,
description: 'Enable creative mode by default'
},
'enable_damage': {
type: 'boolean',
default: true,
description: 'Enable player damage and health'
},
'enable_pvp': {
type: 'boolean',
default: true,
description: 'Enable player vs player combat'
},
'disable_fire': {
type: 'boolean',
default: false,
description: 'Disable fire spreading and burning'
},
'time_speed': {
type: 'number',
default: 72,
min: 1,
max: 1000,
description: 'Time speed (72 = 1 real day = 20 minutes game time)'
}
}
},
'Performance': {
description: 'Server performance and limits',
settings: {
'dedicated_server_step': {
type: 'number',
default: 0.09,
min: 0.01,
max: 1.0,
step: 0.01,
description: 'Time step for dedicated server (seconds)'
},
'max_block_generate_distance': {
type: 'number',
default: 8,
min: 1,
max: 50,
description: 'Maximum distance for generating new blocks'
},
'max_block_send_distance': {
type: 'number',
default: 12,
min: 1,
max: 50,
description: 'Maximum distance for sending blocks to clients'
},
'active_block_range': {
type: 'number',
default: 4,
min: 1,
max: 20,
description: 'Blocks within this distance are kept active'
},
'max_simultaneous_block_sends_per_client': {
type: 'number',
default: 40,
min: 1,
max: 200,
description: 'Max blocks sent to each client per step'
}
}
},
'Security': {
description: 'Security and authentication settings',
settings: {
'disallow_empty_password': {
type: 'boolean',
default: false,
description: 'Require non-empty passwords for players'
},
'enable_rollback_recording': {
type: 'boolean',
default: true,
description: 'Record player actions for rollback'
},
'kick_msg_crash': {
type: 'string',
default: 'This server has experienced an internal error. You will now be disconnected.',
description: 'Message shown to players when server crashes'
},
'ask_reconnect_on_crash': {
type: 'boolean',
default: true,
description: 'Ask players to reconnect after server crashes'
}
}
},
'Network': {
description: 'Network and connection settings',
settings: {
'enable_ipv6': {
type: 'boolean',
default: true,
description: 'Enable IPv6 support'
},
'ipv6_server': {
type: 'boolean',
default: false,
description: 'Use IPv6 for server socket'
},
'max_packets_per_iteration': {
type: 'number',
default: 1024,
min: 1,
max: 10000,
description: 'Maximum packets processed per network iteration'
}
}
},
'Advanced': {
description: 'Advanced server settings',
settings: {
'enable_mod_channels': {
type: 'boolean',
default: false,
description: 'Enable mod channels for mod communication'
},
'csm_restriction_flags': {
type: 'number',
default: 62,
description: 'Client-side mod restriction flags (bitmask)'
},
'csm_restriction_noderange': {
type: 'number',
default: 0,
description: 'Limit client-side mod node range'
}
}
}
};
}
async readConfig() {
try {
const content = await fs.readFile(this.configPath, 'utf8');
return this.parseConfig(content);
} catch (error) {
if (error.code === 'ENOENT') {
// Config file doesn't exist, return empty config
return {};
}
throw error;
}
}
parseConfig(content) {
const config = {};
const lines = content.split('\n');
for (const line of lines) {
const trimmed = line.trim();
// Skip empty lines and comments
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
// Parse key = value pairs
const equalIndex = trimmed.indexOf('=');
if (equalIndex > 0) {
const key = trimmed.substring(0, equalIndex).trim();
const value = trimmed.substring(equalIndex + 1).trim();
config[key] = this.parseValue(value);
}
}
return config;
}
parseValue(value) {
// Remove quotes if present
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
// Try to parse as number
if (!isNaN(value) && !isNaN(parseFloat(value))) {
return parseFloat(value);
}
// Parse boolean
if (value.toLowerCase() === 'true') return true;
if (value.toLowerCase() === 'false') return false;
// Return as string
return value;
}
async writeConfig(config) {
const lines = ['# Minetest configuration file', '# Generated by HostBlock', ''];
// Group settings by section
const usedKeys = new Set();
for (const [sectionName, section] of Object.entries(this.configSections)) {
let hasValues = false;
const sectionLines = [];
sectionLines.push(`# ${section.description}`);
if (section.note) {
sectionLines.push(`# ${section.note}`);
}
for (const [key, setting] of Object.entries(section.settings)) {
if (config.hasOwnProperty(key)) {
const value = config[key];
const formattedValue = this.formatValue(value, setting.type);
sectionLines.push(`${key} = ${formattedValue}`);
usedKeys.add(key);
hasValues = true;
}
}
if (hasValues) {
lines.push(...sectionLines);
lines.push('');
}
}
// Add any unknown settings at the end
const unknownSettings = Object.keys(config).filter(key => !usedKeys.has(key));
if (unknownSettings.length > 0) {
lines.push('# Other settings');
for (const key of unknownSettings) {
const value = config[key];
lines.push(`${key} = ${this.formatValue(value)}`);
}
lines.push('');
}
const content = lines.join('\n');
// Create backup of existing config
try {
await fs.access(this.configPath);
const backupPath = `${this.configPath}.backup.${Date.now()}`;
await fs.copyFile(this.configPath, backupPath);
} catch (error) {
// Original config doesn't exist, no backup needed
}
// Write new config
await fs.writeFile(this.configPath, content, 'utf8');
return { success: true, message: 'Configuration saved successfully' };
}
formatValue(value, type = null) {
if (type === 'string' || type === 'text') {
// Quote strings that contain spaces or special characters
if (typeof value === 'string' && (value.includes(' ') || value.includes('#'))) {
return `"${value}"`;
}
}
return String(value);
}
async updateSetting(key, value) {
const config = await this.readConfig();
config[key] = value;
return await this.writeConfig(config);
}
async updateSettings(settings) {
const config = await this.readConfig();
for (const [key, value] of Object.entries(settings)) {
config[key] = value;
}
return await this.writeConfig(config);
}
async resetToDefaults(section = null) {
const config = await this.readConfig();
if (section && this.configSections[section]) {
// Reset specific section
for (const [key, setting] of Object.entries(this.configSections[section].settings)) {
if (setting.default !== undefined) {
config[key] = setting.default;
}
}
} else {
// Reset all sections
for (const section of Object.values(this.configSections)) {
for (const [key, setting] of Object.entries(section.settings)) {
if (setting.default !== undefined) {
config[key] = setting.default;
}
}
}
}
return await this.writeConfig(config);
}
validateSetting(key, value) {
// Find the setting definition
let settingDef = null;
for (const section of Object.values(this.configSections)) {
if (section.settings[key]) {
settingDef = section.settings[key];
break;
}
}
if (!settingDef) {
// Unknown setting, allow any value
return { valid: true, value };
}
// Type validation
switch (settingDef.type) {
case 'boolean':
if (typeof value === 'string') {
if (value.toLowerCase() === 'true') return { valid: true, value: true };
if (value.toLowerCase() === 'false') return { valid: true, value: false };
return { valid: false, error: 'Must be true or false' };
}
if (typeof value === 'boolean') {
return { valid: true, value };
}
return { valid: false, error: 'Must be a boolean value' };
case 'number':
const num = Number(value);
if (isNaN(num)) {
return { valid: false, error: 'Must be a number' };
}
if (settingDef.min !== undefined && num < settingDef.min) {
return { valid: false, error: `Must be at least ${settingDef.min}` };
}
if (settingDef.max !== undefined && num > settingDef.max) {
return { valid: false, error: `Must be at most ${settingDef.max}` };
}
return { valid: true, value: num };
case 'string':
case 'text':
return { valid: true, value: String(value) };
default:
return { valid: true, value };
}
}
getSettingInfo(key) {
for (const [sectionName, section] of Object.entries(this.configSections)) {
if (section.settings[key]) {
return {
section: sectionName,
...section.settings[key]
};
}
}
return null;
}
getAllSettings() {
return this.configSections;
}
}
module.exports = ConfigManager;
+125
View File
@@ -0,0 +1,125 @@
const fs = require('fs').promises;
const path = require('path');
class ConfigParser {
static async parseConfig(filePath) {
try {
const content = await fs.readFile(filePath, 'utf8');
const config = {};
const lines = content.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const equalIndex = trimmed.indexOf('=');
if (equalIndex === -1) continue;
const key = trimmed.substring(0, equalIndex).trim();
const value = trimmed.substring(equalIndex + 1).trim();
config[key] = value;
}
return config;
} catch (error) {
if (error.code === 'ENOENT') {
return {};
}
throw error;
}
}
static async writeConfig(filePath, config) {
const lines = [];
for (const [key, value] of Object.entries(config)) {
if (value !== undefined && value !== null) {
lines.push(`${key} = ${value}`);
}
}
await fs.writeFile(filePath, lines.join('\n') + '\n', 'utf8');
}
static async parseModConfig(filePath) {
const config = await this.parseConfig(filePath);
if (config.depends) {
config.depends = config.depends.split(',').map(dep => dep.trim()).filter(Boolean);
} else {
config.depends = [];
}
if (config.optional_depends) {
config.optional_depends = config.optional_depends.split(',').map(dep => dep.trim()).filter(Boolean);
} else {
config.optional_depends = [];
}
return config;
}
static async writeModConfig(filePath, config) {
const configCopy = { ...config };
if (Array.isArray(configCopy.depends)) {
configCopy.depends = configCopy.depends.join(', ');
}
if (Array.isArray(configCopy.optional_depends)) {
configCopy.optional_depends = configCopy.optional_depends.join(', ');
}
await this.writeConfig(filePath, configCopy);
}
static async parseWorldConfig(filePath) {
const config = await this.parseConfig(filePath);
const booleanFields = ['creative_mode', 'enable_damage', 'enable_pvp', 'server_announce'];
for (const field of booleanFields) {
if (config[field] !== undefined) {
config[field] = config[field] === 'true';
}
}
return config;
}
static async writeWorldConfig(filePath, config) {
const configCopy = { ...config };
const booleanFields = ['creative_mode', 'enable_damage', 'enable_pvp', 'server_announce'];
for (const field of booleanFields) {
if (typeof configCopy[field] === 'boolean') {
configCopy[field] = configCopy[field].toString();
}
}
await this.writeConfig(filePath, configCopy);
}
static async parseGameConfig(filePath) {
const config = await this.parseConfig(filePath);
// Parse common game config fields
if (config.name) {
config.name = config.name.trim();
}
if (config.title) {
config.title = config.title.trim();
}
if (config.description) {
config.description = config.description.trim();
}
if (config.author) {
config.author = config.author.trim();
}
return config;
}
}
module.exports = ConfigParser;
+202
View File
@@ -0,0 +1,202 @@
/**
* ContentDB URL Parser and Validator
* Handles parsing and validation of ContentDB package URLs
*/
class ContentDBUrlParser {
/**
* Parse a ContentDB URL to extract author and package name
* @param {string} url - The URL to parse
* @returns {Object} - {author, name, isValid, originalUrl}
*/
static parseUrl(url) {
if (!url || typeof url !== 'string') {
return {
author: null,
name: null,
isValid: false,
originalUrl: url,
error: 'URL is required'
};
}
// Clean up the URL
let cleanUrl = url.trim();
// Remove protocol
cleanUrl = cleanUrl.replace(/^https?:\/\//, '');
// Remove trailing slash
cleanUrl = cleanUrl.replace(/\/$/, '');
// Define patterns to match
const patterns = [
// Full ContentDB URL: content.luanti.org/packages/author/name
/^content\.luanti\.org\/packages\/([^\/\s]+)\/([^\/\s]+)$/,
// Alternative domain patterns (if any)
/^(?:www\.)?content\.luanti\.org\/packages\/([^\/\s]+)\/([^\/\s]+)$/,
// Direct author/name format
/^([^\/\s]+)\/([^\/\s]+)$/
];
// Try each pattern
for (const pattern of patterns) {
const match = cleanUrl.match(pattern);
if (match) {
const author = match[1];
const name = match[2];
// Validate author and name format
if (this.isValidIdentifier(author) && this.isValidIdentifier(name)) {
return {
author: author,
name: name,
isValid: true,
originalUrl: url,
cleanUrl: cleanUrl,
fullUrl: `https://content.luanti.org/packages/${author}/${name}/`
};
} else {
return {
author: null,
name: null,
isValid: false,
originalUrl: url,
error: 'Invalid author or package name format'
};
}
}
}
return {
author: null,
name: null,
isValid: false,
originalUrl: url,
error: 'URL format not recognized'
};
}
/**
* Validate an identifier (author or package name)
* @param {string} identifier - The identifier to validate
* @returns {boolean} - Whether the identifier is valid
*/
static isValidIdentifier(identifier) {
if (!identifier || typeof identifier !== 'string') {
return false;
}
// ContentDB identifiers should be alphanumeric with underscores and hyphens
// Length should be reasonable (3-50 characters)
return /^[a-zA-Z0-9_-]{3,50}$/.test(identifier);
}
/**
* Generate various URL formats for a package
* @param {string} author - Package author
* @param {string} name - Package name
* @returns {Object} - Object containing different URL formats
*/
static generateUrls(author, name) {
if (!this.isValidIdentifier(author) || !this.isValidIdentifier(name)) {
throw new Error('Invalid author or package name');
}
return {
web: `https://content.luanti.org/packages/${author}/${name}/`,
api: `https://content.luanti.org/api/packages/${author}/${name}/`,
releases: `https://content.luanti.org/api/packages/${author}/${name}/releases/`,
direct: `${author}/${name}`
};
}
/**
* Validate multiple URL formats and suggest corrections
* @param {string} url - The URL to validate
* @returns {Object} - Validation result with suggestions
*/
static validateWithSuggestions(url) {
const result = this.parseUrl(url);
if (result.isValid) {
return {
...result,
suggestions: []
};
}
// Generate suggestions for common mistakes
const suggestions = [];
if (url.includes('minetest.') || url.includes('minetest/')) {
suggestions.push('Did you mean content.luanti.org instead of minetest?');
}
if (url.includes('://content.luanti.org') && !url.includes('/packages/')) {
suggestions.push('Make sure the URL includes /packages/author/name/');
}
if (url.includes(' ')) {
suggestions.push('Remove spaces from the URL');
}
// Check if it looks like a partial URL
if (url.includes('/') && !url.includes('content.luanti.org')) {
suggestions.push('Try the full URL: https://content.luanti.org/packages/author/name/');
}
return {
...result,
suggestions
};
}
/**
* Extract package information from various URL formats
* @param {string} url - The URL to extract from
* @returns {Promise<Object>} - Package information if available
*/
static async extractPackageInfo(url) {
const parsed = this.parseUrl(url);
if (!parsed.isValid) {
throw new Error(parsed.error || 'Invalid URL format');
}
return {
author: parsed.author,
name: parsed.name,
identifier: `${parsed.author}/${parsed.name}`,
urls: this.generateUrls(parsed.author, parsed.name)
};
}
/**
* Check if a URL is a ContentDB package URL
* @param {string} url - The URL to check
* @returns {boolean} - Whether it's a ContentDB package URL
*/
static isContentDBUrl(url) {
return this.parseUrl(url).isValid;
}
/**
* Normalize a URL to standard format
* @param {string} url - The URL to normalize
* @returns {string} - Normalized URL
*/
static normalizeUrl(url) {
const parsed = this.parseUrl(url);
if (!parsed.isValid) {
throw new Error(parsed.error || 'Invalid URL format');
}
return parsed.fullUrl;
}
}
module.exports = ContentDBUrlParser;
+332
View File
@@ -0,0 +1,332 @@
const axios = require('axios');
const fs = require('fs').promises;
const path = require('path');
const archiver = require('archiver');
const yauzl = require('yauzl');
const { promisify } = require('util');
class ContentDBClient {
constructor() {
this.baseURL = 'https://content.luanti.org/api';
this.client = axios.create({
baseURL: this.baseURL,
timeout: 30000,
headers: {
'User-Agent': 'LuHost/1.0',
'Accept': 'application/json'
},
validateStatus: (status) => {
// Only treat 200-299 as success, but don't throw on 404
return (status >= 200 && status < 300) || status === 404;
}
});
}
// Search packages (mods, games, texture packs)
async searchPackages(query = '', type = '', sort = 'score', order = 'desc', limit = 20, offset = 0) {
try {
const params = {
q: query,
type: type, // mod, game, txp (texture pack)
sort: sort, // score, name, created_at, approved_at, downloads
order: order, // asc, desc
limit: Math.min(limit, 50), // API limit
offset: offset
};
const response = await this.client.get('/packages/', { params });
return response.data;
} catch (error) {
throw new Error(`Failed to search ContentDB: ${error.message}`);
}
}<