commit 3aed09b60f1676acae5de89f21af8dcb574c0bac Author: Nathan Schneider Date: Sat Aug 23 17:32:37 2025 -0600 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3f6a792 --- /dev/null +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4653428 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9215031 --- /dev/null +++ b/README.md @@ -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 \ No newline at end of file diff --git a/app.js b/app.js new file mode 100644 index 0000000..82779f1 --- /dev/null +++ b/app.js @@ -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 }; \ No newline at end of file diff --git a/middleware/auth.js b/middleware/auth.js new file mode 100644 index 0000000..43fa474 --- /dev/null +++ b/middleware/auth.js @@ -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 +}; \ No newline at end of file diff --git a/middleware/security.js b/middleware/security.js new file mode 100644 index 0000000..3607f83 --- /dev/null +++ b/middleware/security.js @@ -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, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * 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 +}; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..732f915 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..31cd53c --- /dev/null +++ b/public/css/style.css @@ -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); +} \ No newline at end of file diff --git a/public/js/main.js b/public/js/main.js new file mode 100644 index 0000000..267bf12 --- /dev/null +++ b/public/js/main.js @@ -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 = ` + [${new Date(logEntry.timestamp).toLocaleTimeString()}] + ${logEntry.message} + `; + } + + 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 = ` + ${message} + + `; + + 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 = ` +
+
+
${text}
+
+ `; + } +}; + +window.hideLoading = function(element) { + if (typeof element === 'string') { + element = document.querySelector(element); + } + if (element) { + element.innerHTML = ''; + } +}; \ No newline at end of file diff --git a/public/js/server.js b/public/js/server.js new file mode 100644 index 0000000..070c599 --- /dev/null +++ b/public/js/server.js @@ -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 = + '' + + '' + + ''; + 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 = + '' + + '' + + ''; + } else { + worldSelect.innerHTML = ''; + 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 = + ''; + } +} + +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 = '' + timestamp + '' + + '' + escapeHtml(message) + ''; + + 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 = '

No players online

