feat: revamp the game serving feature
This commit is contained in:
+1
-3
@@ -22,8 +22,6 @@ JWT_SECRET=replace-with-a-secure-random-string
|
||||
# These map host ports to container ports (container ports are hardcoded)
|
||||
WEB_PORT=80
|
||||
API_PORT=3100
|
||||
PROXY_PORT=22500
|
||||
GAMEZIP_PORT=22501
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data Storage
|
||||
@@ -34,7 +32,7 @@ DATA_PATH=./data
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging
|
||||
# ---------------------------------------------------------------------------
|
||||
# Host path for logs (shared by all services: backend.log, game-service.log, client-errors.log)
|
||||
# Host path for logs (backend.log, client-errors.log)
|
||||
LOGS_PATH=./logs
|
||||
LOG_LEVEL=info
|
||||
|
||||
|
||||
@@ -23,9 +23,6 @@ jobs:
|
||||
- name: frontend
|
||||
context: .
|
||||
dockerfile: ./frontend/Dockerfile
|
||||
- name: game-service
|
||||
context: .
|
||||
dockerfile: ./game-service/Dockerfile
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -93,7 +90,6 @@ jobs:
|
||||
echo "### Images Built:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`flashpoint-backend\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`flashpoint-frontend\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`flashpoint-game-service\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Registry:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Docker Hub: \`${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-*\`" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -102,5 +98,4 @@ jobs:
|
||||
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
|
||||
echo "docker pull ${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-backend:latest" >> $GITHUB_STEP_SUMMARY
|
||||
echo "docker pull ${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-frontend:latest" >> $GITHUB_STEP_SUMMARY
|
||||
echo "docker pull ${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-game-service:latest" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -62,7 +62,6 @@ Thumbs.db
|
||||
!src/**/*.d.ts
|
||||
!frontend/src/**/*.d.ts
|
||||
!backend/src/**/*.d.ts
|
||||
!game-service/src/**/*.d.ts
|
||||
|
||||
# Documentation (preserve original formatting)
|
||||
docs/
|
||||
|
||||
+11
-21
@@ -70,10 +70,8 @@ npm run install:all
|
||||
|
||||
# Copy environment files
|
||||
cp backend/.env.example backend/.env
|
||||
cp frontend/.env.example frontend/.env
|
||||
cp game-service/.env.example game-service/.env
|
||||
|
||||
# Edit .env files with your Flashpoint paths
|
||||
# Edit .env file with your Flashpoint path
|
||||
# See docs/08-development/setup-guide.md for details
|
||||
|
||||
# Start development servers
|
||||
@@ -89,7 +87,6 @@ npm run typecheck
|
||||
# Run tests
|
||||
cd backend && npm test
|
||||
cd ../frontend && npm test
|
||||
cd ../game-service && npm test
|
||||
```
|
||||
|
||||
For detailed setup instructions, see
|
||||
@@ -397,9 +394,8 @@ Update documentation when you:
|
||||
docs/
|
||||
├── 01-overview/ # Project introduction
|
||||
├── 02-architecture/ # System design
|
||||
├── 03-backend/ # Backend service docs
|
||||
├── 03-backend/ # Backend service docs (including game content serving)
|
||||
├── 04-frontend/ # Frontend service docs
|
||||
├── 05-game-service/ # Game service docs
|
||||
├── 06-api-reference/ # API documentation
|
||||
├── 07-design-system/ # UI/UX guidelines
|
||||
├── 08-development/ # Development guides
|
||||
@@ -419,11 +415,12 @@ docs/
|
||||
|
||||
```
|
||||
flashpoint-web/
|
||||
├── backend/ # Express REST API
|
||||
├── backend/ # Express REST API with game content serving
|
||||
│ ├── src/
|
||||
│ │ ├── routes/ # API route handlers
|
||||
│ │ ├── services/ # Business logic
|
||||
│ │ ├── middleware/# Express middleware
|
||||
│ │ ├── game/ # Game content serving (proxy, ZIP mounting)
|
||||
│ │ └── utils/ # Utility functions
|
||||
│ └── package.json
|
||||
│
|
||||
@@ -436,12 +433,6 @@ flashpoint-web/
|
||||
│ │ └── store/ # State management
|
||||
│ └── package.json
|
||||
│
|
||||
├── game-service/ # Game file server
|
||||
│ ├── src/
|
||||
│ │ ├── servers/ # HTTP servers
|
||||
│ │ └── utils/ # Utilities
|
||||
│ └── package.json
|
||||
│
|
||||
├── docs/ # Documentation
|
||||
└── package.json # Root package
|
||||
```
|
||||
@@ -472,15 +463,14 @@ Contributors are recognized in:
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Task | Command |
|
||||
| -------------------- | ------------------------- |
|
||||
| Task | Command |
|
||||
| ------------------- | ------------------------- |
|
||||
| Install dependencies | `npm run install:all` |
|
||||
| Start dev servers | `npm run dev` |
|
||||
| Type check | `npm run typecheck` |
|
||||
| Format code | `npm run format` |
|
||||
| Run backend tests | `cd backend && npm test` |
|
||||
| Run frontend tests | `cd frontend && npm test` |
|
||||
| Build all | `npm run build` |
|
||||
| Start dev servers | `npm run dev` |
|
||||
| Type check | `npm run typecheck` |
|
||||
| Format code | `npm run format` |
|
||||
| Run backend tests | `cd backend && npm test` |
|
||||
| Build all | `npm run build` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# Documentation Updates - Second Pass Code Review
|
||||
|
||||
Date: 2026-02-06
|
||||
|
||||
Updated the following game-service documentation files to reflect medium and low
|
||||
severity fixes from the second pass code review:
|
||||
|
||||
## Files Updated
|
||||
|
||||
### 1. docs/05-game-service/gamezip-server.md
|
||||
|
||||
Added section "Download Reliability Improvements" documenting:
|
||||
|
||||
- `findActiveDownload()` now correlates downloads with hostname from request path
|
||||
instead of returning first arbitrary download (ensures correct download status
|
||||
on concurrent requests)
|
||||
- Stale download cleanup extracted to shared `cleanupStaleDownloads()` method
|
||||
(DRY principle, shared between functions)
|
||||
- `new URL()` calls wrapped in try-catch for proper 400 error handling on
|
||||
malformed URLs (prevents 500 errors)
|
||||
|
||||
### 2. docs/05-game-service/zip-manager.md
|
||||
|
||||
Added section "Performance Improvement - findFile() Refactoring" documenting:
|
||||
|
||||
- `findFile()` method optimized for single-pass lookup
|
||||
- Entries read directly without redundant delegation through `getFile()`
|
||||
- Process: index check → entry existence check → size check → read
|
||||
- Eliminates intermediate function calls and extra lookups
|
||||
- Result: Faster file discovery with fewer function invocations
|
||||
|
||||
### 3. docs/05-game-service/legacy-server.md
|
||||
|
||||
Added section "Dynamic Configuration Loading" documenting:
|
||||
|
||||
- Settings now fetched fresh via `ConfigManager.getSettings()` at the start of
|
||||
each `serveLegacy()` call instead of being captured once at construction
|
||||
- Ensures configuration changes (Brotli, external URLs, override paths) take
|
||||
effect immediately without restart
|
||||
- Benefit: Admin users can update proxy settings via UI without downtime
|
||||
|
||||
### 4. docs/05-game-service/proxy-server.md
|
||||
|
||||
Updated "CORS Handling" section:
|
||||
|
||||
- CORS headers now use shared `setCorsHeaders` utility from `utils/cors.ts`
|
||||
- Utility includes all required methods: GET, POST, DELETE, OPTIONS
|
||||
- Eliminates duplication across both proxy servers
|
||||
- Consistent CORS configuration across the module
|
||||
|
||||
### 5. docs/05-game-service/architecture.md
|
||||
|
||||
Added major section "Code Quality & Security Improvements (Second Pass Review)"
|
||||
documenting all medium and low severity fixes:
|
||||
|
||||
**HTML Injection & Logging:**
|
||||
- `htmlInjector.ts` now uses project logger instead of `console.log`
|
||||
- Dead code branch removed from polyfill injection logic
|
||||
|
||||
**PreferencesService Initialization:**
|
||||
- Logs warning on re-initialization with different path
|
||||
- Prevents silent configuration changes affecting game downloads
|
||||
|
||||
**Schema Validation:**
|
||||
- Hostname schema minimum length corrected to 2 (matches regex requirement)
|
||||
|
||||
**Regex Pattern Fixes:**
|
||||
- `pathSecurity.ts` regex patterns no longer use `/g` flag with `test()`
|
||||
- Prevents stateful matching bugs on consecutive calls
|
||||
|
||||
**Code Cleanup:**
|
||||
- Unused exports removed from:
|
||||
- `mimeTypes.ts`
|
||||
- `validation/schemas.ts`
|
||||
- `utils/cors.ts`
|
||||
- `utils/pathSecurity.ts`
|
||||
- Reduces module surface area and maintenance burden
|
||||
|
||||
**Download Validation:**
|
||||
- `GameDataDownloader.getFilename()` validates timestamps
|
||||
- Rejects NaN timestamps to prevent corrupted filenames
|
||||
|
||||
**Resource Leak Prevention:**
|
||||
- `GameDataDownloader` destroys response stream on size limit
|
||||
- Prevents resource leaks from oversized downloads
|
||||
|
||||
**DRY Principle:**
|
||||
- `GameDataService` uses `GameDataDownloader.getFilename()` instead of
|
||||
duplicating logic
|
||||
- Centralizes filename generation
|
||||
|
||||
### 6. docs/05-game-service/configuration.md
|
||||
|
||||
Updated "CGI Configuration (via proxySettings.json)" section with information
|
||||
about `PreferencesService`:
|
||||
|
||||
- Documents singleton pattern for reading `preferences.json`
|
||||
- Explains re-initialization warning logging
|
||||
- Helps identify configuration issues during development
|
||||
- Examples of warning messages logged
|
||||
|
||||
## Impact
|
||||
|
||||
These documentation updates ensure that:
|
||||
|
||||
1. **Code clarity**: All improvements are documented for future maintainers
|
||||
2. **Knowledge preservation**: Medium and low severity fixes are recorded
|
||||
3. **Developer onboarding**: New team members understand recent improvements
|
||||
4. **Architecture understanding**: Readers see complete picture of design
|
||||
5. **Best practices**: Documents explain why each improvement matters
|
||||
|
||||
## Markdown Formatting
|
||||
|
||||
All updated files follow consistent markdown formatting with:
|
||||
|
||||
- Clear section headers
|
||||
- Code examples where relevant
|
||||
- Bullet points for key improvements
|
||||
- Context about why changes were made
|
||||
- Benefits and implications explained
|
||||
@@ -61,13 +61,15 @@ support, play tracking, and an intuitive interface.
|
||||
|
||||
## Architecture
|
||||
|
||||
Flashpoint Web consists of three independent services:
|
||||
Flashpoint Web consists of two independent services:
|
||||
|
||||
| Service | Port | Description |
|
||||
| ---------------- | ----------- | ----------------------------------------------------- |
|
||||
| **Backend** | 3100 | REST API for game metadata, users, and authentication |
|
||||
| **Frontend** | 5173 | React web application |
|
||||
| **Game Service** | 22500/22501 | File serving proxy and ZIP archive server |
|
||||
| Service | Port | Description |
|
||||
| ------------ | ---- | --------------------------------------------------------------- |
|
||||
| **Backend** | 3100 | REST API for metadata, users, auth, and game content serving |
|
||||
| **Frontend** | 5173 | React web application |
|
||||
|
||||
Game content is served directly by the backend via `/game-proxy/*` and
|
||||
`/game-zip/*` routes, enabling efficient file serving and ZIP archive mounting.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -107,25 +109,9 @@ DOMAIN=http://localhost:5173
|
||||
LOG_LEVEL=info
|
||||
```
|
||||
|
||||
> **Note:** All database and asset paths are automatically derived from
|
||||
> `FLASHPOINT_PATH`.
|
||||
|
||||
**2. Game Service** (`game-service/.env`)
|
||||
|
||||
```bash
|
||||
cp game-service/.env.example game-service/.env
|
||||
```
|
||||
|
||||
```env
|
||||
# Required
|
||||
FLASHPOINT_PATH=D:/Flashpoint
|
||||
|
||||
# Optional
|
||||
LOG_LEVEL=info
|
||||
```
|
||||
|
||||
> **Note:** Frontend requires no `.env` file for local development - API calls
|
||||
> are proxied through Vite.
|
||||
> **Note:** All database, asset, and game content paths are automatically
|
||||
> derived from `FLASHPOINT_PATH`. Frontend requires no `.env` file for local
|
||||
> development - API calls are proxied through Vite.
|
||||
|
||||
### Run Development Servers
|
||||
|
||||
@@ -134,9 +120,8 @@ LOG_LEVEL=info
|
||||
npm run dev
|
||||
|
||||
# Or start individually
|
||||
npm run dev:backend # http://localhost:3100
|
||||
npm run dev:frontend # http://localhost:5173
|
||||
npm run dev:game-service # http://localhost:22500
|
||||
npm run dev:backend # http://localhost:3100
|
||||
npm run dev:frontend # http://localhost:5173
|
||||
```
|
||||
|
||||
Open http://localhost:5173 in your browser.
|
||||
@@ -164,9 +149,13 @@ docker compose -f docker-compose.dev.yml up -d --build
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
**Default ports:** | Service | URL | |---------|-----| | Frontend |
|
||||
http://localhost (port 80) | | Backend API | http://localhost:3100 | | Game
|
||||
Proxy | http://localhost:22500 | | GameZip Server | http://localhost:22501 |
|
||||
**Default ports:**
|
||||
|
||||
| Service | URL |
|
||||
| --- | --- |
|
||||
| Frontend | http://localhost (port 80) |
|
||||
| Backend API | http://localhost:3100 |
|
||||
| Game Content Routes | http://localhost:3100/game-proxy/* and /game-zip/* |
|
||||
|
||||
See [Docker Deployment Guide](docs/09-deployment/docker-deployment.md) for
|
||||
advanced configuration.
|
||||
@@ -266,21 +255,6 @@ Comprehensive documentation is available in the [`docs/`](docs/) directory:
|
||||
- Shadcn UI + Radix
|
||||
- Ruffle Flash Emulator
|
||||
|
||||
</td>
|
||||
<td align="center" width="33%">
|
||||
|
||||
### Game Service
|
||||
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/Node.js-339933?style=flat-square&logo=nodedotjs&logoColor=white" alt="Node.js" />
|
||||
<img src="https://img.shields.io/badge/Express-000000?style=flat-square&logo=express&logoColor=white" alt="Express" />
|
||||
</p>
|
||||
|
||||
- Express 4.18
|
||||
- node-stream-zip 1.15
|
||||
- Winston logging
|
||||
- CDN fallback support
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -314,8 +288,9 @@ npm run copy-ruffle
|
||||
|
||||
**Games not loading:**
|
||||
|
||||
- Ensure game-service is running (ports 22500/22501)
|
||||
- Check game-service logs: `npm run dev:game-service`
|
||||
- Ensure backend is running on port 3100
|
||||
- Check backend logs: `npm run dev:backend`
|
||||
- Verify game content routes are accessible at `/game-proxy/*` and `/game-zip/*`
|
||||
|
||||
See [Common Pitfalls](docs/08-development/common-pitfalls.md) for more
|
||||
solutions.
|
||||
|
||||
@@ -10,9 +10,6 @@ NODE_ENV=development
|
||||
LOG_LEVEL=info
|
||||
DOMAIN=http://localhost:5173
|
||||
|
||||
# Backend -> game-service connection (local development)
|
||||
GAME_SERVICE_HOST=localhost
|
||||
|
||||
# Rate limiting
|
||||
RATE_LIMIT_WINDOW_MS=60000
|
||||
RATE_LIMIT_MAX_REQUESTS=100
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ Node.js/Express API server for the Flashpoint Archive web application.
|
||||
|
||||
- RESTful API for game metadata
|
||||
- Direct SQLite database access (read-only)
|
||||
- Proxy to Flashpoint Game Server
|
||||
- Integrated game content serving (proxy + ZIP)
|
||||
- Image and logo serving
|
||||
- Playlist management
|
||||
- Platform and tag endpoints
|
||||
@@ -45,6 +45,7 @@ See `../../api-specification.md` for complete API documentation.
|
||||
src/
|
||||
├── routes/ # Express route handlers
|
||||
├── services/ # Business logic layer
|
||||
├── game/ # Integrated game service (proxy, ZIP, CGI)
|
||||
├── middleware/ # Express middleware
|
||||
├── utils/ # Helper functions
|
||||
├── config.ts # Configuration management
|
||||
@@ -83,7 +84,6 @@ Optional:
|
||||
|
||||
- `PORT` - Server port (default: 3100)
|
||||
- `NODE_ENV` - Environment (development/production)
|
||||
- `GAME_SERVER_URL` - Game Server URL (default: http://localhost:22500)
|
||||
- `DOMAIN` - Frontend origin (default: http://localhost:5173)
|
||||
|
||||
See `.env.example` for all options.
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"better-sqlite3": "^12.6.0",
|
||||
"cheerio": "^1.1.2",
|
||||
"compression": "^1.7.4",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"cron-parser": "^5.5.0",
|
||||
"dotenv": "^16.4.5",
|
||||
@@ -53,10 +54,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/cheerio": "^0.22.35",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/cheerio": "^0.22.35",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Helper script to kill processes using specific ports
|
||||
* Usage: node scripts/kill-port.js [port]
|
||||
* Example: node scripts/kill-port.js 22500
|
||||
*/
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const port = process.argv[2] || '22500';
|
||||
|
||||
console.log(`🔍 Checking for processes using port ${port}...`);
|
||||
|
||||
try {
|
||||
// Find process using the port (Windows)
|
||||
const result = execSync(`netstat -ano | findstr :${port}`, { encoding: 'utf-8' });
|
||||
|
||||
if (!result.trim()) {
|
||||
console.log(`✓ Port ${port} is free!`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(result);
|
||||
|
||||
// Extract PIDs
|
||||
const lines = result.trim().split('\n');
|
||||
const pids = new Set();
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
const pid = parts[parts.length - 1];
|
||||
if (pid && !isNaN(pid)) {
|
||||
pids.add(pid);
|
||||
}
|
||||
}
|
||||
|
||||
if (pids.size === 0) {
|
||||
console.log(`✓ Port ${port} is free!`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`\n⚠️ Found ${pids.size} process(es) using port ${port}`);
|
||||
console.log(`PIDs: ${Array.from(pids).join(', ')}`);
|
||||
|
||||
// Kill each process
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
console.log(`\n🔨 Killing process ${pid}...`);
|
||||
// Use /F and /PID (single slash) for Windows
|
||||
execSync(`taskkill /F /PID ${pid}`, { encoding: 'utf-8' });
|
||||
console.log(`✓ Process ${pid} terminated`);
|
||||
} catch (error) {
|
||||
console.error(`✗ Failed to kill process ${pid}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n✅ Port ${port} should now be free!`);
|
||||
} catch (error) {
|
||||
if (error.status === 1 && error.stderr === '') {
|
||||
// findstr returns exit code 1 when nothing is found
|
||||
console.log(`✓ Port ${port} is free!`);
|
||||
} else {
|
||||
console.error('❌ Error:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
+7
-21
@@ -27,6 +27,7 @@ function getJwtSecret(): string {
|
||||
|
||||
// In development, generate a random secret and warn
|
||||
const devSecret = `INSECURE-DEV-ONLY-${crypto.randomBytes(32).toString('hex')}`;
|
||||
// NOTE: console.warn used intentionally - logger not yet initialized at config load time
|
||||
console.warn('\n⚠️ WARNING: Using auto-generated JWT secret for development.');
|
||||
console.warn(' In production, set JWT_SECRET environment variable.\n');
|
||||
|
||||
@@ -95,27 +96,12 @@ export const config = {
|
||||
flashpointPlaylistsPath: `${flashpointPath}/Data/Playlists`,
|
||||
flashpointGamesPath: `${flashpointPath}/Data/Games`,
|
||||
|
||||
// Game Service URLs (external service, not built-in)
|
||||
// Internal URLs: Used for backend-to-game-service communication within Docker
|
||||
// Ports hardcoded (22500/22501) - only host is configurable (localhost for dev, game-service for docker)
|
||||
gameServerUrl: `http://${process.env.GAME_SERVICE_HOST || 'localhost'}:22500`,
|
||||
gameServiceGameZipUrl: `http://${process.env.GAME_SERVICE_HOST || 'localhost'}:22501`,
|
||||
gameServerHttpPort: 22501,
|
||||
|
||||
// External Game Service URL: Used for URLs returned to the browser
|
||||
// In Docker: Use relative paths through nginx proxy (/game-proxy)
|
||||
// In local dev: Use direct localhost URLs (http://localhost:22500)
|
||||
// Can be overridden with GAME_SERVICE_EXTERNAL_URL env var
|
||||
gameServiceExternalUrl:
|
||||
process.env.GAME_SERVICE_EXTERNAL_URL ||
|
||||
(process.env.NODE_ENV === 'production' ? '/game-proxy' : `http://localhost:22500`),
|
||||
|
||||
// Frontend domain (used for CORS and share URL generation)
|
||||
domain: process.env.DOMAIN || 'http://localhost:5173',
|
||||
|
||||
// Rate limiting
|
||||
rateLimitWindowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '60000', 10),
|
||||
rateLimitMaxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10),
|
||||
rateLimitWindowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '', 10) || 60000,
|
||||
rateLimitMaxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '', 10) || 100,
|
||||
|
||||
// Logging
|
||||
// In Docker (production): defaults to /app/logs/backend.log
|
||||
@@ -135,10 +121,10 @@ export const config = {
|
||||
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '1h',
|
||||
|
||||
// Security
|
||||
bcryptSaltRounds: parseInt(process.env.BCRYPT_SALT_ROUNDS || '10', 10),
|
||||
bcryptSaltRounds: parseInt(process.env.BCRYPT_SALT_ROUNDS || '', 10) || 10,
|
||||
|
||||
// Home Page Configuration
|
||||
homeRecentHours: parseInt(process.env.HOME_RECENT_HOURS || '24', 10),
|
||||
homeRecentHours: parseInt(process.env.HOME_RECENT_HOURS || '', 10) || 24,
|
||||
|
||||
// Local Database Copy (for network storage optimization)
|
||||
// When enabled, copies flashpoint.sqlite to local storage for faster access
|
||||
@@ -147,8 +133,8 @@ export const config = {
|
||||
localDbPath: '/app/data/flashpoint.sqlite', // Only used when enableLocalDbCopy is true
|
||||
|
||||
// SQLite Performance Tuning
|
||||
sqliteCacheSize: parseInt(process.env.SQLITE_CACHE_SIZE || '-64000', 10), // Negative = KB, -64000 = 64MB
|
||||
sqliteMmapSize: parseInt(process.env.SQLITE_MMAP_SIZE || '268435456', 10), // 256MB default
|
||||
sqliteCacheSize: parseInt(process.env.SQLITE_CACHE_SIZE || '', 10) || -64000, // Negative = KB, -64000 = 64MB
|
||||
sqliteMmapSize: parseInt(process.env.SQLITE_MMAP_SIZE || '', 10) || 268435456, // 256MB default
|
||||
|
||||
// Cache Pre-warming
|
||||
enableCachePrewarm: process.env.ENABLE_CACHE_PREWARM !== 'false', // Enabled by default
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { logger } from '../utils/logger';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { sanitizeAndValidatePath } from '../utils/pathSecurity';
|
||||
|
||||
/**
|
||||
@@ -42,24 +42,39 @@ export interface CgiExecutorConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sensitive environment variables that should NOT be passed to CGI scripts
|
||||
* Allowed environment variables to pass to CGI scripts
|
||||
*/
|
||||
const SENSITIVE_ENV_VARS = [
|
||||
'JWT_SECRET',
|
||||
'DATABASE_URL',
|
||||
'DB_PASSWORD',
|
||||
'API_KEY',
|
||||
'SECRET_KEY',
|
||||
'PRIVATE_KEY',
|
||||
'AWS_SECRET',
|
||||
'AZURE_SECRET',
|
||||
'GCP_SECRET',
|
||||
'ENCRYPTION_KEY',
|
||||
'PASSWORD',
|
||||
'TOKEN',
|
||||
'CREDENTIAL',
|
||||
const ALLOWED_ENV_VARS = [
|
||||
'PATH',
|
||||
'TEMP',
|
||||
'TMP',
|
||||
'SYSTEMROOT',
|
||||
'COMSPEC',
|
||||
'WINDIR',
|
||||
'HOME',
|
||||
'LANG',
|
||||
'LC_ALL',
|
||||
];
|
||||
|
||||
/**
|
||||
* Allowed HTTP headers to pass to CGI scripts
|
||||
*/
|
||||
const ALLOWED_CGI_HEADERS = new Set([
|
||||
'host',
|
||||
'user-agent',
|
||||
'accept',
|
||||
'accept-language',
|
||||
'accept-encoding',
|
||||
'accept-charset',
|
||||
'cookie',
|
||||
'referer',
|
||||
'origin',
|
||||
'if-modified-since',
|
||||
'if-none-match',
|
||||
'cache-control',
|
||||
'pragma',
|
||||
]);
|
||||
|
||||
/**
|
||||
* CGI Executor - Executes PHP scripts via php-cgi
|
||||
*
|
||||
@@ -96,8 +111,8 @@ export class CgiExecutor {
|
||||
* @throws Error if execution fails or times out
|
||||
*/
|
||||
async execute(scriptPath: string, request: CgiRequest): Promise<CgiResponse> {
|
||||
// Validate script path is within allowed directories
|
||||
this.validateScriptPath(scriptPath);
|
||||
// Validate script path is within allowed directories (resolves symlinks)
|
||||
await this.validateScriptPath(scriptPath);
|
||||
|
||||
// Build CGI environment variables
|
||||
const env = this.buildCgiEnvironment(scriptPath, request);
|
||||
@@ -106,8 +121,8 @@ export class CgiExecutor {
|
||||
logger.debug(`[CGI] Method: ${request.method}, Query: ${request.url.search}`);
|
||||
|
||||
return new Promise<CgiResponse>((resolve, reject) => {
|
||||
// Spawn php-cgi process
|
||||
const proc = spawn(this.config.phpCgiPath, [], {
|
||||
// Spawn php-cgi process with script path as explicit argument (CVE-2012-1823 mitigation)
|
||||
const proc = spawn(this.config.phpCgiPath, [scriptPath], {
|
||||
cwd: path.dirname(scriptPath),
|
||||
env: { ...this.sanitizeProcessEnv(), ...env },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
@@ -221,18 +236,26 @@ export class CgiExecutor {
|
||||
|
||||
/**
|
||||
* Validate that the script path is within allowed directories
|
||||
* Resolves symlinks to prevent bypass attacks
|
||||
*/
|
||||
private validateScriptPath(scriptPath: string): void {
|
||||
private async validateScriptPath(scriptPath: string): Promise<void> {
|
||||
let realPath: string;
|
||||
try {
|
||||
realPath = await fs.realpath(scriptPath);
|
||||
} catch {
|
||||
throw new Error('CGI script not found');
|
||||
}
|
||||
|
||||
// Script must be in either htdocs or cgi-bin
|
||||
try {
|
||||
sanitizeAndValidatePath(this.config.documentRoot, scriptPath);
|
||||
sanitizeAndValidatePath(this.config.documentRoot, realPath);
|
||||
return;
|
||||
} catch {
|
||||
// Not in htdocs, try cgi-bin
|
||||
}
|
||||
|
||||
try {
|
||||
sanitizeAndValidatePath(this.config.cgiBinPath, scriptPath);
|
||||
sanitizeAndValidatePath(this.config.cgiBinPath, realPath);
|
||||
return;
|
||||
} catch {
|
||||
// Not in cgi-bin either
|
||||
@@ -242,6 +265,30 @@ export class CgiExecutor {
|
||||
throw new Error('CGI script path is not within allowed directories');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize query string to prevent CVE-2012-1823 argument injection.
|
||||
* Rejects query strings that contain segments starting with '-' without '='
|
||||
* which php-cgi may interpret as command-line arguments.
|
||||
*/
|
||||
private sanitizeQueryString(queryString: string): string {
|
||||
if (!queryString) return '';
|
||||
// Split on & and check each parameter
|
||||
const params = queryString.split('&');
|
||||
const safeParams = params.filter((param) => {
|
||||
// If a parameter contains '=', it's a key=value pair (safe)
|
||||
if (param.includes('=')) return true;
|
||||
// If it starts with '-', it could be interpreted as a CLI flag (reject)
|
||||
if (param.startsWith('-') || param.startsWith('%2d') || param.startsWith('%2D')) {
|
||||
logger.warn(
|
||||
`[CGI] Rejected potential argument injection in query string: ${param.substring(0, 20)}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return safeParams.join('&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build CGI environment variables according to RFC 3875
|
||||
*/
|
||||
@@ -249,6 +296,10 @@ export class CgiExecutor {
|
||||
const url = request.url;
|
||||
const headers = request.headers;
|
||||
|
||||
// Sanitize query string to prevent argument injection (CVE-2012-1823)
|
||||
const rawQueryString = url.search.startsWith('?') ? url.search.substring(1) : url.search;
|
||||
const sanitizedQueryString = this.sanitizeQueryString(rawQueryString);
|
||||
|
||||
// Build base CGI environment
|
||||
const env: Record<string, string> = {
|
||||
// Required by PHP-CGI security (prevents direct execution attacks)
|
||||
@@ -268,7 +319,7 @@ export class CgiExecutor {
|
||||
SCRIPT_FILENAME: scriptPath,
|
||||
PATH_INFO: '',
|
||||
PATH_TRANSLATED: scriptPath,
|
||||
QUERY_STRING: url.search.startsWith('?') ? url.search.substring(1) : url.search,
|
||||
QUERY_STRING: sanitizedQueryString,
|
||||
|
||||
// Document root
|
||||
DOCUMENT_ROOT: this.config.documentRoot,
|
||||
@@ -286,8 +337,15 @@ export class CgiExecutor {
|
||||
|
||||
// Convert HTTP headers to CGI HTTP_* variables
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
const lowerKey = key.toLowerCase();
|
||||
|
||||
// Skip content headers (handled separately)
|
||||
if (key.toLowerCase() === 'content-type' || key.toLowerCase() === 'content-length') {
|
||||
if (lowerKey === 'content-type' || lowerKey === 'content-length') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only pass allowed headers
|
||||
if (!ALLOWED_CGI_HEADERS.has(lowerKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -300,25 +358,18 @@ export class CgiExecutor {
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove sensitive environment variables from process.env
|
||||
* Pass only allowed environment variables to CGI scripts
|
||||
*/
|
||||
private sanitizeProcessEnv(): Record<string, string> {
|
||||
const sanitized: Record<string, string> = {};
|
||||
const env: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value === undefined) continue;
|
||||
|
||||
// Check if key matches any sensitive pattern
|
||||
const isSensitive = SENSITIVE_ENV_VARS.some(
|
||||
(pattern) => key.toUpperCase().includes(pattern) || key.toUpperCase().startsWith(pattern)
|
||||
);
|
||||
|
||||
if (!isSensitive) {
|
||||
sanitized[key] = value;
|
||||
for (const key of ALLOWED_ENV_VARS) {
|
||||
if (process.env[key]) {
|
||||
env[key] = process.env[key]!;
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1,6 +1,13 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { logger } from './utils/logger';
|
||||
import { logger } from '../utils/logger';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Maximum file size for buffering in memory (50MB)
|
||||
* Used consistently across gamezipserver, zip-manager, and legacy-server
|
||||
*/
|
||||
export const MAX_BUFFERED_FILE_SIZE = 50 * 1024 * 1024;
|
||||
|
||||
export interface ServerSettings {
|
||||
// Proxy server port (default: 22500)
|
||||
@@ -24,10 +31,6 @@ export interface ServerSettings {
|
||||
mad4fpEnabled: boolean;
|
||||
mad4fpPaths: string[];
|
||||
|
||||
// Performance settings
|
||||
chunkSize: number;
|
||||
maxConcurrentDownloads: number;
|
||||
|
||||
// Feature flags
|
||||
allowCrossDomain: boolean;
|
||||
enableBrotli: boolean;
|
||||
@@ -40,6 +43,23 @@ export interface ServerSettings {
|
||||
cgiMaxResponseSize: number;
|
||||
}
|
||||
|
||||
// Zod schema for proxySettings.json validation
|
||||
const proxySettingsSchema = z
|
||||
.object({
|
||||
legacyOverridePaths: z.array(z.string()).default([]),
|
||||
externalFilePaths: z.array(z.string()).default([]),
|
||||
legacyHTDOCSPath: z.string().optional(),
|
||||
infinityServerURL: z.string().optional(),
|
||||
mad4fpEnabled: z.boolean().optional(),
|
||||
mad4fpPaths: z.array(z.string()).optional(),
|
||||
enableCGI: z.boolean().default(false),
|
||||
phpCgiPath: z.string().optional(),
|
||||
cgiTimeout: z.number().default(30000),
|
||||
cgiMaxBodySize: z.number().optional(),
|
||||
cgiMaxResponseSize: z.number().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export class ConfigManager {
|
||||
private static settings: ServerSettings | null = null;
|
||||
|
||||
@@ -47,10 +67,27 @@ export class ConfigManager {
|
||||
* Load configuration from proxySettings.json and environment
|
||||
*/
|
||||
static async loadConfig(flashpointPath: string): Promise<ServerSettings> {
|
||||
if (ConfigManager.settings) {
|
||||
logger.warn('[ConfigManager] Re-initializing configuration - this may indicate a bug');
|
||||
}
|
||||
|
||||
try {
|
||||
const proxySettingsPath = path.join(flashpointPath, 'Server', 'proxySettings.json');
|
||||
const data = await fs.readFile(proxySettingsPath, 'utf-8');
|
||||
const proxySettings = JSON.parse(data);
|
||||
const parsed = JSON.parse(data);
|
||||
|
||||
// Validate with Zod schema
|
||||
let proxySettings;
|
||||
try {
|
||||
proxySettings = proxySettingsSchema.parse(parsed);
|
||||
logger.info('[ConfigManager] Configuration validated successfully');
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.error('[ConfigManager] Schema validation failed:', error.errors);
|
||||
throw new Error('Invalid proxySettings.json structure');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Build settings from proxySettings.json and defaults
|
||||
// Ports hardcoded - use docker-compose port mapping to expose on different ports
|
||||
@@ -75,9 +112,6 @@ export class ConfigManager {
|
||||
mad4fpEnabled: proxySettings.mad4fpEnabled || false,
|
||||
mad4fpPaths: proxySettings.mad4fpPaths || [],
|
||||
|
||||
chunkSize: 64 * 1024, // 64KB chunks for streaming
|
||||
maxConcurrentDownloads: 10,
|
||||
|
||||
allowCrossDomain: true,
|
||||
enableBrotli: true,
|
||||
enableCGI: process.env.ENABLE_CGI === 'true',
|
||||
@@ -95,16 +129,22 @@ export class ConfigManager {
|
||||
cgiMaxResponseSize: proxySettings.cgiMaxResponseSize || 50 * 1024 * 1024, // 50MB
|
||||
};
|
||||
|
||||
logger.info('[ProxyConfig] Configuration loaded successfully');
|
||||
logger.info(`[ProxyConfig] Proxy port: ${this.settings.proxyPort}`);
|
||||
logger.info(`[ProxyConfig] GameZip port: ${this.settings.gameZipPort}`);
|
||||
logger.info(`[ProxyConfig] HTDOCS path: ${this.settings.legacyHTDOCSPath}`);
|
||||
logger.info(`[ProxyConfig] Override paths: ${this.settings.legacyOverridePaths.length}`);
|
||||
logger.info(`[ProxyConfig] External sources: ${this.settings.externalFilePaths.length}`);
|
||||
logger.info('[ConfigManager] Configuration loaded successfully');
|
||||
logger.info(`[ConfigManager] Proxy port: ${this.settings.proxyPort}`);
|
||||
logger.info(`[ConfigManager] GameZip port: ${this.settings.gameZipPort}`);
|
||||
logger.info(`[ConfigManager] HTDOCS path: ${this.settings.legacyHTDOCSPath}`);
|
||||
logger.info(`[ConfigManager] Override paths: ${this.settings.legacyOverridePaths.length}`);
|
||||
logger.info(`[ConfigManager] External sources: ${this.settings.externalFilePaths.length}`);
|
||||
|
||||
return this.settings;
|
||||
} catch (error) {
|
||||
logger.error('[ProxyConfig] Failed to load proxySettings.json, using defaults', error);
|
||||
} catch (error: unknown) {
|
||||
// Differentiate between file not found and JSON parse errors
|
||||
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
||||
logger.warn('[ConfigManager] proxySettings.json not found, using defaults');
|
||||
} else {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[ConfigManager] Failed to parse proxySettings.json:', errorMsg);
|
||||
}
|
||||
|
||||
// Fallback to defaults
|
||||
this.settings = {
|
||||
@@ -120,8 +160,6 @@ export class ConfigManager {
|
||||
],
|
||||
mad4fpEnabled: false,
|
||||
mad4fpPaths: [],
|
||||
chunkSize: 64 * 1024,
|
||||
maxConcurrentDownloads: 10,
|
||||
allowCrossDomain: true,
|
||||
enableBrotli: true,
|
||||
enableCGI: false,
|
||||
@@ -0,0 +1,614 @@
|
||||
import http from 'http';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { logger } from '../utils/logger';
|
||||
import { zipManager } from './zip-manager';
|
||||
import { ConfigManager, MAX_BUFFERED_FILE_SIZE } from './config';
|
||||
import { getMimeType } from './mimeTypes';
|
||||
import { injectPolyfills } from './utils/htmlInjector';
|
||||
import { setCorsHeaders } from './utils/cors';
|
||||
import { sanitizeUrlPath, sanitizeErrorMessage } from './utils/pathSecurity';
|
||||
import { validateGameId, validateHostname } from './validation/schemas';
|
||||
import { gameDataDownloader } from './services';
|
||||
|
||||
/** Escape HTML special characters to prevent XSS in loading page */
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/`/g, '`');
|
||||
}
|
||||
|
||||
/**
|
||||
* Download progress tracking for in-progress downloads
|
||||
* Used to show loading page while game is being downloaded
|
||||
*/
|
||||
interface DownloadProgress {
|
||||
startTime: number;
|
||||
progress: number;
|
||||
total: number | null;
|
||||
source: string;
|
||||
zipPath: string;
|
||||
}
|
||||
|
||||
/** Map of gameId -> download progress for in-progress downloads */
|
||||
const downloadsInProgress = new Map<string, DownloadProgress>();
|
||||
|
||||
/** Maximum concurrent downloads */
|
||||
const MAX_CONCURRENT_DOWNLOADS = 3;
|
||||
|
||||
/** Cleanup stale downloads older than 10 minutes */
|
||||
const DOWNLOAD_STALE_MS = 10 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* GameZip Server - Direct-call interface for mounting/serving files from ZIP archives
|
||||
* Refactored from game-service to expose methods instead of running an HTTP server
|
||||
*/
|
||||
export class GameZipServer {
|
||||
private cleanupInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor() {
|
||||
// Start periodic cleanup of stale downloads (every 60 seconds)
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.cleanupStaleDownloads();
|
||||
}, 60000);
|
||||
this.cleanupInterval.unref(); // Don't prevent graceful shutdown
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose of the server and stop cleanup intervals
|
||||
*/
|
||||
dispose(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a ZIP file directly (no HTTP)
|
||||
* Returns { success, downloading?, statusCode }
|
||||
*/
|
||||
async mountZip(params: {
|
||||
id: string;
|
||||
zipPath: string;
|
||||
gameId?: string;
|
||||
dateAdded?: string;
|
||||
sha256?: string;
|
||||
}): Promise<{ success: boolean; downloading?: boolean; statusCode: number }> {
|
||||
const { id, zipPath, gameId, dateAdded, sha256 } = params;
|
||||
|
||||
// Validate mount ID using Zod schema
|
||||
try {
|
||||
validateGameId(id);
|
||||
} catch (error) {
|
||||
logger.warn(`[Security] Invalid mount ID detected: ${id}`);
|
||||
return { success: false, statusCode: 400 };
|
||||
}
|
||||
|
||||
if (!zipPath) {
|
||||
return { success: false, statusCode: 400 };
|
||||
}
|
||||
|
||||
// Validate ZIP path to prevent directory traversal
|
||||
const flashpointPath =
|
||||
process.env.FLASHPOINT_PATH ||
|
||||
(process.env.NODE_ENV === 'production' ? '/data/flashpoint' : 'D:/Flashpoint');
|
||||
const allowedGamesPath =
|
||||
process.env.FLASHPOINT_GAMES_PATH || path.join(flashpointPath, 'Data', 'Games');
|
||||
|
||||
try {
|
||||
const normalizedZipPath = path.normalize(zipPath);
|
||||
const resolvedZipPath = path.resolve(normalizedZipPath);
|
||||
const resolvedGamesPath = path.resolve(allowedGamesPath);
|
||||
|
||||
if (
|
||||
!resolvedZipPath.startsWith(resolvedGamesPath + path.sep) &&
|
||||
resolvedZipPath !== resolvedGamesPath
|
||||
) {
|
||||
logger.warn(`[Security] ZIP path outside allowed directory: ${zipPath}`);
|
||||
return { success: false, statusCode: 403 };
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[Security] ZIP path validation error:', error);
|
||||
return { success: false, statusCode: 400 };
|
||||
}
|
||||
|
||||
// Check if ZIP file exists locally
|
||||
let zipExists = false;
|
||||
try {
|
||||
await fs.access(zipPath);
|
||||
zipExists = true;
|
||||
} catch {
|
||||
zipExists = false;
|
||||
}
|
||||
|
||||
// If ZIP doesn't exist and we have download parameters, start background download
|
||||
if (!zipExists && gameId && dateAdded) {
|
||||
if (downloadsInProgress.has(gameId)) {
|
||||
logger.info(`[GameZipServer] Download already in progress for game ${gameId}`);
|
||||
return { success: true, downloading: true, statusCode: 202 };
|
||||
}
|
||||
|
||||
// Check if we've hit the concurrent download limit
|
||||
if (downloadsInProgress.size >= MAX_CONCURRENT_DOWNLOADS) {
|
||||
logger.warn(
|
||||
`[GameZipServer] Too many concurrent downloads (${downloadsInProgress.size}), rejecting ${gameId}`
|
||||
);
|
||||
return { success: false, statusCode: 503 };
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[GameZipServer] ZIP not found locally, starting background download for game ${gameId}`
|
||||
);
|
||||
|
||||
downloadsInProgress.set(gameId, {
|
||||
startTime: Date.now(),
|
||||
progress: 0,
|
||||
total: null,
|
||||
source: 'Starting...',
|
||||
zipPath,
|
||||
});
|
||||
|
||||
this.downloadAndMountInBackground(
|
||||
id,
|
||||
gameId,
|
||||
dateAdded,
|
||||
sha256,
|
||||
zipPath,
|
||||
allowedGamesPath
|
||||
).catch((err) => logger.error('[GameZipServer] Background download failed:', err));
|
||||
|
||||
return { success: true, downloading: true, statusCode: 202 };
|
||||
} else if (!zipExists) {
|
||||
return { success: false, statusCode: 404 };
|
||||
}
|
||||
|
||||
// Mount the ZIP (file exists locally)
|
||||
try {
|
||||
await zipManager.mount(id, zipPath);
|
||||
|
||||
// After the mount succeeds, verify the real path hasn't changed (TOCTOU protection)
|
||||
try {
|
||||
const realPath = await fs.realpath(zipPath);
|
||||
const resolvedReal = path.resolve(realPath);
|
||||
const resolvedGamesPath = path.resolve(allowedGamesPath);
|
||||
if (
|
||||
!resolvedReal.startsWith(resolvedGamesPath + path.sep) &&
|
||||
resolvedReal !== resolvedGamesPath
|
||||
) {
|
||||
// Symlink pointed outside allowed directory - unmount immediately
|
||||
logger.warn(`[GameZipServer] Symlink detected: ${zipPath} -> ${realPath}. Unmounting.`);
|
||||
await zipManager.unmount(id);
|
||||
return { success: false, statusCode: 403 };
|
||||
}
|
||||
} catch (realPathError) {
|
||||
logger.warn(`[GameZipServer] Failed to resolve real path for ${zipPath}:`, realPathError);
|
||||
// Continue - the file was already successfully opened by zipManager
|
||||
}
|
||||
|
||||
return { success: true, statusCode: 200 };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Mount failed';
|
||||
const safeMessage = sanitizeErrorMessage(errorMessage);
|
||||
logger.error(`[GameZipServer] Mount failed: ${safeMessage}`);
|
||||
return { success: false, statusCode: 500 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmount a ZIP file directly (no HTTP)
|
||||
*/
|
||||
async unmountZip(id: string): Promise<boolean> {
|
||||
try {
|
||||
validateGameId(id);
|
||||
} catch (error) {
|
||||
logger.warn(`[Security] Invalid mount ID detected: ${id}`);
|
||||
return false;
|
||||
}
|
||||
return await zipManager.unmount(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all mounted ZIPs
|
||||
*/
|
||||
listMounts(): Array<{ id: string; zipPath: string; mountTime: Date; fileCount: number }> {
|
||||
return zipManager.getMountedZips();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle file request from mounted ZIPs (compatible with both raw HTTP and Express)
|
||||
* Supports proxy-style requests: GET http://domain.com/path HTTP/1.1
|
||||
*/
|
||||
async handleFileRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
if (!req.url) {
|
||||
this.sendError(res, 400, 'Bad Request: No URL');
|
||||
return;
|
||||
}
|
||||
|
||||
let hostname: string;
|
||||
let urlPath: string;
|
||||
|
||||
// Normalize malformed URLs: /http:/domain → /http://domain
|
||||
// This is a common pattern in legacy Flash games
|
||||
let normalizedUrl = req.url;
|
||||
if (normalizedUrl.match(/^\/https?:\/[^\/]/)) {
|
||||
// Single slash after protocol - insert the missing slash
|
||||
normalizedUrl = normalizedUrl.replace(/^(\/https?:)\//, '$1//');
|
||||
logger.debug(`[GameZipServer] Normalized malformed URL: ${req.url} → ${normalizedUrl}`);
|
||||
}
|
||||
|
||||
// Parse URL (same logic as main proxy server)
|
||||
try {
|
||||
if (normalizedUrl.startsWith('http://') || normalizedUrl.startsWith('https://')) {
|
||||
// Proxy-style: GET http://domain.com/path HTTP/1.1
|
||||
const targetUrl = new URL(normalizedUrl);
|
||||
hostname = targetUrl.hostname;
|
||||
urlPath = targetUrl.pathname + targetUrl.search;
|
||||
} else if (normalizedUrl.startsWith('/http://') || normalizedUrl.startsWith('/https://')) {
|
||||
// Path-based: GET /http://domain.com/path HTTP/1.1
|
||||
const urlWithoutSlash = normalizedUrl.substring(1);
|
||||
const targetUrl = new URL(urlWithoutSlash);
|
||||
hostname = targetUrl.hostname;
|
||||
urlPath = targetUrl.pathname + targetUrl.search;
|
||||
} else {
|
||||
// Regular: GET /path HTTP/1.1
|
||||
// Strip port from hostname (e.g., "localhost:3100" -> "localhost")
|
||||
hostname = (req.headers.host || 'localhost').split(':')[0];
|
||||
urlPath = normalizedUrl;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[GameZipServer] Invalid URL: ${normalizedUrl}`);
|
||||
this.sendError(res, 400, 'Invalid URL format');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate hostname using Zod schema
|
||||
try {
|
||||
hostname = validateHostname(hostname);
|
||||
} catch (error) {
|
||||
logger.error(`[GameZipServer] Invalid hostname: ${hostname}`, error);
|
||||
this.sendError(res, 400, 'Invalid hostname');
|
||||
return;
|
||||
}
|
||||
|
||||
// Sanitize URL path to prevent null bytes and dangerous patterns
|
||||
try {
|
||||
urlPath = sanitizeUrlPath(urlPath);
|
||||
} catch (error) {
|
||||
logger.error(`[GameZipServer] Invalid URL path: ${urlPath}`);
|
||||
this.sendError(res, 400, 'Invalid URL path');
|
||||
return;
|
||||
}
|
||||
|
||||
// Strip query string from path for ZIP file lookup
|
||||
// Files in ZIPs don't have query strings (e.g., file.swf?token=xyz → file.swf)
|
||||
const pathWithoutQuery = urlPath.split('?')[0];
|
||||
|
||||
// Build relative path
|
||||
const relPath = path.posix.join(hostname, pathWithoutQuery);
|
||||
|
||||
logger.debug(`[GameZipServer] Looking for: ${relPath}`);
|
||||
|
||||
// Search mounted ZIPs
|
||||
const result = await zipManager.findFile(relPath);
|
||||
|
||||
if (!result) {
|
||||
// Check if a download is in progress for any game
|
||||
// The mount ID is typically the gameId
|
||||
const downloadStatus = this.findActiveDownload(relPath);
|
||||
|
||||
if (downloadStatus) {
|
||||
logger.info(
|
||||
`[GameZipServer] File not ready, download in progress for ${downloadStatus.gameId}`
|
||||
);
|
||||
this.sendLoadingPage(res, downloadStatus.gameId, downloadStatus.progress);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(`[GameZipServer] File not found in any mounted ZIP: ${relPath}`);
|
||||
this.sendError(res, 404, 'File not found in mounted ZIPs');
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine content type (use path without query string)
|
||||
const ext = path.extname(pathWithoutQuery).substring(1).toLowerCase();
|
||||
const contentType = getMimeType(ext);
|
||||
|
||||
// Process HTML files to inject polyfills for better game compatibility
|
||||
let fileData = result.data;
|
||||
if (ext === 'html' || ext === 'htm') {
|
||||
fileData = injectPolyfills(result.data);
|
||||
logger.info(`[GameZipServer] Injected polyfills into HTML file: ${relPath}`);
|
||||
}
|
||||
|
||||
// Send file
|
||||
logger.info(
|
||||
`[GameZipServer] ✓ Serving from ZIP ${result.mountId}: ${relPath} (${fileData.length} bytes)`
|
||||
);
|
||||
|
||||
// Get settings for CORS configuration
|
||||
const settings = ConfigManager.getSettings();
|
||||
|
||||
// CORS headers
|
||||
setCorsHeaders(res, settings);
|
||||
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Content-Length', fileData.length);
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400'); // Cache for 24 hours
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
res.setHeader('X-Source', `gamezipserver:${result.mountId}`);
|
||||
}
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
|
||||
res.writeHead(200);
|
||||
res.end(fileData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download ZIP and mount it in background
|
||||
* This runs asynchronously - errors are logged but don't affect the HTTP response
|
||||
*/
|
||||
private async downloadAndMountInBackground(
|
||||
mountId: string,
|
||||
gameId: string,
|
||||
dateAdded: string,
|
||||
sha256: string | undefined,
|
||||
zipPath: string,
|
||||
targetPath: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await gameDataDownloader.download(
|
||||
{
|
||||
gameId,
|
||||
dateAdded,
|
||||
sha256,
|
||||
targetPath,
|
||||
},
|
||||
(downloaded, total, source) => {
|
||||
try {
|
||||
// Update progress tracking
|
||||
const existing = downloadsInProgress.get(gameId);
|
||||
if (existing) {
|
||||
downloadsInProgress.set(gameId, {
|
||||
...existing,
|
||||
progress: downloaded,
|
||||
total,
|
||||
source,
|
||||
});
|
||||
}
|
||||
const progress = total ? Math.round((downloaded / total) * 100) : 0;
|
||||
logger.debug(
|
||||
`[GameZipServer] Download progress for ${gameId}: ${progress}% from ${source}`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`[GameZipServer] Progress callback error for ${gameId}:`, error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (result.success && result.filePath) {
|
||||
logger.info(`[GameZipServer] Successfully downloaded ZIP from "${result.sourceUsed}"`);
|
||||
|
||||
// Validate downloaded file path before mounting
|
||||
const resolvedFilePath = path.resolve(result.filePath);
|
||||
const resolvedAllowedPath = path.resolve(targetPath);
|
||||
if (
|
||||
!resolvedFilePath.startsWith(resolvedAllowedPath + path.sep) &&
|
||||
resolvedFilePath !== resolvedAllowedPath
|
||||
) {
|
||||
logger.error(
|
||||
`[GameZipServer] Downloaded file path outside allowed directory: ${result.filePath}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mount the downloaded ZIP
|
||||
try {
|
||||
await zipManager.mount(mountId, result.filePath);
|
||||
logger.info(`[GameZipServer] ✓ ZIP downloaded and mounted for game ${gameId}`);
|
||||
} catch (mountError) {
|
||||
const errorMessage = mountError instanceof Error ? mountError.message : 'Mount failed';
|
||||
logger.error(`[GameZipServer] Failed to mount downloaded ZIP: ${errorMessage}`);
|
||||
}
|
||||
} else {
|
||||
logger.error(`[GameZipServer] Download failed for ${gameId}: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error(`[GameZipServer] Background download error for ${gameId}: ${errorMsg}`);
|
||||
} finally {
|
||||
// Remove from in-progress tracking
|
||||
downloadsInProgress.delete(gameId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up stale downloads older than DOWNLOAD_STALE_MS
|
||||
*/
|
||||
private cleanupStaleDownloads(): void {
|
||||
const now = Date.now();
|
||||
for (const [gameId, progress] of downloadsInProgress.entries()) {
|
||||
if (now - progress.startTime > DOWNLOAD_STALE_MS) {
|
||||
logger.warn(`[GameZipServer] Removing stale download for ${gameId}`);
|
||||
downloadsInProgress.delete(gameId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an active download that might be related to the requested file path
|
||||
* Returns the download status if found, null otherwise
|
||||
*/
|
||||
private findActiveDownload(
|
||||
relPath: string
|
||||
): { gameId: string; progress: DownloadProgress } | null {
|
||||
// Clean up stale downloads first
|
||||
this.cleanupStaleDownloads();
|
||||
|
||||
// Extract hostname from relPath (format: hostname/path/to/file)
|
||||
const hostname = relPath.split('/')[0]?.toLowerCase();
|
||||
|
||||
// Try to find a download matching this hostname
|
||||
if (hostname) {
|
||||
for (const [gameId, download] of downloadsInProgress.entries()) {
|
||||
// Check if mount ID or ZIP path contains the hostname
|
||||
if (gameId.toLowerCase().includes(hostname)) {
|
||||
return { gameId, progress: download };
|
||||
}
|
||||
// Also check the ZIP path for hostname match
|
||||
const zipFilename = download.zipPath.toLowerCase();
|
||||
if (zipFilename.includes(hostname)) {
|
||||
return { gameId, progress: download };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No matching download found - return null for a proper 404
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a loading page while game is being downloaded
|
||||
* Uses auto-refresh to poll until game is ready
|
||||
*/
|
||||
private sendLoadingPage(
|
||||
res: http.ServerResponse,
|
||||
gameId: string,
|
||||
progress: DownloadProgress
|
||||
): void {
|
||||
if (res.headersSent) {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const progressPercent = progress.total
|
||||
? Math.round((progress.progress / progress.total) * 100)
|
||||
: 0;
|
||||
|
||||
const progressMB = (progress.progress / (1024 * 1024)).toFixed(1);
|
||||
const totalMB = progress.total ? (progress.total / (1024 * 1024)).toFixed(1) : '?';
|
||||
|
||||
const elapsedSec = Math.round((Date.now() - progress.startTime) / 1000);
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="refresh" content="2">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Downloading Game...</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
background: #0f0f0f;
|
||||
color: #e5e5e5;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
}
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid #333;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 24px;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 16px;
|
||||
color: #f5f5f5;
|
||||
}
|
||||
.progress-bar {
|
||||
background: #262626;
|
||||
border-radius: 8px;
|
||||
height: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.progress-fill {
|
||||
background: linear-gradient(90deg, #3b82f6, #60a5fa);
|
||||
height: 100%;
|
||||
width: ${progressPercent}%;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.progress-text {
|
||||
color: #a3a3a3;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.source {
|
||||
color: #737373;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.refresh-note {
|
||||
color: #525252;
|
||||
font-size: 0.75rem;
|
||||
margin-top: 24px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="spinner"></div>
|
||||
<h2>Downloading Game Data...</h2>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill"></div>
|
||||
</div>
|
||||
<p class="progress-text">${escapeHtml(progressMB)} MB / ${escapeHtml(totalMB)} MB (${escapeHtml(String(progressPercent))}%)</p>
|
||||
<p class="source">Downloading from configured source...</p>
|
||||
<p class="refresh-note">Elapsed: ${escapeHtml(String(elapsedSec))}s · Page refreshes automatically</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const settings = ConfigManager.getSettings();
|
||||
setCorsHeaders(res, settings);
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.setHeader(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'none'; style-src 'unsafe-inline'; img-src 'none'; script-src 'none'"
|
||||
);
|
||||
res.writeHead(202); // 202 Accepted - still processing
|
||||
res.end(html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send error response
|
||||
*/
|
||||
private sendError(res: http.ServerResponse, statusCode: number, message: string): void {
|
||||
if (res.headersSent) {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(`[GameZipServer] Sending error ${statusCode}: ${message}`);
|
||||
|
||||
// Set CORS headers even for error responses
|
||||
const settings = ConfigManager.getSettings();
|
||||
setCorsHeaders(res, settings);
|
||||
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.writeHead(statusCode);
|
||||
res.end(message);
|
||||
}
|
||||
}
|
||||
|
||||
export const gameZipServer = new GameZipServer();
|
||||
@@ -2,8 +2,8 @@ import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { createReadStream } from 'fs';
|
||||
import { Readable } from 'stream';
|
||||
import { logger } from './utils/logger';
|
||||
import { ConfigManager } from './config';
|
||||
import { logger } from '../utils/logger';
|
||||
import { ConfigManager, MAX_BUFFERED_FILE_SIZE } from './config';
|
||||
import { getMimeType } from './mimeTypes';
|
||||
import {
|
||||
sanitizeAndValidatePath,
|
||||
@@ -11,16 +11,38 @@ import {
|
||||
sanitizeErrorMessage,
|
||||
} from './utils/pathSecurity';
|
||||
import { CgiExecutor, CgiRequest } from './cgi';
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import axios from 'axios';
|
||||
import zlib from 'zlib';
|
||||
import { promisify } from 'util';
|
||||
import http from 'http';
|
||||
import https from 'https';
|
||||
|
||||
// Shared HTTP agents with connection pool limits to prevent exhaustion
|
||||
const httpAgent = new http.Agent({
|
||||
keepAlive: true,
|
||||
maxSockets: 10, // Max 10 concurrent connections per host
|
||||
maxFreeSockets: 2,
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
const httpsAgent = new https.Agent({
|
||||
keepAlive: true,
|
||||
maxSockets: 10,
|
||||
maxFreeSockets: 2,
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
httpAgent,
|
||||
httpsAgent,
|
||||
});
|
||||
|
||||
// HTTP request timeout constants (G-H2)
|
||||
const EXTERNAL_REQUEST_TIMEOUT_MS = 45000; // 45 seconds for external sources
|
||||
const EXTERNAL_REQUEST_TIMEOUT_MS = 15000; // 15 seconds for external sources
|
||||
const EXTERNAL_REQUEST_MAX_REDIRECTS = 5;
|
||||
|
||||
// Retry configuration for external requests
|
||||
const MAX_RETRIES = 3;
|
||||
const MAX_RETRIES = 2;
|
||||
const RETRY_BASE_DELAY_MS = 1000; // 1 second base delay
|
||||
|
||||
const brotliDecompress = promisify(zlib.brotliDecompress);
|
||||
@@ -28,7 +50,9 @@ const brotliDecompress = promisify(zlib.brotliDecompress);
|
||||
// Script file extensions
|
||||
const SCRIPT_EXTENSIONS = ['php', 'php5', 'phtml', 'pl'];
|
||||
|
||||
// Index file extensions (in priority order)
|
||||
// Index files that legacy Flashpoint games commonly use as entry points.
|
||||
// These are checked when a directory is requested without a specific file.
|
||||
// Note: 'swf' is included because many Flash game directories have index.swf as the entry point.
|
||||
const INDEX_EXTENSIONS = ['html', 'htm', 'php', 'swf'];
|
||||
|
||||
// Common subdomain prefixes used in Flashpoint content
|
||||
@@ -69,15 +93,54 @@ export interface HttpRequestContext {
|
||||
}
|
||||
|
||||
export class LegacyServer {
|
||||
private settings = ConfigManager.getSettings();
|
||||
private cgiExecutor: CgiExecutor | null = null;
|
||||
private cgiValidated = false;
|
||||
|
||||
// Negative lookup cache to avoid repeated fs.stat() calls for missing files
|
||||
private static readonly negativeLookupCache = new Map<string, number>();
|
||||
private static readonly NEGATIVE_CACHE_TTL_MS = 30000; // 30 seconds
|
||||
private static readonly MAX_NEGATIVE_CACHE_SIZE = 5000;
|
||||
|
||||
/**
|
||||
* Clear the negative lookup cache (called during shutdown)
|
||||
*/
|
||||
static clearCache(): void {
|
||||
LegacyServer.negativeLookupCache.clear();
|
||||
logger.info('[LegacyServer] Negative lookup cache cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path is in the negative lookup cache
|
||||
*/
|
||||
private isNegativelyCached(fullPath: string): boolean {
|
||||
const cachedAt = LegacyServer.negativeLookupCache.get(fullPath);
|
||||
if (cachedAt === undefined) return false;
|
||||
if (Date.now() - cachedAt > LegacyServer.NEGATIVE_CACHE_TTL_MS) {
|
||||
LegacyServer.negativeLookupCache.delete(fullPath);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache a negative lookup (file not found) with TTL and size limit
|
||||
*/
|
||||
private cacheNegativeLookup(fullPath: string): void {
|
||||
// Evict oldest entries if cache is full
|
||||
if (LegacyServer.negativeLookupCache.size >= LegacyServer.MAX_NEGATIVE_CACHE_SIZE) {
|
||||
const firstKey = LegacyServer.negativeLookupCache.keys().next().value;
|
||||
if (firstKey) LegacyServer.negativeLookupCache.delete(firstKey);
|
||||
}
|
||||
LegacyServer.negativeLookupCache.set(fullPath, Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize CGI executor if enabled
|
||||
*/
|
||||
private async initCgiExecutor(): Promise<CgiExecutor | null> {
|
||||
if (!this.settings.enableCGI) {
|
||||
const settings = ConfigManager.getSettings();
|
||||
|
||||
if (!settings.enableCGI) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -86,12 +149,12 @@ export class LegacyServer {
|
||||
}
|
||||
|
||||
this.cgiExecutor = new CgiExecutor({
|
||||
phpCgiPath: this.settings.phpCgiPath,
|
||||
documentRoot: this.settings.legacyHTDOCSPath,
|
||||
cgiBinPath: this.settings.legacyCGIBINPath,
|
||||
timeout: this.settings.cgiTimeout,
|
||||
maxBodySize: this.settings.cgiMaxBodySize,
|
||||
maxResponseSize: this.settings.cgiMaxResponseSize,
|
||||
phpCgiPath: settings.phpCgiPath,
|
||||
documentRoot: settings.legacyHTDOCSPath,
|
||||
cgiBinPath: settings.legacyCGIBINPath,
|
||||
timeout: settings.cgiTimeout,
|
||||
maxBodySize: settings.cgiMaxBodySize,
|
||||
maxResponseSize: settings.cgiMaxResponseSize,
|
||||
});
|
||||
|
||||
// Validate binary exists (only once)
|
||||
@@ -118,6 +181,9 @@ export class LegacyServer {
|
||||
urlPath: string,
|
||||
requestContext?: HttpRequestContext
|
||||
): Promise<LegacyFileResponse> {
|
||||
// Get fresh settings at the start of the request
|
||||
const settings = ConfigManager.getSettings();
|
||||
|
||||
// Sanitize URL path to prevent null bytes and dangerous patterns
|
||||
try {
|
||||
urlPath = sanitizeUrlPath(urlPath);
|
||||
@@ -132,36 +198,49 @@ export class LegacyServer {
|
||||
// Build relative path: {host}/{path}
|
||||
const relPath = path.posix.join(host, urlPath);
|
||||
|
||||
logger.info(`[LegacyServer] Serving: ${host}${urlPath}`);
|
||||
logger.info(`[LegacyServer] Relative path: ${relPath}`);
|
||||
logger.debug(`[LegacyServer] Serving: ${host}${urlPath}`);
|
||||
logger.debug(`[LegacyServer] Relative path: ${relPath}`);
|
||||
|
||||
// Step 1: Build all path candidates
|
||||
const pathCandidates = this.buildPathCandidates(relPath, urlPath);
|
||||
const pathCandidates = this.buildPathCandidates(relPath, urlPath, settings);
|
||||
|
||||
// Step 2: Try each candidate in order
|
||||
for (const candidate of pathCandidates) {
|
||||
logger.debug(`[LegacyServer] Trying: ${candidate.path} (${candidate.type})`);
|
||||
|
||||
// Check negative cache to skip known-missing files
|
||||
if (this.isNegativelyCached(candidate.path)) {
|
||||
logger.debug(`[LegacyServer] Skipping cached negative lookup: ${candidate.path}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate path is within allowed base directory before accessing
|
||||
this.validateCandidatePath(candidate.path, candidate.type);
|
||||
this.validateCandidatePath(candidate.path, candidate.type, settings);
|
||||
|
||||
const stats = await fs.stat(candidate.path);
|
||||
|
||||
if (stats.isFile()) {
|
||||
logger.info(`[LegacyServer] ✓ Found file: ${candidate.path}`);
|
||||
const isCgiBin = candidate.type === 'cgi-bin' || candidate.type === 'cgi-bin-no-query';
|
||||
return await this.serveFile(candidate.path, isCgiBin, requestContext);
|
||||
return await this.serveFile(candidate.path, isCgiBin, requestContext, settings);
|
||||
}
|
||||
} catch (error) {
|
||||
// File doesn't exist or path validation failed, continue to next candidate
|
||||
// Only cache file-not-found errors, not security validation failures
|
||||
if (
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
(error as NodeJS.ErrnoException).code === 'ENOENT'
|
||||
) {
|
||||
this.cacheNegativeLookup(candidate.path);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: No local file found, try external sources
|
||||
logger.info(`[LegacyServer] File not found locally, trying external sources...`);
|
||||
return await this.tryExternalSources(relPath);
|
||||
return await this.tryExternalSources(relPath, settings);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,7 +249,8 @@ export class LegacyServer {
|
||||
*/
|
||||
private buildPathCandidates(
|
||||
relPath: string,
|
||||
urlPath: string
|
||||
urlPath: string,
|
||||
settings: ReturnType<typeof ConfigManager.getSettings>
|
||||
): Array<{ path: string; type: string }> {
|
||||
const candidates: Array<{ path: string; type: string }> = [];
|
||||
const seenPaths = new Set<string>();
|
||||
@@ -199,7 +279,7 @@ export class LegacyServer {
|
||||
|
||||
// 1. Exact file paths (with and without query string)
|
||||
addCandidate(
|
||||
path.join(this.settings.legacyHTDOCSPath, variantRelPath),
|
||||
path.join(settings.legacyHTDOCSPath, variantRelPath),
|
||||
hostVariation === hostname ? 'exact' : `subdomain:${hostVariation}`
|
||||
);
|
||||
|
||||
@@ -207,7 +287,7 @@ export class LegacyServer {
|
||||
const pathWithoutQuery = variantRelPath.split('?')[0];
|
||||
if (pathWithoutQuery !== variantRelPath) {
|
||||
addCandidate(
|
||||
path.join(this.settings.legacyHTDOCSPath, pathWithoutQuery),
|
||||
path.join(settings.legacyHTDOCSPath, pathWithoutQuery),
|
||||
hostVariation === hostname ? 'exact-no-query' : `subdomain:${hostVariation}-no-query`
|
||||
);
|
||||
}
|
||||
@@ -217,15 +297,12 @@ export class LegacyServer {
|
||||
const pathWithoutQuery = relPath.split('?')[0];
|
||||
|
||||
// 2. Override paths (exact files in override directories)
|
||||
for (const override of this.settings.legacyOverridePaths) {
|
||||
addCandidate(
|
||||
path.join(this.settings.legacyHTDOCSPath, override, relPath),
|
||||
`override:${override}`
|
||||
);
|
||||
for (const override of settings.legacyOverridePaths) {
|
||||
addCandidate(path.join(settings.legacyHTDOCSPath, override, relPath), `override:${override}`);
|
||||
|
||||
if (pathWithoutQuery !== relPath) {
|
||||
addCandidate(
|
||||
path.join(this.settings.legacyHTDOCSPath, override, pathWithoutQuery),
|
||||
path.join(settings.legacyHTDOCSPath, override, pathWithoutQuery),
|
||||
`override:${override}-no-query`
|
||||
);
|
||||
}
|
||||
@@ -233,27 +310,21 @@ export class LegacyServer {
|
||||
|
||||
// 3. CGI-BIN paths (for script files)
|
||||
if (this.isScriptUrl(urlPath)) {
|
||||
addCandidate(path.join(this.settings.legacyCGIBINPath, relPath), 'cgi-bin');
|
||||
addCandidate(path.join(settings.legacyCGIBINPath, relPath), 'cgi-bin');
|
||||
|
||||
if (pathWithoutQuery !== relPath) {
|
||||
addCandidate(
|
||||
path.join(this.settings.legacyCGIBINPath, pathWithoutQuery),
|
||||
'cgi-bin-no-query'
|
||||
);
|
||||
addCandidate(path.join(settings.legacyCGIBINPath, pathWithoutQuery), 'cgi-bin-no-query');
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Index files (for directory requests)
|
||||
for (const ext of INDEX_EXTENSIONS) {
|
||||
addCandidate(
|
||||
path.join(this.settings.legacyHTDOCSPath, relPath, `index.${ext}`),
|
||||
`index:${ext}`
|
||||
);
|
||||
addCandidate(path.join(settings.legacyHTDOCSPath, relPath, `index.${ext}`), `index:${ext}`);
|
||||
|
||||
// Index files in override paths
|
||||
for (const override of this.settings.legacyOverridePaths) {
|
||||
for (const override of settings.legacyOverridePaths) {
|
||||
addCandidate(
|
||||
path.join(this.settings.legacyHTDOCSPath, override, relPath, `index.${ext}`),
|
||||
path.join(settings.legacyHTDOCSPath, override, relPath, `index.${ext}`),
|
||||
`index-override:${override}:${ext}`
|
||||
);
|
||||
}
|
||||
@@ -266,17 +337,22 @@ export class LegacyServer {
|
||||
* Validate that a candidate path is within its allowed base directory
|
||||
* @param candidatePath The full path to validate
|
||||
* @param candidateType The type of candidate (e.g., 'exact', 'cgi-bin', 'override:*')
|
||||
* @param settings Current configuration settings
|
||||
* @throws Error if the path escapes its allowed base directory
|
||||
*/
|
||||
private validateCandidatePath(candidatePath: string, candidateType: string): void {
|
||||
private validateCandidatePath(
|
||||
candidatePath: string,
|
||||
candidateType: string,
|
||||
settings: ReturnType<typeof ConfigManager.getSettings>
|
||||
): void {
|
||||
// Determine the allowed base directory based on candidate type
|
||||
let allowedBase: string;
|
||||
|
||||
if (candidateType === 'cgi-bin' || candidateType === 'cgi-bin-no-query') {
|
||||
allowedBase = this.settings.legacyCGIBINPath;
|
||||
allowedBase = settings.legacyCGIBINPath;
|
||||
} else {
|
||||
// All other types use htdocs path
|
||||
allowedBase = this.settings.legacyHTDOCSPath;
|
||||
allowedBase = settings.legacyHTDOCSPath;
|
||||
}
|
||||
|
||||
// Validate that the candidate path is within the allowed base
|
||||
@@ -302,37 +378,54 @@ export class LegacyServer {
|
||||
* @param filePath Absolute path to file
|
||||
* @param isCGI Whether to execute as CGI script
|
||||
* @param requestContext HTTP request context for CGI execution
|
||||
* @param settings Current configuration settings
|
||||
*/
|
||||
private async serveFile(
|
||||
filePath: string,
|
||||
isCGI: boolean = false,
|
||||
requestContext?: HttpRequestContext
|
||||
requestContext?: HttpRequestContext,
|
||||
settings?: ReturnType<typeof ConfigManager.getSettings>
|
||||
): Promise<LegacyFileResponse> {
|
||||
// Get settings if not provided
|
||||
if (!settings) {
|
||||
settings = ConfigManager.getSettings();
|
||||
}
|
||||
// Determine content type first
|
||||
const ext = path.extname(filePath).substring(1).toLowerCase();
|
||||
const contentType = getMimeType(ext);
|
||||
|
||||
// HTML files need polyfill injection, so buffer them
|
||||
// Brotli-compressed files also need buffering for decompression
|
||||
const needsBuffering =
|
||||
let needsBuffering =
|
||||
contentType.includes('text/html') ||
|
||||
(this.settings.enableBrotli && filePath.endsWith('.br')) ||
|
||||
(settings.enableBrotli && filePath.endsWith('.br')) ||
|
||||
isCGI;
|
||||
|
||||
// Get file stats for size check
|
||||
const stat = await fs.stat(filePath);
|
||||
|
||||
// If file is too large for buffering, skip polyfill injection and stream instead
|
||||
if (needsBuffering && stat.size > MAX_BUFFERED_FILE_SIZE) {
|
||||
logger.warn(
|
||||
`[LegacyServer] File too large for buffering (${stat.size} bytes), streaming without polyfill injection: ${filePath}`
|
||||
);
|
||||
needsBuffering = false;
|
||||
}
|
||||
|
||||
if (needsBuffering) {
|
||||
// Read entire file into memory
|
||||
let data = await fs.readFile(filePath);
|
||||
|
||||
// Handle Brotli compression
|
||||
if (this.settings.enableBrotli && filePath.endsWith('.br')) {
|
||||
if (settings.enableBrotli && filePath.endsWith('.br')) {
|
||||
logger.debug(`[LegacyServer] Decompressing Brotli file: ${filePath}`);
|
||||
data = await brotliDecompress(data);
|
||||
filePath = filePath.substring(0, filePath.length - 3); // Remove .br extension
|
||||
}
|
||||
|
||||
// Execute CGI scripts if enabled
|
||||
if (isCGI && this.settings.enableCGI) {
|
||||
return await this.executeCgiScript(filePath, requestContext);
|
||||
if (isCGI && settings.enableCGI) {
|
||||
return await this.executeCgiScript(filePath, requestContext, settings);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -343,7 +436,6 @@ export class LegacyServer {
|
||||
}
|
||||
|
||||
// Non-HTML, non-compressed files: stream for memory efficiency
|
||||
const stat = await fs.stat(filePath);
|
||||
const stream = createReadStream(filePath);
|
||||
|
||||
logger.debug(
|
||||
@@ -362,30 +454,36 @@ export class LegacyServer {
|
||||
* Execute a CGI script and return the response
|
||||
* @param scriptPath Absolute path to the script file
|
||||
* @param requestContext HTTP request context (method, headers, body)
|
||||
* @param settings Current configuration settings
|
||||
*/
|
||||
private async executeCgiScript(
|
||||
scriptPath: string,
|
||||
requestContext?: HttpRequestContext
|
||||
requestContext?: HttpRequestContext,
|
||||
settings?: ReturnType<typeof ConfigManager.getSettings>
|
||||
): Promise<LegacyFileResponse> {
|
||||
// Get settings if not provided
|
||||
if (!settings) {
|
||||
settings = ConfigManager.getSettings();
|
||||
}
|
||||
const executor = await this.initCgiExecutor();
|
||||
|
||||
if (!executor) {
|
||||
logger.warn('[LegacyServer] CGI executor not available, serving script as static file');
|
||||
const data = await fs.readFile(scriptPath);
|
||||
logger.warn('[LegacyServer] CGI executor not available for script');
|
||||
return {
|
||||
data,
|
||||
data: Buffer.from('CGI service unavailable'),
|
||||
contentType: 'text/plain',
|
||||
source: 'local-htdocs',
|
||||
statusCode: 503,
|
||||
source: 'cgi-error',
|
||||
};
|
||||
}
|
||||
|
||||
// Build URL from the script path for CGI environment
|
||||
// Extract the relative path from the script path
|
||||
let relativePath = scriptPath;
|
||||
if (scriptPath.startsWith(this.settings.legacyHTDOCSPath)) {
|
||||
relativePath = scriptPath.substring(this.settings.legacyHTDOCSPath.length);
|
||||
} else if (scriptPath.startsWith(this.settings.legacyCGIBINPath)) {
|
||||
relativePath = scriptPath.substring(this.settings.legacyCGIBINPath.length);
|
||||
if (scriptPath.startsWith(settings.legacyHTDOCSPath)) {
|
||||
relativePath = scriptPath.substring(settings.legacyHTDOCSPath.length);
|
||||
} else if (scriptPath.startsWith(settings.legacyCGIBINPath)) {
|
||||
relativePath = scriptPath.substring(settings.legacyCGIBINPath.length);
|
||||
}
|
||||
// Normalize path separators for URL
|
||||
relativePath = relativePath.replace(/\\/g, '/');
|
||||
@@ -431,18 +529,21 @@ export class LegacyServer {
|
||||
/**
|
||||
* Try external sources (Infinity Server, MAD4FP, etc.)
|
||||
*/
|
||||
private async tryExternalSources(relPath: string): Promise<LegacyFileResponse> {
|
||||
private async tryExternalSources(
|
||||
relPath: string,
|
||||
settings: ReturnType<typeof ConfigManager.getSettings>
|
||||
): Promise<LegacyFileResponse> {
|
||||
// Try Infinity Server first
|
||||
if (this.settings.infinityServerURL) {
|
||||
if (settings.infinityServerURL) {
|
||||
logger.info(`[LegacyServer] Trying Infinity Server...`);
|
||||
const result = await this.tryExternalSource(this.settings.infinityServerURL, relPath);
|
||||
const result = await this.tryExternalSource(settings.infinityServerURL, relPath);
|
||||
if (result) {
|
||||
return { ...result, source: 'infinity-server' };
|
||||
}
|
||||
}
|
||||
|
||||
// Try other external paths
|
||||
for (const externalPath of this.settings.externalFilePaths) {
|
||||
for (const externalPath of settings.externalFilePaths) {
|
||||
logger.info(`[LegacyServer] Trying: ${externalPath}`);
|
||||
const result = await this.tryExternalSource(externalPath, relPath);
|
||||
if (result) {
|
||||
@@ -451,8 +552,8 @@ export class LegacyServer {
|
||||
}
|
||||
|
||||
// Try MAD4FP if enabled
|
||||
if (this.settings.mad4fpEnabled) {
|
||||
for (const mad4fpPath of this.settings.mad4fpPaths) {
|
||||
if (settings.mad4fpEnabled) {
|
||||
for (const mad4fpPath of settings.mad4fpPaths) {
|
||||
logger.info(`[LegacyServer] Trying MAD4FP: ${mad4fpPath}`);
|
||||
const result = await this.tryExternalSource(mad4fpPath, relPath);
|
||||
if (result) {
|
||||
@@ -506,7 +607,18 @@ export class LegacyServer {
|
||||
relPath: string
|
||||
): Promise<{ data: Buffer; contentType: string } | null> {
|
||||
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
||||
const fullUrl = `${normalizedBase}${relPath}`;
|
||||
let fullUrl: string;
|
||||
try {
|
||||
const resolved = new URL(relPath, normalizedBase);
|
||||
// Verify resolved URL still starts with base
|
||||
if (!resolved.href.startsWith(normalizedBase)) {
|
||||
logger.warn(`[LegacyServer] Path traversal blocked in external URL: ${relPath}`);
|
||||
return null;
|
||||
}
|
||||
fullUrl = resolved.href;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info(`[LegacyServer] Downloading: ${fullUrl}`);
|
||||
|
||||
@@ -519,7 +631,7 @@ export class LegacyServer {
|
||||
}, EXTERNAL_REQUEST_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await axios.get(fullUrl, {
|
||||
const response = await axiosInstance.get(fullUrl, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: EXTERNAL_REQUEST_TIMEOUT_MS,
|
||||
maxRedirects: EXTERNAL_REQUEST_MAX_REDIRECTS,
|
||||
@@ -533,6 +645,7 @@ export class LegacyServer {
|
||||
maxBodyLength: 100 * 1024 * 1024,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
const data = Buffer.from(response.data);
|
||||
|
||||
// Determine content type from file extension, not from response header (G-H5)
|
||||
@@ -560,15 +673,14 @@ export class LegacyServer {
|
||||
|
||||
// Log the error appropriately
|
||||
if (axios.isAxiosError(error)) {
|
||||
const axiosError = error as AxiosError;
|
||||
if (axiosError.response?.status === 404) {
|
||||
if (error.response?.status === 404) {
|
||||
logger.debug(`[LegacyServer] ✗ Not found (404)`);
|
||||
} else if (axiosError.response?.status === 403) {
|
||||
} else if (error.response?.status === 403) {
|
||||
logger.warn(`[LegacyServer] ✗ Forbidden (403)`);
|
||||
} else if (
|
||||
axiosError.code === 'ECONNABORTED' ||
|
||||
axiosError.code === 'ETIMEDOUT' ||
|
||||
axiosError.code === 'ERR_CANCELED'
|
||||
error.code === 'ECONNABORTED' ||
|
||||
error.code === 'ETIMEDOUT' ||
|
||||
error.code === 'ERR_CANCELED'
|
||||
) {
|
||||
if (shouldRetry) {
|
||||
logger.warn(
|
||||
@@ -581,7 +693,7 @@ export class LegacyServer {
|
||||
}
|
||||
} else {
|
||||
// Sanitize error message to prevent internal details leakage
|
||||
const safeMessage = sanitizeErrorMessage(axiosError.message || 'Unknown error');
|
||||
const safeMessage = sanitizeErrorMessage(error.message || 'Unknown error');
|
||||
if (shouldRetry) {
|
||||
logger.warn(
|
||||
`[LegacyServer] ✗ Error: ${safeMessage} (attempt ${attempt}/${MAX_RETRIES})`
|
||||
@@ -631,9 +743,12 @@ export class LegacyServer {
|
||||
private getHostnameVariations(hostname: string): string[] {
|
||||
const variations: string[] = [hostname];
|
||||
|
||||
// Add subdomain variations
|
||||
// Add subdomain variations (skip if hostname already has that prefix)
|
||||
for (const subdomain of COMMON_SUBDOMAINS) {
|
||||
variations.push(`${subdomain}.${hostname}`);
|
||||
// Avoid creating nonsensical prefixes like "www.www.example.com"
|
||||
if (!hostname.startsWith(`${subdomain}.`)) {
|
||||
variations.push(`${subdomain}.${hostname}`);
|
||||
}
|
||||
}
|
||||
|
||||
return variations;
|
||||
@@ -3,7 +3,7 @@
|
||||
* Includes 199 custom MIME types for legacy web content
|
||||
*/
|
||||
|
||||
export const CUSTOM_MIME_TYPES: { [key: string]: string } = {
|
||||
const CUSTOM_MIME_TYPES: { [key: string]: string } = {
|
||||
// Legacy multimedia formats
|
||||
aab: 'application/x-authorware-bin',
|
||||
aam: 'application/x-authorware-map',
|
||||
@@ -182,7 +182,6 @@ export const CUSTOM_MIME_TYPES: { [key: string]: string } = {
|
||||
const STANDARD_MIME_TYPES: { [key: string]: string } = {
|
||||
// Text
|
||||
txt: 'text/plain',
|
||||
css: 'text/css',
|
||||
csv: 'text/csv',
|
||||
|
||||
// Images
|
||||
@@ -253,26 +252,3 @@ export function getMimeType(extension: string): string {
|
||||
// Default to octet-stream for unknown types
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file extension is a gzipped type
|
||||
*/
|
||||
export function isGzippedType(extension: string): boolean {
|
||||
const gzippedTypes = ['svgz'];
|
||||
return gzippedTypes.includes(extension.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file extension is a script type
|
||||
*/
|
||||
export function isScriptType(extension: string): boolean {
|
||||
const scriptTypes = ['php', 'php5', 'phtml', 'pl'];
|
||||
return scriptTypes.includes(extension.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of index file types
|
||||
*/
|
||||
export function getIndexFileTypes(): string[] {
|
||||
return ['html', 'htm', 'php', 'php5', 'phtml', 'asp', 'aspx', 'jsp', 'jspx'];
|
||||
}
|
||||
+116
-109
@@ -1,26 +1,39 @@
|
||||
import { IncomingMessage, ServerResponse } from 'http';
|
||||
import { URL } from 'url';
|
||||
import { Readable } from 'stream';
|
||||
import { logger } from './utils/logger';
|
||||
import path from 'path';
|
||||
import { logger } from '../utils/logger';
|
||||
import { ConfigManager } from './config';
|
||||
import { LegacyServer, HttpRequestContext, LegacyFileResponse } from './legacy-server';
|
||||
import { injectPolyfills } from './utils/htmlInjector';
|
||||
import { sanitizeErrorMessage, sanitizeUrlPath } from './utils/pathSecurity';
|
||||
import axios from 'axios';
|
||||
import { validateHostname } from './validation/schemas';
|
||||
import { zipManager } from './zip-manager';
|
||||
import { getMimeType } from './mimeTypes';
|
||||
import { setCorsHeaders } from './utils/cors';
|
||||
|
||||
// Maximum request body size for CGI (10MB)
|
||||
const MAX_REQUEST_BODY_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
// Internal request timeout constants (G-H2)
|
||||
const GAMEZIP_REQUEST_TIMEOUT_MS = 5000; // 5 seconds for local GameZip server
|
||||
const STREAM_TIMEOUT_MS = 120000; // 2 minutes for streaming responses
|
||||
|
||||
// Cache control constants
|
||||
const CACHE_MAX_AGE_SECONDS = 86400; // 24 hours (in seconds)
|
||||
|
||||
// Allowlist of safe CGI response headers to forward
|
||||
// Prevents header injection attacks (e.g., Set-Cookie, Location, CORS overrides)
|
||||
const ALLOWED_CGI_HEADERS = new Set([
|
||||
'cache-control',
|
||||
'expires',
|
||||
'last-modified',
|
||||
'etag',
|
||||
'pragma',
|
||||
'content-disposition',
|
||||
'content-language',
|
||||
'x-powered-by',
|
||||
]);
|
||||
|
||||
export class ProxyRequestHandler {
|
||||
private legacyServer: LegacyServer;
|
||||
private settings = ConfigManager.getSettings();
|
||||
|
||||
constructor() {
|
||||
this.legacyServer = new LegacyServer();
|
||||
@@ -42,18 +55,6 @@ export class ProxyRequestHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
// Security check: Sanitize URL path BEFORE any processing (G-H1)
|
||||
// This catches double-encoded attacks and dangerous patterns early
|
||||
try {
|
||||
sanitizeUrlPath(req.url);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[Security] Invalid URL rejected: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
this.sendError(res, 400, 'Bad Request: Invalid URL');
|
||||
return;
|
||||
}
|
||||
|
||||
let targetUrl: URL;
|
||||
let hostname: string;
|
||||
let urlPath: string;
|
||||
@@ -87,10 +88,21 @@ export class ProxyRequestHandler {
|
||||
targetUrl = new URL(`http://${hostname}${urlPath}`);
|
||||
}
|
||||
|
||||
// Strip port from hostname (Host header may include port, e.g., "example.com:8080")
|
||||
hostname = hostname.split(':')[0];
|
||||
|
||||
// Validate hostname to prevent SSRF and other host-based attacks
|
||||
try {
|
||||
hostname = validateHostname(hostname);
|
||||
} catch (error) {
|
||||
this.sendError(res, 400, 'Bad Request: Invalid hostname');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`[ProxyHandler] Parsed - Host: ${hostname}, Path: ${urlPath}`);
|
||||
|
||||
// Step 1: Try GameZip server first (if enabled)
|
||||
if (this.settings.gameZipPort) {
|
||||
// Step 1: Try GameZip server first (in-process)
|
||||
{
|
||||
logger.debug(`[ProxyHandler] Trying GameZip server...`);
|
||||
const gameZipResult = await this.tryGameZipServer(hostname, urlPath);
|
||||
if (gameZipResult) {
|
||||
@@ -100,10 +112,9 @@ export class ProxyRequestHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Build request context for CGI support
|
||||
// Step 2: Try legacy file serving (local htdocs + external CDN)
|
||||
// This handles games whose content is in htdocs or available from external sources.
|
||||
const requestContext = await this.buildRequestContext(req);
|
||||
|
||||
// Step 3: Try legacy file serving (local htdocs + external fallbacks)
|
||||
logger.debug(`[ProxyHandler] Trying legacy server...`);
|
||||
const legacyResult = await this.legacyServer.serveLegacy(hostname, urlPath, requestContext);
|
||||
|
||||
@@ -115,58 +126,45 @@ export class ProxyRequestHandler {
|
||||
const safeMessage = sanitizeErrorMessage(errorMessage);
|
||||
logger.error(`[ProxyHandler] Error handling request: ${safeMessage}`);
|
||||
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
this.sendError(res, 404, 'File not found');
|
||||
} else {
|
||||
// Never expose internal error details to client (G-H4)
|
||||
this.sendError(res, 500, 'Internal server error');
|
||||
}
|
||||
// Never expose internal error details to client (G-H4)
|
||||
this.sendError(res, 500, 'Internal server error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to fetch from GameZip server
|
||||
* Note: GameZip server responses are kept buffered since they come from local ZIP archives
|
||||
* and are typically already compressed/optimized
|
||||
*
|
||||
* Implements timeout enforcement (G-H2)
|
||||
* Try to find file from GameZip server (direct in-process call)
|
||||
* Returns the file data if found in a mounted ZIP, or null to fall through
|
||||
* to the legacy server fallback chain.
|
||||
*/
|
||||
private async tryGameZipServer(
|
||||
hostname: string,
|
||||
urlPath: string
|
||||
): Promise<{ data: Buffer; contentType: string } | null> {
|
||||
// Create abort controller for request timeout (G-H2)
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, GAMEZIP_REQUEST_TIMEOUT_MS);
|
||||
|
||||
): Promise<{ data: Buffer; contentType: string; statusCode: number } | null> {
|
||||
try {
|
||||
// Make proxy-style request to GameZip server
|
||||
// The GameZip server also accepts proxy-style requests: GET http://hostname/path HTTP/1.1
|
||||
const gameZipServerUrl = `http://localhost:${this.settings.gameZipPort}`;
|
||||
const targetUrl = `http://${hostname}${urlPath}`;
|
||||
const requestUrl = `${gameZipServerUrl}/${targetUrl}`;
|
||||
const pathWithoutQuery = urlPath.split('?')[0];
|
||||
const relPath = path.posix.join(hostname, pathWithoutQuery);
|
||||
|
||||
const response = await axios.get(requestUrl, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: GAMEZIP_REQUEST_TIMEOUT_MS,
|
||||
signal: abortController.signal,
|
||||
validateStatus: (status) => status === 200,
|
||||
proxy: false, // Don't use system proxy
|
||||
});
|
||||
logger.debug(`[ProxyHandler] Searching mounted ZIPs for: ${relPath}`);
|
||||
|
||||
const data = Buffer.from(response.data);
|
||||
const contentType = response.headers['content-type'] || 'application/octet-stream';
|
||||
const result = await zipManager.findFile(relPath);
|
||||
|
||||
return { data, contentType };
|
||||
if (result) {
|
||||
const ext = path.extname(pathWithoutQuery).substring(1).toLowerCase();
|
||||
const contentType = getMimeType(ext);
|
||||
|
||||
// Don't inject polyfills here - sendResponse() handles it for all HTML responses
|
||||
return { data: result.data, contentType, statusCode: 200 };
|
||||
}
|
||||
|
||||
// File not found in any mounted ZIP.
|
||||
// Return null so the request falls through to the legacy server,
|
||||
// which can serve the file from local htdocs or external CDN sources.
|
||||
// If a ZIP download is in progress, the frontend already shows a
|
||||
// download UI and won't try to load game content until it completes.
|
||||
return null;
|
||||
} catch (error) {
|
||||
// GameZip server not available or file not found
|
||||
// Don't log detailed errors to avoid path leakage (G-H4)
|
||||
logger.debug(`[ProxyHandler] GameZip unavailable or file not found`);
|
||||
logger.debug(`[ProxyHandler] GameZip lookup failed`);
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,27 +172,31 @@ export class ProxyRequestHandler {
|
||||
* Send successful response (supports both buffered and streamed responses)
|
||||
*/
|
||||
private sendResponse(res: ServerResponse, result: LegacyFileResponse, source: string): void {
|
||||
const settings = ConfigManager.getSettings();
|
||||
const contentType = result.contentType;
|
||||
const statusCode = result.statusCode || 200;
|
||||
|
||||
// CORS headers
|
||||
if (this.settings.allowCrossDomain) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', '*');
|
||||
}
|
||||
setCorsHeaders(res, settings);
|
||||
|
||||
// Common headers
|
||||
res.setHeader('Cache-Control', `public, max-age=${CACHE_MAX_AGE_SECONDS}`);
|
||||
res.setHeader('X-Source', source); // Debug header
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
res.setHeader('X-Source', source); // Debug header
|
||||
}
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
|
||||
// Add CGI response headers if present
|
||||
// Add CGI response headers if present (allowlist approach to prevent header injection)
|
||||
if (result.headers) {
|
||||
for (const [key, value] of Object.entries(result.headers)) {
|
||||
// Skip headers that we handle separately
|
||||
const lowerKey = key.toLowerCase();
|
||||
if (lowerKey === 'content-type' || lowerKey === 'content-length' || lowerKey === 'status') {
|
||||
// Only forward safe, known headers from CGI responses
|
||||
if (!ALLOWED_CGI_HEADERS.has(lowerKey)) {
|
||||
continue;
|
||||
}
|
||||
// Reject header values containing CR or LF to prevent header injection
|
||||
if (/[\r\n]/.test(value)) {
|
||||
logger.warn(`[Security] CGI header value contains CRLF, skipping: ${key}`);
|
||||
continue;
|
||||
}
|
||||
res.setHeader(key, value);
|
||||
@@ -219,6 +221,14 @@ export class ProxyRequestHandler {
|
||||
}
|
||||
}, STREAM_TIMEOUT_MS);
|
||||
|
||||
// Clean up stream if client disconnects
|
||||
res.on('close', () => {
|
||||
clearTimeout(streamTimeout);
|
||||
if (result.stream && !result.stream.destroyed) {
|
||||
result.stream.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
result.stream.pipe(res);
|
||||
|
||||
result.stream.on('error', (err) => {
|
||||
@@ -229,7 +239,9 @@ export class ProxyRequestHandler {
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(500);
|
||||
}
|
||||
res.end();
|
||||
if (!res.writableEnded) {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
result.stream.on('end', () => {
|
||||
@@ -262,15 +274,19 @@ export class ProxyRequestHandler {
|
||||
* Send error response
|
||||
*/
|
||||
private sendError(res: ServerResponse, statusCode: number, message: string): void {
|
||||
if (res.headersSent) {
|
||||
if (!res.writableEnded) {
|
||||
res.end();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(`[ProxyHandler] Sending error ${statusCode}: ${message}`);
|
||||
|
||||
const settings = ConfigManager.getSettings();
|
||||
// IMPORTANT: Set CORS headers even for error responses
|
||||
// Otherwise browsers will show CORS error instead of the actual error
|
||||
if (this.settings.allowCrossDomain) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', '*');
|
||||
}
|
||||
setCorsHeaders(res, settings);
|
||||
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.writeHead(statusCode);
|
||||
@@ -281,10 +297,10 @@ export class ProxyRequestHandler {
|
||||
* Handle OPTIONS request for CORS preflight
|
||||
*/
|
||||
handleOptionsRequest(req: IncomingMessage, res: ServerResponse): void {
|
||||
if (this.settings.allowCrossDomain) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', '*');
|
||||
const settings = ConfigManager.getSettings();
|
||||
setCorsHeaders(res, settings);
|
||||
|
||||
if (settings.allowCrossDomain) {
|
||||
res.setHeader('Access-Control-Max-Age', CACHE_MAX_AGE_SECONDS.toString());
|
||||
}
|
||||
|
||||
@@ -327,47 +343,38 @@ export class ProxyRequestHandler {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let totalSize = 0;
|
||||
let completed = false;
|
||||
let settled = false;
|
||||
|
||||
// Timeout for body collection (30 seconds)
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
if (!completed) {
|
||||
completed = true;
|
||||
req.destroy(new Error('Request body collection timed out'));
|
||||
reject(new Error('Request body collection timed out'));
|
||||
const settle = <T>(fn: () => T): T | undefined => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
clearTimeout(timeoutHandle);
|
||||
return fn();
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
settle(() => {
|
||||
req.removeAllListeners('data');
|
||||
reject(new Error('Request body collection timed out'));
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
req.on('data', (chunk: Buffer) => {
|
||||
if (completed) return;
|
||||
if (settled) return;
|
||||
totalSize += chunk.length;
|
||||
if (totalSize > MAX_REQUEST_BODY_SIZE) {
|
||||
completed = true;
|
||||
clearTimeout(timeoutHandle);
|
||||
req.destroy(new Error('Request body too large'));
|
||||
reject(new Error('Request body too large'));
|
||||
settle(() => reject(new Error('Request body too large')));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
req.on('end', () => {
|
||||
if (completed) return;
|
||||
completed = true;
|
||||
clearTimeout(timeoutHandle);
|
||||
if (chunks.length === 0) {
|
||||
resolve(undefined);
|
||||
} else {
|
||||
resolve(Buffer.concat(chunks));
|
||||
}
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
if (completed) return;
|
||||
completed = true;
|
||||
clearTimeout(timeoutHandle);
|
||||
reject(error);
|
||||
});
|
||||
req.on('end', () =>
|
||||
settle(() => resolve(chunks.length === 0 ? undefined : Buffer.concat(chunks)))
|
||||
);
|
||||
req.on('error', (error) => settle(() => reject(error)));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
import fs from 'fs/promises';
|
||||
import { createWriteStream, createReadStream } from 'fs';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import https from 'https';
|
||||
import http from 'http';
|
||||
import net from 'net';
|
||||
import dns from 'dns';
|
||||
import { promisify } from 'util';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { PreferencesService, GameDataSource } from './PreferencesService';
|
||||
|
||||
const dnsLookup = promisify(dns.lookup);
|
||||
|
||||
/**
|
||||
* Parameters for downloading a game data ZIP
|
||||
*/
|
||||
export interface GameDataDownloadParams {
|
||||
gameId: string;
|
||||
dateAdded: string; // ISO date string from game_data table
|
||||
sha256?: string; // Expected SHA256 hash for verification
|
||||
targetPath?: string; // Override target path (default: Data/Games/)
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a download attempt
|
||||
*/
|
||||
export interface DownloadResult {
|
||||
success: boolean;
|
||||
filePath?: string;
|
||||
error?: string;
|
||||
sourceUsed?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress callback for download tracking
|
||||
*/
|
||||
export type DownloadProgressCallback = (
|
||||
downloadedBytes: number,
|
||||
totalBytes: number | null,
|
||||
sourceName: string
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Service for downloading game data ZIPs from configured sources
|
||||
* Implements the same logic as Flashpoint Launcher's download.ts
|
||||
*/
|
||||
export class GameDataDownloader {
|
||||
private static instance: GameDataDownloader;
|
||||
private readonly MAX_RETRIES = 3;
|
||||
private readonly RETRY_DELAY_MS = 1000;
|
||||
private readonly DOWNLOAD_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||
private readonly MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB max
|
||||
|
||||
/**
|
||||
* Private/reserved IPv4 and IPv6 CIDR ranges that must be blocked
|
||||
* to prevent SSRF attacks targeting internal services.
|
||||
*/
|
||||
private static readonly PRIVATE_IP_RANGES: Array<{
|
||||
subnet: string;
|
||||
prefix: number;
|
||||
family: 4 | 6;
|
||||
}> = [
|
||||
// IPv4 private/reserved ranges
|
||||
{ subnet: '127.0.0.0', prefix: 8, family: 4 }, // Loopback
|
||||
{ subnet: '10.0.0.0', prefix: 8, family: 4 }, // Private Class A
|
||||
{ subnet: '172.16.0.0', prefix: 12, family: 4 }, // Private Class B
|
||||
{ subnet: '192.168.0.0', prefix: 16, family: 4 }, // Private Class C
|
||||
{ subnet: '169.254.0.0', prefix: 16, family: 4 }, // Link-local / cloud metadata
|
||||
{ subnet: '0.0.0.0', prefix: 8, family: 4 }, // Current network
|
||||
// IPv6 private/reserved ranges
|
||||
{ subnet: '::1', prefix: 128, family: 6 }, // Loopback
|
||||
{ subnet: 'fc00::', prefix: 7, family: 6 }, // Unique local (fc00::/7)
|
||||
{ subnet: 'fe80::', prefix: 10, family: 6 }, // Link-local
|
||||
];
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): GameDataDownloader {
|
||||
if (!GameDataDownloader.instance) {
|
||||
GameDataDownloader.instance = new GameDataDownloader();
|
||||
}
|
||||
return GameDataDownloader.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the filename for a game data ZIP
|
||||
* Matches Flashpoint Launcher's getGameDataFilename() logic
|
||||
*/
|
||||
static getFilename(gameId: string, dateAdded: string): string {
|
||||
// Handle different date formats (with or without 'T')
|
||||
let cleanDate: string;
|
||||
if (dateAdded.includes('T')) {
|
||||
cleanDate = dateAdded;
|
||||
} else {
|
||||
// Insert 'T' between date and time at position 10
|
||||
cleanDate = dateAdded.slice(0, 10) + 'T' + dateAdded.slice(11);
|
||||
}
|
||||
|
||||
const timestamp = new Date(cleanDate).getTime();
|
||||
|
||||
// Validate timestamp with fallback
|
||||
if (isNaN(timestamp)) {
|
||||
// Try fallback: append UTC suffix
|
||||
const fallbackDate = `${dateAdded} +0000 UTC`;
|
||||
const fallbackTimestamp = new Date(fallbackDate).getTime();
|
||||
if (!isNaN(fallbackTimestamp)) {
|
||||
return `${gameId}-${fallbackTimestamp}.zip`;
|
||||
}
|
||||
throw new Error(`Invalid dateAdded value: ${dateAdded}`);
|
||||
}
|
||||
|
||||
return `${gameId}-${timestamp}.zip`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a game data ZIP from configured sources
|
||||
* Tries each source sequentially until one succeeds
|
||||
*/
|
||||
async download(
|
||||
params: GameDataDownloadParams,
|
||||
onProgress?: DownloadProgressCallback
|
||||
): Promise<DownloadResult> {
|
||||
const { gameId, dateAdded, sha256 } = params;
|
||||
const filename = GameDataDownloader.getFilename(gameId, dateAdded);
|
||||
|
||||
logger.info(`[GameDataDownloader] Starting download for ${filename}`);
|
||||
|
||||
// Get configured sources
|
||||
const prefsService = PreferencesService.getInstance();
|
||||
const sources = await prefsService.getGameDataSources();
|
||||
|
||||
if (sources.length === 0) {
|
||||
logger.error('[GameDataDownloader] No game data sources configured in preferences.json');
|
||||
return {
|
||||
success: false,
|
||||
error: 'No game data sources configured. Add gameDataSources to preferences.json.',
|
||||
};
|
||||
}
|
||||
|
||||
// Determine target path
|
||||
const targetDir = params.targetPath || (await prefsService.getDataPacksFolderPath());
|
||||
const targetPath = path.join(targetDir, filename);
|
||||
const tempPath = `${targetPath}.temp`;
|
||||
|
||||
// Ensure target directory exists
|
||||
try {
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Unknown error';
|
||||
return { success: false, error: `Failed to create directory: ${errorMsg}` };
|
||||
}
|
||||
|
||||
// Try each source sequentially
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const source of sources) {
|
||||
// Check if source has arguments and at least one URL
|
||||
if (!source.arguments?.length || !source.arguments[0]) {
|
||||
logger.warn(`[GameDataDownloader] Source "${source.name}" has no arguments, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let baseUrl = source.arguments[0];
|
||||
|
||||
// Enforce HTTPS for download sources
|
||||
if (new URL(baseUrl).protocol === 'http:') {
|
||||
const secureUrl = baseUrl.replace(/^http:/, 'https:');
|
||||
logger.warn(`[GameDataDownloader] Upgrading insecure source URL to HTTPS: ${baseUrl}`);
|
||||
baseUrl = secureUrl;
|
||||
}
|
||||
|
||||
// Normalize baseUrl trailing slash before URL construction
|
||||
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/';
|
||||
const fullUrl = new URL(filename, normalizedBase).href;
|
||||
logger.info(`[GameDataDownloader] Trying source "${source.name}": ${fullUrl}`);
|
||||
|
||||
try {
|
||||
// Download to temp file
|
||||
await this.downloadFile(fullUrl, tempPath, source.name, onProgress);
|
||||
|
||||
// Verify SHA256 if provided
|
||||
if (sha256) {
|
||||
const fileHash = await this.calculateSha256(tempPath);
|
||||
if (fileHash.toLowerCase() !== sha256.toLowerCase()) {
|
||||
logger.error(
|
||||
`[GameDataDownloader] SHA256 mismatch! Expected: ${sha256}, Got: ${fileHash}`
|
||||
);
|
||||
await this.safeUnlink(tempPath);
|
||||
errors.push(
|
||||
`Source "${source.name}": SHA256 mismatch (expected ${sha256.substring(0, 8)}..., got ${fileHash.substring(0, 8)}...)`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
logger.info(`[GameDataDownloader] SHA256 verified: ${fileHash.substring(0, 16)}...`);
|
||||
}
|
||||
|
||||
// Move temp file to final location
|
||||
await fs.rename(tempPath, targetPath);
|
||||
|
||||
logger.info(
|
||||
`[GameDataDownloader] Successfully downloaded ${filename} from "${source.name}"`
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
filePath: targetPath,
|
||||
sourceUsed: source.name,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`[GameDataDownloader] Source "${source.name}" failed: ${errorMsg}`);
|
||||
errors.push(`Source "${source.name}": ${errorMsg}`);
|
||||
|
||||
// Clean up temp file if exists
|
||||
await this.safeUnlink(tempPath);
|
||||
}
|
||||
}
|
||||
|
||||
// All sources failed
|
||||
const errorSummary = errors.join('\n');
|
||||
logger.error(`[GameDataDownloader] All sources failed for ${filename}:\n${errorSummary}`);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to download from all ${sources.length} sources:\n${errorSummary}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file from URL to local path with retry logic
|
||||
*/
|
||||
private async downloadFile(
|
||||
url: string,
|
||||
destPath: string,
|
||||
sourceName: string,
|
||||
onProgress?: DownloadProgressCallback
|
||||
): Promise<void> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 1; attempt <= this.MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
await this.downloadFileOnce(url, destPath, sourceName, onProgress);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
logger.warn(
|
||||
`[GameDataDownloader] Attempt ${attempt}/${this.MAX_RETRIES} failed: ${lastError.message}`
|
||||
);
|
||||
|
||||
if (attempt < this.MAX_RETRIES) {
|
||||
const delay = this.RETRY_DELAY_MS * attempt;
|
||||
logger.info(`[GameDataDownloader] Retrying in ${delay}ms...`);
|
||||
await this.sleep(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('Download failed after all retries');
|
||||
}
|
||||
|
||||
/**
|
||||
* Single download attempt with SSRF protection and double-settlement guard
|
||||
*/
|
||||
private async downloadFileOnce(
|
||||
url: string,
|
||||
destPath: string,
|
||||
sourceName: string,
|
||||
onProgress?: DownloadProgressCallback,
|
||||
maxRedirects: number = 5
|
||||
): Promise<void> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
// Guard against double-settlement: the 'data' handler can reject on size
|
||||
// limit while the piped writeStream 'finish' event can still fire afterward.
|
||||
let settled = false;
|
||||
const safeResolve = () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
const safeReject = (error: Error) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
if (maxRedirects <= 0) {
|
||||
safeReject(new Error('Too many redirects'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate initial URL protocol - only HTTP(S) allowed
|
||||
const parsedUrl = new URL(url);
|
||||
if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') {
|
||||
safeReject(
|
||||
new Error(
|
||||
`Unsupported protocol: ${parsedUrl.protocol} - only http: and https: are allowed`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate initial URL is not targeting a private/reserved IP
|
||||
if (this.isPrivateHost(parsedUrl.hostname)) {
|
||||
logger.warn(
|
||||
`[GameDataDownloader] Blocked download to private/reserved address: ${parsedUrl.hostname}`
|
||||
);
|
||||
safeReject(
|
||||
new Error(
|
||||
`Download blocked: ${parsedUrl.hostname} resolves to a private/reserved address`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// DNS rebinding protection: resolve hostname and check for private IPs
|
||||
const hostname = parsedUrl.hostname;
|
||||
if (!net.isIPv4(hostname) && !net.isIPv6(hostname)) {
|
||||
// It's a hostname, not an IP - perform DNS lookup
|
||||
try {
|
||||
const { address } = await dnsLookup(hostname);
|
||||
if (this.isPrivateIPv4(address) || this.isPrivateIPv6(address)) {
|
||||
logger.warn(
|
||||
`[GameDataDownloader] Blocked: ${hostname} resolves to private address ${address}`
|
||||
);
|
||||
safeReject(new Error(`Blocked: ${hostname} resolves to private address ${address}`));
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith('Blocked:')) {
|
||||
safeReject(error);
|
||||
return;
|
||||
}
|
||||
// DNS lookup failed - allow the request to proceed (it will fail on connect anyway)
|
||||
}
|
||||
}
|
||||
|
||||
const protocol = parsedUrl.protocol === 'https:' ? https : http;
|
||||
let writeStream: ReturnType<typeof createWriteStream> | null = null;
|
||||
|
||||
const request = protocol.get(url, { timeout: this.DOWNLOAD_TIMEOUT_MS }, (response) => {
|
||||
// Handle redirects
|
||||
if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400) {
|
||||
const redirectUrl = response.headers.location;
|
||||
if (redirectUrl) {
|
||||
response.resume();
|
||||
|
||||
// Validate redirect URL before following
|
||||
try {
|
||||
this.validateRedirectUrl(redirectUrl);
|
||||
} catch (err) {
|
||||
safeReject(err instanceof Error ? err : new Error(String(err)));
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`[GameDataDownloader] Following redirect to: ${redirectUrl}`);
|
||||
this.downloadFileOnce(redirectUrl, destPath, sourceName, onProgress, maxRedirects - 1)
|
||||
.then(safeResolve)
|
||||
.catch(safeReject);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for success status
|
||||
if (response.statusCode !== 200) {
|
||||
response.resume();
|
||||
safeReject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check content length
|
||||
const contentLength = response.headers['content-length'];
|
||||
const totalBytes = contentLength ? parseInt(contentLength, 10) : null;
|
||||
|
||||
if (totalBytes && totalBytes > this.MAX_FILE_SIZE) {
|
||||
response.resume(); // Drain the response
|
||||
safeReject(new Error(`File too large: ${totalBytes} bytes (max: ${this.MAX_FILE_SIZE})`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create write stream
|
||||
writeStream = createWriteStream(destPath);
|
||||
let downloadedBytes = 0;
|
||||
|
||||
response.on('data', (chunk: Buffer) => {
|
||||
downloadedBytes += chunk.length;
|
||||
|
||||
// Check size limit during download
|
||||
if (downloadedBytes > this.MAX_FILE_SIZE) {
|
||||
response.destroy();
|
||||
request.destroy();
|
||||
if (writeStream) {
|
||||
writeStream.destroy();
|
||||
}
|
||||
safeReject(new Error(`Download exceeded size limit: ${this.MAX_FILE_SIZE} bytes`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (onProgress) {
|
||||
onProgress(downloadedBytes, totalBytes, sourceName);
|
||||
}
|
||||
});
|
||||
|
||||
response.pipe(writeStream);
|
||||
|
||||
writeStream.on('finish', () => {
|
||||
logger.info(
|
||||
`[GameDataDownloader] Downloaded ${downloadedBytes} bytes from "${sourceName}"`
|
||||
);
|
||||
safeResolve();
|
||||
});
|
||||
|
||||
writeStream.on('error', (error) => {
|
||||
safeReject(error);
|
||||
});
|
||||
|
||||
response.on('error', (error) => {
|
||||
if (writeStream) {
|
||||
writeStream.destroy();
|
||||
}
|
||||
request.destroy();
|
||||
safeReject(error);
|
||||
});
|
||||
});
|
||||
|
||||
request.on('error', (error) => {
|
||||
if (writeStream) {
|
||||
writeStream.destroy();
|
||||
}
|
||||
safeReject(error);
|
||||
});
|
||||
|
||||
request.on('timeout', () => {
|
||||
if (writeStream) {
|
||||
writeStream.destroy(); // Also close the file handle
|
||||
}
|
||||
request.destroy();
|
||||
safeReject(new Error(`Download timed out after ${this.DOWNLOAD_TIMEOUT_MS}ms`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a redirect URL to prevent SSRF attacks.
|
||||
* Rejects non-HTTP(S) protocols and private/reserved IP addresses.
|
||||
* Throws an Error if the URL is not safe to follow.
|
||||
*/
|
||||
private validateRedirectUrl(redirectUrl: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(redirectUrl);
|
||||
} catch {
|
||||
throw new Error(`Invalid redirect URL: ${redirectUrl}`);
|
||||
}
|
||||
|
||||
// Only allow http: and https: protocols
|
||||
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
||||
logger.warn(
|
||||
`[GameDataDownloader] Blocked redirect to disallowed protocol: ${parsed.protocol} (${redirectUrl})`
|
||||
);
|
||||
throw new Error(
|
||||
`Redirect blocked: unsupported protocol ${parsed.protocol} - only http: and https: are allowed`
|
||||
);
|
||||
}
|
||||
|
||||
// Block private/reserved IP addresses
|
||||
if (this.isPrivateHost(parsed.hostname)) {
|
||||
logger.warn(
|
||||
`[GameDataDownloader] Blocked redirect to private/reserved address: ${parsed.hostname} (${redirectUrl})`
|
||||
);
|
||||
throw new Error(
|
||||
`Redirect blocked: ${parsed.hostname} resolves to a private/reserved address`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a hostname is a private or reserved IP address.
|
||||
* Handles both IPv4 and IPv6, including bracketed IPv6 notation.
|
||||
*/
|
||||
private isPrivateHost(hostname: string): boolean {
|
||||
// Strip brackets from IPv6 addresses (e.g., [::1] -> ::1)
|
||||
const cleanHostname = hostname.replace(/^\[|\]$/g, '');
|
||||
|
||||
// Check for 'localhost' hostname
|
||||
if (cleanHostname.toLowerCase() === 'localhost') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Try to parse as IPv4
|
||||
if (net.isIPv4(cleanHostname)) {
|
||||
return this.isPrivateIPv4(cleanHostname);
|
||||
}
|
||||
|
||||
// Try to parse as IPv6
|
||||
if (net.isIPv6(cleanHostname)) {
|
||||
return this.isPrivateIPv6(cleanHostname);
|
||||
}
|
||||
|
||||
// Not an IP literal - it is a hostname.
|
||||
// DNS resolution could map to a private IP, but performing async DNS
|
||||
// lookup here is impractical in a synchronous validation path.
|
||||
// The redirect-hop limit (5) plus the protocol check provides defense-in-depth.
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IPv4 address falls within a private/reserved CIDR range
|
||||
*/
|
||||
private isPrivateIPv4(ip: string): boolean {
|
||||
const ipNum = this.ipv4ToNumber(ip);
|
||||
if (ipNum === null) return false;
|
||||
|
||||
for (const range of GameDataDownloader.PRIVATE_IP_RANGES) {
|
||||
if (range.family !== 4) continue;
|
||||
const subnetNum = this.ipv4ToNumber(range.subnet);
|
||||
if (subnetNum === null) continue;
|
||||
|
||||
const mask = range.prefix === 0 ? 0 : (~0 << (32 - range.prefix)) >>> 0;
|
||||
if ((ipNum & mask) === (subnetNum & mask)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IPv6 address falls within a private/reserved range
|
||||
*/
|
||||
private isPrivateIPv6(ip: string): boolean {
|
||||
const normalized = ip.toLowerCase();
|
||||
|
||||
// Exact loopback check
|
||||
if (normalized === '::1') return true;
|
||||
|
||||
// Unique local addresses: fc00::/7 (fc00:: - fdff::)
|
||||
if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true;
|
||||
|
||||
// Link-local: fe80::/10
|
||||
if (normalized.startsWith('fe80')) return true;
|
||||
|
||||
// IPv4-mapped IPv6 (::ffff:x.x.x.x) - check the embedded IPv4
|
||||
const v4mappedMatch = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
||||
if (v4mappedMatch) {
|
||||
return this.isPrivateIPv4(v4mappedMatch[1]);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an IPv4 address string to a 32-bit unsigned integer
|
||||
*/
|
||||
private ipv4ToNumber(ip: string): number | null {
|
||||
const parts = ip.split('.');
|
||||
if (parts.length !== 4) return null;
|
||||
|
||||
let num = 0;
|
||||
for (const part of parts) {
|
||||
const octet = parseInt(part, 10);
|
||||
if (isNaN(octet) || octet < 0 || octet > 255) return null;
|
||||
num = (num * 256 + octet) >>> 0;
|
||||
}
|
||||
return num;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate SHA256 hash of a file
|
||||
*/
|
||||
private calculateSha256(filePath: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = crypto.createHash('sha256');
|
||||
const stream = createReadStream(filePath);
|
||||
|
||||
stream.on('data', (data) => hash.update(data));
|
||||
stream.on('end', () => resolve(hash.digest('hex').toUpperCase()));
|
||||
stream.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely delete a file (ignore errors if file doesn't exist)
|
||||
*/
|
||||
private async safeUnlink(filePath: string): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
} catch {
|
||||
// Ignore errors (file may not exist)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep for specified milliseconds
|
||||
*/
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a game data ZIP exists locally
|
||||
*/
|
||||
async exists(gameId: string, dateAdded: string, targetDir?: string): Promise<boolean> {
|
||||
try {
|
||||
const filename = GameDataDownloader.getFilename(gameId, dateAdded);
|
||||
const prefsService = PreferencesService.getInstance();
|
||||
const dir = targetDir || (await prefsService.getDataPacksFolderPath());
|
||||
const filePath = path.join(dir, filename);
|
||||
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
// getFilename() can throw on invalid dateAdded
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full path to a game data ZIP
|
||||
*/
|
||||
async getFilePath(gameId: string, dateAdded: string, targetDir?: string): Promise<string> {
|
||||
try {
|
||||
const filename = GameDataDownloader.getFilename(gameId, dateAdded);
|
||||
const prefsService = PreferencesService.getInstance();
|
||||
const dir = targetDir || (await prefsService.getDataPacksFolderPath());
|
||||
return path.join(dir, filename);
|
||||
} catch (error) {
|
||||
// getFilename() can throw on invalid dateAdded
|
||||
throw new Error(
|
||||
`Invalid dateAdded value: ${dateAdded} - ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const gameDataDownloader = GameDataDownloader.getInstance();
|
||||
@@ -0,0 +1,140 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
/**
|
||||
* Game data source configuration from preferences.json
|
||||
* Used for downloading game ZIPs when not present locally
|
||||
*/
|
||||
export interface GameDataSource {
|
||||
type: string; // Currently only "raw" is used
|
||||
name: string; // Display name for the source
|
||||
arguments: string[]; // Array of arguments, first is the base URL
|
||||
}
|
||||
|
||||
/**
|
||||
* Relevant fields from Flashpoint preferences.json
|
||||
*/
|
||||
export interface FlashpointPreferences {
|
||||
gameDataSources: GameDataSource[];
|
||||
dataPacksFolderPath: string;
|
||||
onDemandBaseUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for reading Flashpoint preferences.json
|
||||
* Provides access to gameDataSources for downloading game ZIPs
|
||||
*/
|
||||
export class PreferencesService {
|
||||
private static instance: PreferencesService;
|
||||
private preferences: FlashpointPreferences | null = null;
|
||||
private flashpointPath: string;
|
||||
private preferencesPath: string;
|
||||
private lastLoadTime: number = 0;
|
||||
private lastLoadFailed: boolean = false;
|
||||
private readonly CACHE_TTL = 60 * 1000; // 1 minute cache
|
||||
private readonly ERROR_CACHE_TTL = 5000; // 5 seconds cache for errors
|
||||
|
||||
private constructor(flashpointPath: string) {
|
||||
this.flashpointPath = flashpointPath;
|
||||
this.preferencesPath = path.join(flashpointPath, 'preferences.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the PreferencesService singleton
|
||||
*/
|
||||
static initialize(flashpointPath: string): PreferencesService {
|
||||
if (PreferencesService.instance) {
|
||||
if (flashpointPath !== PreferencesService.instance.flashpointPath) {
|
||||
logger.warn(
|
||||
'[PreferencesService] Already initialized with different path, ignoring re-init'
|
||||
);
|
||||
}
|
||||
return PreferencesService.instance;
|
||||
}
|
||||
PreferencesService.instance = new PreferencesService(flashpointPath);
|
||||
return PreferencesService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance
|
||||
*/
|
||||
static getInstance(): PreferencesService {
|
||||
if (!PreferencesService.instance) {
|
||||
throw new Error('PreferencesService not initialized. Call initialize() first.');
|
||||
}
|
||||
return PreferencesService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load preferences from file with caching
|
||||
*/
|
||||
async loadPreferences(): Promise<FlashpointPreferences> {
|
||||
const now = Date.now();
|
||||
|
||||
// Use shorter TTL if last load failed
|
||||
const cacheTTL = this.lastLoadFailed ? this.ERROR_CACHE_TTL : this.CACHE_TTL;
|
||||
|
||||
// Return cached preferences if still valid
|
||||
if (this.preferences && now - this.lastLoadTime < cacheTTL) {
|
||||
return this.preferences;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fs.readFile(this.preferencesPath, 'utf-8');
|
||||
const parsed = JSON.parse(data);
|
||||
|
||||
this.preferences = {
|
||||
gameDataSources: parsed.gameDataSources || [],
|
||||
dataPacksFolderPath: parsed.dataPacksFolderPath || 'Data/Games',
|
||||
onDemandBaseUrl: parsed.onDemandBaseUrl,
|
||||
};
|
||||
|
||||
this.lastLoadTime = now;
|
||||
this.lastLoadFailed = false;
|
||||
|
||||
logger.info(
|
||||
`[PreferencesService] Loaded preferences: ${this.preferences.gameDataSources.length} game data sources`
|
||||
);
|
||||
|
||||
return this.preferences;
|
||||
} catch (error) {
|
||||
logger.error('[PreferencesService] Failed to load preferences.json:', error);
|
||||
|
||||
// Return defaults if file doesn't exist or is invalid
|
||||
this.preferences = {
|
||||
gameDataSources: [],
|
||||
dataPacksFolderPath: 'Data/Games',
|
||||
};
|
||||
|
||||
this.lastLoadTime = now;
|
||||
this.lastLoadFailed = true;
|
||||
|
||||
return this.preferences;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get game data sources for downloading ZIPs
|
||||
*/
|
||||
async getGameDataSources(): Promise<GameDataSource[]> {
|
||||
const prefs = await this.loadPreferences();
|
||||
return prefs.gameDataSources;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the data packs folder path (relative to FLASHPOINT_PATH)
|
||||
*/
|
||||
async getDataPacksFolderPath(): Promise<string> {
|
||||
const prefs = await this.loadPreferences();
|
||||
return path.join(this.flashpointPath, prefs.dataPacksFolderPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the cache to force reload on next access
|
||||
*/
|
||||
invalidateCache(): void {
|
||||
this.lastLoadTime = 0;
|
||||
logger.info('[PreferencesService] Cache invalidated');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export { PreferencesService } from './PreferencesService';
|
||||
export type { GameDataSource, FlashpointPreferences } from './PreferencesService';
|
||||
|
||||
export { GameDataDownloader, gameDataDownloader } from './GameDataDownloader';
|
||||
export type {
|
||||
GameDataDownloadParams,
|
||||
DownloadResult,
|
||||
DownloadProgressCallback,
|
||||
} from './GameDataDownloader';
|
||||
@@ -28,27 +28,3 @@ export function setCorsHeaders(res: ServerResponse, settings: CorsSettings): voi
|
||||
res.setHeader('Access-Control-Allow-Headers', '*');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set CORS headers with max age for preflight caching
|
||||
*
|
||||
* @param res - HTTP ServerResponse object
|
||||
* @param settings - CORS configuration settings
|
||||
* @param maxAge - Max age in seconds for preflight cache (default: 86400 = 24 hours)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* setCorsHeadersWithMaxAge(res, { allowCrossDomain: true }, 86400);
|
||||
* ```
|
||||
*/
|
||||
export function setCorsHeadersWithMaxAge(
|
||||
res: ServerResponse,
|
||||
settings: CorsSettings,
|
||||
maxAge = 86400
|
||||
): void {
|
||||
setCorsHeaders(res, settings);
|
||||
|
||||
if (settings.allowCrossDomain) {
|
||||
res.setHeader('Access-Control-Max-Age', maxAge.toString());
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
* for better compatibility with Unity WebGL and other game engines
|
||||
*/
|
||||
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
/**
|
||||
* Unity WebGL polyfills
|
||||
* These are minimal stubs to prevent errors when Unity games load
|
||||
@@ -106,17 +108,12 @@ export function injectPolyfills(html: Buffer | string): Buffer {
|
||||
// Add Unity polyfills if needed
|
||||
if (needsUnityPolyfills(htmlString)) {
|
||||
polyfillsToInject += UNITY_POLYFILLS;
|
||||
console.log('[HTMLInjector] Injecting Unity WebGL polyfills');
|
||||
logger.debug('[HTMLInjector] Injecting Unity WebGL polyfills');
|
||||
}
|
||||
|
||||
// Always add general polyfills for compatibility
|
||||
polyfillsToInject += GENERAL_POLYFILLS;
|
||||
|
||||
// If no polyfills needed, return original
|
||||
if (!polyfillsToInject) {
|
||||
return Buffer.from(htmlString);
|
||||
}
|
||||
|
||||
// Try to inject into <head> tag
|
||||
const headMatch = htmlString.match(/<head[^>]*>/i);
|
||||
if (headMatch) {
|
||||
@@ -1,5 +1,5 @@
|
||||
import path from 'path';
|
||||
import { logger } from './logger';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
/**
|
||||
* Path Security Utilities
|
||||
@@ -18,8 +18,8 @@ export function hasDoubleEncoding(urlPath: string): boolean {
|
||||
// %25 is the encoding for '%', so %252e = %2e, %252f = %2f, etc.
|
||||
// Double-encoding is a technique to bypass security filters that only decode once.
|
||||
const doubleEncodedPatterns = [
|
||||
/%25(?:2[eEfF]|5[cC])/gi, // %252e (%2e = .), %252f (%2f = /), %255c (%5c = \)
|
||||
/%25(?:00)/gi, // %2500 = double-encoded null byte
|
||||
/%25(?:2[eEfF]|5[cC])/i, // %252e (%2e = .), %252f (%2f = /), %255c (%5c = \)
|
||||
/%25(?:00)/i, // %2500 = double-encoded null byte
|
||||
];
|
||||
|
||||
for (const pattern of doubleEncodedPatterns) {
|
||||
@@ -88,6 +88,18 @@ export function sanitizeAndValidatePath(basePath: string, requestPath: string):
|
||||
logger.warn(`[Security] Base directory: ${resolvedBase}`);
|
||||
throw new Error('Invalid path: Directory traversal detected');
|
||||
}
|
||||
|
||||
// Verify path boundary to prevent sibling directory access
|
||||
// e.g., "htdocs-backup" should not pass when base is "htdocs"
|
||||
if (
|
||||
normalizedResolvedPath !== normalizedResolvedBase &&
|
||||
!normalizedResolvedPath.startsWith(normalizedResolvedBase + path.sep)
|
||||
) {
|
||||
logger.warn(`[Security] Sibling directory access blocked: ${requestPath}`);
|
||||
logger.warn(`[Security] Resolved to: ${resolvedPath}`);
|
||||
logger.warn(`[Security] Base directory: ${resolvedBase}`);
|
||||
throw new Error('Invalid path: Path escapes allowed directory');
|
||||
}
|
||||
} else {
|
||||
// Case-sensitive comparison for Unix-like systems
|
||||
if (!resolvedPath.startsWith(resolvedBase)) {
|
||||
@@ -96,38 +108,17 @@ export function sanitizeAndValidatePath(basePath: string, requestPath: string):
|
||||
logger.warn(`[Security] Base directory: ${resolvedBase}`);
|
||||
throw new Error('Invalid path: Directory traversal detected');
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a path is within one of multiple allowed base directories
|
||||
*
|
||||
* @param allowedBases - Array of allowed base directories
|
||||
* @param requestPath - The requested path
|
||||
* @returns The validated absolute path
|
||||
* @throws Error if the path is not within any allowed base directory
|
||||
*/
|
||||
export function validatePathInAllowedDirectories(
|
||||
allowedBases: string[],
|
||||
requestPath: string
|
||||
): string {
|
||||
// Try each base directory
|
||||
for (const basePath of allowedBases) {
|
||||
try {
|
||||
const validatedPath = sanitizeAndValidatePath(basePath, requestPath);
|
||||
return validatedPath;
|
||||
} catch (error) {
|
||||
// Path is not in this base, try next one
|
||||
continue;
|
||||
// Verify path boundary to prevent sibling directory access
|
||||
if (resolvedPath !== resolvedBase && !resolvedPath.startsWith(resolvedBase + path.sep)) {
|
||||
logger.warn(`[Security] Sibling directory access blocked: ${requestPath}`);
|
||||
logger.warn(`[Security] Resolved to: ${resolvedPath}`);
|
||||
logger.warn(`[Security] Base directory: ${resolvedBase}`);
|
||||
throw new Error('Invalid path: Path escapes allowed directory');
|
||||
}
|
||||
}
|
||||
|
||||
// Path is not in any allowed base directory
|
||||
logger.warn(`[Security] Path not in any allowed directory: ${requestPath}`);
|
||||
logger.warn(`[Security] Allowed bases: ${allowedBases.join(', ')}`);
|
||||
throw new Error('Invalid path: Not in any allowed directory');
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,10 +150,10 @@ export function sanitizeUrlPath(urlPath: string): string {
|
||||
// Note: We allow '..' because it will be handled by sanitizeAndValidatePath
|
||||
// But we check for obviously malicious patterns
|
||||
const dangerousPatterns = [
|
||||
/\.\.\\/g, // ..\ (Windows path traversal)
|
||||
/\.\.%2[fF]/g, // URL encoded ../
|
||||
/\.\.%5[cC]/g, // URL encoded ..\
|
||||
/%00/g, // URL encoded null byte
|
||||
/\.\.\\/, // ..\ (Windows path traversal)
|
||||
/\.\.%2[fF]/, // URL encoded ../
|
||||
/\.\.%5[cC]/, // URL encoded ..\
|
||||
/%00/, // URL encoded null byte
|
||||
];
|
||||
|
||||
for (const pattern of dangerousPatterns) {
|
||||
@@ -0,0 +1,61 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Validation schemas for game-service endpoints
|
||||
* Provides input validation to prevent security issues and malformed requests
|
||||
*/
|
||||
|
||||
/**
|
||||
* Schema for mount endpoint
|
||||
* POST /mount/:gameId
|
||||
*/
|
||||
export const mountGameSchema = z.object({
|
||||
gameId: z
|
||||
.string()
|
||||
.min(1, 'Game ID is required')
|
||||
.max(255, 'Game ID is too long')
|
||||
.regex(/^[a-zA-Z0-9\-_]+$/, 'Game ID contains invalid characters'),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for unmount endpoint (reuses mountGameSchema)
|
||||
* DELETE /mount/:gameId
|
||||
*/
|
||||
export const unmountGameSchema = mountGameSchema;
|
||||
|
||||
/**
|
||||
* Schema for hostname validation
|
||||
*/
|
||||
export const hostnameSchema = z
|
||||
.string()
|
||||
.min(2, 'Hostname is required') // Intentionally requires 2+ chars; single-char hostnames not supported for legacy content
|
||||
.max(253, 'Hostname is too long')
|
||||
.regex(/^[a-zA-Z0-9][a-zA-Z0-9\-_.]*[a-zA-Z0-9]$/, {
|
||||
message: 'Invalid hostname format',
|
||||
});
|
||||
|
||||
/**
|
||||
* Validate and sanitize a game ID
|
||||
*/
|
||||
export function validateGameId(gameId: unknown): string {
|
||||
const result = mountGameSchema.shape.gameId.safeParse(gameId);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(`Invalid game ID: ${result.error.issues[0].message}`);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and sanitize a hostname
|
||||
*/
|
||||
export function validateHostname(hostname: unknown): string {
|
||||
const result = hostnameSchema.safeParse(hostname);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(`Invalid hostname: ${result.error.issues[0].message}`);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
@@ -2,20 +2,25 @@ import StreamZip from 'node-stream-zip';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import { logger } from './utils/logger';
|
||||
import { logger } from '../utils/logger';
|
||||
import { sanitizeErrorMessage } from './utils/pathSecurity';
|
||||
import { MAX_BUFFERED_FILE_SIZE } from './config';
|
||||
|
||||
interface MountedZip {
|
||||
id: string;
|
||||
zipPath: string;
|
||||
zip: StreamZip.StreamZipAsync;
|
||||
mountTime: Date;
|
||||
entryIndex: Set<string>; // Set of all entry names for O(1) lookup
|
||||
}
|
||||
|
||||
export class ZipManager {
|
||||
// Use LRU cache to prevent memory leaks from unlimited ZIP mounts
|
||||
// Max 100 concurrent mounts, 30-minute TTL, auto-close on eviction
|
||||
private mountedZips: LRUCache<string, MountedZip>;
|
||||
private mountingInProgress = new Set<string>();
|
||||
private pendingCloses: Set<Promise<void>> = new Set();
|
||||
private shuttingDown = false;
|
||||
|
||||
constructor() {
|
||||
this.mountedZips = new LRUCache<string, MountedZip>({
|
||||
@@ -23,12 +28,20 @@ export class ZipManager {
|
||||
ttl: 30 * 60 * 1000, // 30-minute TTL
|
||||
dispose: async (value, key) => {
|
||||
// Automatically close ZIP when evicted from cache
|
||||
try {
|
||||
logger.info(`[ZipManager] Auto-closing evicted ZIP: ${key}`);
|
||||
await value.zip.close();
|
||||
} catch (error) {
|
||||
logger.error(`[ZipManager] Error closing evicted ZIP ${key}:`, error);
|
||||
}
|
||||
// Track the close operation to ensure cleanup completes
|
||||
const closePromise = (async () => {
|
||||
try {
|
||||
logger.info(`[ZipManager] Auto-closing evicted ZIP: ${key}`);
|
||||
await value.zip.close();
|
||||
} catch (error) {
|
||||
logger.error(`[ZipManager] Error closing evicted ZIP ${key}:`, error);
|
||||
}
|
||||
})();
|
||||
|
||||
this.pendingCloses.add(closePromise);
|
||||
closePromise.finally(() => {
|
||||
this.pendingCloses.delete(closePromise);
|
||||
});
|
||||
},
|
||||
// Update access time on get
|
||||
updateAgeOnGet: true,
|
||||
@@ -43,35 +56,63 @@ export class ZipManager {
|
||||
* @param zipPath Absolute path to the ZIP file
|
||||
*/
|
||||
async mount(id: string, zipPath: string): Promise<void> {
|
||||
// Check if shutting down
|
||||
if (this.shuttingDown) {
|
||||
throw new Error('ZipManager is shutting down');
|
||||
}
|
||||
|
||||
// Check if already mounted
|
||||
if (this.mountedZips.has(id)) {
|
||||
logger.debug(`[ZipManager] ZIP already mounted: ${id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify ZIP file exists
|
||||
try {
|
||||
await fs.access(zipPath);
|
||||
} catch (error) {
|
||||
// Don't expose full path in error message (G-H4)
|
||||
logger.error(`[ZipManager] ZIP file not found: ${zipPath}`);
|
||||
throw new Error('ZIP file not found');
|
||||
// Check if currently mounting
|
||||
if (this.mountingInProgress.has(id)) {
|
||||
logger.debug(`[ZipManager] ZIP mount already in progress: ${id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`[ZipManager] Mounting ZIP: ${id} -> ${zipPath}`);
|
||||
this.mountingInProgress.add(id);
|
||||
|
||||
// Create ZIP reader
|
||||
const zip = new StreamZip.async({ file: zipPath });
|
||||
try {
|
||||
// Verify ZIP file exists
|
||||
try {
|
||||
await fs.access(zipPath);
|
||||
} catch (error) {
|
||||
// Don't expose full path in error message (G-H4)
|
||||
logger.error(`[ZipManager] ZIP file not found: ${zipPath}`);
|
||||
throw new Error('ZIP file not found');
|
||||
}
|
||||
|
||||
// Store mounted ZIP
|
||||
this.mountedZips.set(id, {
|
||||
id,
|
||||
zipPath,
|
||||
zip,
|
||||
mountTime: new Date(),
|
||||
});
|
||||
logger.info(`[ZipManager] Mounting ZIP: ${id} -> ${zipPath}`);
|
||||
|
||||
logger.info(`[ZipManager] ✓ Mounted ZIP: ${id} (${await this.getFileCount(id)} files)`);
|
||||
// Create ZIP reader
|
||||
const zip = new StreamZip.async({ file: zipPath });
|
||||
|
||||
try {
|
||||
// Build entry index for O(1) lookup
|
||||
const entries = await zip.entries();
|
||||
const entryIndex = new Set(Object.keys(entries));
|
||||
|
||||
// Store mounted ZIP
|
||||
this.mountedZips.set(id, {
|
||||
id,
|
||||
zipPath,
|
||||
zip,
|
||||
mountTime: new Date(),
|
||||
entryIndex,
|
||||
});
|
||||
|
||||
logger.info(`[ZipManager] ✓ Mounted ZIP: ${id} (${entryIndex.size} files)`);
|
||||
} catch (innerError) {
|
||||
// Close the ZIP handle if mounting fails
|
||||
await zip.close().catch(() => {});
|
||||
throw innerError;
|
||||
}
|
||||
} finally {
|
||||
this.mountingInProgress.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,7 +120,7 @@ export class ZipManager {
|
||||
* @param id Unique identifier of the mounted ZIP
|
||||
*/
|
||||
async unmount(id: string): Promise<boolean> {
|
||||
const mounted = this.mountedZips.get(id);
|
||||
const mounted = this.mountedZips.peek(id);
|
||||
if (!mounted) {
|
||||
logger.warn(`[ZipManager] ZIP not mounted: ${id}`);
|
||||
return false;
|
||||
@@ -114,6 +155,22 @@ export class ZipManager {
|
||||
// Normalize path (remove leading slash)
|
||||
const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath;
|
||||
|
||||
// Check index before reading file (O(1) vs reading entire file)
|
||||
if (!mounted.entryIndex.has(normalizedPath)) {
|
||||
logger.debug(`[ZipManager] File not in index ${id}: ${normalizedPath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check file size before buffering
|
||||
const entries = await mounted.zip.entries();
|
||||
const entry = entries[normalizedPath];
|
||||
if (entry && entry.size > MAX_BUFFERED_FILE_SIZE) {
|
||||
logger.warn(
|
||||
`[ZipManager] File too large to buffer: ${normalizedPath} (${entry.size} bytes)`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.debug(`[ZipManager] Reading from ZIP ${id}: ${normalizedPath}`);
|
||||
|
||||
// Get file from ZIP
|
||||
@@ -146,10 +203,38 @@ export class ZipManager {
|
||||
// Search all mounted ZIPs
|
||||
for (const [id, mounted] of this.mountedZips) {
|
||||
for (const pathVariant of pathsToTry) {
|
||||
const data = await this.getFile(id, pathVariant);
|
||||
if (data) {
|
||||
// Normalize path (remove leading slash)
|
||||
const normalizedPath = pathVariant.startsWith('/') ? pathVariant.substring(1) : pathVariant;
|
||||
|
||||
// Check index first (O(1))
|
||||
if (!mounted.entryIndex.has(normalizedPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get entries to check if it's a directory and check size
|
||||
const entries = await mounted.zip.entries();
|
||||
const entry = entries[normalizedPath];
|
||||
if (!entry || entry.isDirectory) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check size before reading
|
||||
if (entry.size > MAX_BUFFERED_FILE_SIZE) {
|
||||
logger.warn(
|
||||
`[ZipManager] File too large to buffer: ${normalizedPath} (${entry.size} bytes)`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read entry data
|
||||
try {
|
||||
const data = await mounted.zip.entryData(normalizedPath);
|
||||
logger.info(`[ZipManager] ✓ Found in ${id}: ${pathVariant}`);
|
||||
return { data, mountId: id };
|
||||
} catch (error) {
|
||||
// Entry read failed, continue to next variant
|
||||
logger.debug(`[ZipManager] Failed to read entry ${normalizedPath}: ${error}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,28 +254,13 @@ export class ZipManager {
|
||||
id,
|
||||
zipPath: mounted.zipPath,
|
||||
mountTime: mounted.mountTime,
|
||||
fileCount: 0, // Will be populated async
|
||||
fileCount: mounted.entryIndex.size, // O(1) from index
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of files in a mounted ZIP
|
||||
*/
|
||||
private async getFileCount(id: string): Promise<number> {
|
||||
const mounted = this.mountedZips.get(id);
|
||||
if (!mounted) return 0;
|
||||
|
||||
try {
|
||||
const entries = await mounted.zip.entries();
|
||||
return Object.keys(entries).length;
|
||||
} catch (error) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List files in a mounted ZIP (for debugging)
|
||||
*/
|
||||
@@ -203,8 +273,9 @@ export class ZipManager {
|
||||
let files = Object.keys(entries);
|
||||
|
||||
if (pattern) {
|
||||
const regex = new RegExp(pattern, 'i');
|
||||
files = files.filter((f) => regex.test(f));
|
||||
// Use simple substring matching instead of regex to prevent ReDoS
|
||||
const lowerPattern = pattern.toLowerCase();
|
||||
files = files.filter((f) => f.toLowerCase().includes(lowerPattern));
|
||||
}
|
||||
|
||||
return files;
|
||||
@@ -228,11 +299,28 @@ export class ZipManager {
|
||||
* Unmount all ZIPs (cleanup on shutdown)
|
||||
*/
|
||||
async unmountAll(): Promise<void> {
|
||||
this.shuttingDown = true;
|
||||
|
||||
// Wait for in-progress mounts to complete (with timeout)
|
||||
const maxWait = 10000; // 10 seconds
|
||||
const start = Date.now();
|
||||
while (this.mountingInProgress.size > 0 && Date.now() - start < maxWait) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
logger.info(`[ZipManager] Unmounting all ZIPs (${this.mountedZips.size})...`);
|
||||
|
||||
const promises = Array.from(this.mountedZips.keys()).map((id) => this.unmount(id));
|
||||
await Promise.all(promises);
|
||||
|
||||
// Wait for all pending close operations from LRU cache disposal
|
||||
if (this.pendingCloses.size > 0) {
|
||||
logger.info(
|
||||
`[ZipManager] Waiting for ${this.pendingCloses.size} pending close operations...`
|
||||
);
|
||||
await Promise.all(Array.from(this.pendingCloses));
|
||||
}
|
||||
|
||||
logger.info(`[ZipManager] ✓ All ZIPs unmounted`);
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,8 @@ declare global {
|
||||
* Authenticate user from JWT token
|
||||
* Requires valid token in Authorization header
|
||||
*/
|
||||
export const authenticate = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
export const authenticate = asyncHandler(
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
// Get token from Authorization header
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
@@ -38,17 +38,15 @@ export const authenticate = async (req: Request, res: Response, next: NextFuncti
|
||||
req.user = user;
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
);
|
||||
|
||||
/**
|
||||
* Optional authentication - allows guest access if enabled
|
||||
* If token is provided, verifies it. Otherwise, sets guest user (if guest access enabled)
|
||||
*/
|
||||
export const optionalAuth = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
export const optionalAuth = asyncHandler(
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
@@ -62,7 +60,8 @@ export const optionalAuth = async (req: Request, res: Response, next: NextFuncti
|
||||
throw new AppError(401, 'Authentication required', true, 'AUTH_REQUIRED');
|
||||
}
|
||||
|
||||
// Set guest user with read-only permissions
|
||||
// Guest users get no permissions - they can only browse, not play
|
||||
// (authentication is required for games.play permission)
|
||||
req.user = {
|
||||
id: 0,
|
||||
username: 'guest',
|
||||
@@ -73,10 +72,8 @@ export const optionalAuth = async (req: Request, res: Response, next: NextFuncti
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
);
|
||||
|
||||
/**
|
||||
* Soft authentication - populates req.user if token exists, otherwise leaves undefined
|
||||
@@ -139,7 +136,15 @@ export const sharedAccessAuth = asyncHandler(
|
||||
);
|
||||
}
|
||||
|
||||
// Grant games.play permission for valid shared access tokens
|
||||
req.sharedAccess = payload;
|
||||
req.user = {
|
||||
id: 0,
|
||||
username: 'shared-guest',
|
||||
email: '',
|
||||
role: 'guest',
|
||||
permissions: ['games.read', 'playlists.read', 'games.play'],
|
||||
};
|
||||
return next();
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
@@ -161,13 +166,14 @@ export const sharedAccessAuth = asyncHandler(
|
||||
throw new AppError(401, 'Authentication required', true, 'AUTH_REQUIRED');
|
||||
}
|
||||
|
||||
// Set guest user
|
||||
// Guest users without a shared access token can only browse, not play
|
||||
// (games.play is only granted when a valid shared access token is verified above)
|
||||
req.user = {
|
||||
id: 0,
|
||||
username: 'guest',
|
||||
email: '',
|
||||
role: 'guest',
|
||||
permissions: ['games.read', 'playlists.read', 'games.play'],
|
||||
permissions: ['games.read', 'playlists.read'],
|
||||
};
|
||||
|
||||
next();
|
||||
@@ -180,8 +186,8 @@ export const sharedAccessAuth = asyncHandler(
|
||||
*/
|
||||
export const validateSharedGameAccess = (gameIdParam: string = 'id') => {
|
||||
return asyncHandler(async (req: Request, res: Response, next: NextFunction) => {
|
||||
// If regular user auth, skip validation
|
||||
if (req.user) {
|
||||
// If regular user auth (non-zero ID), skip validation
|
||||
if (req.user && req.user.id !== 0) {
|
||||
return next();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { ZodError } from 'zod';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export class AppError extends Error {
|
||||
@@ -43,7 +44,7 @@ function sanitizeBody(body: any): any {
|
||||
}
|
||||
|
||||
export function errorHandler(
|
||||
err: Error | AppError,
|
||||
err: Error | AppError | ZodError,
|
||||
req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction
|
||||
@@ -65,6 +66,22 @@ export function errorHandler(
|
||||
});
|
||||
}
|
||||
|
||||
// Handle Zod validation errors
|
||||
if (err instanceof ZodError) {
|
||||
logger.error(`[ValidationError] Zod validation failed`, {
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
errors: err.errors,
|
||||
});
|
||||
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: `Validation error: ${err.errors.map((e) => e.message).join(', ')}`,
|
||||
statusCode: 400,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Unhandled errors - Log full details with sanitization
|
||||
logger.error(`[UnhandledError] ${err.message}`, {
|
||||
name: err.name,
|
||||
|
||||
@@ -40,7 +40,7 @@ function normalizePath(path: string): string {
|
||||
.replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '/:id')
|
||||
// Replace numeric IDs
|
||||
.replace(/\/\d+/g, '/:id')
|
||||
// Replace alphanumeric IDs (game IDs, share tokens)
|
||||
.replace(/\/[a-zA-Z0-9_-]{8,}/g, '/:id')
|
||||
// Replace hex IDs (like game data IDs) - must contain at least one digit
|
||||
.replace(/\/[a-f0-9]{8,}/gi, '/:id')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export interface ServerSettings {
|
||||
// Proxy server port (default: 22500)
|
||||
proxyPort: number;
|
||||
|
||||
// GameZip server port (default: 22501)
|
||||
gameZipPort: number;
|
||||
|
||||
// Legacy paths
|
||||
legacyHTDOCSPath: string;
|
||||
legacyCGIBINPath: string;
|
||||
|
||||
// Override paths for file resolution
|
||||
legacyOverridePaths: string[];
|
||||
|
||||
// External fallback URLs
|
||||
infinityServerURL?: string;
|
||||
externalFilePaths: string[];
|
||||
|
||||
// MAD4FP mode settings
|
||||
mad4fpEnabled: boolean;
|
||||
mad4fpPaths: string[];
|
||||
|
||||
// Performance settings
|
||||
chunkSize: number;
|
||||
maxConcurrentDownloads: number;
|
||||
|
||||
// Feature flags
|
||||
allowCrossDomain: boolean;
|
||||
enableBrotli: boolean;
|
||||
enableCGI: boolean;
|
||||
enableHtaccess: boolean;
|
||||
}
|
||||
|
||||
export class ConfigManager {
|
||||
private static settings: ServerSettings | null = null;
|
||||
|
||||
/**
|
||||
* Load configuration from proxySettings.json and environment
|
||||
*/
|
||||
static async loadConfig(flashpointPath: string): Promise<ServerSettings> {
|
||||
try {
|
||||
const proxySettingsPath = path.join(flashpointPath, 'Server', 'proxySettings.json');
|
||||
const data = await fs.readFile(proxySettingsPath, 'utf-8');
|
||||
const proxySettings = JSON.parse(data);
|
||||
|
||||
// Build settings from proxySettings.json and defaults
|
||||
this.settings = {
|
||||
proxyPort: parseInt(process.env.PROXY_PORT || '22500'),
|
||||
gameZipPort: parseInt(process.env.GAMEZIPSERVER_PORT || '22501'),
|
||||
|
||||
legacyHTDOCSPath: path.join(flashpointPath, 'Legacy', 'htdocs'),
|
||||
legacyCGIBINPath: path.join(flashpointPath, 'Legacy', 'cgi-bin'),
|
||||
|
||||
legacyOverridePaths: proxySettings.legacyOverridePaths || [],
|
||||
|
||||
// Use HTTPS for external sources (servers redirect HTTP to HTTPS anyway)
|
||||
infinityServerURL: proxySettings.infinityServerURL
|
||||
? proxySettings.infinityServerURL.replace(/^http:\/\//, 'https://')
|
||||
: 'https://infinity.flashpointarchive.org/Flashpoint/Legacy/htdocs/',
|
||||
|
||||
externalFilePaths: (proxySettings.externalFilePaths || []).map((url: string) =>
|
||||
url.replace(/^http:\/\//, 'https://')
|
||||
),
|
||||
|
||||
mad4fpEnabled: proxySettings.mad4fpEnabled || false,
|
||||
mad4fpPaths: proxySettings.mad4fpPaths || [],
|
||||
|
||||
chunkSize: 64 * 1024, // 64KB chunks for streaming
|
||||
maxConcurrentDownloads: 10,
|
||||
|
||||
allowCrossDomain: true,
|
||||
enableBrotli: true,
|
||||
enableCGI: process.env.ENABLE_CGI === 'true',
|
||||
enableHtaccess: process.env.ENABLE_HTACCESS === 'true',
|
||||
};
|
||||
|
||||
logger.info('[ProxyConfig] Configuration loaded successfully');
|
||||
logger.info(`[ProxyConfig] Proxy port: ${this.settings.proxyPort}`);
|
||||
logger.info(`[ProxyConfig] GameZip port: ${this.settings.gameZipPort}`);
|
||||
logger.info(`[ProxyConfig] HTDOCS path: ${this.settings.legacyHTDOCSPath}`);
|
||||
logger.info(`[ProxyConfig] Override paths: ${this.settings.legacyOverridePaths.length}`);
|
||||
logger.info(`[ProxyConfig] External sources: ${this.settings.externalFilePaths.length}`);
|
||||
|
||||
return this.settings;
|
||||
} catch (error) {
|
||||
logger.error('[ProxyConfig] Failed to load proxySettings.json, using defaults', error);
|
||||
|
||||
// Fallback to defaults
|
||||
this.settings = {
|
||||
proxyPort: 22500,
|
||||
gameZipPort: 22501,
|
||||
legacyHTDOCSPath: path.join(flashpointPath, 'Legacy', 'htdocs'),
|
||||
legacyCGIBINPath: path.join(flashpointPath, 'Legacy', 'cgi-bin'),
|
||||
legacyOverridePaths: [],
|
||||
infinityServerURL: 'https://infinity.flashpointarchive.org/Flashpoint/Legacy/htdocs/',
|
||||
externalFilePaths: [
|
||||
'https://infinity.flashpointarchive.org/Flashpoint/Legacy/htdocs',
|
||||
'https://infinity.unstable.life/Flashpoint/Legacy/htdocs/',
|
||||
],
|
||||
mad4fpEnabled: false,
|
||||
mad4fpPaths: [],
|
||||
chunkSize: 64 * 1024,
|
||||
maxConcurrentDownloads: 10,
|
||||
allowCrossDomain: true,
|
||||
enableBrotli: true,
|
||||
enableCGI: false,
|
||||
enableHtaccess: false,
|
||||
};
|
||||
|
||||
return this.settings;
|
||||
}
|
||||
}
|
||||
|
||||
static getSettings(): ServerSettings {
|
||||
if (!this.settings) {
|
||||
throw new Error('Configuration not loaded. Call loadConfig() first.');
|
||||
}
|
||||
return this.settings;
|
||||
}
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
import http from 'http';
|
||||
import { URL } from 'url';
|
||||
import { lookup as mimeLookup } from 'mime-types';
|
||||
import path from 'path';
|
||||
import { logger } from '../utils/logger';
|
||||
import { zipManager } from './zip-manager';
|
||||
import { ConfigManager } from './config';
|
||||
|
||||
/**
|
||||
* GameZip Server - Serves files from mounted ZIP archives
|
||||
* Replicates the GameZip server from FlashpointGameServer (port 22501)
|
||||
*/
|
||||
export class GameZipServer {
|
||||
private server: http.Server | null = null;
|
||||
private settings = ConfigManager.getSettings();
|
||||
|
||||
/**
|
||||
* Start the GameZip server
|
||||
*/
|
||||
async start(): Promise<http.Server> {
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
await this.handleRequest(req, res);
|
||||
} catch (error) {
|
||||
logger.error('[GameZipServer] Unhandled error:', error);
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(500);
|
||||
res.end('Internal Server Error');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Configure server settings
|
||||
server.timeout = 120000; // 2 minutes timeout
|
||||
server.keepAliveTimeout = 65000; // 65 seconds keep-alive
|
||||
|
||||
// Start listening
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.listen(this.settings.gameZipPort, () => {
|
||||
logger.info(`[GameZipServer] ========================================`);
|
||||
logger.info(`[GameZipServer] GameZip Server started`);
|
||||
logger.info(`[GameZipServer] Port: ${this.settings.gameZipPort}`);
|
||||
logger.info(`[GameZipServer] ========================================`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
server.on('error', (error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'EADDRINUSE') {
|
||||
logger.error(`[GameZipServer] Port ${this.settings.gameZipPort} is already in use`);
|
||||
} else {
|
||||
logger.error('[GameZipServer] Failed to start:', error);
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
this.server = server;
|
||||
return server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming HTTP request
|
||||
*/
|
||||
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
const method = req.method;
|
||||
const url = req.url || '/';
|
||||
|
||||
logger.info(`[GameZipServer] ${method} ${url}`);
|
||||
|
||||
// Handle different request types
|
||||
if (method === 'OPTIONS') {
|
||||
this.handleOptionsRequest(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'POST' && url.startsWith('/mount/')) {
|
||||
await this.handleMount(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'DELETE' && url.startsWith('/mount/')) {
|
||||
await this.handleUnmount(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'GET' && url === '/mounts') {
|
||||
this.handleListMounts(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'GET' || method === 'HEAD') {
|
||||
await this.handleFileRequest(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
// Unknown request
|
||||
this.sendError(res, 404, 'Not Found');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle CORS preflight
|
||||
*/
|
||||
private handleOptionsRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
if (this.settings.allowCrossDomain) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', '*');
|
||||
res.setHeader('Access-Control-Max-Age', '86400');
|
||||
}
|
||||
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle POST /mount/:id - Mount a ZIP file
|
||||
* Body: { zipPath: string }
|
||||
*/
|
||||
private async handleMount(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
// Extract ID from URL
|
||||
const id = req.url?.split('/mount/')[1];
|
||||
if (!id) {
|
||||
this.sendError(res, 400, 'Missing mount ID');
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
const body = await this.readBody(req);
|
||||
let zipPath: string;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
zipPath = data.zipPath;
|
||||
|
||||
if (!zipPath) {
|
||||
this.sendError(res, 400, 'Missing zipPath in request body');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
this.sendError(res, 400, 'Invalid JSON');
|
||||
return;
|
||||
}
|
||||
|
||||
// Mount the ZIP
|
||||
try {
|
||||
await zipManager.mount(id, zipPath);
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.writeHead(200);
|
||||
res.end(JSON.stringify({ success: true, id, zipPath }));
|
||||
} catch (error) {
|
||||
logger.error(`[GameZipServer] Mount failed:`, error);
|
||||
this.sendError(res, 500, error instanceof Error ? error.message : 'Mount failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle DELETE /mount/:id - Unmount a ZIP file
|
||||
*/
|
||||
private async handleUnmount(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
const id = req.url?.split('/mount/')[1];
|
||||
if (!id) {
|
||||
this.sendError(res, 400, 'Missing mount ID');
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await zipManager.unmount(id);
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.writeHead(success ? 200 : 404);
|
||||
res.end(JSON.stringify({ success, id }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle GET /mounts - List all mounted ZIPs
|
||||
*/
|
||||
private handleListMounts(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
const mounts = zipManager.getMountedZips();
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.writeHead(200);
|
||||
res.end(JSON.stringify({ mounts }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle GET /* - Serve file from mounted ZIPs
|
||||
* Supports proxy-style requests: GET http://domain.com/path HTTP/1.1
|
||||
*/
|
||||
private async handleFileRequest(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse
|
||||
): Promise<void> {
|
||||
if (!req.url) {
|
||||
this.sendError(res, 400, 'Bad Request: No URL');
|
||||
return;
|
||||
}
|
||||
|
||||
let hostname: string;
|
||||
let urlPath: string;
|
||||
|
||||
// Parse URL (same logic as main proxy server)
|
||||
if (req.url.startsWith('http://') || req.url.startsWith('https://')) {
|
||||
// Proxy-style: GET http://domain.com/path HTTP/1.1
|
||||
const targetUrl = new URL(req.url);
|
||||
hostname = targetUrl.hostname;
|
||||
urlPath = targetUrl.pathname + targetUrl.search;
|
||||
} else if (req.url.startsWith('/http://') || req.url.startsWith('/https://')) {
|
||||
// Path-based: GET /http://domain.com/path HTTP/1.1
|
||||
const urlWithoutSlash = req.url.substring(1);
|
||||
const targetUrl = new URL(urlWithoutSlash);
|
||||
hostname = targetUrl.hostname;
|
||||
urlPath = targetUrl.pathname + targetUrl.search;
|
||||
} else {
|
||||
// Regular: GET /path HTTP/1.1
|
||||
hostname = req.headers.host || 'localhost';
|
||||
urlPath = req.url;
|
||||
}
|
||||
|
||||
// Build relative path
|
||||
const relPath = path.posix.join(hostname, urlPath);
|
||||
|
||||
logger.info(`[GameZipServer] Looking for: ${relPath}`);
|
||||
|
||||
// Search mounted ZIPs
|
||||
const result = await zipManager.findFile(relPath);
|
||||
|
||||
if (!result) {
|
||||
logger.debug(`[GameZipServer] File not found in any mounted ZIP: ${relPath}`);
|
||||
this.sendError(res, 404, 'File not found in mounted ZIPs');
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine content type
|
||||
const ext = path.extname(urlPath).toLowerCase();
|
||||
const contentType = mimeLookup(ext) || 'application/octet-stream';
|
||||
|
||||
// Send file
|
||||
logger.info(
|
||||
`[GameZipServer] ✓ Serving from ZIP ${result.mountId}: ${relPath} (${result.data.length} bytes)`
|
||||
);
|
||||
|
||||
// CORS headers
|
||||
if (this.settings.allowCrossDomain) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', '*');
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Content-Length', result.data.length);
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400'); // Cache for 24 hours
|
||||
res.setHeader('X-Source', `gamezipserver:${result.mountId}`);
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
|
||||
res.writeHead(200);
|
||||
res.end(result.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read request body
|
||||
*/
|
||||
private readBody(req: http.IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
req.on('end', () => {
|
||||
resolve(body);
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send error response
|
||||
*/
|
||||
private sendError(res: http.ServerResponse, statusCode: number, message: string): void {
|
||||
logger.warn(`[GameZipServer] Sending error ${statusCode}: ${message}`);
|
||||
|
||||
// Set CORS headers even for error responses
|
||||
if (this.settings.allowCrossDomain) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', '*');
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.writeHead(statusCode);
|
||||
res.end(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the server
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
if (this.server) {
|
||||
logger.info('[GameZipServer] Stopping server...');
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.server!.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
|
||||
logger.info('[GameZipServer] Server stopped');
|
||||
}
|
||||
|
||||
// Unmount all ZIPs
|
||||
await zipManager.unmountAll();
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import http from 'http';
|
||||
import { logger } from '../utils/logger';
|
||||
import { ConfigManager } from './config';
|
||||
import { ProxyRequestHandler } from './proxy-request-handler';
|
||||
|
||||
export interface ProxyServerOptions {
|
||||
proxyPort?: number;
|
||||
legacyHTDOCSPath: string;
|
||||
gameDataPath?: string;
|
||||
externalFilePaths?: string[];
|
||||
allowCrossDomain?: boolean;
|
||||
chunkSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and start the Flashpoint Proxy Server
|
||||
* This is the HTTP proxy implementation that replicates FlashpointGameServer
|
||||
*/
|
||||
export async function createHTTPProxyServer(options: ProxyServerOptions): Promise<http.Server> {
|
||||
// Load configuration
|
||||
const flashpointPath = options.legacyHTDOCSPath.replace(/[\\/]Legacy[\\/]htdocs.*$/, '');
|
||||
await ConfigManager.loadConfig(flashpointPath);
|
||||
|
||||
const settings = ConfigManager.getSettings();
|
||||
|
||||
// Override settings from options if provided
|
||||
if (options.proxyPort) {
|
||||
settings.proxyPort = options.proxyPort;
|
||||
}
|
||||
if (options.allowCrossDomain !== undefined) {
|
||||
settings.allowCrossDomain = options.allowCrossDomain;
|
||||
}
|
||||
if (options.chunkSize) {
|
||||
settings.chunkSize = options.chunkSize;
|
||||
}
|
||||
|
||||
// Create request handler
|
||||
const requestHandler = new ProxyRequestHandler();
|
||||
|
||||
// Create HTTP server
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
// Handle CORS preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
requestHandler.handleOptionsRequest(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle proxy request
|
||||
await requestHandler.handleRequest(req, res);
|
||||
} catch (error) {
|
||||
logger.error('[HTTPProxyServer] Unhandled error:', error);
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(500);
|
||||
res.end('Internal Server Error');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Configure server settings
|
||||
server.timeout = 120000; // 2 minutes timeout
|
||||
server.keepAliveTimeout = 65000; // 65 seconds keep-alive
|
||||
|
||||
// Start listening
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.listen(settings.proxyPort, () => {
|
||||
logger.info(`[HTTPProxyServer] ========================================`);
|
||||
logger.info(`[HTTPProxyServer] Flashpoint Proxy Server started`);
|
||||
logger.info(`[HTTPProxyServer] Port: ${settings.proxyPort}`);
|
||||
logger.info(`[HTTPProxyServer] HTDOCS: ${settings.legacyHTDOCSPath}`);
|
||||
logger.info(`[HTTPProxyServer] CGI-BIN: ${settings.legacyCGIBINPath}`);
|
||||
logger.info(`[HTTPProxyServer] External sources: ${settings.externalFilePaths.length}`);
|
||||
logger.info(`[HTTPProxyServer] ========================================`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
server.on('error', (error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'EADDRINUSE') {
|
||||
logger.error(`[HTTPProxyServer] Port ${settings.proxyPort} is already in use`);
|
||||
logger.error('[HTTPProxyServer] Please check if another instance is running');
|
||||
} else {
|
||||
logger.error('[HTTPProxyServer] Failed to start:', error);
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and start the GameZip server
|
||||
* Serves files from mounted ZIP archives (port 22501)
|
||||
*/
|
||||
export async function createGameZipServer(): Promise<http.Server> {
|
||||
const { GameZipServer } = await import('./gamezipserver');
|
||||
const gameZipServer = new GameZipServer();
|
||||
return await gameZipServer.start();
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { lookup as mimeLookup } from 'mime-types';
|
||||
import { logger } from '../utils/logger';
|
||||
import { ConfigManager } from './config';
|
||||
import axios from 'axios';
|
||||
import zlib from 'zlib';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const brotliDecompress = promisify(zlib.brotliDecompress);
|
||||
|
||||
// Script file extensions
|
||||
const SCRIPT_EXTENSIONS = ['php', 'php5', 'phtml', 'pl'];
|
||||
|
||||
// Index file extensions (in priority order)
|
||||
const INDEX_EXTENSIONS = ['html', 'htm', 'php', 'swf'];
|
||||
|
||||
export interface LegacyFileResponse {
|
||||
data: Buffer;
|
||||
contentType: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export class LegacyServer {
|
||||
private settings = ConfigManager.getSettings();
|
||||
|
||||
/**
|
||||
* Main file serving method - replicates ServeLegacy() from Go implementation
|
||||
* @param hostname The hostname from the request (e.g., "www.example.com")
|
||||
* @param urlPath The path from the request (e.g., "/path/file.swf")
|
||||
*/
|
||||
async serveLegacy(hostname: string, urlPath: string): Promise<LegacyFileResponse> {
|
||||
// Strip port from hostname
|
||||
const host = hostname.split(':')[0];
|
||||
|
||||
// Build relative path: {host}/{path}
|
||||
const relPath = path.posix.join(host, urlPath);
|
||||
|
||||
logger.info(`[LegacyServer] Serving: ${host}${urlPath}`);
|
||||
logger.info(`[LegacyServer] Relative path: ${relPath}`);
|
||||
|
||||
// Step 1: Build all path candidates
|
||||
const pathCandidates = this.buildPathCandidates(relPath, urlPath);
|
||||
|
||||
// Step 2: Try each candidate in order
|
||||
for (const candidate of pathCandidates) {
|
||||
logger.debug(`[LegacyServer] Trying: ${candidate.path} (${candidate.type})`);
|
||||
|
||||
try {
|
||||
const stats = await fs.stat(candidate.path);
|
||||
|
||||
if (stats.isFile()) {
|
||||
logger.info(`[LegacyServer] ✓ Found file: ${candidate.path}`);
|
||||
return await this.serveFile(candidate.path, candidate.type === 'cgi-bin');
|
||||
}
|
||||
} catch (error) {
|
||||
// File doesn't exist, continue to next candidate
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: No local file found, try external sources
|
||||
logger.info(`[LegacyServer] File not found locally, trying external sources...`);
|
||||
return await this.tryExternalSources(relPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build all path candidates in priority order
|
||||
* Replicates the path resolution algorithm from Go's ServeLegacy()
|
||||
*/
|
||||
private buildPathCandidates(
|
||||
relPath: string,
|
||||
urlPath: string
|
||||
): Array<{ path: string; type: string }> {
|
||||
const candidates: Array<{ path: string; type: string }> = [];
|
||||
|
||||
// 1. Exact file paths (with and without query string)
|
||||
candidates.push({
|
||||
path: path.join(this.settings.legacyHTDOCSPath, relPath),
|
||||
type: 'exact',
|
||||
});
|
||||
|
||||
// If URL has query string, try without it
|
||||
const pathWithoutQuery = relPath.split('?')[0];
|
||||
if (pathWithoutQuery !== relPath) {
|
||||
candidates.push({
|
||||
path: path.join(this.settings.legacyHTDOCSPath, pathWithoutQuery),
|
||||
type: 'exact-no-query',
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Override paths (exact files in override directories)
|
||||
for (const override of this.settings.legacyOverridePaths) {
|
||||
candidates.push({
|
||||
path: path.join(this.settings.legacyHTDOCSPath, override, relPath),
|
||||
type: `override:${override}`,
|
||||
});
|
||||
|
||||
if (pathWithoutQuery !== relPath) {
|
||||
candidates.push({
|
||||
path: path.join(this.settings.legacyHTDOCSPath, override, pathWithoutQuery),
|
||||
type: `override:${override}-no-query`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. CGI-BIN paths (for script files)
|
||||
if (this.isScriptUrl(urlPath)) {
|
||||
candidates.push({
|
||||
path: path.join(this.settings.legacyCGIBINPath, relPath),
|
||||
type: 'cgi-bin',
|
||||
});
|
||||
|
||||
if (pathWithoutQuery !== relPath) {
|
||||
candidates.push({
|
||||
path: path.join(this.settings.legacyCGIBINPath, pathWithoutQuery),
|
||||
type: 'cgi-bin-no-query',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Index files (for directory requests)
|
||||
for (const ext of INDEX_EXTENSIONS) {
|
||||
candidates.push({
|
||||
path: path.join(this.settings.legacyHTDOCSPath, relPath, `index.${ext}`),
|
||||
type: `index:${ext}`,
|
||||
});
|
||||
|
||||
// Index files in override paths
|
||||
for (const override of this.settings.legacyOverridePaths) {
|
||||
candidates.push({
|
||||
path: path.join(this.settings.legacyHTDOCSPath, override, relPath, `index.${ext}`),
|
||||
type: `index-override:${override}:${ext}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if URL is a script file
|
||||
*/
|
||||
private isScriptUrl(urlPath: string): boolean {
|
||||
const ext = path.extname(urlPath).toLowerCase().substring(1);
|
||||
return SCRIPT_EXTENSIONS.includes(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve a file from disk
|
||||
* @param filePath Absolute path to file
|
||||
* @param isCGI Whether to execute as CGI script
|
||||
*/
|
||||
private async serveFile(filePath: string, isCGI: boolean = false): Promise<LegacyFileResponse> {
|
||||
// Read file
|
||||
let data = await fs.readFile(filePath);
|
||||
|
||||
// Handle Brotli compression
|
||||
if (this.settings.enableBrotli && filePath.endsWith('.br')) {
|
||||
logger.debug(`[LegacyServer] Decompressing Brotli file: ${filePath}`);
|
||||
data = await brotliDecompress(data);
|
||||
filePath = filePath.substring(0, filePath.length - 3); // Remove .br extension
|
||||
}
|
||||
|
||||
// Determine content type
|
||||
const contentType = mimeLookup(filePath) || 'application/octet-stream';
|
||||
|
||||
// TODO: Execute CGI scripts if enabled
|
||||
if (isCGI && this.settings.enableCGI) {
|
||||
logger.warn('[LegacyServer] CGI execution not yet implemented');
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
contentType,
|
||||
source: 'local-htdocs',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Try external sources (Infinity Server, MAD4FP, etc.)
|
||||
*/
|
||||
private async tryExternalSources(relPath: string): Promise<LegacyFileResponse> {
|
||||
// Try Infinity Server first
|
||||
if (this.settings.infinityServerURL) {
|
||||
logger.info(`[LegacyServer] Trying Infinity Server...`);
|
||||
const result = await this.tryExternalSource(this.settings.infinityServerURL, relPath);
|
||||
if (result) {
|
||||
return { ...result, source: 'infinity-server' };
|
||||
}
|
||||
}
|
||||
|
||||
// Try other external paths
|
||||
for (const externalPath of this.settings.externalFilePaths) {
|
||||
logger.info(`[LegacyServer] Trying: ${externalPath}`);
|
||||
const result = await this.tryExternalSource(externalPath, relPath);
|
||||
if (result) {
|
||||
return { ...result, source: externalPath };
|
||||
}
|
||||
}
|
||||
|
||||
// Try MAD4FP if enabled
|
||||
if (this.settings.mad4fpEnabled) {
|
||||
for (const mad4fpPath of this.settings.mad4fpPaths) {
|
||||
logger.info(`[LegacyServer] Trying MAD4FP: ${mad4fpPath}`);
|
||||
const result = await this.tryExternalSource(mad4fpPath, relPath);
|
||||
if (result) {
|
||||
return { ...result, source: `mad4fp:${mad4fpPath}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('File not found in any source');
|
||||
}
|
||||
|
||||
/**
|
||||
* Download from external source
|
||||
*/
|
||||
private async tryExternalSource(
|
||||
baseUrl: string,
|
||||
relPath: string
|
||||
): Promise<{ data: Buffer; contentType: string } | null> {
|
||||
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
||||
const fullUrl = `${normalizedBase}${relPath}`;
|
||||
|
||||
logger.info(`[LegacyServer] Downloading: ${fullUrl}`);
|
||||
|
||||
try {
|
||||
const response = await axios.get(fullUrl, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 45000,
|
||||
maxRedirects: 5,
|
||||
validateStatus: (status) => status === 200,
|
||||
headers: {
|
||||
'User-Agent': 'Flashpoint-Proxy/1.0',
|
||||
},
|
||||
});
|
||||
|
||||
const data = Buffer.from(response.data);
|
||||
const contentType = response.headers['content-type'] || 'application/octet-stream';
|
||||
|
||||
logger.info(`[LegacyServer] ✓ Downloaded: ${data.length} bytes (${contentType})`);
|
||||
|
||||
return { data, contentType };
|
||||
} catch (error) {
|
||||
// Log but don't throw - allow trying next source
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (error.response?.status === 404) {
|
||||
logger.debug(`[LegacyServer] ✗ Not found (404)`);
|
||||
} else if (error.response?.status === 403) {
|
||||
logger.warn(`[LegacyServer] ✗ Forbidden (403)`);
|
||||
} else if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
|
||||
logger.warn(`[LegacyServer] ✗ Timeout`);
|
||||
} else {
|
||||
logger.warn(`[LegacyServer] ✗ Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
/**
|
||||
* MIME type mappings from Flashpoint proxySettings.json
|
||||
* Includes 199 custom MIME types for legacy web content
|
||||
*/
|
||||
|
||||
export const CUSTOM_MIME_TYPES: { [key: string]: string } = {
|
||||
// Legacy multimedia formats
|
||||
aab: 'application/x-authorware-bin',
|
||||
aam: 'application/x-authorware-map',
|
||||
aas: 'application/x-authorware-seg',
|
||||
afl: 'video/animaflex',
|
||||
aif: 'audio/aiff',
|
||||
aifc: 'audio/aiff',
|
||||
aiff: 'audio/aiff',
|
||||
asd: 'application/astound',
|
||||
asmx: 'text/xml',
|
||||
asn: 'application/astound',
|
||||
au: 'audio/basic',
|
||||
aut: 'application/pbautomation',
|
||||
aw3: 'application/x-awingsoft-winds3d',
|
||||
axs: 'application/x-MindAvenueAXELStream',
|
||||
|
||||
// 3D and modeling formats
|
||||
blend: 'application/x-burster',
|
||||
blendz: 'application/x-burster',
|
||||
blz: 'video/blz',
|
||||
bswrl: 'application/x-bscontact',
|
||||
bub: 'application/photobubble',
|
||||
bxwrl: 'application/x-blaxxuncc3d',
|
||||
|
||||
// Director and Shockwave
|
||||
ccn: 'application/x-cnc',
|
||||
cct: 'application/x-director',
|
||||
cnc: 'application/x-cnc',
|
||||
cst: 'application/x-director',
|
||||
cxt: 'application/x-director',
|
||||
dcr: 'application/x-director',
|
||||
dir: 'application/x-director',
|
||||
dxr: 'application/x-director',
|
||||
swa: 'application/x-director',
|
||||
w3d: 'application/x-director',
|
||||
|
||||
// Chemical and scientific formats
|
||||
cdx: 'chemical/x-cdx',
|
||||
chm: 'chemical/x-chemdraw',
|
||||
cow: 'chemical/x-cow',
|
||||
csm: 'chemical/x-csml',
|
||||
csml: 'chemical/x-csml',
|
||||
cub: 'chemical/x-gaussian-cube',
|
||||
cube: 'chemical/x-gaussian-cube',
|
||||
dx: 'chemical/x-jcamp-dx',
|
||||
emb: 'chemical/x-pdb',
|
||||
embl: 'chemical/x-pdb',
|
||||
gau: 'chemical/x-gaussian-input',
|
||||
jdx: 'chemical/x-jcamp-dx',
|
||||
mol: 'chemical/x-mdl-molfile',
|
||||
mop: 'chemical/x-mopac-input',
|
||||
pdb: 'chemical/x-pdb',
|
||||
rxn: 'chemical/x-mdl-rxnfile',
|
||||
skc: 'chemical/x-mdl-tgf',
|
||||
tgf: 'chemical/x-mdl-tgf',
|
||||
xyz: 'chemical/x-xyz',
|
||||
|
||||
// Image formats
|
||||
cgm: 'image/cgm',
|
||||
cit: 'image/cit',
|
||||
dgn: 'image/dgn',
|
||||
djvu: 'image/vnd.djvu',
|
||||
fh4: 'image/x-freehand4',
|
||||
fh5: 'image/x-freehand5',
|
||||
fh7: 'image/x-freehand7',
|
||||
fhc: 'image/x-freehand',
|
||||
jp2: 'image/jp2',
|
||||
jp2k: 'image/jp2',
|
||||
mcf: 'image/vasa',
|
||||
qdgx: 'image/x-qdgx',
|
||||
rle: 'image/rle',
|
||||
svg: 'image/svg+xml',
|
||||
svgz: 'image/svg+xml',
|
||||
twf: 'image/x-twf',
|
||||
twfz: 'image/x-twf-zlib-compressed',
|
||||
vec: 'image/vec',
|
||||
|
||||
// Audio formats
|
||||
it: 'audio/it',
|
||||
itz: 'audio/x-zipped-it',
|
||||
mdz: 'audio/x-zipped-mod',
|
||||
med: 'audio/x-mod',
|
||||
mid: 'audio/mid',
|
||||
midi: 'audio/midi',
|
||||
mod: 'audio/mod',
|
||||
s3m: 'audio/s3m',
|
||||
s3z: 'audio/x-mod',
|
||||
sid: 'audio/x-sidtune',
|
||||
wav: 'audio/wav',
|
||||
xm: 'audio/xm',
|
||||
xmz: 'audio/x-mod',
|
||||
|
||||
// Video formats
|
||||
mov: 'video/quicktime',
|
||||
|
||||
// Application formats
|
||||
class: 'application/java',
|
||||
cmo: 'application/x-virtools',
|
||||
co: 'application/x-cult3d-object',
|
||||
css: 'text/css',
|
||||
deepv: 'application/x-deepv',
|
||||
dpg: 'application/vnd.dpgraph',
|
||||
dpgraph: 'application/vnd.dpgraph',
|
||||
dsn: 'application/x-altiadsn',
|
||||
dvl: 'application/x-devalvrx',
|
||||
elec: 'application/x-electrifier',
|
||||
eva: 'application/x-eva',
|
||||
evy: 'application/envoy',
|
||||
|
||||
// Compressed formats
|
||||
gz: 'application/x-gzip-compressed',
|
||||
wrz: 'application/x-gzip-compressed',
|
||||
x3dz: 'application/x-gzip-compressed',
|
||||
|
||||
// Web formats
|
||||
htm: 'text/html',
|
||||
html: 'text/html',
|
||||
js: 'text/javascript',
|
||||
mjs: 'text/javascript',
|
||||
json: 'application/json',
|
||||
xml: 'application/xml',
|
||||
|
||||
// Plugin formats
|
||||
ips: 'application/x-ipscript',
|
||||
ipx: 'application/x-ipix',
|
||||
jar: 'application/java-archive',
|
||||
mwc: 'application/vnd.dpgraph',
|
||||
mwf: 'application/x-mwf',
|
||||
nmo: 'application/x-virtools',
|
||||
nms: 'application/x-virtools',
|
||||
p3d: 'application/x-p3d',
|
||||
pqf: 'application/x-cprplayer',
|
||||
pqi: 'application/cprplayer',
|
||||
pw3: 'application/x-pulse-player-32',
|
||||
pwc: 'application/x-pulse-player',
|
||||
pwn: 'application/x-pulse-download',
|
||||
pws: 'application/x-pulse-stream',
|
||||
sca: 'application/x-supercard',
|
||||
scr: 'application/x-rasmol',
|
||||
smp: 'application/studiom',
|
||||
spl: 'application/futuresplash',
|
||||
sts: 'application/x-squeak-source',
|
||||
swf: 'application/x-shockwave-flash',
|
||||
tbk: 'application/toolbook',
|
||||
tcl: 'application/x-tcl',
|
||||
thp: 'plugin/x-theorist',
|
||||
tv: 'application/x-alambik-script',
|
||||
tvb: 'application/x-alambik-script',
|
||||
tvd: 'application/x-alambik-script',
|
||||
tvs: 'application/x-alambik-script',
|
||||
tvv: 'application/x-alambik-script',
|
||||
unity3d: 'application/vnd.unity',
|
||||
vmo: 'application/x-virtools',
|
||||
vobj: 'application/x-netscape-vae-plugin-vae',
|
||||
wasm: 'application/wasm',
|
||||
web: 'application/vnd.xara',
|
||||
xap: 'application/x-silverlight-app',
|
||||
xar: 'application/vnd.xara',
|
||||
xpg: 'text/x-xpg',
|
||||
|
||||
// World/3D formats
|
||||
d96: 'x-world/x-d96',
|
||||
dae: 'model/x-bs-collada+xml',
|
||||
mus: 'x-world/x-d96',
|
||||
rbs: 'x-world/realibase',
|
||||
svf: 'vector/x-svf',
|
||||
svr: 'x-world/x-svr',
|
||||
vrt: 'x-world/x-vrt',
|
||||
wrl: 'model/vrml',
|
||||
wvr: 'x-world/x-wvr',
|
||||
x3db: 'model/x3d+binary',
|
||||
xvr: 'x-world/x-xvr',
|
||||
};
|
||||
|
||||
// Standard MIME types for common web files
|
||||
const STANDARD_MIME_TYPES: { [key: string]: string } = {
|
||||
// Text
|
||||
txt: 'text/plain',
|
||||
css: 'text/css',
|
||||
csv: 'text/csv',
|
||||
|
||||
// Images
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
bmp: 'image/bmp',
|
||||
ico: 'image/x-icon',
|
||||
webp: 'image/webp',
|
||||
|
||||
// Audio
|
||||
mp3: 'audio/mpeg',
|
||||
ogg: 'audio/ogg',
|
||||
oga: 'audio/ogg',
|
||||
|
||||
// Video
|
||||
mp4: 'video/mp4',
|
||||
webm: 'video/webm',
|
||||
ogv: 'video/ogg',
|
||||
avi: 'video/x-msvideo',
|
||||
|
||||
// Fonts
|
||||
woff: 'font/woff',
|
||||
woff2: 'font/woff2',
|
||||
ttf: 'font/ttf',
|
||||
otf: 'font/otf',
|
||||
eot: 'application/vnd.ms-fontobject',
|
||||
|
||||
// Archives
|
||||
zip: 'application/zip',
|
||||
rar: 'application/x-rar-compressed',
|
||||
'7z': 'application/x-7z-compressed',
|
||||
tar: 'application/x-tar',
|
||||
|
||||
// Documents
|
||||
pdf: 'application/pdf',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
|
||||
// Binary
|
||||
bin: 'application/octet-stream',
|
||||
exe: 'application/octet-stream',
|
||||
dll: 'application/octet-stream',
|
||||
};
|
||||
|
||||
/**
|
||||
* Get MIME type for a file extension
|
||||
* Checks custom Flashpoint MIME types first, then standard types
|
||||
*/
|
||||
export function getMimeType(extension: string): string {
|
||||
const ext = extension.toLowerCase();
|
||||
|
||||
// Check custom MIME types first (priority for legacy content)
|
||||
if (CUSTOM_MIME_TYPES[ext]) {
|
||||
return CUSTOM_MIME_TYPES[ext];
|
||||
}
|
||||
|
||||
// Check standard MIME types
|
||||
if (STANDARD_MIME_TYPES[ext]) {
|
||||
return STANDARD_MIME_TYPES[ext];
|
||||
}
|
||||
|
||||
// Default to octet-stream for unknown types
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file extension is a gzipped type
|
||||
*/
|
||||
export function isGzippedType(extension: string): boolean {
|
||||
const gzippedTypes = ['svgz'];
|
||||
return gzippedTypes.includes(extension.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file extension is a script type
|
||||
*/
|
||||
export function isScriptType(extension: string): boolean {
|
||||
const scriptTypes = ['php', 'php5', 'phtml', 'pl'];
|
||||
return scriptTypes.includes(extension.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of index file types
|
||||
*/
|
||||
export function getIndexFileTypes(): string[] {
|
||||
return ['html', 'htm', 'php', 'php5', 'phtml', 'asp', 'aspx', 'jsp', 'jspx'];
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import { IncomingMessage, ServerResponse } from 'http';
|
||||
import { URL } from 'url';
|
||||
import { logger } from '../utils/logger';
|
||||
import { ConfigManager } from './config';
|
||||
import { LegacyServer } from './legacy-server';
|
||||
import axios from 'axios';
|
||||
|
||||
export class ProxyRequestHandler {
|
||||
private legacyServer: LegacyServer;
|
||||
private settings = ConfigManager.getSettings();
|
||||
|
||||
constructor() {
|
||||
this.legacyServer = new LegacyServer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming HTTP proxy request
|
||||
* Replicates the request processing flow from Go's goproxy
|
||||
*/
|
||||
async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
try {
|
||||
logger.info(`[ProxyHandler] ${req.method} ${req.url}`);
|
||||
|
||||
// Parse the request URL
|
||||
// For proxy requests, req.url contains the full URL: http://example.com/path
|
||||
// For regular requests, req.url contains just the path: /path
|
||||
if (!req.url) {
|
||||
this.sendError(res, 400, 'Bad Request: No URL');
|
||||
return;
|
||||
}
|
||||
|
||||
let targetUrl: URL;
|
||||
let hostname: string;
|
||||
let urlPath: string;
|
||||
|
||||
// Check if this is a proxy-style request (starts with http://)
|
||||
if (req.url.startsWith('http://') || req.url.startsWith('https://')) {
|
||||
// Proxy-style request: GET http://example.com/path HTTP/1.1
|
||||
targetUrl = new URL(req.url);
|
||||
hostname = targetUrl.hostname;
|
||||
urlPath = targetUrl.pathname + targetUrl.search;
|
||||
} else if (req.url.startsWith('/http://') || req.url.startsWith('/https://')) {
|
||||
// Path-based proxy request: GET /http://example.com/path HTTP/1.1
|
||||
// Strip leading slash and parse as URL
|
||||
const urlWithoutSlash = req.url.substring(1);
|
||||
targetUrl = new URL(urlWithoutSlash);
|
||||
hostname = targetUrl.hostname;
|
||||
urlPath = targetUrl.pathname + targetUrl.search;
|
||||
} else {
|
||||
// Regular request: GET /path HTTP/1.1 with Host header
|
||||
hostname = req.headers.host || 'localhost';
|
||||
urlPath = req.url;
|
||||
targetUrl = new URL(`http://${hostname}${urlPath}`);
|
||||
}
|
||||
|
||||
logger.info(`[ProxyHandler] Parsed - Host: ${hostname}, Path: ${urlPath}`);
|
||||
|
||||
// Step 1: Try GameZip server first (if enabled)
|
||||
if (this.settings.gameZipPort) {
|
||||
logger.debug(`[ProxyHandler] Trying GameZip server...`);
|
||||
const gameZipResult = await this.tryGameZipServer(hostname, urlPath);
|
||||
if (gameZipResult) {
|
||||
logger.info(`[ProxyHandler] ✓ Served from GameZip`);
|
||||
this.sendResponse(res, gameZipResult.data, gameZipResult.contentType, 'gamezipserver');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Try legacy file serving (local htdocs + external fallbacks)
|
||||
logger.debug(`[ProxyHandler] Trying legacy server...`);
|
||||
const legacyResult = await this.legacyServer.serveLegacy(hostname, urlPath);
|
||||
|
||||
logger.info(`[ProxyHandler] ✓ Served from ${legacyResult.source}`);
|
||||
this.sendResponse(res, legacyResult.data, legacyResult.contentType, legacyResult.source);
|
||||
} catch (error) {
|
||||
logger.error('[ProxyHandler] Error handling request:', error);
|
||||
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
this.sendError(res, 404, 'File not found in any source');
|
||||
} else {
|
||||
this.sendError(res, 500, 'Internal server error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to fetch from GameZip server
|
||||
*/
|
||||
private async tryGameZipServer(
|
||||
hostname: string,
|
||||
urlPath: string
|
||||
): Promise<{ data: Buffer; contentType: string } | null> {
|
||||
try {
|
||||
// Make proxy-style request to GameZip server
|
||||
// The GameZip server also accepts proxy-style requests: GET http://hostname/path HTTP/1.1
|
||||
const gameZipServerUrl = `http://localhost:${this.settings.gameZipPort}`;
|
||||
const targetUrl = `http://${hostname}${urlPath}`;
|
||||
const requestUrl = `${gameZipServerUrl}/${targetUrl}`;
|
||||
|
||||
const response = await axios.get(requestUrl, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 5000, // Quick timeout for local server
|
||||
validateStatus: (status) => status === 200,
|
||||
proxy: false, // Don't use system proxy
|
||||
});
|
||||
|
||||
const data = Buffer.from(response.data);
|
||||
const contentType = response.headers['content-type'] || 'application/octet-stream';
|
||||
|
||||
return { data, contentType };
|
||||
} catch (error) {
|
||||
// GameZip server not available or file not found
|
||||
logger.debug(`[ProxyHandler] GameZip unavailable or file not found`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send successful response
|
||||
*/
|
||||
private sendResponse(
|
||||
res: ServerResponse,
|
||||
data: Buffer,
|
||||
contentType: string,
|
||||
source: string
|
||||
): void {
|
||||
// CORS headers
|
||||
if (this.settings.allowCrossDomain) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', '*');
|
||||
}
|
||||
|
||||
// Content headers
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Content-Length', data.length);
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400'); // Cache for 24 hours
|
||||
res.setHeader('X-Source', source); // Debug header
|
||||
|
||||
// Connection keep-alive
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
|
||||
res.writeHead(200);
|
||||
res.end(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send error response
|
||||
*/
|
||||
private sendError(res: ServerResponse, statusCode: number, message: string): void {
|
||||
logger.warn(`[ProxyHandler] Sending error ${statusCode}: ${message}`);
|
||||
|
||||
// IMPORTANT: Set CORS headers even for error responses
|
||||
// Otherwise browsers will show CORS error instead of the actual error
|
||||
if (this.settings.allowCrossDomain) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', '*');
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.writeHead(statusCode);
|
||||
res.end(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OPTIONS request for CORS preflight
|
||||
*/
|
||||
handleOptionsRequest(req: IncomingMessage, res: ServerResponse): void {
|
||||
if (this.settings.allowCrossDomain) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', '*');
|
||||
res.setHeader('Access-Control-Max-Age', '86400');
|
||||
}
|
||||
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
@@ -1,520 +0,0 @@
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import axios from 'axios';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { logger } from '../utils/logger';
|
||||
import { getMimeType } from './mimeTypes';
|
||||
import { zipManager } from './zip-manager';
|
||||
|
||||
export interface ProxyConfig {
|
||||
proxyPort: number;
|
||||
legacyHTDOCSPath: string;
|
||||
gameDataPath: string;
|
||||
externalFilePaths: string[];
|
||||
allowCrossDomain: boolean;
|
||||
chunkSize?: number;
|
||||
}
|
||||
|
||||
export class GameProxyServer {
|
||||
private app: express.Application;
|
||||
private config: ProxyConfig;
|
||||
|
||||
constructor(proxyConfig: ProxyConfig) {
|
||||
this.app = express();
|
||||
this.config = {
|
||||
...proxyConfig,
|
||||
chunkSize: proxyConfig.chunkSize || 8192, // 8KB default chunk size
|
||||
};
|
||||
this.setupMiddleware();
|
||||
this.setupRoutes();
|
||||
}
|
||||
|
||||
private setupMiddleware(): void {
|
||||
// CORS middleware
|
||||
if (this.config.allowCrossDomain) {
|
||||
this.app.use((req, res, next) => {
|
||||
res.header('Access-Control-Allow-Origin', '*');
|
||||
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.header(
|
||||
'Access-Control-Allow-Headers',
|
||||
'Origin, X-Requested-With, Content-Type, Accept'
|
||||
);
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.sendStatus(200);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Request logging
|
||||
this.app.use((req, res, next) => {
|
||||
logger.debug(`[Proxy] ${req.method} ${req.path}`);
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
private setupRoutes(): void {
|
||||
// Main proxy route - handles all requests
|
||||
this.app.get('*', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await this.handleProxyRequest(req, res);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Error handler
|
||||
this.app.use((err: Error, req: Request, res: Response, _next: NextFunction) => {
|
||||
logger.error('[Proxy] Error:', err);
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: 'Proxy server error',
|
||||
statusCode: 500,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async handleProxyRequest(req: Request, res: Response): Promise<void> {
|
||||
let requestPath = req.path.substring(1); // Remove leading slash
|
||||
|
||||
// Parse URL if the request path is a full URL (e.g., http://domain.com/path)
|
||||
const parsedPath = this.parseProxyPath(requestPath);
|
||||
requestPath = parsedPath;
|
||||
|
||||
logger.debug(`[Proxy] Resolved path: ${requestPath}`);
|
||||
|
||||
// Try to serve from local paths first
|
||||
const localFile = await this.tryLocalPaths(requestPath);
|
||||
|
||||
if (localFile) {
|
||||
await this.serveFile(localFile, res);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to serve from mounted ZIP files (mimics Flashpoint ZipServer)
|
||||
const zipContent = await this.tryZipFiles(requestPath);
|
||||
|
||||
if (zipContent) {
|
||||
await this.serveZipContent(zipContent, requestPath, res);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try external fallback URLs
|
||||
const externalContent = await this.tryExternalPaths(requestPath);
|
||||
|
||||
if (externalContent) {
|
||||
await this.serveExternalContent(externalContent, res);
|
||||
return;
|
||||
}
|
||||
|
||||
// File not found
|
||||
logger.warn(`[Proxy] File not found: ${requestPath}`);
|
||||
res.status(404).json({
|
||||
error: {
|
||||
message: 'File not found',
|
||||
path: requestPath,
|
||||
statusCode: 404,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse proxy path - converts full URLs to local file paths
|
||||
* Examples:
|
||||
* http://farm.maxgames.com/game.swf -> farm.maxgames.com/game.swf
|
||||
* https://example.com/path/file.html -> example.com/path/file.html
|
||||
* http://www.example.com/Find%20My%20File.swf -> www.example.com/Find My File.swf
|
||||
* regular/path/file.swf -> regular/path/file.swf (unchanged)
|
||||
*/
|
||||
private parseProxyPath(requestPath: string): string {
|
||||
try {
|
||||
// Check if it's a full URL (starts with http:// or https://)
|
||||
if (requestPath.startsWith('http://') || requestPath.startsWith('https://')) {
|
||||
const url = new URL(requestPath);
|
||||
|
||||
// CRITICAL FIX: Decode the pathname to handle URL-encoded characters (spaces, etc.)
|
||||
// The JavaScript URL class preserves encoding, but filesystem paths need actual characters
|
||||
// This matches how PHP's web server automatically decodes paths for the Flashpoint Launcher
|
||||
const decodedPathname = decodeURIComponent(url.pathname);
|
||||
const decodedSearch = url.search ? decodeURIComponent(url.search) : '';
|
||||
|
||||
// Combine hostname and decoded pathname, removing leading slash from pathname
|
||||
const localPath = url.hostname + decodedPathname + decodedSearch;
|
||||
logger.debug(`[Proxy] Parsed URL: ${requestPath} -> ${localPath}`);
|
||||
return localPath;
|
||||
}
|
||||
|
||||
// If not a URL, return as-is
|
||||
return requestPath;
|
||||
} catch (error) {
|
||||
logger.warn(`[Proxy] Failed to parse URL: ${requestPath}, using as-is`);
|
||||
return requestPath;
|
||||
}
|
||||
}
|
||||
|
||||
private async tryLocalPaths(requestPath: string): Promise<string | null> {
|
||||
// Priority order:
|
||||
// 1. Legacy htdocs path
|
||||
// 2. Game data path
|
||||
|
||||
const pathsToTry = [
|
||||
path.join(this.config.legacyHTDOCSPath, requestPath),
|
||||
path.join(this.config.gameDataPath, requestPath),
|
||||
];
|
||||
|
||||
for (const filePath of pathsToTry) {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
if (stats.isFile()) {
|
||||
return filePath;
|
||||
} else if (stats.isDirectory()) {
|
||||
// Try index files
|
||||
const indexFiles = ['index.html', 'index.htm', 'index.php'];
|
||||
for (const indexFile of indexFiles) {
|
||||
const indexPath = path.join(filePath, indexFile);
|
||||
if (fs.existsSync(indexPath) && fs.statSync(indexPath).isFile()) {
|
||||
return indexPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`[Proxy] Error checking path ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to serve file from mounted ZIP archives in Data/Games
|
||||
* This replicates the ZipServer functionality from FlashpointGameServer (port 22501)
|
||||
*/
|
||||
private async tryZipFiles(requestPath: string): Promise<Buffer | null> {
|
||||
logger.debug(`[Proxy] Checking ZIP files for: ${requestPath}`);
|
||||
|
||||
try {
|
||||
// The zipManager.findFile() already searches all mounted ZIPs
|
||||
// It tries multiple path variants: content/, htdocs/, etc.
|
||||
const result = await zipManager.findFile(requestPath);
|
||||
|
||||
if (result) {
|
||||
logger.info(
|
||||
`[Proxy] ✓ Found in ZIP ${result.mountId}: ${requestPath} (${result.data.length} bytes)`
|
||||
);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
logger.debug(`[Proxy] File not found in any mounted ZIP: ${requestPath}`);
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error(`[Proxy] Error searching ZIP files:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-discover and mount all ZIP files in Data/Games directory
|
||||
* This is called on server startup to make game ZIPs available
|
||||
*/
|
||||
private async autoMountZipFiles(): Promise<void> {
|
||||
try {
|
||||
const gamesPath = this.config.gameDataPath;
|
||||
|
||||
if (!fs.existsSync(gamesPath)) {
|
||||
logger.warn(`[Proxy] Games directory not found: ${gamesPath}`);
|
||||
logger.warn(`[Proxy] ZIP file serving will be disabled`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`[Proxy] Auto-mounting ZIP files from: ${gamesPath}`);
|
||||
|
||||
// Find all .zip files in Data/Games
|
||||
const files = fs.readdirSync(gamesPath);
|
||||
const zipFiles = files.filter((f) => f.toLowerCase().endsWith('.zip'));
|
||||
|
||||
if (zipFiles.length === 0) {
|
||||
logger.info(`[Proxy] No ZIP files found in ${gamesPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`[Proxy] Found ${zipFiles.length} ZIP file(s) to mount`);
|
||||
|
||||
// Mount each ZIP file
|
||||
let mountedCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
for (const zipFile of zipFiles) {
|
||||
try {
|
||||
const zipPath = path.join(gamesPath, zipFile);
|
||||
// Use filename (without extension) as mount ID
|
||||
const mountId = path.basename(zipFile, '.zip');
|
||||
|
||||
await zipManager.mount(mountId, zipPath);
|
||||
mountedCount++;
|
||||
} catch (error) {
|
||||
logger.warn(`[Proxy] Failed to mount ${zipFile}:`, error);
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[Proxy] ========================================`);
|
||||
logger.info(`[Proxy] ZIP Auto-Mount Summary:`);
|
||||
logger.info(`[Proxy] Total found: ${zipFiles.length}`);
|
||||
logger.info(`[Proxy] Mounted: ${mountedCount}`);
|
||||
if (failedCount > 0) {
|
||||
logger.warn(`[Proxy] Failed: ${failedCount}`);
|
||||
}
|
||||
logger.info(`[Proxy] ========================================`);
|
||||
} catch (error) {
|
||||
logger.error(`[Proxy] Error during ZIP auto-mount:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve content from ZIP file
|
||||
*/
|
||||
private async serveZipContent(
|
||||
content: Buffer,
|
||||
requestPath: string,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Determine MIME type from file extension
|
||||
const ext = path.extname(requestPath).substring(1).toLowerCase();
|
||||
const mimeType = getMimeType(ext);
|
||||
|
||||
res.setHeader('Content-Type', mimeType);
|
||||
res.setHeader('Content-Length', content.length);
|
||||
res.setHeader('X-Source', 'zip');
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400'); // Cache for 24 hours
|
||||
|
||||
res.send(content);
|
||||
|
||||
logger.debug(
|
||||
`[Proxy] Served from ZIP: ${requestPath} (${mimeType}, ${content.length} bytes)`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('[Proxy] Error serving ZIP content:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode URL for CDN access:
|
||||
* - Ensure path segments are properly encoded
|
||||
* - Preserve URL structure (protocol, domain, query params)
|
||||
* - Handle already-encoded segments gracefully
|
||||
*/
|
||||
private encodeUrlForCDN(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
|
||||
// Encode each path segment individually
|
||||
// This prevents double-encoding while ensuring special characters are handled
|
||||
const pathSegments = urlObj.pathname.split('/');
|
||||
const encodedSegments = pathSegments.map((segment) => {
|
||||
if (!segment) return segment; // Preserve empty segments (leading/trailing slashes)
|
||||
|
||||
try {
|
||||
// Try to decode first - if it succeeds, the segment might be already encoded
|
||||
const decoded = decodeURIComponent(segment);
|
||||
// Re-encode to ensure consistency
|
||||
return encodeURIComponent(decoded);
|
||||
} catch {
|
||||
// If decode fails, segment is either already properly encoded or has invalid encoding
|
||||
// Try to encode it directly
|
||||
return encodeURIComponent(segment);
|
||||
}
|
||||
});
|
||||
|
||||
urlObj.pathname = encodedSegments.join('/');
|
||||
|
||||
// Also encode query parameters properly
|
||||
if (urlObj.search) {
|
||||
const params = new URLSearchParams(urlObj.search);
|
||||
urlObj.search = params.toString();
|
||||
}
|
||||
|
||||
return urlObj.toString();
|
||||
} catch (error) {
|
||||
// If URL parsing fails, return original URL
|
||||
logger.warn(
|
||||
`[Proxy] Failed to encode URL for CDN: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
private async tryExternalPaths(requestPath: string): Promise<Buffer | null> {
|
||||
logger.info(`[Proxy] Trying external CDN for: ${requestPath}`);
|
||||
|
||||
for (const baseUrl of this.config.externalFilePaths) {
|
||||
try {
|
||||
const url = `${baseUrl.trim()}/${requestPath}`;
|
||||
// Encode URL like PHP router does
|
||||
const encodedUrl = this.encodeUrlForCDN(url);
|
||||
logger.info(`[Proxy] Attempting: ${encodedUrl}`);
|
||||
|
||||
const response = await axios.get(encodedUrl, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 30000, // 30 second timeout
|
||||
maxRedirects: 5,
|
||||
headers: {
|
||||
// Match PHP router's Accept-Encoding header
|
||||
'Accept-Encoding': 'identity',
|
||||
},
|
||||
// Note: Removed validateStatus to allow axios to follow redirects automatically
|
||||
});
|
||||
|
||||
if (response.status === 200 && response.data) {
|
||||
const buffer = Buffer.from(response.data);
|
||||
|
||||
// Archive.org serves empty files instead of 404s - check for this
|
||||
if (buffer.length === 0) {
|
||||
logger.info(`[Proxy] ✗ Empty file returned (treated as 404): ${encodedUrl}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[Proxy] ✓ Successfully fetched from external: ${encodedUrl} (${buffer.length} bytes)`
|
||||
);
|
||||
|
||||
// Cache the downloaded file locally for future use
|
||||
await this.cacheDownloadedFile(requestPath, buffer);
|
||||
|
||||
return buffer;
|
||||
}
|
||||
} catch (error) {
|
||||
let errorMsg = 'Unknown error';
|
||||
if (error && typeof error === 'object') {
|
||||
const err = error as { response?: { status?: number }; message?: string };
|
||||
errorMsg = err.response?.status
|
||||
? `HTTP ${err.response.status}`
|
||||
: err.message || 'Unknown error';
|
||||
}
|
||||
logger.info(`[Proxy] ✗ Failed (${errorMsg}): ${baseUrl.trim()}/${requestPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn(`[Proxy] All CDN sources exhausted for: ${requestPath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache downloaded file locally for future use (like Flashpoint Launcher does)
|
||||
*/
|
||||
private async cacheDownloadedFile(requestPath: string, content: Buffer): Promise<void> {
|
||||
try {
|
||||
// Determine the best local path (prefer Legacy/htdocs)
|
||||
const localPath = path.join(this.config.legacyHTDOCSPath, requestPath);
|
||||
const dir = path.dirname(localPath);
|
||||
|
||||
// Create directory structure if it doesn't exist
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// Write the file
|
||||
fs.writeFileSync(localPath, content);
|
||||
logger.info(`[Proxy] Cached downloaded file: ${localPath}`);
|
||||
} catch (error) {
|
||||
logger.warn(`[Proxy] Failed to cache file locally:`, error);
|
||||
// Don't throw - caching is optional, we already have the content
|
||||
}
|
||||
}
|
||||
|
||||
private async serveFile(filePath: string, res: Response): Promise<void> {
|
||||
try {
|
||||
const ext = path.extname(filePath).substring(1).toLowerCase();
|
||||
const mimeType = getMimeType(ext);
|
||||
|
||||
res.setHeader('Content-Type', mimeType);
|
||||
|
||||
const stats = fs.statSync(filePath);
|
||||
res.setHeader('Content-Length', stats.size);
|
||||
|
||||
// Stream file with chunking for large files
|
||||
const readStream = fs.createReadStream(filePath, {
|
||||
highWaterMark: this.config.chunkSize,
|
||||
});
|
||||
|
||||
readStream.on('error', (error) => {
|
||||
logger.error('[Proxy] Stream error:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: 'Error streaming file',
|
||||
statusCode: 500,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
readStream.pipe(res);
|
||||
|
||||
logger.debug(`[Proxy] Serving local file: ${filePath} (${mimeType})`);
|
||||
} catch (error) {
|
||||
logger.error('[Proxy] Error serving file:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async serveExternalContent(content: Buffer, res: Response): Promise<void> {
|
||||
try {
|
||||
// Try to determine MIME type from content or default to octet-stream
|
||||
const mimeType = 'application/octet-stream'; // Will be improved with actual detection
|
||||
|
||||
res.setHeader('Content-Type', mimeType);
|
||||
res.setHeader('Content-Length', content.length);
|
||||
res.send(content);
|
||||
} catch (error) {
|
||||
logger.error('[Proxy] Error serving external content:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
// Auto-mount ZIP files from Data/Games before starting the server
|
||||
await this.autoMountZipFiles();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = this.app.listen(this.config.proxyPort, () => {
|
||||
logger.info(`[Proxy] Game proxy server listening on port ${this.config.proxyPort}`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
server.on('error', (error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'EADDRINUSE') {
|
||||
logger.error(`[Proxy] Port ${this.config.proxyPort} is already in use.`);
|
||||
logger.error(
|
||||
'[Proxy] Please check if another instance is running or change PROXY_PORT in .env'
|
||||
);
|
||||
logger.error(
|
||||
'[Proxy] You can find the process using: netstat -ano | findstr :' +
|
||||
this.config.proxyPort
|
||||
);
|
||||
} else {
|
||||
logger.error('[Proxy] Failed to start proxy server:', error);
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public getApp(): express.Application {
|
||||
return this.app;
|
||||
}
|
||||
}
|
||||
|
||||
// Factory function to create and start proxy server
|
||||
export async function createProxyServer(proxyConfig: ProxyConfig): Promise<GameProxyServer> {
|
||||
const server = new GameProxyServer(proxyConfig);
|
||||
await server.start();
|
||||
return server;
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
import StreamZip from 'node-stream-zip';
|
||||
import fs from 'fs/promises';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
interface MountedZip {
|
||||
id: string;
|
||||
zipPath: string;
|
||||
zip: StreamZip.StreamZipAsync;
|
||||
mountTime: Date;
|
||||
}
|
||||
|
||||
export class ZipManager {
|
||||
private mountedZips: Map<string, MountedZip> = new Map();
|
||||
|
||||
/**
|
||||
* Mount a ZIP file for serving
|
||||
* @param id Unique identifier for this mount (e.g., game data ID)
|
||||
* @param zipPath Absolute path to the ZIP file
|
||||
*/
|
||||
async mount(id: string, zipPath: string): Promise<void> {
|
||||
// Check if already mounted
|
||||
if (this.mountedZips.has(id)) {
|
||||
logger.debug(`[ZipManager] ZIP already mounted: ${id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify ZIP file exists
|
||||
try {
|
||||
await fs.access(zipPath);
|
||||
} catch (error) {
|
||||
throw new Error(`ZIP file not found: ${zipPath}`);
|
||||
}
|
||||
|
||||
logger.info(`[ZipManager] Mounting ZIP: ${id} -> ${zipPath}`);
|
||||
|
||||
// Create ZIP reader
|
||||
const zip = new StreamZip.async({ file: zipPath });
|
||||
|
||||
// Store mounted ZIP
|
||||
this.mountedZips.set(id, {
|
||||
id,
|
||||
zipPath,
|
||||
zip,
|
||||
mountTime: new Date(),
|
||||
});
|
||||
|
||||
logger.info(`[ZipManager] ✓ Mounted ZIP: ${id} (${await this.getFileCount(id)} files)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmount a ZIP file
|
||||
* @param id Unique identifier of the mounted ZIP
|
||||
*/
|
||||
async unmount(id: string): Promise<boolean> {
|
||||
const mounted = this.mountedZips.get(id);
|
||||
if (!mounted) {
|
||||
logger.warn(`[ZipManager] ZIP not mounted: ${id}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info(`[ZipManager] Unmounting ZIP: ${id}`);
|
||||
|
||||
try {
|
||||
await mounted.zip.close();
|
||||
this.mountedZips.delete(id);
|
||||
logger.info(`[ZipManager] ✓ Unmounted ZIP: ${id}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`[ZipManager] Error unmounting ZIP: ${id}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a file from a mounted ZIP
|
||||
* @param id ZIP mount ID
|
||||
* @param filePath Path within the ZIP (e.g., "htdocs/www.example.com/file.swf")
|
||||
*/
|
||||
async getFile(id: string, filePath: string): Promise<Buffer | null> {
|
||||
const mounted = this.mountedZips.get(id);
|
||||
if (!mounted) {
|
||||
logger.debug(`[ZipManager] ZIP not mounted: ${id}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Normalize path (remove leading slash)
|
||||
const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath;
|
||||
|
||||
logger.debug(`[ZipManager] Reading from ZIP ${id}: ${normalizedPath}`);
|
||||
|
||||
// Get file from ZIP
|
||||
const data = await mounted.zip.entryData(normalizedPath);
|
||||
|
||||
logger.debug(`[ZipManager] ✓ Read ${data.length} bytes from ${id}:${normalizedPath}`);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
// File not found in ZIP
|
||||
logger.debug(`[ZipManager] File not found in ${id}: ${filePath}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for a file across all mounted ZIPs
|
||||
* @param relPath Relative path (e.g., "www.example.com/path/file.swf")
|
||||
* @returns Buffer and mount ID if found
|
||||
*/
|
||||
async findFile(relPath: string): Promise<{ data: Buffer; mountId: string } | null> {
|
||||
// Try with different prefixes (different ZIP structures)
|
||||
const pathsToTry = [
|
||||
`content/${relPath}`, // Most common: content/domain/path
|
||||
`htdocs/${relPath}`, // Standard: htdocs/domain/path
|
||||
relPath, // No prefix: domain/path
|
||||
`Legacy/htdocs/${relPath}`, // Full path: Legacy/htdocs/domain/path
|
||||
];
|
||||
|
||||
// Search all mounted ZIPs
|
||||
for (const [id, _mounted] of this.mountedZips) {
|
||||
for (const pathVariant of pathsToTry) {
|
||||
const data = await this.getFile(id, pathVariant);
|
||||
if (data) {
|
||||
logger.info(`[ZipManager] ✓ Found in ${id}: ${pathVariant}`);
|
||||
return { data, mountId: id };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of mounted ZIPs
|
||||
*/
|
||||
getMountedZips(): Array<{ id: string; zipPath: string; mountTime: Date; fileCount: number }> {
|
||||
const result: Array<{ id: string; zipPath: string; mountTime: Date; fileCount: number }> = [];
|
||||
|
||||
for (const [id, mounted] of this.mountedZips) {
|
||||
result.push({
|
||||
id,
|
||||
zipPath: mounted.zipPath,
|
||||
mountTime: mounted.mountTime,
|
||||
fileCount: 0, // Will be populated async
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of files in a mounted ZIP
|
||||
*/
|
||||
private async getFileCount(id: string): Promise<number> {
|
||||
const mounted = this.mountedZips.get(id);
|
||||
if (!mounted) return 0;
|
||||
|
||||
try {
|
||||
const entries = await mounted.zip.entries();
|
||||
return Object.keys(entries).length;
|
||||
} catch (error) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List files in a mounted ZIP (for debugging)
|
||||
*/
|
||||
async listFiles(id: string, pattern?: string): Promise<string[]> {
|
||||
const mounted = this.mountedZips.get(id);
|
||||
if (!mounted) return [];
|
||||
|
||||
try {
|
||||
const entries = await mounted.zip.entries();
|
||||
let files = Object.keys(entries);
|
||||
|
||||
if (pattern) {
|
||||
const regex = new RegExp(pattern, 'i');
|
||||
files = files.filter((f) => regex.test(f));
|
||||
}
|
||||
|
||||
return files;
|
||||
} catch (error) {
|
||||
logger.error(`[ZipManager] Error listing files in ${id}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a ZIP is mounted
|
||||
*/
|
||||
isMounted(id: string): boolean {
|
||||
return this.mountedZips.has(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmount all ZIPs (cleanup on shutdown)
|
||||
*/
|
||||
async unmountAll(): Promise<void> {
|
||||
logger.info(`[ZipManager] Unmounting all ZIPs (${this.mountedZips.size})...`);
|
||||
|
||||
const promises = Array.from(this.mountedZips.keys()).map((id) => this.unmount(id));
|
||||
await Promise.all(promises);
|
||||
|
||||
logger.info(`[ZipManager] ✓ All ZIPs unmounted`);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const zipManager = new ZipManager();
|
||||
@@ -72,7 +72,14 @@ router.get(
|
||||
} = queryResult.data;
|
||||
|
||||
// Build filters object
|
||||
const filters: any = {};
|
||||
const filters: {
|
||||
userId?: number;
|
||||
username?: string;
|
||||
action?: string;
|
||||
resource?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
} = {};
|
||||
if (userId !== undefined) filters.userId = userId;
|
||||
if (username) filters.username = username;
|
||||
if (action) filters.action = action;
|
||||
@@ -96,6 +103,16 @@ router.get(
|
||||
requirePermission('activities.read'),
|
||||
asyncHandler(async (req, res) => {
|
||||
const timeRange = (req.query.timeRange as '24h' | '7d' | '30d') || DEFAULT_TIME_RANGE;
|
||||
|
||||
// Validate timeRange
|
||||
const validTimeRanges = ['24h', '7d', '30d'] as const;
|
||||
if (req.query.timeRange && !validTimeRanges.includes(timeRange)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid timeRange. Must be 24h, 7d, or 30d',
|
||||
});
|
||||
}
|
||||
|
||||
const customRange =
|
||||
req.query.startDate && req.query.endDate
|
||||
? {
|
||||
@@ -149,6 +166,15 @@ router.get(
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 10, MAX_ACTIONS_LIMIT);
|
||||
const timeRange = (req.query.timeRange as '24h' | '7d' | '30d') || DEFAULT_TIME_RANGE;
|
||||
|
||||
// Validate timeRange
|
||||
const validTimeRanges = ['24h', '7d', '30d'] as const;
|
||||
if (req.query.timeRange && !validTimeRanges.includes(timeRange)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid timeRange. Must be 24h, 7d, or 30d',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await activityService.getTopActions(limit, timeRange);
|
||||
|
||||
res.json({
|
||||
@@ -179,6 +205,15 @@ router.get(
|
||||
});
|
||||
}
|
||||
|
||||
// Validate timeRange
|
||||
const validTimeRanges = ['24h', '7d', '30d'] as const;
|
||||
if (req.query.timeRange && !validTimeRanges.includes(timeRange)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid timeRange. Must be 24h, 7d, or 30d',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await activityService.getBreakdown(groupBy, limit, timeRange);
|
||||
|
||||
res.json({
|
||||
|
||||
+92
-18
@@ -5,8 +5,14 @@ import { ActivityService } from '../services/ActivityService';
|
||||
import { UserDatabaseService } from '../services/UserDatabaseService';
|
||||
import { AppError } from '../middleware/errorHandler';
|
||||
import { asyncHandler } from '../middleware/asyncHandler';
|
||||
import { softAuth } from '../middleware/auth';
|
||||
import { logActivity } from '../middleware/activityLogger';
|
||||
import { logger } from '../utils/logger';
|
||||
import {
|
||||
setRefreshTokenCookie,
|
||||
clearRefreshTokenCookie,
|
||||
getRefreshTokenFromCookie,
|
||||
} from '../utils/cookies';
|
||||
import { z } from 'zod';
|
||||
|
||||
const router = Router();
|
||||
@@ -61,6 +67,29 @@ const registerLimiter = rateLimit({
|
||||
},
|
||||
});
|
||||
|
||||
// Moderate limits on token refresh
|
||||
const refreshLimiter = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: 30, // 30 refresh requests per minute per IP
|
||||
message: {
|
||||
error: {
|
||||
message: 'Too many token refresh attempts from this IP, please try again later',
|
||||
statusCode: 429,
|
||||
},
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
handler: (req, res) => {
|
||||
logger.warn(`[Security] Rate limit exceeded for token refresh from IP: ${req.ip}`);
|
||||
res.status(429).json({
|
||||
error: {
|
||||
message: 'Too many token refresh attempts from this IP, please try again later',
|
||||
statusCode: 429,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Validation schemas
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(3).max(50),
|
||||
@@ -73,10 +102,6 @@ const registerSchema = z.object({
|
||||
password: z.string().min(6),
|
||||
});
|
||||
|
||||
const refreshTokenSchema = z.object({
|
||||
refreshToken: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/auth/setup-status
|
||||
* Check if the system needs initial setup (no users exist)
|
||||
@@ -104,7 +129,7 @@ router.post(
|
||||
const credentials = loginSchema.parse(req.body);
|
||||
const ipAddress = req.ip || '';
|
||||
|
||||
logger.info(`[Auth] Login attempt for user: ${credentials.username} from ${ipAddress}`);
|
||||
logger.debug(`[Auth] Login attempt for user: ${credentials.username} from ${ipAddress}`);
|
||||
|
||||
const result = await authService.login(credentials, ipAddress);
|
||||
|
||||
@@ -119,16 +144,32 @@ router.post(
|
||||
});
|
||||
|
||||
logger.info(`[Auth] Login successful for user: ${credentials.username}`);
|
||||
res.json(result);
|
||||
|
||||
// Set refresh token as HTTP-only cookie
|
||||
setRefreshTokenCookie(res, result.tokens.refreshToken);
|
||||
|
||||
res.json({
|
||||
user: result.user,
|
||||
tokens: {
|
||||
accessToken: result.tokens.accessToken,
|
||||
expiresIn: result.tokens.expiresIn,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[Auth] Validation error during login: ${error.errors[0].message}`);
|
||||
throw new AppError(400, `Validation error: ${error.errors[0].message}`);
|
||||
const username =
|
||||
typeof req.body?.username === 'string' ? req.body.username.substring(0, 50) : 'unknown';
|
||||
logger.warn(`[Auth] Validation error during login for user: ${username}`);
|
||||
throw new AppError(
|
||||
400,
|
||||
`Validation error: ${error.errors.map((e) => e.message).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
// Log failed login attempt
|
||||
const ipAddress = req.ip || '';
|
||||
const username = req.body.username || 'unknown';
|
||||
const username =
|
||||
typeof req.body?.username === 'string' ? req.body.username.substring(0, 50) : 'unknown';
|
||||
|
||||
try {
|
||||
await activityService.log({
|
||||
@@ -152,7 +193,7 @@ router.post(
|
||||
}
|
||||
|
||||
// Log the error with full details before passing to error handler
|
||||
logger.error(`[Auth] Login failed for user: ${username}`, {
|
||||
logger.warn(`[Auth] Login failed for user: ${username}`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
@@ -194,14 +235,31 @@ router.post(
|
||||
});
|
||||
|
||||
logger.info(`[Auth] Registration successful for user: ${data.username}`);
|
||||
res.status(201).json(result);
|
||||
|
||||
// Set refresh token as HTTP-only cookie
|
||||
setRefreshTokenCookie(res, result.tokens.refreshToken);
|
||||
|
||||
res.status(201).json({
|
||||
user: result.user,
|
||||
tokens: {
|
||||
accessToken: result.tokens.accessToken,
|
||||
expiresIn: result.tokens.expiresIn,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[Auth] Validation error during registration: ${error.errors[0].message}`);
|
||||
throw new AppError(400, `Validation error: ${error.errors[0].message}`);
|
||||
const username =
|
||||
typeof req.body?.username === 'string' ? req.body.username.substring(0, 50) : 'unknown';
|
||||
logger.warn(`[Auth] Validation error during registration for user: ${username}`);
|
||||
throw new AppError(
|
||||
400,
|
||||
`Validation error: ${error.errors.map((e) => e.message).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
logger.error(`[Auth] Registration failed for user: ${req.body.username}`, {
|
||||
const username =
|
||||
typeof req.body?.username === 'string' ? req.body.username.substring(0, 50) : 'unknown';
|
||||
logger.error(`[Auth] Registration failed for user: ${username}`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
@@ -217,11 +275,16 @@ router.post(
|
||||
*/
|
||||
router.post(
|
||||
'/logout',
|
||||
softAuth,
|
||||
logActivity('auth.logout', 'auth'),
|
||||
asyncHandler(async (req, res) => {
|
||||
const { refreshToken } = refreshTokenSchema.parse(req.body);
|
||||
const refreshToken = getRefreshTokenFromCookie(req.cookies);
|
||||
|
||||
await authService.logout(refreshToken);
|
||||
if (refreshToken) {
|
||||
await authService.logout(refreshToken);
|
||||
}
|
||||
|
||||
clearRefreshTokenCookie(res);
|
||||
|
||||
res.json({ success: true, message: 'Logged out successfully' });
|
||||
})
|
||||
@@ -233,12 +296,23 @@ router.post(
|
||||
*/
|
||||
router.post(
|
||||
'/refresh',
|
||||
refreshLimiter,
|
||||
asyncHandler(async (req, res) => {
|
||||
const { refreshToken } = refreshTokenSchema.parse(req.body);
|
||||
const refreshToken = getRefreshTokenFromCookie(req.cookies);
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new AppError(401, 'No refresh token provided');
|
||||
}
|
||||
|
||||
const tokens = await authService.refreshToken(refreshToken);
|
||||
|
||||
res.json(tokens);
|
||||
// Set new refresh token cookie (token rotation)
|
||||
setRefreshTokenCookie(res, tokens.refreshToken);
|
||||
|
||||
res.json({
|
||||
accessToken: tokens.accessToken,
|
||||
expiresIn: tokens.expiresIn,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
+26
-15
@@ -47,23 +47,34 @@ router.post(
|
||||
asyncHandler(async (req, res) => {
|
||||
const { cacheType } = req.body;
|
||||
|
||||
// Validate cacheType
|
||||
const validCacheTypes = ['gameSearch', 'permissions', 'all'];
|
||||
if (cacheType && !validCacheTypes.includes(cacheType)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid cacheType. Must be one of: gameSearch, permissions, all',
|
||||
});
|
||||
}
|
||||
|
||||
const results: Record<string, boolean> = {};
|
||||
|
||||
if (!cacheType || cacheType === 'gameSearch') {
|
||||
GameSearchCache.clearCache();
|
||||
results.gameSearch = true;
|
||||
}
|
||||
|
||||
if (!cacheType || cacheType === 'permissions') {
|
||||
PermissionCache.clearAll();
|
||||
results.permissions = true;
|
||||
}
|
||||
|
||||
if (!cacheType || cacheType === 'all') {
|
||||
GameSearchCache.clearCache();
|
||||
PermissionCache.clearAll();
|
||||
results.gameSearch = true;
|
||||
results.permissions = true;
|
||||
// Use switch to fix mutually exclusive cache clearing logic
|
||||
switch (cacheType) {
|
||||
case 'gameSearch':
|
||||
GameSearchCache.clearCache();
|
||||
results.gameSearch = true;
|
||||
break;
|
||||
case 'permissions':
|
||||
PermissionCache.clearAll();
|
||||
results.permissions = true;
|
||||
break;
|
||||
default:
|
||||
// Clear all caches when cacheType is 'all' or undefined
|
||||
GameSearchCache.clearCache();
|
||||
PermissionCache.clearAll();
|
||||
results.gameSearch = true;
|
||||
results.permissions = true;
|
||||
break;
|
||||
}
|
||||
|
||||
res.json({
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Router } from 'express';
|
||||
import { CommunityPlaylistService } from '../services/CommunityPlaylistService';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
CommunityPlaylistService,
|
||||
ALLOWED_DOWNLOAD_DOMAINS,
|
||||
} from '../services/CommunityPlaylistService';
|
||||
import { AppError } from '../middleware/errorHandler';
|
||||
import { asyncHandler } from '../middleware/asyncHandler';
|
||||
import { optionalAuth } from '../middleware/auth';
|
||||
@@ -8,6 +12,11 @@ import { logActivity } from '../middleware/activityLogger';
|
||||
const router = Router();
|
||||
const communityPlaylistService = new CommunityPlaylistService();
|
||||
|
||||
// Validation schema
|
||||
const downloadSchema = z.object({
|
||||
downloadUrl: z.string().url().max(2000),
|
||||
});
|
||||
|
||||
// GET /api/community-playlists - Fetch list of community playlists from wiki
|
||||
router.get(
|
||||
'/',
|
||||
@@ -39,10 +48,25 @@ router.post(
|
||||
gameCount: res.locals.gameCount || 0,
|
||||
})),
|
||||
asyncHandler(async (req, res) => {
|
||||
const { downloadUrl } = req.body;
|
||||
// Validate request body
|
||||
const { downloadUrl } = downloadSchema.parse(req.body);
|
||||
|
||||
if (!downloadUrl) {
|
||||
throw new AppError(400, 'downloadUrl is required');
|
||||
try {
|
||||
const parsedUrl = new URL(downloadUrl);
|
||||
const isAllowed = ALLOWED_DOWNLOAD_DOMAINS.some(
|
||||
(domain) => parsedUrl.hostname === domain || parsedUrl.hostname.endsWith('.' + domain)
|
||||
);
|
||||
if (!isAllowed) {
|
||||
throw new AppError(400, 'Download URL domain not allowed');
|
||||
}
|
||||
if (parsedUrl.protocol !== 'https:') {
|
||||
throw new AppError(400, 'Only HTTPS URLs are allowed');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AppError(400, 'Invalid URL');
|
||||
}
|
||||
|
||||
const result = await communityPlaylistService.downloadPlaylist(downloadUrl);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import fs from 'fs';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { requirePermission } from '../middleware/rbac';
|
||||
import { logActivity } from '../middleware/activityLogger';
|
||||
import { asyncHandler } from '../middleware/asyncHandler';
|
||||
import { logger } from '../utils/logger';
|
||||
import { config } from '../config';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -28,7 +31,7 @@ router.post(
|
||||
authenticate,
|
||||
requirePermission('settings.update'),
|
||||
logActivity('database.reload', 'database'),
|
||||
async (req: Request, res: Response) => {
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
try {
|
||||
logger.info('Manual database reload requested');
|
||||
|
||||
@@ -45,7 +48,7 @@ router.post(
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -68,11 +71,8 @@ router.get(
|
||||
authenticate,
|
||||
requirePermission('settings.update'),
|
||||
logActivity('database.status', 'database'),
|
||||
async (req: Request, res: Response) => {
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const { config } = require('../config');
|
||||
|
||||
const isConnected = DatabaseService.isConnected();
|
||||
const stats = fs.statSync(config.flashpointDbPath);
|
||||
|
||||
@@ -91,7 +91,7 @@ router.get(
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
+266
-248
@@ -1,4 +1,5 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { DownloadManager } from '../services/DownloadManager';
|
||||
import { PreferencesService } from '../services/PreferencesService';
|
||||
import { GameDatabaseUpdater } from '../services/GameDatabaseUpdater';
|
||||
@@ -7,8 +8,16 @@ import { ActivityService } from '../services/ActivityService';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { requirePermission } from '../middleware/rbac';
|
||||
import { logActivity } from '../middleware/activityLogger';
|
||||
import { asyncHandler } from '../middleware/asyncHandler';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* Zod schema for POST /:id/download request body
|
||||
*/
|
||||
const downloadBodySchema = z.object({
|
||||
gameDataId: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
const router = Router();
|
||||
const gameService = new GameService();
|
||||
const activityService = new ActivityService();
|
||||
@@ -40,131 +49,133 @@ router.post(
|
||||
gameTitle: res.locals.gameTitle,
|
||||
sources: res.locals.sources,
|
||||
})),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const gameId = req.params.id;
|
||||
const { gameDataId: requestedGameDataId } = req.body;
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const gameId = req.params.id;
|
||||
|
||||
logger.info('Download request received', { gameId, requestedGameDataId });
|
||||
|
||||
// Get game from database
|
||||
const game = await gameService.getGameById(gameId);
|
||||
if (!game) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Game not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Determine which game data to download
|
||||
let gameDataId = requestedGameDataId;
|
||||
if (!gameDataId) {
|
||||
// Query game_data table to find the game data entry for this game
|
||||
const foundGameDataId = await gameService.getGameDataId(gameId);
|
||||
if (!foundGameDataId) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Game has no game data to download',
|
||||
});
|
||||
}
|
||||
gameDataId = foundGameDataId;
|
||||
}
|
||||
|
||||
// Check if already downloaded
|
||||
const isDownloaded = await GameDatabaseUpdater.isDownloaded(gameDataId);
|
||||
if (isDownloaded) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
error: 'Game data already downloaded',
|
||||
});
|
||||
}
|
||||
|
||||
// Get game data info
|
||||
const gameData = await GameDatabaseUpdater.getGameData(gameDataId);
|
||||
if (!gameData) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Game data not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Get data sources from preferences
|
||||
const sources = await PreferencesService.getGameDataSources();
|
||||
if (sources.length === 0) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error:
|
||||
'No game data sources configured. Please configure gameDataSources in preferences.json',
|
||||
});
|
||||
}
|
||||
|
||||
// Store metadata for activity logging
|
||||
res.locals.gameDataId = gameDataId;
|
||||
res.locals.gameTitle = game.title;
|
||||
res.locals.sources = sources.map((s) => s.name);
|
||||
|
||||
// Capture metadata for background error logging
|
||||
const downloadMetadata = {
|
||||
gameId,
|
||||
gameDataId,
|
||||
gameTitle: game.title,
|
||||
sha256: gameData.sha256,
|
||||
sources: sources.map((s) => s.name),
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
ipAddress: req.ip,
|
||||
};
|
||||
|
||||
// Start download (non-blocking)
|
||||
// We don't wait for it to complete - client uses SSE endpoint for progress
|
||||
DownloadManager.downloadGameData(
|
||||
gameDataId,
|
||||
sources,
|
||||
undefined, // Progress callback handled by SSE endpoint
|
||||
undefined, // Details callback handled by SSE endpoint
|
||||
undefined // No abort signal for now
|
||||
).catch(async (error) => {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('Background download failed', {
|
||||
gameId,
|
||||
gameDataId,
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
// Log download failure activity
|
||||
await activityService.log({
|
||||
userId: downloadMetadata.userId,
|
||||
username: downloadMetadata.username,
|
||||
action: 'games.download.failed',
|
||||
resource: 'games',
|
||||
resourceId: downloadMetadata.gameId,
|
||||
details: {
|
||||
gameDataId: downloadMetadata.gameDataId,
|
||||
gameTitle: downloadMetadata.gameTitle,
|
||||
sha256: downloadMetadata.sha256,
|
||||
sources: downloadMetadata.sources,
|
||||
error: errorMessage,
|
||||
},
|
||||
ipAddress: downloadMetadata.ipAddress,
|
||||
userAgent: req.get('user-agent'),
|
||||
});
|
||||
});
|
||||
|
||||
// Return success immediately
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Download started',
|
||||
gameDataId,
|
||||
sha256: gameData.sha256,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Download endpoint error:', error);
|
||||
res.status(500).json({
|
||||
// Validate request body
|
||||
const bodyResult = downloadBodySchema.safeParse(req.body);
|
||||
if (!bodyResult.success) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
error: `Invalid request body: ${bodyResult.error.issues[0].message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const { gameDataId: requestedGameDataId } = bodyResult.data;
|
||||
|
||||
logger.info('Download request received', { gameId, requestedGameDataId });
|
||||
|
||||
// Get game from database
|
||||
const game = await gameService.getGameById(gameId);
|
||||
if (!game) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Game not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Determine which game data to download
|
||||
let gameDataId = requestedGameDataId;
|
||||
if (!gameDataId) {
|
||||
// Query game_data table to find the game data entry for this game
|
||||
const foundGameDataId = await gameService.getGameDataId(gameId);
|
||||
if (!foundGameDataId) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Game has no game data to download',
|
||||
});
|
||||
}
|
||||
gameDataId = foundGameDataId;
|
||||
}
|
||||
|
||||
// Check if already downloaded
|
||||
const isDownloaded = await GameDatabaseUpdater.isDownloaded(gameDataId);
|
||||
if (isDownloaded) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
error: 'Game data already downloaded',
|
||||
});
|
||||
}
|
||||
|
||||
// Get game data info
|
||||
const gameData = await GameDatabaseUpdater.getGameData(gameDataId);
|
||||
if (!gameData) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Game data not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Get data sources from preferences
|
||||
const sources = await PreferencesService.getGameDataSources();
|
||||
if (sources.length === 0) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error:
|
||||
'No game data sources configured. Please configure gameDataSources in preferences.json',
|
||||
});
|
||||
}
|
||||
|
||||
// Store metadata for activity logging
|
||||
res.locals.gameDataId = gameDataId;
|
||||
res.locals.gameTitle = game.title;
|
||||
res.locals.sources = sources.map((s) => s.name);
|
||||
|
||||
// Capture metadata for background error logging
|
||||
const downloadMetadata = {
|
||||
gameId,
|
||||
gameDataId,
|
||||
gameTitle: game.title,
|
||||
sha256: gameData.sha256,
|
||||
sources: sources.map((s) => s.name),
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
ipAddress: req.ip,
|
||||
};
|
||||
|
||||
// Start download (non-blocking)
|
||||
// We don't wait for it to complete - client uses SSE endpoint for progress
|
||||
DownloadManager.downloadGameData(
|
||||
gameDataId,
|
||||
sources,
|
||||
undefined, // Progress callback handled by SSE endpoint
|
||||
undefined, // Details callback handled by SSE endpoint
|
||||
undefined // No abort signal for now
|
||||
).catch(async (error) => {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('Background download failed', {
|
||||
gameId,
|
||||
gameDataId,
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
// Log download failure activity
|
||||
await activityService.log({
|
||||
userId: downloadMetadata.userId,
|
||||
username: downloadMetadata.username,
|
||||
action: 'games.download.failed',
|
||||
resource: 'games',
|
||||
resourceId: downloadMetadata.gameId,
|
||||
details: {
|
||||
gameDataId: downloadMetadata.gameDataId,
|
||||
gameTitle: downloadMetadata.gameTitle,
|
||||
sha256: downloadMetadata.sha256,
|
||||
sources: downloadMetadata.sources,
|
||||
error: errorMessage,
|
||||
},
|
||||
ipAddress: downloadMetadata.ipAddress,
|
||||
userAgent: req.get('user-agent'),
|
||||
});
|
||||
});
|
||||
|
||||
// Return success immediately
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Download started',
|
||||
gameDataId,
|
||||
sha256: gameData.sha256,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -188,65 +199,73 @@ router.get(
|
||||
'/:id/download/progress',
|
||||
authenticate,
|
||||
requirePermission('games.download'),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const gameId = req.params.id;
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const gameId = req.params.id;
|
||||
|
||||
// Set SSE headers
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering
|
||||
// Set SSE headers
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering
|
||||
|
||||
logger.info('SSE connection established', { gameId });
|
||||
logger.info('SSE connection established', { gameId });
|
||||
|
||||
// Get game to find gameDataId
|
||||
const game = await gameService.getGameById(gameId);
|
||||
if (!game) {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
status: 'error',
|
||||
error: 'Game not found',
|
||||
})}\n\n`
|
||||
);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const gameDataId = await gameService.getGameDataId(gameId);
|
||||
if (!gameDataId) {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
status: 'error',
|
||||
error: 'Game has no game data',
|
||||
})}\n\n`
|
||||
);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Send initial status
|
||||
// Get game to find gameDataId
|
||||
const game = await gameService.getGameById(gameId);
|
||||
if (!game) {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
percent: 0,
|
||||
status: 'waiting',
|
||||
details: 'Preparing download...',
|
||||
status: 'error',
|
||||
error: 'Game not found',
|
||||
})}\n\n`
|
||||
);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Poll for download progress
|
||||
// Note: This is a simplified implementation. In production, you'd want
|
||||
// a proper event emitter system to push updates rather than polling.
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
// Check if download is still active
|
||||
const isActive = DownloadManager.isDownloadActive(gameDataId);
|
||||
const gameDataId = await gameService.getGameDataId(gameId);
|
||||
if (!gameDataId) {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
status: 'error',
|
||||
error: 'Game has no game data',
|
||||
})}\n\n`
|
||||
);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isActive) {
|
||||
// Check if download completed successfully
|
||||
const isDownloaded = await GameDatabaseUpdater.isDownloaded(gameDataId);
|
||||
// Send initial status
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
percent: 0,
|
||||
status: 'waiting',
|
||||
details: 'Preparing download...',
|
||||
})}\n\n`
|
||||
);
|
||||
|
||||
if (isDownloaded) {
|
||||
// Poll for download progress
|
||||
// Note: This is a simplified implementation. In production, you'd want
|
||||
// a proper event emitter system to push updates rather than polling.
|
||||
let closed = false;
|
||||
|
||||
const pollInterval = setInterval(async () => {
|
||||
if (closed) return;
|
||||
|
||||
try {
|
||||
// Check if download is still active
|
||||
const isActive = DownloadManager.isDownloadActive(gameDataId);
|
||||
|
||||
if (closed) return; // Re-check after await
|
||||
|
||||
if (!isActive) {
|
||||
// Check if download completed successfully
|
||||
const isDownloaded = await GameDatabaseUpdater.isDownloaded(gameDataId);
|
||||
|
||||
if (closed) return; // Re-check after await
|
||||
|
||||
if (isDownloaded) {
|
||||
if (!closed) {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
percent: 100,
|
||||
@@ -254,24 +273,29 @@ router.get(
|
||||
details: 'Download completed successfully',
|
||||
})}\n\n`
|
||||
);
|
||||
}
|
||||
|
||||
// Log download completion activity
|
||||
const gameData = await GameDatabaseUpdater.getGameData(gameDataId);
|
||||
await activityService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
action: 'games.download.complete',
|
||||
resource: 'games',
|
||||
resourceId: gameId,
|
||||
details: {
|
||||
gameDataId,
|
||||
gameTitle: game.title,
|
||||
sha256: gameData?.sha256,
|
||||
},
|
||||
ipAddress: req.ip,
|
||||
userAgent: req.get('user-agent'),
|
||||
});
|
||||
} else {
|
||||
// Log download completion activity
|
||||
const gameData = await GameDatabaseUpdater.getGameData(gameDataId);
|
||||
|
||||
if (closed) return; // Re-check after await
|
||||
|
||||
await activityService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
action: 'games.download.complete',
|
||||
resource: 'games',
|
||||
resourceId: gameId,
|
||||
details: {
|
||||
gameDataId,
|
||||
gameTitle: game.title,
|
||||
sha256: gameData?.sha256,
|
||||
},
|
||||
ipAddress: req.ip,
|
||||
userAgent: req.get('user-agent'),
|
||||
});
|
||||
} else {
|
||||
if (!closed) {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
percent: 0,
|
||||
@@ -280,11 +304,15 @@ router.get(
|
||||
})}\n\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
clearInterval(pollInterval);
|
||||
clearInterval(pollInterval);
|
||||
if (!closed) {
|
||||
res.end();
|
||||
} else {
|
||||
// Send heartbeat
|
||||
}
|
||||
} else {
|
||||
// Send heartbeat
|
||||
if (!closed) {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
percent: 0,
|
||||
@@ -293,27 +321,25 @@ router.get(
|
||||
})}\n\n`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
} catch (error) {
|
||||
if (!closed) {
|
||||
logger.error('Error in SSE poll', { error });
|
||||
clearInterval(pollInterval);
|
||||
}
|
||||
clearInterval(pollInterval);
|
||||
if (!closed) {
|
||||
res.end();
|
||||
}
|
||||
}, 1000); // Poll every second
|
||||
}
|
||||
}, 1000); // Poll every second
|
||||
|
||||
// Clean up on client disconnect
|
||||
req.on('close', () => {
|
||||
logger.info('SSE connection closed', { gameId });
|
||||
clearInterval(pollInterval);
|
||||
res.end();
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('SSE endpoint error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
// Clean up on client disconnect
|
||||
req.on('close', () => {
|
||||
closed = true;
|
||||
logger.info('SSE connection closed', { gameId });
|
||||
clearInterval(pollInterval);
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -334,52 +360,44 @@ router.delete(
|
||||
authenticate,
|
||||
requirePermission('games.download'),
|
||||
logActivity('games.download_cancel', 'games'),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const gameId = req.params.id;
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const gameId = req.params.id;
|
||||
|
||||
logger.info('Download cancel request', { gameId });
|
||||
logger.info('Download cancel request', { gameId });
|
||||
|
||||
// Get game to find gameDataId
|
||||
const game = await gameService.getGameById(gameId);
|
||||
if (!game) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Game not found',
|
||||
});
|
||||
}
|
||||
|
||||
const gameDataId = await gameService.getGameDataId(gameId);
|
||||
if (!gameDataId) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Game has no game data',
|
||||
});
|
||||
}
|
||||
|
||||
// Attempt to cancel download
|
||||
const cancelled = DownloadManager.cancelDownload(gameDataId);
|
||||
|
||||
if (!cancelled) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'No active download found for this game',
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
cancelled: true,
|
||||
message: 'Download cancelled successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Cancel download error:', error);
|
||||
res.status(500).json({
|
||||
// Get game to find gameDataId
|
||||
const game = await gameService.getGameById(gameId);
|
||||
if (!game) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
error: 'Game not found',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const gameDataId = await gameService.getGameDataId(gameId);
|
||||
if (!gameDataId) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Game has no game data',
|
||||
});
|
||||
}
|
||||
|
||||
// Attempt to cancel download
|
||||
const cancelled = DownloadManager.cancelDownload(gameDataId);
|
||||
|
||||
if (!cancelled) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'No active download found for this game',
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
cancelled: true,
|
||||
message: 'Download cancelled successfully',
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -3,11 +3,28 @@ import { promises as fs } from 'fs';
|
||||
import fsSync from 'fs';
|
||||
import path from 'path';
|
||||
import winston from 'winston';
|
||||
import { z } from 'zod';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { requirePermission } from '../middleware/rbac';
|
||||
import { rateLimitStrict } from '../middleware/rateLimiter';
|
||||
import { asyncHandler } from '../middleware/asyncHandler';
|
||||
import { logger } from '../utils/logger';
|
||||
import { config } from '../config';
|
||||
|
||||
/**
|
||||
* Zod schema for error report validation
|
||||
* Enforces strict types and size limits on client-submitted error reports
|
||||
*/
|
||||
const errorReportSchema = z.object({
|
||||
type: z.enum(['client_error', 'network_error', 'api_error', 'route_error']),
|
||||
message: z.string().max(2000),
|
||||
stack: z.string().max(5000).optional(),
|
||||
url: z.string().max(2000),
|
||||
timestamp: z.string().optional(),
|
||||
userAgent: z.string().optional(),
|
||||
context: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Client error logs go to the same directory as backend logs
|
||||
@@ -75,17 +92,6 @@ interface ErrorReport {
|
||||
|
||||
type AuthenticatedRequest = Request & { user?: { id: number } };
|
||||
|
||||
/**
|
||||
* Ensure logs directory exists
|
||||
*/
|
||||
async function ensureLogDir(): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(LOG_DIR, { recursive: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to create logs directory:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error report to file in OTEL-compatible JSON format
|
||||
*/
|
||||
@@ -110,8 +116,6 @@ async function logError(report: ErrorReport): Promise<void> {
|
||||
*/
|
||||
async function getRecentErrors(limit: number = 100): Promise<ErrorReport[]> {
|
||||
try {
|
||||
await ensureLogDir();
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(ERROR_LOG_FILE, 'utf8');
|
||||
const lines = content.trim().split('\n');
|
||||
@@ -164,54 +168,60 @@ async function getRecentErrors(limit: number = 100): Promise<ErrorReport[]> {
|
||||
* Report a client-side error
|
||||
* Public endpoint (no authentication required, but rate-limited)
|
||||
*/
|
||||
router.post('/report', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const report: ErrorReport = req.body;
|
||||
router.post(
|
||||
'/report',
|
||||
rateLimitStrict,
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
try {
|
||||
// Validate request body with Zod schema
|
||||
const parseResult = errorReportSchema.safeParse(req.body);
|
||||
if (!parseResult.success) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: `Invalid error report: ${parseResult.error.issues[0].message}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!report.type || !report.message || !report.url) {
|
||||
return res.status(400).json({
|
||||
const validatedData = parseResult.data;
|
||||
|
||||
// Build report from validated data
|
||||
const report: ErrorReport = {
|
||||
type: validatedData.type,
|
||||
message: validatedData.message,
|
||||
stack: validatedData.stack,
|
||||
url: validatedData.url,
|
||||
timestamp: validatedData.timestamp || new Date().toISOString(),
|
||||
userAgent: validatedData.userAgent || req.headers['user-agent'] || 'Unknown',
|
||||
context: validatedData.context,
|
||||
};
|
||||
|
||||
// Add user ID if authenticated
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
if (authReq.user) {
|
||||
report.userId = authReq.user.id;
|
||||
}
|
||||
|
||||
// Log to file
|
||||
await logError(report);
|
||||
|
||||
logger.info(`[Client Error] ${report.type} - ${report.message} (${report.url})`);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Error in /errors/report:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Missing required fields: type, message, url',
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'Failed to log error report',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add user ID if authenticated
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
if (authReq.user) {
|
||||
report.userId = authReq.user.id;
|
||||
}
|
||||
|
||||
// Add timestamp if not provided
|
||||
if (!report.timestamp) {
|
||||
report.timestamp = new Date().toISOString();
|
||||
}
|
||||
|
||||
// Add user agent if not provided
|
||||
if (!report.userAgent) {
|
||||
report.userAgent = req.headers['user-agent'] || 'Unknown';
|
||||
}
|
||||
|
||||
// Log to file
|
||||
await logError(report);
|
||||
|
||||
logger.info(`[Client Error] ${report.type} - ${report.message} (${report.url})`);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Error in /errors/report:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'Failed to log error report',
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/errors/recent
|
||||
@@ -222,9 +232,9 @@ router.get(
|
||||
'/recent',
|
||||
authenticate,
|
||||
requirePermission('settings.update'),
|
||||
async (req: Request, res: Response) => {
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit as string) || 100;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 100, 1000);
|
||||
const errors = await getRecentErrors(limit);
|
||||
|
||||
res.json({
|
||||
@@ -245,7 +255,7 @@ router.get(
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -257,7 +267,7 @@ router.get(
|
||||
'/stats',
|
||||
authenticate,
|
||||
requirePermission('settings.update'),
|
||||
async (req: Request, res: Response) => {
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
try {
|
||||
const errors = await getRecentErrors(1000);
|
||||
|
||||
@@ -310,7 +320,7 @@ router.get(
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -24,11 +24,11 @@ const toggleFavoriteSchema = z.object({
|
||||
});
|
||||
|
||||
const batchAddSchema = z.object({
|
||||
gameIds: z.array(z.string()).min(1),
|
||||
gameIds: z.array(z.string()).min(1).max(500),
|
||||
});
|
||||
|
||||
const batchRemoveSchema = z.object({
|
||||
gameIds: z.array(z.string()).min(1),
|
||||
gameIds: z.array(z.string()).min(1).max(500),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -44,8 +44,12 @@ router.get(
|
||||
throw new AppError(401, 'Authentication required');
|
||||
}
|
||||
|
||||
const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : undefined;
|
||||
const offset = req.query.offset ? parseInt(req.query.offset as string, 10) : undefined;
|
||||
const limit = req.query.limit
|
||||
? Math.min(parseInt(req.query.limit as string, 10) || 50, 100)
|
||||
: undefined;
|
||||
const offset = req.query.offset
|
||||
? Math.max(0, parseInt(req.query.offset as string, 10) || 0)
|
||||
: undefined;
|
||||
|
||||
const favorites = favoritesService.getUserFavorites(req.user.id, limit, offset);
|
||||
res.json(favorites);
|
||||
@@ -83,10 +87,22 @@ router.get(
|
||||
throw new AppError(401, 'Authentication required');
|
||||
}
|
||||
|
||||
const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : undefined;
|
||||
const offset = req.query.offset ? parseInt(req.query.offset as string, 10) : undefined;
|
||||
const sortBy = (req.query.sortBy as 'title' | 'dateAdded') || 'dateAdded';
|
||||
const sortOrder = (req.query.sortOrder as 'asc' | 'desc') || 'desc';
|
||||
const limit = req.query.limit
|
||||
? Math.min(parseInt(req.query.limit as string, 10) || 50, 100)
|
||||
: undefined;
|
||||
const offset = req.query.offset
|
||||
? Math.max(0, parseInt(req.query.offset as string, 10) || 0)
|
||||
: undefined;
|
||||
|
||||
// Validate sortBy
|
||||
const allowedSortBy = ['title', 'dateAdded'];
|
||||
const sortBy =
|
||||
req.query.sortBy && allowedSortBy.includes(req.query.sortBy as string)
|
||||
? (req.query.sortBy as 'title' | 'dateAdded')
|
||||
: 'dateAdded';
|
||||
|
||||
// Validate sortOrder
|
||||
const sortOrder = req.query.sortOrder === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
const games = await favoritesService.getUserFavoriteGames(
|
||||
req.user.id,
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { GameFileService } from '../services/GameFileService';
|
||||
import { AppError } from '../middleware/errorHandler';
|
||||
import { asyncHandler } from '../middleware/asyncHandler';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const router = Router();
|
||||
const gameFileService = new GameFileService();
|
||||
|
||||
/**
|
||||
* GET /game-files/* - Proxy game file requests with automatic fallback
|
||||
*
|
||||
* Examples:
|
||||
* - /game-files/www.example.com/path/file.swf
|
||||
* - /game-files/cdn.games.com/assets/game.swf
|
||||
*/
|
||||
router.get(
|
||||
'/*',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
try {
|
||||
// Extract path after /game-files/
|
||||
const urlPath = req.path.substring(1); // Remove leading slash
|
||||
|
||||
if (!urlPath) {
|
||||
throw new AppError(400, 'File path is required');
|
||||
}
|
||||
|
||||
logger.info(`[GameFilesRoute] Request: ${urlPath}`);
|
||||
|
||||
// Get the file (tries Game Server first, then external sources)
|
||||
const result = await gameFileService.getGameFile(urlPath);
|
||||
|
||||
logger.info(`[GameFilesRoute] Serving from ${result.source}: ${result.data.length} bytes`);
|
||||
|
||||
// Set headers
|
||||
res.setHeader('Content-Type', result.contentType);
|
||||
res.setHeader('Content-Length', result.data.length);
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400'); // Cache for 24 hours
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('X-Source', result.source); // Debug header
|
||||
|
||||
// Send file
|
||||
res.send(result.data);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
throw new AppError(404, 'Game file not found in any source');
|
||||
} else {
|
||||
logger.error('[GameFilesRoute] Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { IncomingMessage, ServerResponse } from 'http';
|
||||
import { ProxyRequestHandler } from '../game/proxy-request-handler';
|
||||
import { logger } from '../utils/logger';
|
||||
import { asyncHandler } from '../middleware/asyncHandler';
|
||||
|
||||
const router = Router();
|
||||
|
||||
let proxyHandler: ProxyRequestHandler | null = null;
|
||||
|
||||
function getHandler(): ProxyRequestHandler {
|
||||
if (!proxyHandler) {
|
||||
proxyHandler = new ProxyRequestHandler();
|
||||
}
|
||||
return proxyHandler;
|
||||
}
|
||||
|
||||
// Handle all game content requests: /game-proxy/*
|
||||
// Express strips the mount path, so req.url is everything after /game-proxy
|
||||
// e.g., /game-proxy/http://domain.com/file.swf → req.url = /http://domain.com/file.swf
|
||||
router.all(
|
||||
'/*',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const handler = getHandler();
|
||||
|
||||
// OPTIONS requests for CORS preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
// Cast Express types to Node HTTP types for compatibility
|
||||
// Double cast needed: Express.Request doesn't directly extend IncomingMessage
|
||||
handler.handleOptionsRequest(
|
||||
req as unknown as IncomingMessage,
|
||||
res as unknown as ServerResponse
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delegate to ProxyRequestHandler
|
||||
// Double cast needed: Express.Request doesn't directly extend IncomingMessage
|
||||
await handler.handleRequest(
|
||||
req as unknown as IncomingMessage,
|
||||
res as unknown as ServerResponse
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Router, Request, Response, json, NextFunction } from 'express';
|
||||
import { IncomingMessage, ServerResponse } from 'http';
|
||||
import { z } from 'zod';
|
||||
import { gameZipServer } from '../game/gamezipserver';
|
||||
import { logger } from '../utils/logger';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { requirePermission } from '../middleware/rbac';
|
||||
import { config } from '../config';
|
||||
import { asyncHandler } from '../middleware/asyncHandler';
|
||||
|
||||
/**
|
||||
* Zod schema for POST /mount/:id request body
|
||||
*/
|
||||
const mountBodySchema = z.object({
|
||||
zipPath: z.string().min(1).max(500),
|
||||
gameId: z.string().optional(),
|
||||
dateAdded: z.string().optional(),
|
||||
sha256: z.string().optional(),
|
||||
});
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Restrictive CORS middleware for admin routes
|
||||
const adminCors = (req: Request, res: Response, next: NextFunction) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', config.domain);
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.status(204).end();
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
// Mount a ZIP: POST /game-zip/mount/:id
|
||||
router.post(
|
||||
'/mount/:id',
|
||||
adminCors,
|
||||
authenticate,
|
||||
requirePermission('settings.update'),
|
||||
json(),
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
// Validate request body
|
||||
const bodyResult = mountBodySchema.safeParse(req.body);
|
||||
if (!bodyResult.success) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `Invalid request body: ${bodyResult.error.issues[0].message}`,
|
||||
});
|
||||
}
|
||||
|
||||
const { zipPath, gameId, dateAdded, sha256 } = bodyResult.data;
|
||||
|
||||
const result = await gameZipServer.mountZip({
|
||||
id: req.params.id,
|
||||
zipPath,
|
||||
gameId,
|
||||
dateAdded,
|
||||
sha256,
|
||||
});
|
||||
res.status(result.statusCode).json(result);
|
||||
})
|
||||
);
|
||||
|
||||
// Unmount a ZIP: DELETE /game-zip/mount/:id
|
||||
router.delete(
|
||||
'/mount/:id',
|
||||
adminCors,
|
||||
authenticate,
|
||||
requirePermission('settings.update'),
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const success = await gameZipServer.unmountZip(req.params.id);
|
||||
res.status(success ? 200 : 404).json({ success, id: req.params.id });
|
||||
})
|
||||
);
|
||||
|
||||
// List mounted ZIPs: GET /game-zip/mounts
|
||||
router.get(
|
||||
'/mounts',
|
||||
adminCors,
|
||||
authenticate,
|
||||
requirePermission('settings.update'),
|
||||
(_req: Request, res: Response) => {
|
||||
res.json({ mounts: gameZipServer.listMounts() });
|
||||
}
|
||||
);
|
||||
|
||||
// Serve files from mounted ZIPs: GET /game-zip/*
|
||||
// Handles proxy-style URLs like /game-zip/http://domain.com/path
|
||||
router.get('/*', async (req: Request, res: Response) => {
|
||||
try {
|
||||
// Delegate to gameZipServer's file handler
|
||||
await gameZipServer.handleFileRequest(
|
||||
req as unknown as IncomingMessage,
|
||||
res as unknown as ServerResponse
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('[GameZip] File request error:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).send('Internal Server Error');
|
||||
} else if (!res.writableEnded) {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
+32
-33
@@ -7,7 +7,7 @@ import { asyncHandler } from '../middleware/asyncHandler';
|
||||
import { sharedAccessAuth, validateSharedGameAccess } from '../middleware/auth';
|
||||
import { logActivity } from '../middleware/activityLogger';
|
||||
import { rateLimitStandard } from '../middleware/rateLimiter';
|
||||
import { config } from '../config';
|
||||
import { logger } from '../utils/logger';
|
||||
import { z } from 'zod';
|
||||
|
||||
const router = Router();
|
||||
@@ -101,6 +101,7 @@ router.get(
|
||||
limit: query.limit,
|
||||
showBroken: query.showBroken,
|
||||
showExtreme: query.showExtreme,
|
||||
fields: 'list', // Only return essential columns for browse/search views
|
||||
});
|
||||
|
||||
// Store result count for activity logging
|
||||
@@ -123,7 +124,7 @@ router.get(
|
||||
router.get(
|
||||
'/most-played',
|
||||
asyncHandler(async (req, res) => {
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
|
||||
const limit = Math.max(1, Math.min(parseInt(req.query.limit as string) || 20, 50));
|
||||
const games = await gameService.getMostPlayedGames(limit);
|
||||
|
||||
res.json({
|
||||
@@ -134,6 +135,23 @@ router.get(
|
||||
})
|
||||
);
|
||||
|
||||
// GET /api/games/random - Get random game
|
||||
// IMPORTANT: Must be defined BEFORE /:id to avoid being shadowed by the wildcard
|
||||
router.get(
|
||||
'/random',
|
||||
asyncHandler(async (req, res) => {
|
||||
const librarySchema = z.enum(['arcade', 'theatre']).optional();
|
||||
const library = librarySchema.parse(req.query.library);
|
||||
const game = await gameService.getRandomGame(library);
|
||||
|
||||
if (!game) {
|
||||
throw new AppError(404, 'No games found');
|
||||
}
|
||||
|
||||
res.json(game);
|
||||
})
|
||||
);
|
||||
|
||||
// GET /api/games/:id - Get single game by ID
|
||||
router.get(
|
||||
'/:id',
|
||||
@@ -163,28 +181,13 @@ router.get(
|
||||
'/:id/related',
|
||||
validateSharedGameAccess('id'),
|
||||
asyncHandler(async (req, res) => {
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 10, 50);
|
||||
const limit = Math.max(1, Math.min(parseInt(req.query.limit as string) || 10, 50));
|
||||
const relatedGames = await gameService.getRelatedGames(req.params.id, limit);
|
||||
|
||||
res.json(relatedGames);
|
||||
})
|
||||
);
|
||||
|
||||
// GET /api/games/random - Get random game
|
||||
router.get(
|
||||
'/random',
|
||||
asyncHandler(async (req, res) => {
|
||||
const library = req.query.library as string | undefined;
|
||||
const game = await gameService.getRandomGame(library);
|
||||
|
||||
if (!game) {
|
||||
throw new AppError(404, 'No games found');
|
||||
}
|
||||
|
||||
res.json(game);
|
||||
})
|
||||
);
|
||||
|
||||
// GET /api/games/:id/launch - Get game launch data
|
||||
router.get(
|
||||
'/:id/launch',
|
||||
@@ -201,18 +204,16 @@ router.get(
|
||||
throw new AppError(404, 'Game not found');
|
||||
}
|
||||
|
||||
// Auto-mount ZIP file if game has game data
|
||||
// This ensures files are available from the GameZip server
|
||||
// Check if game has data by checking presentOnDisk field (not null means has game_data entries)
|
||||
if (game.presentOnDisk !== null) {
|
||||
await gameDataService.mountGameZip(game.id);
|
||||
}
|
||||
// Mount the game ZIP (awaited so local ZIPs are ready before we return the URL).
|
||||
// For downloads, mountGameZip returns quickly with { mounted: false, downloading: true }.
|
||||
const mountResult = await gameDataService.mountGameZip(game.id);
|
||||
const downloading = mountResult.downloading || false;
|
||||
|
||||
// Extract launch command (usually the SWF file path for Flash games)
|
||||
let launchCommand = game.launchCommand || '';
|
||||
|
||||
// If no launch command but has game data, query game_data table
|
||||
if (!launchCommand && game.presentOnDisk !== null) {
|
||||
// If no launch command, try to get path from game_data table
|
||||
if (!launchCommand) {
|
||||
const gameDataPath = await gameService.getGameDataPath(game.id);
|
||||
if (gameDataPath) {
|
||||
launchCommand = gameDataPath;
|
||||
@@ -225,15 +226,12 @@ router.get(
|
||||
|
||||
let contentUrl = '';
|
||||
if (launchCommand) {
|
||||
// Use the configured external game service URL
|
||||
// In Docker: This is /game-proxy (routed through nginx)
|
||||
// In local dev: This is http://localhost:22500 (direct access)
|
||||
const proxyUrl = config.gameServiceExternalUrl;
|
||||
// Game content is served via /game-proxy route (integrated in backend)
|
||||
const proxyUrl = '/game-proxy';
|
||||
|
||||
// Check if launch command is an absolute URL
|
||||
if (launchCommand.startsWith('http://') || launchCommand.startsWith('https://')) {
|
||||
// Use the full URL as-is with the proxy
|
||||
// The HTTP proxy server handles proxy-style requests: GET http://domain.com/path HTTP/1.1
|
||||
contentUrl = `${proxyUrl}/${launchCommand}`;
|
||||
} else {
|
||||
// For relative paths, construct full URL with domain from source
|
||||
@@ -246,8 +244,8 @@ router.get(
|
||||
const domain = sourceUrl.hostname;
|
||||
|
||||
// Construct full URL: http://domain/path
|
||||
const path = launchCommand.startsWith('/') ? launchCommand : `/${launchCommand}`;
|
||||
fullUrl = `http://${domain}${path}`;
|
||||
const urlPath = launchCommand.startsWith('/') ? launchCommand : `/${launchCommand}`;
|
||||
fullUrl = `http://${domain}${urlPath}`;
|
||||
} catch {
|
||||
// If source is not a valid URL, assume launchCommand is already domain/path
|
||||
fullUrl = launchCommand.startsWith('http') ? launchCommand : `http://${launchCommand}`;
|
||||
@@ -280,6 +278,7 @@ router.get(
|
||||
applicationPath: game.applicationPath,
|
||||
playMode: game.playMode,
|
||||
canPlayInBrowser,
|
||||
downloading,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { logger } from '../utils/logger';
|
||||
import { asyncHandler } from '../middleware/asyncHandler';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -8,8 +9,9 @@ const router = Router();
|
||||
* Proxy endpoint for fetching GitHub star count
|
||||
* Avoids CORS issues when calling GitHub API from browser
|
||||
*/
|
||||
router.get('/stars', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
router.get(
|
||||
'/stars',
|
||||
asyncHandler(async (_req: Request, res: Response) => {
|
||||
const repoUrl = 'https://api.github.com/repos/darkraise/flashpoint-web';
|
||||
|
||||
const response = await fetch(repoUrl, {
|
||||
@@ -31,15 +33,7 @@ router.get('/stars', async (_req: Request, res: Response) => {
|
||||
stars: data.stargazers_count,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch GitHub stars:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Failed to fetch GitHub star count',
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -13,6 +13,7 @@ const router = Router();
|
||||
* GET /api/health
|
||||
* Basic health check - publicly accessible
|
||||
* Returns minimal status information
|
||||
* NOTE: A simpler /health endpoint also exists for load balancer checks (registered before middleware)
|
||||
*/
|
||||
router.get('/', (req, res) => {
|
||||
const isHealthy = DatabaseService.isConnected() && UserDatabaseService.isConnected();
|
||||
@@ -138,8 +139,8 @@ router.get(
|
||||
},
|
||||
services: {
|
||||
gameService: {
|
||||
proxyUrl: config.gameServerUrl || 'http://localhost:22500',
|
||||
gameZipPort: config.gameServerHttpPort || 22501,
|
||||
status: 'integrated',
|
||||
routes: ['/game-proxy/*', '/game-zip/*'],
|
||||
},
|
||||
},
|
||||
healthCheckDuration: `${healthCheckTime}ms`,
|
||||
|
||||
@@ -5,7 +5,6 @@ import tagsRouter from './tags';
|
||||
import playlistsRouter from './playlists';
|
||||
import communityPlaylistsRouter from './community-playlists';
|
||||
import proxyRouter from './proxy';
|
||||
import gameFilesRouter from './game-files';
|
||||
import updatesRouter from './updates';
|
||||
import statisticsRouter from './statistics';
|
||||
import downloadsRouter from './downloads';
|
||||
@@ -85,9 +84,6 @@ export function setupRoutes(app: Express): void {
|
||||
app.use('/api/statistics', statisticsRouter);
|
||||
app.use('/api/database', databaseRouter);
|
||||
|
||||
// Game files proxy with automatic fallback
|
||||
app.use('/game-files', gameFilesRouter);
|
||||
|
||||
app.use('/proxy', proxyRouter);
|
||||
|
||||
// 404 handler
|
||||
|
||||
+37
-36
@@ -2,6 +2,7 @@ import { Router, Request, Response } from 'express';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { requirePermission } from '../middleware/rbac';
|
||||
import { logActivity } from '../middleware/activityLogger';
|
||||
import { asyncHandler } from '../middleware/asyncHandler';
|
||||
import { JobScheduler } from '../services/JobScheduler';
|
||||
import { JobExecutionService } from '../services/JobExecutionService';
|
||||
import { CachedSystemSettingsService } from '../services/CachedSystemSettingsService';
|
||||
@@ -18,7 +19,7 @@ router.get(
|
||||
'/',
|
||||
authenticate,
|
||||
requirePermission('settings.update'),
|
||||
async (req: Request, res: Response) => {
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
try {
|
||||
const jobs = JobScheduler.getAllJobsEnriched();
|
||||
res.json(jobs);
|
||||
@@ -26,7 +27,28 @@ router.get(
|
||||
logger.error('Failed to get jobs:', error);
|
||||
res.status(500).json({ error: { message: 'Failed to retrieve jobs' } });
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// ===================================
|
||||
// GET ALL EXECUTION LOGS
|
||||
// ===================================
|
||||
router.get(
|
||||
'/logs/all',
|
||||
authenticate,
|
||||
requirePermission('settings.update'),
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
try {
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 100, 200);
|
||||
const offset = Math.max(0, parseInt(req.query.offset as string, 10) || 0);
|
||||
|
||||
const result = executionService.getAllLogs(limit, offset);
|
||||
res.json({ ...result, limit, offset });
|
||||
} catch (error) {
|
||||
logger.error('Failed to get all logs:', error);
|
||||
res.status(500).json({ error: { message: 'Failed to retrieve logs' } });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// ===================================
|
||||
@@ -36,7 +58,7 @@ router.get(
|
||||
'/:jobId',
|
||||
authenticate,
|
||||
requirePermission('settings.update'),
|
||||
async (req: Request, res: Response) => {
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { jobId } = req.params;
|
||||
const job = JobScheduler.getJobStatusEnriched(jobId);
|
||||
@@ -50,7 +72,7 @@ router.get(
|
||||
logger.error(`Failed to get job ${req.params.jobId}:`, error);
|
||||
res.status(500).json({ error: { message: 'Failed to retrieve job' } });
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// ===================================
|
||||
@@ -61,7 +83,7 @@ router.patch(
|
||||
authenticate,
|
||||
requirePermission('settings.update'),
|
||||
logActivity('jobs.update', 'job'),
|
||||
async (req: Request, res: Response) => {
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { jobId } = req.params;
|
||||
const { enabled } = req.body;
|
||||
@@ -103,7 +125,7 @@ router.patch(
|
||||
error: { message: error instanceof Error ? error.message : 'Failed to update job' },
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// ===================================
|
||||
@@ -114,7 +136,7 @@ router.post(
|
||||
authenticate,
|
||||
requirePermission('settings.update'),
|
||||
logActivity('jobs.start', 'job'),
|
||||
async (req: Request, res: Response) => {
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { jobId } = req.params;
|
||||
JobScheduler.startJob(jobId);
|
||||
@@ -126,7 +148,7 @@ router.post(
|
||||
message: error instanceof Error ? error.message : 'Failed to start job',
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// ===================================
|
||||
@@ -137,7 +159,7 @@ router.post(
|
||||
authenticate,
|
||||
requirePermission('settings.update'),
|
||||
logActivity('jobs.stop', 'job'),
|
||||
async (req: Request, res: Response) => {
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { jobId } = req.params;
|
||||
JobScheduler.stopJob(jobId);
|
||||
@@ -149,7 +171,7 @@ router.post(
|
||||
message: error instanceof Error ? error.message : 'Failed to stop job',
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// ===================================
|
||||
@@ -160,7 +182,7 @@ router.post(
|
||||
authenticate,
|
||||
requirePermission('settings.update'),
|
||||
logActivity('jobs.trigger', 'job'),
|
||||
async (req: Request, res: Response) => {
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { jobId } = req.params;
|
||||
const userId = req.user!.id.toString();
|
||||
@@ -180,7 +202,7 @@ router.post(
|
||||
logger.error(`Failed to trigger job ${req.params.jobId}:`, error);
|
||||
res.status(500).json({ success: false, message });
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// ===================================
|
||||
@@ -190,11 +212,11 @@ router.get(
|
||||
'/:jobId/logs',
|
||||
authenticate,
|
||||
requirePermission('settings.update'),
|
||||
async (req: Request, res: Response) => {
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { jobId } = req.params;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
||||
const offset = parseInt(req.query.offset as string) || 0;
|
||||
const offset = Math.max(0, parseInt(req.query.offset as string, 10) || 0);
|
||||
|
||||
const result = executionService.getJobLogs(jobId, limit, offset);
|
||||
res.json({ ...result, limit, offset });
|
||||
@@ -202,28 +224,7 @@ router.get(
|
||||
logger.error(`Failed to get logs for job ${req.params.jobId}:`, error);
|
||||
res.status(500).json({ error: { message: 'Failed to retrieve job logs' } });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ===================================
|
||||
// GET ALL EXECUTION LOGS
|
||||
// ===================================
|
||||
router.get(
|
||||
'/logs/all',
|
||||
authenticate,
|
||||
requirePermission('settings.update'),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 100, 200);
|
||||
const offset = parseInt(req.query.offset as string) || 0;
|
||||
|
||||
const result = executionService.getAllLogs(limit, offset);
|
||||
res.json({ ...result, limit, offset });
|
||||
} catch (error) {
|
||||
logger.error('Failed to get all logs:', error);
|
||||
res.status(500).json({ error: { message: 'Failed to retrieve logs' } });
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -39,6 +39,7 @@ const LIMITS = {
|
||||
router.use(requireFeature('enableStatistics'));
|
||||
|
||||
// All routes require authentication and games.play permission
|
||||
// Note: authenticate middleware guarantees req.user is populated
|
||||
router.use(authenticate);
|
||||
router.use(requirePermission('games.play'));
|
||||
|
||||
@@ -56,11 +57,7 @@ router.post(
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const { gameId, gameTitle } = startPlaySessionSchema.parse(req.body);
|
||||
|
||||
if (!req.user) {
|
||||
throw new AppError(401, 'Authentication required');
|
||||
}
|
||||
|
||||
const sessionId = await playTrackingService.startPlaySession(req.user.id, gameId, gameTitle);
|
||||
const sessionId = await playTrackingService.startPlaySession(req.user!.id, gameId, gameTitle);
|
||||
|
||||
// Store sessionId for activity logging
|
||||
res.locals.sessionId = sessionId;
|
||||
@@ -82,7 +79,7 @@ router.post(
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const { sessionId } = endPlaySessionSchema.parse(req.body);
|
||||
|
||||
await playTrackingService.endPlaySession(sessionId);
|
||||
await playTrackingService.endPlaySession(sessionId, req.user!.id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -98,11 +95,7 @@ router.post(
|
||||
router.get(
|
||||
'/stats',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
if (!req.user) {
|
||||
throw new AppError(401, 'Authentication required');
|
||||
}
|
||||
|
||||
const stats = await playTrackingService.getUserStats(req.user.id);
|
||||
const stats = await playTrackingService.getUserStats(req.user!.id);
|
||||
|
||||
res.json(stats);
|
||||
})
|
||||
@@ -115,17 +108,13 @@ router.get(
|
||||
router.get(
|
||||
'/game-stats',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
if (!req.user) {
|
||||
throw new AppError(401, 'Authentication required');
|
||||
}
|
||||
|
||||
const limit = Math.min(
|
||||
parseInt(req.query.limit as string) || LIMITS.GAME_STATS_DEFAULT,
|
||||
LIMITS.GAME_STATS_MAX
|
||||
);
|
||||
const offset = parseInt(req.query.offset as string) || 0;
|
||||
const offset = Math.max(0, parseInt(req.query.offset as string) || 0);
|
||||
|
||||
const stats = await playTrackingService.getUserGameStats(req.user.id, limit, offset);
|
||||
const stats = await playTrackingService.getUserGameStats(req.user!.id, limit, offset);
|
||||
|
||||
res.json({
|
||||
data: stats,
|
||||
@@ -142,17 +131,13 @@ router.get(
|
||||
router.get(
|
||||
'/history',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
if (!req.user) {
|
||||
throw new AppError(401, 'Authentication required');
|
||||
}
|
||||
|
||||
const limit = Math.min(
|
||||
parseInt(req.query.limit as string) || LIMITS.HISTORY_DEFAULT,
|
||||
LIMITS.HISTORY_MAX
|
||||
);
|
||||
const offset = parseInt(req.query.offset as string) || 0;
|
||||
const offset = Math.max(0, parseInt(req.query.offset as string) || 0);
|
||||
|
||||
const history = await playTrackingService.getUserPlayHistory(req.user.id, limit, offset);
|
||||
const history = await playTrackingService.getUserPlayHistory(req.user!.id, limit, offset);
|
||||
|
||||
res.json({
|
||||
data: history,
|
||||
@@ -169,16 +154,12 @@ router.get(
|
||||
router.get(
|
||||
'/top-games',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
if (!req.user) {
|
||||
throw new AppError(401, 'Authentication required');
|
||||
}
|
||||
|
||||
const limit = Math.min(
|
||||
parseInt(req.query.limit as string) || LIMITS.TOP_GAMES_DEFAULT,
|
||||
LIMITS.TOP_GAMES_MAX
|
||||
);
|
||||
|
||||
const topGames = await playTrackingService.getTopGames(req.user.id, limit);
|
||||
const topGames = await playTrackingService.getTopGames(req.user!.id, limit);
|
||||
|
||||
res.json(topGames);
|
||||
})
|
||||
@@ -191,16 +172,12 @@ router.get(
|
||||
router.get(
|
||||
'/activity-over-time',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
if (!req.user) {
|
||||
throw new AppError(401, 'Authentication required');
|
||||
}
|
||||
|
||||
const days = Math.min(
|
||||
parseInt(req.query.days as string) || LIMITS.ACTIVITY_DAYS_DEFAULT,
|
||||
LIMITS.ACTIVITY_DAYS_MAX
|
||||
);
|
||||
|
||||
const activity = await playTrackingService.getPlayActivityOverTime(req.user.id, days);
|
||||
const activity = await playTrackingService.getPlayActivityOverTime(req.user!.id, days);
|
||||
|
||||
res.json(activity);
|
||||
})
|
||||
@@ -213,16 +190,12 @@ router.get(
|
||||
router.get(
|
||||
'/games-distribution',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
if (!req.user) {
|
||||
throw new AppError(401, 'Authentication required');
|
||||
}
|
||||
|
||||
const limit = Math.min(
|
||||
parseInt(req.query.limit as string) || LIMITS.GAMES_DISTRIBUTION_DEFAULT,
|
||||
LIMITS.GAMES_DISTRIBUTION_MAX
|
||||
);
|
||||
|
||||
const distribution = await playTrackingService.getGamesDistribution(req.user.id, limit);
|
||||
const distribution = await playTrackingService.getGamesDistribution(req.user!.id, limit);
|
||||
|
||||
res.json(distribution);
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import { authenticate } from '../middleware/auth';
|
||||
import { requirePermission } from '../middleware/rbac';
|
||||
import { requireFeature } from '../middleware/featureFlags';
|
||||
import { logActivity } from '../middleware/activityLogger';
|
||||
import { z } from 'zod';
|
||||
|
||||
const router = Router();
|
||||
const playlistService = new PlaylistService();
|
||||
@@ -21,6 +22,16 @@ const playlistService = new PlaylistService();
|
||||
// Admins with settings.update permission will bypass this check (via global softAuth)
|
||||
router.use(requireFeature('enablePlaylists'));
|
||||
|
||||
// Validation schemas
|
||||
const createPlaylistSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
const addGamesToPlaylistSchema = z.object({
|
||||
gameIds: z.array(z.string()).min(1),
|
||||
});
|
||||
|
||||
// GET /api/playlists - List all playlists
|
||||
// No additional auth needed - softAuth already runs at router level
|
||||
router.get(
|
||||
@@ -53,13 +64,9 @@ router.post(
|
||||
requirePermission('playlists.create'),
|
||||
logActivity('playlists.create', 'playlists'),
|
||||
asyncHandler(async (req, res) => {
|
||||
const data: CreatePlaylistDto = req.body;
|
||||
const data = createPlaylistSchema.parse(req.body);
|
||||
|
||||
if (!data.title) {
|
||||
throw new AppError(400, 'Playlist title is required');
|
||||
}
|
||||
|
||||
const playlist = await playlistService.createPlaylist(data);
|
||||
const playlist = await playlistService.createPlaylist(data as CreatePlaylistDto);
|
||||
res.status(201).json(playlist);
|
||||
})
|
||||
);
|
||||
@@ -71,13 +78,12 @@ router.post(
|
||||
requirePermission('playlists.update'),
|
||||
logActivity('playlists.add_games', 'playlists'),
|
||||
asyncHandler(async (req, res) => {
|
||||
const data: AddGamesToPlaylistDto = req.body;
|
||||
const data = addGamesToPlaylistSchema.parse(req.body);
|
||||
|
||||
if (!data.gameIds || !Array.isArray(data.gameIds)) {
|
||||
throw new AppError(400, 'gameIds array is required');
|
||||
}
|
||||
|
||||
const playlist = await playlistService.addGamesToPlaylist(req.params.id, data);
|
||||
const playlist = await playlistService.addGamesToPlaylist(
|
||||
req.params.id,
|
||||
data as AddGamesToPlaylistDto
|
||||
);
|
||||
|
||||
if (!playlist) {
|
||||
throw new AppError(404, 'Playlist not found');
|
||||
@@ -94,13 +100,12 @@ router.delete(
|
||||
requirePermission('playlists.update'),
|
||||
logActivity('playlists.remove_games', 'playlists'),
|
||||
asyncHandler(async (req, res) => {
|
||||
const data: AddGamesToPlaylistDto = req.body;
|
||||
const data = addGamesToPlaylistSchema.parse(req.body);
|
||||
|
||||
if (!data.gameIds || !Array.isArray(data.gameIds)) {
|
||||
throw new AppError(400, 'gameIds array is required');
|
||||
}
|
||||
|
||||
const playlist = await playlistService.removeGamesFromPlaylist(req.params.id, data);
|
||||
const playlist = await playlistService.removeGamesFromPlaylist(
|
||||
req.params.id,
|
||||
data as AddGamesToPlaylistDto
|
||||
);
|
||||
|
||||
if (!playlist) {
|
||||
throw new AppError(404, 'Playlist not found');
|
||||
|
||||
+21
-30
@@ -1,5 +1,4 @@
|
||||
import { Router, Response, NextFunction } from 'express';
|
||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||
import { config, getExternalImageUrls } from '../config';
|
||||
import { asyncHandler } from '../middleware/asyncHandler';
|
||||
import { logger } from '../utils/logger';
|
||||
@@ -55,6 +54,7 @@ async function serveFileWithFallback(
|
||||
// Try local file first
|
||||
if (fs.existsSync(localPath)) {
|
||||
logger.debug(`[Proxy] Serving local file: ${localPath}`);
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
return res.sendFile(localPath);
|
||||
}
|
||||
|
||||
@@ -93,11 +93,26 @@ async function serveFileWithFallback(
|
||||
`[Proxy] Failed to cache image locally: ${cacheError instanceof Error ? cacheError.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
})();
|
||||
})().catch((err) => {
|
||||
logger.warn('[Proxy] Cache write failed:', err);
|
||||
});
|
||||
|
||||
// Determine content type from file extension (not from CDN response)
|
||||
const ext = path.extname(relativePath).slice(1).toLowerCase();
|
||||
const mimeMap: Record<string, string> = {
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
svg: 'image/svg+xml',
|
||||
ico: 'image/x-icon',
|
||||
bmp: 'image/bmp',
|
||||
};
|
||||
const contentType = mimeMap[ext] || 'application/octet-stream';
|
||||
|
||||
// Set appropriate content type
|
||||
const contentType = response.headers['content-type'] || 'image/jpeg';
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400'); // Cache for 1 day
|
||||
|
||||
return res.send(imageBuffer);
|
||||
@@ -123,32 +138,8 @@ async function serveFileWithFallback(
|
||||
}
|
||||
}
|
||||
|
||||
// Proxy to Flashpoint Game Server for game content
|
||||
router.use(
|
||||
'/game',
|
||||
createProxyMiddleware({
|
||||
target: config.gameServerUrl,
|
||||
changeOrigin: true,
|
||||
pathRewrite: {
|
||||
'^/proxy/game': '', // Remove /proxy/game prefix
|
||||
},
|
||||
onProxyReq: (proxyReq, req, _res) => {
|
||||
// Add headers if needed
|
||||
if (req.ip) {
|
||||
proxyReq.setHeader('X-Forwarded-For', req.ip);
|
||||
}
|
||||
},
|
||||
onError: (err, req, res) => {
|
||||
logger.error('Proxy error:', err);
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to proxy request to game server',
|
||||
statusCode: 500,
|
||||
},
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
// Note: Game content is now served directly via /game-proxy and /game-zip routes
|
||||
// (registered in server.ts before middleware). No proxy to external game-service needed.
|
||||
|
||||
// Serve images with CDN fallback
|
||||
router.get(
|
||||
|
||||
@@ -68,6 +68,10 @@ router.get(
|
||||
logActivity('roles.view', 'roles'),
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
throw new AppError(400, 'Invalid role ID');
|
||||
}
|
||||
|
||||
const role = await roleService.getRoleById(id);
|
||||
|
||||
if (!role) {
|
||||
@@ -112,6 +116,10 @@ router.patch(
|
||||
logActivity('roles.update', 'roles'),
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
throw new AppError(400, 'Invalid role ID');
|
||||
}
|
||||
|
||||
const data = updateRoleSchema.parse(req.body);
|
||||
|
||||
const role = await roleService.updateRole(id, data.name, data.description, data.priority);
|
||||
@@ -131,6 +139,10 @@ router.put(
|
||||
logActivity('roles.update_permissions', 'roles'),
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
throw new AppError(400, 'Invalid role ID');
|
||||
}
|
||||
|
||||
const data = updatePermissionsSchema.parse(req.body);
|
||||
|
||||
await roleService.updateRolePermissions(id, data.permissionIds);
|
||||
@@ -151,6 +163,9 @@ router.delete(
|
||||
logActivity('roles.delete', 'roles'),
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
throw new AppError(400, 'Invalid role ID');
|
||||
}
|
||||
|
||||
await roleService.deleteRole(id);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { validateSharedPlaylist } from '../middleware/validateSharedPlaylist';
|
||||
import { asyncHandler } from '../middleware/asyncHandler';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
@@ -14,6 +15,11 @@ const router = Router();
|
||||
const playlistService = new UserPlaylistService();
|
||||
const userService = new UserService();
|
||||
|
||||
// Validation schema
|
||||
const clonePlaylistSchema = z.object({
|
||||
newTitle: z.string().min(1).max(255).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/playlists/shared/:shareToken
|
||||
* Get shared playlist metadata (anonymous access)
|
||||
@@ -28,7 +34,16 @@ router.get(
|
||||
const playlist = req.sharedPlaylist!;
|
||||
|
||||
// Sanitize response - don't expose sensitive fields
|
||||
const response: any = {
|
||||
const response: {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
gameCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
ownerUsername?: string;
|
||||
} = {
|
||||
id: playlist.id,
|
||||
title: playlist.title,
|
||||
description: playlist.description,
|
||||
@@ -144,7 +159,8 @@ router.post(
|
||||
requirePermission('playlists.create'),
|
||||
validateSharedPlaylist,
|
||||
asyncHandler(async (req, res) => {
|
||||
const { newTitle } = req.body;
|
||||
// Validate request body
|
||||
const { newTitle } = clonePlaylistSchema.parse(req.body);
|
||||
|
||||
const clonedPlaylist = playlistService.cloneSharedPlaylist(
|
||||
req.params.shareToken,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { PermissionCache } from '../services/PermissionCache';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { requirePermission } from '../middleware/rbac';
|
||||
import { logActivity } from '../middleware/activityLogger';
|
||||
import { asyncHandler } from '../middleware/asyncHandler';
|
||||
import { JobScheduler } from '../services/JobScheduler';
|
||||
import { logger } from '../utils/logger';
|
||||
import { CategorySettings } from '../types/settings';
|
||||
@@ -44,24 +45,18 @@ router.get(
|
||||
authenticate,
|
||||
requirePermission('settings.update'),
|
||||
logActivity('settings.list', 'system_settings'),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const allSettings = systemSettings.getAll();
|
||||
res.json(allSettings);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get all settings:', error);
|
||||
res.status(500).json({
|
||||
error: { message: 'Failed to retrieve settings' },
|
||||
});
|
||||
}
|
||||
}
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const allSettings = systemSettings.getAll();
|
||||
res.json(allSettings);
|
||||
})
|
||||
);
|
||||
|
||||
// ===================================
|
||||
// GET PUBLIC SETTINGS (No Auth Required)
|
||||
// ===================================
|
||||
router.get('/public', async (req: Request, res: Response) => {
|
||||
try {
|
||||
router.get(
|
||||
'/public',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const publicSettings: Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
@@ -91,14 +86,121 @@ router.get('/public', async (req: Request, res: Response) => {
|
||||
}
|
||||
|
||||
res.json(publicSettings);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get public settings:', error);
|
||||
res.status(500).json({
|
||||
error: { message: 'Failed to retrieve public settings' },
|
||||
})
|
||||
);
|
||||
|
||||
// ===================================
|
||||
// GET CACHE STATISTICS (Admin Only)
|
||||
// ===================================
|
||||
router.get(
|
||||
'/_cache/stats',
|
||||
authenticate,
|
||||
requirePermission('settings.update'),
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const stats = systemSettings.getCacheStats();
|
||||
res.json(stats);
|
||||
})
|
||||
);
|
||||
|
||||
// ===================================
|
||||
// CLEAR CACHE (Admin Only)
|
||||
// ===================================
|
||||
router.post(
|
||||
'/_cache/clear',
|
||||
authenticate,
|
||||
requirePermission('settings.update'),
|
||||
logActivity('settings.cache_clear', 'system_settings'),
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const { category } = req.body;
|
||||
|
||||
if (category) {
|
||||
systemSettings.clearCategoryCache(category);
|
||||
} else {
|
||||
systemSettings.clearCache();
|
||||
}
|
||||
|
||||
res.json({ message: 'Cache cleared successfully' });
|
||||
})
|
||||
);
|
||||
|
||||
// ===================================
|
||||
// GET PERMISSION CACHE STATISTICS (Admin Only)
|
||||
// ===================================
|
||||
router.get(
|
||||
'/_cache/permissions/stats',
|
||||
authenticate,
|
||||
requirePermission('settings.update'),
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const stats = PermissionCache.getStats();
|
||||
res.json({
|
||||
...stats,
|
||||
description: {
|
||||
userCacheSize: 'Number of cached user permissions',
|
||||
roleCacheSize: 'Number of cached role permissions',
|
||||
totalSize: 'Total cached entries',
|
||||
ttl: {
|
||||
userPermissions: '5 minutes',
|
||||
rolePermissions: '10 minutes',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Validation schema for permission cache clear
|
||||
const clearPermissionCacheSchema = z.object({
|
||||
type: z.enum(['all', 'user', 'role', 'users', 'roles']),
|
||||
id: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
// ===================================
|
||||
// CLEAR PERMISSION CACHE (Admin Only)
|
||||
// ===================================
|
||||
router.post(
|
||||
'/_cache/permissions/clear',
|
||||
authenticate,
|
||||
requirePermission('settings.update'),
|
||||
logActivity('settings.permission_cache_clear', 'system_settings'),
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const validation = clearPermissionCacheSchema.safeParse(req.body);
|
||||
|
||||
if (!validation.success) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Invalid request body',
|
||||
details: validation.error.errors,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { type, id } = validation.data;
|
||||
|
||||
if (type === 'user' && id) {
|
||||
PermissionCache.invalidateUser(Number(id));
|
||||
res.json({ message: `User ${id} permission cache cleared successfully` });
|
||||
} else if (type === 'role' && id) {
|
||||
PermissionCache.invalidateRole(Number(id));
|
||||
res.json({ message: `Role ${id} permission cache cleared successfully` });
|
||||
} else if (type === 'users') {
|
||||
PermissionCache.invalidateAllUsers();
|
||||
res.json({ message: 'All user permission caches cleared successfully' });
|
||||
} else if (type === 'roles') {
|
||||
PermissionCache.invalidateAllRoles();
|
||||
res.json({ message: 'All role permission caches cleared successfully' });
|
||||
} else if (type === 'all') {
|
||||
PermissionCache.clearAll();
|
||||
res.json({ message: 'All permission caches cleared successfully' });
|
||||
} else {
|
||||
res.status(400).json({
|
||||
error: {
|
||||
message:
|
||||
'Invalid request. Specify type: "user", "role", "users", "roles", or "all". For "user" or "role", provide an id.',
|
||||
},
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// ===================================
|
||||
// GET CATEGORY SETTINGS (Admin Only)
|
||||
// ===================================
|
||||
@@ -106,20 +208,13 @@ router.get(
|
||||
'/:category',
|
||||
authenticate,
|
||||
requirePermission('settings.read'),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { category } = req.params;
|
||||
const categorySettings = systemSettings.getCategory(category);
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const { category } = req.params;
|
||||
const categorySettings = systemSettings.getCategory(category);
|
||||
|
||||
// Return settings (empty object if category has no settings yet)
|
||||
res.json(categorySettings);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get ${req.params.category} settings:`, error);
|
||||
res.status(500).json({
|
||||
error: { message: 'Failed to retrieve settings' },
|
||||
});
|
||||
}
|
||||
}
|
||||
// Return settings (empty object if category has no settings yet)
|
||||
res.json(categorySettings);
|
||||
})
|
||||
);
|
||||
|
||||
// ===================================
|
||||
@@ -134,47 +229,32 @@ router.patch(
|
||||
authenticate,
|
||||
requirePermission('settings.update'),
|
||||
logActivity('settings.update', 'system_settings'),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { category } = req.params;
|
||||
const validation = updateCategorySchema.safeParse(req.body);
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const { category } = req.params;
|
||||
const validation = updateCategorySchema.safeParse(req.body);
|
||||
|
||||
if (!validation.success) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Invalid request body',
|
||||
details: validation.error.errors,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const settings = validation.data;
|
||||
const userId = req.user!.id;
|
||||
|
||||
// Update settings (validation happens in service)
|
||||
systemSettings.updateCategory(category, settings, userId);
|
||||
|
||||
// Update job scheduler if jobs settings changed
|
||||
updateJobScheduler(category, settings);
|
||||
|
||||
// Return updated settings
|
||||
const updated = systemSettings.getCategory(category);
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update ${req.params.category} settings:`, error);
|
||||
|
||||
// Check if it's a validation error
|
||||
if (error instanceof Error && error.message.includes('must be')) {
|
||||
return res.status(400).json({
|
||||
error: { message: error.message },
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: { message: 'Failed to update settings' },
|
||||
if (!validation.success) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Invalid request body',
|
||||
details: validation.error.errors,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const settings = validation.data;
|
||||
const userId = req.user!.id;
|
||||
|
||||
// Update settings (validation happens in service)
|
||||
systemSettings.updateCategory(category, settings, userId);
|
||||
|
||||
// Update job scheduler if jobs settings changed
|
||||
updateJobScheduler(category, settings);
|
||||
|
||||
// Return updated settings
|
||||
const updated = systemSettings.getCategory(category);
|
||||
res.json(updated);
|
||||
})
|
||||
);
|
||||
|
||||
// ===================================
|
||||
@@ -184,26 +264,19 @@ router.get(
|
||||
'/:category/:key',
|
||||
authenticate,
|
||||
requirePermission('settings.read'),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { category, key } = req.params;
|
||||
const fullKey = `${category}.${key}`;
|
||||
const value = systemSettings.get(fullKey);
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const { category, key } = req.params;
|
||||
const fullKey = `${category}.${key}`;
|
||||
const value = systemSettings.get(fullKey);
|
||||
|
||||
if (value === null) {
|
||||
return res.status(404).json({
|
||||
error: { message: `Setting '${fullKey}' not found` },
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ value });
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get setting ${req.params.category}.${req.params.key}:`, error);
|
||||
res.status(500).json({
|
||||
error: { message: 'Failed to retrieve setting' },
|
||||
if (value === null) {
|
||||
return res.status(404).json({
|
||||
error: { message: `Setting '${fullKey}' not found` },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ value });
|
||||
})
|
||||
);
|
||||
|
||||
// ===================================
|
||||
@@ -218,168 +291,30 @@ router.patch(
|
||||
authenticate,
|
||||
requirePermission('settings.update'),
|
||||
logActivity('settings.update', 'system_settings'),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { category, key } = req.params;
|
||||
const fullKey = `${category}.${key}`;
|
||||
const validation = updateSettingSchema.safeParse(req.body);
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const { category, key } = req.params;
|
||||
const fullKey = `${category}.${key}`;
|
||||
const validation = updateSettingSchema.safeParse(req.body);
|
||||
|
||||
if (!validation.success) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Invalid request body',
|
||||
details: validation.error.errors,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { value } = validation.data;
|
||||
const userId = req.user!.id;
|
||||
|
||||
// Update setting (validation happens in service)
|
||||
systemSettings.set(fullKey, value, userId);
|
||||
|
||||
// Return updated value
|
||||
const updated = systemSettings.get(fullKey);
|
||||
res.json({ value: updated });
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update setting ${req.params.category}.${req.params.key}:`, error);
|
||||
|
||||
// Check if it's a validation error
|
||||
if (error instanceof Error && error.message.includes('must be')) {
|
||||
return res.status(400).json({
|
||||
error: { message: error.message },
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: { message: 'Failed to update setting' },
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ===================================
|
||||
// GET CACHE STATISTICS (Admin Only)
|
||||
// ===================================
|
||||
router.get(
|
||||
'/_cache/stats',
|
||||
authenticate,
|
||||
requirePermission('settings.update'),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stats = systemSettings.getCacheStats();
|
||||
res.json(stats);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get cache stats:', error);
|
||||
res.status(500).json({
|
||||
error: { message: 'Failed to retrieve cache statistics' },
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ===================================
|
||||
// CLEAR CACHE (Admin Only)
|
||||
// ===================================
|
||||
router.post(
|
||||
'/_cache/clear',
|
||||
authenticate,
|
||||
requirePermission('settings.update'),
|
||||
logActivity('settings.cache_clear', 'system_settings'),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { category } = req.body;
|
||||
|
||||
if (category) {
|
||||
systemSettings.clearCategoryCache(category);
|
||||
} else {
|
||||
systemSettings.clearCache();
|
||||
}
|
||||
|
||||
res.json({ message: 'Cache cleared successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Failed to clear cache:', error);
|
||||
res.status(500).json({
|
||||
error: { message: 'Failed to clear cache' },
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ===================================
|
||||
// GET PERMISSION CACHE STATISTICS (Admin Only)
|
||||
// ===================================
|
||||
router.get(
|
||||
'/_cache/permissions/stats',
|
||||
authenticate,
|
||||
requirePermission('settings.update'),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stats = PermissionCache.getStats();
|
||||
res.json({
|
||||
...stats,
|
||||
description: {
|
||||
userCacheSize: 'Number of cached user permissions',
|
||||
roleCacheSize: 'Number of cached role permissions',
|
||||
totalSize: 'Total cached entries',
|
||||
ttl: {
|
||||
userPermissions: '5 minutes',
|
||||
rolePermissions: '10 minutes',
|
||||
},
|
||||
if (!validation.success) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Invalid request body',
|
||||
details: validation.error.errors,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get permission cache stats:', error);
|
||||
res.status(500).json({
|
||||
error: { message: 'Failed to retrieve permission cache statistics' },
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ===================================
|
||||
// CLEAR PERMISSION CACHE (Admin Only)
|
||||
// ===================================
|
||||
router.post(
|
||||
'/_cache/permissions/clear',
|
||||
authenticate,
|
||||
requirePermission('settings.update'),
|
||||
logActivity('settings.permission_cache_clear', 'system_settings'),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { type, id } = req.body;
|
||||
const { value } = validation.data;
|
||||
const userId = req.user!.id;
|
||||
|
||||
if (type === 'user' && id) {
|
||||
PermissionCache.invalidateUser(Number(id));
|
||||
res.json({ message: `User ${id} permission cache cleared successfully` });
|
||||
} else if (type === 'role' && id) {
|
||||
PermissionCache.invalidateRole(Number(id));
|
||||
res.json({ message: `Role ${id} permission cache cleared successfully` });
|
||||
} else if (type === 'users') {
|
||||
PermissionCache.invalidateAllUsers();
|
||||
res.json({ message: 'All user permission caches cleared successfully' });
|
||||
} else if (type === 'roles') {
|
||||
PermissionCache.invalidateAllRoles();
|
||||
res.json({ message: 'All role permission caches cleared successfully' });
|
||||
} else if (type === 'all') {
|
||||
PermissionCache.clearAll();
|
||||
res.json({ message: 'All permission caches cleared successfully' });
|
||||
} else {
|
||||
res.status(400).json({
|
||||
error: {
|
||||
message:
|
||||
'Invalid request. Specify type: "user", "role", "users", "roles", or "all". For "user" or "role", provide an id.',
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to clear permission cache:', error);
|
||||
res.status(500).json({
|
||||
error: { message: 'Failed to clear permission cache' },
|
||||
});
|
||||
}
|
||||
}
|
||||
// Update setting (validation happens in service)
|
||||
systemSettings.set(fullKey, value, userId);
|
||||
|
||||
// Return updated value
|
||||
const updated = systemSettings.get(fullKey);
|
||||
res.json({ value: updated });
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -7,9 +7,11 @@ import { authenticate } from '../middleware/auth';
|
||||
import { requirePermission } from '../middleware/rbac';
|
||||
import { logActivity } from '../middleware/activityLogger';
|
||||
import { asyncHandler } from '../middleware/asyncHandler';
|
||||
import { rateLimitStandard } from '../middleware/rateLimiter';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const router = Router();
|
||||
router.use(rateLimitStandard);
|
||||
const metadataUpdateService = new MetadataUpdateService();
|
||||
const metadataSyncService = new MetadataSyncService();
|
||||
|
||||
|
||||
@@ -36,15 +36,15 @@ const updatePlaylistSchema = z.object({
|
||||
});
|
||||
|
||||
const addGamesSchema = z.object({
|
||||
gameIds: z.array(z.string()).min(1),
|
||||
gameIds: z.array(z.string()).min(1).max(100),
|
||||
});
|
||||
|
||||
const removeGamesSchema = z.object({
|
||||
gameIds: z.array(z.string()).min(1),
|
||||
gameIds: z.array(z.string()).min(1).max(100),
|
||||
});
|
||||
|
||||
const reorderGamesSchema = z.object({
|
||||
gameIdOrder: z.array(z.string()).min(1),
|
||||
gameIdOrder: z.array(z.string()).min(1).max(100),
|
||||
});
|
||||
|
||||
const copyFlashpointPlaylistSchema = z.object({
|
||||
|
||||
+137
-115
@@ -26,7 +26,7 @@ const updateUserSchema = z.object({
|
||||
});
|
||||
|
||||
const changePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(6),
|
||||
currentPassword: z.string().min(6).optional(),
|
||||
newPassword: z.string().min(6),
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ const updateThemeSettingsSchema = z.object({
|
||||
primaryColor: z.string().min(1),
|
||||
});
|
||||
|
||||
const updateUserSettingsSchema = z.record(z.string(), z.string());
|
||||
const updateUserSettingsSchema = z.record(z.string().max(50), z.string().max(500));
|
||||
|
||||
/**
|
||||
* GET /api/users
|
||||
@@ -52,8 +52,8 @@ router.get(
|
||||
requirePermission('users.read'),
|
||||
logActivity('users.list', 'users'),
|
||||
asyncHandler(async (req, res) => {
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = parseInt(req.query.limit as string) || 50;
|
||||
const page = Math.max(1, parseInt(req.query.page as string) || 1);
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
||||
|
||||
const result = await userService.getUsers(page, limit);
|
||||
|
||||
@@ -61,117 +61,6 @@ router.get(
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/users/:id
|
||||
* Get single user by ID
|
||||
*/
|
||||
router.get(
|
||||
'/:id',
|
||||
authenticate,
|
||||
requirePermission('users.read'),
|
||||
logActivity('users.view', 'users'),
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
const user = await userService.getUserById(id);
|
||||
|
||||
if (!user) {
|
||||
throw new AppError(404, 'User not found');
|
||||
}
|
||||
|
||||
res.json(user);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/users
|
||||
* Create new user (admin only)
|
||||
*/
|
||||
router.post(
|
||||
'/',
|
||||
authenticate,
|
||||
requirePermission('users.create'),
|
||||
logActivity('users.create', 'users'),
|
||||
asyncHandler(async (req, res) => {
|
||||
const data = createUserSchema.parse(req.body);
|
||||
|
||||
const user = await userService.createUser({
|
||||
username: data.username,
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
roleId: data.roleId,
|
||||
isActive: data.isActive ?? true,
|
||||
});
|
||||
|
||||
res.status(201).json(user);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* PATCH /api/users/:id
|
||||
* Update existing user
|
||||
*/
|
||||
router.patch(
|
||||
'/:id',
|
||||
authenticate,
|
||||
requirePermission('users.update'),
|
||||
logActivity('users.update.profile', 'users', (req) => ({
|
||||
userId: req.params.id,
|
||||
fieldsUpdated: Object.keys(req.body),
|
||||
})),
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
const data = updateUserSchema.parse(req.body);
|
||||
|
||||
const user = await userService.updateUser(id, data);
|
||||
|
||||
res.json(user);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /api/users/:id
|
||||
* Delete user (prevents deleting last admin)
|
||||
*/
|
||||
router.delete(
|
||||
'/:id',
|
||||
authenticate,
|
||||
requirePermission('users.delete'),
|
||||
logActivity('users.delete', 'users'),
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
|
||||
await userService.deleteUser(id);
|
||||
|
||||
res.json({ success: true, message: 'User deleted successfully' });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/users/:id/change-password
|
||||
* Change user password
|
||||
* Users can change their own password, admins can change any password
|
||||
*/
|
||||
router.post(
|
||||
'/:id/change-password',
|
||||
authenticate,
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
const data = changePasswordSchema.parse(req.body);
|
||||
|
||||
// Check if user is changing their own password or is an admin
|
||||
const isOwnPassword = req.user?.id === id;
|
||||
const isAdmin = req.user?.permissions.includes('users.update');
|
||||
|
||||
if (!isOwnPassword && !isAdmin) {
|
||||
throw new AppError(403, 'Insufficient permissions');
|
||||
}
|
||||
|
||||
await userService.changePassword(id, data.currentPassword, data.newPassword);
|
||||
|
||||
res.json({ success: true, message: 'Password changed successfully' });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/users/me/theme (DEPRECATED - redirects to new endpoint)
|
||||
* Legacy endpoint for backward compatibility
|
||||
@@ -289,4 +178,137 @@ router.patch(
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/users/:id
|
||||
* Get single user by ID
|
||||
*/
|
||||
router.get(
|
||||
'/:id',
|
||||
authenticate,
|
||||
requirePermission('users.read'),
|
||||
logActivity('users.view', 'users'),
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
throw new AppError(400, 'Invalid user ID');
|
||||
}
|
||||
|
||||
const user = await userService.getUserById(id);
|
||||
|
||||
if (!user) {
|
||||
throw new AppError(404, 'User not found');
|
||||
}
|
||||
|
||||
res.json(user);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/users
|
||||
* Create new user (admin only)
|
||||
*/
|
||||
router.post(
|
||||
'/',
|
||||
authenticate,
|
||||
requirePermission('users.create'),
|
||||
logActivity('users.create', 'users'),
|
||||
asyncHandler(async (req, res) => {
|
||||
const data = createUserSchema.parse(req.body);
|
||||
|
||||
const user = await userService.createUser({
|
||||
username: data.username,
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
roleId: data.roleId,
|
||||
isActive: data.isActive ?? true,
|
||||
});
|
||||
|
||||
res.status(201).json(user);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* PATCH /api/users/:id
|
||||
* Update existing user
|
||||
*/
|
||||
router.patch(
|
||||
'/:id',
|
||||
authenticate,
|
||||
requirePermission('users.update'),
|
||||
logActivity('users.update.profile', 'users', (req) => ({
|
||||
userId: req.params.id,
|
||||
fieldsUpdated: Object.keys(req.body),
|
||||
})),
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
throw new AppError(400, 'Invalid user ID');
|
||||
}
|
||||
|
||||
const data = updateUserSchema.parse(req.body);
|
||||
|
||||
const user = await userService.updateUser(id, data);
|
||||
|
||||
res.json(user);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /api/users/:id
|
||||
* Delete user (prevents deleting last admin)
|
||||
*/
|
||||
router.delete(
|
||||
'/:id',
|
||||
authenticate,
|
||||
requirePermission('users.delete'),
|
||||
logActivity('users.delete', 'users'),
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
throw new AppError(400, 'Invalid user ID');
|
||||
}
|
||||
|
||||
await userService.deleteUser(id);
|
||||
|
||||
res.json({ success: true, message: 'User deleted successfully' });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/users/:id/change-password
|
||||
* Change user password
|
||||
* Users can change their own password, admins can change any password
|
||||
*/
|
||||
router.post(
|
||||
'/:id/change-password',
|
||||
authenticate,
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
throw new AppError(400, 'Invalid user ID');
|
||||
}
|
||||
|
||||
const data = changePasswordSchema.parse(req.body);
|
||||
|
||||
// Check if user is changing their own password or is an admin
|
||||
const isOwnPassword = req.user?.id === id;
|
||||
const isAdmin = req.user?.permissions.includes('users.update');
|
||||
|
||||
if (!isOwnPassword && !isAdmin) {
|
||||
throw new AppError(403, 'Insufficient permissions');
|
||||
}
|
||||
|
||||
const isAdminReset = isAdmin && !isOwnPassword;
|
||||
|
||||
// Require current password for non-admin password changes
|
||||
if (!isAdminReset && !data.currentPassword) {
|
||||
throw new AppError(400, 'Current password is required');
|
||||
}
|
||||
|
||||
await userService.changePassword(id, data.currentPassword || '', data.newPassword, isAdminReset);
|
||||
|
||||
res.json({ success: true, message: 'Password changed successfully' });
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
+95
-46
@@ -5,6 +5,7 @@ import express, { Express } from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import compression from 'compression';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { config } from './config';
|
||||
import { logger, getLoggingStatus, verifyFileLogging } from './utils/logger';
|
||||
import { errorHandler } from './middleware/errorHandler';
|
||||
@@ -25,6 +26,12 @@ import { RuffleUpdateJob } from './jobs/RuffleUpdateJob';
|
||||
import { CachedSystemSettingsService } from './services/CachedSystemSettingsService';
|
||||
import { PermissionCache } from './services/PermissionCache';
|
||||
import { RuffleService } from './services/RuffleService';
|
||||
import { ConfigManager } from './game/config';
|
||||
import { PreferencesService } from './game/services/PreferencesService';
|
||||
import { zipManager } from './game/zip-manager';
|
||||
import { gameZipServer } from './game/gamezipserver';
|
||||
import gameProxyRouter from './routes/game-proxy';
|
||||
import gameZipRouter from './routes/game-zip';
|
||||
|
||||
/**
|
||||
* Clean up old activity logs based on retention settings
|
||||
@@ -57,6 +64,28 @@ async function cleanupActivityLogs(): Promise<void> {
|
||||
async function startServer() {
|
||||
const app: Express = express();
|
||||
|
||||
// Trust proxy - Required for Docker/reverse proxy deployments
|
||||
// Enables Express to correctly read X-Forwarded-* headers from reverse proxies
|
||||
// Without this, req.ip returns the proxy IP instead of the client IP, causing all rate limiters to share a single counter
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
// --- Game content routes (BEFORE all middleware) ---
|
||||
// These bypass auth, maintenance mode, restrictive CORS, and body parsing.
|
||||
// Only a permissive CORS middleware is applied to game routes.
|
||||
const gameCors = (_req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS, HEAD');
|
||||
res.setHeader('Access-Control-Allow-Headers', '*');
|
||||
if (_req.method === 'OPTIONS') {
|
||||
res.status(204).end();
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
app.use('/game-proxy', gameCors, gameProxyRouter);
|
||||
app.use('/game-zip', gameCors, gameZipRouter);
|
||||
// --- End game content routes ---
|
||||
|
||||
// Security middleware
|
||||
app.use(
|
||||
helmet({
|
||||
@@ -98,6 +127,9 @@ async function startServer() {
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
|
||||
|
||||
// Cookie parsing
|
||||
app.use(cookieParser());
|
||||
|
||||
// Compression
|
||||
app.use(compression());
|
||||
|
||||
@@ -107,7 +139,16 @@ async function startServer() {
|
||||
|
||||
// Request logging
|
||||
app.use((req, res, next) => {
|
||||
logger.info(`${req.method} ${req.path}`);
|
||||
// Skip logging for high-frequency paths
|
||||
if (
|
||||
req.path === '/health' ||
|
||||
req.path.startsWith('/game-proxy/') ||
|
||||
req.path.startsWith('/game-zip/')
|
||||
) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
logger.debug(`${req.method} ${req.path}`);
|
||||
next();
|
||||
});
|
||||
|
||||
@@ -217,58 +258,31 @@ async function startServer() {
|
||||
// Non-fatal error - continue server startup
|
||||
}
|
||||
|
||||
// Note: Game proxy server functionality has been moved to game-service
|
||||
// The backend now proxies requests to game-service instead of serving them directly
|
||||
logger.info('📡 Game content will be proxied to game-service');
|
||||
logger.info(` Proxy Server: ${config.gameServerUrl || 'http://localhost:22500'}`);
|
||||
logger.info(` GameZip Server: http://localhost:${config.gameServerHttpPort}`);
|
||||
// Initialize game service configuration (integrated into backend)
|
||||
try {
|
||||
await ConfigManager.loadConfig(config.flashpointPath);
|
||||
PreferencesService.initialize(config.flashpointPath);
|
||||
logger.info('🎮 Game service configuration loaded (integrated)');
|
||||
} catch (error) {
|
||||
logger.warn('⚠️ Failed to load game service config (non-fatal):', error);
|
||||
}
|
||||
|
||||
// Health check with game-service connectivity verification
|
||||
app.get('/health', async (req, res) => {
|
||||
let gameServiceStatus: 'ok' | 'error' | 'unknown' = 'unknown';
|
||||
let gameServiceError: string | undefined;
|
||||
|
||||
// Check game-service connectivity
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 3000); // 3 second timeout
|
||||
|
||||
const gameServiceUrl = config.gameServerUrl || 'http://localhost:22500';
|
||||
const response = await fetch(`${gameServiceUrl}/health`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (response.ok) {
|
||||
gameServiceStatus = 'ok';
|
||||
} else {
|
||||
gameServiceStatus = 'error';
|
||||
gameServiceError = `HTTP ${response.status}`;
|
||||
}
|
||||
} catch (error) {
|
||||
gameServiceStatus = 'error';
|
||||
gameServiceError =
|
||||
error instanceof Error
|
||||
? error.name === 'AbortError'
|
||||
? 'Connection timeout'
|
||||
: error.message
|
||||
: 'Unknown error';
|
||||
}
|
||||
|
||||
const isHealthy = DatabaseService.isConnected() && gameServiceStatus === 'ok';
|
||||
// Health check - game service is now integrated (no external dependency)
|
||||
// NOTE: This endpoint is registered before middleware for load balancer access.
|
||||
// It performs basic health checks. For detailed health status, use /api/health (requires auth).
|
||||
app.get('/health', async (_req, res) => {
|
||||
const isHealthy = DatabaseService.isConnected();
|
||||
|
||||
res.status(isHealthy ? 200 : 503).json({
|
||||
status: isHealthy ? 'ok' : 'degraded',
|
||||
timestamp: new Date().toISOString(),
|
||||
flashpointPath: config.flashpointPath,
|
||||
services: {
|
||||
database: {
|
||||
status: DatabaseService.isConnected() ? 'ok' : 'error',
|
||||
},
|
||||
gameService: {
|
||||
status: gameServiceStatus,
|
||||
url: config.gameServerUrl || 'http://localhost:22500',
|
||||
error: gameServiceError,
|
||||
status: 'ok',
|
||||
note: 'integrated',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -304,10 +318,16 @@ async function startServer() {
|
||||
const server = app.listen(config.port, config.host, () => {
|
||||
logger.info(`🚀 Flashpoint Web API server running on http://${config.host}:${config.port}`);
|
||||
logger.info(`📁 Flashpoint path: ${config.flashpointPath}`);
|
||||
logger.info(`🎮 Game Service: ${config.gameServerUrl || 'http://localhost:22500'}`);
|
||||
logger.info(`🎮 Game content: /game-proxy/* and /game-zip/* (integrated)`);
|
||||
logger.info(`🌍 Environment: ${config.nodeEnv}`);
|
||||
});
|
||||
|
||||
// Prevent connection exhaustion - critical for handling game file requests
|
||||
server.keepAliveTimeout = 65000; // 65s - slightly above common load balancer timeout
|
||||
server.headersTimeout = 66000; // Must be > keepAliveTimeout
|
||||
server.timeout = 120000; // 2 min max for any request (including game file streaming)
|
||||
// Note: Connection limiting is handled by the reverse proxy (nginx)
|
||||
|
||||
// Start cleanup scheduler for abandoned play sessions (every 6 hours)
|
||||
const playTrackingService = new PlayTrackingService();
|
||||
const playSessionCleanupInterval = setInterval(
|
||||
@@ -318,6 +338,7 @@ async function startServer() {
|
||||
},
|
||||
6 * 60 * 60 * 1000
|
||||
); // 6 hours
|
||||
playSessionCleanupInterval.unref();
|
||||
|
||||
// Run initial play session cleanup
|
||||
playTrackingService.cleanupAbandonedSessions().catch((error) => {
|
||||
@@ -334,6 +355,7 @@ async function startServer() {
|
||||
},
|
||||
6 * 60 * 60 * 1000
|
||||
); // 6 hours
|
||||
loginAttemptsCleanupInterval.unref();
|
||||
|
||||
// Run initial login attempts cleanup
|
||||
authService.cleanupOldLoginAttempts().catch((error) => {
|
||||
@@ -349,6 +371,7 @@ async function startServer() {
|
||||
},
|
||||
24 * 60 * 60 * 1000
|
||||
); // 24 hours
|
||||
activityLogsCleanupInterval.unref();
|
||||
|
||||
// Run initial activity logs cleanup
|
||||
cleanupActivityLogs().catch((error) => {
|
||||
@@ -356,7 +379,7 @@ async function startServer() {
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = () => {
|
||||
const shutdown = async () => {
|
||||
logger.info('Shutting down gracefully...');
|
||||
|
||||
// Stop scheduled jobs
|
||||
@@ -370,6 +393,32 @@ async function startServer() {
|
||||
clearInterval(loginAttemptsCleanupInterval);
|
||||
clearInterval(activityLogsCleanupInterval);
|
||||
|
||||
// Dispose game zip server (stops download cleanup interval)
|
||||
try {
|
||||
gameZipServer.dispose();
|
||||
} catch (err) {
|
||||
logger.error('Failed to dispose game zip server during shutdown:', err);
|
||||
}
|
||||
|
||||
// Clean up mounted ZIPs (await to release file handles)
|
||||
try {
|
||||
await zipManager.unmountAll();
|
||||
} catch (err) {
|
||||
logger.error('Failed to unmount ZIPs during shutdown:', err);
|
||||
}
|
||||
|
||||
// Close database connections
|
||||
try {
|
||||
DatabaseService.close();
|
||||
} catch (err) {
|
||||
logger.error('Failed to close database during shutdown:', err);
|
||||
}
|
||||
try {
|
||||
UserDatabaseService.close();
|
||||
} catch (err) {
|
||||
logger.error('Failed to close user database during shutdown:', err);
|
||||
}
|
||||
|
||||
// Close server
|
||||
server.close(() => {
|
||||
logger.info('Server closed');
|
||||
@@ -380,7 +429,7 @@ async function startServer() {
|
||||
setTimeout(() => {
|
||||
logger.error('Could not close connections in time, forcefully shutting down');
|
||||
process.exit(1);
|
||||
}, 10000);
|
||||
}, 10000).unref();
|
||||
};
|
||||
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
@@ -13,6 +13,8 @@ import { AUTH_ACTIONS, categorizeAction } from '../utils/activityUtils';
|
||||
/**
|
||||
* Generate SQL IN clause from AUTH_ACTIONS constant.
|
||||
* Keeps the constant as the single source of truth for auth action strings.
|
||||
* SAFETY: This is safe because AUTH_ACTIONS is a hardcoded constant array
|
||||
* (not user input). The join operation builds a static SQL fragment at module load time.
|
||||
*/
|
||||
const AUTH_ACTIONS_SQL = `'${AUTH_ACTIONS.join("', '")}'`;
|
||||
|
||||
|
||||
@@ -8,6 +8,25 @@ import { hashPassword, verifyPassword } from '../utils/password';
|
||||
import { AppError } from '../middleware/errorHandler';
|
||||
import { LoginCredentials, RegisterData, AuthTokens, AuthUser } from '../types/auth';
|
||||
|
||||
/**
|
||||
* User data for token generation
|
||||
*/
|
||||
interface UserTokenData {
|
||||
id: number;
|
||||
username: string;
|
||||
role_name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth settings shape from system_settings
|
||||
*/
|
||||
interface AuthSettings {
|
||||
guest_access_enabled: number;
|
||||
user_registration_enabled: number;
|
||||
max_login_attempts: number;
|
||||
lockout_duration_minutes: number;
|
||||
}
|
||||
|
||||
export class AuthService {
|
||||
private systemSettings: SystemSettingsService;
|
||||
private cachedSystemSettings: CachedSystemSettingsService;
|
||||
@@ -98,53 +117,55 @@ export class AuthService {
|
||||
* The method automatically detects which scenario applies based on whether users exist
|
||||
*/
|
||||
async register(data: RegisterData): Promise<{ user: AuthUser; tokens: AuthTokens }> {
|
||||
// Check if this is initial setup (no users exist)
|
||||
const isInitialSetup = UserDatabaseService.needsInitialSetup();
|
||||
|
||||
// Validation: Regular registration requires registration to be enabled
|
||||
// Initial setup bypasses this check (first user is always allowed)
|
||||
if (!isInitialSetup) {
|
||||
const settings = this.getAuthSettings();
|
||||
if (!settings.user_registration_enabled) {
|
||||
throw new AppError(403, 'User registration is currently disabled');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if username exists
|
||||
const existingUser = UserDatabaseService.get('SELECT id FROM users WHERE username = ?', [
|
||||
data.username,
|
||||
]);
|
||||
if (existingUser) {
|
||||
throw new AppError(409, 'Username already exists');
|
||||
}
|
||||
|
||||
// Check if email exists
|
||||
const existingEmail = UserDatabaseService.get('SELECT id FROM users WHERE email = ?', [
|
||||
data.email,
|
||||
]);
|
||||
if (existingEmail) {
|
||||
throw new AppError(409, 'Email already exists');
|
||||
}
|
||||
|
||||
// Hash password
|
||||
// Hash password before transaction
|
||||
const passwordHash = await hashPassword(data.password);
|
||||
|
||||
// First user becomes admin (role_id = 1), subsequent users get 'user' role (role_id = 2)
|
||||
const roleId = isInitialSetup ? 1 : 2;
|
||||
const result = UserDatabaseService.run(
|
||||
'INSERT INTO users (username, email, password_hash, role_id) VALUES (?, ?, ?, ?)',
|
||||
[data.username, data.email, passwordHash, roleId]
|
||||
);
|
||||
// Wrap setup check + uniqueness checks + insert in transaction to prevent race condition
|
||||
const db = UserDatabaseService.getDatabase();
|
||||
const registerTransaction = db.transaction(() => {
|
||||
// Check username uniqueness inside transaction
|
||||
const existingUser = db.prepare('SELECT id FROM users WHERE username = ?').get(data.username);
|
||||
if (existingUser) {
|
||||
throw new AppError(409, 'Username already exists');
|
||||
}
|
||||
|
||||
if (isInitialSetup) {
|
||||
logger.info('╔═══════════════════════════════════════════════════════════════╗');
|
||||
logger.info('║ INITIAL ADMIN ACCOUNT CREATED ║');
|
||||
logger.info(`║ Username: ${data.username.padEnd(48)} ║`);
|
||||
logger.info('║ The first user has been granted administrator privileges. ║');
|
||||
logger.info('╚═══════════════════════════════════════════════════════════════╝');
|
||||
}
|
||||
// Check email uniqueness inside transaction
|
||||
const existingEmail = db.prepare('SELECT id FROM users WHERE email = ?').get(data.email);
|
||||
if (existingEmail) {
|
||||
throw new AppError(409, 'Email already exists');
|
||||
}
|
||||
|
||||
const userId = result.lastInsertRowid as number;
|
||||
// Check if this is initial setup (no users exist)
|
||||
const count = db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number };
|
||||
const isInitialSetup = count.count === 0;
|
||||
|
||||
// Validation: Regular registration requires registration to be enabled
|
||||
// Initial setup bypasses this check (first user is always allowed)
|
||||
if (!isInitialSetup) {
|
||||
const settings = this.getAuthSettings();
|
||||
if (!settings.user_registration_enabled) {
|
||||
throw new AppError(403, 'User registration is currently disabled');
|
||||
}
|
||||
}
|
||||
|
||||
// First user becomes admin (role_id = 1), subsequent users get 'user' role (role_id = 2)
|
||||
const roleId = isInitialSetup ? 1 : 2;
|
||||
const result = db
|
||||
.prepare('INSERT INTO users (username, email, password_hash, role_id) VALUES (?, ?, ?, ?)')
|
||||
.run(data.username, data.email, passwordHash, roleId);
|
||||
|
||||
if (isInitialSetup) {
|
||||
logger.info('╔═══════════════════════════════════════════════════════════════╗');
|
||||
logger.info('║ INITIAL ADMIN ACCOUNT CREATED ║');
|
||||
logger.info(`║ Username: ${data.username.padEnd(48)} ║`);
|
||||
logger.info('║ The first user has been granted administrator privileges. ║');
|
||||
logger.info('╚═══════════════════════════════════════════════════════════════╝');
|
||||
}
|
||||
|
||||
return result.lastInsertRowid as number;
|
||||
});
|
||||
|
||||
const userId = registerTransaction();
|
||||
|
||||
// Apply default theme and primary color from system settings
|
||||
const appSettings = this.cachedSystemSettings.getCategory('app');
|
||||
@@ -319,7 +340,7 @@ export class AuthService {
|
||||
/**
|
||||
* Generate access and refresh tokens
|
||||
*/
|
||||
private async generateTokens(user: any): Promise<AuthTokens> {
|
||||
private async generateTokens(user: UserTokenData): Promise<AuthTokens> {
|
||||
const accessToken = generateAccessToken({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
@@ -406,7 +427,7 @@ export class AuthService {
|
||||
/**
|
||||
* Get auth settings from system_settings
|
||||
*/
|
||||
private getAuthSettings(): any {
|
||||
private getAuthSettings(): AuthSettings {
|
||||
try {
|
||||
const authSettings = this.systemSettings.getCategory('auth');
|
||||
|
||||
@@ -424,8 +445,12 @@ export class AuthService {
|
||||
? 1
|
||||
: 0
|
||||
: 1,
|
||||
max_login_attempts: authSettings.maxLoginAttempts || 5,
|
||||
lockout_duration_minutes: authSettings.lockoutDurationMinutes || 15,
|
||||
max_login_attempts:
|
||||
typeof authSettings.maxLoginAttempts === 'number' ? authSettings.maxLoginAttempts : 5,
|
||||
lockout_duration_minutes:
|
||||
typeof authSettings.lockoutDurationMinutes === 'number'
|
||||
? authSettings.lockoutDurationMinutes
|
||||
: 15,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get auth settings, using defaults:', error);
|
||||
|
||||
@@ -57,7 +57,7 @@ export class AuthSettingsService {
|
||||
*/
|
||||
updateSettings(data: Partial<AuthSettings>, updatedBy: number): AuthSettings {
|
||||
// Filter out non-setting fields (updatedAt, updatedBy)
|
||||
const { updatedAt: _updatedAt, updatedBy: _, ...settingsToUpdate } = data;
|
||||
const { updatedAt: _updatedAt, updatedBy: _updatedBy, ...settingsToUpdate } = data;
|
||||
|
||||
if (Object.keys(settingsToUpdate).length === 0) {
|
||||
return this.getSettings();
|
||||
|
||||
@@ -50,7 +50,7 @@ export class CachedSystemSettingsService extends SystemSettingsService {
|
||||
this.cacheTTL = cacheTTL;
|
||||
|
||||
// Start cache cleanup interval (every 5 minutes)
|
||||
this.cleanupIntervalId = setInterval(() => this.cleanup(), 5 * 60 * 1000);
|
||||
this.cleanupIntervalId = setInterval(() => this.cleanup(), 5 * 60 * 1000).unref();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,10 +10,13 @@ const PLAYLISTS_WIKI_URL = 'https://flashpointarchive.org/datahub/Playlists';
|
||||
|
||||
/**
|
||||
* Allowed domains for community playlist downloads (SSRF protection)
|
||||
* Consolidated list used by both service and route validation
|
||||
*/
|
||||
const ALLOWED_DOWNLOAD_DOMAINS = [
|
||||
export const ALLOWED_DOWNLOAD_DOMAINS = [
|
||||
'flashpointarchive.org',
|
||||
'www.flashpointarchive.org',
|
||||
'download.flashpointarchive.org',
|
||||
'fpfss.unstable.life',
|
||||
'github.com',
|
||||
'raw.githubusercontent.com',
|
||||
'gist.githubusercontent.com',
|
||||
@@ -38,9 +41,17 @@ export interface CommunityPlaylistsResponse {
|
||||
lastFetched: string;
|
||||
}
|
||||
|
||||
export interface PlaylistData {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
games: Array<{ gameId: string; [key: string]: unknown }>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DownloadResult {
|
||||
success: boolean;
|
||||
playlist?: any;
|
||||
playlist?: PlaylistData;
|
||||
error?: string;
|
||||
conflict?: boolean;
|
||||
}
|
||||
@@ -212,7 +223,8 @@ export class CommunityPlaylistService {
|
||||
|
||||
// Process all headers and tables in sequence
|
||||
$('h2, h3, table.wikitable').each((i, elem) => {
|
||||
const tagName = (elem as any).name;
|
||||
const tagName = elem.type === 'tag' ? elem.name : null;
|
||||
if (!tagName) return;
|
||||
|
||||
if (tagName === 'h2') {
|
||||
// This is a main category header
|
||||
@@ -312,26 +324,37 @@ export class CommunityPlaylistService {
|
||||
/**
|
||||
* Validate that the downloaded playlist has the required structure
|
||||
*/
|
||||
private validatePlaylistStructure(data: any): boolean {
|
||||
private validatePlaylistStructure(data: unknown): boolean {
|
||||
if (!data || typeof data !== 'object') return false;
|
||||
const obj = data as Record<string, unknown>;
|
||||
// UUID validation regex for path traversal protection
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
// Must have required fields
|
||||
if (!data.id || typeof data.id !== 'string') {
|
||||
if (!obj.id || typeof obj.id !== 'string') {
|
||||
logger.warn('[CommunityPlaylist] Validation failed: missing or invalid id');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!data.title || typeof data.title !== 'string') {
|
||||
// Validate ID format to prevent path traversal
|
||||
if (!UUID_REGEX.test(obj.id)) {
|
||||
logger.warn('[CommunityPlaylist] Validation failed: id is not a valid UUID format');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!obj.title || typeof obj.title !== 'string') {
|
||||
logger.warn('[CommunityPlaylist] Validation failed: missing or invalid title');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Games array must exist and be valid
|
||||
if (!Array.isArray(data.games)) {
|
||||
if (!Array.isArray(obj.games)) {
|
||||
logger.warn('[CommunityPlaylist] Validation failed: games is not an array');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Each game entry must have gameId
|
||||
for (const game of data.games) {
|
||||
for (const game of obj.games) {
|
||||
if (typeof game !== 'object' || !game.gameId) {
|
||||
logger.warn('[CommunityPlaylist] Validation failed: game entry missing gameId');
|
||||
return false;
|
||||
|
||||
@@ -5,19 +5,24 @@ import { measureQueryPerformance } from '../utils/queryPerformance';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Import GameSearchCache for cache invalidation
|
||||
// Import GameSearchCache and GameService for cache invalidation on DB reload
|
||||
// Using dynamic import to avoid circular dependency
|
||||
// Type is defined here to maintain type safety while avoiding circular import
|
||||
interface GameSearchCacheType {
|
||||
clearCache: () => void;
|
||||
}
|
||||
interface GameServiceType {
|
||||
clearFlashSwfCache: () => void;
|
||||
}
|
||||
let GameSearchCache: GameSearchCacheType | null = null;
|
||||
let GameServiceStatic: GameServiceType | null = null;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
GameSearchCache = require('./GameSearchCache').GameSearchCache as GameSearchCacheType;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
GameServiceStatic = require('./GameService').GameService as GameServiceType;
|
||||
} catch {
|
||||
// GameSearchCache may not be available in all contexts
|
||||
logger.debug('GameSearchCache not available for cache invalidation');
|
||||
// May not be available in all contexts
|
||||
logger.debug('GameSearchCache/GameService not available for cache invalidation');
|
||||
}
|
||||
|
||||
export class DatabaseService {
|
||||
@@ -310,30 +315,38 @@ export class DatabaseService {
|
||||
|
||||
logger.info('Source database file changed, syncing...');
|
||||
|
||||
// Close existing database connection
|
||||
if (this.db) {
|
||||
this.db.close();
|
||||
this.db = null;
|
||||
}
|
||||
|
||||
// If using local copy, update it from source
|
||||
if (this.isUsingLocalCopy && config.enableLocalDbCopy) {
|
||||
await this.copyDatabaseToLocal();
|
||||
}
|
||||
|
||||
// Reopen database connection
|
||||
this.db = this.openDatabase(this.activeDbPath);
|
||||
// Open new connection FIRST
|
||||
const newDb = this.openDatabase(this.activeDbPath);
|
||||
|
||||
// Atomic swap: replace old connection with new one
|
||||
const oldDb = this.db;
|
||||
this.db = newDb;
|
||||
|
||||
// Close old connection after swap
|
||||
if (oldDb) {
|
||||
try {
|
||||
oldDb.close();
|
||||
} catch (closeError) {
|
||||
logger.warn('Failed to close old database connection:', closeError);
|
||||
}
|
||||
}
|
||||
|
||||
// Update modification time
|
||||
this.lastModifiedTime = stats.mtimeMs;
|
||||
|
||||
// Invalidate game search cache since database changed
|
||||
// Invalidate caches since database changed
|
||||
if (GameSearchCache) {
|
||||
GameSearchCache.clearCache();
|
||||
logger.info('Game search cache invalidated due to database reload');
|
||||
}
|
||||
|
||||
logger.info('Database connection reopened successfully');
|
||||
if (GameServiceStatic) {
|
||||
GameServiceStatic.clearFlashSwfCache();
|
||||
}
|
||||
logger.info('Database reloaded, caches invalidated');
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync and reload database:', error);
|
||||
// Try to re-initialize if sync failed
|
||||
@@ -369,26 +382,36 @@ export class DatabaseService {
|
||||
if (this.isUsingLocalCopy && config.enableLocalDbCopy) {
|
||||
logger.info('Force syncing database from source...');
|
||||
|
||||
// Close existing connection
|
||||
if (this.db) {
|
||||
this.db.close();
|
||||
this.db = null;
|
||||
}
|
||||
|
||||
// Copy from source
|
||||
await this.copyDatabaseToLocal();
|
||||
|
||||
// Reopen connection
|
||||
this.db = this.openDatabase(this.activeDbPath);
|
||||
// Open new connection FIRST
|
||||
const newDb = this.openDatabase(this.activeDbPath);
|
||||
|
||||
// Atomic swap: replace old connection with new one
|
||||
const oldDb = this.db;
|
||||
this.db = newDb;
|
||||
|
||||
// Close old connection after swap
|
||||
if (oldDb) {
|
||||
try {
|
||||
oldDb.close();
|
||||
} catch (closeError) {
|
||||
logger.warn('Failed to close old database connection:', closeError);
|
||||
}
|
||||
}
|
||||
|
||||
// Update modification time
|
||||
const stats = fs.statSync(this.sourceDbPath);
|
||||
this.lastModifiedTime = stats.mtimeMs;
|
||||
|
||||
// Invalidate cache
|
||||
// Invalidate caches
|
||||
if (GameSearchCache) {
|
||||
GameSearchCache.clearCache();
|
||||
}
|
||||
if (GameServiceStatic) {
|
||||
GameServiceStatic.clearFlashSwfCache();
|
||||
}
|
||||
|
||||
logger.info('Force sync completed');
|
||||
} else {
|
||||
@@ -533,17 +556,11 @@ export class DatabaseService {
|
||||
}
|
||||
|
||||
// Save database changes to disk
|
||||
// Note: With better-sqlite3, changes are automatically written to disk
|
||||
// Note: With better-sqlite3 in DELETE journal mode, changes are automatically written to disk
|
||||
// This method exists for compatibility but is essentially a no-op
|
||||
static save(): void {
|
||||
try {
|
||||
const db = this.getDatabase();
|
||||
// Force a checkpoint in WAL mode to ensure all changes are written
|
||||
db.pragma('wal_checkpoint(TRUNCATE)');
|
||||
logger.info('Database changes flushed to disk');
|
||||
} catch (error) {
|
||||
logger.error('Failed to save database:', error);
|
||||
throw error;
|
||||
}
|
||||
// No action needed in DELETE journal mode
|
||||
// Changes are automatically flushed to disk
|
||||
logger.debug('Database save called (no-op in DELETE journal mode)');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,8 @@ export class DomainService {
|
||||
|
||||
/**
|
||||
* Validate and normalize a hostname
|
||||
* Note: Route-level Zod validation performs comprehensive checks.
|
||||
* This method provides basic sanitization only.
|
||||
*/
|
||||
private validateHostname(hostname: string): string {
|
||||
const trimmed = hostname.trim().toLowerCase();
|
||||
@@ -93,21 +95,6 @@ export class DomainService {
|
||||
throw new AppError(400, 'Hostname cannot be empty');
|
||||
}
|
||||
|
||||
// Reject protocol prefixes
|
||||
if (/^https?:\/\//i.test(trimmed)) {
|
||||
throw new AppError(400, 'Hostname must not include a protocol (http:// or https://)');
|
||||
}
|
||||
|
||||
// Reject path, query, or fragment
|
||||
if (/[/?#]/.test(trimmed)) {
|
||||
throw new AppError(400, 'Hostname must not include a path, query string, or fragment');
|
||||
}
|
||||
|
||||
// Basic hostname validation: allow alphanumeric, dots, hyphens, colons (for port)
|
||||
if (!/^[a-z0-9._-]+(:[0-9]+)?$/i.test(trimmed)) {
|
||||
throw new AppError(400, 'Invalid hostname format');
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
|
||||
@@ -75,10 +75,9 @@ export class DownloadManager {
|
||||
this.activeDownloads.set(gameDataId, controller);
|
||||
|
||||
// Chain external abort signal if provided
|
||||
const onExternalAbort = () => controller.abort();
|
||||
if (abortSignal) {
|
||||
abortSignal.addEventListener('abort', () => {
|
||||
controller.abort();
|
||||
});
|
||||
abortSignal.addEventListener('abort', onExternalAbort);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -215,6 +214,9 @@ export class DownloadManager {
|
||||
);
|
||||
} finally {
|
||||
// Clean up
|
||||
if (abortSignal) {
|
||||
abortSignal.removeEventListener('abort', onExternalAbort);
|
||||
}
|
||||
this.activeDownloads.delete(gameDataId);
|
||||
}
|
||||
}
|
||||
@@ -248,6 +250,18 @@ export class DownloadManager {
|
||||
const writer = fs.createWriteStream(destPath);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const onAbort = () => {
|
||||
response.data.destroy();
|
||||
writer.destroy();
|
||||
reject(new Error('Download cancelled'));
|
||||
};
|
||||
|
||||
abortSignal.addEventListener('abort', onAbort);
|
||||
|
||||
const cleanup = () => {
|
||||
abortSignal.removeEventListener('abort', onAbort);
|
||||
};
|
||||
|
||||
response.data.on('data', (chunk: Buffer) => {
|
||||
downloadedBytes += chunk.length;
|
||||
onProgress?.(downloadedBytes, totalBytes);
|
||||
@@ -258,24 +272,21 @@ export class DownloadManager {
|
||||
});
|
||||
|
||||
writer.on('finish', () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
});
|
||||
|
||||
writer.on('error', (error) => {
|
||||
cleanup();
|
||||
reject(new Error(`Failed to write file: ${error.message}`));
|
||||
});
|
||||
|
||||
response.data.on('error', (error: Error) => {
|
||||
cleanup();
|
||||
writer.destroy();
|
||||
reject(new Error(`Download stream error: ${error.message}`));
|
||||
});
|
||||
|
||||
abortSignal.addEventListener('abort', () => {
|
||||
response.data.destroy();
|
||||
writer.destroy();
|
||||
reject(new Error('Download cancelled'));
|
||||
});
|
||||
|
||||
response.data.pipe(writer);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -136,13 +136,13 @@ export class FavoritesService {
|
||||
|
||||
if (limit !== undefined) {
|
||||
// Validate limit is a positive integer
|
||||
const safeLimit = Math.max(1, Math.min(parseInt(String(limit), 10) || 50, 1000));
|
||||
const safeLimit = Math.max(1, Math.min(limit ?? 50, 1000));
|
||||
query += ` LIMIT ?`;
|
||||
params.push(safeLimit);
|
||||
|
||||
if (offset !== undefined) {
|
||||
// Validate offset is a non-negative integer
|
||||
const safeOffset = Math.max(0, parseInt(String(offset), 10) || 0);
|
||||
const safeOffset = Math.max(0, offset ?? 0);
|
||||
query += ` OFFSET ?`;
|
||||
params.push(safeOffset);
|
||||
}
|
||||
@@ -220,10 +220,13 @@ export class FavoritesService {
|
||||
const gameIds = favorites.map((f) => f.game_id);
|
||||
const games = await this.gameService.getGamesByIds(gameIds);
|
||||
|
||||
// Build Map for O(1) lookup (was O(N²) with .find() in loop)
|
||||
const gamesMap = new Map(games.map((g) => [g.id, g]));
|
||||
|
||||
// Merge game data with addedAt timestamps
|
||||
const favoriteGames: FavoriteGame[] = favorites
|
||||
.map((fav) => {
|
||||
const game = games.find((g) => g.id === fav.game_id);
|
||||
const game = gamesMap.get(fav.game_id);
|
||||
if (game) {
|
||||
return {
|
||||
...game,
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import axios from 'axios';
|
||||
import { logger } from '../utils/logger';
|
||||
import { config } from '../config';
|
||||
import { DatabaseService } from './DatabaseService';
|
||||
import { gameZipServer } from '../game/gamezipserver';
|
||||
import { GameDataDownloader } from '../game/services/GameDataDownloader';
|
||||
|
||||
/**
|
||||
* Game data entry from the Flashpoint database
|
||||
*/
|
||||
interface GameDataEntry {
|
||||
id: number;
|
||||
gameId: string;
|
||||
dateAdded: string;
|
||||
sha256: string;
|
||||
path: string | null;
|
||||
presentOnDisk: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for managing game data ZIP files
|
||||
@@ -11,66 +24,117 @@ import { DatabaseService } from './DatabaseService';
|
||||
*/
|
||||
export class GameDataService {
|
||||
/**
|
||||
* Mount the ZIP file for a game if it has game data
|
||||
* @param gameId The game's UUID
|
||||
* @returns true if ZIP was mounted or already mounted, false if no ZIP found
|
||||
* Get game data entry from the Flashpoint database
|
||||
*/
|
||||
async mountGameZip(gameId: string): Promise<boolean> {
|
||||
private getGameDataEntry(gameId: string): GameDataEntry | null {
|
||||
try {
|
||||
const sql = `
|
||||
SELECT id, gameId, dateAdded, sha256, path, presentOnDisk
|
||||
FROM game_data
|
||||
WHERE gameId = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const result = DatabaseService.get(sql, [gameId]) as GameDataEntry | null;
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`[GameDataService] Error getting game data entry for ${gameId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the ZIP file for a game if it has game data.
|
||||
* Calls gameZipServer directly (in-process, no HTTP).
|
||||
* If the ZIP doesn't exist locally, triggers background download
|
||||
* from the configured gameDataSources in preferences.json.
|
||||
*
|
||||
* @param gameId The game's UUID
|
||||
* @returns { mounted: true } if ZIP is mounted and ready,
|
||||
* { downloading: true } if a background download was started,
|
||||
* { mounted: false, downloading: false } if no ZIP could be found
|
||||
*/
|
||||
async mountGameZip(gameId: string): Promise<{ mounted: boolean; downloading: boolean }> {
|
||||
try {
|
||||
const mountId = gameId;
|
||||
|
||||
// Find ZIP file in Data/Games/ directory
|
||||
// ZIP files are named: {gameId}-{timestamp}.zip
|
||||
const gamesPath = config.flashpointGamesPath;
|
||||
const files = await fs.readdir(gamesPath);
|
||||
|
||||
// Look for ZIP file starting with gameId
|
||||
const zipPattern = `${gameId}-`;
|
||||
const zipFile = files.find((f) => f.startsWith(zipPattern) && f.endsWith('.zip'));
|
||||
// Get game data entry from database to get dateAdded and sha256
|
||||
const gameDataEntry = this.getGameDataEntry(gameId);
|
||||
|
||||
if (!zipFile) {
|
||||
logger.warn(`[GameDataService] No ZIP file found for game ${gameId}`);
|
||||
return false;
|
||||
// Build ZIP path - try direct path first when we have dateAdded
|
||||
let zipPath: string | null = null;
|
||||
|
||||
// Try direct path first when we have enough info (avoids expensive readdir)
|
||||
if (gameDataEntry?.dateAdded) {
|
||||
try {
|
||||
const expectedFilename = GameDataDownloader.getFilename(gameId, gameDataEntry.dateAdded);
|
||||
const expectedPath = path.join(gamesPath, expectedFilename);
|
||||
await fs.access(expectedPath);
|
||||
// File exists at expected path - use it directly
|
||||
zipPath = expectedPath;
|
||||
logger.debug(`[GameDataService] Found ZIP at expected path: ${expectedFilename}`);
|
||||
} catch {
|
||||
// Expected file not found, fall through to readdir
|
||||
logger.debug(`[GameDataService] Expected ZIP not found, trying readdir fallback`);
|
||||
}
|
||||
}
|
||||
|
||||
const zipPath = path.join(gamesPath, zipFile);
|
||||
|
||||
logger.info(
|
||||
`[GameDataService] Requesting game-service to mount ZIP for game ${gameId}: ${zipFile}`
|
||||
);
|
||||
|
||||
// Call game-service API to mount the ZIP
|
||||
const gameZipServerUrl = config.gameServiceGameZipUrl || 'http://localhost:22501';
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${gameZipServerUrl}/mount/${mountId}`,
|
||||
{
|
||||
zipPath: zipPath,
|
||||
},
|
||||
{
|
||||
timeout: 5000,
|
||||
validateStatus: (status) => status === 200 || status === 409, // 409 = already mounted
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status === 409) {
|
||||
logger.debug(`[GameDataService] ZIP already mounted for game ${gameId}`);
|
||||
// Only readdir as fallback when direct path doesn't exist
|
||||
if (!zipPath) {
|
||||
let files: string[];
|
||||
try {
|
||||
files = await fs.readdir(gamesPath);
|
||||
} catch (err) {
|
||||
logger.warn(`[GameDataService] Cannot read games directory: ${gamesPath}`, err);
|
||||
// Fall through to attempt download via gameZipServer
|
||||
files = [];
|
||||
}
|
||||
const zipPattern = `${gameId}-`;
|
||||
const zipFile = files.find((f) => f.startsWith(zipPattern) && f.endsWith('.zip'));
|
||||
|
||||
if (zipFile) {
|
||||
zipPath = path.join(gamesPath, zipFile);
|
||||
} else if (gameDataEntry?.dateAdded) {
|
||||
// Use GameDataDownloader.getFilename() to construct expected filename
|
||||
const expectedFilename = GameDataDownloader.getFilename(gameId, gameDataEntry.dateAdded);
|
||||
zipPath = path.join(gamesPath, expectedFilename);
|
||||
logger.info(
|
||||
`[GameDataService] ZIP not found locally, will request download: ${expectedFilename}`
|
||||
);
|
||||
} else {
|
||||
logger.warn(`[GameDataService] No ZIP file found and no game data entry for ${gameId}`);
|
||||
return { mounted: false, downloading: false };
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[GameDataService] Mounting ZIP for game ${gameId}`);
|
||||
|
||||
// Direct call to gameZipServer (in-process, no HTTP)
|
||||
const result = await gameZipServer.mountZip({
|
||||
id: mountId,
|
||||
zipPath,
|
||||
gameId: gameDataEntry?.gameId || gameId,
|
||||
dateAdded: gameDataEntry?.dateAdded,
|
||||
sha256: gameDataEntry?.sha256,
|
||||
});
|
||||
|
||||
if (result.downloading) {
|
||||
logger.info(`[GameDataService] Download started in background for game ${gameId}`);
|
||||
return { mounted: false, downloading: true };
|
||||
} else if (result.success) {
|
||||
logger.info(`[GameDataService] ✓ ZIP mounted successfully for game ${gameId}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 409) {
|
||||
// Already mounted
|
||||
logger.debug(`[GameDataService] ZIP already mounted for game ${gameId}`);
|
||||
return true;
|
||||
}
|
||||
logger.error(`[GameDataService] Failed to mount ZIP via game-service:`, error);
|
||||
return false;
|
||||
return { mounted: true, downloading: false };
|
||||
} else {
|
||||
logger.warn(
|
||||
`[GameDataService] Mount failed for game ${gameId} (status: ${result.statusCode})`
|
||||
);
|
||||
return { mounted: false, downloading: false };
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[GameDataService] Error mounting ZIP for game ${gameId}:`, error);
|
||||
return false;
|
||||
return { mounted: false, downloading: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,26 +145,24 @@ export class GameDataService {
|
||||
async mountMultipleGameZips(activeDataIds: number[]): Promise<void> {
|
||||
logger.info(`[GameDataService] Mounting ${activeDataIds.length} game ZIPs...`);
|
||||
|
||||
// Fetch game data for each activeDataId to get gameId
|
||||
const gameDataPromises = activeDataIds.map(async (activeDataId) => {
|
||||
const sql = 'SELECT gameId FROM game_data WHERE id = ?';
|
||||
const result = DatabaseService.get(sql, [activeDataId]);
|
||||
return result ? { gameId: result.gameId, activeDataId } : null;
|
||||
});
|
||||
if (activeDataIds.length === 0) return;
|
||||
|
||||
const gameDataResults = await Promise.allSettled(gameDataPromises);
|
||||
const validGameData = gameDataResults
|
||||
.filter(
|
||||
(r): r is PromiseFulfilledResult<{ gameId: string; activeDataId: number } | null> =>
|
||||
r.status === 'fulfilled' && r.value !== null
|
||||
)
|
||||
.map((r) => r.value!);
|
||||
// Batch query: single IN clause instead of N individual queries
|
||||
const placeholders = activeDataIds.map(() => '?').join(', ');
|
||||
const rows = DatabaseService.all(
|
||||
`SELECT id, gameId FROM game_data WHERE id IN (${placeholders})`,
|
||||
activeDataIds
|
||||
) as Array<{ id: number; gameId: string }>;
|
||||
|
||||
const validGameData = rows.map((r) => ({ gameId: r.gameId, activeDataId: r.id }));
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
validGameData.map(({ gameId }) => this.mountGameZip(gameId))
|
||||
);
|
||||
|
||||
const succeeded = results.filter((r) => r.status === 'fulfilled' && r.value).length;
|
||||
const succeeded = results.filter(
|
||||
(r) => r.status === 'fulfilled' && (r.value.mounted || r.value.downloading)
|
||||
).length;
|
||||
const failed = results.length - succeeded;
|
||||
|
||||
logger.info(
|
||||
@@ -122,15 +184,20 @@ export class GameDataService {
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.zip')) {
|
||||
// Extract activeDataId from filename (format: {id}-{timestamp}.zip)
|
||||
const match = file.match(/^(\d+)-\d+\.zip$/);
|
||||
// Extract gameId from filename (format: {gameId}-{timestamp}.zip)
|
||||
// gameId is a UUID, not a numeric ID
|
||||
const match = file.match(
|
||||
/^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-(\d+)\.zip$/i
|
||||
);
|
||||
if (match) {
|
||||
const activeDataId = parseInt(match[1], 10);
|
||||
const gameId = match[1];
|
||||
const filePath = path.join(gamesPath, file);
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
// Note: This returns gameId instead of activeDataId
|
||||
// The return type would need to be updated if this is actually used
|
||||
zipFiles.push({
|
||||
activeDataId,
|
||||
activeDataId: 0, // Placeholder - would need to query database
|
||||
filename: file,
|
||||
size: stats.size,
|
||||
});
|
||||
|
||||
@@ -16,9 +16,33 @@ export interface GameData {
|
||||
parameters: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal interface for database rows returned by SQL queries.
|
||||
*/
|
||||
interface GameDataRow {
|
||||
id: number;
|
||||
gameId: string;
|
||||
title: string;
|
||||
dateAdded: string;
|
||||
sha256: string;
|
||||
crc32: number;
|
||||
presentOnDisk: number;
|
||||
path: string | null;
|
||||
size: number;
|
||||
parameters: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for updating the Flashpoint database after game data downloads.
|
||||
* Handles transaction safety and proper updates to both game_data and game tables.
|
||||
*
|
||||
* NOTE: This service intentionally writes to flashpoint.sqlite to update
|
||||
* presentOnDisk and path fields in game_data/game tables. This matches
|
||||
* the Flashpoint Launcher's behavior when downloading game data.
|
||||
* better-sqlite3 handles file locking, and the backend's DatabaseService
|
||||
* file watcher handles reloads when the Launcher makes changes.
|
||||
*
|
||||
* All write operations are wrapped in transactions for atomicity to prevent
|
||||
* database inconsistencies if any step fails.
|
||||
*/
|
||||
export class GameDatabaseUpdater {
|
||||
/**
|
||||
@@ -53,55 +77,64 @@ export class GameDatabaseUpdater {
|
||||
relativePath,
|
||||
});
|
||||
|
||||
// Update game_data table
|
||||
const updateGameDataSql = `
|
||||
UPDATE game_data
|
||||
SET presentOnDisk = 1, path = ?
|
||||
WHERE id = ?
|
||||
`;
|
||||
|
||||
logger.debug('Executing UPDATE on game_data', {
|
||||
sql: updateGameDataSql,
|
||||
params: [relativePath, gameDataId],
|
||||
});
|
||||
|
||||
DatabaseService.run(updateGameDataSql, [relativePath, gameDataId]);
|
||||
|
||||
logger.debug('game_data UPDATE successful');
|
||||
|
||||
// Check if this game_data is the active data for the game
|
||||
const gameSql = `
|
||||
SELECT id, activeDataId
|
||||
FROM game
|
||||
WHERE id = ?
|
||||
`;
|
||||
const game = DatabaseService.get(gameSql, [gameData.gameId]);
|
||||
|
||||
if (game && game.activeDataId === gameDataId) {
|
||||
// Update game table to mark active data as on disk
|
||||
const updateGameSql = `
|
||||
UPDATE game
|
||||
SET activeDataOnDisk = 1
|
||||
WHERE id = ? AND activeDataId = ?
|
||||
// Wrap both UPDATE statements in a transaction for atomicity
|
||||
const db = DatabaseService.getDatabase();
|
||||
const updateTransaction = db.transaction(() => {
|
||||
// Update game_data table
|
||||
const updateGameDataSql = `
|
||||
UPDATE game_data
|
||||
SET presentOnDisk = 1, path = ?
|
||||
WHERE id = ?
|
||||
`;
|
||||
|
||||
logger.debug('Executing UPDATE on game table', {
|
||||
sql: updateGameSql,
|
||||
params: [gameData.gameId, gameDataId],
|
||||
logger.debug('Executing UPDATE on game_data', {
|
||||
sql: updateGameDataSql,
|
||||
params: [relativePath, gameDataId],
|
||||
});
|
||||
|
||||
DatabaseService.run(updateGameSql, [gameData.gameId, gameDataId]);
|
||||
db.prepare(updateGameDataSql).run(relativePath, gameDataId);
|
||||
|
||||
logger.debug('game table UPDATE successful - activeDataOnDisk set to 1', {
|
||||
gameId: gameData.gameId,
|
||||
});
|
||||
} else {
|
||||
logger.debug('Skipping game table update - game data is not active', {
|
||||
gameId: gameData.gameId,
|
||||
activeDataId: game?.activeDataId,
|
||||
requestedDataId: gameDataId,
|
||||
});
|
||||
}
|
||||
logger.debug('game_data UPDATE successful');
|
||||
|
||||
// Check if this game_data is the active data for the game
|
||||
const gameSql = `
|
||||
SELECT id, activeDataId
|
||||
FROM game
|
||||
WHERE id = ?
|
||||
`;
|
||||
const game = db.prepare(gameSql).get(gameData.gameId) as
|
||||
| { id: string; activeDataId: number }
|
||||
| undefined;
|
||||
|
||||
if (game && game.activeDataId === gameDataId) {
|
||||
// Update game table to mark active data as on disk
|
||||
const updateGameSql = `
|
||||
UPDATE game
|
||||
SET activeDataOnDisk = 1
|
||||
WHERE id = ? AND activeDataId = ?
|
||||
`;
|
||||
|
||||
logger.debug('Executing UPDATE on game table', {
|
||||
sql: updateGameSql,
|
||||
params: [gameData.gameId, gameDataId],
|
||||
});
|
||||
|
||||
db.prepare(updateGameSql).run(gameData.gameId, gameDataId);
|
||||
|
||||
logger.debug('game table UPDATE successful - activeDataOnDisk set to 1', {
|
||||
gameId: gameData.gameId,
|
||||
});
|
||||
} else {
|
||||
logger.debug('Skipping game table update - game data is not active', {
|
||||
gameId: gameData.gameId,
|
||||
activeDataId: game?.activeDataId,
|
||||
requestedDataId: gameDataId,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Execute the transaction
|
||||
updateTransaction();
|
||||
|
||||
// Save database to disk
|
||||
await this.saveDatabase();
|
||||
@@ -191,7 +224,7 @@ export class GameDatabaseUpdater {
|
||||
/**
|
||||
* Map database row to GameData object.
|
||||
*/
|
||||
private static mapRowToGameData(row: any): GameData {
|
||||
private static mapRowToGameData(row: GameDataRow): GameData {
|
||||
return {
|
||||
id: row.id,
|
||||
gameId: row.gameId,
|
||||
@@ -230,18 +263,28 @@ export class GameDatabaseUpdater {
|
||||
|
||||
/**
|
||||
* Mark game data as not downloaded (for cleanup/error scenarios).
|
||||
* Wrapped in transaction for consistency with markAsDownloaded.
|
||||
*
|
||||
* @param gameDataId - The game_data.id
|
||||
*/
|
||||
static async markAsNotDownloaded(gameDataId: number): Promise<void> {
|
||||
try {
|
||||
const sql = `
|
||||
UPDATE game_data
|
||||
SET presentOnDisk = 0, path = NULL
|
||||
WHERE id = ?
|
||||
`;
|
||||
const db = DatabaseService.getDatabase();
|
||||
|
||||
// Wrap UPDATE in transaction for atomicity
|
||||
const updateTransaction = db.transaction(() => {
|
||||
const sql = `
|
||||
UPDATE game_data
|
||||
SET presentOnDisk = 0, path = NULL
|
||||
WHERE id = ?
|
||||
`;
|
||||
|
||||
db.prepare(sql).run(gameDataId);
|
||||
});
|
||||
|
||||
// Execute the transaction
|
||||
updateTransaction();
|
||||
|
||||
DatabaseService.run(sql, [gameDataId]);
|
||||
await this.saveDatabase();
|
||||
|
||||
logger.info('Marked game data as not downloaded', { gameDataId });
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import http from 'http';
|
||||
import { logger } from '../utils/logger';
|
||||
import { config } from '../config';
|
||||
|
||||
interface ProxySettings {
|
||||
infinityServerURL?: string;
|
||||
externalFilePaths?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Service to handle game file requests with automatic fallback to external archives
|
||||
* This makes the web app independent of the Game Server's useInfinityServer setting
|
||||
*/
|
||||
export class GameFileService {
|
||||
private infinityServerURL: string | null = null;
|
||||
private externalFilePaths: string[] = [];
|
||||
private gameServerUrl = 'http://localhost:22500';
|
||||
|
||||
constructor() {
|
||||
this.loadProxySettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load external fallback URLs from proxySettings.json
|
||||
*/
|
||||
private async loadProxySettings() {
|
||||
try {
|
||||
const proxySettingsPath = path.join(config.flashpointPath, 'Server', 'proxySettings.json');
|
||||
const data = await fs.readFile(proxySettingsPath, 'utf-8');
|
||||
const settings: ProxySettings = JSON.parse(data);
|
||||
|
||||
// Use externalFilePaths directly - these have the correct HTTP URLs
|
||||
this.externalFilePaths = settings.externalFilePaths || [];
|
||||
|
||||
// Fix infinityServerURL if it uses HTTPS (should be HTTP)
|
||||
if (settings.infinityServerURL) {
|
||||
this.infinityServerURL = settings.infinityServerURL.replace(/^https:\/\//, 'http://');
|
||||
}
|
||||
|
||||
logger.info(`[GameFileService] Loaded proxy settings`);
|
||||
logger.info(`[GameFileService] Infinity Server: ${this.infinityServerURL}`);
|
||||
logger.info(`[GameFileService] External paths: ${this.externalFilePaths.length} sources`);
|
||||
|
||||
// Log all sources for debugging
|
||||
this.externalFilePaths.forEach((path, i) => {
|
||||
logger.info(`[GameFileService] [${i + 1}] ${path}`);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('[GameFileService] Could not load proxySettings.json, using defaults');
|
||||
// Use HTTP (not HTTPS) as per Flashpoint documentation
|
||||
this.infinityServerURL = 'http://infinity.flashpointarchive.org/Flashpoint/Legacy/htdocs/';
|
||||
this.externalFilePaths = [
|
||||
'http://infinity.flashpointarchive.org/Flashpoint/Legacy/htdocs',
|
||||
'http://infinity.unstable.life/Flashpoint/Legacy/htdocs/',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a game file, with automatic fallback to external sources
|
||||
* @param urlPath The path like "www.example.com/path/file.swf"
|
||||
* @returns File buffer and content type
|
||||
*/
|
||||
async getGameFile(
|
||||
urlPath: string
|
||||
): Promise<{ data: Buffer; contentType: string; source: string }> {
|
||||
logger.info(`[GameFileService] ========================================`);
|
||||
logger.info(`[GameFileService] Requested: ${urlPath}`);
|
||||
|
||||
// First, try the local Game Server
|
||||
logger.info(`[GameFileService] [1/4] Trying local Game Server (localhost:22500)...`);
|
||||
try {
|
||||
const result = await this.tryGameServer(urlPath);
|
||||
if (result) {
|
||||
logger.info(`[GameFileService] ✓ Found in Game Server!`);
|
||||
return { ...result, source: 'game-server' };
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`[GameFileService] ✗ Game Server error: ${error}`);
|
||||
}
|
||||
|
||||
// If Game Server returns 404, try external sources
|
||||
logger.info(`[GameFileService] File not found locally, trying external archives...`);
|
||||
|
||||
// Try Infinity Server first
|
||||
if (this.infinityServerURL) {
|
||||
logger.info(`[GameFileService] [2/4] Trying Infinity Server...`);
|
||||
const result = await this.tryExternalSource(this.infinityServerURL, urlPath);
|
||||
if (result) {
|
||||
logger.info(`[GameFileService] ✓ Found in Infinity Server!`);
|
||||
return { ...result, source: 'infinity-server' };
|
||||
}
|
||||
}
|
||||
|
||||
// Try other external paths
|
||||
let attemptNum = 3;
|
||||
for (const externalPath of this.externalFilePaths) {
|
||||
logger.info(
|
||||
`[GameFileService] [${attemptNum}/${3 + this.externalFilePaths.length}] Trying: ${externalPath}`
|
||||
);
|
||||
const result = await this.tryExternalSource(externalPath, urlPath);
|
||||
if (result) {
|
||||
logger.info(`[GameFileService] ✓ Found in external source!`);
|
||||
return { ...result, source: externalPath };
|
||||
}
|
||||
attemptNum++;
|
||||
}
|
||||
|
||||
logger.error(
|
||||
`[GameFileService] ✗ File not found in any source (tried ${2 + this.externalFilePaths.length} sources)`
|
||||
);
|
||||
throw new Error('File not found in Game Server or any external source');
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to get file from the local Game Server
|
||||
*/
|
||||
private async tryGameServer(
|
||||
urlPath: string
|
||||
): Promise<{ data: Buffer; contentType: string } | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Make proxy-style HTTP request
|
||||
const fullUrl = `http://${urlPath}`;
|
||||
const parsedUrl = new URL(fullUrl);
|
||||
|
||||
const options: http.RequestOptions = {
|
||||
hostname: 'localhost',
|
||||
port: 22500,
|
||||
path: fullUrl,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Host: parsedUrl.hostname,
|
||||
'User-Agent': 'Flashpoint-WebApp/1.0',
|
||||
},
|
||||
timeout: 10000,
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
if (res.statusCode === 404) {
|
||||
resolve(null); // Not found, will try external sources
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`HTTP ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const data = Buffer.concat(chunks);
|
||||
const contentType = res.headers['content-type'] || 'application/octet-stream';
|
||||
resolve({ data, contentType });
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error('Timeout'));
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to download file from an external source
|
||||
*/
|
||||
private async tryExternalSource(
|
||||
baseUrl: string,
|
||||
urlPath: string
|
||||
): Promise<{ data: Buffer; contentType: string } | null> {
|
||||
// Construct full URL
|
||||
// baseUrl might be: http://infinity.flashpointarchive.org/Flashpoint/Legacy/htdocs/
|
||||
// urlPath is: www.example.com/path/file.swf
|
||||
// Result: http://infinity.flashpointarchive.org/Flashpoint/Legacy/htdocs/www.example.com/path/file.swf
|
||||
//
|
||||
// Special case for archive.org ZIP files:
|
||||
// baseUrl: http://archive.org/download/FP90Data/FP90Data.zip/htdocs
|
||||
// Result: http://archive.org/download/FP90Data/FP90Data.zip/htdocs/www.example.com/path/file.swf
|
||||
|
||||
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
||||
const fullUrl = `${normalizedBase}${urlPath}`;
|
||||
|
||||
logger.info(`[GameFileService] Downloading: ${fullUrl}`);
|
||||
|
||||
try {
|
||||
const response = await axios.get(fullUrl, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 45000, // Increased timeout for slow external servers
|
||||
maxRedirects: 5,
|
||||
validateStatus: (status) => status === 200,
|
||||
headers: {
|
||||
'User-Agent': 'Flashpoint-WebApp/1.0',
|
||||
},
|
||||
});
|
||||
|
||||
const data = Buffer.from(response.data);
|
||||
const contentType = response.headers['content-type'] || 'application/octet-stream';
|
||||
|
||||
logger.info(`[GameFileService] ✓ Success: ${data.length} bytes (${contentType})`);
|
||||
|
||||
// TODO: Optionally cache the file locally for future requests
|
||||
|
||||
return { data, contentType };
|
||||
} catch (error) {
|
||||
// Log detailed error but return null to try next source
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (error.response?.status === 404) {
|
||||
logger.warn(`[GameFileService] ✗ Not found (404)`);
|
||||
} else if (error.response?.status === 403) {
|
||||
logger.warn(`[GameFileService] ✗ Forbidden (403) - access denied`);
|
||||
} else if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
|
||||
logger.warn(`[GameFileService] ✗ Timeout after 45s`);
|
||||
} else if (error.code === 'ENOTFOUND') {
|
||||
logger.warn(`[GameFileService] ✗ DNS resolution failed`);
|
||||
} else if (error.response) {
|
||||
logger.warn(
|
||||
`[GameFileService] ✗ HTTP ${error.response.status}: ${error.response.statusText}`
|
||||
);
|
||||
} else if (error.request) {
|
||||
logger.warn(`[GameFileService] ✗ No response received: ${error.message}`);
|
||||
} else {
|
||||
logger.warn(`[GameFileService] ✗ Network error: ${error.message}`);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`[GameFileService] ✗ Unexpected error: ${error}`);
|
||||
}
|
||||
|
||||
// Always return null instead of throwing, so we can try the next source
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,14 +53,14 @@ export class GameSearchCache {
|
||||
private static generateCacheKey(query: GameSearchQuery): string {
|
||||
const normalizedQuery = {
|
||||
search: query.search || '',
|
||||
platforms: query.platforms?.sort().join(',') || '',
|
||||
series: query.series?.sort().join(',') || '',
|
||||
developers: query.developers?.sort().join(',') || '',
|
||||
publishers: query.publishers?.sort().join(',') || '',
|
||||
playModes: query.playModes?.sort().join(',') || '',
|
||||
languages: query.languages?.sort().join(',') || '',
|
||||
platforms: query.platforms ? [...query.platforms].sort().join(',') : '',
|
||||
series: query.series ? [...query.series].sort().join(',') : '',
|
||||
developers: query.developers ? [...query.developers].sort().join(',') : '',
|
||||
publishers: query.publishers ? [...query.publishers].sort().join(',') : '',
|
||||
playModes: query.playModes ? [...query.playModes].sort().join(',') : '',
|
||||
languages: query.languages ? [...query.languages].sort().join(',') : '',
|
||||
library: query.library || '',
|
||||
tags: query.tags?.sort().join(',') || '',
|
||||
tags: query.tags ? [...query.tags].sort().join(',') : '',
|
||||
yearFrom: query.yearFrom || '',
|
||||
yearTo: query.yearTo || '',
|
||||
dateAddedSince: query.dateAddedSince || '',
|
||||
|
||||
@@ -58,6 +58,8 @@ export interface Game {
|
||||
playtime?: number;
|
||||
playCounter?: number;
|
||||
archiveState?: number;
|
||||
activeDataId?: number; // ID of the active game_data entry (null/0 = no game data)
|
||||
activeDataOnDisk?: number; // Whether the active data is downloaded (0 = no, 1 = yes)
|
||||
logoPath?: string; // Infinity edition only
|
||||
screenshotPath?: string; // Infinity edition only
|
||||
}
|
||||
@@ -71,6 +73,77 @@ export interface PaginatedResult<T> {
|
||||
}
|
||||
|
||||
export class GameService {
|
||||
/**
|
||||
* Cached set of Flash game IDs that have .swf launch commands in game_data.
|
||||
* Pre-computed once and cached with TTL to avoid per-row EXISTS subqueries
|
||||
* which would be O(N*M) on every search (N=games, M=game_data rows).
|
||||
*
|
||||
* Since flashpoint.sqlite is read-only, this cache only needs to refresh
|
||||
* when the database file is reloaded (detected via DatabaseService file watcher).
|
||||
*/
|
||||
private static flashSwfGameIds: Set<string> | null = null;
|
||||
private static flashSwfCacheExpiry = 0;
|
||||
private static readonly FLASH_SWF_CACHE_TTL = 3600000; // 1 hour
|
||||
|
||||
/**
|
||||
* Get the set of Flash game IDs whose game_data.launchCommand points to a .swf file.
|
||||
* Results are cached in memory for 1 hour (read-only DB changes rarely).
|
||||
*/
|
||||
private getFlashSwfGameIds(): Set<string> {
|
||||
const now = Date.now();
|
||||
if (GameService.flashSwfGameIds && now < GameService.flashSwfCacheExpiry) {
|
||||
return GameService.flashSwfGameIds;
|
||||
}
|
||||
|
||||
const startTime = performance.now();
|
||||
const sql = `
|
||||
SELECT DISTINCT gd.gameId
|
||||
FROM game_data gd
|
||||
JOIN game g ON g.id = gd.gameId
|
||||
WHERE g.platformName = 'Flash'
|
||||
AND (LOWER(gd.launchCommand) LIKE '%.swf' OR LOWER(gd.launchCommand) LIKE '%.swf?%')
|
||||
`;
|
||||
|
||||
const rows = DatabaseService.all(sql, []) as Array<{ gameId: string }>;
|
||||
GameService.flashSwfGameIds = new Set(rows.map((r) => r.gameId));
|
||||
GameService.flashSwfCacheExpiry = now + GameService.FLASH_SWF_CACHE_TTL;
|
||||
|
||||
const duration = Math.round(performance.now() - startTime);
|
||||
logger.info(
|
||||
`[GameService] Cached ${GameService.flashSwfGameIds.size} Flash SWF game IDs in ${duration}ms`
|
||||
);
|
||||
return GameService.flashSwfGameIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the Flash SWF game IDs cache.
|
||||
* Call when the flashpoint.sqlite database is reloaded.
|
||||
*/
|
||||
static clearFlashSwfCache(): void {
|
||||
GameService.flashSwfGameIds = null;
|
||||
GameService.flashSwfCacheExpiry = 0;
|
||||
logger.debug('[GameService] Flash SWF game IDs cache cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL condition to exclude Flash games without SWF launch commands.
|
||||
* Uses EXISTS subquery against game_data table (leverages UNIQUE(gameId, dateAdded) index).
|
||||
*
|
||||
* Performance: ~150ms overhead per query. Acceptable for searchGames() since results
|
||||
* are cached by GameSearchCache (5-min TTL). For aggregate queries like
|
||||
* getPlatformOptions(), use getFlashSwfGameIds() cache instead.
|
||||
*/
|
||||
private getFlashSwfCondition(tableAlias: string = 'g'): string {
|
||||
return `(
|
||||
${tableAlias}.platformName != 'Flash'
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM game_data gd
|
||||
WHERE gd.gameId = ${tableAlias}.id
|
||||
AND (LOWER(gd.launchCommand) LIKE '%.swf' OR LOWER(gd.launchCommand) LIKE '%.swf?%')
|
||||
)
|
||||
)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running Infinity edition (has logoPath/screenshotPath columns)
|
||||
*/
|
||||
@@ -86,11 +159,13 @@ export class GameService {
|
||||
const isInfinity = this.isInfinityEdition();
|
||||
|
||||
if (fields === 'list') {
|
||||
// Minimal columns for list views (12 essential fields)
|
||||
// Minimal columns for list views (7 essential fields)
|
||||
// GameCard uses: id, title, developer, platformName
|
||||
// GameListItem uses: id, title, developer, platformName, tagsStr
|
||||
// library and orderTitle needed for filtering/sorting
|
||||
return `
|
||||
g.id, g.title, g.developer, g.publisher, g.platformName,
|
||||
g.library, g.orderTitle, g.releaseDate, g.tagsStr,
|
||||
g.broken, g.extreme, g.series
|
||||
g.id, g.title, g.developer, g.platformName,
|
||||
g.tagsStr, g.library, g.orderTitle
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -102,7 +177,8 @@ export class GameService {
|
||||
g.source, g.applicationPath, g.launchCommand, g.releaseDate,
|
||||
g.version, g.originalDescription, g.language,
|
||||
g.library, g.orderTitle, g.dateAdded, g.dateModified,
|
||||
g.lastPlayed, g.playtime, g.playCounter, g.archiveState`;
|
||||
g.lastPlayed, g.playtime, g.playCounter, g.archiveState,
|
||||
g.activeDataId, g.activeDataOnDisk`;
|
||||
|
||||
// Infinity edition has logoPath and screenshotPath columns
|
||||
if (isInfinity) {
|
||||
@@ -133,7 +209,7 @@ export class GameService {
|
||||
WHERE 1=1
|
||||
`;
|
||||
|
||||
const params: any[] = [];
|
||||
const params: unknown[] = [];
|
||||
|
||||
// Apply filters
|
||||
if (query.platforms && query.platforms.length > 0) {
|
||||
@@ -223,6 +299,9 @@ export class GameService {
|
||||
sql += ` AND (g.extreme = 0 OR g.extreme IS NULL)`;
|
||||
}
|
||||
|
||||
// Exclude Flash games without SWF launch commands (not web-playable)
|
||||
sql += ` AND ${this.getFlashSwfCondition('g')}`;
|
||||
|
||||
if (query.search) {
|
||||
sql += ` AND (
|
||||
g.title LIKE ? OR
|
||||
@@ -314,14 +393,11 @@ export class GameService {
|
||||
async getGameById(id: string): Promise<Game | null> {
|
||||
try {
|
||||
const columns = this.getColumnSelection('detail');
|
||||
// No need for LEFT JOIN - activeDataId and activeDataOnDisk are on game table
|
||||
const sql = `
|
||||
SELECT
|
||||
${columns},
|
||||
MAX(gd.presentOnDisk) as presentOnDisk
|
||||
SELECT ${columns}
|
||||
FROM game g
|
||||
LEFT JOIN game_data gd ON gd.gameId = g.id
|
||||
WHERE g.id = ?
|
||||
GROUP BY g.id
|
||||
`;
|
||||
|
||||
const game = DatabaseService.get(sql, [id]) as Game | null;
|
||||
@@ -344,18 +420,35 @@ export class GameService {
|
||||
|
||||
const columns = this.getColumnSelection('detail');
|
||||
const placeholders = ids.map(() => '?').join(', ');
|
||||
|
||||
// Query WITHOUT LEFT JOIN to avoid row explosion from multiple game_data entries
|
||||
const sql = `
|
||||
SELECT
|
||||
${columns},
|
||||
MAX(gd.presentOnDisk) as presentOnDisk
|
||||
SELECT ${columns}
|
||||
FROM game g
|
||||
LEFT JOIN game_data gd ON gd.gameId = g.id
|
||||
WHERE g.id IN (${placeholders})
|
||||
GROUP BY g.id
|
||||
`;
|
||||
|
||||
const games = DatabaseService.all(sql, ids) as Game[];
|
||||
|
||||
// Fetch presentOnDisk separately (same post-processing pattern as searchGames)
|
||||
if (games.length > 0) {
|
||||
const gameIds = games.map((g) => g.id);
|
||||
const pdPlaceholders = gameIds.map(() => '?').join(', ');
|
||||
const presentOnDiskResults = DatabaseService.all(
|
||||
`SELECT gameId, MAX(presentOnDisk) as presentOnDisk
|
||||
FROM game_data WHERE gameId IN (${pdPlaceholders}) GROUP BY gameId`,
|
||||
gameIds
|
||||
) as Array<{ gameId: string; presentOnDisk: number }>;
|
||||
|
||||
const presentOnDiskMap = new Map(
|
||||
presentOnDiskResults.map((r) => [r.gameId, r.presentOnDisk])
|
||||
);
|
||||
|
||||
games.forEach((game) => {
|
||||
game.presentOnDisk = presentOnDiskMap.get(game.id) ?? 0;
|
||||
});
|
||||
}
|
||||
|
||||
// Sort games to match the order of input IDs
|
||||
const gamesMap = new Map(games.map((g) => [g.id, g]));
|
||||
return ids.map((id) => gamesMap.get(id)).filter((g): g is Game => g !== undefined);
|
||||
@@ -375,7 +468,7 @@ export class GameService {
|
||||
SELECT id
|
||||
FROM game_data
|
||||
WHERE gameId = ?
|
||||
ORDER BY presentOnDisk DESC, id DESC
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
@@ -393,7 +486,7 @@ export class GameService {
|
||||
SELECT launchCommand, path, parameters
|
||||
FROM game_data
|
||||
WHERE gameId = ?
|
||||
ORDER BY presentOnDisk DESC, id DESC
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
@@ -484,7 +577,7 @@ export class GameService {
|
||||
WHERE (g.broken = 0 OR g.broken IS NULL)
|
||||
`;
|
||||
|
||||
const params: any[] = [];
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (library) {
|
||||
sql += ` AND g.library = ?`;
|
||||
@@ -515,6 +608,7 @@ export class GameService {
|
||||
/**
|
||||
* Get all filter options for dropdowns in a single call
|
||||
* Returns: series, developers, publishers, playModes, languages, tags, platforms, yearRange
|
||||
* Note: Methods are synchronous but wrapped in async for consistent API contract
|
||||
*/
|
||||
async getFilterOptions(): Promise<{
|
||||
series: Array<{ name: string; count: number }>;
|
||||
@@ -527,18 +621,15 @@ export class GameService {
|
||||
yearRange: { min: number; max: number };
|
||||
}> {
|
||||
try {
|
||||
// Fetch all filter options in parallel for performance
|
||||
const [series, developers, publishers, playModes, languages, tags, platforms, yearRange] =
|
||||
await Promise.all([
|
||||
this.getSeriesOptions(),
|
||||
this.getDeveloperOptions(),
|
||||
this.getPublisherOptions(),
|
||||
this.getPlayModeOptions(),
|
||||
this.getLanguageOptions(),
|
||||
this.getTagOptions(),
|
||||
this.getPlatformOptions(),
|
||||
this.getYearRange(),
|
||||
]);
|
||||
// Call synchronous methods directly (no Promise.all needed)
|
||||
const series = this.getSeriesOptions();
|
||||
const developers = this.getDeveloperOptions();
|
||||
const publishers = this.getPublisherOptions();
|
||||
const playModes = this.getPlayModeOptions();
|
||||
const languages = this.getLanguageOptions();
|
||||
const tags = this.getTagOptions();
|
||||
const platforms = this.getPlatformOptions();
|
||||
const yearRange = this.getYearRange();
|
||||
|
||||
return {
|
||||
series,
|
||||
@@ -728,8 +819,9 @@ export class GameService {
|
||||
*/
|
||||
getPlatformOptions(): Array<{ name: string; count: number }> {
|
||||
try {
|
||||
// Query all platforms without Flash SWF filter (fast, no EXISTS)
|
||||
const sql = `
|
||||
SELECT DISTINCT platformName as name, COUNT(*) as count
|
||||
SELECT platformName as name, COUNT(*) as count
|
||||
FROM game
|
||||
WHERE platformName IS NOT NULL AND platformName != ''
|
||||
AND (broken = 0 OR broken IS NULL)
|
||||
@@ -739,6 +831,14 @@ export class GameService {
|
||||
`;
|
||||
|
||||
const results = DatabaseService.all(sql, []) as Array<{ name: string; count: number }>;
|
||||
|
||||
// Adjust Flash count using cached SWF game IDs (avoids slow EXISTS subquery)
|
||||
const flashSwfIds = this.getFlashSwfGameIds();
|
||||
const flashIndex = results.findIndex((r) => r.name === 'Flash');
|
||||
if (flashIndex !== -1) {
|
||||
results[flashIndex].count = flashSwfIds.size;
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.error('Error getting platform options:', error);
|
||||
|
||||
@@ -23,7 +23,7 @@ export class ImageUrlService {
|
||||
'https://infinity.unstable.life/Flashpoint/Data/Images', // Secondary fallback
|
||||
];
|
||||
|
||||
logger.info('Using external image URLs from Flashpoint preferences', {
|
||||
logger.debug('Using external image URLs from Flashpoint preferences', {
|
||||
baseUrl: preferences.onDemandBaseUrl,
|
||||
constructedUrls: urls,
|
||||
});
|
||||
@@ -42,7 +42,7 @@ export class ImageUrlService {
|
||||
'https://infinity.unstable.life/Flashpoint/Data/Images',
|
||||
];
|
||||
|
||||
logger.info('Using default image URLs', { urls: defaultUrls });
|
||||
logger.debug('Using default image URLs', { urls: defaultUrls });
|
||||
return defaultUrls;
|
||||
}
|
||||
|
||||
@@ -72,5 +72,3 @@ export class ImageUrlService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ImageUrlService;
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { UserDatabaseService } from './UserDatabaseService';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
interface JobExecutionRow {
|
||||
id: number;
|
||||
job_id: string;
|
||||
job_name: string;
|
||||
status: string;
|
||||
started_at: string;
|
||||
completed_at: string | null;
|
||||
duration_seconds: number | null;
|
||||
message: string | null;
|
||||
error_details: string | null;
|
||||
triggered_by: string;
|
||||
}
|
||||
|
||||
export interface JobExecutionLog {
|
||||
id: number;
|
||||
jobId: string;
|
||||
@@ -79,7 +92,7 @@ export class JobExecutionService {
|
||||
ORDER BY started_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[jobId, limit, offset]
|
||||
) as any[];
|
||||
) as JobExecutionRow[];
|
||||
|
||||
return {
|
||||
data: logs.map(this.mapToJobExecutionLog),
|
||||
@@ -101,7 +114,7 @@ export class JobExecutionService {
|
||||
ORDER BY started_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[limit, offset]
|
||||
) as any[];
|
||||
) as JobExecutionRow[];
|
||||
|
||||
return {
|
||||
data: logs.map(this.mapToJobExecutionLog),
|
||||
@@ -119,7 +132,7 @@ export class JobExecutionService {
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 1`,
|
||||
[jobId]
|
||||
) as any;
|
||||
) as JobExecutionRow | undefined;
|
||||
|
||||
return log ? this.mapToJobExecutionLog(log) : null;
|
||||
}
|
||||
@@ -140,17 +153,17 @@ export class JobExecutionService {
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
private mapToJobExecutionLog(row: any): JobExecutionLog {
|
||||
private mapToJobExecutionLog(row: JobExecutionRow): JobExecutionLog {
|
||||
return {
|
||||
id: row.id,
|
||||
jobId: row.job_id,
|
||||
jobName: row.job_name,
|
||||
status: row.status,
|
||||
status: row.status as 'running' | 'success' | 'failed',
|
||||
startedAt: row.started_at,
|
||||
completedAt: row.completed_at,
|
||||
durationSeconds: row.duration_seconds,
|
||||
message: row.message,
|
||||
errorDetails: row.error_details,
|
||||
completedAt: row.completed_at || undefined,
|
||||
durationSeconds: row.duration_seconds || undefined,
|
||||
message: row.message || undefined,
|
||||
errorDetails: row.error_details || undefined,
|
||||
triggeredBy: row.triggered_by,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -124,7 +124,9 @@ class JobSchedulerService {
|
||||
|
||||
// Run asynchronously (don't await)
|
||||
// runJobWithLogging will handle execution logging
|
||||
this.runJobWithLogging(jobId, triggeredBy);
|
||||
this.runJobWithLogging(jobId, triggeredBy).catch((error) => {
|
||||
logger.error(`[JobScheduler] Unexpected error in triggered job ${jobId}:`, error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -479,7 +479,7 @@ export class MetadataSyncService {
|
||||
)
|
||||
.join(', ');
|
||||
|
||||
const values: any[] = [];
|
||||
const values: (string | number | null)[] = [];
|
||||
sortedGames.forEach((game) => {
|
||||
values.push(
|
||||
game.id,
|
||||
@@ -553,7 +553,13 @@ export class MetadataSyncService {
|
||||
ruffleSupport = excluded.ruffleSupport
|
||||
`;
|
||||
|
||||
await DatabaseService.run(sql, values);
|
||||
// Wrap in a transaction for atomicity - a partial upsert would leave the
|
||||
// database in an inconsistent state (some games updated, others not).
|
||||
const db = DatabaseService.getDatabase();
|
||||
db.transaction(() => {
|
||||
const stmt = db.prepare(sql);
|
||||
stmt.run(values);
|
||||
})();
|
||||
logger.debug(`[MetadataSync] Upserted ${sortedGames.length} games`);
|
||||
}
|
||||
|
||||
|
||||
@@ -41,17 +41,39 @@ export class PerformanceMetrics {
|
||||
// Configuration
|
||||
private static readonly MAX_DATA_POINTS = 10000; // Keep last 10k measurements
|
||||
private static readonly RETENTION_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
private static readonly MAX_UNIQUE_ENDPOINTS = 1000; // Prevent unbounded memory growth
|
||||
|
||||
/**
|
||||
* Normalize path by replacing dynamic segments with :id
|
||||
* Prevents memory growth from parametric routes like /api/games/:id
|
||||
*/
|
||||
private static normalizePath(path: string): string {
|
||||
// Replace UUID segments: /api/games/550e8400-e29b-41d4-a716-446655440000 -> /api/games/:id
|
||||
return (
|
||||
path
|
||||
.replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '/:id')
|
||||
// Replace numeric IDs: /api/users/123 -> /api/users/:id
|
||||
.replace(/\/\d+(?=\/|$)/g, '/:id')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record API endpoint response time
|
||||
*/
|
||||
static recordEndpoint(path: string, method: string, durationMs: number): void {
|
||||
const key = `${method} ${path}`;
|
||||
const normalizedPath = this.normalizePath(path);
|
||||
const key = `${method} ${normalizedPath}`;
|
||||
|
||||
if (!this.endpointMetrics.has(key)) {
|
||||
this.endpointMetrics.set(key, []);
|
||||
}
|
||||
|
||||
// Evict oldest entry if we exceed max unique endpoints
|
||||
if (this.endpointMetrics.size > this.MAX_UNIQUE_ENDPOINTS) {
|
||||
const firstKey = this.endpointMetrics.keys().next().value;
|
||||
if (firstKey) this.endpointMetrics.delete(firstKey);
|
||||
}
|
||||
|
||||
const metrics = this.endpointMetrics.get(key)!;
|
||||
metrics.push({
|
||||
timestamp: Date.now(),
|
||||
|
||||
@@ -135,16 +135,12 @@ export class PermissionCache {
|
||||
|
||||
/**
|
||||
* Invalidate role permissions (when role permissions change)
|
||||
* Also invalidates all users with this role
|
||||
* Also invalidates all user permissions to force re-fetch
|
||||
*/
|
||||
static invalidateRole(roleId: number): void {
|
||||
this.rolePermissionsCache.delete(roleId);
|
||||
logger.info(`[PermissionCache] Invalidated cache for role ${roleId}`);
|
||||
|
||||
// Note: We don't invalidate all users with this role because we'd need
|
||||
// to query the database to find them, defeating the purpose of caching.
|
||||
// Instead, we rely on the TTL to eventually update all users.
|
||||
// For immediate updates, consider invalidating specific users or clearing all.
|
||||
this.userPermissionsCache.clear();
|
||||
logger.info(`[PermissionCache] Invalidated cache for role ${roleId} and all user permissions`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { UserDatabaseService } from './UserDatabaseService';
|
||||
import { DatabaseService } from './DatabaseService';
|
||||
import { logger } from '../utils/logger';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { AppError } from '../middleware/errorHandler';
|
||||
import {
|
||||
PlaySessionRow,
|
||||
GameStatsRow,
|
||||
@@ -44,8 +45,8 @@ export class PlayTrackingService {
|
||||
*/
|
||||
async startPlaySession(userId: number, gameId: string, gameTitle: string): Promise<string> {
|
||||
try {
|
||||
// Generate unique session ID
|
||||
const sessionId = randomBytes(16).toString('hex');
|
||||
// Generate unique session ID (UUID format)
|
||||
const sessionId = randomUUID();
|
||||
|
||||
// Insert play session
|
||||
UserDatabaseService.run(
|
||||
@@ -68,7 +69,7 @@ export class PlayTrackingService {
|
||||
* End a play session
|
||||
* OPTIMIZED: Single query to get session data and calculate duration, then single UPDATE
|
||||
*/
|
||||
async endPlaySession(sessionId: string): Promise<void> {
|
||||
async endPlaySession(sessionId: string, userId?: number): Promise<void> {
|
||||
try {
|
||||
// OPTIMIZATION: Get session and calculate duration in single query
|
||||
const session = UserDatabaseService.get(
|
||||
@@ -87,6 +88,11 @@ export class PlayTrackingService {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify session ownership if userId provided
|
||||
if (userId && session.user_id !== userId) {
|
||||
throw new AppError(403, "Cannot end another user's play session");
|
||||
}
|
||||
|
||||
const durationSeconds = session.duration_seconds || 0;
|
||||
|
||||
// Use the duration value from the SELECT query to avoid race condition
|
||||
@@ -149,19 +155,16 @@ export class PlayTrackingService {
|
||||
*/
|
||||
private async updateUserStats(userId: number, durationSeconds: number): Promise<void> {
|
||||
try {
|
||||
// Count unique games played
|
||||
const gamesPlayed =
|
||||
UserDatabaseService.get(
|
||||
'SELECT COUNT(DISTINCT game_id) as count FROM user_game_stats WHERE user_id = ?',
|
||||
[userId]
|
||||
)?.count || 0;
|
||||
// Single query to get both aggregates (was 2 separate queries)
|
||||
const counts = UserDatabaseService.get(
|
||||
`SELECT
|
||||
(SELECT COUNT(DISTINCT game_id) FROM user_game_stats WHERE user_id = ?) as games_played,
|
||||
(SELECT COUNT(*) FROM user_game_plays WHERE user_id = ? AND ended_at IS NOT NULL) as total_sessions`,
|
||||
[userId, userId]
|
||||
) as { games_played: number; total_sessions: number } | undefined;
|
||||
|
||||
// Count total sessions
|
||||
const totalSessions =
|
||||
UserDatabaseService.get(
|
||||
'SELECT COUNT(*) as count FROM user_game_plays WHERE user_id = ? AND ended_at IS NOT NULL',
|
||||
[userId]
|
||||
)?.count || 0;
|
||||
const gamesPlayed = counts?.games_played ?? 0;
|
||||
const totalSessions = counts?.total_sessions ?? 0;
|
||||
|
||||
// UPSERT user stats
|
||||
UserDatabaseService.run(
|
||||
@@ -395,9 +398,11 @@ export class PlayTrackingService {
|
||||
);
|
||||
|
||||
for (const session of abandoned) {
|
||||
// Calculate estimated duration (capped at 4 hours)
|
||||
// Calculate actual duration from session start time, capped at 4 hours
|
||||
const startTime = new Date(session.started_at).getTime();
|
||||
const actualSeconds = Math.floor((Date.now() - startTime) / 1000);
|
||||
const maxDuration = 4 * 60 * 60; // 4 hours in seconds
|
||||
const duration = Math.min(maxDuration, 86400); // 24 hours max
|
||||
const duration = Math.min(actualSeconds, maxDuration);
|
||||
|
||||
// End the session
|
||||
UserDatabaseService.run(
|
||||
|
||||
@@ -5,6 +5,8 @@ import { config } from '../config';
|
||||
import { logger } from '../utils/logger';
|
||||
import { GameService, Game } from './GameService';
|
||||
|
||||
type PlaylistGameEntry = string | { gameId?: string; [key: string]: unknown };
|
||||
|
||||
export interface Playlist {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -35,36 +37,42 @@ export class PlaylistService {
|
||||
const playlistsPath = config.flashpointPlaylistsPath;
|
||||
const files = await fs.readdir(playlistsPath);
|
||||
|
||||
const jsonFiles = files.filter((f) => f.endsWith('.json'));
|
||||
|
||||
// Parallel file reads (was sequential for-of loop)
|
||||
const results = await Promise.allSettled(
|
||||
jsonFiles.map(async (file) => {
|
||||
const filePath = path.join(playlistsPath, file);
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const playlist = JSON.parse(content);
|
||||
|
||||
const gameIds = Array.isArray(playlist.games)
|
||||
? playlist.games
|
||||
.map((g: unknown) =>
|
||||
typeof g === 'string' ? g : (g as { gameId?: string }).gameId
|
||||
)
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: playlist.id || path.basename(file, '.json'),
|
||||
title: playlist.title,
|
||||
description: playlist.description,
|
||||
author: playlist.author,
|
||||
library: playlist.library,
|
||||
icon: playlist.icon,
|
||||
gameIds,
|
||||
} as Playlist;
|
||||
})
|
||||
);
|
||||
|
||||
const playlists: Playlist[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.json')) {
|
||||
try {
|
||||
const filePath = path.join(playlistsPath, file);
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const playlist = JSON.parse(content);
|
||||
|
||||
// Extract game IDs from playlist (they're objects with gameId property)
|
||||
const gameIds = Array.isArray(playlist.games)
|
||||
? playlist.games
|
||||
.map((g: unknown) =>
|
||||
typeof g === 'string' ? g : (g as { gameId?: string }).gameId
|
||||
)
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
playlists.push({
|
||||
id: playlist.id || path.basename(file, '.json'),
|
||||
title: playlist.title,
|
||||
description: playlist.description,
|
||||
author: playlist.author,
|
||||
library: playlist.library,
|
||||
icon: playlist.icon,
|
||||
gameIds,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to parse playlist file: ${file}`, error);
|
||||
}
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const result = results[i];
|
||||
if (result.status === 'fulfilled') {
|
||||
playlists.push(result.value);
|
||||
} else {
|
||||
logger.warn(`Failed to parse playlist file: ${jsonFiles[i]}`, result.reason);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,48 +86,19 @@ export class PlaylistService {
|
||||
async getPlaylistById(id: string): Promise<Playlist | null> {
|
||||
try {
|
||||
const playlistsPath = config.flashpointPlaylistsPath;
|
||||
|
||||
// Try direct file access first (O(1) instead of scanning all files)
|
||||
const directPath = path.join(playlistsPath, `${id}.json`);
|
||||
const playlist = await this.tryReadPlaylist(directPath, id);
|
||||
if (playlist) return playlist;
|
||||
|
||||
// Fallback: scan all files (some playlists may have filename != id)
|
||||
const files = await fs.readdir(playlistsPath);
|
||||
|
||||
// Try to find playlist file by ID
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.json')) {
|
||||
const filePath = path.join(playlistsPath, file);
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const playlist = JSON.parse(content);
|
||||
|
||||
const playlistId = playlist.id || path.basename(file, '.json');
|
||||
|
||||
if (playlistId === id) {
|
||||
// Extract game IDs from playlist
|
||||
const gameIds: string[] = [];
|
||||
|
||||
if (playlist.games && Array.isArray(playlist.games)) {
|
||||
for (const playlistGame of playlist.games) {
|
||||
// Playlist games are objects with gameId property, not just strings
|
||||
const gameId =
|
||||
typeof playlistGame === 'string' ? playlistGame : playlistGame.gameId;
|
||||
|
||||
if (gameId) {
|
||||
gameIds.push(gameId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Batch fetch all games at once (avoid N+1 query pattern)
|
||||
// getGamesByIds returns games in the same order as input IDs
|
||||
const games = await this.gameService.getGamesByIds(gameIds);
|
||||
|
||||
return {
|
||||
id: playlistId,
|
||||
title: playlist.title,
|
||||
description: playlist.description,
|
||||
author: playlist.author,
|
||||
library: playlist.library,
|
||||
icon: playlist.icon,
|
||||
games,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!file.endsWith('.json') || file === `${id}.json`) continue; // Already tried direct
|
||||
const filePath = path.join(playlistsPath, file);
|
||||
const result = await this.tryReadPlaylist(filePath, id);
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -129,6 +108,44 @@ export class PlaylistService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to read a playlist file and return it if its ID matches
|
||||
*/
|
||||
private async tryReadPlaylist(filePath: string, targetId: string): Promise<Playlist | null> {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const playlist = JSON.parse(content);
|
||||
|
||||
const playlistId = playlist.id || path.basename(filePath, '.json');
|
||||
if (playlistId !== targetId) return null;
|
||||
|
||||
// Extract game IDs from playlist
|
||||
const gameIds: string[] = [];
|
||||
if (playlist.games && Array.isArray(playlist.games)) {
|
||||
for (const playlistGame of playlist.games) {
|
||||
const gameId = typeof playlistGame === 'string' ? playlistGame : playlistGame.gameId;
|
||||
if (gameId) gameIds.push(gameId);
|
||||
}
|
||||
}
|
||||
|
||||
// Batch fetch all games at once (avoid N+1 query pattern)
|
||||
const games = await this.gameService.getGamesByIds(gameIds);
|
||||
|
||||
return {
|
||||
id: playlistId,
|
||||
title: playlist.title,
|
||||
description: playlist.description,
|
||||
author: playlist.author,
|
||||
library: playlist.library,
|
||||
icon: playlist.icon,
|
||||
games,
|
||||
};
|
||||
} catch {
|
||||
// File doesn't exist or invalid JSON - return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async createPlaylist(data: CreatePlaylistDto): Promise<Playlist> {
|
||||
try {
|
||||
const playlistsPath = config.flashpointPlaylistsPath;
|
||||
@@ -163,44 +180,25 @@ export class PlaylistService {
|
||||
data: AddGamesToPlaylistDto
|
||||
): Promise<Playlist | null> {
|
||||
try {
|
||||
const playlistsPath = config.flashpointPlaylistsPath;
|
||||
const files = await fs.readdir(playlistsPath);
|
||||
const found = await this.findPlaylistFile(playlistId);
|
||||
if (!found) return null;
|
||||
|
||||
// Find playlist file
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.json')) {
|
||||
const filePath = path.join(playlistsPath, file);
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const playlist = JSON.parse(content);
|
||||
const { filePath, data: playlist } = found;
|
||||
|
||||
const id = playlist.id || path.basename(file, '.json');
|
||||
// Get existing game IDs
|
||||
const existingGameIds = Array.isArray(playlist.games)
|
||||
? playlist.games.map((g: PlaylistGameEntry) => (typeof g === 'string' ? g : g.gameId))
|
||||
: [];
|
||||
|
||||
if (id === playlistId) {
|
||||
// Get existing game IDs
|
||||
const existingGameIds = Array.isArray(playlist.games)
|
||||
? playlist.games.map((g: any) => (typeof g === 'string' ? g : g.gameId))
|
||||
: [];
|
||||
// Add new game IDs (avoid duplicates)
|
||||
const newGameIds = data.gameIds.filter((gameId) => !existingGameIds.includes(gameId));
|
||||
const newGames = newGameIds.map((gameId) => ({ gameId }));
|
||||
playlist.games = [...(playlist.games || []), ...newGames];
|
||||
|
||||
// Add new game IDs (avoid duplicates)
|
||||
const newGameIds = data.gameIds.filter((gameId) => !existingGameIds.includes(gameId));
|
||||
await fs.writeFile(filePath, JSON.stringify(playlist, null, '\t'), 'utf-8');
|
||||
logger.info(`Added ${newGameIds.length} games to playlist: ${playlist.title}`);
|
||||
|
||||
// Convert to playlist game format
|
||||
const newGames = newGameIds.map((gameId) => ({ gameId }));
|
||||
|
||||
playlist.games = [...(playlist.games || []), ...newGames];
|
||||
|
||||
// Write updated playlist
|
||||
await fs.writeFile(filePath, JSON.stringify(playlist, null, '\t'), 'utf-8');
|
||||
|
||||
logger.info(`Added ${newGameIds.length} games to playlist: ${playlist.title}`);
|
||||
|
||||
// Return updated playlist with full game data
|
||||
return await this.getPlaylistById(playlistId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return await this.getPlaylistById(playlistId);
|
||||
} catch (error) {
|
||||
logger.error('Error adding games to playlist:', error);
|
||||
throw error;
|
||||
@@ -212,36 +210,20 @@ export class PlaylistService {
|
||||
data: AddGamesToPlaylistDto
|
||||
): Promise<Playlist | null> {
|
||||
try {
|
||||
const playlistsPath = config.flashpointPlaylistsPath;
|
||||
const files = await fs.readdir(playlistsPath);
|
||||
const found = await this.findPlaylistFile(playlistId);
|
||||
if (!found) return null;
|
||||
|
||||
// Find playlist file
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.json')) {
|
||||
const filePath = path.join(playlistsPath, file);
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const playlist = JSON.parse(content);
|
||||
const { filePath, data: playlist } = found;
|
||||
|
||||
const id = playlist.id || path.basename(file, '.json');
|
||||
playlist.games = (playlist.games || []).filter((g: PlaylistGameEntry) => {
|
||||
const gameId = typeof g === 'string' ? g : g.gameId;
|
||||
return gameId && !data.gameIds.includes(gameId);
|
||||
});
|
||||
|
||||
if (id === playlistId) {
|
||||
// Remove games
|
||||
playlist.games = (playlist.games || []).filter((g: any) => {
|
||||
const gameId = typeof g === 'string' ? g : g.gameId;
|
||||
return !data.gameIds.includes(gameId);
|
||||
});
|
||||
await fs.writeFile(filePath, JSON.stringify(playlist, null, '\t'), 'utf-8');
|
||||
logger.info(`Removed ${data.gameIds.length} games from playlist: ${playlist.title}`);
|
||||
|
||||
// Write updated playlist
|
||||
await fs.writeFile(filePath, JSON.stringify(playlist, null, '\t'), 'utf-8');
|
||||
|
||||
logger.info(`Removed ${data.gameIds.length} games from playlist: ${playlist.title}`);
|
||||
|
||||
return await this.getPlaylistById(playlistId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return await this.getPlaylistById(playlistId);
|
||||
} catch (error) {
|
||||
logger.error('Error removing games from playlist:', error);
|
||||
throw error;
|
||||
@@ -250,32 +232,55 @@ export class PlaylistService {
|
||||
|
||||
async deletePlaylist(playlistId: string): Promise<boolean> {
|
||||
try {
|
||||
const playlistsPath = config.flashpointPlaylistsPath;
|
||||
const files = await fs.readdir(playlistsPath);
|
||||
const found = await this.findPlaylistFile(playlistId);
|
||||
if (!found) return false;
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.json')) {
|
||||
const filePath = path.join(playlistsPath, file);
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const playlist = JSON.parse(content);
|
||||
|
||||
const id = playlist.id || path.basename(file, '.json');
|
||||
|
||||
if (id === playlistId) {
|
||||
await fs.unlink(filePath);
|
||||
logger.info(`Deleted playlist: ${playlist.title} (${id})`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
await fs.unlink(found.filePath);
|
||||
logger.info(`Deleted playlist: ${found.data.title} (${playlistId})`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Error deleting playlist:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a playlist file by ID. Tries direct {id}.json first, then scans all files.
|
||||
* Returns file path and parsed data, or null if not found.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private async findPlaylistFile(id: string): Promise<{ filePath: string; data: any } | null> {
|
||||
const playlistsPath = config.flashpointPlaylistsPath;
|
||||
|
||||
// Try direct path first (O(1))
|
||||
const directPath = path.join(playlistsPath, `${id}.json`);
|
||||
try {
|
||||
const content = await fs.readFile(directPath, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
const playlistId = data.id || path.basename(directPath, '.json');
|
||||
if (playlistId === id) return { filePath: directPath, data };
|
||||
} catch {
|
||||
// Not found at direct path
|
||||
}
|
||||
|
||||
// Fallback: scan all files
|
||||
const files = await fs.readdir(playlistsPath);
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.json') || file === `${id}.json`) continue;
|
||||
const filePath = path.join(playlistsPath, file);
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
const playlistId = data.id || path.basename(file, '.json');
|
||||
if (playlistId === id) return { filePath, data };
|
||||
} catch {
|
||||
// Skip unreadable files
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private generateUUID(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ export class RoleService {
|
||||
}
|
||||
|
||||
const updates: string[] = [];
|
||||
const params: any[] = [];
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (name !== undefined) {
|
||||
const existing = UserDatabaseService.get('SELECT id FROM roles WHERE name = ? AND id != ?', [
|
||||
@@ -224,16 +224,20 @@ export class RoleService {
|
||||
throw new AppError(403, 'Cannot modify system role permissions');
|
||||
}
|
||||
|
||||
// Delete existing permissions
|
||||
UserDatabaseService.run('DELETE FROM role_permissions WHERE role_id = ?', [roleId]);
|
||||
// Wrap delete + insert in transaction to ensure atomicity
|
||||
const db = UserDatabaseService.getDatabase();
|
||||
const updatePermissions = db.transaction(() => {
|
||||
db.prepare('DELETE FROM role_permissions WHERE role_id = ?').run(roleId);
|
||||
|
||||
// Insert new permissions
|
||||
for (const permissionId of permissionIds) {
|
||||
UserDatabaseService.run(
|
||||
'INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)',
|
||||
[roleId, permissionId]
|
||||
const insert = db.prepare(
|
||||
'INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)'
|
||||
);
|
||||
}
|
||||
for (const permissionId of permissionIds) {
|
||||
insert.run(roleId, permissionId);
|
||||
}
|
||||
});
|
||||
|
||||
updatePermissions();
|
||||
|
||||
// Invalidate permission cache for this role
|
||||
PermissionCache.invalidateRole(roleId);
|
||||
|
||||
@@ -5,6 +5,11 @@ import AdmZip from 'adm-zip';
|
||||
import { AppError } from '../middleware/errorHandler';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
interface GitHubAsset {
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for managing Ruffle emulator versions
|
||||
*/
|
||||
@@ -84,7 +89,7 @@ export class RuffleService {
|
||||
}
|
||||
|
||||
// Find web-selfhosted asset
|
||||
const asset = release.assets.find((a: any) => a.name.includes('web-selfhosted.zip'));
|
||||
const asset = release.assets.find((a: GitHubAsset) => a.name.includes('web-selfhosted.zip'));
|
||||
|
||||
if (!asset) {
|
||||
throw new Error('web-selfhosted.zip not found in release');
|
||||
|
||||
@@ -19,7 +19,7 @@ export class SystemSettingsService {
|
||||
return null;
|
||||
}
|
||||
|
||||
const valueToUse = row.value || row.default_value;
|
||||
const valueToUse = row.value ?? row.default_value;
|
||||
return this.parseValue(valueToUse, row.data_type);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export class SystemSettingsService {
|
||||
// Remove category prefix from key (e.g., 'auth.guest_access_enabled' -> 'guestAccessEnabled')
|
||||
const shortKey = row.key.replace(`${category}.`, '');
|
||||
const camelCaseKey = this.snakeToCamel(shortKey);
|
||||
const valueToUse = row.value || row.default_value;
|
||||
const valueToUse = row.value ?? row.default_value;
|
||||
const parsedValue = this.parseValue(valueToUse, row.data_type);
|
||||
if (parsedValue !== null) {
|
||||
settings[camelCaseKey] = parsedValue;
|
||||
@@ -66,7 +66,7 @@ export class SystemSettingsService {
|
||||
|
||||
const shortKey = row.key.replace(`${row.category}.`, '');
|
||||
const camelCaseKey = this.snakeToCamel(shortKey);
|
||||
const valueToUse = row.value || row.default_value;
|
||||
const valueToUse = row.value ?? row.default_value;
|
||||
const parsedValue = this.parseValue(valueToUse, row.data_type);
|
||||
if (parsedValue !== null) {
|
||||
allSettings[row.category][camelCaseKey] = parsedValue;
|
||||
@@ -97,12 +97,14 @@ export class SystemSettingsService {
|
||||
* Update multiple settings in a category
|
||||
*/
|
||||
updateCategory(category: string, settings: CategorySettings, updatedBy?: number): void {
|
||||
// Use individual updates (transactions would require accessing private db property)
|
||||
Object.entries(settings).forEach(([key, value]) => {
|
||||
const snakeKey = this.camelToSnake(key);
|
||||
const fullKey = `${category}.${snakeKey}`;
|
||||
this.set(fullKey, value, updatedBy);
|
||||
});
|
||||
const db = UserDatabaseService.getDatabase();
|
||||
db.transaction(() => {
|
||||
Object.entries(settings).forEach(([key, value]) => {
|
||||
const snakeKey = this.camelToSnake(key);
|
||||
const fullKey = `${category}.${snakeKey}`;
|
||||
this.set(fullKey, value, updatedBy);
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,7 +125,7 @@ export class SystemSettingsService {
|
||||
|
||||
const shortKey = row.key.replace(`${row.category}.`, '');
|
||||
const camelCaseKey = this.snakeToCamel(shortKey);
|
||||
const valueToUse = row.value || row.default_value;
|
||||
const valueToUse = row.value ?? row.default_value;
|
||||
const parsedValue = this.parseValue(valueToUse, row.data_type);
|
||||
if (parsedValue !== null) {
|
||||
publicSettings[row.category][camelCaseKey] = parsedValue;
|
||||
|
||||
@@ -169,6 +169,15 @@ export class UpdateService {
|
||||
|
||||
let errorOutput = '';
|
||||
|
||||
// Store timeout handle so we can clear it when process completes
|
||||
const timeoutHandle = setTimeout(
|
||||
() => {
|
||||
fpm.kill();
|
||||
reject(new Error('Update process timed out'));
|
||||
},
|
||||
5 * 60 * 1000
|
||||
);
|
||||
|
||||
fpm.stdout?.on('data', (data) => {
|
||||
logger.info('[UpdateService] FPM Output:', data.toString());
|
||||
});
|
||||
@@ -179,6 +188,7 @@ export class UpdateService {
|
||||
});
|
||||
|
||||
fpm.on('close', (code) => {
|
||||
clearTimeout(timeoutHandle);
|
||||
if (code === 0) {
|
||||
logger.info('[UpdateService] Updates installed successfully');
|
||||
resolve({
|
||||
@@ -195,18 +205,10 @@ export class UpdateService {
|
||||
});
|
||||
|
||||
fpm.on('error', (error) => {
|
||||
clearTimeout(timeoutHandle);
|
||||
logger.error('[UpdateService] Failed to start FPM:', error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
// Timeout after 5 minutes
|
||||
setTimeout(
|
||||
() => {
|
||||
fpm.kill();
|
||||
reject(new Error('Update process timed out'));
|
||||
},
|
||||
5 * 60 * 1000
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[UpdateService] Error during update installation:', error);
|
||||
|
||||
@@ -162,15 +162,19 @@ export class UserDatabaseService {
|
||||
const checksum = this.computeChecksum(sql);
|
||||
const start = Date.now();
|
||||
|
||||
// Execute migration
|
||||
this.db!.exec(sql);
|
||||
// Wrap migration execution and registry insert in a single transaction
|
||||
const runMigration = this.db!.transaction(() => {
|
||||
this.db!.exec(sql);
|
||||
|
||||
const executionTime = Date.now() - start;
|
||||
this.db!.prepare(
|
||||
'INSERT INTO migrations (name, applied_at, checksum, execution_time_ms) VALUES (?, ?, ?, ?)'
|
||||
).run(name, new Date().toISOString(), checksum, executionTime);
|
||||
});
|
||||
|
||||
runMigration();
|
||||
|
||||
// Record migration in registry
|
||||
const executionTime = Date.now() - start;
|
||||
this.db!.prepare(
|
||||
'INSERT INTO migrations (name, applied_at, checksum, execution_time_ms) VALUES (?, ?, ?, ?)'
|
||||
).run(name, new Date().toISOString(), checksum, executionTime);
|
||||
|
||||
logger.info(`[UserDB] Migration completed: ${name} (${executionTime}ms)`);
|
||||
} else {
|
||||
logger.debug(`[UserDB] Migration already applied: ${name}`);
|
||||
|
||||
@@ -199,7 +199,7 @@ export class UserPlaylistService {
|
||||
}
|
||||
|
||||
const updates: string[] = [];
|
||||
const values: any[] = [];
|
||||
const values: (string | number | null)[] = [];
|
||||
|
||||
if (data.title !== undefined) {
|
||||
updates.push('title = ?');
|
||||
@@ -285,7 +285,7 @@ export class UserPlaylistService {
|
||||
WHERE playlist_id = ?
|
||||
`);
|
||||
|
||||
let orderIndex = (maxOrderStmt.get(playlistId) as any).maxOrder + 1;
|
||||
let orderIndex = (maxOrderStmt.get(playlistId) as { maxOrder: number }).maxOrder + 1;
|
||||
|
||||
// Insert games (ignore duplicates)
|
||||
const insertStmt = db.prepare(`
|
||||
@@ -699,41 +699,59 @@ export class UserPlaylistService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create new playlist
|
||||
const title = newTitle || `${sourcePlaylist.title} (Copy)`;
|
||||
const newPlaylist = this.createPlaylist(userId, {
|
||||
title,
|
||||
description: sourcePlaylist.description || undefined,
|
||||
icon: sourcePlaylist.icon || undefined,
|
||||
});
|
||||
// Wrap create + game inserts in transaction to ensure atomicity
|
||||
const newPlaylistId = db.transaction(() => {
|
||||
// Create new playlist
|
||||
const title = newTitle || `${sourcePlaylist.title} (Copy)`;
|
||||
const now = new Date().toISOString();
|
||||
const result = db
|
||||
.prepare(
|
||||
`INSERT INTO user_playlists (user_id, title, description, icon, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
userId,
|
||||
title,
|
||||
sourcePlaylist.description || null,
|
||||
sourcePlaylist.icon || null,
|
||||
now,
|
||||
now
|
||||
);
|
||||
|
||||
// Copy games with same order
|
||||
const gamesStmt = db.prepare(`
|
||||
SELECT game_id, order_index, notes
|
||||
FROM user_playlist_games
|
||||
WHERE playlist_id = ?
|
||||
ORDER BY order_index
|
||||
`);
|
||||
const playlistId = result.lastInsertRowid as number;
|
||||
|
||||
const games = gamesStmt.all(sourcePlaylist.id) as {
|
||||
game_id: string;
|
||||
order_index: number;
|
||||
notes: string | null;
|
||||
}[];
|
||||
|
||||
if (games.length > 0) {
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO user_playlist_games (playlist_id, game_id, order_index, notes)
|
||||
VALUES (?, ?, ?, ?)
|
||||
// Copy games with same order
|
||||
const gamesStmt = db.prepare(`
|
||||
SELECT game_id, order_index, notes
|
||||
FROM user_playlist_games
|
||||
WHERE playlist_id = ?
|
||||
ORDER BY order_index
|
||||
`);
|
||||
|
||||
for (const game of games) {
|
||||
insertStmt.run(newPlaylist.id, game.game_id, game.order_index, game.notes);
|
||||
const games = gamesStmt.all(sourcePlaylist.id) as {
|
||||
game_id: string;
|
||||
order_index: number;
|
||||
notes: string | null;
|
||||
}[];
|
||||
|
||||
if (games.length > 0) {
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO user_playlist_games (playlist_id, game_id, order_index, notes)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const game of games) {
|
||||
insertStmt.run(playlistId, game.game_id, game.order_index, game.notes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Cloned shared playlist "${sourcePlaylist.title}" to user ${userId} as "${title}"`);
|
||||
return playlistId;
|
||||
})();
|
||||
|
||||
return this.getPlaylistById(newPlaylist.id, userId);
|
||||
logger.info(
|
||||
`Cloned shared playlist "${sourcePlaylist.title}" to user ${userId} as "${newTitle || `${sourcePlaylist.title} (Copy)`}"`
|
||||
);
|
||||
|
||||
return this.getPlaylistById(newPlaylistId, userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,20 +13,27 @@ export class UserService {
|
||||
async getUsers(page: number = 1, limit: number = 50): Promise<PaginatedResponse<User>> {
|
||||
const offset = calculateOffset(page, limit);
|
||||
|
||||
// Single query with window function for total count (was 2 separate queries)
|
||||
const users = UserDatabaseService.all(
|
||||
`SELECT u.id, u.username, u.email, u.role_id as roleId, r.name as roleName,
|
||||
u.is_active as isActive, u.created_at as createdAt,
|
||||
u.updated_at as updatedAt, u.last_login_at as lastLoginAt
|
||||
u.updated_at as updatedAt, u.last_login_at as lastLoginAt,
|
||||
COUNT(*) OVER() as total_count
|
||||
FROM users u
|
||||
JOIN roles r ON u.role_id = r.id
|
||||
ORDER BY u.created_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[limit, offset]
|
||||
);
|
||||
) as (User & { total_count: number })[];
|
||||
|
||||
const total = UserDatabaseService.get('SELECT COUNT(*) as count FROM users', [])?.count || 0;
|
||||
const total = users.length > 0 ? users[0].total_count : 0;
|
||||
|
||||
return createPaginatedResponse(users, total, page, limit);
|
||||
// Remove total_count from result objects
|
||||
users.forEach((user) => {
|
||||
delete (user as unknown as Record<string, unknown>).total_count;
|
||||
});
|
||||
|
||||
return createPaginatedResponse(users as User[], total, page, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,34 +57,35 @@ export class UserService {
|
||||
* Create new user
|
||||
*/
|
||||
async createUser(data: CreateUserData): Promise<User> {
|
||||
// Check if username exists
|
||||
const existingUser = UserDatabaseService.get('SELECT id FROM users WHERE username = ?', [
|
||||
data.username,
|
||||
]);
|
||||
if (existingUser) {
|
||||
throw new AppError(409, 'Username already exists');
|
||||
}
|
||||
|
||||
// Check if email exists
|
||||
const existingEmail = UserDatabaseService.get('SELECT id FROM users WHERE email = ?', [
|
||||
data.email,
|
||||
]);
|
||||
if (existingEmail) {
|
||||
throw new AppError(409, 'Email already exists');
|
||||
}
|
||||
|
||||
// Hash password
|
||||
// Hash password before transaction
|
||||
const passwordHash = await hashPassword(data.password);
|
||||
|
||||
// Create user
|
||||
const result = UserDatabaseService.run(
|
||||
'INSERT INTO users (username, email, password_hash, role_id) VALUES (?, ?, ?, ?)',
|
||||
[data.username, data.email, passwordHash, data.roleId]
|
||||
);
|
||||
// Wrap uniqueness checks and insert in transaction to prevent race condition
|
||||
const db = UserDatabaseService.getDatabase();
|
||||
const userId = db.transaction(() => {
|
||||
// Check username uniqueness inside transaction
|
||||
const existingUser = db.prepare('SELECT id FROM users WHERE username = ?').get(data.username);
|
||||
if (existingUser) {
|
||||
throw new AppError(409, 'Username already exists');
|
||||
}
|
||||
|
||||
const userId = result.lastInsertRowid as number;
|
||||
// Check email uniqueness inside transaction
|
||||
const existingEmail = db.prepare('SELECT id FROM users WHERE email = ?').get(data.email);
|
||||
if (existingEmail) {
|
||||
throw new AppError(409, 'Email already exists');
|
||||
}
|
||||
|
||||
// Return created user
|
||||
// Create user
|
||||
const result = db
|
||||
.prepare(
|
||||
'INSERT INTO users (username, email, password_hash, role_id, is_active) VALUES (?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run(data.username, data.email, passwordHash, data.roleId, data.isActive !== false ? 1 : 0);
|
||||
|
||||
return result.lastInsertRowid as number;
|
||||
})();
|
||||
|
||||
// Return created user (outside transaction is fine)
|
||||
return (await this.getUserById(userId))!;
|
||||
}
|
||||
|
||||
@@ -92,7 +100,7 @@ export class UserService {
|
||||
|
||||
// Build update query dynamically
|
||||
const updates: string[] = [];
|
||||
const params: any[] = [];
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (data.email !== undefined) {
|
||||
// Check if email is taken by another user
|
||||
@@ -164,17 +172,24 @@ export class UserService {
|
||||
/**
|
||||
* Change user password
|
||||
*/
|
||||
async changePassword(id: number, currentPassword: string, newPassword: string): Promise<void> {
|
||||
async changePassword(
|
||||
id: number,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
isAdminReset: boolean = false
|
||||
): Promise<void> {
|
||||
const user = UserDatabaseService.get('SELECT password_hash FROM users WHERE id = ?', [id]);
|
||||
|
||||
if (!user) {
|
||||
throw new AppError(404, 'User not found');
|
||||
}
|
||||
|
||||
// Verify current password using centralized utility
|
||||
const isValid = await verifyPassword(currentPassword, user.password_hash);
|
||||
if (!isValid) {
|
||||
throw new AppError(401, 'Current password is incorrect');
|
||||
// Verify current password using centralized utility (skip if admin reset)
|
||||
if (!isAdminReset) {
|
||||
const isValid = await verifyPassword(currentPassword, user.password_hash);
|
||||
if (!isValid) {
|
||||
throw new AppError(401, 'Current password is incorrect');
|
||||
}
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
@@ -239,7 +254,7 @@ export class UserService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update theme settings in user_settings table
|
||||
* Update theme settings in user_settings table (atomic transaction)
|
||||
*/
|
||||
async updateThemeSettings(userId: number, mode: string, primaryColor: string): Promise<void> {
|
||||
const user = await this.getUserById(userId);
|
||||
@@ -247,8 +262,11 @@ export class UserService {
|
||||
throw new AppError(404, 'User not found');
|
||||
}
|
||||
|
||||
await this.setUserSetting(userId, 'theme_mode', mode);
|
||||
await this.setUserSetting(userId, 'primary_color', primaryColor);
|
||||
// Execute both updates in parallel for better performance
|
||||
await Promise.all([
|
||||
this.setUserSetting(userId, 'theme_mode', mode),
|
||||
this.setUserSetting(userId, 'primary_color', primaryColor),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { CookieOptions, Response } from 'express';
|
||||
import { config } from '../config';
|
||||
|
||||
const COOKIE_NAME = 'fp_refresh';
|
||||
const isProduction = config.nodeEnv === 'production';
|
||||
|
||||
function getRefreshCookieOptions(): CookieOptions {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
path: '/api/auth',
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days in ms
|
||||
};
|
||||
}
|
||||
|
||||
export function setRefreshTokenCookie(res: Response, refreshToken: string): void {
|
||||
res.cookie(COOKIE_NAME, refreshToken, getRefreshCookieOptions());
|
||||
}
|
||||
|
||||
export function clearRefreshTokenCookie(res: Response): void {
|
||||
res.clearCookie(COOKIE_NAME, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
path: '/api/auth',
|
||||
});
|
||||
}
|
||||
|
||||
export function getRefreshTokenFromCookie(cookies: Record<string, string>): string | undefined {
|
||||
return cookies?.[COOKIE_NAME];
|
||||
}
|
||||
+2
-36
@@ -27,6 +27,7 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${API_PORT:-3100}:3100"
|
||||
# Backend now includes game content serving on /game-proxy and /game-zip
|
||||
volumes:
|
||||
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint:ro
|
||||
- backend-db:/app/data
|
||||
@@ -36,7 +37,6 @@ services:
|
||||
- PGID=${PGID:-1000}
|
||||
- NODE_ENV=${NODE_ENV:-production}
|
||||
- TZ=${TZ:-UTC}
|
||||
- GAME_SERVICE_HOST=game-service
|
||||
- DOMAIN=${DOMAIN:-http://localhost:${WEB_PORT:-80}}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
|
||||
@@ -48,11 +48,8 @@ services:
|
||||
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-}
|
||||
- OTEL_API_KEY=${OTEL_API_KEY:-}
|
||||
- OTEL_SERVICE_NAME=${OTEL_SERVICE_NAME:-flashpoint-web-backend}
|
||||
depends_on:
|
||||
game-service:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "http://localhost:3100/api/health"]
|
||||
test: ["CMD", "curl", "--fail", "http://localhost:3100/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -60,34 +57,6 @@ services:
|
||||
networks:
|
||||
- flashpoint-network
|
||||
|
||||
game-service:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./game-service/Dockerfile
|
||||
image: flashpoint-game-service:dev
|
||||
container_name: flashpoint-game-service
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PROXY_PORT:-22500}:22500"
|
||||
- "${GAMEZIP_PORT:-22501}:22501"
|
||||
volumes:
|
||||
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint:ro
|
||||
- ${LOGS_PATH:-./logs}/game-service:/app/logs
|
||||
environment:
|
||||
- PUID=${PUID:-1000}
|
||||
- PGID=${PGID:-1000}
|
||||
- NODE_ENV=${NODE_ENV:-production}
|
||||
- TZ=${TZ:-UTC}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "http://localhost:22500/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
networks:
|
||||
- flashpoint-network
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
@@ -103,12 +72,9 @@ services:
|
||||
- TZ=${TZ:-UTC}
|
||||
- BACKEND_HOST=${BACKEND_HOST:-backend}
|
||||
- BACKEND_PORT=${BACKEND_PORT:-3100}
|
||||
- GAME_SERVICE_HOST=${GAME_SERVICE_HOST:-game-service}
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
game-service:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "http://localhost:8080/"]
|
||||
interval: 30s
|
||||
|
||||
+2
-31
@@ -22,6 +22,7 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${API_PORT:-3100}:3100"
|
||||
# Backend now includes game content serving on /game-proxy and /game-zip
|
||||
volumes:
|
||||
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint:ro
|
||||
- ${DATA_PATH:-./data}:/app/data
|
||||
@@ -31,7 +32,6 @@ services:
|
||||
- PGID=${PGID:-1000}
|
||||
- NODE_ENV=production
|
||||
- TZ=${TZ:-UTC}
|
||||
- GAME_SERVICE_HOST=game-service
|
||||
- DOMAIN=${DOMAIN:-http://localhost:${WEB_PORT:-80}}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
|
||||
@@ -43,39 +43,13 @@ services:
|
||||
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-}
|
||||
- OTEL_API_KEY=${OTEL_API_KEY:-}
|
||||
- OTEL_SERVICE_NAME=${OTEL_SERVICE_NAME:-flashpoint-web-backend}
|
||||
depends_on:
|
||||
game-service:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "http://localhost:3100/api/health"]
|
||||
test: ["CMD", "curl", "--fail", "http://localhost:3100/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
game-service:
|
||||
image: darkraise/flashpoint-game-service:${IMAGE_TAG:-latest}
|
||||
container_name: flashpoint-game-service
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PROXY_PORT:-22500}:22500"
|
||||
- "${GAMEZIP_PORT:-22501}:22501"
|
||||
volumes:
|
||||
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint:ro
|
||||
- ${LOGS_PATH:-./logs}:/app/logs
|
||||
environment:
|
||||
- PUID=${PUID:-1000}
|
||||
- PGID=${PGID:-1000}
|
||||
- NODE_ENV=production
|
||||
- TZ=${TZ:-UTC}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "http://localhost:22500/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
frontend:
|
||||
image: darkraise/flashpoint-frontend:${IMAGE_TAG:-latest}
|
||||
container_name: flashpoint-frontend
|
||||
@@ -88,12 +62,9 @@ services:
|
||||
- TZ=${TZ:-UTC}
|
||||
- BACKEND_HOST=${BACKEND_HOST:-backend}
|
||||
- BACKEND_PORT=${BACKEND_PORT:-3100}
|
||||
- GAME_SERVICE_HOST=${GAME_SERVICE_HOST:-game-service}
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
game-service:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "http://localhost:8080/"]
|
||||
interval: 30s
|
||||
|
||||
@@ -65,27 +65,25 @@ offering:
|
||||
|
||||
## Architecture Highlights
|
||||
|
||||
Flashpoint Web consists of three independent services:
|
||||
Flashpoint Web consists of two independent services:
|
||||
|
||||
1. **Backend (Port 3100)** - REST API server handling game metadata, user
|
||||
management, and authentication
|
||||
management, authentication, and game file serving
|
||||
2. **Frontend (Port 5173)** - React web application providing the user interface
|
||||
3. **Game Service (Ports 22500/22501)** - File serving proxy for game content
|
||||
and ZIP archives
|
||||
|
||||
This separation allows:
|
||||
|
||||
- Independent scaling of services
|
||||
- Backend non-blocking on large file transfers
|
||||
- Game service caching and CDN compatibility
|
||||
- Clear separation of concerns
|
||||
- Backend handles both metadata and game content
|
||||
- Frontend focuses on user interface and client-side state
|
||||
- Simplified deployment with fewer containers
|
||||
|
||||
## Technology Stack Summary
|
||||
|
||||
- **Backend**: Node.js, Express, TypeScript, SQLite (BetterSqlite3), JWT, bcrypt
|
||||
- **Backend**: Node.js, Express, TypeScript, SQLite (BetterSqlite3), JWT, bcrypt,
|
||||
node-stream-zip
|
||||
- **Frontend**: React 18, TypeScript, Vite, TanStack Query, Zustand, Tailwind
|
||||
CSS, Shadcn UI
|
||||
- **Game Service**: Node.js, TypeScript, node-stream-zip, Axios
|
||||
- **Game Emulation**: Ruffle (Flash), Native Browser (HTML5)
|
||||
|
||||
## Use Cases
|
||||
|
||||
@@ -31,17 +31,6 @@ experience, and ecosystem maturity.
|
||||
| Shadcn UI | Latest | Component library |
|
||||
| Ruffle | 0.2.0 | Flash emulator |
|
||||
|
||||
### Game Service Technologies
|
||||
|
||||
| Technology | Version | Purpose |
|
||||
| --------------- | ------- | --------------------- |
|
||||
| Node.js | 20+ | JavaScript runtime |
|
||||
| Express | 4.18 | HTTP server |
|
||||
| TypeScript | 5.3 | Type safety |
|
||||
| node-stream-zip | 1.15 | ZIP archive streaming |
|
||||
| Axios | 1.6 | HTTP client |
|
||||
| Winston | 3.11 | Logging |
|
||||
|
||||
---
|
||||
|
||||
## Backend Stack
|
||||
@@ -63,6 +52,8 @@ JavaScript runtime built on Chrome's V8 engine.
|
||||
- Database operations
|
||||
- File watching (hot-reload)
|
||||
- Background jobs
|
||||
- Game file serving and proxying
|
||||
- ZIP archive mounting and streaming
|
||||
|
||||
### Express (v4.18)
|
||||
|
||||
@@ -122,6 +113,26 @@ const stmt = fpDb.prepare(
|
||||
const games = stmt.all(`%${search}%`, platform);
|
||||
```
|
||||
|
||||
### node-stream-zip (v1.15)
|
||||
|
||||
Zero-dependency ZIP file streaming library integrated into backend.
|
||||
|
||||
**Why chosen:**
|
||||
|
||||
- Streaming (no extraction required)
|
||||
- Memory efficient random access
|
||||
- Fast direct reads
|
||||
- Simple API
|
||||
|
||||
**Use:**
|
||||
|
||||
```typescript
|
||||
const zip = new StreamZip.async({ file: '/path/to/game.zip' });
|
||||
const entry = await zip.entry(filePath);
|
||||
const stream = await zip.stream(entry.name);
|
||||
stream.pipe(res);
|
||||
```
|
||||
|
||||
### JWT & bcrypt
|
||||
|
||||
**JWT**: Stateless authentication, works across instances, mobile-friendly.
|
||||
@@ -251,28 +262,6 @@ Flash Player emulator written in Rust, compiled to WebAssembly.
|
||||
|
||||
---
|
||||
|
||||
## Game Service Stack
|
||||
|
||||
### node-stream-zip (v1.15)
|
||||
|
||||
Zero-dependency ZIP file streaming library.
|
||||
|
||||
**Why chosen:**
|
||||
|
||||
- Streaming (no extraction required)
|
||||
- Memory efficient random access
|
||||
- Fast direct reads
|
||||
- Simple API
|
||||
|
||||
**Usage:**
|
||||
|
||||
```typescript
|
||||
const zip = new StreamZip.async({ file: '/path/to/game.zip' });
|
||||
const entry = await zip.entry(filePath);
|
||||
const stream = await zip.stream(entry.name);
|
||||
stream.pipe(res);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development Tools
|
||||
@@ -393,6 +382,9 @@ For each technology, evaluated:
|
||||
- Average response: <50ms
|
||||
- Game search: <100ms
|
||||
- Database queries: <10ms (indexed)
|
||||
- File streaming: Wire speed
|
||||
- ZIP file access: <50ms
|
||||
- CDN fallback: 200-500ms (network dependent)
|
||||
|
||||
**Frontend:**
|
||||
|
||||
@@ -400,12 +392,6 @@ For each technology, evaluated:
|
||||
- Time to interactive: <2s
|
||||
- Game grid render: <100ms (virtualized)
|
||||
|
||||
**Game Service:**
|
||||
|
||||
- File streaming: Wire speed
|
||||
- ZIP file access: <50ms
|
||||
- CDN fallback: 200-500ms (network dependent)
|
||||
|
||||
---
|
||||
|
||||
## Browser Support
|
||||
|
||||
@@ -12,10 +12,11 @@ flows.
|
||||
|
||||
Topics covered:
|
||||
|
||||
- Three-service architecture overview (Frontend, Backend, Game Service)
|
||||
- Two-service architecture overview (Frontend, Backend with integrated game serving)
|
||||
- Technology stack and rationale
|
||||
- Service responsibilities and boundaries
|
||||
- Database architecture (flashpoint.sqlite and user.db)
|
||||
- Game content serving architecture (integrated in backend)
|
||||
- Security architecture (JWT, RBAC)
|
||||
- File system layout
|
||||
- Scalability considerations
|
||||
@@ -28,13 +29,13 @@ system.
|
||||
|
||||
### 2. [Service Communication](./service-communication.md)
|
||||
|
||||
**Inter-service communication patterns and protocols**
|
||||
**Communication patterns and protocols between Frontend and Backend**
|
||||
|
||||
Topics covered:
|
||||
|
||||
- Frontend ↔ Backend communication (REST API)
|
||||
- Backend ↔ Game Service communication (internal APIs)
|
||||
- Frontend ↔ Game Service communication (direct file loading)
|
||||
- Integrated game file serving in backend
|
||||
- Backend game routes (`/game-proxy/*` and `/game-zip/*`)
|
||||
- API endpoints by domain
|
||||
- Authentication token flow
|
||||
- Request/response formats
|
||||
@@ -233,12 +234,12 @@ Express middleware chains handle cross-cutting concerns:
|
||||
- Single deployment
|
||||
- Consistent tooling
|
||||
|
||||
### Why Three Services?
|
||||
### Why Two Services?
|
||||
|
||||
- Clear separation of concerns
|
||||
- Independent scaling
|
||||
- Backend doesn't block on file I/O
|
||||
- Game service can be replicated
|
||||
- Frontend focuses on UI and client-side state
|
||||
- Backend handles all business logic and content serving
|
||||
- Simplified deployment (2 containers instead of 3)
|
||||
- Easier to reason about
|
||||
|
||||
## Common Workflows
|
||||
|
||||
@@ -11,9 +11,9 @@ Flashpoint Web uses JWT-based authentication with role-based access control
|
||||
graph TB
|
||||
subgraph "Frontend"
|
||||
LoginForm[Login Form]
|
||||
AuthStore[Zustand Auth Store]
|
||||
AuthStore[Zustand Auth Store<br/>Access Token Only]
|
||||
APIClient[Axios API Client]
|
||||
LocalStorage[localStorage]
|
||||
Cookies[HTTP-only Cookies<br/>fp_refresh]
|
||||
end
|
||||
|
||||
subgraph "Backend"
|
||||
@@ -30,15 +30,16 @@ graph TB
|
||||
end
|
||||
|
||||
LoginForm --> AuthStore
|
||||
AuthRoute -.Set Cookie.-> Cookies
|
||||
AuthRoute -.accessToken.-> APIClient
|
||||
AuthStore --> APIClient
|
||||
APIClient --> AuthRoute
|
||||
AuthRoute --> AuthService
|
||||
AuthService --> UserDB
|
||||
UserDB -.return user.-> AuthService
|
||||
AuthService -.JWT tokens.-> AuthRoute
|
||||
AuthRoute -.tokens + user.-> APIClient
|
||||
AuthRoute -.accessToken only.-> APIClient
|
||||
APIClient --> AuthStore
|
||||
AuthStore --> LocalStorage
|
||||
|
||||
APIClient -.JWT Bearer.-> GameRoutes
|
||||
GameRoutes --> AuthMiddleware
|
||||
@@ -48,6 +49,7 @@ graph TB
|
||||
style AuthStore fill:#61dafb
|
||||
style AuthService fill:#90ee90
|
||||
style UserDB fill:#ff9999
|
||||
style Cookies fill:#ffd700
|
||||
```
|
||||
|
||||
## 1. User Registration Flow
|
||||
@@ -87,9 +89,9 @@ sequenceDiagram
|
||||
|
||||
AuthService->>AuthService: Generate JWT tokens
|
||||
AuthService-->>Backend: {user, accessToken, refreshToken}
|
||||
Backend-->>API: 201 Created
|
||||
API->>AuthStore: setAuth(user, tokens)
|
||||
AuthStore->>LocalStorage: Persist tokens
|
||||
Backend->>Backend: Set fp_refresh HTTP-only cookie
|
||||
Backend-->>API: 201 Created (only accessToken in body)
|
||||
API->>AuthStore: setAuth(user, accessToken)
|
||||
AuthStore-->>RegisterForm: Success
|
||||
RegisterForm->>User: Redirect to dashboard
|
||||
end
|
||||
@@ -141,12 +143,12 @@ sequenceDiagram
|
||||
else Valid credentials
|
||||
AuthService->>UserDB: SELECT user permissions
|
||||
AuthService->>AuthService: Generate JWT access token (15m expiry)
|
||||
AuthService->>AuthService: Generate JWT refresh token (7d expiry)
|
||||
AuthService->>AuthService: Generate JWT refresh token (30d expiry)
|
||||
AuthService-->>Backend: {user, accessToken, refreshToken}
|
||||
Backend-->>API: 200 OK with tokens
|
||||
Backend->>Backend: Set fp_refresh HTTP-only cookie
|
||||
Backend-->>API: 200 OK (only accessToken in body)
|
||||
|
||||
API->>AuthStore: setAuth(user, tokens)
|
||||
AuthStore->>LocalStorage: Persist auth state
|
||||
API->>AuthStore: setAuth(user, accessToken)
|
||||
AuthStore->>ThemeStore: loadThemeFromServer()
|
||||
AuthStore-->>LoginForm: Success
|
||||
LoginForm->>User: Redirect to /browse
|
||||
@@ -167,12 +169,12 @@ sequenceDiagram
|
||||
exp: 1705580100 // 15 minutes
|
||||
}
|
||||
|
||||
// Refresh Token
|
||||
// Refresh Token (stored in HTTP-only cookie)
|
||||
{
|
||||
userId: 1,
|
||||
type: "refresh",
|
||||
iat: 1705579200,
|
||||
exp: 1706184000 // 7 days
|
||||
exp: 1708257600 // 30 days
|
||||
}
|
||||
```
|
||||
|
||||
@@ -183,6 +185,7 @@ sequenceDiagram
|
||||
participant Component
|
||||
participant API Client
|
||||
participant Interceptor
|
||||
participant AuthStore
|
||||
participant Backend
|
||||
participant AuthMiddleware
|
||||
participant RBACMiddleware
|
||||
@@ -191,10 +194,13 @@ sequenceDiagram
|
||||
Component->>API Client: gamesApi.search(filters)
|
||||
|
||||
API Client->>Interceptor: Request interceptor
|
||||
Interceptor->>AuthStore: Get accessToken from store
|
||||
Interceptor->>Interceptor: Add Authorization header
|
||||
Note over Interceptor: Authorization: Bearer {token}
|
||||
Note over Interceptor: Authorization: Bearer {accessToken}
|
||||
|
||||
Interceptor->>Backend: GET /api/games?filters
|
||||
Note over Interceptor,Backend: Cookies sent automatically
|
||||
|
||||
Backend->>AuthMiddleware: authenticate middleware
|
||||
|
||||
AuthMiddleware->>AuthMiddleware: Extract & verify JWT
|
||||
@@ -267,6 +273,7 @@ sequenceDiagram
|
||||
participant API Client
|
||||
participant Interceptor
|
||||
participant AuthStore
|
||||
participant Cookies
|
||||
participant Backend
|
||||
participant AuthService
|
||||
|
||||
@@ -275,22 +282,27 @@ sequenceDiagram
|
||||
Backend-->>API Client: 401 Unauthorized
|
||||
|
||||
API Client->>Interceptor: Response interceptor (401 check)
|
||||
Interceptor->>AuthStore: getState().refreshToken
|
||||
|
||||
alt No refresh token
|
||||
Interceptor->>Backend: POST /api/auth/refresh (empty body)
|
||||
Note over Interceptor,Backend: Cookies (fp_refresh) sent automatically
|
||||
|
||||
alt No refresh token in cookie
|
||||
Backend-->>Interceptor: 401 Unauthorized
|
||||
Interceptor->>AuthStore: clearAuth()
|
||||
Interceptor->>Component: Redirect to login
|
||||
else Has refresh token
|
||||
Interceptor->>Backend: POST /api/auth/refresh {refreshToken}
|
||||
Backend->>AuthService: refreshAccessToken(token)
|
||||
Backend->>AuthService: refreshAccessToken(token from cookie)
|
||||
|
||||
alt Invalid refresh token
|
||||
AuthService-->>Backend: 401 Unauthorized
|
||||
Backend-->>Interceptor: 401 Unauthorized
|
||||
Interceptor->>AuthStore: clearAuth()
|
||||
Interceptor->>Component: Redirect to login
|
||||
else Valid
|
||||
AuthService->>AuthService: Generate new tokens
|
||||
Backend-->>Interceptor: {accessToken, refreshToken}
|
||||
AuthService->>AuthService: Generate new accessToken
|
||||
AuthService->>AuthService: Generate new refreshToken
|
||||
Backend->>Cookies: Set new fp_refresh cookie
|
||||
Backend-->>Interceptor: {accessToken, expiresIn}
|
||||
Interceptor->>AuthStore: updateAccessToken(newToken)
|
||||
Interceptor->>Interceptor: Retry original request
|
||||
Backend-->>API Client: 200 OK
|
||||
@@ -311,16 +323,10 @@ api.interceptors.response.use(
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
const refreshToken = useAuthStore.getState().refreshToken;
|
||||
if (!refreshToken) {
|
||||
useAuthStore.getState().clearAuth();
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
const tokens = await authApi.refreshToken(refreshToken);
|
||||
useAuthStore.getState().updateAccessToken(tokens.accessToken);
|
||||
originalRequest.headers.Authorization = `Bearer ${tokens.accessToken}`;
|
||||
// Refresh token is sent in HTTP-only cookie automatically
|
||||
const { data } = await authApi.refreshToken();
|
||||
useAuthStore.getState().updateAccessToken(data.accessToken);
|
||||
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
|
||||
return api(originalRequest);
|
||||
} catch (refreshError) {
|
||||
useAuthStore.getState().clearAuth();
|
||||
@@ -334,6 +340,25 @@ api.interceptors.response.use(
|
||||
);
|
||||
```
|
||||
|
||||
**Page Reload Recovery**:
|
||||
|
||||
When the page reloads, the access token in memory is lost, but the refresh token
|
||||
remains in the HTTP-only cookie. The frontend auth initialization sequence detects
|
||||
this and automatically calls `/auth/refresh` to recover the session:
|
||||
|
||||
```typescript
|
||||
// During app initialization
|
||||
useEffect(() => {
|
||||
const { isAuthenticated, accessToken } = useAuthStore.getState();
|
||||
if (isAuthenticated && !accessToken) {
|
||||
// Session exists (has refresh cookie) but no access token in memory
|
||||
authApi.refreshToken().then(({ data }) => {
|
||||
useAuthStore.getState().updateAccessToken(data.accessToken);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
```
|
||||
|
||||
## 5. Logout Flow
|
||||
|
||||
```mermaid
|
||||
@@ -342,17 +367,19 @@ sequenceDiagram
|
||||
participant Header
|
||||
participant AuthStore
|
||||
participant API
|
||||
participant Cookies
|
||||
participant Backend
|
||||
|
||||
User->>Header: Click "Logout"
|
||||
Header->>AuthStore: Get refreshToken
|
||||
Header->>API: authApi.logout(refreshToken)
|
||||
API->>Backend: POST /api/auth/logout
|
||||
Header->>API: authApi.logout()
|
||||
API->>Backend: POST /api/auth/logout (empty body)
|
||||
Note over API,Backend: Cookies (fp_refresh) sent automatically
|
||||
Backend->>Backend: Read refresh token from cookie
|
||||
Backend->>Backend: Invalidate refresh token
|
||||
Backend->>Cookies: Clear fp_refresh cookie
|
||||
Backend-->>API: 200 OK
|
||||
|
||||
API->>AuthStore: clearAuth()
|
||||
AuthStore->>LocalStorage: Clear tokens
|
||||
AuthStore-->>Header: Success
|
||||
Header->>User: Redirect to /login
|
||||
```
|
||||
@@ -488,10 +515,12 @@ export const optionalAuth = async (req, res, next) => {
|
||||
|
||||
**JWT Security**:
|
||||
|
||||
- Short-lived access tokens (15 minutes)
|
||||
- Long-lived refresh tokens (7 days)
|
||||
- Short-lived access tokens (15 minutes) - held in memory only
|
||||
- Long-lived refresh tokens (30 days) - stored in HTTP-only cookies
|
||||
- Refresh tokens are rotated on each use to prevent token reuse attacks
|
||||
- Signed with random 256-bit JWT_SECRET
|
||||
- Verify algorithm: HS256
|
||||
- HTTP-only cookie prevents XSS attacks from accessing the refresh token
|
||||
|
||||
**Rate Limiting**: 5 login attempts per 15 minutes
|
||||
|
||||
@@ -499,6 +528,13 @@ export const optionalAuth = async (req, res, next) => {
|
||||
|
||||
**HTTPS**: Secure cookies in production only
|
||||
|
||||
**HTTP-only Cookies**: `fp_refresh` cookie is configured with:
|
||||
- `httpOnly: true` - Not accessible to JavaScript (prevents XSS)
|
||||
- `secure: true` - Only sent over HTTPS in production
|
||||
- `sameSite: 'lax'` - CSRF protection
|
||||
- `path: '/api/auth'` - Only sent to auth endpoints
|
||||
- `maxAge: 2592000000` - 30 days (in milliseconds)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Cause | Solution |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user