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
.gitignore vendored Normal file
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
LICENSE Normal file
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
README.md Normal file
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
app.js Normal file
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
middleware/auth.js Normal file
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
middleware/security.js Normal file
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
package.json Normal file
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
public/css/style.css Normal file
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
public/js/main.js Normal file
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
public/js/server.js Normal file
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);
}
}

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
routes/api.js Normal file
View File

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

262
routes/auth.js Normal file
View File

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

316
routes/config.js Normal file
View File

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

529
routes/contentdb.js Normal file
View File

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

365
routes/extensions.js Normal file
View File

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

318
routes/mods.js Normal file
View File

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

473
routes/server.js Normal file
View File

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

118
routes/users.js Normal file
View File

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

411
routes/worlds.js Normal file
View File

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

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

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

288
utils/auth.js Normal file
View File

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

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

@@ -0,0 +1,442 @@
const fs = require('fs').promises;
const path = require('path');
const paths = require('./paths');
class ConfigManager {
constructor() {
this.configPath = paths.configFile;
this.configSections = this.getConfigSections();
}
getConfigSections() {
return {
'Server': {
description: 'Basic server settings',
settings: {
'server_name': {
type: 'string',
default: 'Luanti Server',
description: 'Name of the server as displayed in the server list'
},
'server_description': {
type: 'text',
default: 'A Luanti server powered by the web interface',
description: 'Server description shown to players'
},
'port': {
type: 'number',
default: 30000,
min: 1024,
max: 65535,
description: 'Port for the game server'
},
'max_users': {
type: 'number',
default: 15,
min: 1,
max: 1000,
description: 'Maximum number of players'
},
'motd': {
type: 'text',
default: 'Welcome to the server!',
description: 'Message of the day shown to connecting players'
},
'server_announce': {
type: 'boolean',
default: false,
description: 'Announce server to the public server list'
},
'serverlist_url': {
type: 'string',
default: 'servers.minetest.net',
description: 'Server list URL for announcements'
}
}
},
'World': {
description: 'World and gameplay settings',
note: 'Many world settings can also be configured per-world in /worlds',
settings: {
'default_game': {
type: 'string',
default: 'minetest_game',
description: 'Default game/subgame to use for new worlds'
},
'creative_mode': {
type: 'boolean',
default: false,
description: 'Enable creative mode by default'
},
'enable_damage': {
type: 'boolean',
default: true,
description: 'Enable player damage and health'
},
'enable_pvp': {
type: 'boolean',
default: true,
description: 'Enable player vs player combat'
},
'disable_fire': {
type: 'boolean',
default: false,
description: 'Disable fire spreading and burning'
},
'time_speed': {
type: 'number',
default: 72,
min: 1,
max: 1000,
description: 'Time speed (72 = 1 real day = 20 minutes game time)'
}
}
},
'Performance': {
description: 'Server performance and limits',
settings: {
'dedicated_server_step': {
type: 'number',
default: 0.09,
min: 0.01,
max: 1.0,
step: 0.01,
description: 'Time step for dedicated server (seconds)'
},
'max_block_generate_distance': {
type: 'number',
default: 8,
min: 1,
max: 50,
description: 'Maximum distance for generating new blocks'
},
'max_block_send_distance': {
type: 'number',
default: 12,
min: 1,
max: 50,
description: 'Maximum distance for sending blocks to clients'
},
'active_block_range': {
type: 'number',
default: 4,
min: 1,
max: 20,
description: 'Blocks within this distance are kept active'
},
'max_simultaneous_block_sends_per_client': {
type: 'number',
default: 40,
min: 1,
max: 200,
description: 'Max blocks sent to each client per step'
}
}
},
'Security': {
description: 'Security and authentication settings',
settings: {
'disallow_empty_password': {
type: 'boolean',
default: false,
description: 'Require non-empty passwords for players'
},
'enable_rollback_recording': {
type: 'boolean',
default: true,
description: 'Record player actions for rollback'
},
'kick_msg_crash': {
type: 'string',
default: 'This server has experienced an internal error. You will now be disconnected.',
description: 'Message shown to players when server crashes'
},
'ask_reconnect_on_crash': {
type: 'boolean',
default: true,
description: 'Ask players to reconnect after server crashes'
}
}
},
'Network': {
description: 'Network and connection settings',
settings: {
'enable_ipv6': {
type: 'boolean',
default: true,
description: 'Enable IPv6 support'
},
'ipv6_server': {
type: 'boolean',
default: false,
description: 'Use IPv6 for server socket'
},
'max_packets_per_iteration': {
type: 'number',
default: 1024,
min: 1,
max: 10000,
description: 'Maximum packets processed per network iteration'
}
}
},
'Advanced': {
description: 'Advanced server settings',
settings: {
'enable_mod_channels': {
type: 'boolean',
default: false,
description: 'Enable mod channels for mod communication'
},
'csm_restriction_flags': {
type: 'number',
default: 62,
description: 'Client-side mod restriction flags (bitmask)'
},
'csm_restriction_noderange': {
type: 'number',
default: 0,
description: 'Limit client-side mod node range'
}
}
}
};
}
async readConfig() {
try {
const content = await fs.readFile(this.configPath, 'utf8');
return this.parseConfig(content);
} catch (error) {
if (error.code === 'ENOENT') {
// Config file doesn't exist, return empty config
return {};
}
throw error;
}
}
parseConfig(content) {
const config = {};
const lines = content.split('\n');
for (const line of lines) {
const trimmed = line.trim();
// Skip empty lines and comments
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
// Parse key = value pairs
const equalIndex = trimmed.indexOf('=');
if (equalIndex > 0) {
const key = trimmed.substring(0, equalIndex).trim();
const value = trimmed.substring(equalIndex + 1).trim();
config[key] = this.parseValue(value);
}
}
return config;
}
parseValue(value) {
// Remove quotes if present
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
// Try to parse as number
if (!isNaN(value) && !isNaN(parseFloat(value))) {
return parseFloat(value);
}
// Parse boolean
if (value.toLowerCase() === 'true') return true;
if (value.toLowerCase() === 'false') return false;
// Return as string
return value;
}
async writeConfig(config) {
const lines = ['# Minetest configuration file', '# Generated by HostBlock', ''];
// Group settings by section
const usedKeys = new Set();
for (const [sectionName, section] of Object.entries(this.configSections)) {
let hasValues = false;
const sectionLines = [];
sectionLines.push(`# ${section.description}`);
if (section.note) {
sectionLines.push(`# ${section.note}`);
}
for (const [key, setting] of Object.entries(section.settings)) {
if (config.hasOwnProperty(key)) {
const value = config[key];
const formattedValue = this.formatValue(value, setting.type);
sectionLines.push(`${key} = ${formattedValue}`);
usedKeys.add(key);
hasValues = true;
}
}
if (hasValues) {
lines.push(...sectionLines);
lines.push('');
}
}
// Add any unknown settings at the end
const unknownSettings = Object.keys(config).filter(key => !usedKeys.has(key));
if (unknownSettings.length > 0) {
lines.push('# Other settings');
for (const key of unknownSettings) {
const value = config[key];
lines.push(`${key} = ${this.formatValue(value)}`);
}
lines.push('');
}
const content = lines.join('\n');
// Create backup of existing config
try {
await fs.access(this.configPath);
const backupPath = `${this.configPath}.backup.${Date.now()}`;
await fs.copyFile(this.configPath, backupPath);
} catch (error) {
// Original config doesn't exist, no backup needed
}
// Write new config
await fs.writeFile(this.configPath, content, 'utf8');
return { success: true, message: 'Configuration saved successfully' };
}
formatValue(value, type = null) {
if (type === 'string' || type === 'text') {
// Quote strings that contain spaces or special characters
if (typeof value === 'string' && (value.includes(' ') || value.includes('#'))) {
return `"${value}"`;
}
}
return String(value);
}
async updateSetting(key, value) {
const config = await this.readConfig();
config[key] = value;
return await this.writeConfig(config);
}
async updateSettings(settings) {
const config = await this.readConfig();
for (const [key, value] of Object.entries(settings)) {
config[key] = value;
}
return await this.writeConfig(config);
}
async resetToDefaults(section = null) {
const config = await this.readConfig();
if (section && this.configSections[section]) {
// Reset specific section
for (const [key, setting] of Object.entries(this.configSections[section].settings)) {
if (setting.default !== undefined) {
config[key] = setting.default;
}
}
} else {
// Reset all sections
for (const section of Object.values(this.configSections)) {
for (const [key, setting] of Object.entries(section.settings)) {
if (setting.default !== undefined) {
config[key] = setting.default;
}
}
}
}
return await this.writeConfig(config);
}
validateSetting(key, value) {
// Find the setting definition
let settingDef = null;
for (const section of Object.values(this.configSections)) {
if (section.settings[key]) {
settingDef = section.settings[key];
break;
}
}
if (!settingDef) {
// Unknown setting, allow any value
return { valid: true, value };
}
// Type validation
switch (settingDef.type) {
case 'boolean':
if (typeof value === 'string') {
if (value.toLowerCase() === 'true') return { valid: true, value: true };
if (value.toLowerCase() === 'false') return { valid: true, value: false };
return { valid: false, error: 'Must be true or false' };
}
if (typeof value === 'boolean') {
return { valid: true, value };
}
return { valid: false, error: 'Must be a boolean value' };
case 'number':
const num = Number(value);
if (isNaN(num)) {
return { valid: false, error: 'Must be a number' };
}
if (settingDef.min !== undefined && num < settingDef.min) {
return { valid: false, error: `Must be at least ${settingDef.min}` };
}
if (settingDef.max !== undefined && num > settingDef.max) {
return { valid: false, error: `Must be at most ${settingDef.max}` };
}
return { valid: true, value: num };
case 'string':
case 'text':
return { valid: true, value: String(value) };
default:
return { valid: true, value };
}
}
getSettingInfo(key) {
for (const [sectionName, section] of Object.entries(this.configSections)) {
if (section.settings[key]) {
return {
section: sectionName,
...section.settings[key]
};
}
}
return null;
}
getAllSettings() {
return this.configSections;
}
}
module.exports = ConfigManager;

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

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

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

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

332
utils/contentdb.js Normal file
View File

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

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

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

194
utils/paths.js Normal file
View File

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

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

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

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

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

View File

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

63
views/auth/login.ejs Normal file
View File

@@ -0,0 +1,63 @@
<%
const body = `
<div style="max-width: 400px; margin: 2rem auto;">
<div class="card">
<div class="card-header" style="text-align: center;">
<h2>Login to Luanti Server Manager</h2>
<p style="color: var(--text-secondary); margin: 0;">Enter your credentials to access the server management interface</p>
</div>
${typeof error !== 'undefined' ? `
<div class="alert alert-danger">
<strong>Error:</strong> ${typeof escapeHtml !== 'undefined' ? escapeHtml(error) : error}
</div>
` : ''}
${typeof req !== 'undefined' && req.query.message ? `
<div class="alert alert-info">
${req.query.message}
</div>
` : ''}
<form method="POST" action="/login">
<input type="hidden" name="redirect" value="${redirectUrl || '/'}">
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
<div class="form-group">
<label for="username">Username</label>
<input type="text"
id="username"
name="username"
class="form-control"
value="${typeof formData !== 'undefined' ? formData.username || '' : ''}"
required
autofocus
autocomplete="username">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password"
id="password"
name="password"
class="form-control"
required
autocomplete="current-password">
</div>
<div style="display: flex; justify-content: flex-end; align-items: center; margin-top: 2rem;">
<button type="submit" class="btn btn-primary">
Sign In
</button>
</div>
</form>
</div>
<div style="text-align: center; margin-top: 1rem; color: var(--text-secondary); font-size: 0.875rem;">
<p>Need an account? Contact an existing administrator to create one for you.</p>
</div>
</div>
`;
%>
<%- include('../layout', { body: body, currentPage: 'login', title: title }) %>