'; + return; + } + + // Create a table for better formatting with kick functionality + const playersHtml = '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + 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 '' + + '' + + '' + + '' + + ''; + }).join('') + + '' + + '
PlayerLast ActivityActions
' + escapeHtml(player.name) + '' + + '' + lastActivity + '
' + + '' + (player.lastAction || 'Active') + '' + + '
' + + '' + + '
'; + + 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); + } +} \ No newline at end of file diff --git a/public/js/shared-status.js b/public/js/shared-status.js new file mode 100644 index 0000000..02a1461 --- /dev/null +++ b/public/js/shared-status.js @@ -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}`; + } +} \ No newline at end of file diff --git a/routes/api.js b/routes/api.js new file mode 100644 index 0000000..1f84a7f --- /dev/null +++ b/routes/api.js @@ -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 +}; \ No newline at end of file diff --git a/routes/auth.js b/routes/auth.js new file mode 100644 index 0000000..a1494d6 --- /dev/null +++ b/routes/auth.js @@ -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; \ No newline at end of file diff --git a/routes/config.js b/routes/config.js new file mode 100644 index 0000000..e9ffa70 --- /dev/null +++ b/routes/config.js @@ -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; \ No newline at end of file diff --git a/routes/contentdb.js b/routes/contentdb.js new file mode 100644 index 0000000..b62c88a --- /dev/null +++ b/routes/contentdb.js @@ -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; \ No newline at end of file diff --git a/routes/extensions.js b/routes/extensions.js new file mode 100644 index 0000000..4a0b4bb --- /dev/null +++ b/routes/extensions.js @@ -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; \ No newline at end of file diff --git a/routes/mods.js b/routes/mods.js new file mode 100644 index 0000000..07eca18 --- /dev/null +++ b/routes/mods.js @@ -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; \ No newline at end of file diff --git a/routes/server.js b/routes/server.js new file mode 100644 index 0000000..877b682 --- /dev/null +++ b/routes/server.js @@ -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; \ No newline at end of file diff --git a/routes/users.js b/routes/users.js new file mode 100644 index 0000000..8832e81 --- /dev/null +++ b/routes/users.js @@ -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; \ No newline at end of file diff --git a/routes/worlds.js b/routes/worlds.js new file mode 100644 index 0000000..b273b0b --- /dev/null +++ b/routes/worlds.js @@ -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; \ No newline at end of file diff --git a/utils/app-config.js b/utils/app-config.js new file mode 100644 index 0000000..4d18c7d --- /dev/null +++ b/utils/app-config.js @@ -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(); \ No newline at end of file diff --git a/utils/auth.js b/utils/auth.js new file mode 100644 index 0000000..0e96e88 --- /dev/null +++ b/utils/auth.js @@ -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; \ No newline at end of file diff --git a/utils/config-manager.js b/utils/config-manager.js new file mode 100644 index 0000000..50f6f59 --- /dev/null +++ b/utils/config-manager.js @@ -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; \ No newline at end of file diff --git a/utils/config-parser.js b/utils/config-parser.js new file mode 100644 index 0000000..9b4e770 --- /dev/null +++ b/utils/config-parser.js @@ -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; \ No newline at end of file diff --git a/utils/contentdb-url.js b/utils/contentdb-url.js new file mode 100644 index 0000000..027c19a --- /dev/null +++ b/utils/contentdb-url.js @@ -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} - 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; \ No newline at end of file diff --git a/utils/contentdb.js b/utils/contentdb.js new file mode 100644 index 0000000..6f23e50 --- /dev/null +++ b/utils/contentdb.js @@ -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; \ No newline at end of file diff --git a/utils/package-registry.js b/utils/package-registry.js new file mode 100644 index 0000000..abda422 --- /dev/null +++ b/utils/package-registry.js @@ -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; \ No newline at end of file diff --git a/utils/paths.js b/utils/paths.js new file mode 100644 index 0000000..f3f2167 --- /dev/null +++ b/utils/paths.js @@ -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(); \ No newline at end of file diff --git a/utils/security-logger.js b/utils/security-logger.js new file mode 100644 index 0000000..20e63c7 --- /dev/null +++ b/utils/security-logger.js @@ -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; \ No newline at end of file diff --git a/utils/server-manager.js b/utils/server-manager.js new file mode 100644 index 0000000..354285b --- /dev/null +++ b/utils/server-manager.js @@ -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; \ No newline at end of file diff --git a/utils/shared-server-manager.js b/utils/shared-server-manager.js new file mode 100644 index 0000000..98a5535 --- /dev/null +++ b/utils/shared-server-manager.js @@ -0,0 +1,6 @@ +const ServerManager = require('./server-manager'); + +// Create a single shared instance +const sharedServerManager = new ServerManager(); + +module.exports = sharedServerManager; \ No newline at end of file diff --git a/views/auth/login.ejs b/views/auth/login.ejs new file mode 100644 index 0000000..975825a --- /dev/null +++ b/views/auth/login.ejs @@ -0,0 +1,63 @@ +<% +const body = ` +
+
+
+

Login to Luanti Server Manager

+

Enter your credentials to access the server management interface

+
+ + ${typeof error !== 'undefined' ? ` +
+ Error: ${typeof escapeHtml !== 'undefined' ? escapeHtml(error) : error} +
+ ` : ''} + + ${typeof req !== 'undefined' && req.query.message ? ` +
+ ${req.query.message} +
+ ` : ''} + +
+ + ${typeof csrfToken !== 'undefined' && csrfToken ? `` : ''} + +
+ + +
+ +
+ + +
+ +
+ +
+
+
+ +
+

Need an account? Contact an existing administrator to create one for you.

+
+
+`; +%> + +<%- include('../layout', { body: body, currentPage: 'login', title: title }) %> \ No newline at end of file diff --git a/views/auth/register.ejs b/views/auth/register.ejs new file mode 100644 index 0000000..6d68937 --- /dev/null +++ b/views/auth/register.ejs @@ -0,0 +1,116 @@ +<% +const body = ` +
+
+
+

