Initial commit: LuHost - Luanti Server Management Web Interface
A modern web interface for Luanti (Minetest) server management with ContentDB integration. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+141
@@ -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/
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
@@ -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, """)
|
||||
.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
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
// Main JavaScript for Luanti Web Server
|
||||
class LuantiWebServer {
|
||||
constructor() {
|
||||
this.socket = null;
|
||||
this.serverStatus = 'stopped';
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Initialize Socket.IO
|
||||
this.initSocket();
|
||||
|
||||
// Initialize UI components
|
||||
this.initUI();
|
||||
|
||||
// Initialize forms
|
||||
this.initForms();
|
||||
|
||||
// Initialize real-time updates
|
||||
this.initRealTime();
|
||||
}
|
||||
|
||||
initSocket() {
|
||||
this.socket = io();
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
console.log('Connected to server');
|
||||
this.updateConnectionStatus(true);
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', () => {
|
||||
console.log('Disconnected from server');
|
||||
this.updateConnectionStatus(false);
|
||||
});
|
||||
|
||||
this.socket.on('serverStatus', (status) => {
|
||||
this.updateServerStatus(status);
|
||||
});
|
||||
|
||||
this.socket.on('serverLog', (logEntry) => {
|
||||
this.appendLogEntry(logEntry);
|
||||
});
|
||||
}
|
||||
|
||||
initUI() {
|
||||
// Modal functionality
|
||||
this.initModals();
|
||||
|
||||
// Tooltips
|
||||
this.initTooltips();
|
||||
|
||||
// Auto-refresh toggles
|
||||
this.initAutoRefresh();
|
||||
}
|
||||
|
||||
initModals() {
|
||||
// Generic modal handling
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.matches('[data-modal-open]')) {
|
||||
const modalId = e.target.getAttribute('data-modal-open');
|
||||
this.openModal(modalId);
|
||||
}
|
||||
|
||||
if (e.target.matches('[data-modal-close]') || e.target.closest('[data-modal-close]')) {
|
||||
this.closeModal();
|
||||
}
|
||||
|
||||
if (e.target.matches('.modal')) {
|
||||
this.closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Escape key to close modal
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
this.closeModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openModal(modalId) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) {
|
||||
modal.style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Focus first input in modal
|
||||
const firstInput = modal.querySelector('input, textarea, select');
|
||||
if (firstInput) {
|
||||
setTimeout(() => firstInput.focus(), 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closeModal() {
|
||||
const modals = document.querySelectorAll('.modal');
|
||||
modals.forEach(modal => {
|
||||
modal.style.display = 'none';
|
||||
});
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
initTooltips() {
|
||||
// Simple tooltip implementation
|
||||
document.querySelectorAll('[data-tooltip]').forEach(element => {
|
||||
element.addEventListener('mouseenter', (e) => {
|
||||
this.showTooltip(e.target, e.target.getAttribute('data-tooltip'));
|
||||
});
|
||||
|
||||
element.addEventListener('mouseleave', () => {
|
||||
this.hideTooltip();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
showTooltip(element, text) {
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.className = 'tooltip';
|
||||
tooltip.textContent = text;
|
||||
tooltip.style.cssText = `
|
||||
position: absolute;
|
||||
background: #1f2937;
|
||||
color: white;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
z-index: 1000;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
`;
|
||||
|
||||
document.body.appendChild(tooltip);
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
tooltip.style.top = rect.top - tooltip.offsetHeight - 8 + 'px';
|
||||
tooltip.style.left = rect.left + (rect.width - tooltip.offsetWidth) / 2 + 'px';
|
||||
}
|
||||
|
||||
hideTooltip() {
|
||||
const tooltip = document.querySelector('.tooltip');
|
||||
if (tooltip) {
|
||||
tooltip.remove();
|
||||
}
|
||||
}
|
||||
|
||||
initAutoRefresh() {
|
||||
const autoRefreshElements = document.querySelectorAll('[data-auto-refresh]');
|
||||
autoRefreshElements.forEach(element => {
|
||||
const interval = parseInt(element.getAttribute('data-auto-refresh')) || 5000;
|
||||
const url = element.getAttribute('data-refresh-url') || window.location.href;
|
||||
|
||||
setInterval(async () => {
|
||||
if (element.checked || element.getAttribute('data-auto-refresh-active') === 'true') {
|
||||
await this.refreshElement(element, url);
|
||||
}
|
||||
}, interval);
|
||||
});
|
||||
}
|
||||
|
||||
async refreshElement(element, url) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
// This would need specific implementation per element type
|
||||
console.log('Auto-refresh triggered for', element);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auto-refresh failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
initForms() {
|
||||
// AJAX form submission
|
||||
document.addEventListener('submit', async (e) => {
|
||||
if (e.target.matches('[data-ajax-form]')) {
|
||||
e.preventDefault();
|
||||
await this.submitAjaxForm(e.target);
|
||||
}
|
||||
});
|
||||
|
||||
// Real-time form validation
|
||||
this.initFormValidation();
|
||||
}
|
||||
|
||||
async submitAjaxForm(form) {
|
||||
const submitBtn = form.querySelector('[type="submit"]');
|
||||
const originalText = submitBtn.textContent;
|
||||
|
||||
try {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Processing...';
|
||||
|
||||
const formData = new FormData(form);
|
||||
const response = await fetch(form.action, {
|
||||
method: form.method || 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
if (response.headers.get('content-type')?.includes('application/json')) {
|
||||
const result = await response.json();
|
||||
this.showAlert(result.message || 'Success', 'success');
|
||||
} else {
|
||||
// Handle redirect or page reload
|
||||
window.location.reload();
|
||||
}
|
||||
} else {
|
||||
const error = await response.text();
|
||||
this.showAlert(error || 'An error occurred', 'danger');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showAlert('Network error: ' + error.message, 'danger');
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
initFormValidation() {
|
||||
// Real-time validation for world/mod names
|
||||
document.addEventListener('input', (e) => {
|
||||
if (e.target.matches('[data-validate-name]')) {
|
||||
this.validateName(e.target);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
validateName(input) {
|
||||
const value = input.value;
|
||||
const isValid = /^[a-zA-Z0-9_-]+$/.test(value) && value.length <= 50;
|
||||
|
||||
input.setCustomValidity(isValid ? '' : 'Only letters, numbers, underscore and hyphen allowed (max 50 chars)');
|
||||
|
||||
// Visual feedback
|
||||
input.classList.toggle('is-invalid', !isValid && value.length > 0);
|
||||
input.classList.toggle('is-valid', isValid && value.length > 0);
|
||||
}
|
||||
|
||||
initRealTime() {
|
||||
// Auto-scroll logs
|
||||
this.initLogAutoScroll();
|
||||
}
|
||||
|
||||
initLogAutoScroll() {
|
||||
const logsContainer = document.querySelector('.logs');
|
||||
if (logsContainer) {
|
||||
// Auto-scroll to bottom when new logs arrive
|
||||
const observer = new MutationObserver(() => {
|
||||
if (logsContainer.scrollTop + logsContainer.clientHeight >= logsContainer.scrollHeight - 100) {
|
||||
logsContainer.scrollTop = logsContainer.scrollHeight;
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(logsContainer, { childList: true, subtree: true });
|
||||
}
|
||||
}
|
||||
|
||||
updateConnectionStatus(connected) {
|
||||
const statusElement = document.getElementById('connection-status');
|
||||
if (statusElement) {
|
||||
statusElement.className = connected ? 'status status-running' : 'status status-error';
|
||||
statusElement.textContent = connected ? 'Connected' : 'Disconnected';
|
||||
}
|
||||
}
|
||||
|
||||
updateServerStatus(status) {
|
||||
this.serverStatus = status.status;
|
||||
|
||||
// Update status badge
|
||||
const statusElement = document.getElementById('server-status');
|
||||
if (statusElement) {
|
||||
statusElement.className = `status status-${status.status}`;
|
||||
statusElement.textContent = status.status.charAt(0).toUpperCase() + status.status.slice(1);
|
||||
}
|
||||
|
||||
// Update PID
|
||||
const pidElement = document.getElementById('server-pid');
|
||||
if (pidElement) {
|
||||
pidElement.textContent = status.pid || 'N/A';
|
||||
}
|
||||
|
||||
// Update uptime
|
||||
const uptimeElement = document.getElementById('server-uptime');
|
||||
if (uptimeElement) {
|
||||
uptimeElement.textContent = this.formatUptime(status.uptime);
|
||||
}
|
||||
|
||||
// Update control buttons
|
||||
this.updateServerControls(status.status);
|
||||
}
|
||||
|
||||
updateServerControls(status) {
|
||||
const startBtn = document.getElementById('server-start');
|
||||
const stopBtn = document.getElementById('server-stop');
|
||||
const restartBtn = document.getElementById('server-restart');
|
||||
|
||||
if (startBtn) startBtn.disabled = status === 'running';
|
||||
if (stopBtn) stopBtn.disabled = status === 'stopped';
|
||||
if (restartBtn) restartBtn.disabled = status === 'stopped';
|
||||
}
|
||||
|
||||
appendLogEntry(logEntry) {
|
||||
const logsContainer = document.querySelector('.logs');
|
||||
if (!logsContainer) return;
|
||||
|
||||
const logElement = document.createElement('div');
|
||||
logElement.className = 'log-entry';
|
||||
|
||||
if (typeof logEntry === 'string') {
|
||||
logElement.textContent = logEntry;
|
||||
} else {
|
||||
logElement.innerHTML = `
|
||||
<span class="log-timestamp">[${new Date(logEntry.timestamp).toLocaleTimeString()}]</span>
|
||||
<span class="log-level-${logEntry.level}">${logEntry.message}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
logsContainer.appendChild(logElement);
|
||||
|
||||
// Limit log entries to prevent memory issues
|
||||
const logEntries = logsContainer.children;
|
||||
if (logEntries.length > 1000) {
|
||||
logEntries[0].remove();
|
||||
}
|
||||
}
|
||||
|
||||
formatUptime(uptime) {
|
||||
if (!uptime) return 'N/A';
|
||||
const seconds = Math.floor(uptime / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
} else {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
}
|
||||
|
||||
showAlert(message, type = 'info', duration = 5000) {
|
||||
const alertsContainer = document.getElementById('alerts') || this.createAlertsContainer();
|
||||
|
||||
const alert = document.createElement('div');
|
||||
alert.className = `alert alert-${type}`;
|
||||
alert.innerHTML = `
|
||||
<span>${message}</span>
|
||||
<button type="button" class="modal-close" style="margin-left: auto;">×</button>
|
||||
`;
|
||||
|
||||
alertsContainer.appendChild(alert);
|
||||
|
||||
// Auto-remove alert
|
||||
setTimeout(() => {
|
||||
if (alert.parentNode) {
|
||||
alert.remove();
|
||||
}
|
||||
}, duration);
|
||||
|
||||
// Manual close
|
||||
alert.querySelector('.modal-close').addEventListener('click', () => {
|
||||
alert.remove();
|
||||
});
|
||||
}
|
||||
|
||||
createAlertsContainer() {
|
||||
const container = document.createElement('div');
|
||||
container.id = 'alerts';
|
||||
container.style.cssText = `
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 1000;
|
||||
max-width: 400px;
|
||||
`;
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
async api(endpoint, options = {}) {
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
},
|
||||
...options
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
return await response.json();
|
||||
} else {
|
||||
return await response.text();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
formatDate(date) {
|
||||
return new Date(date).toLocaleString();
|
||||
}
|
||||
|
||||
debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.luantiWebServer = new LuantiWebServer();
|
||||
});
|
||||
|
||||
// Global utility functions
|
||||
window.confirmDelete = function(itemType, itemName) {
|
||||
console.log(`confirmDelete called with: itemType="${itemType}", itemName="${itemName}"`);
|
||||
|
||||
if (itemType === 'world') {
|
||||
// Extra confirmation for world deletion - require typing the world name
|
||||
const message = `WARNING: You are about to permanently delete the world "${itemName}".\n\n` +
|
||||
`This will remove ALL world data including:\n` +
|
||||
`• All builds and constructions\n` +
|
||||
`• Player inventories and progress\n` +
|
||||
`• World settings and configuration\n` +
|
||||
`• All world-specific mods and data\n\n` +
|
||||
`This action cannot be undone!\n\n` +
|
||||
`Type the world name exactly to confirm:`;
|
||||
|
||||
const confirmation = prompt(message);
|
||||
console.log(`User entered: "${confirmation}", expected: "${itemName}"`);
|
||||
|
||||
if (confirmation === null) {
|
||||
console.log('User cancelled the dialog');
|
||||
return false;
|
||||
}
|
||||
|
||||
const matches = confirmation === itemName;
|
||||
console.log(`Confirmation ${matches ? 'matches' : 'does not match'}`);
|
||||
|
||||
if (!matches && confirmation !== null) {
|
||||
alert('Deletion cancelled - world name did not match exactly.');
|
||||
}
|
||||
|
||||
return matches;
|
||||
} else {
|
||||
// Standard confirmation for other items
|
||||
return confirm(`Are you sure you want to delete the ${itemType} "${itemName}"? This action cannot be undone.`);
|
||||
}
|
||||
};
|
||||
|
||||
window.showLoading = function(element, text = 'Loading...') {
|
||||
if (typeof element === 'string') {
|
||||
element = document.querySelector(element);
|
||||
}
|
||||
if (element) {
|
||||
element.innerHTML = `
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<div>${text}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
};
|
||||
|
||||
window.hideLoading = function(element) {
|
||||
if (typeof element === 'string') {
|
||||
element = document.querySelector(element);
|
||||
}
|
||||
if (element) {
|
||||
element.innerHTML = '';
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,629 @@
|
||||
let socket;
|
||||
let autoScroll = true;
|
||||
let serverRunning = false;
|
||||
let isExternalServer = false;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize WebSocket connection for real-time updates
|
||||
initializeWebSocket();
|
||||
|
||||
// Load initial data
|
||||
loadWorlds();
|
||||
updateServerStatus();
|
||||
|
||||
// Set up periodic status updates (every 3 seconds for better responsiveness)
|
||||
setInterval(updateServerStatus, 3000);
|
||||
|
||||
// Add event listeners for buttons
|
||||
document.getElementById('startBtn').addEventListener('click', startServer);
|
||||
document.getElementById('stopBtn').addEventListener('click', stopServer);
|
||||
document.getElementById('restartBtn').addEventListener('click', restartServer);
|
||||
document.getElementById('downloadBtn').addEventListener('click', downloadLogs);
|
||||
document.getElementById('clearBtn').addEventListener('click', clearLogs);
|
||||
document.getElementById('autoScrollBtn').addEventListener('click', toggleAutoScroll);
|
||||
document.getElementById('sendBtn').addEventListener('click', sendCommand);
|
||||
|
||||
// Add enter key handler for console input
|
||||
document.getElementById('consoleInput').addEventListener('keypress', function(event) {
|
||||
if (event.key === 'Enter') {
|
||||
sendCommand();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function initializeWebSocket() {
|
||||
socket = io();
|
||||
|
||||
socket.on('server:log', function(logEntry) {
|
||||
addLogEntry(logEntry.type, logEntry.content, logEntry.timestamp);
|
||||
});
|
||||
|
||||
socket.on('server:status', function(status) {
|
||||
isExternalServer = status.isExternal || false;
|
||||
updateStatusDisplay(status);
|
||||
});
|
||||
|
||||
socket.on('server:players', function(players) {
|
||||
updatePlayersList(players, isExternalServer);
|
||||
});
|
||||
}
|
||||
|
||||
async function updateServerStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/server/status');
|
||||
|
||||
// Check for authentication redirect
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('text/html')) {
|
||||
console.warn('Authentication required for server status');
|
||||
// Silently fail for status updates, don't redirect automatically
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('HTTP error! status: ' + response.status);
|
||||
}
|
||||
|
||||
const status = await response.json();
|
||||
isExternalServer = status.isExternal || false;
|
||||
updateStatusDisplay(status);
|
||||
} catch (error) {
|
||||
console.error('Failed to update server status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkServerStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/server/status');
|
||||
|
||||
// Check for authentication redirect
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('text/html')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Failed to check server status:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatusDisplay(status) {
|
||||
const statusLight = document.getElementById('statusLight');
|
||||
const statusText = document.getElementById('statusText');
|
||||
const startBtn = document.getElementById('startBtn');
|
||||
const stopBtn = document.getElementById('stopBtn');
|
||||
const restartBtn = document.getElementById('restartBtn');
|
||||
const consoleInputGroup = document.getElementById('consoleInputGroup');
|
||||
|
||||
const wasRunning = serverRunning;
|
||||
serverRunning = status.isRunning;
|
||||
|
||||
if (status.isRunning) {
|
||||
if (status.isReady) {
|
||||
// Server is running and ready to accept connections
|
||||
statusLight.className = 'status-light online';
|
||||
statusText.textContent = status.isExternal ? 'Running (External - Monitor Only)' : 'Running';
|
||||
} else {
|
||||
// Server process is running but not ready yet
|
||||
statusLight.className = 'status-light starting';
|
||||
statusText.textContent = 'Starting...';
|
||||
}
|
||||
|
||||
// For external servers, disable control buttons
|
||||
if (status.isExternal) {
|
||||
startBtn.disabled = true;
|
||||
stopBtn.disabled = true;
|
||||
restartBtn.disabled = true;
|
||||
consoleInputGroup.style.display = 'none';
|
||||
} else {
|
||||
startBtn.disabled = true;
|
||||
stopBtn.disabled = false;
|
||||
restartBtn.disabled = false;
|
||||
consoleInputGroup.style.display = 'block';
|
||||
}
|
||||
} else {
|
||||
statusLight.className = 'status-light offline';
|
||||
statusText.textContent = 'Offline';
|
||||
startBtn.disabled = false;
|
||||
stopBtn.disabled = true;
|
||||
restartBtn.disabled = true;
|
||||
consoleInputGroup.style.display = 'none';
|
||||
|
||||
|
||||
// Reset button states if server stopped unexpectedly
|
||||
if (startBtn.textContent === '⏳ Starting...') {
|
||||
startBtn.textContent = '▶️ Start Server';
|
||||
}
|
||||
if (restartBtn.textContent === '⏳ Restarting...') {
|
||||
restartBtn.textContent = '🔄 Restart Server';
|
||||
}
|
||||
|
||||
// Log if server stopped unexpectedly
|
||||
if (wasRunning && !status.isRunning) {
|
||||
addLogEntry('warning', 'Server has stopped. Check logs for details.');
|
||||
}
|
||||
}
|
||||
|
||||
// Update stats
|
||||
document.getElementById('uptime').textContent = formatUptime(status.uptime);
|
||||
document.getElementById('playerCount').textContent = status.players || 0;
|
||||
document.getElementById('memoryUsage').textContent = status.memoryUsage ?
|
||||
Math.round(status.memoryUsage) + ' MB' : '--';
|
||||
|
||||
// Debug: Log the status to see what we're getting
|
||||
console.log('Server status update:', {
|
||||
isRunning: status.isRunning,
|
||||
players: status.players,
|
||||
uptime: status.uptime
|
||||
});
|
||||
}
|
||||
|
||||
function formatUptime(milliseconds) {
|
||||
if (!milliseconds) return '--';
|
||||
|
||||
const seconds = Math.floor(milliseconds / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) return days + 'd ' + (hours % 24) + 'h';
|
||||
if (hours > 0) return hours + 'h ' + (minutes % 60) + 'm';
|
||||
if (minutes > 0) return minutes + 'm ' + (seconds % 60) + 's';
|
||||
return seconds + 's';
|
||||
}
|
||||
|
||||
async function loadWorlds() {
|
||||
console.log('loadWorlds() called');
|
||||
try {
|
||||
const response = await fetch('/api/worlds');
|
||||
|
||||
// Check for authentication redirect
|
||||
const contentType = response.headers.get('content-type');
|
||||
console.log('Response status:', response.status, 'Content-Type:', contentType);
|
||||
if (contentType && contentType.includes('text/html')) {
|
||||
console.warn('Authentication required for loading worlds');
|
||||
document.getElementById('worldSelect').innerHTML =
|
||||
'<option value="">Please log in to load worlds</option>' +
|
||||
'<option value="" disabled>───────────────────</option>' +
|
||||
'<option value="" disabled>🔒 Authentication required</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('HTTP error! status: ' + response.status);
|
||||
}
|
||||
|
||||
const worlds = await response.json();
|
||||
console.log('Worlds received:', worlds);
|
||||
const worldSelect = document.getElementById('worldSelect');
|
||||
|
||||
if (worlds.length === 0) {
|
||||
worldSelect.innerHTML =
|
||||
'<option value="">No worlds found - server will create default world</option>' +
|
||||
'<option value="" disabled>───────────────────</option>' +
|
||||
'<option value="" disabled>💡 Create worlds in the Worlds section</option>';
|
||||
} else {
|
||||
worldSelect.innerHTML = '<option value="" disabled selected>Choose a world to run</option>';
|
||||
worlds.forEach(world => {
|
||||
const option = document.createElement('option');
|
||||
option.value = world.name;
|
||||
option.textContent = '🌍 ' + (world.displayName || world.name);
|
||||
worldSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load worlds:', error);
|
||||
document.getElementById('worldSelect').innerHTML =
|
||||
'<option value="">Error loading worlds - will use defaults</option>';
|
||||
}
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
const worldName = document.getElementById('worldSelect').value;
|
||||
console.log('Starting server with world:', worldName);
|
||||
const startBtn = document.getElementById('startBtn');
|
||||
|
||||
// Validate that a world is selected
|
||||
if (!worldName) {
|
||||
addLogEntry('error', 'Please select a world before starting the server');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
startBtn.disabled = true;
|
||||
startBtn.textContent = '⏳ Starting...';
|
||||
|
||||
const response = await fetch('/api/server/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ worldName: worldName })
|
||||
});
|
||||
|
||||
// Check if response is HTML (redirect to login) instead of JSON
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('text/html')) {
|
||||
addLogEntry('error', 'Authentication required. Please refresh the page and log in.');
|
||||
startBtn.disabled = false;
|
||||
startBtn.textContent = '▶️ Start Server';
|
||||
// Optionally redirect to login
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname);
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
startBtn.disabled = false;
|
||||
startBtn.textContent = '▶️ Start Server';
|
||||
throw new Error('HTTP error! status: ' + response.status + ' - ' + errorText);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
addLogEntry('info', result.message || 'Server started successfully');
|
||||
await updateServerStatus();
|
||||
// Monitor for early server crash
|
||||
setTimeout(async () => {
|
||||
const status = await checkServerStatus();
|
||||
if (status && !status.isRunning) {
|
||||
addLogEntry('warning', 'Server appears to have stopped unexpectedly. Check logs for errors.');
|
||||
startBtn.disabled = false;
|
||||
startBtn.textContent = '▶️ Start Server';
|
||||
}
|
||||
}, 3000); // Check after 3 seconds
|
||||
} else {
|
||||
addLogEntry('error', 'Failed to start server: ' + (result.error || 'Unknown error'));
|
||||
startBtn.disabled = false;
|
||||
startBtn.textContent = '▶️ Start Server';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Server start error:', error);
|
||||
addLogEntry('error', 'Failed to start server: ' + error.message);
|
||||
startBtn.disabled = false;
|
||||
startBtn.textContent = '▶️ Start Server';
|
||||
}
|
||||
}
|
||||
|
||||
async function stopServer() {
|
||||
const stopBtn = document.getElementById('stopBtn');
|
||||
|
||||
try {
|
||||
stopBtn.disabled = true;
|
||||
stopBtn.textContent = '⏳ Stopping...';
|
||||
|
||||
const response = await fetch('/api/server/stop', { method: 'POST' });
|
||||
|
||||
// Check for authentication redirect
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('text/html')) {
|
||||
addLogEntry('error', 'Authentication required. Please refresh the page and log in.');
|
||||
stopBtn.disabled = false;
|
||||
stopBtn.textContent = '⏹️ Stop Server';
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname);
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('HTTP error! status: ' + response.status);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
addLogEntry('info', result.message || 'Server stopped successfully');
|
||||
await updateServerStatus();
|
||||
} else {
|
||||
addLogEntry('error', 'Failed to stop server: ' + (result.error || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Server stop error:', error);
|
||||
addLogEntry('error', 'Failed to stop server: ' + error.message);
|
||||
} finally {
|
||||
stopBtn.disabled = false;
|
||||
stopBtn.textContent = '⏹️ Stop Server';
|
||||
}
|
||||
}
|
||||
|
||||
async function restartServer() {
|
||||
const worldName = document.getElementById('worldSelect').value;
|
||||
const restartBtn = document.getElementById('restartBtn');
|
||||
|
||||
// Validate that a world is selected
|
||||
if (!worldName) {
|
||||
addLogEntry('error', 'Please select a world before restarting the server');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
restartBtn.disabled = true;
|
||||
restartBtn.textContent = '⏳ Restarting...';
|
||||
|
||||
const response = await fetch('/api/server/restart', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ worldName: worldName || null })
|
||||
});
|
||||
|
||||
// Check for authentication redirect
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('text/html')) {
|
||||
addLogEntry('error', 'Authentication required. Please refresh the page and log in.');
|
||||
restartBtn.disabled = false;
|
||||
restartBtn.textContent = '🔄 Restart Server';
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname);
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
restartBtn.disabled = false;
|
||||
restartBtn.textContent = '🔄 Restart Server';
|
||||
throw new Error('HTTP error! status: ' + response.status + ' - ' + errorText);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
addLogEntry('info', result.message || 'Server restarted successfully');
|
||||
await updateServerStatus();
|
||||
// Monitor for early server crash
|
||||
setTimeout(async () => {
|
||||
const status = await checkServerStatus();
|
||||
if (status && !status.isRunning) {
|
||||
addLogEntry('warning', 'Server appears to have stopped unexpectedly after restart. Check logs for errors.');
|
||||
restartBtn.disabled = false;
|
||||
restartBtn.textContent = '🔄 Restart Server';
|
||||
}
|
||||
}, 3000); // Check after 3 seconds
|
||||
} else {
|
||||
addLogEntry('error', 'Failed to restart server: ' + (result.error || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Server restart error:', error);
|
||||
addLogEntry('error', 'Failed to restart server: ' + error.message);
|
||||
} finally {
|
||||
restartBtn.disabled = false;
|
||||
restartBtn.textContent = '🔄 Restart Server';
|
||||
}
|
||||
}
|
||||
|
||||
function addLogEntry(type, message, timestamp) {
|
||||
const consoleContent = document.getElementById('consoleContent');
|
||||
const logEntry = document.createElement('div');
|
||||
|
||||
timestamp = timestamp || new Date().toLocaleTimeString();
|
||||
|
||||
logEntry.className = 'log-entry ' + type;
|
||||
logEntry.innerHTML = '<span class="timestamp">' + timestamp + '</span>' +
|
||||
'<span class="message">' + escapeHtml(message) + '</span>';
|
||||
|
||||
consoleContent.appendChild(logEntry);
|
||||
|
||||
// Auto-scroll to bottom if enabled
|
||||
if (autoScroll) {
|
||||
consoleContent.scrollTop = consoleContent.scrollHeight;
|
||||
}
|
||||
|
||||
// Limit log entries to prevent memory issues
|
||||
const maxEntries = 1000;
|
||||
while (consoleContent.children.length > maxEntries) {
|
||||
consoleContent.removeChild(consoleContent.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
document.getElementById('consoleContent').innerHTML = '';
|
||||
addLogEntry('info', 'Console cleared');
|
||||
}
|
||||
|
||||
function toggleAutoScroll() {
|
||||
autoScroll = !autoScroll;
|
||||
document.getElementById('autoScrollText').textContent = 'Auto-scroll: ' + (autoScroll ? 'ON' : 'OFF');
|
||||
}
|
||||
|
||||
async function sendCommand() {
|
||||
const input = document.getElementById('consoleInput');
|
||||
const command = input.value.trim();
|
||||
|
||||
if (!command) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/server/command', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command })
|
||||
});
|
||||
|
||||
// Check for authentication redirect
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('text/html')) {
|
||||
addLogEntry('error', 'Authentication required. Please refresh the page and log in.');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname);
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('HTTP error! status: ' + response.status);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
addLogEntry('info', 'Command sent: ' + command);
|
||||
input.value = '';
|
||||
} else {
|
||||
addLogEntry('error', 'Failed to send command: ' + (result.error || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Send command error:', error);
|
||||
addLogEntry('error', 'Failed to send command: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function updatePlayersList(players, isExternal) {
|
||||
const playersList = document.getElementById('playersList');
|
||||
|
||||
if (!players || players.length === 0) {
|
||||
playersList.innerHTML = '<p class="text-muted">No players online</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a table for better formatting with kick functionality
|
||||
const playersHtml = '<table class="table table-sm">' +
|
||||
'<thead>' +
|
||||
'<tr>' +
|
||||
'<th>Player</th>' +
|
||||
'<th>Last Activity</th>' +
|
||||
'<th>Actions</th>' +
|
||||
'</tr>' +
|
||||
'</thead>' +
|
||||
'<tbody>' +
|
||||
players.map((player, index) => {
|
||||
// Format the last seen time
|
||||
let lastActivity = '--';
|
||||
if (player.lastSeen) {
|
||||
const now = new Date();
|
||||
const lastSeenTime = new Date(player.lastSeen);
|
||||
const diffMinutes = Math.floor((now - lastSeenTime) / (1000 * 60));
|
||||
|
||||
if (diffMinutes < 1) {
|
||||
lastActivity = 'Just now';
|
||||
} else if (diffMinutes < 60) {
|
||||
lastActivity = diffMinutes + 'm ago';
|
||||
} else {
|
||||
lastActivity = Math.floor(diffMinutes / 60) + 'h ago';
|
||||
}
|
||||
}
|
||||
|
||||
return '<tr>' +
|
||||
'<td><strong>' + escapeHtml(player.name) + '</strong></td>' +
|
||||
'<td>' +
|
||||
'<small class="text-muted">' + lastActivity + '</small><br>' +
|
||||
'<span class="badge badge-secondary">' + (player.lastAction || 'Active') + '</span>' +
|
||||
'</td>' +
|
||||
'<td>' +
|
||||
'<button class="btn btn-sm btn-outline-danger kick-player-btn" data-player-name="' + escapeHtml(player.name) + '"' +
|
||||
(isExternal ? ' disabled title="Cannot kick players on external servers"' : '') + '>' +
|
||||
'<i class="fas fa-user-slash"></i> Kick' +
|
||||
'</button>' +
|
||||
'</td>' +
|
||||
'</tr>';
|
||||
}).join('') +
|
||||
'</tbody>' +
|
||||
'</table>';
|
||||
|
||||
playersList.innerHTML = playersHtml;
|
||||
|
||||
// Add event listeners for kick buttons
|
||||
const kickButtons = playersList.querySelectorAll('.kick-player-btn');
|
||||
kickButtons.forEach(button => {
|
||||
button.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const playerName = this.getAttribute('data-player-name');
|
||||
kickPlayer(playerName);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function kickPlayer(playerName) {
|
||||
console.log('kickPlayer() called for player:', playerName);
|
||||
addLogEntry('info', 'Attempting to kick player: ' + playerName);
|
||||
|
||||
if (!confirm('Are you sure you want to kick ' + playerName + '?')) {
|
||||
console.log('Kick cancelled by user');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Sending kick request...');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/server/command', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
command: '/kick ' + playerName
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
addLogEntry('error', 'Authentication required to kick players. Please refresh the page.');
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
throw new Error('HTTP error! status: ' + response.status);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
addLogEntry('success', 'Kicked player: ' + playerName);
|
||||
// Refresh player list after a short delay
|
||||
setTimeout(updateServerStatus, 1000);
|
||||
} else {
|
||||
addLogEntry('error', 'Failed to kick player: ' + (result.error || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error kicking player:', error);
|
||||
addLogEntry('error', 'Error kicking player: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadLogs() {
|
||||
try {
|
||||
const response = await fetch('/api/server/logs');
|
||||
|
||||
// Check for authentication redirect
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('text/html')) {
|
||||
addLogEntry('error', 'Authentication required to download logs');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname);
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'server-logs-' + new Date().toISOString().split('T')[0] + '.txt';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} else {
|
||||
addLogEntry('error', 'Failed to download logs: HTTP ' + response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Download logs error:', error);
|
||||
addLogEntry('error', 'Failed to download logs: ' + error.message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Shared server status functionality for all pages
|
||||
|
||||
async function updateServerStatus(statusElementId) {
|
||||
try {
|
||||
const response = await fetch('/api/server/status');
|
||||
|
||||
// Check for authentication redirect
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('text/html')) {
|
||||
console.warn('Authentication required for server status');
|
||||
// Silently fail for status updates, don't redirect automatically
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('HTTP error! status: ' + response.status);
|
||||
}
|
||||
|
||||
const status = await response.json();
|
||||
updateStatusElement(statusElementId, status);
|
||||
} catch (error) {
|
||||
console.error('Failed to update server status:', error);
|
||||
// Show error state
|
||||
const statusElement = document.getElementById(statusElementId);
|
||||
if (statusElement) {
|
||||
statusElement.textContent = 'Error';
|
||||
statusElement.className = 'status status-stopped';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatusElement(elementId, status) {
|
||||
const statusElement = document.getElementById(elementId);
|
||||
if (statusElement) {
|
||||
const statusText = status.statusText || (status.isRunning ? 'running' : 'stopped');
|
||||
statusElement.textContent = statusText.charAt(0).toUpperCase() + statusText.slice(1);
|
||||
statusElement.className = `status status-${statusText}`;
|
||||
}
|
||||
}
|
||||
+534
@@ -0,0 +1,534 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
const paths = require('../utils/paths');
|
||||
const serverManager = require('../utils/shared-server-manager');
|
||||
const ConfigManager = require('../utils/config-manager');
|
||||
const ConfigParser = require('../utils/config-parser');
|
||||
const appConfig = require('../utils/app-config');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Create global config manager instance
|
||||
const configManager = new ConfigManager();
|
||||
|
||||
// Initialize server manager with socket.io when available
|
||||
let io = null;
|
||||
|
||||
function setSocketIO(socketInstance) {
|
||||
io = socketInstance;
|
||||
|
||||
// Attach server manager events to socket.io
|
||||
serverManager.on('log', (logEntry) => {
|
||||
if (io) {
|
||||
io.emit('server:log', logEntry);
|
||||
}
|
||||
});
|
||||
|
||||
serverManager.on('stats', (stats) => {
|
||||
if (io) {
|
||||
io.emit('server:stats', stats);
|
||||
}
|
||||
});
|
||||
|
||||
serverManager.on('status', (status) => {
|
||||
if (io) {
|
||||
io.emit('server:status', status);
|
||||
}
|
||||
});
|
||||
|
||||
serverManager.on('exit', (exitInfo) => {
|
||||
if (io) {
|
||||
// Broadcast status immediately when server exits
|
||||
serverManager.getServerStatus().then(status => {
|
||||
io.emit('server:status', status);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Server status endpoint
|
||||
router.get('/server/status', async (req, res) => {
|
||||
try {
|
||||
const status = await serverManager.getServerStatus();
|
||||
|
||||
// For all running servers, get player list from debug.txt
|
||||
let playerList = [];
|
||||
if (status.isRunning) {
|
||||
const playerData = await serverManager.getExternalServerPlayerData();
|
||||
playerList = playerData.players;
|
||||
|
||||
// Also update the server stats with current player count
|
||||
serverManager.serverStats.players = playerData.count;
|
||||
|
||||
// Emit player list via WebSocket if available
|
||||
if (io) {
|
||||
io.emit('server:players', playerList);
|
||||
}
|
||||
}
|
||||
|
||||
const isExternal = serverManager.serverProcess?.external || false;
|
||||
console.log('API: serverManager.serverProcess =', serverManager.serverProcess);
|
||||
console.log('API: isExternal =', isExternal);
|
||||
|
||||
console.log('API endpoint returning status:', {
|
||||
isRunning: status.isRunning,
|
||||
players: playerList.length, // Use the actual detected player count
|
||||
playerNames: playerList.map(p => p.name),
|
||||
statusText: status.isRunning ? 'running' : 'stopped',
|
||||
isExternal: isExternal
|
||||
});
|
||||
|
||||
res.json({
|
||||
...status,
|
||||
players: playerList.length, // Override with actual player count
|
||||
playerList: playerList,
|
||||
// Add simple string status for UI
|
||||
statusText: status.isRunning ? 'running' : 'stopped',
|
||||
// Include external server information
|
||||
isExternal: isExternal
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('API: Server status error:', error);
|
||||
res.status(500).json({
|
||||
error: error.message,
|
||||
statusText: 'stopped',
|
||||
isRunning: false,
|
||||
isReady: false,
|
||||
playerList: []
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Start server
|
||||
router.post('/server/start', async (req, res) => {
|
||||
try {
|
||||
const { worldName } = req.body;
|
||||
console.log('Server start requested with world:', worldName);
|
||||
const result = await serverManager.startServer(worldName);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Server start error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Stop server
|
||||
router.post('/server/stop', async (req, res) => {
|
||||
try {
|
||||
const { force = false } = req.body;
|
||||
const result = await serverManager.stopServer(force);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Restart server
|
||||
router.post('/server/restart', async (req, res) => {
|
||||
try {
|
||||
const { worldName } = req.body;
|
||||
const result = await serverManager.restartServer(worldName);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Send command to server
|
||||
router.post('/server/command', async (req, res) => {
|
||||
try {
|
||||
const { command } = req.body;
|
||||
if (!command || typeof command !== 'string') {
|
||||
return res.status(400).json({ error: 'Command is required' });
|
||||
}
|
||||
|
||||
const result = await serverManager.sendCommand(command.trim());
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get server logs
|
||||
router.get('/server/logs', async (req, res) => {
|
||||
try {
|
||||
const { lines = 500, format = 'text' } = req.query;
|
||||
const logs = serverManager.getLogs(parseInt(lines));
|
||||
|
||||
if (format === 'json') {
|
||||
res.json(logs);
|
||||
} else {
|
||||
// Return as downloadable text file
|
||||
const logText = logs.map(log =>
|
||||
`[${log.timestamp}] ${log.type.toUpperCase()}: ${log.content}`
|
||||
).join('\n');
|
||||
|
||||
res.setHeader('Content-Disposition', 'attachment; filename=server-logs.txt');
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.send(logText);
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get server info
|
||||
router.get('/server/info', async (req, res) => {
|
||||
try {
|
||||
const info = await serverManager.getServerInfo();
|
||||
res.json(info);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Configuration endpoints
|
||||
|
||||
// Get all configuration sections
|
||||
router.get('/config/sections', async (req, res) => {
|
||||
try {
|
||||
const sections = configManager.getAllSettings();
|
||||
res.json(sections);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get current configuration
|
||||
router.get('/config', async (req, res) => {
|
||||
try {
|
||||
// Use the new configuration schema approach instead of ConfigManager
|
||||
const configSchema = {
|
||||
System: {
|
||||
data_directory: {
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'Luanti data directory path (leave empty for auto-detection)'
|
||||
}
|
||||
},
|
||||
Server: {
|
||||
port: {
|
||||
type: 'number',
|
||||
default: 30000,
|
||||
description: 'Port for server to listen on'
|
||||
},
|
||||
server_name: {
|
||||
type: 'string',
|
||||
default: 'Luanti Server',
|
||||
description: 'Name of the server'
|
||||
},
|
||||
server_description: {
|
||||
type: 'string',
|
||||
default: 'A Luanti server',
|
||||
description: 'Server description'
|
||||
},
|
||||
server_announce: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Announce server to server list'
|
||||
},
|
||||
max_users: {
|
||||
type: 'number',
|
||||
default: 20,
|
||||
description: 'Maximum number of users'
|
||||
}
|
||||
},
|
||||
World: {
|
||||
creative_mode: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Enable creative mode by default'
|
||||
},
|
||||
enable_damage: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Enable player damage by default'
|
||||
},
|
||||
enable_pvp: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Enable player vs player combat by default'
|
||||
},
|
||||
default_game: {
|
||||
type: 'string',
|
||||
default: 'minetest_game',
|
||||
description: 'Default game to use for new worlds'
|
||||
},
|
||||
time_speed: {
|
||||
type: 'number',
|
||||
default: 72,
|
||||
description: 'Time speed (72 = normal, higher = faster)'
|
||||
}
|
||||
},
|
||||
Security: {
|
||||
disallow_empty_password: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Disallow empty passwords'
|
||||
},
|
||||
strict_protocol_version_checking: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Strict protocol version checking'
|
||||
}
|
||||
},
|
||||
Performance: {
|
||||
dedicated_server_step: {
|
||||
type: 'number',
|
||||
default: 0.1,
|
||||
description: 'Server step time in seconds'
|
||||
},
|
||||
num_emerge_threads: {
|
||||
type: 'number',
|
||||
default: 1,
|
||||
description: 'Number of emerge threads'
|
||||
},
|
||||
server_map_save_interval: {
|
||||
type: 'number',
|
||||
default: 15.3,
|
||||
description: 'Map save interval in seconds'
|
||||
},
|
||||
max_block_send_distance: {
|
||||
type: 'number',
|
||||
default: 12,
|
||||
description: 'Maximum block send distance'
|
||||
}
|
||||
},
|
||||
Network: {
|
||||
server_address: {
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'IP address to bind to (empty for all interfaces)'
|
||||
},
|
||||
server_dedicated: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Run as dedicated server'
|
||||
}
|
||||
},
|
||||
Advanced: {
|
||||
max_simultaneous_block_sends_per_client: {
|
||||
type: 'number',
|
||||
default: 40,
|
||||
description: 'Maximum simultaneous block sends per client'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Load both Luanti config and app config
|
||||
const luantiConfig = await ConfigParser.parseConfig(paths.configFile);
|
||||
await appConfig.load();
|
||||
|
||||
// Combine configs for display
|
||||
const combinedConfig = {
|
||||
...luantiConfig,
|
||||
data_directory: appConfig.getDataDirectory()
|
||||
};
|
||||
|
||||
// Organize schema into sections with proper structure for frontend
|
||||
const sections = {};
|
||||
for (const [sectionName, sectionFields] of Object.entries(configSchema)) {
|
||||
sections[sectionName] = {
|
||||
description: sectionName + ' configuration settings',
|
||||
settings: sectionFields
|
||||
};
|
||||
}
|
||||
|
||||
res.json({
|
||||
current: combinedConfig,
|
||||
sections: sections,
|
||||
schema: configSchema
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting config via API:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Update configuration
|
||||
router.post('/config', async (req, res) => {
|
||||
try {
|
||||
const { settings } = req.body;
|
||||
|
||||
if (!settings || typeof settings !== 'object') {
|
||||
return res.status(400).json({ error: 'Settings object is required' });
|
||||
}
|
||||
|
||||
// Validate all settings
|
||||
const validationErrors = [];
|
||||
const validatedSettings = {};
|
||||
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
const validation = configManager.validateSetting(key, value);
|
||||
if (validation.valid) {
|
||||
validatedSettings[key] = validation.value;
|
||||
} else {
|
||||
validationErrors.push({ key, error: validation.error });
|
||||
}
|
||||
}
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
return res.status(400).json({
|
||||
error: 'Validation failed',
|
||||
details: validationErrors
|
||||
});
|
||||
}
|
||||
|
||||
const result = await configManager.updateSettings(validatedSettings);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Update single configuration setting
|
||||
router.put('/config/:key', async (req, res) => {
|
||||
try {
|
||||
const { key } = req.params;
|
||||
const { value } = req.body;
|
||||
|
||||
const validation = configManager.validateSetting(key, value);
|
||||
if (!validation.valid) {
|
||||
return res.status(400).json({ error: validation.error });
|
||||
}
|
||||
|
||||
const result = await configManager.updateSetting(key, validation.value);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Reset configuration section to defaults
|
||||
router.post('/config/reset/:section?', async (req, res) => {
|
||||
try {
|
||||
const { section } = req.params;
|
||||
const result = await configManager.resetToDefaults(section);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get setting information
|
||||
router.get('/config/setting/:key', async (req, res) => {
|
||||
try {
|
||||
const { key } = req.params;
|
||||
const info = configManager.getSettingInfo(key);
|
||||
|
||||
if (!info) {
|
||||
return res.status(404).json({ error: 'Setting not found' });
|
||||
}
|
||||
|
||||
res.json(info);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Worlds endpoints (basic)
|
||||
router.get('/worlds', async (req, res) => {
|
||||
try {
|
||||
await fs.mkdir(paths.worldsDir, { recursive: true });
|
||||
const worldDirs = await fs.readdir(paths.worldsDir);
|
||||
const worlds = [];
|
||||
|
||||
for (const worldDir of worldDirs) {
|
||||
try {
|
||||
const worldPath = paths.getWorldPath(worldDir);
|
||||
const stats = await fs.stat(worldPath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
// Try to read world.mt for display name
|
||||
let displayName = worldDir;
|
||||
try {
|
||||
const worldConfig = await fs.readFile(
|
||||
path.join(worldPath, 'world.mt'),
|
||||
'utf8'
|
||||
);
|
||||
const nameMatch = worldConfig.match(/world_name\s*=\s*(.+)/);
|
||||
if (nameMatch) {
|
||||
displayName = nameMatch[1].trim().replace(/^["']|["']$/g, '');
|
||||
}
|
||||
} catch {}
|
||||
|
||||
worlds.push({
|
||||
name: worldDir,
|
||||
displayName: displayName,
|
||||
path: worldPath,
|
||||
lastModified: stats.mtime
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip invalid world directories
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by last modified
|
||||
worlds.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
|
||||
|
||||
res.json(worlds);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ContentDB package info endpoint for validation
|
||||
router.post('/contentdb/package-info', async (req, res) => {
|
||||
try {
|
||||
const { author, name } = req.body;
|
||||
|
||||
if (!author || !name) {
|
||||
return res.status(400).json({ error: 'Author and name are required' });
|
||||
}
|
||||
|
||||
const ContentDBClient = require('../utils/contentdb');
|
||||
const contentdb = new ContentDBClient();
|
||||
|
||||
// Get package info from ContentDB
|
||||
const packageInfo = await contentdb.getPackage(author, name);
|
||||
|
||||
res.json({
|
||||
type: packageInfo.type || 'mod',
|
||||
title: packageInfo.title || name,
|
||||
author: packageInfo.author || author,
|
||||
name: packageInfo.name || name,
|
||||
short_description: packageInfo.short_description || ''
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting package info:', error);
|
||||
|
||||
// If it's a 404 error, return that specifically
|
||||
if (error.message === 'Package not found') {
|
||||
return res.status(404).json({ error: 'Package not found on ContentDB' });
|
||||
}
|
||||
|
||||
// For other errors, return a generic error but don't fail completely
|
||||
res.status(200).json({
|
||||
error: 'Could not verify package information',
|
||||
type: 'mod', // Default to mod type
|
||||
fallback: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
router,
|
||||
setSocketIO,
|
||||
serverManager,
|
||||
configManager
|
||||
};
|
||||
+262
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,365 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
const paths = require('../utils/paths');
|
||||
const ConfigParser = require('../utils/config-parser');
|
||||
const ContentDBClient = require('../utils/contentdb');
|
||||
const ContentDBUrlParser = require('../utils/contentdb-url');
|
||||
const PackageRegistry = require('../utils/package-registry');
|
||||
|
||||
const router = express.Router();
|
||||
const contentdb = new ContentDBClient();
|
||||
const packageRegistry = new PackageRegistry();
|
||||
|
||||
// Initialize package registry
|
||||
packageRegistry.init().catch(console.error);
|
||||
|
||||
// Main Extensions page - shows installed content and installer
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
paths.ensureDirectories();
|
||||
|
||||
// Get installed packages from registry (games, mods, texture packs)
|
||||
const allRegistryPackages = await packageRegistry.getAllInstallations();
|
||||
const statistics = await packageRegistry.getStatistics();
|
||||
|
||||
// Filter registry packages to only include those that actually exist on disk
|
||||
const installedPackages = [];
|
||||
for (const pkg of allRegistryPackages) {
|
||||
let packagePath;
|
||||
if (pkg.package_type === 'game') {
|
||||
packagePath = paths.getGamePath(pkg.name);
|
||||
} else if (pkg.package_type === 'mod') {
|
||||
packagePath = paths.getModPath(pkg.name);
|
||||
} else {
|
||||
// For other types, assume they exist (texture packs, etc.)
|
||||
installedPackages.push(pkg);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only include if the package directory actually exists
|
||||
try {
|
||||
const stats = await fs.stat(packagePath);
|
||||
if (stats.isDirectory()) {
|
||||
installedPackages.push(pkg);
|
||||
}
|
||||
} catch (error) {
|
||||
// Package directory doesn't exist, don't include it
|
||||
console.log(`Package ${pkg.name} (${pkg.package_type}) not found at ${packagePath}, excluding from installed list`);
|
||||
}
|
||||
}
|
||||
|
||||
// Get local mods (not from ContentDB)
|
||||
let localMods = [];
|
||||
try {
|
||||
const modDirs = await fs.readdir(paths.modsDir);
|
||||
|
||||
for (const modDir of modDirs) {
|
||||
try {
|
||||
const modPath = paths.getModPath(modDir);
|
||||
const configPath = paths.getModConfigPath(modDir);
|
||||
|
||||
const stats = await fs.stat(modPath);
|
||||
if (!stats.isDirectory()) continue;
|
||||
|
||||
// Check if this mod is already in the registry (from ContentDB)
|
||||
const isFromContentDB = installedPackages.some(pkg =>
|
||||
pkg.name === modDir && pkg.install_location === 'global'
|
||||
);
|
||||
|
||||
if (!isFromContentDB) {
|
||||
const config = await ConfigParser.parseModConfig(configPath);
|
||||
|
||||
localMods.push({
|
||||
name: modDir,
|
||||
title: config.title || modDir,
|
||||
description: config.description || '',
|
||||
author: config.author || 'Local',
|
||||
type: 'mod',
|
||||
location: 'global',
|
||||
source: 'local',
|
||||
path: modPath,
|
||||
lastModified: stats.mtime
|
||||
});
|
||||
}
|
||||
} catch (modError) {
|
||||
console.error(`Error reading mod ${modDir}:`, modError);
|
||||
}
|
||||
}
|
||||
} catch (dirError) {
|
||||
console.warn('Could not read mods directory:', dirError);
|
||||
}
|
||||
|
||||
// Get installed games from all locations (only those NOT already in ContentDB registry)
|
||||
let localGames = [];
|
||||
try {
|
||||
const allInstalledGames = await paths.getInstalledGames();
|
||||
|
||||
for (const game of allInstalledGames) {
|
||||
// Check if this game is already in the ContentDB registry
|
||||
const isFromContentDB = installedPackages.some(pkg =>
|
||||
(pkg.name === game.name || pkg.name === game.directoryName) && pkg.package_type === 'game'
|
||||
);
|
||||
|
||||
if (!isFromContentDB) {
|
||||
localGames.push({
|
||||
name: game.name,
|
||||
title: game.title,
|
||||
description: game.description,
|
||||
author: game.author || 'Unknown',
|
||||
type: 'game',
|
||||
location: 'games',
|
||||
source: game.isSystemGame ? 'system' : 'local',
|
||||
path: game.path,
|
||||
lastModified: null // We don't have this info from the paths util
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (dirError) {
|
||||
console.warn('Could not read games:', dirError);
|
||||
}
|
||||
|
||||
// Combine all content (ContentDB packages already include games)
|
||||
const allContent = [
|
||||
...installedPackages.map(pkg => ({ ...pkg, source: 'contentdb' })),
|
||||
...localMods,
|
||||
...localGames
|
||||
];
|
||||
|
||||
// Sort by type (games first, then mods, then texture packs) and name
|
||||
const sortOrder = { game: 1, mod: 2, txp: 3 };
|
||||
allContent.sort((a, b) => {
|
||||
const typeA = sortOrder[a.package_type || a.type] || 4;
|
||||
const typeB = sortOrder[b.package_type || b.type] || 4;
|
||||
|
||||
if (typeA !== typeB) return typeA - typeB;
|
||||
return (a.title || a.name).localeCompare(b.title || b.name);
|
||||
});
|
||||
|
||||
res.render('extensions/index', {
|
||||
title: 'Extensions',
|
||||
allContent: allContent,
|
||||
statistics: {
|
||||
...statistics,
|
||||
games: installedPackages.filter(pkg => pkg.package_type === 'game').length + localGames.length,
|
||||
local_mods: localMods.length
|
||||
},
|
||||
currentPage: 'extensions'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading extensions:', error);
|
||||
res.status(500).render('error', {
|
||||
error: 'Failed to load extensions',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Install package from URL (same as ContentDB)
|
||||
router.post('/install-url', async (req, res) => {
|
||||
try {
|
||||
const { packageUrl, installLocation, worldName, installDeps } = req.body;
|
||||
|
||||
if (!packageUrl) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Package URL is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Parse and validate URL
|
||||
const parsed = ContentDBUrlParser.parseUrl(packageUrl);
|
||||
|
||||
if (!parsed.isValid) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: parsed.error || 'Invalid URL format'
|
||||
});
|
||||
}
|
||||
|
||||
const { author, name } = parsed;
|
||||
|
||||
// Get package info to determine type
|
||||
const packageInfo = await contentdb.getPackage(author, name);
|
||||
const packageType = packageInfo.type || 'mod';
|
||||
|
||||
// Determine target path based on package type
|
||||
let targetPath;
|
||||
let locationDescription;
|
||||
|
||||
if (packageType === 'game') {
|
||||
await fs.mkdir(paths.gamesDir, { recursive: true });
|
||||
targetPath = paths.getGamePath(name);
|
||||
locationDescription = 'games directory';
|
||||
} else if (packageType === 'txp') {
|
||||
await fs.mkdir(paths.texturesDir, { recursive: true });
|
||||
targetPath = path.join(paths.texturesDir, name);
|
||||
locationDescription = 'textures directory';
|
||||
} else {
|
||||
if (installLocation === 'world') {
|
||||
if (!worldName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'World name is required when installing to specific world'
|
||||
});
|
||||
}
|
||||
|
||||
if (!paths.isValidWorldName(worldName)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid world name'
|
||||
});
|
||||
}
|
||||
|
||||
const worldModsPath = paths.getWorldModsPath(worldName);
|
||||
await fs.mkdir(worldModsPath, { recursive: true });
|
||||
|
||||
targetPath = path.join(worldModsPath, name);
|
||||
locationDescription = `world "${worldName}"`;
|
||||
} else {
|
||||
await fs.mkdir(paths.modsDir, { recursive: true });
|
||||
targetPath = path.join(paths.modsDir, name);
|
||||
locationDescription = 'global directory';
|
||||
}
|
||||
}
|
||||
|
||||
// Check if already installed
|
||||
let installLocationKey;
|
||||
if (packageType === 'game') {
|
||||
installLocationKey = 'games';
|
||||
} else if (packageType === 'txp') {
|
||||
installLocationKey = 'textures';
|
||||
} else {
|
||||
installLocationKey = installLocation === 'world' ? `world:${worldName}` : 'global';
|
||||
}
|
||||
|
||||
const isInstalled = await packageRegistry.isPackageInstalled(author, name, installLocationKey);
|
||||
|
||||
if (isInstalled) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
error: `Package "${name}" is already installed in ${locationDescription}`
|
||||
});
|
||||
}
|
||||
|
||||
// Install the package
|
||||
let installResult;
|
||||
|
||||
if (installDeps === 'on' && packageType === 'mod') {
|
||||
const basePath = installLocation === 'world'
|
||||
? paths.getWorldModsPath(worldName)
|
||||
: paths.modsDir;
|
||||
|
||||
installResult = await contentdb.installPackageWithDeps(author, name, basePath, true);
|
||||
|
||||
if (installResult.errors && installResult.errors.length > 0) {
|
||||
console.warn('Installation completed with errors:', installResult.errors);
|
||||
}
|
||||
} else {
|
||||
installResult = await contentdb.downloadPackage(author, name, targetPath);
|
||||
}
|
||||
|
||||
// Record installation in registry
|
||||
try {
|
||||
const packageInfo = installResult.main ? installResult.main.package : installResult.package;
|
||||
const releaseInfo = installResult.main ? installResult.main.release : installResult.release;
|
||||
|
||||
await packageRegistry.recordInstallation({
|
||||
author: author,
|
||||
name: name,
|
||||
version: releaseInfo?.title || 'latest',
|
||||
releaseId: releaseInfo?.id,
|
||||
installLocation: installLocationKey,
|
||||
installPath: targetPath,
|
||||
contentdbUrl: parsed.fullUrl,
|
||||
packageType: packageInfo?.type || 'mod',
|
||||
title: packageInfo?.title || name,
|
||||
shortDescription: packageInfo?.short_description || '',
|
||||
dependencies: packageInfo?.hard_dependencies || []
|
||||
});
|
||||
|
||||
// Record dependencies if installed
|
||||
if (installDeps === 'on' && installResult.dependencies) {
|
||||
for (const dep of installResult.dependencies) {
|
||||
const depInfo = dep.package;
|
||||
const depRelease = dep.release;
|
||||
const depPath = path.join(
|
||||
installLocation === 'world' ? paths.getWorldModsPath(worldName) : paths.modsDir,
|
||||
depInfo.name
|
||||
);
|
||||
|
||||
await packageRegistry.recordInstallation({
|
||||
author: depInfo.author,
|
||||
name: depInfo.name,
|
||||
version: depRelease?.title || 'latest',
|
||||
releaseId: depRelease?.id,
|
||||
installLocation: installLocationKey,
|
||||
installPath: depPath,
|
||||
contentdbUrl: `https://content.luanti.org/packages/${depInfo.author}/${depInfo.name}/`,
|
||||
packageType: depInfo.type || 'mod',
|
||||
title: depInfo.title || depInfo.name,
|
||||
shortDescription: depInfo.short_description || '',
|
||||
dependencies: depInfo.hard_dependencies || []
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (registryError) {
|
||||
console.warn('Failed to record installation in registry:', registryError);
|
||||
}
|
||||
|
||||
// Create success response
|
||||
let message = `Successfully installed "${name}" to ${locationDescription}`;
|
||||
|
||||
if (installDeps === 'on' && installResult.dependencies) {
|
||||
const depCount = installResult.dependencies.length;
|
||||
if (depCount > 0) {
|
||||
message += ` with ${depCount} dependenc${depCount === 1 ? 'y' : 'ies'}`;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: message,
|
||||
package: {
|
||||
author: author,
|
||||
name: name,
|
||||
location: locationDescription
|
||||
},
|
||||
installResult: installResult
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error installing package from URL:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Installation failed: ' + error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// API endpoint for search (AJAX)
|
||||
router.get('/api/search', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
q = '',
|
||||
type = '',
|
||||
sort = 'score',
|
||||
order = 'desc',
|
||||
limit = '10'
|
||||
} = req.query;
|
||||
|
||||
const packages = await contentdb.searchPackages(q, type, sort, order, parseInt(limit), 0);
|
||||
|
||||
res.json({
|
||||
packages: packages || [],
|
||||
query: q,
|
||||
type: type
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error searching ContentDB:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+318
@@ -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;
|
||||
@@ -0,0 +1,473 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs').promises;
|
||||
const { spawn } = require('child_process');
|
||||
const chokidar = require('chokidar');
|
||||
|
||||
const paths = require('../utils/paths');
|
||||
const ConfigParser = require('../utils/config-parser');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Security function to validate configuration overrides
|
||||
function validateConfigOverrides(configOverrides) {
|
||||
if (!configOverrides || typeof configOverrides !== 'object') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const sanitized = {};
|
||||
|
||||
// Whitelist of allowed configuration parameters
|
||||
const allowedConfigKeys = [
|
||||
'port', 'bind', 'name', 'motd', 'max_users', 'password', 'default_game',
|
||||
'enable_damage', 'creative_mode', 'enable_rollback_recording', 'disallow_empty_password',
|
||||
'server_announce', 'serverlist_url', 'enable_pvp', 'time_speed', 'day_night_ratio',
|
||||
'max_simultaneous_block_sends_per_client', 'max_block_send_distance',
|
||||
'max_block_generate_distance', 'secure', 'enable_client_modding', 'csm_restriction_flags',
|
||||
'csm_restriction_noderange', 'player_transfer_distance', 'max_packets_per_iteration',
|
||||
'dedicated_server_step', 'ignore_world_load_errors', 'remote_media'
|
||||
];
|
||||
|
||||
for (const [key, value] of Object.entries(configOverrides)) {
|
||||
// Validate key
|
||||
if (!allowedConfigKeys.includes(key) || !/^[a-z_]+$/.test(key)) {
|
||||
continue; // Skip invalid keys
|
||||
}
|
||||
|
||||
// Validate and sanitize value
|
||||
let sanitizedValue = String(value).trim();
|
||||
|
||||
// Remove control characters
|
||||
sanitizedValue = sanitizedValue.replace(/[\x00-\x1F\x7F]/g, '');
|
||||
|
||||
// Limit length
|
||||
if (sanitizedValue.length > 200) {
|
||||
continue; // Skip overly long values
|
||||
}
|
||||
|
||||
// Type-specific validation
|
||||
if (['port', 'max_users', 'time_speed', 'max_simultaneous_block_sends_per_client',
|
||||
'max_block_send_distance', 'max_block_generate_distance', 'csm_restriction_noderange',
|
||||
'player_transfer_distance', 'max_packets_per_iteration', 'dedicated_server_step'].includes(key)) {
|
||||
const numValue = parseInt(sanitizedValue, 10);
|
||||
if (!isNaN(numValue) && numValue >= 0 && numValue <= 65535) {
|
||||
sanitized[key] = numValue.toString();
|
||||
}
|
||||
} else if (['enable_damage', 'creative_mode', 'enable_rollback_recording', 'disallow_empty_password',
|
||||
'server_announce', 'enable_pvp', 'secure', 'enable_client_modding', 'ignore_world_load_errors'].includes(key)) {
|
||||
if (['true', 'false'].includes(sanitizedValue.toLowerCase())) {
|
||||
sanitized[key] = sanitizedValue.toLowerCase();
|
||||
}
|
||||
} else if (['bind', 'name', 'motd', 'password', 'default_game', 'serverlist_url'].includes(key)) {
|
||||
// String values - ensure they don't contain shell metacharacters
|
||||
if (!/[;&|`$(){}[\]<>\\]/.test(sanitizedValue)) {
|
||||
sanitized[key] = sanitizedValue;
|
||||
}
|
||||
} else {
|
||||
// Floating point values
|
||||
const floatValue = parseFloat(sanitizedValue);
|
||||
if (!isNaN(floatValue) && isFinite(floatValue)) {
|
||||
sanitized[key] = floatValue.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
// Global server state
|
||||
let serverProcess = null;
|
||||
let serverStatus = 'stopped';
|
||||
let serverLogs = [];
|
||||
let logWatcher = null;
|
||||
|
||||
// Server management page
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
paths.ensureDirectories();
|
||||
|
||||
// Get available worlds for dropdown
|
||||
let worlds = [];
|
||||
try {
|
||||
const worldDirs = await fs.readdir(paths.worldsDir);
|
||||
for (const worldDir of worldDirs) {
|
||||
try {
|
||||
const worldPath = paths.getWorldPath(worldDir);
|
||||
const configPath = paths.getWorldConfigPath(worldDir);
|
||||
const stats = await fs.stat(worldPath);
|
||||
if (stats.isDirectory()) {
|
||||
const config = await ConfigParser.parseWorldConfig(configPath);
|
||||
worlds.push({
|
||||
name: worldDir,
|
||||
displayName: config.server_name || worldDir
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Get recent logs
|
||||
let recentLogs = [];
|
||||
try {
|
||||
const logContent = await fs.readFile(paths.debugFile, 'utf8');
|
||||
const lines = logContent.split('\n').filter(line => line.trim());
|
||||
recentLogs = lines.slice(-50); // Last 50 lines
|
||||
} catch {
|
||||
// Debug file might not exist
|
||||
}
|
||||
|
||||
const serverInfo = {
|
||||
status: serverStatus,
|
||||
pid: serverProcess ? serverProcess.pid : null,
|
||||
uptime: serverProcess ? Date.now() - serverProcess.startTime : 0,
|
||||
logs: [...recentLogs, ...serverLogs.map(log => log.message || log)].slice(-100)
|
||||
};
|
||||
|
||||
res.render('server/index', {
|
||||
title: 'Server Management',
|
||||
server: serverInfo,
|
||||
worlds: worlds,
|
||||
currentPage: 'server',
|
||||
scripts: ['server.js']
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading server page:', error);
|
||||
res.status(500).render('error', {
|
||||
error: 'Failed to load server management',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get server status (API)
|
||||
router.get('/api/status', (req, res) => {
|
||||
res.json({
|
||||
status: serverStatus,
|
||||
pid: serverProcess ? serverProcess.pid : null,
|
||||
uptime: serverProcess ? Date.now() - serverProcess.startTime : 0
|
||||
});
|
||||
});
|
||||
|
||||
// Get server logs (API)
|
||||
router.get('/api/logs', async (req, res) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit) || 100;
|
||||
const offset = parseInt(req.query.offset) || 0;
|
||||
|
||||
let fileLogs = [];
|
||||
try {
|
||||
const logContent = await fs.readFile(paths.debugFile, 'utf8');
|
||||
const lines = logContent.split('\n').filter(line => line.trim());
|
||||
fileLogs = lines.slice(-1000);
|
||||
} catch {}
|
||||
|
||||
const allLogs = [...fileLogs, ...serverLogs.map(log => log.message || log)];
|
||||
const paginatedLogs = allLogs.slice(offset, offset + limit);
|
||||
|
||||
res.json({
|
||||
logs: paginatedLogs,
|
||||
total: allLogs.length,
|
||||
offset,
|
||||
limit
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting logs:', error);
|
||||
res.status(500).json({ error: 'Failed to get logs' });
|
||||
}
|
||||
});
|
||||
|
||||
// Start server
|
||||
router.post('/start', async (req, res) => {
|
||||
try {
|
||||
if (serverProcess && serverStatus === 'running') {
|
||||
return res.status(409).json({ error: 'Server is already running' });
|
||||
}
|
||||
|
||||
const { worldName, configOverrides } = req.body;
|
||||
|
||||
if (!worldName || !paths.isValidWorldName(worldName)) {
|
||||
if (req.headers.accept && req.headers.accept.includes('application/json')) {
|
||||
return res.status(400).json({ error: 'Valid world name required' });
|
||||
} else {
|
||||
return res.redirect('/server?error=Valid+world+name+required');
|
||||
}
|
||||
}
|
||||
|
||||
const worldPath = paths.getWorldPath(worldName);
|
||||
|
||||
try {
|
||||
await fs.access(worldPath);
|
||||
} catch {
|
||||
if (req.headers.accept && req.headers.accept.includes('application/json')) {
|
||||
return res.status(404).json({ error: 'World not found' });
|
||||
} else {
|
||||
return res.redirect('/server?error=World+not+found');
|
||||
}
|
||||
}
|
||||
|
||||
const args = [
|
||||
'--server',
|
||||
'--world', worldPath,
|
||||
'--logfile', paths.debugFile
|
||||
];
|
||||
|
||||
if (configOverrides) {
|
||||
const sanitizedOverrides = validateConfigOverrides(configOverrides);
|
||||
for (const [key, value] of Object.entries(sanitizedOverrides)) {
|
||||
args.push(`--${key}`, value);
|
||||
}
|
||||
}
|
||||
|
||||
serverProcess = spawn('luanti', args, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
detached: false
|
||||
});
|
||||
|
||||
serverProcess.startTime = Date.now();
|
||||
serverStatus = 'starting';
|
||||
serverLogs = [];
|
||||
|
||||
// Get Socket.IO instance from main app
|
||||
const { io } = require('../app');
|
||||
|
||||
serverProcess.stdout.on('data', (data) => {
|
||||
const logLine = data.toString().trim();
|
||||
if (logLine) {
|
||||
const logEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'info',
|
||||
message: logLine
|
||||
};
|
||||
serverLogs.push(logEntry);
|
||||
if (serverLogs.length > 1000) {
|
||||
serverLogs = serverLogs.slice(-1000);
|
||||
}
|
||||
|
||||
if (io) {
|
||||
io.emit('serverLog', logEntry);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
serverProcess.stderr.on('data', (data) => {
|
||||
const logLine = data.toString().trim();
|
||||
if (logLine) {
|
||||
const logEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'error',
|
||||
message: logLine
|
||||
};
|
||||
serverLogs.push(logEntry);
|
||||
if (serverLogs.length > 1000) {
|
||||
serverLogs = serverLogs.slice(-1000);
|
||||
}
|
||||
|
||||
if (io) {
|
||||
io.emit('serverLog', logEntry);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
serverProcess.on('spawn', () => {
|
||||
serverStatus = 'running';
|
||||
console.log('Luanti server started');
|
||||
|
||||
if (io) {
|
||||
io.emit('serverStatus', {
|
||||
status: serverStatus,
|
||||
pid: serverProcess.pid,
|
||||
uptime: Date.now() - serverProcess.startTime
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
serverProcess.on('error', (error) => {
|
||||
console.error('Server error:', error);
|
||||
serverStatus = 'error';
|
||||
const logEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'error',
|
||||
message: `Server error: ${error.message}`
|
||||
};
|
||||
serverLogs.push(logEntry);
|
||||
|
||||
if (io) {
|
||||
io.emit('serverLog', logEntry);
|
||||
io.emit('serverStatus', {
|
||||
status: serverStatus,
|
||||
pid: null,
|
||||
uptime: 0
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
serverProcess.on('exit', (code, signal) => {
|
||||
console.log(`Server exited with code ${code}, signal ${signal}`);
|
||||
serverStatus = 'stopped';
|
||||
const logEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'info',
|
||||
message: `Server stopped (code: ${code}, signal: ${signal})`
|
||||
};
|
||||
serverLogs.push(logEntry);
|
||||
serverProcess = null;
|
||||
|
||||
if (io) {
|
||||
io.emit('serverLog', logEntry);
|
||||
io.emit('serverStatus', {
|
||||
status: serverStatus,
|
||||
pid: null,
|
||||
uptime: 0
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Watch debug log file
|
||||
if (logWatcher) {
|
||||
logWatcher.close();
|
||||
}
|
||||
|
||||
logWatcher = chokidar.watch(paths.debugFile, { persistent: true });
|
||||
logWatcher.on('change', async () => {
|
||||
try {
|
||||
const logContent = await fs.readFile(paths.debugFile, 'utf8');
|
||||
const lines = logContent.split('\n');
|
||||
const newLines = lines.slice(-10);
|
||||
|
||||
for (const line of newLines) {
|
||||
if (line.trim() && !serverLogs.some(log => (log.message || log) === line.trim())) {
|
||||
const logEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'info',
|
||||
message: line.trim()
|
||||
};
|
||||
serverLogs.push(logEntry);
|
||||
|
||||
if (io) {
|
||||
io.emit('serverLog', logEntry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (serverLogs.length > 1000) {
|
||||
serverLogs = serverLogs.slice(-1000);
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
|
||||
if (req.headers.accept && req.headers.accept.includes('application/json')) {
|
||||
res.json({ message: 'Server starting', pid: serverProcess.pid });
|
||||
} else {
|
||||
res.redirect('/server?started=true');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error starting server:', error);
|
||||
if (req.headers.accept && req.headers.accept.includes('application/json')) {
|
||||
res.status(500).json({ error: 'Failed to start server' });
|
||||
} else {
|
||||
res.redirect(`/server?error=${encodeURIComponent(error.message)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Stop server
|
||||
router.post('/stop', (req, res) => {
|
||||
try {
|
||||
if (!serverProcess || serverStatus !== 'running') {
|
||||
if (req.headers.accept && req.headers.accept.includes('application/json')) {
|
||||
return res.status(409).json({ error: 'Server is not running' });
|
||||
} else {
|
||||
return res.redirect('/server?error=Server+is+not+running');
|
||||
}
|
||||
}
|
||||
|
||||
serverStatus = 'stopping';
|
||||
|
||||
serverProcess.kill('SIGTERM');
|
||||
|
||||
setTimeout(() => {
|
||||
if (serverProcess && serverStatus === 'stopping') {
|
||||
serverProcess.kill('SIGKILL');
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
if (logWatcher) {
|
||||
logWatcher.close();
|
||||
logWatcher = null;
|
||||
}
|
||||
|
||||
const { io } = require('../app');
|
||||
if (io) {
|
||||
io.emit('serverStatus', {
|
||||
status: serverStatus,
|
||||
pid: serverProcess ? serverProcess.pid : null,
|
||||
uptime: serverProcess ? Date.now() - serverProcess.startTime : 0
|
||||
});
|
||||
}
|
||||
|
||||
if (req.headers.accept && req.headers.accept.includes('application/json')) {
|
||||
res.json({ message: 'Server stopping' });
|
||||
} else {
|
||||
res.redirect('/server?stopped=true');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error stopping server:', error);
|
||||
if (req.headers.accept && req.headers.accept.includes('application/json')) {
|
||||
res.status(500).json({ error: 'Failed to stop server' });
|
||||
} else {
|
||||
res.redirect(`/server?error=${encodeURIComponent(error.message)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Send command to server
|
||||
router.post('/command', (req, res) => {
|
||||
try {
|
||||
if (!serverProcess || serverStatus !== 'running') {
|
||||
return res.status(409).json({ error: 'Server is not running' });
|
||||
}
|
||||
|
||||
const { command } = req.body;
|
||||
|
||||
if (!command) {
|
||||
return res.status(400).json({ error: 'Command required' });
|
||||
}
|
||||
|
||||
// Validate and sanitize the command using ServerManager's validation
|
||||
const ServerManager = require('../utils/server-manager');
|
||||
const serverManager = new ServerManager();
|
||||
|
||||
try {
|
||||
const sanitizedCommand = serverManager.validateServerCommand(command);
|
||||
serverProcess.stdin.write(sanitizedCommand + '\n');
|
||||
|
||||
const logEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'command',
|
||||
message: `> ${sanitizedCommand}`
|
||||
};
|
||||
serverLogs.push(logEntry);
|
||||
|
||||
const { io } = require('../app');
|
||||
if (io) {
|
||||
io.emit('serverLog', logEntry);
|
||||
}
|
||||
|
||||
res.json({ message: 'Command sent successfully' });
|
||||
} catch (validationError) {
|
||||
return res.status(400).json({ error: validationError.message });
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error sending command:', error);
|
||||
res.status(500).json({ error: 'Failed to send command' });
|
||||
}
|
||||
});
|
||||
|
||||
// Export server state for use in main app
|
||||
router.getServerState = () => ({
|
||||
process: serverProcess,
|
||||
status: serverStatus,
|
||||
logs: serverLogs
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+118
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,127 @@
|
||||
const fs = require('fs').promises;
|
||||
const fsSync = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
class AppConfig {
|
||||
constructor() {
|
||||
this.configDir = path.join(os.homedir(), '.luhost');
|
||||
this.configFile = path.join(this.configDir, 'config.json');
|
||||
this.defaultConfig = {
|
||||
dataDirectory: this.getDefaultDataDirectory(),
|
||||
serverPort: 3000,
|
||||
debugMode: false
|
||||
};
|
||||
this.config = null;
|
||||
}
|
||||
|
||||
getDefaultDataDirectory() {
|
||||
const homeDir = os.homedir();
|
||||
const possibleDirs = [
|
||||
path.join(homeDir, '.luanti'),
|
||||
path.join(homeDir, '.minetest')
|
||||
];
|
||||
|
||||
// Use the first one that exists, or default to .minetest
|
||||
for (const dir of possibleDirs) {
|
||||
if (fsSync.existsSync(dir)) {
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
|
||||
return path.join(homeDir, '.minetest');
|
||||
}
|
||||
|
||||
async load() {
|
||||
try {
|
||||
// Ensure config directory exists
|
||||
if (!fsSync.existsSync(this.configDir)) {
|
||||
await fs.mkdir(this.configDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Try to read existing config
|
||||
try {
|
||||
const configData = await fs.readFile(this.configFile, 'utf8');
|
||||
this.config = { ...this.defaultConfig, ...JSON.parse(configData) };
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
// Config file doesn't exist, create it with defaults
|
||||
this.config = { ...this.defaultConfig };
|
||||
await this.save();
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return this.config;
|
||||
} catch (error) {
|
||||
console.error('Failed to load app config:', error);
|
||||
// Fall back to defaults if config loading fails
|
||||
this.config = { ...this.defaultConfig };
|
||||
return this.config;
|
||||
}
|
||||
}
|
||||
|
||||
async save() {
|
||||
try {
|
||||
if (!fsSync.existsSync(this.configDir)) {
|
||||
await fs.mkdir(this.configDir, { recursive: true });
|
||||
}
|
||||
|
||||
await fs.writeFile(this.configFile, JSON.stringify(this.config, null, 2), 'utf8');
|
||||
} catch (error) {
|
||||
console.error('Failed to save app config:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
get(key) {
|
||||
return this.config ? this.config[key] : this.defaultConfig[key];
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
if (!this.config) {
|
||||
this.config = { ...this.defaultConfig };
|
||||
}
|
||||
this.config[key] = value;
|
||||
}
|
||||
|
||||
async update(updates) {
|
||||
if (!this.config) {
|
||||
this.config = { ...this.defaultConfig };
|
||||
}
|
||||
|
||||
Object.assign(this.config, updates);
|
||||
await this.save();
|
||||
}
|
||||
|
||||
getDataDirectory() {
|
||||
return this.get('dataDirectory');
|
||||
}
|
||||
|
||||
async setDataDirectory(dataDir) {
|
||||
const resolvedPath = path.resolve(dataDir);
|
||||
|
||||
// Validate that the directory exists or can be created
|
||||
try {
|
||||
await fs.access(resolvedPath);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
// Try to create the directory
|
||||
try {
|
||||
await fs.mkdir(resolvedPath, { recursive: true });
|
||||
} catch (createError) {
|
||||
throw new Error(`Cannot create data directory: ${createError.message}`);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Cannot access data directory: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.set('dataDirectory', resolvedPath);
|
||||
await this.save();
|
||||
return resolvedPath;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AppConfig();
|
||||
+288
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* ContentDB URL Parser and Validator
|
||||
* Handles parsing and validation of ContentDB package URLs
|
||||
*/
|
||||
|
||||
class ContentDBUrlParser {
|
||||
/**
|
||||
* Parse a ContentDB URL to extract author and package name
|
||||
* @param {string} url - The URL to parse
|
||||
* @returns {Object} - {author, name, isValid, originalUrl}
|
||||
*/
|
||||
static parseUrl(url) {
|
||||
if (!url || typeof url !== 'string') {
|
||||
return {
|
||||
author: null,
|
||||
name: null,
|
||||
isValid: false,
|
||||
originalUrl: url,
|
||||
error: 'URL is required'
|
||||
};
|
||||
}
|
||||
|
||||
// Clean up the URL
|
||||
let cleanUrl = url.trim();
|
||||
|
||||
// Remove protocol
|
||||
cleanUrl = cleanUrl.replace(/^https?:\/\//, '');
|
||||
|
||||
// Remove trailing slash
|
||||
cleanUrl = cleanUrl.replace(/\/$/, '');
|
||||
|
||||
// Define patterns to match
|
||||
const patterns = [
|
||||
// Full ContentDB URL: content.luanti.org/packages/author/name
|
||||
/^content\.luanti\.org\/packages\/([^\/\s]+)\/([^\/\s]+)$/,
|
||||
|
||||
// Alternative domain patterns (if any)
|
||||
/^(?:www\.)?content\.luanti\.org\/packages\/([^\/\s]+)\/([^\/\s]+)$/,
|
||||
|
||||
// Direct author/name format
|
||||
/^([^\/\s]+)\/([^\/\s]+)$/
|
||||
];
|
||||
|
||||
// Try each pattern
|
||||
for (const pattern of patterns) {
|
||||
const match = cleanUrl.match(pattern);
|
||||
if (match) {
|
||||
const author = match[1];
|
||||
const name = match[2];
|
||||
|
||||
// Validate author and name format
|
||||
if (this.isValidIdentifier(author) && this.isValidIdentifier(name)) {
|
||||
return {
|
||||
author: author,
|
||||
name: name,
|
||||
isValid: true,
|
||||
originalUrl: url,
|
||||
cleanUrl: cleanUrl,
|
||||
fullUrl: `https://content.luanti.org/packages/${author}/${name}/`
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
author: null,
|
||||
name: null,
|
||||
isValid: false,
|
||||
originalUrl: url,
|
||||
error: 'Invalid author or package name format'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
author: null,
|
||||
name: null,
|
||||
isValid: false,
|
||||
originalUrl: url,
|
||||
error: 'URL format not recognized'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an identifier (author or package name)
|
||||
* @param {string} identifier - The identifier to validate
|
||||
* @returns {boolean} - Whether the identifier is valid
|
||||
*/
|
||||
static isValidIdentifier(identifier) {
|
||||
if (!identifier || typeof identifier !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ContentDB identifiers should be alphanumeric with underscores and hyphens
|
||||
// Length should be reasonable (3-50 characters)
|
||||
return /^[a-zA-Z0-9_-]{3,50}$/.test(identifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate various URL formats for a package
|
||||
* @param {string} author - Package author
|
||||
* @param {string} name - Package name
|
||||
* @returns {Object} - Object containing different URL formats
|
||||
*/
|
||||
static generateUrls(author, name) {
|
||||
if (!this.isValidIdentifier(author) || !this.isValidIdentifier(name)) {
|
||||
throw new Error('Invalid author or package name');
|
||||
}
|
||||
|
||||
return {
|
||||
web: `https://content.luanti.org/packages/${author}/${name}/`,
|
||||
api: `https://content.luanti.org/api/packages/${author}/${name}/`,
|
||||
releases: `https://content.luanti.org/api/packages/${author}/${name}/releases/`,
|
||||
direct: `${author}/${name}`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate multiple URL formats and suggest corrections
|
||||
* @param {string} url - The URL to validate
|
||||
* @returns {Object} - Validation result with suggestions
|
||||
*/
|
||||
static validateWithSuggestions(url) {
|
||||
const result = this.parseUrl(url);
|
||||
|
||||
if (result.isValid) {
|
||||
return {
|
||||
...result,
|
||||
suggestions: []
|
||||
};
|
||||
}
|
||||
|
||||
// Generate suggestions for common mistakes
|
||||
const suggestions = [];
|
||||
|
||||
if (url.includes('minetest.') || url.includes('minetest/')) {
|
||||
suggestions.push('Did you mean content.luanti.org instead of minetest?');
|
||||
}
|
||||
|
||||
if (url.includes('://content.luanti.org') && !url.includes('/packages/')) {
|
||||
suggestions.push('Make sure the URL includes /packages/author/name/');
|
||||
}
|
||||
|
||||
if (url.includes(' ')) {
|
||||
suggestions.push('Remove spaces from the URL');
|
||||
}
|
||||
|
||||
// Check if it looks like a partial URL
|
||||
if (url.includes('/') && !url.includes('content.luanti.org')) {
|
||||
suggestions.push('Try the full URL: https://content.luanti.org/packages/author/name/');
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
suggestions
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract package information from various URL formats
|
||||
* @param {string} url - The URL to extract from
|
||||
* @returns {Promise<Object>} - Package information if available
|
||||
*/
|
||||
static async extractPackageInfo(url) {
|
||||
const parsed = this.parseUrl(url);
|
||||
|
||||
if (!parsed.isValid) {
|
||||
throw new Error(parsed.error || 'Invalid URL format');
|
||||
}
|
||||
|
||||
return {
|
||||
author: parsed.author,
|
||||
name: parsed.name,
|
||||
identifier: `${parsed.author}/${parsed.name}`,
|
||||
urls: this.generateUrls(parsed.author, parsed.name)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is a ContentDB package URL
|
||||
* @param {string} url - The URL to check
|
||||
* @returns {boolean} - Whether it's a ContentDB package URL
|
||||
*/
|
||||
static isContentDBUrl(url) {
|
||||
return this.parseUrl(url).isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a URL to standard format
|
||||
* @param {string} url - The URL to normalize
|
||||
* @returns {string} - Normalized URL
|
||||
*/
|
||||
static normalizeUrl(url) {
|
||||
const parsed = this.parseUrl(url);
|
||||
|
||||
if (!parsed.isValid) {
|
||||
throw new Error(parsed.error || 'Invalid URL format');
|
||||
}
|
||||
|
||||
return parsed.fullUrl;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ContentDBUrlParser;
|
||||
@@ -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}`);
|
||||
}
|
||||
}< | ||||