116
views/auth/register.ejs Normal file
View File

@@ -0,0 +1,116 @@
<%
const body = `
<div style="max-width: 500px; margin: 2rem auto;">
<div class="card">
<div class="card-header" style="text-align: center;">
<h2>${isFirstUser ? 'Setup Administrator Account' : 'Create Account'}</h2>
<p style="color: var(--text-secondary); margin: 0;">
${isFirstUser ?
'Create the first administrator account for this Luanti server' :
'Join this Luanti server management team'
}
</p>
</div>
${typeof error !== 'undefined' ? `
<div class="alert alert-danger">
<strong>Error:</strong> ${error}
</div>
` : ''}
${isFirstUser ? `
<div class="alert alert-info">
<strong>First User Setup:</strong> You are creating the first administrator account for this server. All users have full admin privileges.
</div>
` : ''}
<form method="POST" action="/register">
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
<div class="form-group">
<label for="username">Username*</label>
<input type="text"
id="username"
name="username"
class="form-control"
value="${typeof formData !== 'undefined' ? formData.username || '' : ''}"
required
pattern="[a-zA-Z0-9_-]{3,20}"
title="3-20 characters, letters, numbers, underscore, or hyphen only"
data-validate-name
autofocus
autocomplete="username">
<small style="color: var(--text-secondary);">3-20 characters, letters, numbers, underscore, or hyphen only</small>
</div>
<div class="form-row">
<div class="form-group">
<label for="password">Password*</label>
<input type="password"
id="password"
name="password"
class="form-control"
required
minlength="8"
autocomplete="new-password">
<small style="color: var(--text-secondary);">At least 8 characters long</small>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm Password*</label>
<input type="password"
id="confirmPassword"
name="confirmPassword"
class="form-control"
required
minlength="8"
autocomplete="new-password">
</div>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 2rem;">
<a href="/login" class="btn btn-outline">
Already have an account?
</a>
<button type="submit" class="btn btn-success">
${isFirstUser ? 'Setup Account' : 'Create Account'}
</button>
</div>
</form>
</div>
<div style="text-align: center; margin-top: 1rem; color: var(--text-secondary); font-size: 0.875rem;">
<p>
${isFirstUser ?
'This will be the primary administrator account.' :
'All accounts have full server administration privileges.'
}
</p>
</div>
</div>
<script>
// Client-side password confirmation validation
document.getElementById('confirmPassword').addEventListener('input', function() {
const password = document.getElementById('password').value;
const confirmPassword = this.value;
if (password && confirmPassword) {
if (password !== confirmPassword) {
this.setCustomValidity('Passwords do not match');
} else {
this.setCustomValidity('');
}
}
});
document.getElementById('password').addEventListener('input', function() {
const confirmPassword = document.getElementById('confirmPassword');
if (confirmPassword.value) {
confirmPassword.dispatchEvent(new Event('input'));
}
});
</script>
`;
%>
<%- include('../layout', { body: body, currentPage: 'register', title: title }) %>

687
views/config/index.ejs Normal file
View File

@@ -0,0 +1,687 @@
<%
const body = `
<div class="page-header">
<h2>⚙️ Server Configuration</h2>
<p>Configure your Luanti server's global settings</p>
<div class="config-help">
<small class="text-muted">
💡 These are global server settings. For world-specific settings, visit
<a href="/worlds">🌍 World Management</a>
</small>
</div>
</div>
<div class="row">
<div class="col-md-9">
<div class="card">
<div class="card-header">
<div class="config-header-flex">
<h3>⚙️ Server Configuration</h3>
<div class="config-status" id="configStatus">
<span class="status-indicator saved">✅ Saved</span>
</div>
</div>
<p class="section-description">Configure all server settings below</p>
</div>
<div class="card-body">
<form id="configForm">
<div id="configSections">
<!-- All configuration sections will be loaded here -->
<div class="loading">Loading configuration...</div>
</div>
</form>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-header">
<h3>💾 Actions</h3>
</div>
<div class="card-body">
<div class="config-actions">
<button class="btn btn-success btn-sm btn-block" id="saveBtn">
💾 Save Changes
</button>
<button class="btn btn-outline-secondary btn-sm btn-block" id="reloadBtn">
🔄 Reload
</button>
<button class="btn btn-outline-warning btn-sm btn-block" id="resetBtn">
↩️ Reset All
</button>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>📝 Configuration File</h3>
</div>
<div class="card-body">
<p class="text-muted small">
Location: <code id="configPath">~/.minetest/minetest.conf</code>
</p>
<button class="btn btn-outline-info btn-sm btn-block" id="downloadBtn">
📄 Download Config
</button>
</div>
</div>
<div class="card">
<div class="card-header">
<h3> Status</h3>
</div>
<div class="card-body">
<div class="status-info">
<div class="info-item">
<strong>Data Directory:</strong><br>
<small id="currentDataDir">Loading...</small>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Modal temporarily removed for debugging -->
<style>
.config-help {
margin-top: 0.5rem;
padding: 0.75rem;
background: var(--bg-accent);
border-radius: var(--border-radius);
border-left: 4px solid var(--primary-color);
}
.config-header-flex {
display: flex;
justify-content: space-between;
align-items: center;
}
.config-divider {
margin: 1rem 0;
}
/* Navigation styles removed - now showing all sections */
.config-actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
.section-description {
margin: 0.5rem 0 0 0;
color: var(--text-muted);
font-size: 0.9rem;
}
.config-status {
font-size: 0.9rem;
}
.status-indicator {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-weight: bold;
}
.status-indicator.saved {
color: var(--success-color);
}
.status-indicator.modified {
background: var(--warning-color);
color: white;
}
.status-indicator.error {
background: var(--danger-color);
color: white;
}
.config-section-header {
margin-top: 2rem;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--border-color);
}
.config-section-header:first-child {
margin-top: 0;
}
.config-section-header h3 {
margin: 0;
color: var(--text-primary);
font-size: 1.25rem;
}
.config-section-header p {
margin: 0.25rem 0 0 0;
color: var(--text-muted);
font-size: 0.9rem;
}
.config-section-content {
margin-bottom: 2rem;
}
.setting-group {
margin-bottom: 1.5rem;
padding: 1rem;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
background: var(--bg-secondary);
}
.setting-label {
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
color: var(--text-primary);
}
.setting-description {
font-size: 0.875rem;
color: var(--text-muted);
margin-bottom: 0.75rem;
line-height: 1.4;
}
.setting-input {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
background: var(--bg-primary);
color: var(--text-primary);
}
.setting-input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.2);
}
.setting-input.number {
max-width: 200px;
}
.setting-input.boolean {
width: auto;
}
.setting-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--text-muted);
}
.setting-default {
font-style: italic;
}
.setting-validation {
color: var(--danger-color);
font-size: 0.875rem;
margin-top: 0.25rem;
}
.loading {
text-align: center;
padding: 2rem;
color: var(--text-muted);
}
/* Modal styles temporarily removed for debugging */
.status-info .info-item {
margin-bottom: 1rem;
padding: 0.5rem;
background: var(--bg-accent);
border-radius: var(--border-radius);
}
.status-info .info-item:last-child {
margin-bottom: 0;
}
@media (max-width: 768px) {
.config-nav {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.nav-btn {
text-align: center;
font-size: 0.8rem;
padding: 0.5rem;
}
/* Modal media query removed */
}
</style>
<script>
// Configuration page JavaScript - properly outside template literal
let configData = {};
let originalConfig = {};
let configSections = {};
let unsavedChanges = false;
console.log('Configuration page JavaScript is loading...');
document.addEventListener('DOMContentLoaded', function() {
console.log('DOM Content Loaded - Config Page');
loadConfiguration();
// Add event listeners for action buttons
const saveBtn = document.getElementById('saveBtn');
if (saveBtn) {
saveBtn.addEventListener('click', saveConfiguration);
}
const reloadBtn = document.getElementById('reloadBtn');
if (reloadBtn) {
reloadBtn.addEventListener('click', reloadConfiguration);
}
const resetBtn = document.getElementById('resetBtn');
if (resetBtn) {
resetBtn.addEventListener('click', resetSection);
}
const downloadBtn = document.getElementById('downloadBtn');
if (downloadBtn) {
downloadBtn.addEventListener('click', downloadConfig);
}
// Warn about unsaved changes
window.addEventListener('beforeunload', function(e) {
if (unsavedChanges) {
e.preventDefault();
e.returnValue = '';
}
});
});
async function loadConfiguration() {
try {
console.log('loadConfiguration function started...');
console.log('About to fetch /api/config...');
const response = await fetch('/api/config');
console.log('Fetch response:', response);
if (!response.ok) {
console.error('Response not ok:', response.status, response.statusText);
throw new Error('HTTP ' + response.status + ': ' + response.statusText);
}
console.log('About to parse JSON...');
const data = await response.json();
console.log('Configuration data received:', data);
console.log('Data keys:', Object.keys(data));
configData = data.current;
originalConfig = { ...data.current };
configSections = data.sections;
console.log('About to render config sections...');
renderConfigSections();
console.log('Config sections rendered, updating status...');
updateStatus('saved');
// Update status info
document.getElementById('currentDataDir').textContent = data.current.data_directory || 'Not configured';
document.getElementById('configPath').textContent = (data.current.data_directory || '~/.minetest') + '/minetest.conf';
} catch (error) {
console.error('Failed to load configuration:', error);
console.error('Error type:', typeof error);
console.error('Error message:', error.message);
console.error('Error stack:', error.stack);
// Show a basic fallback form
const sectionsElement = document.getElementById('configSections');
console.log('Setting error content to element:', sectionsElement);
if (sectionsElement) {
sectionsElement.innerHTML =
'<div class="alert alert-warning">' +
'<strong>⚠️ Configuration Loading Failed</strong><br>' +
error.message + '<br>' +
'Showing basic form instead.' +
'</div>';
} else {
console.error('configSections element not found!');
}
}
}
function renderConfigSections() {
const container = document.getElementById('configSections');
container.innerHTML = '';
// Icons for each section
const sectionIcons = {
'System': '🏗️',
'Server': '🖥️',
'World': '🌍',
'Performance': '⚡',
'Security': '🔒',
'Network': '🌐',
'Advanced': '🔧'
};
for (const [sectionName, section] of Object.entries(configSections)) {
// Create section header
const sectionHeaderDiv = document.createElement('div');
sectionHeaderDiv.className = 'config-section-header';
sectionHeaderDiv.innerHTML =
'<h3>' + (sectionIcons[sectionName] || '⚙️') + ' ' + sectionName + ' Configuration</h3>' +
'<p>' + (section.description || (sectionName + ' configuration settings')) + '</p>';
container.appendChild(sectionHeaderDiv);
// Create section content
const sectionDiv = document.createElement('div');
sectionDiv.className = 'config-section-content';
if (section.note) {
const noteDiv = document.createElement('div');
noteDiv.className = 'alert alert-info';
noteDiv.innerHTML = '💡 ' + section.note;
sectionDiv.appendChild(noteDiv);
}
for (const [settingKey, setting] of Object.entries(section.settings)) {
const settingDiv = document.createElement('div');
settingDiv.className = 'setting-group';
const currentValue = configData[settingKey] !== undefined ?
configData[settingKey] : setting.default;
settingDiv.innerHTML =
'<label class="setting-label" for="' + settingKey + '">' +
settingKey +
'</label>' +
'<div class="setting-description">' +
setting.description +
'</div>' +
renderSettingInput(settingKey, setting, currentValue) +
'<div class="setting-meta">' +
'<span class="setting-default">Default: ' + setting.default + '</span>' +
'<span class="setting-type">' + setting.type + '</span>' +
'</div>' +
'<div class="setting-validation" id="validation-' + settingKey + '"></div>';
sectionDiv.appendChild(settingDiv);
}
container.appendChild(sectionDiv);
}
// Add event listeners for changes
container.addEventListener('input', handleSettingChange);
container.addEventListener('change', handleSettingChange);
}
function renderSettingInput(key, setting, value) {
switch (setting.type) {
case 'boolean':
return '<input type="checkbox" ' +
'id="' + key + '" ' +
'name="' + key + '" ' +
'class="setting-input boolean" ' +
(value ? 'checked' : '') + '>';
case 'number':
const step = setting.step || (setting.min !== undefined && setting.min < 1 ? '0.01' : '1');
return '<input type="number" ' +
'id="' + key + '" ' +
'name="' + key + '" ' +
'class="setting-input number" ' +
'value="' + value + '"' +
'step="' + step + '"' +
(setting.min !== undefined ? 'min="' + setting.min + '"' : '') +
(setting.max !== undefined ? 'max="' + setting.max + '"' : '') + '>';
case 'text':
return '<textarea id="' + key + '" ' +
'name="' + key + '" ' +
'class="setting-input" ' +
'rows="3">' + (value || '') + '</textarea>';
default: // string
return '<input type="text" ' +
'id="' + key + '" ' +
'name="' + key + '" ' +
'class="setting-input" ' +
'value="' + (value || '') + '">';
}
}
function handleSettingChange(event) {
const input = event.target;
const key = input.name;
if (!key) return;
let value;
if (input.type === 'checkbox') {
value = input.checked;
} else if (input.type === 'number') {
value = parseFloat(input.value) || 0;
} else {
value = input.value;
}
configData[key] = value;
// Validate setting
validateSetting(key, value);
// Mark as modified
if (JSON.stringify(configData) !== JSON.stringify(originalConfig)) {
updateStatus('modified');
unsavedChanges = true;
} else {
updateStatus('saved');
unsavedChanges = false;
}
}
function validateSetting(key, value) {
const validationElement = document.getElementById('validation-' + key);
if (!validationElement) return;
// Find setting definition
let setting = null;
for (const section of Object.values(configSections)) {
if (section.settings && section.settings[key]) {
setting = section.settings[key];
break;
}
}
if (!setting) return;
// Validate based on type
let isValid = true;
let errorMessage = '';
if (setting.type === 'number') {
const num = Number(value);
if (isNaN(num)) {
isValid = false;
errorMessage = 'Must be a number';
} else {
if (setting.min !== undefined && num < setting.min) {
isValid = false;
errorMessage = 'Must be at least ' + setting.min;
}
if (setting.max !== undefined && num > setting.max) {
isValid = false;
errorMessage = 'Must be at most ' + setting.max;
}
}
}
if (isValid) {
validationElement.textContent = '';
const input = document.getElementById(key);
if (input) input.style.borderColor = '';
} else {
validationElement.textContent = errorMessage;
const input = document.getElementById(key);
if (input) input.style.borderColor = 'var(--danger-color)';
}
return isValid;
}
// showSection function removed - now showing all sections at once
async function saveConfiguration() {
try {
// Validate all current settings
let hasErrors = false;
for (const [key, value] of Object.entries(configData)) {
if (!validateSetting(key, value)) {
hasErrors = true;
}
}
if (hasErrors) {
updateStatus('error');
alert('Please fix validation errors before saving.');
return;
}
updateStatus('saving');
const response = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings: configData })
});
const result = await response.json();
if (result.success) {
originalConfig = { ...configData };
unsavedChanges = false;
updateStatus('saved');
// Show success message briefly
const statusElement = document.getElementById('configStatus');
if (statusElement) {
statusElement.innerHTML = '<span class="status-indicator saved">✅ Configuration saved!</span>';
setTimeout(() => {
statusElement.innerHTML = '<span class="status-indicator saved">✅ Saved</span>';
}, 3000);
}
} else {
updateStatus('error');
alert('Failed to save configuration: ' + (result.error || 'Unknown error'));
}
} catch (error) {
updateStatus('error');
alert('Failed to save configuration: ' + error.message);
}
}
async function reloadConfiguration() {
if (unsavedChanges) {
if (!confirm('You have unsaved changes. Are you sure you want to reload?')) {
return;
}
}
await loadConfiguration();
}
async function resetSection() {
if (!confirm('Reset all configuration settings to defaults?')) {
return;
}
try {
const response = await fetch('/api/config/reset', {
method: 'POST'
});
const result = await response.json();
if (result.success) {
await loadConfiguration();
} else {
alert('Failed to reset configuration: ' + (result.error || 'Unknown error'));
}
} catch (error) {
alert('Failed to reset configuration: ' + error.message);
}
}
async function downloadConfig() {
try {
const response = await fetch('/api/config');
const data = await response.json();
// Generate config file content
let content = '# Minetest configuration file\\n';
content += '# Generated by LuHost\\n\\n';
for (const [key, value] of Object.entries(data.current)) {
content += key + ' = ' + value + '\\n';
}
// Download as file
const blob = new Blob([content], { type: 'text/plain' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'minetest.conf';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (error) {
alert('Failed to download config: ' + error.message);
}
}
function updateStatus(status) {
const statusElement = document.getElementById('configStatus');
if (!statusElement) return;
switch (status) {
case 'saved':
statusElement.innerHTML = '<span class="status-indicator saved">✅ Saved</span>';
break;
case 'modified':
statusElement.innerHTML = '<span class="status-indicator modified">⚠️ Modified</span>';
break;
case 'saving':
statusElement.innerHTML = '<span class="status-indicator">💾 Saving...</span>';
break;
case 'error':
statusElement.innerHTML = '<span class="status-indicator error">❌ Error</span>';
break;
}
}
</script>
`;
%>
<%- include('../layout', { body: body, currentPage: 'config', title: title }) %>