${isFirstUser ? 'Setup Administrator Account' : 'Create Account'}

+

+ ${isFirstUser ? + 'Create the first administrator account for this Luanti server' : + 'Join this Luanti server management team' + } +

+
+ + ${typeof error !== 'undefined' ? ` +
+ Error: ${error} +
+ ` : ''} + + ${isFirstUser ? ` +
+ First User Setup: You are creating the first administrator account for this server. All users have full admin privileges. +
+ ` : ''} + +
+ ${typeof csrfToken !== 'undefined' && csrfToken ? `` : ''} +
+ + + 3-20 characters, letters, numbers, underscore, or hyphen only +
+ +
+
+ + + At least 8 characters long +
+ +
+ + +
+
+ +
+ + Already have an account? + + +
+
+
+ +
+

+ ${isFirstUser ? + 'This will be the primary administrator account.' : + 'All accounts have full server administration privileges.' + } +

+
+
+ + +`; +%> + +<%- include('../layout', { body: body, currentPage: 'register', title: title }) %> \ No newline at end of file diff --git a/views/config/index.ejs b/views/config/index.ejs new file mode 100644 index 0000000..cba8769 --- /dev/null +++ b/views/config/index.ejs @@ -0,0 +1,687 @@ +<% +const body = ` + + +
+
+
+
+
+

âš™ī¸ Server Configuration

+
+ ✅ Saved +
+
+

Configure all server settings below

+
+
+
+
+ +
Loading configuration...
+
+
+
+
+
+ +
+
+
+

💾 Actions

+
+
+
+ + + +
+
+
+ +
+
+

📝 Configuration File

+
+
+

+ Location: ~/.minetest/minetest.conf +

+ +
+
+ +
+
+

â„šī¸ Status

+
+
+
+
+ Data Directory:
+ Loading... +
+
+
+
+
+
+ + + + + + +`; +%> + +<%- include('../layout', { body: body, currentPage: 'config', title: title }) %> \ No newline at end of file diff --git a/views/contentdb/index.ejs b/views/contentdb/index.ejs new file mode 100644 index 0000000..8e4f852 --- /dev/null +++ b/views/contentdb/index.ejs @@ -0,0 +1,429 @@ +<% +const body = ` + + +
+
+
+
+

Install from URL

+
+
+
+ ${typeof csrfToken !== 'undefined' && csrfToken ? `` : ''} +
+ + + + Paste any ContentDB package URL or use format: author/package_name + +
+
+ +
+
+ + + Choose where to install this content +
+ + +
+ +
+
+ + +
+ + Recommended: Automatically download and install required dependencies + +
+ +
+ + +
+
+ + +
+
+
+ +
+
+
+

How to Use

+
+
+

📋 Step 1: Copy URL

+

Go to content.luanti.org and copy the URL of any content (mods, games, texture packs).

+ +

📍 Step 2: Auto-Detection

+

Games install automatically to games directory.
+ Mods let you choose global or world-specific.
+ Texture packs install automatically to textures directory.

+ +

⚡ Step 3: Install

+

Click install and dependencies will be resolved automatically.

+
+
+ +
+
+

🔄 Package Updates

+
+
+

Check for updates to your installed packages:

+ Check for Updates + View Installed +
+
+
+
+ +
+
+

📝 Supported URL Formats

+
+
+
+

✅ Supported Formats:

+
    +
  • https://content.luanti.org/packages/author/package_name/
  • +
  • content.luanti.org/packages/author/package_name/
  • +
  • author/package_name (direct format)
  • +
+ +

📋 Example URLs:

+
    +
  • Mod: https://content.luanti.org/packages/VanessaE/basic_materials/
  • +
  • Game: https://content.luanti.org/packages/GreenXenith/nodecore/
  • +
  • Texture Pack: https://content.luanti.org/packages/author/texture_pack/
  • +
  • Direct: VanessaE/basic_materials
  • +
