feat: revamp the game serving feature

This commit is contained in:
Darkraise
2026-02-07 23:36:45 +07:00
parent 492c049499
commit 96cf4a5b00
244 changed files with 12994 additions and 10557 deletions
+1 -3
View File
@@ -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
-5
View File
@@ -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
-1
View File
@@ -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/
+359 -722
View File
File diff suppressed because it is too large Load Diff
+11 -21
View File
@@ -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` |
---
+120
View File
@@ -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
+23 -48
View File
@@ -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.
-3
View File
@@ -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
View File
@@ -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.
+3 -1
View File
@@ -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",
-67
View File
@@ -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
View File
@@ -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,
+614
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/`/g, '&#96;');
}
/**
* 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 &middot; 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'];
}
@@ -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');
}
}
+9
View File
@@ -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) {
+61
View File
@@ -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`);
}
}
+21 -15
View File
@@ -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();
}
+18 -1
View File
@@ -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')
);
}
-124
View File
@@ -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;
}
}
-314
View File
@@ -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();
}
}
-100
View File
@@ -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();
}
-262
View File
@@ -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;
}
}
}
-278
View File
@@ -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'];
}
-180
View File
@@ -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();
}
}
-520
View File
@@ -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;
}
-210
View File
@@ -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();
+36 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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({
+28 -4
View File
@@ -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);
+7 -7
View File
@@ -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
View File
@@ -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;
+70 -60
View File
@@ -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 -8
View File
@@ -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,
-55
View File
@@ -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;
+46
View File
@@ -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;
+108
View File
@@ -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
View File
@@ -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,
});
})
);
+7 -13
View File
@@ -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;
+3 -2
View File
@@ -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`,
-4
View File
@@ -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
View File
@@ -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;
+11 -38
View File
@@ -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);
})
+23 -18
View File
@@ -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
View File
@@ -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(
+15
View File
@@ -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);
+18 -2
View File
@@ -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,
+177 -242
View File
@@ -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;
+2
View File
@@ -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();
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+2
View File
@@ -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("', '")}'`;
+72 -47
View File
@@ -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);
+1 -1
View File
@@ -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;
+52 -35
View File
@@ -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)');
}
}
+2 -15
View File
@@ -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;
}
+20 -9
View File
@@ -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);
});
}
+6 -3
View File
@@ -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,
+132 -65
View File
@@ -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,
});
+95 -52
View File
@@ -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 });
-239
View File
@@ -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;
}
}
}
+7 -7
View File
@@ -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 || '',
+132 -32
View File
@@ -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);
+2 -4
View File
@@ -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;
+22 -9
View File
@@ -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,
};
}
+3 -1
View File
@@ -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);
});
}
/**
+8 -2
View File
@@ -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`);
}
+23 -1
View File
@@ -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(),
+3 -7
View File
@@ -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`);
}
/**
+23 -18
View File
@@ -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(
+152 -147
View File
@@ -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();
}
+13 -9
View File
@@ -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);
+6 -1
View File
@@ -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');
+12 -10
View File
@@ -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;
+11 -9
View File
@@ -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);
+11 -7
View File
@@ -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}`);
+49 -31
View File
@@ -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);
}
}
+55 -37
View File
@@ -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),
]);
}
/**
+32
View File
@@ -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
View File
@@ -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
View File
@@ -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
+7 -9
View File
@@ -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
+25 -39
View File
@@ -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
+9 -8
View File
@@ -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
+72 -36
View File
@@ -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