429
views/contentdb/index.ejs Normal file
View File

@@ -0,0 +1,429 @@
<%
const body = `
<div class="page-header">
<h2>ContentDB Installer</h2>
<p>Install mods, games, and texture packs by pasting ContentDB URLs</p>
</div>
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h3>Install from URL</h3>
</div>
<div class="card-body">
<form method="POST" action="/contentdb/install-url" id="installForm">
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
<div class="form-group">
<label for="packageUrl">ContentDB Package URL*</label>
<input type="text"
id="packageUrl"
name="packageUrl"
class="form-control"
placeholder="https://content.luanti.org/packages/author/package_name/"
required>
<small class="form-text text-muted">
Paste any ContentDB package URL or use format: author/package_name
</small>
<div id="urlValidation" class="mt-2"></div>
</div>
<div class="form-row" id="locationSelectionGroup">
<div class="form-group">
<label for="installLocation">Install Location</label>
<select name="installLocation" id="installLocation" class="form-control" required>
<option value="global">Global</option>
<option value="world">Specific World</option>
</select>
<small class="form-text text-muted" id="locationHelp">Choose where to install this content</small>
</div>
<div class="form-group" id="worldSelectionGroup" style="display: none;">
<label for="worldName">Target World</label>
<select name="worldName" id="worldName" class="form-control">
<option value="">Select a world...</option>
<!-- Will be populated dynamically -->
</select>
</div>
</div>
<div class="form-group" id="dependencyGroup">
<div class="checkbox-wrapper">
<input type="checkbox" id="installDeps" name="installDeps" checked>
<label for="installDeps" class="checkbox-label">
Install dependencies automatically
</label>
</div>
<small class="form-text text-muted" id="depsHelp">
Recommended: Automatically download and install required dependencies
</small>
</div>
<div class="form-group">
<button type="submit" class="btn btn-success btn-lg" id="installBtn">
📦 Install Package
</button>
<button type="button" class="btn btn-secondary" onclick="clearForm()">
Clear
</button>
</div>
</form>
<div id="installStatus" style="display: none;">
<div class="alert" id="statusAlert">
<div id="statusMessage"></div>
<div id="installProgress" class="mt-2">
<div class="spinner"></div>
<span>Installing package...</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h3>How to Use</h3>
</div>
<div class="card-body">
<h4>📋 Step 1: Copy URL</h4>
<p>Go to <a href="https://content.luanti.org" target="_blank">content.luanti.org</a> and copy the URL of any content (mods, games, texture packs).</p>
<h4>📍 Step 2: Auto-Detection</h4>
<p><strong>Games</strong> install automatically to games directory.<br>
<strong>Mods</strong> let you choose global or world-specific.<br>
<strong>Texture packs</strong> install automatically to textures directory.</p>
<h4>⚡ Step 3: Install</h4>
<p>Click install and dependencies will be resolved automatically.</p>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>🔄 Package Updates</h3>
</div>
<div class="card-body">
<p>Check for updates to your installed packages:</p>
<a href="/contentdb/updates" class="btn btn-primary btn-block">Check for Updates</a>
<a href="/contentdb/installed" class="btn btn-outline-primary btn-block">View Installed</a>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>📝 Supported URL Formats</h3>
</div>
<div class="card-body">
<div class="url-examples">
<h4>✅ Supported Formats:</h4>
<ul>
<li><code>https://content.luanti.org/packages/author/package_name/</code></li>
<li><code>content.luanti.org/packages/author/package_name/</code></li>
<li><code>author/package_name</code> (direct format)</li>
</ul>
<h4>📋 Example URLs:</h4>
<ul>
<li><strong>Mod:</strong> <code>https://content.luanti.org/packages/VanessaE/basic_materials/</code></li>
<li><strong>Game:</strong> <code>https://content.luanti.org/packages/GreenXenith/nodecore/</code></li>
<li><strong>Texture Pack:</strong> <code>https://content.luanti.org/packages/author/texture_pack/</code></li>
<li><strong>Direct:</strong> <code>VanessaE/basic_materials</code></li>
</ul>
</div>
</div>
</div>
<style>
.form-row {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.form-row .form-group {
flex: 1;
min-width: 250px;
}
.url-examples ul {
margin-left: 1.5rem;
}
.url-examples li {
margin-bottom: 0.5rem;
}
.url-examples code {
background: var(--bg-accent);
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-family: monospace;
color: var(--text-primary);
}
.btn-block {
width: 100%;
margin-bottom: 0.5rem;
}
#urlValidation {
font-size: 0.875rem;
}
.validation-success {
color: var(--success-color);
}
.validation-error {
color: var(--danger-color);
}
.validation-info {
color: var(--primary-color);
}
#installProgress {
display: flex;
align-items: center;
gap: 0.5rem;
}
@media (max-width: 768px) {
.form-row {
flex-direction: column;
}
.form-row .form-group {
min-width: auto;
}
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const packageUrlInput = document.getElementById('packageUrl');
const installLocationSelect = document.getElementById('installLocation');
const worldSelectionGroup = document.getElementById('worldSelectionGroup');
const worldNameSelect = document.getElementById('worldName');
const installForm = document.getElementById('installForm');
const installBtn = document.getElementById('installBtn');
const installStatus = document.getElementById('installStatus');
const urlValidation = document.getElementById('urlValidation');
const locationSelectionGroup = document.getElementById('locationSelectionGroup');
const dependencyGroup = document.getElementById('dependencyGroup');
const locationHelp = document.getElementById('locationHelp');
const depsHelp = document.getElementById('depsHelp');
let currentPackageType = null;
// Load available worlds
loadWorlds();
// Show/hide world selection based on install location
installLocationSelect.addEventListener('change', function() {
if (this.value === 'world') {
worldSelectionGroup.style.display = 'block';
worldNameSelect.required = true;
} else {
worldSelectionGroup.style.display = 'none';
worldNameSelect.required = false;
}
});
// Real-time URL validation
let validationTimeout;
packageUrlInput.addEventListener('input', function() {
clearTimeout(validationTimeout);
validationTimeout = setTimeout(() => {
validateUrl(this.value);
}, 500);
});
// Form submission
installForm.addEventListener('submit', async function(e) {
e.preventDefault();
const url = packageUrlInput.value.trim();
if (!url) {
showError('Please enter a package URL');
return;
}
// Show installation status
installBtn.disabled = true;
installBtn.textContent = '⏳ Installing...';
showStatus('Installing package...', 'info', true);
try {
const formData = new FormData(this);
const params = new URLSearchParams();
// Convert FormData to URLSearchParams for proper encoding
for (let [key, value] of formData.entries()) {
params.append(key, value);
}
const response = await fetch('/contentdb/install-url', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params
});
const result = await response.json();
if (result.success) {
showStatus(result.message + ' ✅', 'success', false);
clearForm();
// Auto-hide success message after 5 seconds
setTimeout(() => {
installStatus.style.display = 'none';
}, 5000);
} else {
showStatus(result.error || 'Installation failed', 'error', false);
}
} catch (error) {
console.error('Installation error:', error);
showStatus('Installation failed: ' + error.message, 'error', false);
} finally {
installBtn.disabled = false;
installBtn.textContent = '📦 Install Package';
}
});
async function loadWorlds() {
try {
const response = await fetch('/api/worlds');
const worlds = await response.json();
worldNameSelect.innerHTML = '<option value="">Select a world...</option>';
worlds.forEach(world => {
const option = document.createElement('option');
option.value = world.name;
option.textContent = world.displayName || world.name;
worldNameSelect.appendChild(option);
});
} catch (error) {
console.error('Failed to load worlds:', error);
}
}
async function validateUrl(url) {
if (!url.trim()) {
urlValidation.innerHTML = '';
resetUIForPackageType();
return;
}
// Parse URL client-side for immediate feedback
const parsed = parseContentDBUrl(url);
if (parsed.author && parsed.name) {
// Show valid URL format - type detection happens during installation
urlValidation.innerHTML = '<span class="validation-success">✅ Valid: ' + parsed.author + '/' + parsed.name + ' (type will be detected during installation)</span>';
resetUIForPackageType();
} else {
urlValidation.innerHTML = '<span class="validation-error">❌ Invalid URL format</span>';
resetUIForPackageType();
}
}
function parseContentDBUrl(url) {
// Remove protocol and clean up
url = url.replace(/^https?:\\/\\//, '').replace(/\\/$/, '');
// Match patterns
const patterns = [
/^content\\.luanti\\.org\\/packages\\/([^/]+)\\/([^/]+)$/,
/^([^/]+)\\/([^/]+)$/
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match) {
return { author: match[1], name: match[2] };
}
}
return { author: null, name: null };
}
function updateUIForPackageType(packageType, author, name, title) {
const typeDisplayName = packageType === 'game' ? 'Game' :
packageType === 'txp' ? 'Texture Pack' : 'Mod';
const typeEmoji = packageType === 'game' ? '🎮' :
packageType === 'txp' ? '🎨' : '📦';
urlValidation.innerHTML = \`<span class="validation-success">✅ \${typeEmoji} \${typeDisplayName}: \${title || (author + '/' + name)}</span>\`;
if (packageType === 'game') {
// Games go to games directory - no location choice
locationSelectionGroup.style.display = 'none';
dependencyGroup.style.display = 'none';
installBtn.innerHTML = '🎮 Install Game';
} else if (packageType === 'txp') {
// Texture packs go to textures directory - no location choice
locationSelectionGroup.style.display = 'none';
dependencyGroup.style.display = 'none';
installBtn.innerHTML = '🎨 Install Texture Pack';
} else {
// Mods can be installed globally or per-world
locationSelectionGroup.style.display = 'block';
dependencyGroup.style.display = 'block';
locationHelp.textContent = 'Choose where to install this mod';
depsHelp.textContent = 'Recommended: Automatically download and install required dependencies';
installBtn.innerHTML = '📦 Install Mod';
}
}
function resetUIForPackageType() {
currentPackageType = null;
locationSelectionGroup.style.display = 'block';
dependencyGroup.style.display = 'block';
installBtn.innerHTML = '📦 Install Package';
locationHelp.textContent = 'Choose where to install this content';
depsHelp.textContent = 'Recommended: Automatically download and install required dependencies';
}
function showStatus(message, type, showProgress) {
const statusAlert = document.getElementById('statusAlert');
const statusMessage = document.getElementById('statusMessage');
const installProgress = document.getElementById('installProgress');
// Map alert types to Bootstrap classes
const alertClass = type === 'error' ? 'alert-danger' : type === 'info' ? 'alert-info' : 'alert-' + type;
statusAlert.className = 'alert ' + alertClass;
statusMessage.textContent = message;
installProgress.style.display = showProgress ? 'flex' : 'none';
installStatus.style.display = 'block';
// Scroll to status for better visibility
installStatus.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function showError(message) {
showStatus(message, 'danger', false);
}
window.clearForm = function() {
installForm.reset();
urlValidation.innerHTML = '';
installStatus.style.display = 'none';
worldSelectionGroup.style.display = 'none';
worldNameSelect.required = false;
resetUIForPackageType();
};
});
</script>
`;
%>
<%- include('../layout', { body: body, currentPage: 'contentdb', title: title }) %>