+
+
+
+ + + + +`; +%> + +<%- include('../layout', { body: body, currentPage: 'contentdb', title: title }) %> \ No newline at end of file diff --git a/views/contentdb/installed.ejs b/views/contentdb/installed.ejs new file mode 100644 index 0000000..9554c30 --- /dev/null +++ b/views/contentdb/installed.ejs @@ -0,0 +1,308 @@ +<% +const body = ` + + +
+
+
+
+

📊 Statistics

+
+
+
+ ${statistics.total_packages || 0} + Total Packages +
+
+ ${statistics.global_packages || 0} + Global Mods +
+
+ ${statistics.world_packages || 0} + World-specific +
+
+ ${statistics.worlds_with_packages || 0} + Worlds with Mods +
+
+
+ +
+
+

🔍 Filter Packages

+
+
+ + All Locations + + + Global Mods + +
+ World-specific filters coming soon +
+
+
+
+ +
+ ${packages.length === 0 ? ` +
+
+

📭 No Packages Installed

+

You haven't installed any packages yet from ContentDB.

+ + Browse ContentDB + +
+
+ ` : ` +
+ ${packages.map(pkg => ` +
+
+
+

${pkg.title || pkg.name}

+ by ${pkg.author} +
+
+ + ${pkg.package_type || 'mod'} + +
+
+
+
+

+ ${pkg.short_description || 'No description available.'} +

+ +
+
+ Location: + + ${pkg.install_location === 'global' ? 'Global' : pkg.install_location.replace('world:', '')} + +
+
+ Version: + ${pkg.version || 'Unknown'} +
+
+ Installed: + ${new Date(pkg.installed_at).toLocaleDateString()} +
+
+ + ${pkg.dependencies && pkg.dependencies.length > 0 ? ` +
+ Dependencies (${pkg.dependencies.length}): +
+ ${pkg.dependencies.map(dep => + typeof dep === 'string' ? dep : `${dep.author}/${dep.name}` + ).join(', ')} +
+
+ ` : ''} +
+ +
+ ${pkg.contentdb_url ? ` + + View on ContentDB + + ` : ''} + + +
+
+
+ `).join('')} +
+ `} +
+
+ + + + +`; +%> + +<%- include('../layout', { body: body, currentPage: 'contentdb', title: title }) %> \ No newline at end of file diff --git a/views/contentdb/package.ejs b/views/contentdb/package.ejs new file mode 100644 index 0000000..6c5851a --- /dev/null +++ b/views/contentdb/package.ejs @@ -0,0 +1,22 @@ +<% +const body = ` + + +
+
+

Package details will be displayed here.

+ Browse All Content +
+
+`; +%> + +<%- include('../layout', { body: body, currentPage: 'contentdb', title: title }) %> \ No newline at end of file diff --git a/views/contentdb/popular.ejs b/views/contentdb/popular.ejs new file mode 100644 index 0000000..dd1f3a0 --- /dev/null +++ b/views/contentdb/popular.ejs @@ -0,0 +1,22 @@ +<% +const body = ` + + +
+
+

Popular content will be displayed here.

+ Browse All Content +
+
+`; +%> + +<%- include('../layout', { body: body, currentPage: 'contentdb', title: title }) %> \ No newline at end of file diff --git a/views/contentdb/recent.ejs b/views/contentdb/recent.ejs new file mode 100644 index 0000000..71b5db5 --- /dev/null +++ b/views/contentdb/recent.ejs @@ -0,0 +1,22 @@ +<% +const body = ` + + +
+
+

Recent content will be displayed here.

+ Browse All Content +
+
+`; +%> + +<%- include('../layout', { body: body, currentPage: 'contentdb', title: title }) %> \ No newline at end of file diff --git a/views/contentdb/updates.ejs b/views/contentdb/updates.ejs new file mode 100644 index 0000000..1fa60d4 --- /dev/null +++ b/views/contentdb/updates.ejs @@ -0,0 +1,305 @@ +<% +const body = ` + + +
+
+
+
+

📊 Update Status

+
+
+
+ ${installedCount || 0} + Total Packages +
+
+ ${updateCount || 0} + Updates Available +
+
+ ${installedCount - updateCount || 0} + Up to Date +
+
+
+ +
+
+

⚡ Quick Actions

+
+
+ ${updateCount > 0 ? ` + + + ` : ` + + `} + + đŸ“Ļ View All Installed + + + 🌐 Browse ContentDB + +
+
+
+ +
+ ${updateCount === 0 ? ` +
+
+

✅ All Packages Up to Date!

+

All your installed packages are running the latest versions.

+
🎉
+

+ ${installedCount === 0 ? + 'You haven\\'t installed any packages yet.' : + \`Checked \${installedCount} package\${installedCount !== 1 ? 's' : ''}.\` + } +

+
+
+ ` : ` +
+ ${updates.map(update => ` +
+
+
+

${update.latest.package.title || update.installed.name}

+ by ${update.installed.author} +
+
+ Update Available +
+
+
+
+
+
Current Version
+
${update.installed.version}
+
+ Installed: ${new Date(update.installed.installed_at).toLocaleDateString()} +
+
+
➜
+
+
Latest Version
+
${update.latest.release.title}
+
+ Released: ${new Date(update.latest.release.created_at).toLocaleDateString()} +
+
+
+ +
+ Location: + + ${update.installed.install_location === 'global' ? 'Global' : update.installed.install_location.replace('world:', '')} + +
+ +
+ + + View on ContentDB + +
+
+
+ `).join('')} +
+ `} +
+
+ + + + +`; +%> + +<%- include('../layout', { body: body, currentPage: 'contentdb', title: title }) %> \ No newline at end of file diff --git a/views/dashboard.ejs b/views/dashboard.ejs new file mode 100644 index 0000000..dc57131 --- /dev/null +++ b/views/dashboard.ejs @@ -0,0 +1,142 @@ +<% +const body = ` + +
+
+
${stats.worlds}
+
Worlds
+
+
+
${stats.mods}
+
Mods
+
+
+
+ + Loading... + +
+
Server Status
+
+
+
+ ${stats.minetestDir} +
+
Minetest Directory
+
+
+ + +
+
+

Quick Actions

+
+ +
+
+

World Management

+

+ Create and manage your game worlds +

+ +
+ +
+

Extensions

+

+ Manage games, mods, and texture packs +

+ +
+ +
+

Server Control

+

+ Start, stop, and monitor your server +

+ +
+ +
+

ContentDB Browser

+

+ Discover new content on ContentDB +

+ +
+
+
+ + +
+

System Information

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
Platform${systemInfo.platform}
Architecture${systemInfo.arch}
Node.js Version${systemInfo.nodeVersion}
Minetest Directory${stats.minetestDir}
Server UptimeN/A
Process IDN/A
+
+
+ + +
+

Recent Activity

+
+

Activity logging will be implemented in a future update.

+ This will show recent world changes, mod installations, and server events. +
+
+ + + +`; +%> + +<%- include('layout', { body: body, currentPage: 'dashboard', title: title }) %> diff --git a/views/error.ejs b/views/error.ejs new file mode 100644 index 0000000..758e002 --- /dev/null +++ b/views/error.ejs @@ -0,0 +1,41 @@ + + + + + + Error | Luanti Server Manager + + + +
+
+
+ âš ī¸ +
+ +

+ <%= error %> +

+ + <% if (typeof message !== 'undefined' && message) { %> +
+ Details: <%= message %> +
+ <% } %> + + + +
+

If this problem persists, please check the server logs or restart the application.

+
+
+
+ + \ No newline at end of file diff --git a/views/extensions/index.ejs b/views/extensions/index.ejs new file mode 100644 index 0000000..6b708f5 --- /dev/null +++ b/views/extensions/index.ejs @@ -0,0 +1,693 @@ +<% +const body = ` + + +
+ +
+ +
+
+