View File

@@ -0,0 +1,308 @@
<%
const body = `
<div class="page-header">
<h2>📦 Installed Packages</h2>
<p>Manage your installed mods, games, and texture packs</p>
</div>
<div class="row">
<div class="col-md-3">
<div class="card">
<div class="card-header">
<h3>📊 Statistics</h3>
</div>
<div class="card-body">
<div class="stat-item">
<strong>${statistics.total_packages || 0}</strong>
<span>Total Packages</span>
</div>
<div class="stat-item">
<strong>${statistics.global_packages || 0}</strong>
<span>Global Mods</span>
</div>
<div class="stat-item">
<strong>${statistics.world_packages || 0}</strong>
<span>World-specific</span>
</div>
<div class="stat-item">
<strong>${statistics.worlds_with_packages || 0}</strong>
<span>Worlds with Mods</span>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>🔍 Filter Packages</h3>
</div>
<div class="card-body">
<a href="/contentdb/installed"
class="btn ${selectedLocation === 'all' ? 'btn-success' : 'btn-outline-secondary'} btn-sm btn-block">
All Locations
</a>
<a href="/contentdb/installed?location=global"
class="btn ${selectedLocation === 'global' ? 'btn-success' : 'btn-outline-secondary'} btn-sm btn-block">
Global Mods
</a>
<div class="mt-2">
<small class="text-muted">World-specific filters coming soon</small>
</div>
</div>
</div>
</div>
<div class="col-md-9">
${packages.length === 0 ? `
<div class="card">
<div class="card-body text-center">
<h3>📭 No Packages Installed</h3>
<p>You haven't installed any packages yet from ContentDB.</p>
<a href="/contentdb" class="btn btn-primary">
Browse ContentDB
</a>
</div>
</div>
` : `
<div class="packages-grid">
${packages.map(pkg => `
<div class="card package-card">
<div class="card-header">
<div class="package-title">
<h4>${pkg.title || pkg.name}</h4>
<small class="text-muted">by ${pkg.author}</small>
</div>
<div class="package-actions">
<span class="badge badge-${pkg.package_type === 'game' ? 'success' : pkg.package_type === 'txp' ? 'warning' : 'primary'}">
${pkg.package_type || 'mod'}
</span>
</div>
</div>
<div class="card-body">
<div class="package-details">
<p class="package-description">
${pkg.short_description || 'No description available.'}
</p>
<div class="package-meta">
<div class="meta-item">
<strong>Location:</strong>
<span class="location-badge ${pkg.install_location === 'global' ? 'global' : 'world'}">
${pkg.install_location === 'global' ? 'Global' : pkg.install_location.replace('world:', '')}
</span>
</div>
<div class="meta-item">
<strong>Version:</strong>
<span>${pkg.version || 'Unknown'}</span>
</div>
<div class="meta-item">
<strong>Installed:</strong>
<span>${new Date(pkg.installed_at).toLocaleDateString()}</span>
</div>
</div>
${pkg.dependencies && pkg.dependencies.length > 0 ? `
<div class="dependencies">
<strong>Dependencies (${pkg.dependencies.length}):</strong>
<div class="dep-list">
${pkg.dependencies.map(dep =>
typeof dep === 'string' ? dep : `${dep.author}/${dep.name}`
).join(', ')}
</div>
</div>
` : ''}
</div>
<div class="package-actions">
${pkg.contentdb_url ? `
<a href="\${pkg.contentdb_url}" target="_blank" class="btn btn-outline-primary btn-sm">
View on ContentDB
</a>
` : ''}
<button class="btn btn-outline-warning btn-sm"
onclick="checkForUpdate('\${pkg.author}', '\${pkg.name}', '\${pkg.install_location}')">
Check Update
</button>
<button class="btn btn-outline-danger btn-sm"
onclick="uninstallPackage('\${pkg.author}', '\${pkg.name}', '\${pkg.install_location}')">
Uninstall
</button>
</div>
</div>
</div>
`).join('')}
</div>
`}
</div>
</div>
<style>
.stat-item {
text-align: center;
padding: 0.75rem 0;
border-bottom: 1px solid var(--border-color);
}
.stat-item:last-child {
border-bottom: none;
}
.stat-item strong {
display: block;
font-size: 1.5rem;
color: var(--primary-color);
}
.stat-item span {
font-size: 0.875rem;
color: var(--text-muted);
}
.packages-grid {
display: grid;
gap: 1.5rem;
}
.package-card {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.package-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-block), 0 8px 16px rgba(0, 0, 0, 0.1);
}
.package-title {
flex: 1;
}
.package-title h4 {
margin: 0;
color: var(--text-primary);
}
.package-description {
color: var(--text-secondary);
margin-bottom: 1rem;
font-size: 0.9rem;
line-height: 1.4;
}
.package-meta {
background: var(--bg-accent);
padding: 0.75rem;
border-radius: var(--border-radius);
margin-bottom: 1rem;
font-size: 0.875rem;
}
.meta-item {
display: flex;
justify-content: space-between;
margin-bottom: 0.25rem;
}
.meta-item:last-child {
margin-bottom: 0;
}
.location-badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: bold;
text-transform: uppercase;
}
.location-badge.global {
background: var(--success-color);
color: white;
}
.location-badge.world {
background: var(--primary-color);
color: white;
}
.dependencies {
background: var(--bg-secondary);
padding: 0.75rem;
border-radius: var(--border-radius);
margin-bottom: 1rem;
font-size: 0.875rem;
}
.dep-list {
margin-top: 0.5rem;
color: var(--text-muted);
font-family: monospace;
}
.package-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
.badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: bold;
text-transform: uppercase;
}
.badge-primary {
background: var(--primary-color);
color: white;
}
.badge-success {
background: var(--success-color);
color: white;
}
.badge-warning {
background: var(--warning-color);
color: white;
}
@media (max-width: 768px) {
.package-actions {
justify-content: stretch;
}
.package-actions .btn {
flex: 1;
text-align: center;
}
.meta-item {
flex-direction: column;
gap: 0.25rem;
}
}
</style>
<script>
function checkForUpdate(author, name, location) {
alert('Update checking feature coming soon!');
// TODO: Implement update checking
}
function uninstallPackage(author, name, location) {
if (confirm('Are you sure you want to uninstall ' + name + '?')) {
alert('Uninstall feature coming soon!');
// TODO: Implement package uninstallation
}
}
</script>
`;
%>
<%- include('../layout', { body: body, currentPage: 'contentdb', title: title }) %>