📊 Overview

+
+
+
+
+ ${statistics.games || 0} + Games +
+
+ ${(statistics.global_packages || 0) + (statistics.local_mods || 0)} + Mods +
+
+ ${statistics.total_packages || 0} + Total +
+
+
+
+ + +
+
+

⚡ Quick Install

+
+
+
+ ${typeof csrfToken !== 'undefined' && csrfToken ? `` : ''} +
+ + +
+
+ +
+ + + + +
+ +
+ +
+ + + + +
+
+
+
+ + +
+
+
+ + + + +
+
+ + ${allContent.length === 0 ? ` +
+
+

📭 No Extensions Installed

+

Install games, mods, and texture packs from ContentDB or add them manually.

+ + Browse ContentDB + +
+
+ ` : ` +
+ ${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 ` +
+
+
+

${typeIcon} ${ext.title || ext.name}

+ + ${sourceIcon} ${ext.author || 'Local'} + ${ext.source === 'contentdb' ? '(ContentDB)' : '(Local)'} + +
+
+ + ${type === 'txp' ? 'Texture Pack' : type.charAt(0).toUpperCase() + type.slice(1)} + +
+
+
+
+

+ ${ext.short_description || ext.description || 'No description available.'} +

+ +
+
+ Location: + + ${ext.install_location === 'global' || ext.location === 'global' ? 'Global' : + ext.install_location ? ext.install_location.replace('world:', '') : ext.location || 'Games'} + +
+ ${ext.version ? ` +
+ Version: + ${ext.version} +
+ ` : ''} +
+ Modified: + ${ext.installed_at ? new Date(ext.installed_at).toLocaleDateString() : + new Date(ext.lastModified).toLocaleDateString()} +
+
+ + ${ext.dependencies && ext.dependencies.length > 0 ? ` +
+ Dependencies (${ext.dependencies.length}): +
+ ${ext.dependencies.map(dep => + typeof dep === 'string' ? dep : `${dep.author}/${dep.name}` + ).join(', ')} +
+
+ ` : ''} +
+ +
+ ${ext.contentdb_url ? ` + + View on ContentDB + + ` : ''} + ${ext.source === 'contentdb' ? ` + + ` : ''} + +
+
+
+ `; + }).join('')} +
+ `} +
+
+ + + + +`; +%> + +<%- include('../layout', { body: body, currentPage: 'extensions', title: title }) %> \ No newline at end of file diff --git a/views/layout.ejs b/views/layout.ejs new file mode 100644 index 0000000..6ca81a0 --- /dev/null +++ b/views/layout.ejs @@ -0,0 +1,109 @@ + + + + + + <%= title %> | LuHost + + + + +
+ +
+
+
+

LuHost

+

Hosting Luanti made easy

+
+ + <% if (typeof isAuthenticated !== 'undefined' && isAuthenticated && typeof user !== 'undefined') { %> +
+
+ Welcome, <%= user.username %> +
+
+ Connected + Logout +
+
+ <% } else { %> +
+ Connected +
+ <% } %> +
+
+ + + <% if (typeof isAuthenticated !== 'undefined' && isAuthenticated) { %> + + <% } %> + + +
+ <%- body %> +
+ + + +
+ + + + + + + + + <% if (typeof scripts !== 'undefined') { %> + <% scripts.forEach(function(script) { %> + + <% }); %> + <% } %> + + + <% if (typeof inlineScript !== 'undefined') { %> + + <% } %> + + diff --git a/views/server/index.ejs b/views/server/index.ejs new file mode 100644 index 0000000..cb7d278 --- /dev/null +++ b/views/server/index.ejs @@ -0,0 +1,318 @@ +<% +const body = ` + + +
+
+
+
+

📊 Server Status

+
+
+
+
+ Loading... +
+ +
+
+ -- + Uptime +
+
+ -- + Players Online +
+
+ -- + Memory Usage +
+
+
+
+ +
+
+

🎮 Server Controls

+
+
+
+ + +
+ +
+ + + +
+
+
+ +
+
+

âš™ī¸ Quick Actions

+
+ +
+
+ +
+
+
+
+

📋 Server Console

+
+ + +
+
+
+
+
+
+
+ ${new Date().toLocaleTimeString()} + Console ready. Start server to see logs. +
+
+
+ + +
+
+ +
+
+

đŸ‘Ĩ Online Players

+
+
+
+

No players online

+
+
+
+
+
+ + + +`; +%> + +<%- include('../layout', { body: body, currentPage: 'server', title: title }) %> \ No newline at end of file diff --git a/views/users/index.ejs b/views/users/index.ejs new file mode 100644 index 0000000..9a2fd9a --- /dev/null +++ b/views/users/index.ejs @@ -0,0 +1,114 @@ +<% +const body = ` +
+
+

User Management

+ Create New User +
+ + ${typeof req !== 'undefined' && req.query.created ? ` +
+ User "${req.query.created}" created successfully! +
+ ` : ''} + + ${typeof req !== 'undefined' && req.query.deleted ? ` +
+ User deleted successfully. +
+ ` : ''} + + ${typeof req !== 'undefined' && req.query.error ? ` +
+ Error: ${req.query.error} +
+ ` : ''} + +
+ Feudal Authority: Only you can create new user accounts. All users have full administrator privileges over the Luanti server. +
+ + ${users.length === 0 ? ` +
+
đŸ‘Ĩ
+

No Users Found

+

This shouldn't happen since you're logged in. Please report this issue.

+
+ ` : ` +
+ + + + + + + + + + + ${users.map(user => ` + + + + + + + `).join('')} + +
UsernameCreatedLast LoginActions
+ ${user.username} + ${user.id === 1 ? 'Founder' : ''} + + ${formatDate(user.created_at)} + + ${user.last_login ? `${formatDate(user.last_login)}` : 'Never'} + +
+ ${user.id !== 1 ? ` +
+ ${typeof csrfToken !== 'undefined' && csrfToken ? `` : ''} + +
+ ` : ` + Protected + `} +
+
+
+ `} +
+ +
+

Authority & Permissions

+
+
+

🏰 Feudal System

+

This server uses an "implicit feudalism" security model:

+
    +
  • Only existing administrators can create new accounts
  • +
  • The founder account (first user) cannot be deleted
  • +
  • All users have equal administrative privileges
  • +
  • No public registration - authority must be granted
  • +
+
+ +
+

👑 Administrative Powers

+

Every user account can:

+
    +
  • Manage worlds (create, configure, delete)
  • +
  • Install and manage mods
  • +
  • Browse and install from ContentDB
  • +
  • Control the Luanti server (start, stop, restart)
  • +
  • Modify server configuration
  • +
  • Create additional user accounts
  • +
+
+
+
+`; +%> + +<%- include('../layout', { body: body, currentPage: 'users', title: title }) %> \ No newline at end of file diff --git a/views/users/new.ejs b/views/users/new.ejs new file mode 100644 index 0000000..6f7a94c --- /dev/null +++ b/views/users/new.ejs @@ -0,0 +1,106 @@ +<% +const body = ` +
+
+
+

Create New Administrator

+

+ Grant administrative access to a new user +

+
+ + ${typeof error !== 'undefined' ? ` +
+ Error: ${error} +
+ ` : ''} + +
+ Authority Note: This user will have full administrative privileges over the Luanti server, including the ability to create additional accounts. +
+ +
+ ${typeof csrfToken !== 'undefined' && csrfToken ? `` : ''} +
+ + + 3-20 characters, letters, numbers, underscore, or hyphen only +
+ +
+
+ + + At least 8 characters long +
+ +
+ + +
+
+ +
+ + Cancel + + +
+
+
+ +
+

This user will be able to perform all server management tasks and create additional accounts.

+
+
+ + +`; +%> + +<%- include('../layout', { body: body, currentPage: 'users', title: title }) %> \ No newline at end of file