View File

@@ -0,0 +1,22 @@
<%
const body = `
<div class="page-header">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<h2>Package Details</h2>
<p>View and install content from ContentDB</p>
</div>
<a href="/contentdb" class="btn btn-secondary">← Back to Browse</a>
</div>
</div>
<div class="card">
<div class="card-body text-center">
<p>Package details will be displayed here.</p>
<a href="/contentdb" class="btn btn-primary">Browse All Content</a>
</div>
</div>
`;
%>
<%- include('../layout', { body: body, currentPage: 'contentdb', title: title }) %>

View File

@@ -0,0 +1,22 @@
<%
const body = `
<div class="page-header">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<h2>Popular Content</h2>
<p>Most downloaded mods and games from ContentDB</p>
</div>
<a href="/contentdb" class="btn btn-secondary">← Back to Browse</a>
</div>
</div>
<div class="card">
<div class="card-body text-center">
<p>Popular content will be displayed here.</p>
<a href="/contentdb" class="btn btn-primary">Browse All Content</a>
</div>
</div>
`;
%>
<%- include('../layout', { body: body, currentPage: 'contentdb', title: title }) %>

View File

@@ -0,0 +1,22 @@
<%
const body = `
<div class="page-header">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<h2>Recent Content</h2>
<p>Recently added mods and games from ContentDB</p>
</div>
<a href="/contentdb" class="btn btn-secondary">← Back to Browse</a>
</div>
</div>
<div class="card">
<div class="card-body text-center">
<p>Recent content will be displayed here.</p>
<a href="/contentdb" class="btn btn-primary">Browse All Content</a>
</div>
</div>
`;
%>
<%- include('../layout', { body: body, currentPage: 'contentdb', title: title }) %>

305
views/contentdb/updates.ejs Normal file
View File

@@ -0,0 +1,305 @@
<%
const body = `
<div class="page-header">
<h2>🔄 Package Updates</h2>
<p>Check and install updates for your packages</p>
</div>
<div class="row">
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h3>📊 Update Status</h3>
</div>
<div class="card-body">
<div class="stat-item">
<strong>${installedCount || 0}</strong>
<span>Total Packages</span>
</div>
<div class="stat-item">
<strong>${updateCount || 0}</strong>
<span>Updates Available</span>
</div>
<div class="stat-item">
<strong>${installedCount - updateCount || 0}</strong>
<span>Up to Date</span>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>⚡ Quick Actions</h3>
</div>
<div class="card-body">
${updateCount > 0 ? `
<button class="btn btn-success btn-block" onclick="updateAllPackages()">
📦 Update All (${updateCount})
</button>
<button class="btn btn-outline-primary btn-block" onclick="window.location.reload()">
🔄 Refresh Check
</button>
` : `
<button class="btn btn-outline-primary btn-block" onclick="window.location.reload()">
🔄 Check Again
</button>
`}
<a href="/contentdb/installed" class="btn btn-outline-secondary btn-block">
📦 View All Installed
</a>
<a href="/contentdb" class="btn btn-outline-secondary btn-block">
🌐 Browse ContentDB
</a>
</div>
</div>
</div>
<div class="col-md-8">
${updateCount === 0 ? `
<div class="card">
<div class="card-body text-center">
<h3>✅ All Packages Up to Date!</h3>
<p>All your installed packages are running the latest versions.</p>
<div class="emoji-large">🎉</div>
<p class="text-muted">
${installedCount === 0 ?
'You haven\\'t installed any packages yet.' :
\`Checked \${installedCount} package\${installedCount !== 1 ? 's' : ''}.\`
}
</p>
</div>
</div>
` : `
<div class="updates-list">
${updates.map(update => `
<div class="card update-card">
<div class="card-header">
<div class="update-title">
<h4>${update.latest.package.title || update.installed.name}</h4>
<small class="text-muted">by ${update.installed.author}</small>
</div>
<div class="update-badge">
<span class="badge badge-warning">Update Available</span>
</div>
</div>
<div class="card-body">
<div class="version-comparison">
<div class="version-item current">
<div class="version-label">Current Version</div>
<div class="version-value">${update.installed.version}</div>
<div class="version-date">
Installed: ${new Date(update.installed.installed_at).toLocaleDateString()}
</div>
</div>
<div class="version-arrow">➜</div>
<div class="version-item latest">
<div class="version-label">Latest Version</div>
<div class="version-value">${update.latest.release.title}</div>
<div class="version-date">
Released: ${new Date(update.latest.release.created_at).toLocaleDateString()}
</div>
</div>
</div>
<div class="package-location">
<strong>Location:</strong>
<span class="location-badge ${update.installed.install_location === 'global' ? 'global' : 'world'}">
${update.installed.install_location === 'global' ? 'Global' : update.installed.install_location.replace('world:', '')}
</span>
</div>
<div class="update-actions">
<button class="btn btn-success"
onclick="updatePackage('${update.installed.author}', '${update.installed.name}', '${update.installed.install_location}')">
📦 Update Now
</button>
<a href="https://content.luanti.org/packages/${update.installed.author}/${update.installed.name}/"
target="_blank"
class="btn btn-outline-primary">
View on ContentDB
</a>
</div>
</div>
</div>
`).join('')}
</div>
`}
</div>
</div>
<style>
.stat-item {
text-align: center;
padding: 0.75rem 0;
border-bottom: 1px solid var(--border-color);
}
.stat-item:last-child {
border-bottom: none;
}
.stat-item strong {
display: block;
font-size: 1.5rem;
color: var(--primary-color);
}
.stat-item span {
font-size: 0.875rem;
color: var(--text-muted);
}
.emoji-large {
font-size: 3rem;
margin: 1rem 0;
}
.updates-list {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.update-card {
transition: transform 0.2s ease;
}
.update-card:hover {
transform: translateY(-2px);
}
.update-title {
flex: 1;
}
.update-title h4 {
margin: 0;
color: var(--text-primary);
}
.version-comparison {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
padding: 1rem;
background: var(--bg-accent);
border-radius: var(--border-radius);
}
.version-item {
flex: 1;
text-align: center;
}
.version-label {
font-size: 0.875rem;
color: var(--text-muted);
margin-bottom: 0.25rem;
}
.version-value {
font-size: 1.1rem;
font-weight: bold;
color: var(--text-primary);
margin-bottom: 0.25rem;
}
.version-date {
font-size: 0.75rem;
color: var(--text-muted);
}
.version-arrow {
font-size: 1.5rem;
color: var(--primary-color);
}
.current .version-value {
color: var(--warning-color);
}
.latest .version-value {
color: var(--success-color);
}
.package-location {
margin-bottom: 1rem;
font-size: 0.9rem;
}
.location-badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: bold;
text-transform: uppercase;
}
.location-badge.global {
background: var(--success-color);
color: white;
}
.location-badge.world {
background: var(--primary-color);
color: white;
}
.update-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: bold;
text-transform: uppercase;
}
.badge-warning {
background: var(--warning-color);
color: white;
}
@media (max-width: 768px) {
.version-comparison {
flex-direction: column;
gap: 0.5rem;
}
.version-arrow {
transform: rotate(90deg);
}
.update-actions {
flex-direction: column;
}
.update-actions .btn {
width: 100%;
}
}
</style>
<script>
function updatePackage(author, name, location) {
alert('Update functionality coming soon!');
// TODO: Implement individual package update
}
function updateAllPackages() {
if (!confirm('Update all packages? This may take a while.')) {
return;
}
alert('Bulk update functionality coming soon!');
// TODO: Implement bulk package update
}
</script>
`;
%>
<%- include('../layout', { body: body, currentPage: 'contentdb', title: title }) %>

142
views/dashboard.ejs Normal file
View File

@@ -0,0 +1,142 @@
<%
const body = `
<!-- Dashboard Statistics -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">${stats.worlds}</div>
<div class="stat-label">Worlds</div>
</div>
<div class="stat-card">
<div class="stat-value">${stats.mods}</div>
<div class="stat-label">Mods</div>
</div>
<div class="stat-card">
<div class="stat-value">
<span id="server-status" class="status status-stopped">
Loading...
</span>
</div>
<div class="stat-label">Server Status</div>
</div>
<div class="stat-card">
<div class="stat-value" style="font-size: 1rem; word-break: break-all;">
${stats.minetestDir}
</div>
<div class="stat-label">Minetest Directory</div>
</div>
</div>
<!-- Quick Actions -->
<div class="card">
<div class="card-header">
<h2>Quick Actions</h2>
</div>
<div class="grid grid-2">
<div class="card" style="margin: 0;">
<h3>World Management</h3>
<p style="color: var(--text-secondary); margin-bottom: 1rem;">
Create and manage your game worlds
</p>
<div class="btn-group">
<a href="/worlds" class="btn btn-primary">Manage Worlds</a>
<a href="/worlds/new" class="btn btn-outline">Create World</a>
</div>
</div>
<div class="card" style="margin: 0;">
<h3>Extensions</h3>
<p style="color: var(--text-secondary); margin-bottom: 1rem;">
Manage games, mods, and texture packs
</p>
<div class="btn-group">
<a href="/extensions" class="btn btn-primary">Manage Extensions</a>
</div>
</div>
<div class="card" style="margin: 0;">
<h3>Server Control</h3>
<p style="color: var(--text-secondary); margin-bottom: 1rem;">
Start, stop, and monitor your server
</p>
<div class="btn-group">
<a href="/server" class="btn btn-primary">Server Console</a>
</div>
</div>
<div class="card" style="margin: 0;">
<h3>ContentDB Browser</h3>
<p style="color: var(--text-secondary); margin-bottom: 1rem;">
Discover new content on ContentDB
</p>
<div class="btn-group">
<a href="/contentdb" class="btn btn-primary">Browse ContentDB</a>
</div>
</div>
</div>
</div>
<!-- System Information -->
<div class="card">
<h3>System Information</h3>
<div class="table-container">
<table class="table">
<tbody>
<tr>
<td><strong>Platform</strong></td>
<td>${systemInfo.platform}</td>
</tr>
<tr>
<td><strong>Architecture</strong></td>
<td>${systemInfo.arch}</td>
</tr>
<tr>
<td><strong>Node.js Version</strong></td>
<td>${systemInfo.nodeVersion}</td>
</tr>
<tr>
<td><strong>Minetest Directory</strong></td>
<td style="word-break: break-all;">${stats.minetestDir}</td>
</tr>
<tr>
<td><strong>Server Uptime</strong></td>
<td id="server-uptime">N/A</td>
</tr>
<tr>
<td><strong>Process ID</strong></td>
<td id="server-pid">N/A</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Recent Activity (placeholder for future implementation) -->
<div class="card">
<h3>Recent Activity</h3>
<div class="empty-state">
<p>Activity logging will be implemented in a future update.</p>
<small>This will show recent world changes, mod installations, and server events.</small>
</div>
</div>
<script src="/static/js/shared-status.js"></script>
<script>
// Set current page for navigation
window.addEventListener('DOMContentLoaded', function() {
// Add any dashboard-specific JavaScript here
console.log('Dashboard loaded');
// Update page context for navigation
if (window.luantiWebServer) {
window.luantiWebServer.currentPage = 'dashboard';
}
// Load server status using shared function
updateServerStatus('server-status');
});
</script>
`;
%>
<%- include('layout', { body: body, currentPage: 'dashboard', title: title }) %>

41
views/error.ejs Normal file
View File

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Error | Luanti Server Manager</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<div class="container">
<div class="card" style="max-width: 600px; margin: 2rem auto; text-align: center;">
<div style="font-size: 4rem; color: var(--danger-color); margin-bottom: 1rem;">
⚠️
</div>
<h1 style="color: var(--danger-color); margin-bottom: 1rem;">
<%= error %>
</h1>
<% if (typeof message !== 'undefined' && message) { %>
<div class="alert alert-danger" style="text-align: left;">
<strong>Details:</strong> <%= message %>
</div>
<% } %>
<div class="btn-group" style="margin-top: 2rem;">
<a href="javascript:history.back()" class="btn btn-secondary">
Go Back
</a>
<a href="/" class="btn btn-primary">
Return to Dashboard
</a>
</div>
<div style="margin-top: 2rem; font-size: 0.875rem; color: var(--text-secondary);">
<p>If this problem persists, please check the server logs or restart the application.</p>
</div>
</div>
</div>
</body>
</html>

693
views/extensions/index.ejs Normal file
View File

@@ -0,0 +1,693 @@
<%
const body = `
<div class="page-header">
<h2>🧩 Extensions</h2>
<p>Manage games, mods, and texture packs for your Luanti server</p>
</div>
<div class="extensions-layout">
<!-- Sidebar -->
<div class="extensions-sidebar">
<!-- Overview Card -->
<div class="card">
<div class="card-header">
<h4>📊 Overview</h4>
</div>
<div class="card-body">
<div class="overview-stats">
<div class="stat-item">
<strong>${statistics.games || 0}</strong>
<span>Games</span>
</div>
<div class="stat-item">
<strong>${(statistics.global_packages || 0) + (statistics.local_mods || 0)}</strong>
<span>Mods</span>
</div>
<div class="stat-item">
<strong>${statistics.total_packages || 0}</strong>
<span>Total</span>
</div>
</div>
</div>
</div>
<!-- Quick Install Card -->
<div class="card">
<div class="card-header">
<h4>⚡ Quick Install</h4>
</div>
<div class="card-body">
<form id="quickInstallForm">
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
<div class="form-group mb-3">
<label for="quickPackageUrl">Package URL or Author/Name:</label>
<input type="text" id="quickPackageUrl" name="packageUrl" class="form-control"
placeholder="e.g., mesecons or author/name" required>
<div id="quickUrlValidation"></div>
</div>
<div class="form-group mb-3" id="quickLocationGroup">
<label for="quickInstallLocation">Install Location:</label>
<select id="quickInstallLocation" name="installLocation" class="form-control">
<option value="global">Global</option>
<option value="world">Specific World</option>
</select>
<select id="quickWorldName" name="worldName" class="form-control mt-2" style="display: none;">
<option value="">Select a world...</option>
</select>
</div>
<div class="form-group mb-3">
<label>
<input type="checkbox" name="installDeps" value="on">
Install Dependencies
</label>
</div>
<button type="submit" id="quickInstallBtn" class="btn btn-primary btn-block">
📦 Install
</button>
<div id="quickInstallStatus" style="display: none;">
<div id="quickStatusAlert" class="alert mt-2">
<span id="quickStatusMessage"></span>
</div>
</div>
</form>
</div>
</div>
</div>
<!-- Main Content -->
<div class="extensions-main">
<div class="extensions-header">
<div class="extensions-tabs">
<button class="tab-btn active" onclick="filterExtensions('all')">
All (${allContent.length})
</button>
<button class="tab-btn" onclick="filterExtensions('game')">
Games (${allContent.filter(c => (c.package_type || c.type) === 'game').length})
</button>
<button class="tab-btn" onclick="filterExtensions('mod')">
Mods (${allContent.filter(c => (c.package_type || c.type) === 'mod').length})
</button>
<button class="tab-btn" onclick="filterExtensions('txp')">
Texture Packs (${allContent.filter(c => (c.package_type || c.type) === 'txp').length})
</button>
</div>
</div>
${allContent.length === 0 ? `
<div class="card">
<div class="card-body text-center">
<h3>📭 No Extensions Installed</h3>
<p>Install games, mods, and texture packs from ContentDB or add them manually.</p>
<a href="/contentdb" class="btn btn-primary">
Browse ContentDB
</a>
</div>
</div>
` : `
<div class="extensions-grid" id="extensionsGrid">
${allContent.map(ext => {
const type = ext.package_type || ext.type;
const typeIcon = type === 'game' ? '🎮' : type === 'txp' ? '🎨' : '📦';
const typeBadge = type === 'game' ? 'success' : type === 'txp' ? 'warning' : 'primary';
const sourceIcon = ext.source === 'contentdb' ? '🌐' : '📁';
return `
<div class="card extension-card" data-type="${type}">
<div class="card-header">
<div class="extension-title">
<h4>${typeIcon} ${ext.title || ext.name}</h4>
<small class="text-muted">
${sourceIcon} ${ext.author || 'Local'}
${ext.source === 'contentdb' ? '(ContentDB)' : '(Local)'}
</small>
</div>
<div class="extension-badges">
<span class="badge badge-${typeBadge}">
${type === 'txp' ? 'Texture Pack' : type.charAt(0).toUpperCase() + type.slice(1)}
</span>
</div>
</div>
<div class="card-body">
<div class="extension-details">
<p class="extension-description">
${ext.short_description || ext.description || 'No description available.'}
</p>
<div class="extension-meta">
<div class="meta-item">
<strong>Location:</strong>
<span class="location-badge ${ext.install_location === 'global' || ext.location === 'global' ? 'global' : 'world'}">
${ext.install_location === 'global' || ext.location === 'global' ? 'Global' :
ext.install_location ? ext.install_location.replace('world:', '') : ext.location || 'Games'}
</span>
</div>
${ext.version ? `
<div class="meta-item">
<strong>Version:</strong>
<span>${ext.version}</span>
</div>
` : ''}
<div class="meta-item">
<strong>Modified:</strong>
<span>${ext.installed_at ? new Date(ext.installed_at).toLocaleDateString() :
new Date(ext.lastModified).toLocaleDateString()}</span>
</div>
</div>
${ext.dependencies && ext.dependencies.length > 0 ? `
<div class="dependencies">
<strong>Dependencies (${ext.dependencies.length}):</strong>
<div class="dep-list">
${ext.dependencies.map(dep =>
typeof dep === 'string' ? dep : `${dep.author}/${dep.name}`
).join(', ')}
</div>
</div>
` : ''}
</div>
<div class="extension-actions">
${ext.contentdb_url ? `
<a href="${ext.contentdb_url}" target="_blank" class="btn btn-outline-primary btn-sm">
View on ContentDB
</a>
` : ''}
${ext.source === 'contentdb' ? `
<button class="btn btn-outline-warning btn-sm"
onclick="checkForUpdate('${ext.author}', '${ext.name}')">
Check Update
</button>
` : ''}
<button class="btn btn-outline-danger btn-sm"
onclick="uninstallExtension('${ext.name}', '${type}', '${ext.install_location || ext.location}')">
Remove
</button>
</div>
</div>
</div>
`;
}).join('')}
</div>
`}
</div>
</div>
<style>
.extensions-layout {
display: flex;
gap: 2rem;
}
.extensions-sidebar {
flex: 0 0 300px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.extensions-main {
flex: 1;
min-width: 0;
}
.extensions-header {
margin-bottom: 1rem;
}
.extensions-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.tab-btn {
padding: 0.5rem 1rem;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-secondary);
border-radius: var(--border-radius);
cursor: pointer;
transition: all 0.2s ease;
}
.tab-btn:hover {
background: var(--bg-accent);
color: var(--text-primary);
}
.tab-btn.active {
background: var(--primary-color);
color: white;
border-color: var(--primary-color);
}
.extensions-grid {
display: grid;
gap: 1rem;
}
.extension-card {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.extension-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-block), 0 8px 16px rgba(0, 0, 0, 0.1);
}
.extension-card[data-type="game"] {
border-left: 4px solid var(--success-color);
}
.extension-card[data-type="mod"] {
border-left: 4px solid var(--primary-color);
}
.extension-card[data-type="txp"] {
border-left: 4px solid var(--warning-color);
}
.extension-title h4 {
margin: 0;
color: var(--text-primary);
}
.extension-description {
color: var(--text-secondary);
margin-bottom: 1rem;
font-size: 0.9rem;
line-height: 1.4;
}
.extension-meta {
background: var(--bg-accent);
padding: 0.75rem;
border-radius: var(--border-radius);
margin-bottom: 1rem;
font-size: 0.875rem;
}
.extension-badges {
display: flex;
gap: 0.5rem;
align-items: center;
}
.stat-item {
text-align: center;
padding: 0.75rem 0;
border-bottom: 1px solid var(--border-color);
}
.stat-item:last-child {
border-bottom: none;
}
.stat-item strong {
display: block;
font-size: 1.5rem;
color: var(--primary-color);
}
.stat-item span {
font-size: 0.875rem;
color: var(--text-muted);
}
.meta-item {
display: flex;
justify-content: space-between;
margin-bottom: 0.25rem;
}
.meta-item:last-child {
margin-bottom: 0;
}
.location-badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: bold;
text-transform: uppercase;
}
.location-badge.global {
background: var(--success-color);
color: white;
}
.location-badge.world {
background: var(--primary-color);
color: white;
}
.dependencies {
background: var(--bg-secondary);
padding: 0.75rem;
border-radius: var(--border-radius);
margin-bottom: 1rem;
font-size: 0.875rem;
}
.dep-list {
margin-top: 0.5rem;
color: var(--text-muted);
font-family: monospace;
}
.extension-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: bold;
text-transform: uppercase;
}
.badge-primary {
background: var(--primary-color);
color: white;
}
.badge-success {
background: var(--success-color);
color: white;
}
.badge-warning {
background: var(--warning-color);
color: white;
}
@media (max-width: 768px) {
.extensions-layout {
flex-direction: column;
}
.extensions-sidebar {
flex: none;
order: 2;
}
.extensions-main {
order: 1;
}
.extensions-tabs {
flex-direction: column;
}
.tab-btn {
text-align: center;
}
.extension-actions {
justify-content: stretch;
}
.extension-actions .btn {
flex: 1;
text-align: center;
}
.meta-item {
flex-direction: column;
gap: 0.25rem;
}
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const quickInstallForm = document.getElementById('quickInstallForm');
const quickPackageUrlInput = document.getElementById('quickPackageUrl');
const quickInstallLocationSelect = document.getElementById('quickInstallLocation');
const quickWorldNameSelect = document.getElementById('quickWorldName');
const quickLocationGroup = document.getElementById('quickLocationGroup');
const quickInstallBtn = document.getElementById('quickInstallBtn');
const quickInstallStatus = document.getElementById('quickInstallStatus');
const quickUrlValidation = document.getElementById('quickUrlValidation');
// Load available worlds
loadWorlds();
// Show/hide world selection
quickInstallLocationSelect.addEventListener('change', function() {
if (this.value === 'world') {
quickWorldNameSelect.style.display = 'block';
quickWorldNameSelect.required = true;
} else {
quickWorldNameSelect.style.display = 'none';
quickWorldNameSelect.required = false;
}
});
// URL validation
let validationTimeout;
quickPackageUrlInput.addEventListener('input', function() {
clearTimeout(validationTimeout);
validationTimeout = setTimeout(() => {
validateUrl(this.value);
}, 500);
});
// Quick install form submission
quickInstallForm.addEventListener('submit', async function(e) {
e.preventDefault();
const url = quickPackageUrlInput.value.trim();
if (!url) {
showQuickStatus('Please enter a package URL', 'danger');
return;
}
quickInstallBtn.disabled = true;
quickInstallBtn.textContent = '⏳ Installing...';
showQuickStatus('Installing package...', 'info');
try {
const formData = new FormData(this);
const params = new URLSearchParams();
for (let [key, value] of formData.entries()) {
params.append(key, value);
}
const response = await fetch('/extensions/install-url', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest'
},
body: params
});
const result = await response.json();
if (result.success) {
showQuickStatus(result.message + ' ✅', 'success');
quickInstallForm.reset();
quickWorldNameSelect.style.display = 'none';
quickWorldNameSelect.required = false;
// Reload page after 2 seconds to show new extension
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
// Handle specific validation errors with better messaging
if (result.type === 'invalid_installation_target' && result.packageType === 'game') {
showQuickStatus('❌ ' + result.error, 'warning');
} else {
showQuickStatus(result.error || 'Installation failed', 'danger');
}
}
} catch (error) {
console.error('Installation error:', error);
showQuickStatus('Installation failed: ' + error.message, 'danger');
} finally {
quickInstallBtn.disabled = false;
quickInstallBtn.textContent = '📦 Install';
}
});
async function loadWorlds() {
try {
const response = await fetch('/api/worlds');
const worlds = await response.json();
quickWorldNameSelect.innerHTML = '<option value="">Select a world...</option>';
worlds.forEach(world => {
const option = document.createElement('option');
option.value = world.name;
option.textContent = world.displayName || world.name;
quickWorldNameSelect.appendChild(option);
});
} catch (error) {
console.error('Failed to load worlds:', error);
}
}
async function validateUrl(url) {
if (!url.trim()) {
quickUrlValidation.innerHTML = '';
resetLocationOptions();
return;
}
const parsed = parseContentDBUrl(url);
if (parsed.author && parsed.name) {
quickUrlValidation.innerHTML = '<small class="text-info">🔄 Checking package...</small>';
try {
// Check package type via API
const response = await fetch('/api/contentdb/package-info', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ author: parsed.author, name: parsed.name })
});
if (response.ok) {
const packageInfo = await response.json();
const packageType = packageInfo.type || 'mod';
if (packageType === 'game') {
quickUrlValidation.innerHTML = '<small class="text-success">✅ Game: ' + parsed.author + '/' + parsed.name + '</small>';
restrictLocationOptionsForGame();
} else {
quickUrlValidation.innerHTML = '<small class="text-success">✅ ' + packageType.charAt(0).toUpperCase() + packageType.slice(1) + ': ' + parsed.author + '/' + parsed.name + '</small>';
resetLocationOptions();
}
} else {
quickUrlValidation.innerHTML = '<small class="text-success">✅ Valid: ' + parsed.author + '/' + parsed.name + '</small>';
resetLocationOptions();
}
} catch (error) {
quickUrlValidation.innerHTML = '<small class="text-success">✅ Valid: ' + parsed.author + '/' + parsed.name + '</small>';
resetLocationOptions();
}
} else {
quickUrlValidation.innerHTML = '<small class="text-danger">❌ Invalid URL format</small>';
resetLocationOptions();
}
}
function restrictLocationOptionsForGame() {
// For games, only allow global installation
quickInstallLocationSelect.innerHTML = '<option value="global">Global (Games are shared across all worlds)</option>';
quickInstallLocationSelect.disabled = true;
quickWorldNameSelect.style.display = 'none';
quickWorldNameSelect.required = false;
// Add explanation
const existingWarning = document.getElementById('game-warning');
if (!existingWarning) {
const warning = document.createElement('div');
warning.id = 'game-warning';
warning.className = 'alert alert-info mt-2';
warning.innerHTML = '<small><strong> Note:</strong> Games are installed globally and shared across all worlds. To use this game, create a new world and select it during world creation.</small>';
quickLocationGroup.appendChild(warning);
}
}
function resetLocationOptions() {
// Reset to normal options
quickInstallLocationSelect.innerHTML =
'<option value="global">Global</option>' +
'<option value="world">Specific World</option>';
quickInstallLocationSelect.disabled = false;
// Remove warning if it exists
const warning = document.getElementById('game-warning');
if (warning) {
warning.remove();
}
// Reset world selection based on current value
if (quickInstallLocationSelect.value === 'world') {
quickWorldNameSelect.style.display = 'block';
quickWorldNameSelect.required = true;
} else {
quickWorldNameSelect.style.display = 'none';
quickWorldNameSelect.required = false;
}
}
function parseContentDBUrl(url) {
url = url.replace(/^https?:\\/\\//, '').replace(/\\/$/, '');
const patterns = [
/^content\\.luanti\\.org\\/packages\\/([^/]+)\\/([^/]+)$/,
/^([^/]+)\\/([^/]+)$/
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match) {
return { author: match[1], name: match[2] };
}
}
return { author: null, name: null };
}
function showQuickStatus(message, type) {
const statusAlert = document.getElementById('quickStatusAlert');
const statusMessage = document.getElementById('quickStatusMessage');
const alertClass = 'alert-' + type;
statusAlert.className = 'alert mt-2 ' + alertClass;
statusMessage.textContent = message;
quickInstallStatus.style.display = 'block';
}
});
function filterExtensions(type) {
const cards = document.querySelectorAll('.extension-card');
const tabs = document.querySelectorAll('.tab-btn');
// Update active tab
tabs.forEach(tab => tab.classList.remove('active'));
event.target.classList.add('active');
// Filter cards
cards.forEach(card => {
if (type === 'all') {
card.style.display = 'block';
} else {
const cardType = card.getAttribute('data-type');
card.style.display = cardType === type ? 'block' : 'none';
}
});
}
function checkForUpdate(author, name) {
alert('Update checking feature coming soon!');
// TODO: Implement update checking
}
function uninstallExtension(name, type, location) {
if (confirm('Are you sure you want to remove ' + name + '?\\n\\nThis will permanently delete the extension files.')) {
alert('Uninstall feature coming soon!');
// TODO: Implement extension removal
}
}
</script>
`;
%>
<%- include('../layout', { body: body, currentPage: 'extensions', title: title }) %>

109
views/layout.ejs Normal file
View File

@@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= title %> | LuHost</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
</head>
<body>
<div class="container">
<!-- Header -->
<header class="header">
<div style="display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: 1rem;">
<div style="text-align: left;">
<h1>LuHost</h1>
<p>Hosting Luanti made easy</p>
</div>
<% if (typeof isAuthenticated !== 'undefined' && isAuthenticated && typeof user !== 'undefined') { %>
<div style="text-align: right;">
<div style="color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 0.5rem;">
Welcome, <strong><%= user.username %></strong>
</div>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<span id="connection-status" class="status status-running">Connected</span>
<a href="/logout" class="btn btn-sm btn-secondary">Logout</a>
</div>
</div>
<% } else { %>
<div style="margin-top: 1rem;">
<span id="connection-status" class="status status-running">Connected</span>
</div>
<% } %>
</div>
</header>
<!-- Navigation (only show when authenticated) -->
<% if (typeof isAuthenticated !== 'undefined' && isAuthenticated) { %>
<nav class="nav">
<div class="nav-item">
<a href="/" class="nav-link <%= currentPage === 'dashboard' ? 'active' : '' %>">
Dashboard
</a>
</div>
<div class="nav-item">
<a href="/worlds" class="nav-link <%= currentPage === 'worlds' ? 'active' : '' %>">
Worlds
</a>
</div>
<div class="nav-item">
<a href="/extensions" class="nav-link <%= currentPage === 'extensions' ? 'active' : '' %>">
Extensions
</a>
</div>
<div class="nav-item">
<a href="/server" class="nav-link <%= currentPage === 'server' ? 'active' : '' %>">
Server
</a>
</div>
<div class="nav-item">
<a href="/config" class="nav-link <%= currentPage === 'config' ? 'active' : '' %>">
Configuration
</a>
</div>
<div class="nav-item">
<a href="/users" class="nav-link <%= currentPage === 'users' ? 'active' : '' %>">
Users
</a>
</div>
</nav>
<% } %>
<!-- Main Content -->
<main>
<%- body %>
</main>
<!-- Footer -->
<footer style="margin-top: 2rem; padding: 2rem; text-align: center; color: var(--text-secondary); font-size: 0.875rem;">
<p><a href="https://git.medlab.host/Modpol/luhost" target="_blank" style="color: var(--primary-color);">LuHost</a> |
<a href="https://luanti.org" target="_blank" style="color: var(--primary-color);">Luanti</a> |
<a href="https://content.luanti.org" target="_blank" style="color: var(--primary-color);">ContentDB</a>
</p>
<p>A project of the <a href="https://www.colorado.edu/lab/medlab/" target="_blank" style="color: var(--primary-color);">Media Economies Design Lab</a></p>
</footer>
</div>
<!-- Socket.IO -->
<script src="/socket.io/socket.io.js"></script>
<!-- Main JavaScript -->
<script src="/static/js/main.js"></script>
<!-- Page-specific scripts -->
<% if (typeof scripts !== 'undefined') { %>
<% scripts.forEach(function(script) { %>
<script src="/static/js/<%= script %>"></script>
<% }); %>
<% } %>
<!-- Inline scripts -->
<% if (typeof inlineScript !== 'undefined') { %>
<script>
<%- inlineScript %>
</script>
<% } %>
</body>
</html>

318
views/server/index.ejs Normal file
View File

@@ -0,0 +1,318 @@
<%
const body = `
<div class="page-header">
<h2>🖥️ Server Management</h2>
<p>Monitor and control your Luanti server</p>
</div>
<div class="row">
<div class="col-md-4">
<div class="card server-status-card">
<div class="card-header">
<h3>📊 Server Status</h3>
</div>
<div class="card-body">
<div class="status-indicator" id="serverStatus">
<div class="status-light offline" id="statusLight"></div>
<span id="statusText">Loading...</span>
</div>
<div class="server-stats" id="serverStats">
<div class="stat-item">
<strong id="uptime">--</strong>
<span>Uptime</span>
</div>
<div class="stat-item">
<strong id="playerCount">--</strong>
<span>Players Online</span>
</div>
<div class="stat-item">
<strong id="memoryUsage">--</strong>
<span>Memory Usage</span>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>🎮 Server Controls</h3>
</div>
<div class="card-body">
<div class="control-group">
<label for="worldSelect">Choose world:</label>
<select id="worldSelect" class="form-control">
<option value="">Use server defaults (no specific world)</option>
<!-- Will be populated by JavaScript -->
</select>
</div>
<div class="server-controls">
<button id="startBtn" class="btn btn-success btn-block">
▶️ Start Server
</button>
<button id="stopBtn" class="btn btn-danger btn-block" disabled>
⏹️ Stop Server
</button>
<button id="restartBtn" class="btn btn-warning btn-block" disabled>
🔄 Restart Server
</button>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>⚙️ Quick Actions</h3>
</div>
<div class="card-body">
<a href="/config" class="btn btn-outline-primary btn-block">
⚙️ Server Configuration
</a>
<a href="/worlds" class="btn btn-outline-secondary btn-block">
🌍 World Configuration
</a>
<a href="/extensions" class="btn btn-outline-secondary btn-block">
🧩 Manage Extensions
</a>
<button id="downloadBtn" class="btn btn-outline-info btn-block">
📁 Download Logs
</button>
</div>
</div>
</div>
<div class="col-md-8">
<div class="card">
<div class="card-header">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h3>📋 Server Console</h3>
<div>
<button id="clearBtn" class="btn btn-outline-secondary btn-sm">Clear</button>
<button id="autoScrollBtn" class="btn btn-outline-primary btn-sm">
<span id="autoScrollText">Auto-scroll: ON</span>
</button>
</div>
</div>
</div>
<div class="card-body">
<div class="console" id="serverConsole">
<div class="console-content" id="consoleContent">
<div class="log-entry info">
<span class="timestamp">${new Date().toLocaleTimeString()}</span>
<span class="message">Console ready. Start server to see logs.</span>
</div>
</div>
</div>
<div class="console-input" id="consoleInputGroup" style="display: none;">
<div class="input-group">
<input type="text" id="consoleInput" class="form-control"
placeholder="Enter server command (e.g., /say Hello World)">
<div class="input-group-append">
<button id="sendBtn" class="btn btn-primary">Send</button>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>👥 Online Players</h3>
</div>
<div class="card-body">
<div id="playersList">
<p class="text-muted">No players online</p>
</div>
</div>
</div>
</div>
</div>
<style>
.server-status-card .card-body {
text-align: center;
}
.status-indicator {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.5rem;
font-size: 1.1rem;
font-weight: bold;
}
.status-light {
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 0.5rem;
animation: pulse 2s infinite;
}
.status-light.online {
background: var(--success-color);
}
.status-light.offline {
background: var(--danger-color);
animation: none;
}
.status-light.starting {
background: var(--warning-color);
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
.server-stats {
display: grid;
gap: 1rem;
}
.stat-item {
text-align: center;
padding: 0.75rem;
background: var(--bg-accent);
border-radius: var(--border-radius);
}
.stat-item strong {
display: block;
font-size: 1.3rem;
color: var(--primary-color);
}
.stat-item span {
font-size: 0.875rem;
color: var(--text-muted);
}
.control-group {
margin-bottom: 1rem;
}
.control-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
}
.server-controls {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.console {
background: #1a1a1a;
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius);
height: 400px;
font-family: 'Courier New', monospace;
font-size: 0.875rem;
overflow: hidden;
}
.console-content {
height: 100%;
overflow-y: auto;
overflow-x: auto;
padding: 1rem;
color: #ffffff;
scrollbar-width: auto;
scrollbar-color: #666666 #2a2a2a;
}
.console-content::-webkit-scrollbar {
width: 12px;
height: 12px;
}
.console-content::-webkit-scrollbar-track {
background: #2a2a2a;
border-radius: 6px;
}
.console-content::-webkit-scrollbar-thumb {
background: #666666;
border-radius: 6px;
border: 2px solid #2a2a2a;
}
.console-content::-webkit-scrollbar-thumb:hover {
background: #888888;
}
.console-content::-webkit-scrollbar-corner {
background: #2a2a2a;
}
.log-entry {
margin-bottom: 0.25rem;
word-wrap: break-word;
}
.log-entry.stdout {
color: #ffffff;
}
.log-entry.stderr {
color: #ff6b6b;
}
.log-entry.info {
color: #74c0fc;
}
.log-entry.warning {
color: #ffd43b;
}
.log-entry.error {
color: #ff6b6b;
}
.timestamp {
color: #868e96;
margin-right: 0.5rem;
}
.console-input {
margin-top: 1rem;
}
.console-input .form-control {
background: var(--bg-secondary);
border-color: var(--border-color);
color: var(--text-primary);
}
.console-input .form-control:focus {
background: var(--bg-secondary);
border-color: var(--primary-color);
color: var(--text-primary);
box-shadow: 0 0 0 0.2rem rgba(var(--primary-rgb), 0.25);
}
@media (max-width: 768px) {
.server-controls {
gap: 0.75rem;
}
.console {
height: 300px;
}
}
</style>
`;
%>
<%- include('../layout', { body: body, currentPage: 'server', title: title }) %>

114
views/users/index.ejs Normal file
View File

@@ -0,0 +1,114 @@
<%
const body = `
<div class="card">
<div class="card-header">
<h2>User Management</h2>
<a href="/users/new" class="btn btn-success">Create New User</a>
</div>
${typeof req !== 'undefined' && req.query.created ? `
<div class="alert alert-success">
User "${req.query.created}" created successfully!
</div>
` : ''}
${typeof req !== 'undefined' && req.query.deleted ? `
<div class="alert alert-info">
User deleted successfully.
</div>
` : ''}
${typeof req !== 'undefined' && req.query.error ? `
<div class="alert alert-danger">
<strong>Error:</strong> ${req.query.error}
</div>
` : ''}
<div class="alert alert-info">
<strong>Feudal Authority:</strong> Only you can create new user accounts. All users have full administrator privileges over the Luanti server.
</div>
${users.length === 0 ? `
<div class="empty-state">
<div style="font-size: 3rem; margin-bottom: 1rem;">👥</div>
<h3>No Users Found</h3>
<p>This shouldn't happen since you're logged in. Please report this issue.</p>
</div>
` : `
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Username</th>
<th>Created</th>
<th>Last Login</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${users.map(user => `
<tr>
<td>
<strong>${user.username}</strong>
${user.id === 1 ? '<span class="status" style="background: #e8f5e8; color: #2e7d32; margin-left: 0.5rem;">Founder</span>' : ''}
</td>
<td>
<small>${formatDate(user.created_at)}</small>
</td>
<td>
${user.last_login ? `<small>${formatDate(user.last_login)}</small>` : '<small style="color: var(--text-secondary);">Never</small>'}
</td>
<td>
<div class="btn-group">
${user.id !== 1 ? `
<form method="POST" action="/users/delete/${user.id}"
style="display: inline;"
onsubmit="return confirmDelete('user', '${user.username}')">
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
` : `
<span class="btn btn-sm btn-secondary" style="cursor: not-allowed;" title="Cannot delete founder account">Protected</span>
`}
</div>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`}
</div>
<div class="card">
<h3>Authority & Permissions</h3>
<div style="display: grid; gap: 1rem;">
<div>
<h4 style="color: var(--primary-color); margin-bottom: 0.5rem;">🏰 Feudal System</h4>
<p>This server uses an "implicit feudalism" security model:</p>
<ul style="margin-left: 1.5rem; color: var(--text-secondary);">
<li>Only existing administrators can create new accounts</li>
<li>The founder account (first user) cannot be deleted</li>
<li>All users have equal administrative privileges</li>
<li>No public registration - authority must be granted</li>
</ul>
</div>
<div>
<h4 style="color: var(--success-color); margin-bottom: 0.5rem;">👑 Administrative Powers</h4>
<p>Every user account can:</p>
<ul style="margin-left: 1.5rem; color: var(--text-secondary);">
<li>Manage worlds (create, configure, delete)</li>
<li>Install and manage mods</li>
<li>Browse and install from ContentDB</li>
<li>Control the Luanti server (start, stop, restart)</li>
<li>Modify server configuration</li>
<li>Create additional user accounts</li>
</ul>
</div>
</div>
</div>
`;
%>
<%- include('../layout', { body: body, currentPage: 'users', title: title }) %>

106
views/users/new.ejs Normal file
View File

@@ -0,0 +1,106 @@
<%
const body = `
<div style="max-width: 500px; margin: 2rem auto;">
<div class="card">
<div class="card-header">
<h2>Create New Administrator</h2>
<p style="color: var(--text-secondary); margin: 0;">
Grant administrative access to a new user
</p>
</div>
${typeof error !== 'undefined' ? `
<div class="alert alert-danger">
<strong>Error:</strong> ${error}
</div>
` : ''}
<div class="alert alert-info">
<strong>Authority Note:</strong> This user will have full administrative privileges over the Luanti server, including the ability to create additional accounts.
</div>
<form method="POST" action="/users/create">
${typeof csrfToken !== 'undefined' && csrfToken ? `<input type="hidden" name="_csrf" value="${csrfToken}">` : ''}
<div class="form-group">
<label for="username">Username*</label>
<input type="text"
id="username"
name="username"
class="form-control"
value="${typeof formData !== 'undefined' ? formData.username || '' : ''}"
required
pattern="[a-zA-Z0-9_-]{3,20}"
title="3-20 characters, letters, numbers, underscore, or hyphen only"
data-validate-name
autofocus
autocomplete="username">
<small style="color: var(--text-secondary);">3-20 characters, letters, numbers, underscore, or hyphen only</small>
</div>
<div class="form-row">
<div class="form-group">
<label for="password">Password*</label>
<input type="password"
id="password"
name="password"
class="form-control"
required
minlength="8"
autocomplete="new-password">
<small style="color: var(--text-secondary);">At least 8 characters long</small>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm Password*</label>
<input type="password"
id="confirmPassword"
name="confirmPassword"
class="form-control"
required
minlength="8"
autocomplete="new-password">
</div>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 2rem;">
<a href="/users" class="btn btn-secondary">
Cancel
</a>
<button type="submit" class="btn btn-success">
Create Administrator
</button>
</div>
</form>
</div>
<div style="text-align: center; margin-top: 1rem; color: var(--text-secondary); font-size: 0.875rem;">
<p>This user will be able to perform all server management tasks and create additional accounts.</p>
</div>
</div>
<script>
// Client-side password confirmation validation
document.getElementById('confirmPassword').addEventListener('input', function() {
const password = document.getElementById('password').value;
const confirmPassword = this.value;
if (password && confirmPassword) {
if (password !== confirmPassword) {
this.setCustomValidity('Passwords do not match');
} else {
this.setCustomValidity('');
}
}
});
document.getElementById('password').addEventListener('input', function() {
const confirmPassword = document.getElementById('confirmPassword');
if (confirmPassword.value) {
confirmPassword.dispatchEvent(new Event('input'));
}
});
</script>
`;
%>
<%- include('../layout', { body: body, currentPage: 'users', title: title }) %>