feat: revamp the game serving feature
This commit is contained in:
+1
-3
@@ -22,8 +22,6 @@ JWT_SECRET=replace-with-a-secure-random-string
|
|||||||
# These map host ports to container ports (container ports are hardcoded)
|
# These map host ports to container ports (container ports are hardcoded)
|
||||||
WEB_PORT=80
|
WEB_PORT=80
|
||||||
API_PORT=3100
|
API_PORT=3100
|
||||||
PROXY_PORT=22500
|
|
||||||
GAMEZIP_PORT=22501
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Data Storage
|
# Data Storage
|
||||||
@@ -34,7 +32,7 @@ DATA_PATH=./data
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Logging
|
# 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
|
LOGS_PATH=./logs
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
|||||||
@@ -23,9 +23,6 @@ jobs:
|
|||||||
- name: frontend
|
- name: frontend
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./frontend/Dockerfile
|
dockerfile: ./frontend/Dockerfile
|
||||||
- name: game-service
|
|
||||||
context: .
|
|
||||||
dockerfile: ./game-service/Dockerfile
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -93,7 +90,6 @@ jobs:
|
|||||||
echo "### Images Built:" >> $GITHUB_STEP_SUMMARY
|
echo "### Images Built:" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- \`flashpoint-backend\`" >> $GITHUB_STEP_SUMMARY
|
echo "- \`flashpoint-backend\`" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- \`flashpoint-frontend\`" >> $GITHUB_STEP_SUMMARY
|
echo "- \`flashpoint-frontend\`" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- \`flashpoint-game-service\`" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "### Registry:" >> $GITHUB_STEP_SUMMARY
|
echo "### Registry:" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- Docker Hub: \`${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-*\`" >> $GITHUB_STEP_SUMMARY
|
echo "- Docker Hub: \`${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-*\`" >> $GITHUB_STEP_SUMMARY
|
||||||
@@ -102,5 +98,4 @@ jobs:
|
|||||||
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
|
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "docker pull ${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-backend:latest" >> $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-frontend:latest" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "docker pull ${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-game-service:latest" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ Thumbs.db
|
|||||||
!src/**/*.d.ts
|
!src/**/*.d.ts
|
||||||
!frontend/src/**/*.d.ts
|
!frontend/src/**/*.d.ts
|
||||||
!backend/src/**/*.d.ts
|
!backend/src/**/*.d.ts
|
||||||
!game-service/src/**/*.d.ts
|
|
||||||
|
|
||||||
# Documentation (preserve original formatting)
|
# Documentation (preserve original formatting)
|
||||||
docs/
|
docs/
|
||||||
|
|||||||
+11
-21
@@ -70,10 +70,8 @@ npm run install:all
|
|||||||
|
|
||||||
# Copy environment files
|
# Copy environment files
|
||||||
cp backend/.env.example backend/.env
|
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
|
# See docs/08-development/setup-guide.md for details
|
||||||
|
|
||||||
# Start development servers
|
# Start development servers
|
||||||
@@ -89,7 +87,6 @@ npm run typecheck
|
|||||||
# Run tests
|
# Run tests
|
||||||
cd backend && npm test
|
cd backend && npm test
|
||||||
cd ../frontend && npm test
|
cd ../frontend && npm test
|
||||||
cd ../game-service && npm test
|
|
||||||
```
|
```
|
||||||
|
|
||||||
For detailed setup instructions, see
|
For detailed setup instructions, see
|
||||||
@@ -397,9 +394,8 @@ Update documentation when you:
|
|||||||
docs/
|
docs/
|
||||||
├── 01-overview/ # Project introduction
|
├── 01-overview/ # Project introduction
|
||||||
├── 02-architecture/ # System design
|
├── 02-architecture/ # System design
|
||||||
├── 03-backend/ # Backend service docs
|
├── 03-backend/ # Backend service docs (including game content serving)
|
||||||
├── 04-frontend/ # Frontend service docs
|
├── 04-frontend/ # Frontend service docs
|
||||||
├── 05-game-service/ # Game service docs
|
|
||||||
├── 06-api-reference/ # API documentation
|
├── 06-api-reference/ # API documentation
|
||||||
├── 07-design-system/ # UI/UX guidelines
|
├── 07-design-system/ # UI/UX guidelines
|
||||||
├── 08-development/ # Development guides
|
├── 08-development/ # Development guides
|
||||||
@@ -419,11 +415,12 @@ docs/
|
|||||||
|
|
||||||
```
|
```
|
||||||
flashpoint-web/
|
flashpoint-web/
|
||||||
├── backend/ # Express REST API
|
├── backend/ # Express REST API with game content serving
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── routes/ # API route handlers
|
│ │ ├── routes/ # API route handlers
|
||||||
│ │ ├── services/ # Business logic
|
│ │ ├── services/ # Business logic
|
||||||
│ │ ├── middleware/# Express middleware
|
│ │ ├── middleware/# Express middleware
|
||||||
|
│ │ ├── game/ # Game content serving (proxy, ZIP mounting)
|
||||||
│ │ └── utils/ # Utility functions
|
│ │ └── utils/ # Utility functions
|
||||||
│ └── package.json
|
│ └── package.json
|
||||||
│
|
│
|
||||||
@@ -436,12 +433,6 @@ flashpoint-web/
|
|||||||
│ │ └── store/ # State management
|
│ │ └── store/ # State management
|
||||||
│ └── package.json
|
│ └── package.json
|
||||||
│
|
│
|
||||||
├── game-service/ # Game file server
|
|
||||||
│ ├── src/
|
|
||||||
│ │ ├── servers/ # HTTP servers
|
|
||||||
│ │ └── utils/ # Utilities
|
|
||||||
│ └── package.json
|
|
||||||
│
|
|
||||||
├── docs/ # Documentation
|
├── docs/ # Documentation
|
||||||
└── package.json # Root package
|
└── package.json # Root package
|
||||||
```
|
```
|
||||||
@@ -472,15 +463,14 @@ Contributors are recognized in:
|
|||||||
|
|
||||||
## Quick Reference
|
## Quick Reference
|
||||||
|
|
||||||
| Task | Command |
|
| Task | Command |
|
||||||
| -------------------- | ------------------------- |
|
| ------------------- | ------------------------- |
|
||||||
| Install dependencies | `npm run install:all` |
|
| Install dependencies | `npm run install:all` |
|
||||||
| Start dev servers | `npm run dev` |
|
| Start dev servers | `npm run dev` |
|
||||||
| Type check | `npm run typecheck` |
|
| Type check | `npm run typecheck` |
|
||||||
| Format code | `npm run format` |
|
| Format code | `npm run format` |
|
||||||
| Run backend tests | `cd backend && npm test` |
|
| Run backend tests | `cd backend && npm test` |
|
||||||
| Run frontend tests | `cd frontend && npm test` |
|
| Build all | `npm run build` |
|
||||||
| Build all | `npm run build` |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Documentation Updates - Second Pass Code Review
|
||||||
|
|
||||||
|
Date: 2026-02-06
|
||||||
|
|
||||||
|
Updated the following game-service documentation files to reflect medium and low
|
||||||
|
severity fixes from the second pass code review:
|
||||||
|
|
||||||
|
## Files Updated
|
||||||
|
|
||||||
|
### 1. docs/05-game-service/gamezip-server.md
|
||||||
|
|
||||||
|
Added section "Download Reliability Improvements" documenting:
|
||||||
|
|
||||||
|
- `findActiveDownload()` now correlates downloads with hostname from request path
|
||||||
|
instead of returning first arbitrary download (ensures correct download status
|
||||||
|
on concurrent requests)
|
||||||
|
- Stale download cleanup extracted to shared `cleanupStaleDownloads()` method
|
||||||
|
(DRY principle, shared between functions)
|
||||||
|
- `new URL()` calls wrapped in try-catch for proper 400 error handling on
|
||||||
|
malformed URLs (prevents 500 errors)
|
||||||
|
|
||||||
|
### 2. docs/05-game-service/zip-manager.md
|
||||||
|
|
||||||
|
Added section "Performance Improvement - findFile() Refactoring" documenting:
|
||||||
|
|
||||||
|
- `findFile()` method optimized for single-pass lookup
|
||||||
|
- Entries read directly without redundant delegation through `getFile()`
|
||||||
|
- Process: index check → entry existence check → size check → read
|
||||||
|
- Eliminates intermediate function calls and extra lookups
|
||||||
|
- Result: Faster file discovery with fewer function invocations
|
||||||
|
|
||||||
|
### 3. docs/05-game-service/legacy-server.md
|
||||||
|
|
||||||
|
Added section "Dynamic Configuration Loading" documenting:
|
||||||
|
|
||||||
|
- Settings now fetched fresh via `ConfigManager.getSettings()` at the start of
|
||||||
|
each `serveLegacy()` call instead of being captured once at construction
|
||||||
|
- Ensures configuration changes (Brotli, external URLs, override paths) take
|
||||||
|
effect immediately without restart
|
||||||
|
- Benefit: Admin users can update proxy settings via UI without downtime
|
||||||
|
|
||||||
|
### 4. docs/05-game-service/proxy-server.md
|
||||||
|
|
||||||
|
Updated "CORS Handling" section:
|
||||||
|
|
||||||
|
- CORS headers now use shared `setCorsHeaders` utility from `utils/cors.ts`
|
||||||
|
- Utility includes all required methods: GET, POST, DELETE, OPTIONS
|
||||||
|
- Eliminates duplication across both proxy servers
|
||||||
|
- Consistent CORS configuration across the module
|
||||||
|
|
||||||
|
### 5. docs/05-game-service/architecture.md
|
||||||
|
|
||||||
|
Added major section "Code Quality & Security Improvements (Second Pass Review)"
|
||||||
|
documenting all medium and low severity fixes:
|
||||||
|
|
||||||
|
**HTML Injection & Logging:**
|
||||||
|
- `htmlInjector.ts` now uses project logger instead of `console.log`
|
||||||
|
- Dead code branch removed from polyfill injection logic
|
||||||
|
|
||||||
|
**PreferencesService Initialization:**
|
||||||
|
- Logs warning on re-initialization with different path
|
||||||
|
- Prevents silent configuration changes affecting game downloads
|
||||||
|
|
||||||
|
**Schema Validation:**
|
||||||
|
- Hostname schema minimum length corrected to 2 (matches regex requirement)
|
||||||
|
|
||||||
|
**Regex Pattern Fixes:**
|
||||||
|
- `pathSecurity.ts` regex patterns no longer use `/g` flag with `test()`
|
||||||
|
- Prevents stateful matching bugs on consecutive calls
|
||||||
|
|
||||||
|
**Code Cleanup:**
|
||||||
|
- Unused exports removed from:
|
||||||
|
- `mimeTypes.ts`
|
||||||
|
- `validation/schemas.ts`
|
||||||
|
- `utils/cors.ts`
|
||||||
|
- `utils/pathSecurity.ts`
|
||||||
|
- Reduces module surface area and maintenance burden
|
||||||
|
|
||||||
|
**Download Validation:**
|
||||||
|
- `GameDataDownloader.getFilename()` validates timestamps
|
||||||
|
- Rejects NaN timestamps to prevent corrupted filenames
|
||||||
|
|
||||||
|
**Resource Leak Prevention:**
|
||||||
|
- `GameDataDownloader` destroys response stream on size limit
|
||||||
|
- Prevents resource leaks from oversized downloads
|
||||||
|
|
||||||
|
**DRY Principle:**
|
||||||
|
- `GameDataService` uses `GameDataDownloader.getFilename()` instead of
|
||||||
|
duplicating logic
|
||||||
|
- Centralizes filename generation
|
||||||
|
|
||||||
|
### 6. docs/05-game-service/configuration.md
|
||||||
|
|
||||||
|
Updated "CGI Configuration (via proxySettings.json)" section with information
|
||||||
|
about `PreferencesService`:
|
||||||
|
|
||||||
|
- Documents singleton pattern for reading `preferences.json`
|
||||||
|
- Explains re-initialization warning logging
|
||||||
|
- Helps identify configuration issues during development
|
||||||
|
- Examples of warning messages logged
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
These documentation updates ensure that:
|
||||||
|
|
||||||
|
1. **Code clarity**: All improvements are documented for future maintainers
|
||||||
|
2. **Knowledge preservation**: Medium and low severity fixes are recorded
|
||||||
|
3. **Developer onboarding**: New team members understand recent improvements
|
||||||
|
4. **Architecture understanding**: Readers see complete picture of design
|
||||||
|
5. **Best practices**: Documents explain why each improvement matters
|
||||||
|
|
||||||
|
## Markdown Formatting
|
||||||
|
|
||||||
|
All updated files follow consistent markdown formatting with:
|
||||||
|
|
||||||
|
- Clear section headers
|
||||||
|
- Code examples where relevant
|
||||||
|
- Bullet points for key improvements
|
||||||
|
- Context about why changes were made
|
||||||
|
- Benefits and implications explained
|
||||||
@@ -61,13 +61,15 @@ support, play tracking, and an intuitive interface.
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Flashpoint Web consists of three independent services:
|
Flashpoint Web consists of two independent services:
|
||||||
|
|
||||||
| Service | Port | Description |
|
| Service | Port | Description |
|
||||||
| ---------------- | ----------- | ----------------------------------------------------- |
|
| ------------ | ---- | --------------------------------------------------------------- |
|
||||||
| **Backend** | 3100 | REST API for game metadata, users, and authentication |
|
| **Backend** | 3100 | REST API for metadata, users, auth, and game content serving |
|
||||||
| **Frontend** | 5173 | React web application |
|
| **Frontend** | 5173 | React web application |
|
||||||
| **Game Service** | 22500/22501 | File serving proxy and ZIP archive server |
|
|
||||||
|
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
|
## Quick Start
|
||||||
|
|
||||||
@@ -107,25 +109,9 @@ DOMAIN=http://localhost:5173
|
|||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** All database and asset paths are automatically derived from
|
> **Note:** All database, asset, and game content paths are automatically
|
||||||
> `FLASHPOINT_PATH`.
|
> derived from `FLASHPOINT_PATH`. Frontend requires no `.env` file for local
|
||||||
|
> development - API calls are proxied through Vite.
|
||||||
**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.
|
|
||||||
|
|
||||||
### Run Development Servers
|
### Run Development Servers
|
||||||
|
|
||||||
@@ -134,9 +120,8 @@ LOG_LEVEL=info
|
|||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
# Or start individually
|
# Or start individually
|
||||||
npm run dev:backend # http://localhost:3100
|
npm run dev:backend # http://localhost:3100
|
||||||
npm run dev:frontend # http://localhost:5173
|
npm run dev:frontend # http://localhost:5173
|
||||||
npm run dev:game-service # http://localhost:22500
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open http://localhost:5173 in your browser.
|
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
|
docker compose logs -f
|
||||||
```
|
```
|
||||||
|
|
||||||
**Default ports:** | Service | URL | |---------|-----| | Frontend |
|
**Default ports:**
|
||||||
http://localhost (port 80) | | Backend API | http://localhost:3100 | | Game
|
|
||||||
Proxy | http://localhost:22500 | | GameZip Server | http://localhost:22501 |
|
| 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
|
See [Docker Deployment Guide](docs/09-deployment/docker-deployment.md) for
|
||||||
advanced configuration.
|
advanced configuration.
|
||||||
@@ -266,21 +255,6 @@ Comprehensive documentation is available in the [`docs/`](docs/) directory:
|
|||||||
- Shadcn UI + Radix
|
- Shadcn UI + Radix
|
||||||
- Ruffle Flash Emulator
|
- 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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -314,8 +288,9 @@ npm run copy-ruffle
|
|||||||
|
|
||||||
**Games not loading:**
|
**Games not loading:**
|
||||||
|
|
||||||
- Ensure game-service is running (ports 22500/22501)
|
- Ensure backend is running on port 3100
|
||||||
- Check game-service logs: `npm run dev:game-service`
|
- 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
|
See [Common Pitfalls](docs/08-development/common-pitfalls.md) for more
|
||||||
solutions.
|
solutions.
|
||||||
|
|||||||
@@ -10,9 +10,6 @@ NODE_ENV=development
|
|||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
DOMAIN=http://localhost:5173
|
DOMAIN=http://localhost:5173
|
||||||
|
|
||||||
# Backend -> game-service connection (local development)
|
|
||||||
GAME_SERVICE_HOST=localhost
|
|
||||||
|
|
||||||
# Rate limiting
|
# Rate limiting
|
||||||
RATE_LIMIT_WINDOW_MS=60000
|
RATE_LIMIT_WINDOW_MS=60000
|
||||||
RATE_LIMIT_MAX_REQUESTS=100
|
RATE_LIMIT_MAX_REQUESTS=100
|
||||||
|
|||||||
+2
-2
@@ -6,7 +6,7 @@ Node.js/Express API server for the Flashpoint Archive web application.
|
|||||||
|
|
||||||
- RESTful API for game metadata
|
- RESTful API for game metadata
|
||||||
- Direct SQLite database access (read-only)
|
- Direct SQLite database access (read-only)
|
||||||
- Proxy to Flashpoint Game Server
|
- Integrated game content serving (proxy + ZIP)
|
||||||
- Image and logo serving
|
- Image and logo serving
|
||||||
- Playlist management
|
- Playlist management
|
||||||
- Platform and tag endpoints
|
- Platform and tag endpoints
|
||||||
@@ -45,6 +45,7 @@ See `../../api-specification.md` for complete API documentation.
|
|||||||
src/
|
src/
|
||||||
├── routes/ # Express route handlers
|
├── routes/ # Express route handlers
|
||||||
├── services/ # Business logic layer
|
├── services/ # Business logic layer
|
||||||
|
├── game/ # Integrated game service (proxy, ZIP, CGI)
|
||||||
├── middleware/ # Express middleware
|
├── middleware/ # Express middleware
|
||||||
├── utils/ # Helper functions
|
├── utils/ # Helper functions
|
||||||
├── config.ts # Configuration management
|
├── config.ts # Configuration management
|
||||||
@@ -83,7 +84,6 @@ Optional:
|
|||||||
|
|
||||||
- `PORT` - Server port (default: 3100)
|
- `PORT` - Server port (default: 3100)
|
||||||
- `NODE_ENV` - Environment (development/production)
|
- `NODE_ENV` - Environment (development/production)
|
||||||
- `GAME_SERVER_URL` - Game Server URL (default: http://localhost:22500)
|
|
||||||
- `DOMAIN` - Frontend origin (default: http://localhost:5173)
|
- `DOMAIN` - Frontend origin (default: http://localhost:5173)
|
||||||
|
|
||||||
See `.env.example` for all options.
|
See `.env.example` for all options.
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
"better-sqlite3": "^12.6.0",
|
"better-sqlite3": "^12.6.0",
|
||||||
"cheerio": "^1.1.2",
|
"cheerio": "^1.1.2",
|
||||||
"compression": "^1.7.4",
|
"compression": "^1.7.4",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"cron-parser": "^5.5.0",
|
"cron-parser": "^5.5.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
@@ -53,10 +54,11 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/adm-zip": "^0.5.7",
|
"@types/adm-zip": "^0.5.7",
|
||||||
"@types/cheerio": "^0.22.35",
|
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/cheerio": "^0.22.35",
|
||||||
"@types/compression": "^1.7.5",
|
"@types/compression": "^1.7.5",
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper script to kill processes using specific ports
|
|
||||||
* Usage: node scripts/kill-port.js [port]
|
|
||||||
* Example: node scripts/kill-port.js 22500
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { execSync } = require('child_process');
|
|
||||||
|
|
||||||
const port = process.argv[2] || '22500';
|
|
||||||
|
|
||||||
console.log(`🔍 Checking for processes using port ${port}...`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Find process using the port (Windows)
|
|
||||||
const result = execSync(`netstat -ano | findstr :${port}`, { encoding: 'utf-8' });
|
|
||||||
|
|
||||||
if (!result.trim()) {
|
|
||||||
console.log(`✓ Port ${port} is free!`);
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(result);
|
|
||||||
|
|
||||||
// Extract PIDs
|
|
||||||
const lines = result.trim().split('\n');
|
|
||||||
const pids = new Set();
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
const parts = line.trim().split(/\s+/);
|
|
||||||
const pid = parts[parts.length - 1];
|
|
||||||
if (pid && !isNaN(pid)) {
|
|
||||||
pids.add(pid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pids.size === 0) {
|
|
||||||
console.log(`✓ Port ${port} is free!`);
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\n⚠️ Found ${pids.size} process(es) using port ${port}`);
|
|
||||||
console.log(`PIDs: ${Array.from(pids).join(', ')}`);
|
|
||||||
|
|
||||||
// Kill each process
|
|
||||||
for (const pid of pids) {
|
|
||||||
try {
|
|
||||||
console.log(`\n🔨 Killing process ${pid}...`);
|
|
||||||
// Use /F and /PID (single slash) for Windows
|
|
||||||
execSync(`taskkill /F /PID ${pid}`, { encoding: 'utf-8' });
|
|
||||||
console.log(`✓ Process ${pid} terminated`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`✗ Failed to kill process ${pid}:`, error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\n✅ Port ${port} should now be free!`);
|
|
||||||
} catch (error) {
|
|
||||||
if (error.status === 1 && error.stderr === '') {
|
|
||||||
// findstr returns exit code 1 when nothing is found
|
|
||||||
console.log(`✓ Port ${port} is free!`);
|
|
||||||
} else {
|
|
||||||
console.error('❌ Error:', error.message);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+7
-21
@@ -27,6 +27,7 @@ function getJwtSecret(): string {
|
|||||||
|
|
||||||
// In development, generate a random secret and warn
|
// In development, generate a random secret and warn
|
||||||
const devSecret = `INSECURE-DEV-ONLY-${crypto.randomBytes(32).toString('hex')}`;
|
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('\n⚠️ WARNING: Using auto-generated JWT secret for development.');
|
||||||
console.warn(' In production, set JWT_SECRET environment variable.\n');
|
console.warn(' In production, set JWT_SECRET environment variable.\n');
|
||||||
|
|
||||||
@@ -95,27 +96,12 @@ export const config = {
|
|||||||
flashpointPlaylistsPath: `${flashpointPath}/Data/Playlists`,
|
flashpointPlaylistsPath: `${flashpointPath}/Data/Playlists`,
|
||||||
flashpointGamesPath: `${flashpointPath}/Data/Games`,
|
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)
|
// Frontend domain (used for CORS and share URL generation)
|
||||||
domain: process.env.DOMAIN || 'http://localhost:5173',
|
domain: process.env.DOMAIN || 'http://localhost:5173',
|
||||||
|
|
||||||
// Rate limiting
|
// Rate limiting
|
||||||
rateLimitWindowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '60000', 10),
|
rateLimitWindowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '', 10) || 60000,
|
||||||
rateLimitMaxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10),
|
rateLimitMaxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '', 10) || 100,
|
||||||
|
|
||||||
// Logging
|
// Logging
|
||||||
// In Docker (production): defaults to /app/logs/backend.log
|
// In Docker (production): defaults to /app/logs/backend.log
|
||||||
@@ -135,10 +121,10 @@ export const config = {
|
|||||||
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '1h',
|
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '1h',
|
||||||
|
|
||||||
// Security
|
// Security
|
||||||
bcryptSaltRounds: parseInt(process.env.BCRYPT_SALT_ROUNDS || '10', 10),
|
bcryptSaltRounds: parseInt(process.env.BCRYPT_SALT_ROUNDS || '', 10) || 10,
|
||||||
|
|
||||||
// Home Page Configuration
|
// 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)
|
// Local Database Copy (for network storage optimization)
|
||||||
// When enabled, copies flashpoint.sqlite to local storage for faster access
|
// 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
|
localDbPath: '/app/data/flashpoint.sqlite', // Only used when enableLocalDbCopy is true
|
||||||
|
|
||||||
// SQLite Performance Tuning
|
// SQLite Performance Tuning
|
||||||
sqliteCacheSize: parseInt(process.env.SQLITE_CACHE_SIZE || '-64000', 10), // Negative = KB, -64000 = 64MB
|
sqliteCacheSize: parseInt(process.env.SQLITE_CACHE_SIZE || '', 10) || -64000, // Negative = KB, -64000 = 64MB
|
||||||
sqliteMmapSize: parseInt(process.env.SQLITE_MMAP_SIZE || '268435456', 10), // 256MB default
|
sqliteMmapSize: parseInt(process.env.SQLITE_MMAP_SIZE || '', 10) || 268435456, // 256MB default
|
||||||
|
|
||||||
// Cache Pre-warming
|
// Cache Pre-warming
|
||||||
enableCachePrewarm: process.env.ENABLE_CACHE_PREWARM !== 'false', // Enabled by default
|
enableCachePrewarm: process.env.ENABLE_CACHE_PREWARM !== 'false', // Enabled by default
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import { sanitizeAndValidatePath } from '../utils/pathSecurity';
|
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 = [
|
const ALLOWED_ENV_VARS = [
|
||||||
'JWT_SECRET',
|
'PATH',
|
||||||
'DATABASE_URL',
|
'TEMP',
|
||||||
'DB_PASSWORD',
|
'TMP',
|
||||||
'API_KEY',
|
'SYSTEMROOT',
|
||||||
'SECRET_KEY',
|
'COMSPEC',
|
||||||
'PRIVATE_KEY',
|
'WINDIR',
|
||||||
'AWS_SECRET',
|
'HOME',
|
||||||
'AZURE_SECRET',
|
'LANG',
|
||||||
'GCP_SECRET',
|
'LC_ALL',
|
||||||
'ENCRYPTION_KEY',
|
|
||||||
'PASSWORD',
|
|
||||||
'TOKEN',
|
|
||||||
'CREDENTIAL',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* CGI Executor - Executes PHP scripts via php-cgi
|
||||||
*
|
*
|
||||||
@@ -96,8 +111,8 @@ export class CgiExecutor {
|
|||||||
* @throws Error if execution fails or times out
|
* @throws Error if execution fails or times out
|
||||||
*/
|
*/
|
||||||
async execute(scriptPath: string, request: CgiRequest): Promise<CgiResponse> {
|
async execute(scriptPath: string, request: CgiRequest): Promise<CgiResponse> {
|
||||||
// Validate script path is within allowed directories
|
// Validate script path is within allowed directories (resolves symlinks)
|
||||||
this.validateScriptPath(scriptPath);
|
await this.validateScriptPath(scriptPath);
|
||||||
|
|
||||||
// Build CGI environment variables
|
// Build CGI environment variables
|
||||||
const env = this.buildCgiEnvironment(scriptPath, request);
|
const env = this.buildCgiEnvironment(scriptPath, request);
|
||||||
@@ -106,8 +121,8 @@ export class CgiExecutor {
|
|||||||
logger.debug(`[CGI] Method: ${request.method}, Query: ${request.url.search}`);
|
logger.debug(`[CGI] Method: ${request.method}, Query: ${request.url.search}`);
|
||||||
|
|
||||||
return new Promise<CgiResponse>((resolve, reject) => {
|
return new Promise<CgiResponse>((resolve, reject) => {
|
||||||
// Spawn php-cgi process
|
// Spawn php-cgi process with script path as explicit argument (CVE-2012-1823 mitigation)
|
||||||
const proc = spawn(this.config.phpCgiPath, [], {
|
const proc = spawn(this.config.phpCgiPath, [scriptPath], {
|
||||||
cwd: path.dirname(scriptPath),
|
cwd: path.dirname(scriptPath),
|
||||||
env: { ...this.sanitizeProcessEnv(), ...env },
|
env: { ...this.sanitizeProcessEnv(), ...env },
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
@@ -221,18 +236,26 @@ export class CgiExecutor {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate that the script path is within allowed directories
|
* 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
|
// Script must be in either htdocs or cgi-bin
|
||||||
try {
|
try {
|
||||||
sanitizeAndValidatePath(this.config.documentRoot, scriptPath);
|
sanitizeAndValidatePath(this.config.documentRoot, realPath);
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
// Not in htdocs, try cgi-bin
|
// Not in htdocs, try cgi-bin
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
sanitizeAndValidatePath(this.config.cgiBinPath, scriptPath);
|
sanitizeAndValidatePath(this.config.cgiBinPath, realPath);
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
// Not in cgi-bin either
|
// Not in cgi-bin either
|
||||||
@@ -242,6 +265,30 @@ export class CgiExecutor {
|
|||||||
throw new Error('CGI script path is not within allowed directories');
|
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
|
* Build CGI environment variables according to RFC 3875
|
||||||
*/
|
*/
|
||||||
@@ -249,6 +296,10 @@ export class CgiExecutor {
|
|||||||
const url = request.url;
|
const url = request.url;
|
||||||
const headers = request.headers;
|
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
|
// Build base CGI environment
|
||||||
const env: Record<string, string> = {
|
const env: Record<string, string> = {
|
||||||
// Required by PHP-CGI security (prevents direct execution attacks)
|
// Required by PHP-CGI security (prevents direct execution attacks)
|
||||||
@@ -268,7 +319,7 @@ export class CgiExecutor {
|
|||||||
SCRIPT_FILENAME: scriptPath,
|
SCRIPT_FILENAME: scriptPath,
|
||||||
PATH_INFO: '',
|
PATH_INFO: '',
|
||||||
PATH_TRANSLATED: scriptPath,
|
PATH_TRANSLATED: scriptPath,
|
||||||
QUERY_STRING: url.search.startsWith('?') ? url.search.substring(1) : url.search,
|
QUERY_STRING: sanitizedQueryString,
|
||||||
|
|
||||||
// Document root
|
// Document root
|
||||||
DOCUMENT_ROOT: this.config.documentRoot,
|
DOCUMENT_ROOT: this.config.documentRoot,
|
||||||
@@ -286,8 +337,15 @@ export class CgiExecutor {
|
|||||||
|
|
||||||
// Convert HTTP headers to CGI HTTP_* variables
|
// Convert HTTP headers to CGI HTTP_* variables
|
||||||
for (const [key, value] of Object.entries(headers)) {
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
const lowerKey = key.toLowerCase();
|
||||||
|
|
||||||
// Skip content headers (handled separately)
|
// 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;
|
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> {
|
private sanitizeProcessEnv(): Record<string, string> {
|
||||||
const sanitized: Record<string, string> = {};
|
const env: Record<string, string> = {};
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(process.env)) {
|
for (const key of ALLOWED_ENV_VARS) {
|
||||||
if (value === undefined) continue;
|
if (process.env[key]) {
|
||||||
|
env[key] = process.env[key]!;
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return sanitized;
|
return env;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs/promises';
|
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 {
|
export interface ServerSettings {
|
||||||
// Proxy server port (default: 22500)
|
// Proxy server port (default: 22500)
|
||||||
@@ -24,10 +31,6 @@ export interface ServerSettings {
|
|||||||
mad4fpEnabled: boolean;
|
mad4fpEnabled: boolean;
|
||||||
mad4fpPaths: string[];
|
mad4fpPaths: string[];
|
||||||
|
|
||||||
// Performance settings
|
|
||||||
chunkSize: number;
|
|
||||||
maxConcurrentDownloads: number;
|
|
||||||
|
|
||||||
// Feature flags
|
// Feature flags
|
||||||
allowCrossDomain: boolean;
|
allowCrossDomain: boolean;
|
||||||
enableBrotli: boolean;
|
enableBrotli: boolean;
|
||||||
@@ -40,6 +43,23 @@ export interface ServerSettings {
|
|||||||
cgiMaxResponseSize: number;
|
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 {
|
export class ConfigManager {
|
||||||
private static settings: ServerSettings | null = null;
|
private static settings: ServerSettings | null = null;
|
||||||
|
|
||||||
@@ -47,10 +67,27 @@ export class ConfigManager {
|
|||||||
* Load configuration from proxySettings.json and environment
|
* Load configuration from proxySettings.json and environment
|
||||||
*/
|
*/
|
||||||
static async loadConfig(flashpointPath: string): Promise<ServerSettings> {
|
static async loadConfig(flashpointPath: string): Promise<ServerSettings> {
|
||||||
|
if (ConfigManager.settings) {
|
||||||
|
logger.warn('[ConfigManager] Re-initializing configuration - this may indicate a bug');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const proxySettingsPath = path.join(flashpointPath, 'Server', 'proxySettings.json');
|
const proxySettingsPath = path.join(flashpointPath, 'Server', 'proxySettings.json');
|
||||||
const data = await fs.readFile(proxySettingsPath, 'utf-8');
|
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
|
// Build settings from proxySettings.json and defaults
|
||||||
// Ports hardcoded - use docker-compose port mapping to expose on different ports
|
// Ports hardcoded - use docker-compose port mapping to expose on different ports
|
||||||
@@ -75,9 +112,6 @@ export class ConfigManager {
|
|||||||
mad4fpEnabled: proxySettings.mad4fpEnabled || false,
|
mad4fpEnabled: proxySettings.mad4fpEnabled || false,
|
||||||
mad4fpPaths: proxySettings.mad4fpPaths || [],
|
mad4fpPaths: proxySettings.mad4fpPaths || [],
|
||||||
|
|
||||||
chunkSize: 64 * 1024, // 64KB chunks for streaming
|
|
||||||
maxConcurrentDownloads: 10,
|
|
||||||
|
|
||||||
allowCrossDomain: true,
|
allowCrossDomain: true,
|
||||||
enableBrotli: true,
|
enableBrotli: true,
|
||||||
enableCGI: process.env.ENABLE_CGI === 'true',
|
enableCGI: process.env.ENABLE_CGI === 'true',
|
||||||
@@ -95,16 +129,22 @@ export class ConfigManager {
|
|||||||
cgiMaxResponseSize: proxySettings.cgiMaxResponseSize || 50 * 1024 * 1024, // 50MB
|
cgiMaxResponseSize: proxySettings.cgiMaxResponseSize || 50 * 1024 * 1024, // 50MB
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.info('[ProxyConfig] Configuration loaded successfully');
|
logger.info('[ConfigManager] Configuration loaded successfully');
|
||||||
logger.info(`[ProxyConfig] Proxy port: ${this.settings.proxyPort}`);
|
logger.info(`[ConfigManager] Proxy port: ${this.settings.proxyPort}`);
|
||||||
logger.info(`[ProxyConfig] GameZip port: ${this.settings.gameZipPort}`);
|
logger.info(`[ConfigManager] GameZip port: ${this.settings.gameZipPort}`);
|
||||||
logger.info(`[ProxyConfig] HTDOCS path: ${this.settings.legacyHTDOCSPath}`);
|
logger.info(`[ConfigManager] HTDOCS path: ${this.settings.legacyHTDOCSPath}`);
|
||||||
logger.info(`[ProxyConfig] Override paths: ${this.settings.legacyOverridePaths.length}`);
|
logger.info(`[ConfigManager] Override paths: ${this.settings.legacyOverridePaths.length}`);
|
||||||
logger.info(`[ProxyConfig] External sources: ${this.settings.externalFilePaths.length}`);
|
logger.info(`[ConfigManager] External sources: ${this.settings.externalFilePaths.length}`);
|
||||||
|
|
||||||
return this.settings;
|
return this.settings;
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
logger.error('[ProxyConfig] Failed to load proxySettings.json, using defaults', error);
|
// 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
|
// Fallback to defaults
|
||||||
this.settings = {
|
this.settings = {
|
||||||
@@ -120,8 +160,6 @@ export class ConfigManager {
|
|||||||
],
|
],
|
||||||
mad4fpEnabled: false,
|
mad4fpEnabled: false,
|
||||||
mad4fpPaths: [],
|
mad4fpPaths: [],
|
||||||
chunkSize: 64 * 1024,
|
|
||||||
maxConcurrentDownloads: 10,
|
|
||||||
allowCrossDomain: true,
|
allowCrossDomain: true,
|
||||||
enableBrotli: true,
|
enableBrotli: true,
|
||||||
enableCGI: false,
|
enableCGI: false,
|
||||||
@@ -0,0 +1,614 @@
|
|||||||
|
import http from 'http';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
import { zipManager } from './zip-manager';
|
||||||
|
import { ConfigManager, MAX_BUFFERED_FILE_SIZE } from './config';
|
||||||
|
import { getMimeType } from './mimeTypes';
|
||||||
|
import { injectPolyfills } from './utils/htmlInjector';
|
||||||
|
import { setCorsHeaders } from './utils/cors';
|
||||||
|
import { sanitizeUrlPath, sanitizeErrorMessage } from './utils/pathSecurity';
|
||||||
|
import { validateGameId, validateHostname } from './validation/schemas';
|
||||||
|
import { gameDataDownloader } from './services';
|
||||||
|
|
||||||
|
/** Escape HTML special characters to prevent XSS in loading page */
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
.replace(/`/g, '`');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download progress tracking for in-progress downloads
|
||||||
|
* Used to show loading page while game is being downloaded
|
||||||
|
*/
|
||||||
|
interface DownloadProgress {
|
||||||
|
startTime: number;
|
||||||
|
progress: number;
|
||||||
|
total: number | null;
|
||||||
|
source: string;
|
||||||
|
zipPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map of gameId -> download progress for in-progress downloads */
|
||||||
|
const downloadsInProgress = new Map<string, DownloadProgress>();
|
||||||
|
|
||||||
|
/** Maximum concurrent downloads */
|
||||||
|
const MAX_CONCURRENT_DOWNLOADS = 3;
|
||||||
|
|
||||||
|
/** Cleanup stale downloads older than 10 minutes */
|
||||||
|
const DOWNLOAD_STALE_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GameZip Server - Direct-call interface for mounting/serving files from ZIP archives
|
||||||
|
* Refactored from game-service to expose methods instead of running an HTTP server
|
||||||
|
*/
|
||||||
|
export class GameZipServer {
|
||||||
|
private cleanupInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Start periodic cleanup of stale downloads (every 60 seconds)
|
||||||
|
this.cleanupInterval = setInterval(() => {
|
||||||
|
this.cleanupStaleDownloads();
|
||||||
|
}, 60000);
|
||||||
|
this.cleanupInterval.unref(); // Don't prevent graceful shutdown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispose of the server and stop cleanup intervals
|
||||||
|
*/
|
||||||
|
dispose(): void {
|
||||||
|
if (this.cleanupInterval) {
|
||||||
|
clearInterval(this.cleanupInterval);
|
||||||
|
this.cleanupInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mount a ZIP file directly (no HTTP)
|
||||||
|
* Returns { success, downloading?, statusCode }
|
||||||
|
*/
|
||||||
|
async mountZip(params: {
|
||||||
|
id: string;
|
||||||
|
zipPath: string;
|
||||||
|
gameId?: string;
|
||||||
|
dateAdded?: string;
|
||||||
|
sha256?: string;
|
||||||
|
}): Promise<{ success: boolean; downloading?: boolean; statusCode: number }> {
|
||||||
|
const { id, zipPath, gameId, dateAdded, sha256 } = params;
|
||||||
|
|
||||||
|
// Validate mount ID using Zod schema
|
||||||
|
try {
|
||||||
|
validateGameId(id);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`[Security] Invalid mount ID detected: ${id}`);
|
||||||
|
return { success: false, statusCode: 400 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!zipPath) {
|
||||||
|
return { success: false, statusCode: 400 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate ZIP path to prevent directory traversal
|
||||||
|
const flashpointPath =
|
||||||
|
process.env.FLASHPOINT_PATH ||
|
||||||
|
(process.env.NODE_ENV === 'production' ? '/data/flashpoint' : 'D:/Flashpoint');
|
||||||
|
const allowedGamesPath =
|
||||||
|
process.env.FLASHPOINT_GAMES_PATH || path.join(flashpointPath, 'Data', 'Games');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const normalizedZipPath = path.normalize(zipPath);
|
||||||
|
const resolvedZipPath = path.resolve(normalizedZipPath);
|
||||||
|
const resolvedGamesPath = path.resolve(allowedGamesPath);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!resolvedZipPath.startsWith(resolvedGamesPath + path.sep) &&
|
||||||
|
resolvedZipPath !== resolvedGamesPath
|
||||||
|
) {
|
||||||
|
logger.warn(`[Security] ZIP path outside allowed directory: ${zipPath}`);
|
||||||
|
return { success: false, statusCode: 403 };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[Security] ZIP path validation error:', error);
|
||||||
|
return { success: false, statusCode: 400 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if ZIP file exists locally
|
||||||
|
let zipExists = false;
|
||||||
|
try {
|
||||||
|
await fs.access(zipPath);
|
||||||
|
zipExists = true;
|
||||||
|
} catch {
|
||||||
|
zipExists = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If ZIP doesn't exist and we have download parameters, start background download
|
||||||
|
if (!zipExists && gameId && dateAdded) {
|
||||||
|
if (downloadsInProgress.has(gameId)) {
|
||||||
|
logger.info(`[GameZipServer] Download already in progress for game ${gameId}`);
|
||||||
|
return { success: true, downloading: true, statusCode: 202 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we've hit the concurrent download limit
|
||||||
|
if (downloadsInProgress.size >= MAX_CONCURRENT_DOWNLOADS) {
|
||||||
|
logger.warn(
|
||||||
|
`[GameZipServer] Too many concurrent downloads (${downloadsInProgress.size}), rejecting ${gameId}`
|
||||||
|
);
|
||||||
|
return { success: false, statusCode: 503 };
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[GameZipServer] ZIP not found locally, starting background download for game ${gameId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
downloadsInProgress.set(gameId, {
|
||||||
|
startTime: Date.now(),
|
||||||
|
progress: 0,
|
||||||
|
total: null,
|
||||||
|
source: 'Starting...',
|
||||||
|
zipPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.downloadAndMountInBackground(
|
||||||
|
id,
|
||||||
|
gameId,
|
||||||
|
dateAdded,
|
||||||
|
sha256,
|
||||||
|
zipPath,
|
||||||
|
allowedGamesPath
|
||||||
|
).catch((err) => logger.error('[GameZipServer] Background download failed:', err));
|
||||||
|
|
||||||
|
return { success: true, downloading: true, statusCode: 202 };
|
||||||
|
} else if (!zipExists) {
|
||||||
|
return { success: false, statusCode: 404 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mount the ZIP (file exists locally)
|
||||||
|
try {
|
||||||
|
await zipManager.mount(id, zipPath);
|
||||||
|
|
||||||
|
// After the mount succeeds, verify the real path hasn't changed (TOCTOU protection)
|
||||||
|
try {
|
||||||
|
const realPath = await fs.realpath(zipPath);
|
||||||
|
const resolvedReal = path.resolve(realPath);
|
||||||
|
const resolvedGamesPath = path.resolve(allowedGamesPath);
|
||||||
|
if (
|
||||||
|
!resolvedReal.startsWith(resolvedGamesPath + path.sep) &&
|
||||||
|
resolvedReal !== resolvedGamesPath
|
||||||
|
) {
|
||||||
|
// Symlink pointed outside allowed directory - unmount immediately
|
||||||
|
logger.warn(`[GameZipServer] Symlink detected: ${zipPath} -> ${realPath}. Unmounting.`);
|
||||||
|
await zipManager.unmount(id);
|
||||||
|
return { success: false, statusCode: 403 };
|
||||||
|
}
|
||||||
|
} catch (realPathError) {
|
||||||
|
logger.warn(`[GameZipServer] Failed to resolve real path for ${zipPath}:`, realPathError);
|
||||||
|
// Continue - the file was already successfully opened by zipManager
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, statusCode: 200 };
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Mount failed';
|
||||||
|
const safeMessage = sanitizeErrorMessage(errorMessage);
|
||||||
|
logger.error(`[GameZipServer] Mount failed: ${safeMessage}`);
|
||||||
|
return { success: false, statusCode: 500 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unmount a ZIP file directly (no HTTP)
|
||||||
|
*/
|
||||||
|
async unmountZip(id: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
validateGameId(id);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`[Security] Invalid mount ID detected: ${id}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return await zipManager.unmount(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all mounted ZIPs
|
||||||
|
*/
|
||||||
|
listMounts(): Array<{ id: string; zipPath: string; mountTime: Date; fileCount: number }> {
|
||||||
|
return zipManager.getMountedZips();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle file request from mounted ZIPs (compatible with both raw HTTP and Express)
|
||||||
|
* Supports proxy-style requests: GET http://domain.com/path HTTP/1.1
|
||||||
|
*/
|
||||||
|
async handleFileRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||||
|
if (!req.url) {
|
||||||
|
this.sendError(res, 400, 'Bad Request: No URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hostname: string;
|
||||||
|
let urlPath: string;
|
||||||
|
|
||||||
|
// Normalize malformed URLs: /http:/domain → /http://domain
|
||||||
|
// This is a common pattern in legacy Flash games
|
||||||
|
let normalizedUrl = req.url;
|
||||||
|
if (normalizedUrl.match(/^\/https?:\/[^\/]/)) {
|
||||||
|
// Single slash after protocol - insert the missing slash
|
||||||
|
normalizedUrl = normalizedUrl.replace(/^(\/https?:)\//, '$1//');
|
||||||
|
logger.debug(`[GameZipServer] Normalized malformed URL: ${req.url} → ${normalizedUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse URL (same logic as main proxy server)
|
||||||
|
try {
|
||||||
|
if (normalizedUrl.startsWith('http://') || normalizedUrl.startsWith('https://')) {
|
||||||
|
// Proxy-style: GET http://domain.com/path HTTP/1.1
|
||||||
|
const targetUrl = new URL(normalizedUrl);
|
||||||
|
hostname = targetUrl.hostname;
|
||||||
|
urlPath = targetUrl.pathname + targetUrl.search;
|
||||||
|
} else if (normalizedUrl.startsWith('/http://') || normalizedUrl.startsWith('/https://')) {
|
||||||
|
// Path-based: GET /http://domain.com/path HTTP/1.1
|
||||||
|
const urlWithoutSlash = normalizedUrl.substring(1);
|
||||||
|
const targetUrl = new URL(urlWithoutSlash);
|
||||||
|
hostname = targetUrl.hostname;
|
||||||
|
urlPath = targetUrl.pathname + targetUrl.search;
|
||||||
|
} else {
|
||||||
|
// Regular: GET /path HTTP/1.1
|
||||||
|
// Strip port from hostname (e.g., "localhost:3100" -> "localhost")
|
||||||
|
hostname = (req.headers.host || 'localhost').split(':')[0];
|
||||||
|
urlPath = normalizedUrl;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[GameZipServer] Invalid URL: ${normalizedUrl}`);
|
||||||
|
this.sendError(res, 400, 'Invalid URL format');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate hostname using Zod schema
|
||||||
|
try {
|
||||||
|
hostname = validateHostname(hostname);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[GameZipServer] Invalid hostname: ${hostname}`, error);
|
||||||
|
this.sendError(res, 400, 'Invalid hostname');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize URL path to prevent null bytes and dangerous patterns
|
||||||
|
try {
|
||||||
|
urlPath = sanitizeUrlPath(urlPath);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[GameZipServer] Invalid URL path: ${urlPath}`);
|
||||||
|
this.sendError(res, 400, 'Invalid URL path');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip query string from path for ZIP file lookup
|
||||||
|
// Files in ZIPs don't have query strings (e.g., file.swf?token=xyz → file.swf)
|
||||||
|
const pathWithoutQuery = urlPath.split('?')[0];
|
||||||
|
|
||||||
|
// Build relative path
|
||||||
|
const relPath = path.posix.join(hostname, pathWithoutQuery);
|
||||||
|
|
||||||
|
logger.debug(`[GameZipServer] Looking for: ${relPath}`);
|
||||||
|
|
||||||
|
// Search mounted ZIPs
|
||||||
|
const result = await zipManager.findFile(relPath);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
// Check if a download is in progress for any game
|
||||||
|
// The mount ID is typically the gameId
|
||||||
|
const downloadStatus = this.findActiveDownload(relPath);
|
||||||
|
|
||||||
|
if (downloadStatus) {
|
||||||
|
logger.info(
|
||||||
|
`[GameZipServer] File not ready, download in progress for ${downloadStatus.gameId}`
|
||||||
|
);
|
||||||
|
this.sendLoadingPage(res, downloadStatus.gameId, downloadStatus.progress);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`[GameZipServer] File not found in any mounted ZIP: ${relPath}`);
|
||||||
|
this.sendError(res, 404, 'File not found in mounted ZIPs');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine content type (use path without query string)
|
||||||
|
const ext = path.extname(pathWithoutQuery).substring(1).toLowerCase();
|
||||||
|
const contentType = getMimeType(ext);
|
||||||
|
|
||||||
|
// Process HTML files to inject polyfills for better game compatibility
|
||||||
|
let fileData = result.data;
|
||||||
|
if (ext === 'html' || ext === 'htm') {
|
||||||
|
fileData = injectPolyfills(result.data);
|
||||||
|
logger.info(`[GameZipServer] Injected polyfills into HTML file: ${relPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send file
|
||||||
|
logger.info(
|
||||||
|
`[GameZipServer] ✓ Serving from ZIP ${result.mountId}: ${relPath} (${fileData.length} bytes)`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get settings for CORS configuration
|
||||||
|
const settings = ConfigManager.getSettings();
|
||||||
|
|
||||||
|
// CORS headers
|
||||||
|
setCorsHeaders(res, settings);
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', contentType);
|
||||||
|
res.setHeader('Content-Length', fileData.length);
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=86400'); // Cache for 24 hours
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
res.setHeader('X-Source', `gamezipserver:${result.mountId}`);
|
||||||
|
}
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end(fileData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download ZIP and mount it in background
|
||||||
|
* This runs asynchronously - errors are logged but don't affect the HTTP response
|
||||||
|
*/
|
||||||
|
private async downloadAndMountInBackground(
|
||||||
|
mountId: string,
|
||||||
|
gameId: string,
|
||||||
|
dateAdded: string,
|
||||||
|
sha256: string | undefined,
|
||||||
|
zipPath: string,
|
||||||
|
targetPath: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = await gameDataDownloader.download(
|
||||||
|
{
|
||||||
|
gameId,
|
||||||
|
dateAdded,
|
||||||
|
sha256,
|
||||||
|
targetPath,
|
||||||
|
},
|
||||||
|
(downloaded, total, source) => {
|
||||||
|
try {
|
||||||
|
// Update progress tracking
|
||||||
|
const existing = downloadsInProgress.get(gameId);
|
||||||
|
if (existing) {
|
||||||
|
downloadsInProgress.set(gameId, {
|
||||||
|
...existing,
|
||||||
|
progress: downloaded,
|
||||||
|
total,
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const progress = total ? Math.round((downloaded / total) * 100) : 0;
|
||||||
|
logger.debug(
|
||||||
|
`[GameZipServer] Download progress for ${gameId}: ${progress}% from ${source}`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[GameZipServer] Progress callback error for ${gameId}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success && result.filePath) {
|
||||||
|
logger.info(`[GameZipServer] Successfully downloaded ZIP from "${result.sourceUsed}"`);
|
||||||
|
|
||||||
|
// Validate downloaded file path before mounting
|
||||||
|
const resolvedFilePath = path.resolve(result.filePath);
|
||||||
|
const resolvedAllowedPath = path.resolve(targetPath);
|
||||||
|
if (
|
||||||
|
!resolvedFilePath.startsWith(resolvedAllowedPath + path.sep) &&
|
||||||
|
resolvedFilePath !== resolvedAllowedPath
|
||||||
|
) {
|
||||||
|
logger.error(
|
||||||
|
`[GameZipServer] Downloaded file path outside allowed directory: ${result.filePath}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mount the downloaded ZIP
|
||||||
|
try {
|
||||||
|
await zipManager.mount(mountId, result.filePath);
|
||||||
|
logger.info(`[GameZipServer] ✓ ZIP downloaded and mounted for game ${gameId}`);
|
||||||
|
} catch (mountError) {
|
||||||
|
const errorMessage = mountError instanceof Error ? mountError.message : 'Mount failed';
|
||||||
|
logger.error(`[GameZipServer] Failed to mount downloaded ZIP: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.error(`[GameZipServer] Download failed for ${gameId}: ${result.error}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error(`[GameZipServer] Background download error for ${gameId}: ${errorMsg}`);
|
||||||
|
} finally {
|
||||||
|
// Remove from in-progress tracking
|
||||||
|
downloadsInProgress.delete(gameId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up stale downloads older than DOWNLOAD_STALE_MS
|
||||||
|
*/
|
||||||
|
private cleanupStaleDownloads(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [gameId, progress] of downloadsInProgress.entries()) {
|
||||||
|
if (now - progress.startTime > DOWNLOAD_STALE_MS) {
|
||||||
|
logger.warn(`[GameZipServer] Removing stale download for ${gameId}`);
|
||||||
|
downloadsInProgress.delete(gameId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find an active download that might be related to the requested file path
|
||||||
|
* Returns the download status if found, null otherwise
|
||||||
|
*/
|
||||||
|
private findActiveDownload(
|
||||||
|
relPath: string
|
||||||
|
): { gameId: string; progress: DownloadProgress } | null {
|
||||||
|
// Clean up stale downloads first
|
||||||
|
this.cleanupStaleDownloads();
|
||||||
|
|
||||||
|
// Extract hostname from relPath (format: hostname/path/to/file)
|
||||||
|
const hostname = relPath.split('/')[0]?.toLowerCase();
|
||||||
|
|
||||||
|
// Try to find a download matching this hostname
|
||||||
|
if (hostname) {
|
||||||
|
for (const [gameId, download] of downloadsInProgress.entries()) {
|
||||||
|
// Check if mount ID or ZIP path contains the hostname
|
||||||
|
if (gameId.toLowerCase().includes(hostname)) {
|
||||||
|
return { gameId, progress: download };
|
||||||
|
}
|
||||||
|
// Also check the ZIP path for hostname match
|
||||||
|
const zipFilename = download.zipPath.toLowerCase();
|
||||||
|
if (zipFilename.includes(hostname)) {
|
||||||
|
return { gameId, progress: download };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No matching download found - return null for a proper 404
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a loading page while game is being downloaded
|
||||||
|
* Uses auto-refresh to poll until game is ready
|
||||||
|
*/
|
||||||
|
private sendLoadingPage(
|
||||||
|
res: http.ServerResponse,
|
||||||
|
gameId: string,
|
||||||
|
progress: DownloadProgress
|
||||||
|
): void {
|
||||||
|
if (res.headersSent) {
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressPercent = progress.total
|
||||||
|
? Math.round((progress.progress / progress.total) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const progressMB = (progress.progress / (1024 * 1024)).toFixed(1);
|
||||||
|
const totalMB = progress.total ? (progress.total / (1024 * 1024)).toFixed(1) : '?';
|
||||||
|
|
||||||
|
const elapsedSec = Math.round((Date.now() - progress.startTime) / 1000);
|
||||||
|
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="refresh" content="2">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Downloading Game...</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
background: #0f0f0f;
|
||||||
|
color: #e5e5e5;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
.spinner {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border: 4px solid #333;
|
||||||
|
border-top-color: #3b82f6;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto 24px;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: #f5f5f5;
|
||||||
|
}
|
||||||
|
.progress-bar {
|
||||||
|
background: #262626;
|
||||||
|
border-radius: 8px;
|
||||||
|
height: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.progress-fill {
|
||||||
|
background: linear-gradient(90deg, #3b82f6, #60a5fa);
|
||||||
|
height: 100%;
|
||||||
|
width: ${progressPercent}%;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
.progress-text {
|
||||||
|
color: #a3a3a3;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.source {
|
||||||
|
color: #737373;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.refresh-note {
|
||||||
|
color: #525252;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<h2>Downloading Game Data...</h2>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
<p class="progress-text">${escapeHtml(progressMB)} MB / ${escapeHtml(totalMB)} MB (${escapeHtml(String(progressPercent))}%)</p>
|
||||||
|
<p class="source">Downloading from configured source...</p>
|
||||||
|
<p class="refresh-note">Elapsed: ${escapeHtml(String(elapsedSec))}s · Page refreshes automatically</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
const settings = ConfigManager.getSettings();
|
||||||
|
setCorsHeaders(res, settings);
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
|
res.setHeader(
|
||||||
|
'Content-Security-Policy',
|
||||||
|
"default-src 'none'; style-src 'unsafe-inline'; img-src 'none'; script-src 'none'"
|
||||||
|
);
|
||||||
|
res.writeHead(202); // 202 Accepted - still processing
|
||||||
|
res.end(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send error response
|
||||||
|
*/
|
||||||
|
private sendError(res: http.ServerResponse, statusCode: number, message: string): void {
|
||||||
|
if (res.headersSent) {
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn(`[GameZipServer] Sending error ${statusCode}: ${message}`);
|
||||||
|
|
||||||
|
// Set CORS headers even for error responses
|
||||||
|
const settings = ConfigManager.getSettings();
|
||||||
|
setCorsHeaders(res, settings);
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/plain');
|
||||||
|
res.writeHead(statusCode);
|
||||||
|
res.end(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const gameZipServer = new GameZipServer();
|
||||||
@@ -2,8 +2,8 @@ import path from 'path';
|
|||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import { createReadStream } from 'fs';
|
import { createReadStream } from 'fs';
|
||||||
import { Readable } from 'stream';
|
import { Readable } from 'stream';
|
||||||
import { logger } from './utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { ConfigManager } from './config';
|
import { ConfigManager, MAX_BUFFERED_FILE_SIZE } from './config';
|
||||||
import { getMimeType } from './mimeTypes';
|
import { getMimeType } from './mimeTypes';
|
||||||
import {
|
import {
|
||||||
sanitizeAndValidatePath,
|
sanitizeAndValidatePath,
|
||||||
@@ -11,16 +11,38 @@ import {
|
|||||||
sanitizeErrorMessage,
|
sanitizeErrorMessage,
|
||||||
} from './utils/pathSecurity';
|
} from './utils/pathSecurity';
|
||||||
import { CgiExecutor, CgiRequest } from './cgi';
|
import { CgiExecutor, CgiRequest } from './cgi';
|
||||||
import axios, { AxiosError } from 'axios';
|
import axios from 'axios';
|
||||||
import zlib from 'zlib';
|
import zlib from 'zlib';
|
||||||
import { promisify } from 'util';
|
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)
|
// 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;
|
const EXTERNAL_REQUEST_MAX_REDIRECTS = 5;
|
||||||
|
|
||||||
// Retry configuration for external requests
|
// Retry configuration for external requests
|
||||||
const MAX_RETRIES = 3;
|
const MAX_RETRIES = 2;
|
||||||
const RETRY_BASE_DELAY_MS = 1000; // 1 second base delay
|
const RETRY_BASE_DELAY_MS = 1000; // 1 second base delay
|
||||||
|
|
||||||
const brotliDecompress = promisify(zlib.brotliDecompress);
|
const brotliDecompress = promisify(zlib.brotliDecompress);
|
||||||
@@ -28,7 +50,9 @@ const brotliDecompress = promisify(zlib.brotliDecompress);
|
|||||||
// Script file extensions
|
// Script file extensions
|
||||||
const SCRIPT_EXTENSIONS = ['php', 'php5', 'phtml', 'pl'];
|
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'];
|
const INDEX_EXTENSIONS = ['html', 'htm', 'php', 'swf'];
|
||||||
|
|
||||||
// Common subdomain prefixes used in Flashpoint content
|
// Common subdomain prefixes used in Flashpoint content
|
||||||
@@ -69,15 +93,54 @@ export interface HttpRequestContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class LegacyServer {
|
export class LegacyServer {
|
||||||
private settings = ConfigManager.getSettings();
|
|
||||||
private cgiExecutor: CgiExecutor | null = null;
|
private cgiExecutor: CgiExecutor | null = null;
|
||||||
private cgiValidated = false;
|
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
|
* Initialize CGI executor if enabled
|
||||||
*/
|
*/
|
||||||
private async initCgiExecutor(): Promise<CgiExecutor | null> {
|
private async initCgiExecutor(): Promise<CgiExecutor | null> {
|
||||||
if (!this.settings.enableCGI) {
|
const settings = ConfigManager.getSettings();
|
||||||
|
|
||||||
|
if (!settings.enableCGI) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,12 +149,12 @@ export class LegacyServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.cgiExecutor = new CgiExecutor({
|
this.cgiExecutor = new CgiExecutor({
|
||||||
phpCgiPath: this.settings.phpCgiPath,
|
phpCgiPath: settings.phpCgiPath,
|
||||||
documentRoot: this.settings.legacyHTDOCSPath,
|
documentRoot: settings.legacyHTDOCSPath,
|
||||||
cgiBinPath: this.settings.legacyCGIBINPath,
|
cgiBinPath: settings.legacyCGIBINPath,
|
||||||
timeout: this.settings.cgiTimeout,
|
timeout: settings.cgiTimeout,
|
||||||
maxBodySize: this.settings.cgiMaxBodySize,
|
maxBodySize: settings.cgiMaxBodySize,
|
||||||
maxResponseSize: this.settings.cgiMaxResponseSize,
|
maxResponseSize: settings.cgiMaxResponseSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Validate binary exists (only once)
|
// Validate binary exists (only once)
|
||||||
@@ -118,6 +181,9 @@ export class LegacyServer {
|
|||||||
urlPath: string,
|
urlPath: string,
|
||||||
requestContext?: HttpRequestContext
|
requestContext?: HttpRequestContext
|
||||||
): Promise<LegacyFileResponse> {
|
): 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
|
// Sanitize URL path to prevent null bytes and dangerous patterns
|
||||||
try {
|
try {
|
||||||
urlPath = sanitizeUrlPath(urlPath);
|
urlPath = sanitizeUrlPath(urlPath);
|
||||||
@@ -132,36 +198,49 @@ export class LegacyServer {
|
|||||||
// Build relative path: {host}/{path}
|
// Build relative path: {host}/{path}
|
||||||
const relPath = path.posix.join(host, urlPath);
|
const relPath = path.posix.join(host, urlPath);
|
||||||
|
|
||||||
logger.info(`[LegacyServer] Serving: ${host}${urlPath}`);
|
logger.debug(`[LegacyServer] Serving: ${host}${urlPath}`);
|
||||||
logger.info(`[LegacyServer] Relative path: ${relPath}`);
|
logger.debug(`[LegacyServer] Relative path: ${relPath}`);
|
||||||
|
|
||||||
// Step 1: Build all path candidates
|
// 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
|
// Step 2: Try each candidate in order
|
||||||
for (const candidate of pathCandidates) {
|
for (const candidate of pathCandidates) {
|
||||||
logger.debug(`[LegacyServer] Trying: ${candidate.path} (${candidate.type})`);
|
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 {
|
try {
|
||||||
// Validate path is within allowed base directory before accessing
|
// 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);
|
const stats = await fs.stat(candidate.path);
|
||||||
|
|
||||||
if (stats.isFile()) {
|
if (stats.isFile()) {
|
||||||
logger.info(`[LegacyServer] ✓ Found file: ${candidate.path}`);
|
logger.info(`[LegacyServer] ✓ Found file: ${candidate.path}`);
|
||||||
const isCgiBin = candidate.type === 'cgi-bin' || candidate.type === 'cgi-bin-no-query';
|
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) {
|
} 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;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: No local file found, try external sources
|
// Step 3: No local file found, try external sources
|
||||||
logger.info(`[LegacyServer] File not found locally, trying 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(
|
private buildPathCandidates(
|
||||||
relPath: string,
|
relPath: string,
|
||||||
urlPath: string
|
urlPath: string,
|
||||||
|
settings: ReturnType<typeof ConfigManager.getSettings>
|
||||||
): Array<{ path: string; type: string }> {
|
): Array<{ path: string; type: string }> {
|
||||||
const candidates: Array<{ path: string; type: string }> = [];
|
const candidates: Array<{ path: string; type: string }> = [];
|
||||||
const seenPaths = new Set<string>();
|
const seenPaths = new Set<string>();
|
||||||
@@ -199,7 +279,7 @@ export class LegacyServer {
|
|||||||
|
|
||||||
// 1. Exact file paths (with and without query string)
|
// 1. Exact file paths (with and without query string)
|
||||||
addCandidate(
|
addCandidate(
|
||||||
path.join(this.settings.legacyHTDOCSPath, variantRelPath),
|
path.join(settings.legacyHTDOCSPath, variantRelPath),
|
||||||
hostVariation === hostname ? 'exact' : `subdomain:${hostVariation}`
|
hostVariation === hostname ? 'exact' : `subdomain:${hostVariation}`
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -207,7 +287,7 @@ export class LegacyServer {
|
|||||||
const pathWithoutQuery = variantRelPath.split('?')[0];
|
const pathWithoutQuery = variantRelPath.split('?')[0];
|
||||||
if (pathWithoutQuery !== variantRelPath) {
|
if (pathWithoutQuery !== variantRelPath) {
|
||||||
addCandidate(
|
addCandidate(
|
||||||
path.join(this.settings.legacyHTDOCSPath, pathWithoutQuery),
|
path.join(settings.legacyHTDOCSPath, pathWithoutQuery),
|
||||||
hostVariation === hostname ? 'exact-no-query' : `subdomain:${hostVariation}-no-query`
|
hostVariation === hostname ? 'exact-no-query' : `subdomain:${hostVariation}-no-query`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -217,15 +297,12 @@ export class LegacyServer {
|
|||||||
const pathWithoutQuery = relPath.split('?')[0];
|
const pathWithoutQuery = relPath.split('?')[0];
|
||||||
|
|
||||||
// 2. Override paths (exact files in override directories)
|
// 2. Override paths (exact files in override directories)
|
||||||
for (const override of this.settings.legacyOverridePaths) {
|
for (const override of settings.legacyOverridePaths) {
|
||||||
addCandidate(
|
addCandidate(path.join(settings.legacyHTDOCSPath, override, relPath), `override:${override}`);
|
||||||
path.join(this.settings.legacyHTDOCSPath, override, relPath),
|
|
||||||
`override:${override}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (pathWithoutQuery !== relPath) {
|
if (pathWithoutQuery !== relPath) {
|
||||||
addCandidate(
|
addCandidate(
|
||||||
path.join(this.settings.legacyHTDOCSPath, override, pathWithoutQuery),
|
path.join(settings.legacyHTDOCSPath, override, pathWithoutQuery),
|
||||||
`override:${override}-no-query`
|
`override:${override}-no-query`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -233,27 +310,21 @@ export class LegacyServer {
|
|||||||
|
|
||||||
// 3. CGI-BIN paths (for script files)
|
// 3. CGI-BIN paths (for script files)
|
||||||
if (this.isScriptUrl(urlPath)) {
|
if (this.isScriptUrl(urlPath)) {
|
||||||
addCandidate(path.join(this.settings.legacyCGIBINPath, relPath), 'cgi-bin');
|
addCandidate(path.join(settings.legacyCGIBINPath, relPath), 'cgi-bin');
|
||||||
|
|
||||||
if (pathWithoutQuery !== relPath) {
|
if (pathWithoutQuery !== relPath) {
|
||||||
addCandidate(
|
addCandidate(path.join(settings.legacyCGIBINPath, pathWithoutQuery), 'cgi-bin-no-query');
|
||||||
path.join(this.settings.legacyCGIBINPath, pathWithoutQuery),
|
|
||||||
'cgi-bin-no-query'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Index files (for directory requests)
|
// 4. Index files (for directory requests)
|
||||||
for (const ext of INDEX_EXTENSIONS) {
|
for (const ext of INDEX_EXTENSIONS) {
|
||||||
addCandidate(
|
addCandidate(path.join(settings.legacyHTDOCSPath, relPath, `index.${ext}`), `index:${ext}`);
|
||||||
path.join(this.settings.legacyHTDOCSPath, relPath, `index.${ext}`),
|
|
||||||
`index:${ext}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Index files in override paths
|
// Index files in override paths
|
||||||
for (const override of this.settings.legacyOverridePaths) {
|
for (const override of settings.legacyOverridePaths) {
|
||||||
addCandidate(
|
addCandidate(
|
||||||
path.join(this.settings.legacyHTDOCSPath, override, relPath, `index.${ext}`),
|
path.join(settings.legacyHTDOCSPath, override, relPath, `index.${ext}`),
|
||||||
`index-override:${override}:${ext}`
|
`index-override:${override}:${ext}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -266,17 +337,22 @@ export class LegacyServer {
|
|||||||
* Validate that a candidate path is within its allowed base directory
|
* Validate that a candidate path is within its allowed base directory
|
||||||
* @param candidatePath The full path to validate
|
* @param candidatePath The full path to validate
|
||||||
* @param candidateType The type of candidate (e.g., 'exact', 'cgi-bin', 'override:*')
|
* @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
|
* @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
|
// Determine the allowed base directory based on candidate type
|
||||||
let allowedBase: string;
|
let allowedBase: string;
|
||||||
|
|
||||||
if (candidateType === 'cgi-bin' || candidateType === 'cgi-bin-no-query') {
|
if (candidateType === 'cgi-bin' || candidateType === 'cgi-bin-no-query') {
|
||||||
allowedBase = this.settings.legacyCGIBINPath;
|
allowedBase = settings.legacyCGIBINPath;
|
||||||
} else {
|
} else {
|
||||||
// All other types use htdocs path
|
// All other types use htdocs path
|
||||||
allowedBase = this.settings.legacyHTDOCSPath;
|
allowedBase = settings.legacyHTDOCSPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate that the candidate path is within the allowed base
|
// Validate that the candidate path is within the allowed base
|
||||||
@@ -302,37 +378,54 @@ export class LegacyServer {
|
|||||||
* @param filePath Absolute path to file
|
* @param filePath Absolute path to file
|
||||||
* @param isCGI Whether to execute as CGI script
|
* @param isCGI Whether to execute as CGI script
|
||||||
* @param requestContext HTTP request context for CGI execution
|
* @param requestContext HTTP request context for CGI execution
|
||||||
|
* @param settings Current configuration settings
|
||||||
*/
|
*/
|
||||||
private async serveFile(
|
private async serveFile(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
isCGI: boolean = false,
|
isCGI: boolean = false,
|
||||||
requestContext?: HttpRequestContext
|
requestContext?: HttpRequestContext,
|
||||||
|
settings?: ReturnType<typeof ConfigManager.getSettings>
|
||||||
): Promise<LegacyFileResponse> {
|
): Promise<LegacyFileResponse> {
|
||||||
|
// Get settings if not provided
|
||||||
|
if (!settings) {
|
||||||
|
settings = ConfigManager.getSettings();
|
||||||
|
}
|
||||||
// Determine content type first
|
// Determine content type first
|
||||||
const ext = path.extname(filePath).substring(1).toLowerCase();
|
const ext = path.extname(filePath).substring(1).toLowerCase();
|
||||||
const contentType = getMimeType(ext);
|
const contentType = getMimeType(ext);
|
||||||
|
|
||||||
// HTML files need polyfill injection, so buffer them
|
// HTML files need polyfill injection, so buffer them
|
||||||
// Brotli-compressed files also need buffering for decompression
|
// Brotli-compressed files also need buffering for decompression
|
||||||
const needsBuffering =
|
let needsBuffering =
|
||||||
contentType.includes('text/html') ||
|
contentType.includes('text/html') ||
|
||||||
(this.settings.enableBrotli && filePath.endsWith('.br')) ||
|
(settings.enableBrotli && filePath.endsWith('.br')) ||
|
||||||
isCGI;
|
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) {
|
if (needsBuffering) {
|
||||||
// Read entire file into memory
|
// Read entire file into memory
|
||||||
let data = await fs.readFile(filePath);
|
let data = await fs.readFile(filePath);
|
||||||
|
|
||||||
// Handle Brotli compression
|
// Handle Brotli compression
|
||||||
if (this.settings.enableBrotli && filePath.endsWith('.br')) {
|
if (settings.enableBrotli && filePath.endsWith('.br')) {
|
||||||
logger.debug(`[LegacyServer] Decompressing Brotli file: ${filePath}`);
|
logger.debug(`[LegacyServer] Decompressing Brotli file: ${filePath}`);
|
||||||
data = await brotliDecompress(data);
|
data = await brotliDecompress(data);
|
||||||
filePath = filePath.substring(0, filePath.length - 3); // Remove .br extension
|
filePath = filePath.substring(0, filePath.length - 3); // Remove .br extension
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute CGI scripts if enabled
|
// Execute CGI scripts if enabled
|
||||||
if (isCGI && this.settings.enableCGI) {
|
if (isCGI && settings.enableCGI) {
|
||||||
return await this.executeCgiScript(filePath, requestContext);
|
return await this.executeCgiScript(filePath, requestContext, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -343,7 +436,6 @@ export class LegacyServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Non-HTML, non-compressed files: stream for memory efficiency
|
// Non-HTML, non-compressed files: stream for memory efficiency
|
||||||
const stat = await fs.stat(filePath);
|
|
||||||
const stream = createReadStream(filePath);
|
const stream = createReadStream(filePath);
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -362,30 +454,36 @@ export class LegacyServer {
|
|||||||
* Execute a CGI script and return the response
|
* Execute a CGI script and return the response
|
||||||
* @param scriptPath Absolute path to the script file
|
* @param scriptPath Absolute path to the script file
|
||||||
* @param requestContext HTTP request context (method, headers, body)
|
* @param requestContext HTTP request context (method, headers, body)
|
||||||
|
* @param settings Current configuration settings
|
||||||
*/
|
*/
|
||||||
private async executeCgiScript(
|
private async executeCgiScript(
|
||||||
scriptPath: string,
|
scriptPath: string,
|
||||||
requestContext?: HttpRequestContext
|
requestContext?: HttpRequestContext,
|
||||||
|
settings?: ReturnType<typeof ConfigManager.getSettings>
|
||||||
): Promise<LegacyFileResponse> {
|
): Promise<LegacyFileResponse> {
|
||||||
|
// Get settings if not provided
|
||||||
|
if (!settings) {
|
||||||
|
settings = ConfigManager.getSettings();
|
||||||
|
}
|
||||||
const executor = await this.initCgiExecutor();
|
const executor = await this.initCgiExecutor();
|
||||||
|
|
||||||
if (!executor) {
|
if (!executor) {
|
||||||
logger.warn('[LegacyServer] CGI executor not available, serving script as static file');
|
logger.warn('[LegacyServer] CGI executor not available for script');
|
||||||
const data = await fs.readFile(scriptPath);
|
|
||||||
return {
|
return {
|
||||||
data,
|
data: Buffer.from('CGI service unavailable'),
|
||||||
contentType: 'text/plain',
|
contentType: 'text/plain',
|
||||||
source: 'local-htdocs',
|
statusCode: 503,
|
||||||
|
source: 'cgi-error',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build URL from the script path for CGI environment
|
// Build URL from the script path for CGI environment
|
||||||
// Extract the relative path from the script path
|
// Extract the relative path from the script path
|
||||||
let relativePath = scriptPath;
|
let relativePath = scriptPath;
|
||||||
if (scriptPath.startsWith(this.settings.legacyHTDOCSPath)) {
|
if (scriptPath.startsWith(settings.legacyHTDOCSPath)) {
|
||||||
relativePath = scriptPath.substring(this.settings.legacyHTDOCSPath.length);
|
relativePath = scriptPath.substring(settings.legacyHTDOCSPath.length);
|
||||||
} else if (scriptPath.startsWith(this.settings.legacyCGIBINPath)) {
|
} else if (scriptPath.startsWith(settings.legacyCGIBINPath)) {
|
||||||
relativePath = scriptPath.substring(this.settings.legacyCGIBINPath.length);
|
relativePath = scriptPath.substring(settings.legacyCGIBINPath.length);
|
||||||
}
|
}
|
||||||
// Normalize path separators for URL
|
// Normalize path separators for URL
|
||||||
relativePath = relativePath.replace(/\\/g, '/');
|
relativePath = relativePath.replace(/\\/g, '/');
|
||||||
@@ -431,18 +529,21 @@ export class LegacyServer {
|
|||||||
/**
|
/**
|
||||||
* Try external sources (Infinity Server, MAD4FP, etc.)
|
* 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
|
// Try Infinity Server first
|
||||||
if (this.settings.infinityServerURL) {
|
if (settings.infinityServerURL) {
|
||||||
logger.info(`[LegacyServer] Trying Infinity Server...`);
|
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) {
|
if (result) {
|
||||||
return { ...result, source: 'infinity-server' };
|
return { ...result, source: 'infinity-server' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try other external paths
|
// Try other external paths
|
||||||
for (const externalPath of this.settings.externalFilePaths) {
|
for (const externalPath of settings.externalFilePaths) {
|
||||||
logger.info(`[LegacyServer] Trying: ${externalPath}`);
|
logger.info(`[LegacyServer] Trying: ${externalPath}`);
|
||||||
const result = await this.tryExternalSource(externalPath, relPath);
|
const result = await this.tryExternalSource(externalPath, relPath);
|
||||||
if (result) {
|
if (result) {
|
||||||
@@ -451,8 +552,8 @@ export class LegacyServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try MAD4FP if enabled
|
// Try MAD4FP if enabled
|
||||||
if (this.settings.mad4fpEnabled) {
|
if (settings.mad4fpEnabled) {
|
||||||
for (const mad4fpPath of this.settings.mad4fpPaths) {
|
for (const mad4fpPath of settings.mad4fpPaths) {
|
||||||
logger.info(`[LegacyServer] Trying MAD4FP: ${mad4fpPath}`);
|
logger.info(`[LegacyServer] Trying MAD4FP: ${mad4fpPath}`);
|
||||||
const result = await this.tryExternalSource(mad4fpPath, relPath);
|
const result = await this.tryExternalSource(mad4fpPath, relPath);
|
||||||
if (result) {
|
if (result) {
|
||||||
@@ -506,7 +607,18 @@ export class LegacyServer {
|
|||||||
relPath: string
|
relPath: string
|
||||||
): Promise<{ data: Buffer; contentType: string } | null> {
|
): Promise<{ data: Buffer; contentType: string } | null> {
|
||||||
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
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}`);
|
logger.info(`[LegacyServer] Downloading: ${fullUrl}`);
|
||||||
|
|
||||||
@@ -519,7 +631,7 @@ export class LegacyServer {
|
|||||||
}, EXTERNAL_REQUEST_TIMEOUT_MS);
|
}, EXTERNAL_REQUEST_TIMEOUT_MS);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(fullUrl, {
|
const response = await axiosInstance.get(fullUrl, {
|
||||||
responseType: 'arraybuffer',
|
responseType: 'arraybuffer',
|
||||||
timeout: EXTERNAL_REQUEST_TIMEOUT_MS,
|
timeout: EXTERNAL_REQUEST_TIMEOUT_MS,
|
||||||
maxRedirects: EXTERNAL_REQUEST_MAX_REDIRECTS,
|
maxRedirects: EXTERNAL_REQUEST_MAX_REDIRECTS,
|
||||||
@@ -533,6 +645,7 @@ export class LegacyServer {
|
|||||||
maxBodyLength: 100 * 1024 * 1024,
|
maxBodyLength: 100 * 1024 * 1024,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
const data = Buffer.from(response.data);
|
const data = Buffer.from(response.data);
|
||||||
|
|
||||||
// Determine content type from file extension, not from response header (G-H5)
|
// Determine content type from file extension, not from response header (G-H5)
|
||||||
@@ -560,15 +673,14 @@ export class LegacyServer {
|
|||||||
|
|
||||||
// Log the error appropriately
|
// Log the error appropriately
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
const axiosError = error as AxiosError;
|
if (error.response?.status === 404) {
|
||||||
if (axiosError.response?.status === 404) {
|
|
||||||
logger.debug(`[LegacyServer] ✗ Not found (404)`);
|
logger.debug(`[LegacyServer] ✗ Not found (404)`);
|
||||||
} else if (axiosError.response?.status === 403) {
|
} else if (error.response?.status === 403) {
|
||||||
logger.warn(`[LegacyServer] ✗ Forbidden (403)`);
|
logger.warn(`[LegacyServer] ✗ Forbidden (403)`);
|
||||||
} else if (
|
} else if (
|
||||||
axiosError.code === 'ECONNABORTED' ||
|
error.code === 'ECONNABORTED' ||
|
||||||
axiosError.code === 'ETIMEDOUT' ||
|
error.code === 'ETIMEDOUT' ||
|
||||||
axiosError.code === 'ERR_CANCELED'
|
error.code === 'ERR_CANCELED'
|
||||||
) {
|
) {
|
||||||
if (shouldRetry) {
|
if (shouldRetry) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@@ -581,7 +693,7 @@ export class LegacyServer {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Sanitize error message to prevent internal details leakage
|
// Sanitize error message to prevent internal details leakage
|
||||||
const safeMessage = sanitizeErrorMessage(axiosError.message || 'Unknown error');
|
const safeMessage = sanitizeErrorMessage(error.message || 'Unknown error');
|
||||||
if (shouldRetry) {
|
if (shouldRetry) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[LegacyServer] ✗ Error: ${safeMessage} (attempt ${attempt}/${MAX_RETRIES})`
|
`[LegacyServer] ✗ Error: ${safeMessage} (attempt ${attempt}/${MAX_RETRIES})`
|
||||||
@@ -631,9 +743,12 @@ export class LegacyServer {
|
|||||||
private getHostnameVariations(hostname: string): string[] {
|
private getHostnameVariations(hostname: string): string[] {
|
||||||
const variations: string[] = [hostname];
|
const variations: string[] = [hostname];
|
||||||
|
|
||||||
// Add subdomain variations
|
// Add subdomain variations (skip if hostname already has that prefix)
|
||||||
for (const subdomain of COMMON_SUBDOMAINS) {
|
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;
|
return variations;
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
* Includes 199 custom MIME types for legacy web content
|
* 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
|
// Legacy multimedia formats
|
||||||
aab: 'application/x-authorware-bin',
|
aab: 'application/x-authorware-bin',
|
||||||
aam: 'application/x-authorware-map',
|
aam: 'application/x-authorware-map',
|
||||||
@@ -182,7 +182,6 @@ export const CUSTOM_MIME_TYPES: { [key: string]: string } = {
|
|||||||
const STANDARD_MIME_TYPES: { [key: string]: string } = {
|
const STANDARD_MIME_TYPES: { [key: string]: string } = {
|
||||||
// Text
|
// Text
|
||||||
txt: 'text/plain',
|
txt: 'text/plain',
|
||||||
css: 'text/css',
|
|
||||||
csv: 'text/csv',
|
csv: 'text/csv',
|
||||||
|
|
||||||
// Images
|
// Images
|
||||||
@@ -253,26 +252,3 @@ export function getMimeType(extension: string): string {
|
|||||||
// Default to octet-stream for unknown types
|
// Default to octet-stream for unknown types
|
||||||
return 'application/octet-stream';
|
return 'application/octet-stream';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a file extension is a gzipped type
|
|
||||||
*/
|
|
||||||
export function isGzippedType(extension: string): boolean {
|
|
||||||
const gzippedTypes = ['svgz'];
|
|
||||||
return gzippedTypes.includes(extension.toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a file extension is a script type
|
|
||||||
*/
|
|
||||||
export function isScriptType(extension: string): boolean {
|
|
||||||
const scriptTypes = ['php', 'php5', 'phtml', 'pl'];
|
|
||||||
return scriptTypes.includes(extension.toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get list of index file types
|
|
||||||
*/
|
|
||||||
export function getIndexFileTypes(): string[] {
|
|
||||||
return ['html', 'htm', 'php', 'php5', 'phtml', 'asp', 'aspx', 'jsp', 'jspx'];
|
|
||||||
}
|
|
||||||
+116
-109
@@ -1,26 +1,39 @@
|
|||||||
import { IncomingMessage, ServerResponse } from 'http';
|
import { IncomingMessage, ServerResponse } from 'http';
|
||||||
import { URL } from 'url';
|
import path from 'path';
|
||||||
import { Readable } from 'stream';
|
import { logger } from '../utils/logger';
|
||||||
import { logger } from './utils/logger';
|
|
||||||
import { ConfigManager } from './config';
|
import { ConfigManager } from './config';
|
||||||
import { LegacyServer, HttpRequestContext, LegacyFileResponse } from './legacy-server';
|
import { LegacyServer, HttpRequestContext, LegacyFileResponse } from './legacy-server';
|
||||||
import { injectPolyfills } from './utils/htmlInjector';
|
import { injectPolyfills } from './utils/htmlInjector';
|
||||||
import { sanitizeErrorMessage, sanitizeUrlPath } from './utils/pathSecurity';
|
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)
|
// Maximum request body size for CGI (10MB)
|
||||||
const MAX_REQUEST_BODY_SIZE = 10 * 1024 * 1024;
|
const MAX_REQUEST_BODY_SIZE = 10 * 1024 * 1024;
|
||||||
|
|
||||||
// Internal request timeout constants (G-H2)
|
// 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
|
const STREAM_TIMEOUT_MS = 120000; // 2 minutes for streaming responses
|
||||||
|
|
||||||
// Cache control constants
|
// Cache control constants
|
||||||
const CACHE_MAX_AGE_SECONDS = 86400; // 24 hours (in seconds)
|
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 {
|
export class ProxyRequestHandler {
|
||||||
private legacyServer: LegacyServer;
|
private legacyServer: LegacyServer;
|
||||||
private settings = ConfigManager.getSettings();
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.legacyServer = new LegacyServer();
|
this.legacyServer = new LegacyServer();
|
||||||
@@ -42,18 +55,6 @@ export class ProxyRequestHandler {
|
|||||||
return;
|
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 targetUrl: URL;
|
||||||
let hostname: string;
|
let hostname: string;
|
||||||
let urlPath: string;
|
let urlPath: string;
|
||||||
@@ -87,10 +88,21 @@ export class ProxyRequestHandler {
|
|||||||
targetUrl = new URL(`http://${hostname}${urlPath}`);
|
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}`);
|
logger.info(`[ProxyHandler] Parsed - Host: ${hostname}, Path: ${urlPath}`);
|
||||||
|
|
||||||
// Step 1: Try GameZip server first (if enabled)
|
// Step 1: Try GameZip server first (in-process)
|
||||||
if (this.settings.gameZipPort) {
|
{
|
||||||
logger.debug(`[ProxyHandler] Trying GameZip server...`);
|
logger.debug(`[ProxyHandler] Trying GameZip server...`);
|
||||||
const gameZipResult = await this.tryGameZipServer(hostname, urlPath);
|
const gameZipResult = await this.tryGameZipServer(hostname, urlPath);
|
||||||
if (gameZipResult) {
|
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);
|
const requestContext = await this.buildRequestContext(req);
|
||||||
|
|
||||||
// Step 3: Try legacy file serving (local htdocs + external fallbacks)
|
|
||||||
logger.debug(`[ProxyHandler] Trying legacy server...`);
|
logger.debug(`[ProxyHandler] Trying legacy server...`);
|
||||||
const legacyResult = await this.legacyServer.serveLegacy(hostname, urlPath, requestContext);
|
const legacyResult = await this.legacyServer.serveLegacy(hostname, urlPath, requestContext);
|
||||||
|
|
||||||
@@ -115,58 +126,45 @@ export class ProxyRequestHandler {
|
|||||||
const safeMessage = sanitizeErrorMessage(errorMessage);
|
const safeMessage = sanitizeErrorMessage(errorMessage);
|
||||||
logger.error(`[ProxyHandler] Error handling request: ${safeMessage}`);
|
logger.error(`[ProxyHandler] Error handling request: ${safeMessage}`);
|
||||||
|
|
||||||
if (error instanceof Error && error.message.includes('not found')) {
|
// Never expose internal error details to client (G-H4)
|
||||||
this.sendError(res, 404, 'File not found');
|
this.sendError(res, 500, 'Internal server error');
|
||||||
} else {
|
|
||||||
// Never expose internal error details to client (G-H4)
|
|
||||||
this.sendError(res, 500, 'Internal server error');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Try to fetch from GameZip server
|
* Try to find file from GameZip server (direct in-process call)
|
||||||
* Note: GameZip server responses are kept buffered since they come from local ZIP archives
|
* Returns the file data if found in a mounted ZIP, or null to fall through
|
||||||
* and are typically already compressed/optimized
|
* to the legacy server fallback chain.
|
||||||
*
|
|
||||||
* Implements timeout enforcement (G-H2)
|
|
||||||
*/
|
*/
|
||||||
private async tryGameZipServer(
|
private async tryGameZipServer(
|
||||||
hostname: string,
|
hostname: string,
|
||||||
urlPath: string
|
urlPath: string
|
||||||
): Promise<{ data: Buffer; contentType: string } | null> {
|
): Promise<{ data: Buffer; contentType: string; statusCode: number } | null> {
|
||||||
// Create abort controller for request timeout (G-H2)
|
|
||||||
const abortController = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
abortController.abort();
|
|
||||||
}, GAMEZIP_REQUEST_TIMEOUT_MS);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Make proxy-style request to GameZip server
|
const pathWithoutQuery = urlPath.split('?')[0];
|
||||||
// The GameZip server also accepts proxy-style requests: GET http://hostname/path HTTP/1.1
|
const relPath = path.posix.join(hostname, pathWithoutQuery);
|
||||||
const gameZipServerUrl = `http://localhost:${this.settings.gameZipPort}`;
|
|
||||||
const targetUrl = `http://${hostname}${urlPath}`;
|
|
||||||
const requestUrl = `${gameZipServerUrl}/${targetUrl}`;
|
|
||||||
|
|
||||||
const response = await axios.get(requestUrl, {
|
logger.debug(`[ProxyHandler] Searching mounted ZIPs for: ${relPath}`);
|
||||||
responseType: 'arraybuffer',
|
|
||||||
timeout: GAMEZIP_REQUEST_TIMEOUT_MS,
|
|
||||||
signal: abortController.signal,
|
|
||||||
validateStatus: (status) => status === 200,
|
|
||||||
proxy: false, // Don't use system proxy
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = Buffer.from(response.data);
|
const result = await zipManager.findFile(relPath);
|
||||||
const contentType = response.headers['content-type'] || 'application/octet-stream';
|
|
||||||
|
|
||||||
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) {
|
} catch (error) {
|
||||||
// GameZip server not available or file not found
|
logger.debug(`[ProxyHandler] GameZip lookup failed`);
|
||||||
// Don't log detailed errors to avoid path leakage (G-H4)
|
|
||||||
logger.debug(`[ProxyHandler] GameZip unavailable or file not found`);
|
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,27 +172,31 @@ export class ProxyRequestHandler {
|
|||||||
* Send successful response (supports both buffered and streamed responses)
|
* Send successful response (supports both buffered and streamed responses)
|
||||||
*/
|
*/
|
||||||
private sendResponse(res: ServerResponse, result: LegacyFileResponse, source: string): void {
|
private sendResponse(res: ServerResponse, result: LegacyFileResponse, source: string): void {
|
||||||
|
const settings = ConfigManager.getSettings();
|
||||||
const contentType = result.contentType;
|
const contentType = result.contentType;
|
||||||
const statusCode = result.statusCode || 200;
|
const statusCode = result.statusCode || 200;
|
||||||
|
|
||||||
// CORS headers
|
// CORS headers
|
||||||
if (this.settings.allowCrossDomain) {
|
setCorsHeaders(res, settings);
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
||||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
||||||
res.setHeader('Access-Control-Allow-Headers', '*');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Common headers
|
// Common headers
|
||||||
res.setHeader('Cache-Control', `public, max-age=${CACHE_MAX_AGE_SECONDS}`);
|
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');
|
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) {
|
if (result.headers) {
|
||||||
for (const [key, value] of Object.entries(result.headers)) {
|
for (const [key, value] of Object.entries(result.headers)) {
|
||||||
// Skip headers that we handle separately
|
|
||||||
const lowerKey = key.toLowerCase();
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
res.setHeader(key, value);
|
res.setHeader(key, value);
|
||||||
@@ -219,6 +221,14 @@ export class ProxyRequestHandler {
|
|||||||
}
|
}
|
||||||
}, STREAM_TIMEOUT_MS);
|
}, 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.pipe(res);
|
||||||
|
|
||||||
result.stream.on('error', (err) => {
|
result.stream.on('error', (err) => {
|
||||||
@@ -229,7 +239,9 @@ export class ProxyRequestHandler {
|
|||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.writeHead(500);
|
res.writeHead(500);
|
||||||
}
|
}
|
||||||
res.end();
|
if (!res.writableEnded) {
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
result.stream.on('end', () => {
|
result.stream.on('end', () => {
|
||||||
@@ -262,15 +274,19 @@ export class ProxyRequestHandler {
|
|||||||
* Send error response
|
* Send error response
|
||||||
*/
|
*/
|
||||||
private sendError(res: ServerResponse, statusCode: number, message: string): void {
|
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}`);
|
logger.warn(`[ProxyHandler] Sending error ${statusCode}: ${message}`);
|
||||||
|
|
||||||
|
const settings = ConfigManager.getSettings();
|
||||||
// IMPORTANT: Set CORS headers even for error responses
|
// IMPORTANT: Set CORS headers even for error responses
|
||||||
// Otherwise browsers will show CORS error instead of the actual error
|
// Otherwise browsers will show CORS error instead of the actual error
|
||||||
if (this.settings.allowCrossDomain) {
|
setCorsHeaders(res, settings);
|
||||||
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.setHeader('Content-Type', 'text/plain');
|
||||||
res.writeHead(statusCode);
|
res.writeHead(statusCode);
|
||||||
@@ -281,10 +297,10 @@ export class ProxyRequestHandler {
|
|||||||
* Handle OPTIONS request for CORS preflight
|
* Handle OPTIONS request for CORS preflight
|
||||||
*/
|
*/
|
||||||
handleOptionsRequest(req: IncomingMessage, res: ServerResponse): void {
|
handleOptionsRequest(req: IncomingMessage, res: ServerResponse): void {
|
||||||
if (this.settings.allowCrossDomain) {
|
const settings = ConfigManager.getSettings();
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
setCorsHeaders(res, settings);
|
||||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
||||||
res.setHeader('Access-Control-Allow-Headers', '*');
|
if (settings.allowCrossDomain) {
|
||||||
res.setHeader('Access-Control-Max-Age', CACHE_MAX_AGE_SECONDS.toString());
|
res.setHeader('Access-Control-Max-Age', CACHE_MAX_AGE_SECONDS.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,47 +343,38 @@ export class ProxyRequestHandler {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
let totalSize = 0;
|
let totalSize = 0;
|
||||||
let completed = false;
|
let settled = false;
|
||||||
|
|
||||||
// Timeout for body collection (30 seconds)
|
const settle = <T>(fn: () => T): T | undefined => {
|
||||||
const timeoutHandle = setTimeout(() => {
|
if (!settled) {
|
||||||
if (!completed) {
|
settled = true;
|
||||||
completed = true;
|
clearTimeout(timeoutHandle);
|
||||||
req.destroy(new Error('Request body collection timed out'));
|
return fn();
|
||||||
reject(new Error('Request body collection timed out'));
|
|
||||||
}
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeoutHandle = setTimeout(() => {
|
||||||
|
settle(() => {
|
||||||
|
req.removeAllListeners('data');
|
||||||
|
reject(new Error('Request body collection timed out'));
|
||||||
|
});
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
req.on('data', (chunk: Buffer) => {
|
req.on('data', (chunk: Buffer) => {
|
||||||
if (completed) return;
|
if (settled) return;
|
||||||
totalSize += chunk.length;
|
totalSize += chunk.length;
|
||||||
if (totalSize > MAX_REQUEST_BODY_SIZE) {
|
if (totalSize > MAX_REQUEST_BODY_SIZE) {
|
||||||
completed = true;
|
settle(() => reject(new Error('Request body too large')));
|
||||||
clearTimeout(timeoutHandle);
|
|
||||||
req.destroy(new Error('Request body too large'));
|
|
||||||
reject(new Error('Request body too large'));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
chunks.push(chunk);
|
chunks.push(chunk);
|
||||||
});
|
});
|
||||||
|
|
||||||
req.on('end', () => {
|
req.on('end', () =>
|
||||||
if (completed) return;
|
settle(() => resolve(chunks.length === 0 ? undefined : Buffer.concat(chunks)))
|
||||||
completed = true;
|
);
|
||||||
clearTimeout(timeoutHandle);
|
req.on('error', (error) => settle(() => reject(error)));
|
||||||
if (chunks.length === 0) {
|
|
||||||
resolve(undefined);
|
|
||||||
} else {
|
|
||||||
resolve(Buffer.concat(chunks));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
req.on('error', (error) => {
|
|
||||||
if (completed) return;
|
|
||||||
completed = true;
|
|
||||||
clearTimeout(timeoutHandle);
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,642 @@
|
|||||||
|
import fs from 'fs/promises';
|
||||||
|
import { createWriteStream, createReadStream } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import https from 'https';
|
||||||
|
import http from 'http';
|
||||||
|
import net from 'net';
|
||||||
|
import dns from 'dns';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
import { PreferencesService, GameDataSource } from './PreferencesService';
|
||||||
|
|
||||||
|
const dnsLookup = promisify(dns.lookup);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parameters for downloading a game data ZIP
|
||||||
|
*/
|
||||||
|
export interface GameDataDownloadParams {
|
||||||
|
gameId: string;
|
||||||
|
dateAdded: string; // ISO date string from game_data table
|
||||||
|
sha256?: string; // Expected SHA256 hash for verification
|
||||||
|
targetPath?: string; // Override target path (default: Data/Games/)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a download attempt
|
||||||
|
*/
|
||||||
|
export interface DownloadResult {
|
||||||
|
success: boolean;
|
||||||
|
filePath?: string;
|
||||||
|
error?: string;
|
||||||
|
sourceUsed?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progress callback for download tracking
|
||||||
|
*/
|
||||||
|
export type DownloadProgressCallback = (
|
||||||
|
downloadedBytes: number,
|
||||||
|
totalBytes: number | null,
|
||||||
|
sourceName: string
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for downloading game data ZIPs from configured sources
|
||||||
|
* Implements the same logic as Flashpoint Launcher's download.ts
|
||||||
|
*/
|
||||||
|
export class GameDataDownloader {
|
||||||
|
private static instance: GameDataDownloader;
|
||||||
|
private readonly MAX_RETRIES = 3;
|
||||||
|
private readonly RETRY_DELAY_MS = 1000;
|
||||||
|
private readonly DOWNLOAD_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
|
private readonly MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB max
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Private/reserved IPv4 and IPv6 CIDR ranges that must be blocked
|
||||||
|
* to prevent SSRF attacks targeting internal services.
|
||||||
|
*/
|
||||||
|
private static readonly PRIVATE_IP_RANGES: Array<{
|
||||||
|
subnet: string;
|
||||||
|
prefix: number;
|
||||||
|
family: 4 | 6;
|
||||||
|
}> = [
|
||||||
|
// IPv4 private/reserved ranges
|
||||||
|
{ subnet: '127.0.0.0', prefix: 8, family: 4 }, // Loopback
|
||||||
|
{ subnet: '10.0.0.0', prefix: 8, family: 4 }, // Private Class A
|
||||||
|
{ subnet: '172.16.0.0', prefix: 12, family: 4 }, // Private Class B
|
||||||
|
{ subnet: '192.168.0.0', prefix: 16, family: 4 }, // Private Class C
|
||||||
|
{ subnet: '169.254.0.0', prefix: 16, family: 4 }, // Link-local / cloud metadata
|
||||||
|
{ subnet: '0.0.0.0', prefix: 8, family: 4 }, // Current network
|
||||||
|
// IPv6 private/reserved ranges
|
||||||
|
{ subnet: '::1', prefix: 128, family: 6 }, // Loopback
|
||||||
|
{ subnet: 'fc00::', prefix: 7, family: 6 }, // Unique local (fc00::/7)
|
||||||
|
{ subnet: 'fe80::', prefix: 10, family: 6 }, // Link-local
|
||||||
|
];
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
static getInstance(): GameDataDownloader {
|
||||||
|
if (!GameDataDownloader.instance) {
|
||||||
|
GameDataDownloader.instance = new GameDataDownloader();
|
||||||
|
}
|
||||||
|
return GameDataDownloader.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the filename for a game data ZIP
|
||||||
|
* Matches Flashpoint Launcher's getGameDataFilename() logic
|
||||||
|
*/
|
||||||
|
static getFilename(gameId: string, dateAdded: string): string {
|
||||||
|
// Handle different date formats (with or without 'T')
|
||||||
|
let cleanDate: string;
|
||||||
|
if (dateAdded.includes('T')) {
|
||||||
|
cleanDate = dateAdded;
|
||||||
|
} else {
|
||||||
|
// Insert 'T' between date and time at position 10
|
||||||
|
cleanDate = dateAdded.slice(0, 10) + 'T' + dateAdded.slice(11);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date(cleanDate).getTime();
|
||||||
|
|
||||||
|
// Validate timestamp with fallback
|
||||||
|
if (isNaN(timestamp)) {
|
||||||
|
// Try fallback: append UTC suffix
|
||||||
|
const fallbackDate = `${dateAdded} +0000 UTC`;
|
||||||
|
const fallbackTimestamp = new Date(fallbackDate).getTime();
|
||||||
|
if (!isNaN(fallbackTimestamp)) {
|
||||||
|
return `${gameId}-${fallbackTimestamp}.zip`;
|
||||||
|
}
|
||||||
|
throw new Error(`Invalid dateAdded value: ${dateAdded}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${gameId}-${timestamp}.zip`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a game data ZIP from configured sources
|
||||||
|
* Tries each source sequentially until one succeeds
|
||||||
|
*/
|
||||||
|
async download(
|
||||||
|
params: GameDataDownloadParams,
|
||||||
|
onProgress?: DownloadProgressCallback
|
||||||
|
): Promise<DownloadResult> {
|
||||||
|
const { gameId, dateAdded, sha256 } = params;
|
||||||
|
const filename = GameDataDownloader.getFilename(gameId, dateAdded);
|
||||||
|
|
||||||
|
logger.info(`[GameDataDownloader] Starting download for ${filename}`);
|
||||||
|
|
||||||
|
// Get configured sources
|
||||||
|
const prefsService = PreferencesService.getInstance();
|
||||||
|
const sources = await prefsService.getGameDataSources();
|
||||||
|
|
||||||
|
if (sources.length === 0) {
|
||||||
|
logger.error('[GameDataDownloader] No game data sources configured in preferences.json');
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'No game data sources configured. Add gameDataSources to preferences.json.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine target path
|
||||||
|
const targetDir = params.targetPath || (await prefsService.getDataPacksFolderPath());
|
||||||
|
const targetPath = path.join(targetDir, filename);
|
||||||
|
const tempPath = `${targetPath}.temp`;
|
||||||
|
|
||||||
|
// Ensure target directory exists
|
||||||
|
try {
|
||||||
|
await fs.mkdir(targetDir, { recursive: true });
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
return { success: false, error: `Failed to create directory: ${errorMsg}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try each source sequentially
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (const source of sources) {
|
||||||
|
// Check if source has arguments and at least one URL
|
||||||
|
if (!source.arguments?.length || !source.arguments[0]) {
|
||||||
|
logger.warn(`[GameDataDownloader] Source "${source.name}" has no arguments, skipping`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let baseUrl = source.arguments[0];
|
||||||
|
|
||||||
|
// Enforce HTTPS for download sources
|
||||||
|
if (new URL(baseUrl).protocol === 'http:') {
|
||||||
|
const secureUrl = baseUrl.replace(/^http:/, 'https:');
|
||||||
|
logger.warn(`[GameDataDownloader] Upgrading insecure source URL to HTTPS: ${baseUrl}`);
|
||||||
|
baseUrl = secureUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize baseUrl trailing slash before URL construction
|
||||||
|
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/';
|
||||||
|
const fullUrl = new URL(filename, normalizedBase).href;
|
||||||
|
logger.info(`[GameDataDownloader] Trying source "${source.name}": ${fullUrl}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Download to temp file
|
||||||
|
await this.downloadFile(fullUrl, tempPath, source.name, onProgress);
|
||||||
|
|
||||||
|
// Verify SHA256 if provided
|
||||||
|
if (sha256) {
|
||||||
|
const fileHash = await this.calculateSha256(tempPath);
|
||||||
|
if (fileHash.toLowerCase() !== sha256.toLowerCase()) {
|
||||||
|
logger.error(
|
||||||
|
`[GameDataDownloader] SHA256 mismatch! Expected: ${sha256}, Got: ${fileHash}`
|
||||||
|
);
|
||||||
|
await this.safeUnlink(tempPath);
|
||||||
|
errors.push(
|
||||||
|
`Source "${source.name}": SHA256 mismatch (expected ${sha256.substring(0, 8)}..., got ${fileHash.substring(0, 8)}...)`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
logger.info(`[GameDataDownloader] SHA256 verified: ${fileHash.substring(0, 16)}...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move temp file to final location
|
||||||
|
await fs.rename(tempPath, targetPath);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[GameDataDownloader] Successfully downloaded ${filename} from "${source.name}"`
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
filePath: targetPath,
|
||||||
|
sourceUsed: source.name,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error(`[GameDataDownloader] Source "${source.name}" failed: ${errorMsg}`);
|
||||||
|
errors.push(`Source "${source.name}": ${errorMsg}`);
|
||||||
|
|
||||||
|
// Clean up temp file if exists
|
||||||
|
await this.safeUnlink(tempPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All sources failed
|
||||||
|
const errorSummary = errors.join('\n');
|
||||||
|
logger.error(`[GameDataDownloader] All sources failed for ${filename}:\n${errorSummary}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Failed to download from all ${sources.length} sources:\n${errorSummary}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a file from URL to local path with retry logic
|
||||||
|
*/
|
||||||
|
private async downloadFile(
|
||||||
|
url: string,
|
||||||
|
destPath: string,
|
||||||
|
sourceName: string,
|
||||||
|
onProgress?: DownloadProgressCallback
|
||||||
|
): Promise<void> {
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= this.MAX_RETRIES; attempt++) {
|
||||||
|
try {
|
||||||
|
await this.downloadFileOnce(url, destPath, sourceName, onProgress);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
logger.warn(
|
||||||
|
`[GameDataDownloader] Attempt ${attempt}/${this.MAX_RETRIES} failed: ${lastError.message}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (attempt < this.MAX_RETRIES) {
|
||||||
|
const delay = this.RETRY_DELAY_MS * attempt;
|
||||||
|
logger.info(`[GameDataDownloader] Retrying in ${delay}ms...`);
|
||||||
|
await this.sleep(delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError || new Error('Download failed after all retries');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single download attempt with SSRF protection and double-settlement guard
|
||||||
|
*/
|
||||||
|
private async downloadFileOnce(
|
||||||
|
url: string,
|
||||||
|
destPath: string,
|
||||||
|
sourceName: string,
|
||||||
|
onProgress?: DownloadProgressCallback,
|
||||||
|
maxRedirects: number = 5
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise(async (resolve, reject) => {
|
||||||
|
// Guard against double-settlement: the 'data' handler can reject on size
|
||||||
|
// limit while the piped writeStream 'finish' event can still fire afterward.
|
||||||
|
let settled = false;
|
||||||
|
const safeResolve = () => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const safeReject = (error: Error) => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (maxRedirects <= 0) {
|
||||||
|
safeReject(new Error('Too many redirects'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate initial URL protocol - only HTTP(S) allowed
|
||||||
|
const parsedUrl = new URL(url);
|
||||||
|
if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') {
|
||||||
|
safeReject(
|
||||||
|
new Error(
|
||||||
|
`Unsupported protocol: ${parsedUrl.protocol} - only http: and https: are allowed`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate initial URL is not targeting a private/reserved IP
|
||||||
|
if (this.isPrivateHost(parsedUrl.hostname)) {
|
||||||
|
logger.warn(
|
||||||
|
`[GameDataDownloader] Blocked download to private/reserved address: ${parsedUrl.hostname}`
|
||||||
|
);
|
||||||
|
safeReject(
|
||||||
|
new Error(
|
||||||
|
`Download blocked: ${parsedUrl.hostname} resolves to a private/reserved address`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNS rebinding protection: resolve hostname and check for private IPs
|
||||||
|
const hostname = parsedUrl.hostname;
|
||||||
|
if (!net.isIPv4(hostname) && !net.isIPv6(hostname)) {
|
||||||
|
// It's a hostname, not an IP - perform DNS lookup
|
||||||
|
try {
|
||||||
|
const { address } = await dnsLookup(hostname);
|
||||||
|
if (this.isPrivateIPv4(address) || this.isPrivateIPv6(address)) {
|
||||||
|
logger.warn(
|
||||||
|
`[GameDataDownloader] Blocked: ${hostname} resolves to private address ${address}`
|
||||||
|
);
|
||||||
|
safeReject(new Error(`Blocked: ${hostname} resolves to private address ${address}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.startsWith('Blocked:')) {
|
||||||
|
safeReject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// DNS lookup failed - allow the request to proceed (it will fail on connect anyway)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = parsedUrl.protocol === 'https:' ? https : http;
|
||||||
|
let writeStream: ReturnType<typeof createWriteStream> | null = null;
|
||||||
|
|
||||||
|
const request = protocol.get(url, { timeout: this.DOWNLOAD_TIMEOUT_MS }, (response) => {
|
||||||
|
// Handle redirects
|
||||||
|
if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400) {
|
||||||
|
const redirectUrl = response.headers.location;
|
||||||
|
if (redirectUrl) {
|
||||||
|
response.resume();
|
||||||
|
|
||||||
|
// Validate redirect URL before following
|
||||||
|
try {
|
||||||
|
this.validateRedirectUrl(redirectUrl);
|
||||||
|
} catch (err) {
|
||||||
|
safeReject(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[GameDataDownloader] Following redirect to: ${redirectUrl}`);
|
||||||
|
this.downloadFileOnce(redirectUrl, destPath, sourceName, onProgress, maxRedirects - 1)
|
||||||
|
.then(safeResolve)
|
||||||
|
.catch(safeReject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for success status
|
||||||
|
if (response.statusCode !== 200) {
|
||||||
|
response.resume();
|
||||||
|
safeReject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check content length
|
||||||
|
const contentLength = response.headers['content-length'];
|
||||||
|
const totalBytes = contentLength ? parseInt(contentLength, 10) : null;
|
||||||
|
|
||||||
|
if (totalBytes && totalBytes > this.MAX_FILE_SIZE) {
|
||||||
|
response.resume(); // Drain the response
|
||||||
|
safeReject(new Error(`File too large: ${totalBytes} bytes (max: ${this.MAX_FILE_SIZE})`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create write stream
|
||||||
|
writeStream = createWriteStream(destPath);
|
||||||
|
let downloadedBytes = 0;
|
||||||
|
|
||||||
|
response.on('data', (chunk: Buffer) => {
|
||||||
|
downloadedBytes += chunk.length;
|
||||||
|
|
||||||
|
// Check size limit during download
|
||||||
|
if (downloadedBytes > this.MAX_FILE_SIZE) {
|
||||||
|
response.destroy();
|
||||||
|
request.destroy();
|
||||||
|
if (writeStream) {
|
||||||
|
writeStream.destroy();
|
||||||
|
}
|
||||||
|
safeReject(new Error(`Download exceeded size limit: ${this.MAX_FILE_SIZE} bytes`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress(downloadedBytes, totalBytes, sourceName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
response.pipe(writeStream);
|
||||||
|
|
||||||
|
writeStream.on('finish', () => {
|
||||||
|
logger.info(
|
||||||
|
`[GameDataDownloader] Downloaded ${downloadedBytes} bytes from "${sourceName}"`
|
||||||
|
);
|
||||||
|
safeResolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
writeStream.on('error', (error) => {
|
||||||
|
safeReject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
response.on('error', (error) => {
|
||||||
|
if (writeStream) {
|
||||||
|
writeStream.destroy();
|
||||||
|
}
|
||||||
|
request.destroy();
|
||||||
|
safeReject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
request.on('error', (error) => {
|
||||||
|
if (writeStream) {
|
||||||
|
writeStream.destroy();
|
||||||
|
}
|
||||||
|
safeReject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
request.on('timeout', () => {
|
||||||
|
if (writeStream) {
|
||||||
|
writeStream.destroy(); // Also close the file handle
|
||||||
|
}
|
||||||
|
request.destroy();
|
||||||
|
safeReject(new Error(`Download timed out after ${this.DOWNLOAD_TIMEOUT_MS}ms`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a redirect URL to prevent SSRF attacks.
|
||||||
|
* Rejects non-HTTP(S) protocols and private/reserved IP addresses.
|
||||||
|
* Throws an Error if the URL is not safe to follow.
|
||||||
|
*/
|
||||||
|
private validateRedirectUrl(redirectUrl: string): void {
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(redirectUrl);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Invalid redirect URL: ${redirectUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow http: and https: protocols
|
||||||
|
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
||||||
|
logger.warn(
|
||||||
|
`[GameDataDownloader] Blocked redirect to disallowed protocol: ${parsed.protocol} (${redirectUrl})`
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`Redirect blocked: unsupported protocol ${parsed.protocol} - only http: and https: are allowed`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block private/reserved IP addresses
|
||||||
|
if (this.isPrivateHost(parsed.hostname)) {
|
||||||
|
logger.warn(
|
||||||
|
`[GameDataDownloader] Blocked redirect to private/reserved address: ${parsed.hostname} (${redirectUrl})`
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`Redirect blocked: ${parsed.hostname} resolves to a private/reserved address`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a hostname is a private or reserved IP address.
|
||||||
|
* Handles both IPv4 and IPv6, including bracketed IPv6 notation.
|
||||||
|
*/
|
||||||
|
private isPrivateHost(hostname: string): boolean {
|
||||||
|
// Strip brackets from IPv6 addresses (e.g., [::1] -> ::1)
|
||||||
|
const cleanHostname = hostname.replace(/^\[|\]$/g, '');
|
||||||
|
|
||||||
|
// Check for 'localhost' hostname
|
||||||
|
if (cleanHostname.toLowerCase() === 'localhost') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse as IPv4
|
||||||
|
if (net.isIPv4(cleanHostname)) {
|
||||||
|
return this.isPrivateIPv4(cleanHostname);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse as IPv6
|
||||||
|
if (net.isIPv6(cleanHostname)) {
|
||||||
|
return this.isPrivateIPv6(cleanHostname);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not an IP literal - it is a hostname.
|
||||||
|
// DNS resolution could map to a private IP, but performing async DNS
|
||||||
|
// lookup here is impractical in a synchronous validation path.
|
||||||
|
// The redirect-hop limit (5) plus the protocol check provides defense-in-depth.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an IPv4 address falls within a private/reserved CIDR range
|
||||||
|
*/
|
||||||
|
private isPrivateIPv4(ip: string): boolean {
|
||||||
|
const ipNum = this.ipv4ToNumber(ip);
|
||||||
|
if (ipNum === null) return false;
|
||||||
|
|
||||||
|
for (const range of GameDataDownloader.PRIVATE_IP_RANGES) {
|
||||||
|
if (range.family !== 4) continue;
|
||||||
|
const subnetNum = this.ipv4ToNumber(range.subnet);
|
||||||
|
if (subnetNum === null) continue;
|
||||||
|
|
||||||
|
const mask = range.prefix === 0 ? 0 : (~0 << (32 - range.prefix)) >>> 0;
|
||||||
|
if ((ipNum & mask) === (subnetNum & mask)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an IPv6 address falls within a private/reserved range
|
||||||
|
*/
|
||||||
|
private isPrivateIPv6(ip: string): boolean {
|
||||||
|
const normalized = ip.toLowerCase();
|
||||||
|
|
||||||
|
// Exact loopback check
|
||||||
|
if (normalized === '::1') return true;
|
||||||
|
|
||||||
|
// Unique local addresses: fc00::/7 (fc00:: - fdff::)
|
||||||
|
if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true;
|
||||||
|
|
||||||
|
// Link-local: fe80::/10
|
||||||
|
if (normalized.startsWith('fe80')) return true;
|
||||||
|
|
||||||
|
// IPv4-mapped IPv6 (::ffff:x.x.x.x) - check the embedded IPv4
|
||||||
|
const v4mappedMatch = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
||||||
|
if (v4mappedMatch) {
|
||||||
|
return this.isPrivateIPv4(v4mappedMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an IPv4 address string to a 32-bit unsigned integer
|
||||||
|
*/
|
||||||
|
private ipv4ToNumber(ip: string): number | null {
|
||||||
|
const parts = ip.split('.');
|
||||||
|
if (parts.length !== 4) return null;
|
||||||
|
|
||||||
|
let num = 0;
|
||||||
|
for (const part of parts) {
|
||||||
|
const octet = parseInt(part, 10);
|
||||||
|
if (isNaN(octet) || octet < 0 || octet > 255) return null;
|
||||||
|
num = (num * 256 + octet) >>> 0;
|
||||||
|
}
|
||||||
|
return num;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate SHA256 hash of a file
|
||||||
|
*/
|
||||||
|
private calculateSha256(filePath: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const hash = crypto.createHash('sha256');
|
||||||
|
const stream = createReadStream(filePath);
|
||||||
|
|
||||||
|
stream.on('data', (data) => hash.update(data));
|
||||||
|
stream.on('end', () => resolve(hash.digest('hex').toUpperCase()));
|
||||||
|
stream.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely delete a file (ignore errors if file doesn't exist)
|
||||||
|
*/
|
||||||
|
private async safeUnlink(filePath: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fs.unlink(filePath);
|
||||||
|
} catch {
|
||||||
|
// Ignore errors (file may not exist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleep for specified milliseconds
|
||||||
|
*/
|
||||||
|
private sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a game data ZIP exists locally
|
||||||
|
*/
|
||||||
|
async exists(gameId: string, dateAdded: string, targetDir?: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const filename = GameDataDownloader.getFilename(gameId, dateAdded);
|
||||||
|
const prefsService = PreferencesService.getInstance();
|
||||||
|
const dir = targetDir || (await prefsService.getDataPacksFolderPath());
|
||||||
|
const filePath = path.join(dir, filename);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(filePath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// getFilename() can throw on invalid dateAdded
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the full path to a game data ZIP
|
||||||
|
*/
|
||||||
|
async getFilePath(gameId: string, dateAdded: string, targetDir?: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const filename = GameDataDownloader.getFilename(gameId, dateAdded);
|
||||||
|
const prefsService = PreferencesService.getInstance();
|
||||||
|
const dir = targetDir || (await prefsService.getDataPacksFolderPath());
|
||||||
|
return path.join(dir, filename);
|
||||||
|
} catch (error) {
|
||||||
|
// getFilename() can throw on invalid dateAdded
|
||||||
|
throw new Error(
|
||||||
|
`Invalid dateAdded value: ${dateAdded} - ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const gameDataDownloader = GameDataDownloader.getInstance();
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Game data source configuration from preferences.json
|
||||||
|
* Used for downloading game ZIPs when not present locally
|
||||||
|
*/
|
||||||
|
export interface GameDataSource {
|
||||||
|
type: string; // Currently only "raw" is used
|
||||||
|
name: string; // Display name for the source
|
||||||
|
arguments: string[]; // Array of arguments, first is the base URL
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relevant fields from Flashpoint preferences.json
|
||||||
|
*/
|
||||||
|
export interface FlashpointPreferences {
|
||||||
|
gameDataSources: GameDataSource[];
|
||||||
|
dataPacksFolderPath: string;
|
||||||
|
onDemandBaseUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for reading Flashpoint preferences.json
|
||||||
|
* Provides access to gameDataSources for downloading game ZIPs
|
||||||
|
*/
|
||||||
|
export class PreferencesService {
|
||||||
|
private static instance: PreferencesService;
|
||||||
|
private preferences: FlashpointPreferences | null = null;
|
||||||
|
private flashpointPath: string;
|
||||||
|
private preferencesPath: string;
|
||||||
|
private lastLoadTime: number = 0;
|
||||||
|
private lastLoadFailed: boolean = false;
|
||||||
|
private readonly CACHE_TTL = 60 * 1000; // 1 minute cache
|
||||||
|
private readonly ERROR_CACHE_TTL = 5000; // 5 seconds cache for errors
|
||||||
|
|
||||||
|
private constructor(flashpointPath: string) {
|
||||||
|
this.flashpointPath = flashpointPath;
|
||||||
|
this.preferencesPath = path.join(flashpointPath, 'preferences.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the PreferencesService singleton
|
||||||
|
*/
|
||||||
|
static initialize(flashpointPath: string): PreferencesService {
|
||||||
|
if (PreferencesService.instance) {
|
||||||
|
if (flashpointPath !== PreferencesService.instance.flashpointPath) {
|
||||||
|
logger.warn(
|
||||||
|
'[PreferencesService] Already initialized with different path, ignoring re-init'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return PreferencesService.instance;
|
||||||
|
}
|
||||||
|
PreferencesService.instance = new PreferencesService(flashpointPath);
|
||||||
|
return PreferencesService.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the singleton instance
|
||||||
|
*/
|
||||||
|
static getInstance(): PreferencesService {
|
||||||
|
if (!PreferencesService.instance) {
|
||||||
|
throw new Error('PreferencesService not initialized. Call initialize() first.');
|
||||||
|
}
|
||||||
|
return PreferencesService.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load preferences from file with caching
|
||||||
|
*/
|
||||||
|
async loadPreferences(): Promise<FlashpointPreferences> {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Use shorter TTL if last load failed
|
||||||
|
const cacheTTL = this.lastLoadFailed ? this.ERROR_CACHE_TTL : this.CACHE_TTL;
|
||||||
|
|
||||||
|
// Return cached preferences if still valid
|
||||||
|
if (this.preferences && now - this.lastLoadTime < cacheTTL) {
|
||||||
|
return this.preferences;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(this.preferencesPath, 'utf-8');
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
|
||||||
|
this.preferences = {
|
||||||
|
gameDataSources: parsed.gameDataSources || [],
|
||||||
|
dataPacksFolderPath: parsed.dataPacksFolderPath || 'Data/Games',
|
||||||
|
onDemandBaseUrl: parsed.onDemandBaseUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.lastLoadTime = now;
|
||||||
|
this.lastLoadFailed = false;
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[PreferencesService] Loaded preferences: ${this.preferences.gameDataSources.length} game data sources`
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.preferences;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[PreferencesService] Failed to load preferences.json:', error);
|
||||||
|
|
||||||
|
// Return defaults if file doesn't exist or is invalid
|
||||||
|
this.preferences = {
|
||||||
|
gameDataSources: [],
|
||||||
|
dataPacksFolderPath: 'Data/Games',
|
||||||
|
};
|
||||||
|
|
||||||
|
this.lastLoadTime = now;
|
||||||
|
this.lastLoadFailed = true;
|
||||||
|
|
||||||
|
return this.preferences;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get game data sources for downloading ZIPs
|
||||||
|
*/
|
||||||
|
async getGameDataSources(): Promise<GameDataSource[]> {
|
||||||
|
const prefs = await this.loadPreferences();
|
||||||
|
return prefs.gameDataSources;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the data packs folder path (relative to FLASHPOINT_PATH)
|
||||||
|
*/
|
||||||
|
async getDataPacksFolderPath(): Promise<string> {
|
||||||
|
const prefs = await this.loadPreferences();
|
||||||
|
return path.join(this.flashpointPath, prefs.dataPacksFolderPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate the cache to force reload on next access
|
||||||
|
*/
|
||||||
|
invalidateCache(): void {
|
||||||
|
this.lastLoadTime = 0;
|
||||||
|
logger.info('[PreferencesService] Cache invalidated');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export { PreferencesService } from './PreferencesService';
|
||||||
|
export type { GameDataSource, FlashpointPreferences } from './PreferencesService';
|
||||||
|
|
||||||
|
export { GameDataDownloader, gameDataDownloader } from './GameDataDownloader';
|
||||||
|
export type {
|
||||||
|
GameDataDownloadParams,
|
||||||
|
DownloadResult,
|
||||||
|
DownloadProgressCallback,
|
||||||
|
} from './GameDataDownloader';
|
||||||
@@ -28,27 +28,3 @@ export function setCorsHeaders(res: ServerResponse, settings: CorsSettings): voi
|
|||||||
res.setHeader('Access-Control-Allow-Headers', '*');
|
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
|
* for better compatibility with Unity WebGL and other game engines
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unity WebGL polyfills
|
* Unity WebGL polyfills
|
||||||
* These are minimal stubs to prevent errors when Unity games load
|
* 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
|
// Add Unity polyfills if needed
|
||||||
if (needsUnityPolyfills(htmlString)) {
|
if (needsUnityPolyfills(htmlString)) {
|
||||||
polyfillsToInject += UNITY_POLYFILLS;
|
polyfillsToInject += UNITY_POLYFILLS;
|
||||||
console.log('[HTMLInjector] Injecting Unity WebGL polyfills');
|
logger.debug('[HTMLInjector] Injecting Unity WebGL polyfills');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always add general polyfills for compatibility
|
// Always add general polyfills for compatibility
|
||||||
polyfillsToInject += GENERAL_POLYFILLS;
|
polyfillsToInject += GENERAL_POLYFILLS;
|
||||||
|
|
||||||
// If no polyfills needed, return original
|
|
||||||
if (!polyfillsToInject) {
|
|
||||||
return Buffer.from(htmlString);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to inject into <head> tag
|
// Try to inject into <head> tag
|
||||||
const headMatch = htmlString.match(/<head[^>]*>/i);
|
const headMatch = htmlString.match(/<head[^>]*>/i);
|
||||||
if (headMatch) {
|
if (headMatch) {
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { logger } from './logger';
|
import { logger } from '../../utils/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Path Security Utilities
|
* Path Security Utilities
|
||||||
@@ -18,8 +18,8 @@ export function hasDoubleEncoding(urlPath: string): boolean {
|
|||||||
// %25 is the encoding for '%', so %252e = %2e, %252f = %2f, etc.
|
// %25 is the encoding for '%', so %252e = %2e, %252f = %2f, etc.
|
||||||
// Double-encoding is a technique to bypass security filters that only decode once.
|
// Double-encoding is a technique to bypass security filters that only decode once.
|
||||||
const doubleEncodedPatterns = [
|
const doubleEncodedPatterns = [
|
||||||
/%25(?:2[eEfF]|5[cC])/gi, // %252e (%2e = .), %252f (%2f = /), %255c (%5c = \)
|
/%25(?:2[eEfF]|5[cC])/i, // %252e (%2e = .), %252f (%2f = /), %255c (%5c = \)
|
||||||
/%25(?:00)/gi, // %2500 = double-encoded null byte
|
/%25(?:00)/i, // %2500 = double-encoded null byte
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const pattern of doubleEncodedPatterns) {
|
for (const pattern of doubleEncodedPatterns) {
|
||||||
@@ -88,6 +88,18 @@ export function sanitizeAndValidatePath(basePath: string, requestPath: string):
|
|||||||
logger.warn(`[Security] Base directory: ${resolvedBase}`);
|
logger.warn(`[Security] Base directory: ${resolvedBase}`);
|
||||||
throw new Error('Invalid path: Directory traversal detected');
|
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 {
|
} else {
|
||||||
// Case-sensitive comparison for Unix-like systems
|
// Case-sensitive comparison for Unix-like systems
|
||||||
if (!resolvedPath.startsWith(resolvedBase)) {
|
if (!resolvedPath.startsWith(resolvedBase)) {
|
||||||
@@ -96,38 +108,17 @@ export function sanitizeAndValidatePath(basePath: string, requestPath: string):
|
|||||||
logger.warn(`[Security] Base directory: ${resolvedBase}`);
|
logger.warn(`[Security] Base directory: ${resolvedBase}`);
|
||||||
throw new Error('Invalid path: Directory traversal detected');
|
throw new Error('Invalid path: Directory traversal detected');
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return resolvedPath;
|
// 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}`);
|
||||||
* Validate that a path is within one of multiple allowed base directories
|
logger.warn(`[Security] Base directory: ${resolvedBase}`);
|
||||||
*
|
throw new Error('Invalid path: Path escapes allowed directory');
|
||||||
* @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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path is not in any allowed base directory
|
return resolvedPath;
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -159,10 +150,10 @@ export function sanitizeUrlPath(urlPath: string): string {
|
|||||||
// Note: We allow '..' because it will be handled by sanitizeAndValidatePath
|
// Note: We allow '..' because it will be handled by sanitizeAndValidatePath
|
||||||
// But we check for obviously malicious patterns
|
// But we check for obviously malicious patterns
|
||||||
const dangerousPatterns = [
|
const dangerousPatterns = [
|
||||||
/\.\.\\/g, // ..\ (Windows path traversal)
|
/\.\.\\/, // ..\ (Windows path traversal)
|
||||||
/\.\.%2[fF]/g, // URL encoded ../
|
/\.\.%2[fF]/, // URL encoded ../
|
||||||
/\.\.%5[cC]/g, // URL encoded ..\
|
/\.\.%5[cC]/, // URL encoded ..\
|
||||||
/%00/g, // URL encoded null byte
|
/%00/, // URL encoded null byte
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const pattern of dangerousPatterns) {
|
for (const pattern of dangerousPatterns) {
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation schemas for game-service endpoints
|
||||||
|
* Provides input validation to prevent security issues and malformed requests
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for mount endpoint
|
||||||
|
* POST /mount/:gameId
|
||||||
|
*/
|
||||||
|
export const mountGameSchema = z.object({
|
||||||
|
gameId: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'Game ID is required')
|
||||||
|
.max(255, 'Game ID is too long')
|
||||||
|
.regex(/^[a-zA-Z0-9\-_]+$/, 'Game ID contains invalid characters'),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for unmount endpoint (reuses mountGameSchema)
|
||||||
|
* DELETE /mount/:gameId
|
||||||
|
*/
|
||||||
|
export const unmountGameSchema = mountGameSchema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for hostname validation
|
||||||
|
*/
|
||||||
|
export const hostnameSchema = z
|
||||||
|
.string()
|
||||||
|
.min(2, 'Hostname is required') // Intentionally requires 2+ chars; single-char hostnames not supported for legacy content
|
||||||
|
.max(253, 'Hostname is too long')
|
||||||
|
.regex(/^[a-zA-Z0-9][a-zA-Z0-9\-_.]*[a-zA-Z0-9]$/, {
|
||||||
|
message: 'Invalid hostname format',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and sanitize a game ID
|
||||||
|
*/
|
||||||
|
export function validateGameId(gameId: unknown): string {
|
||||||
|
const result = mountGameSchema.shape.gameId.safeParse(gameId);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(`Invalid game ID: ${result.error.issues[0].message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and sanitize a hostname
|
||||||
|
*/
|
||||||
|
export function validateHostname(hostname: unknown): string {
|
||||||
|
const result = hostnameSchema.safeParse(hostname);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(`Invalid hostname: ${result.error.issues[0].message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
@@ -2,20 +2,25 @@ import StreamZip from 'node-stream-zip';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import { LRUCache } from 'lru-cache';
|
import { LRUCache } from 'lru-cache';
|
||||||
import { logger } from './utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { sanitizeErrorMessage } from './utils/pathSecurity';
|
import { sanitizeErrorMessage } from './utils/pathSecurity';
|
||||||
|
import { MAX_BUFFERED_FILE_SIZE } from './config';
|
||||||
|
|
||||||
interface MountedZip {
|
interface MountedZip {
|
||||||
id: string;
|
id: string;
|
||||||
zipPath: string;
|
zipPath: string;
|
||||||
zip: StreamZip.StreamZipAsync;
|
zip: StreamZip.StreamZipAsync;
|
||||||
mountTime: Date;
|
mountTime: Date;
|
||||||
|
entryIndex: Set<string>; // Set of all entry names for O(1) lookup
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ZipManager {
|
export class ZipManager {
|
||||||
// Use LRU cache to prevent memory leaks from unlimited ZIP mounts
|
// Use LRU cache to prevent memory leaks from unlimited ZIP mounts
|
||||||
// Max 100 concurrent mounts, 30-minute TTL, auto-close on eviction
|
// Max 100 concurrent mounts, 30-minute TTL, auto-close on eviction
|
||||||
private mountedZips: LRUCache<string, MountedZip>;
|
private mountedZips: LRUCache<string, MountedZip>;
|
||||||
|
private mountingInProgress = new Set<string>();
|
||||||
|
private pendingCloses: Set<Promise<void>> = new Set();
|
||||||
|
private shuttingDown = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.mountedZips = new LRUCache<string, MountedZip>({
|
this.mountedZips = new LRUCache<string, MountedZip>({
|
||||||
@@ -23,12 +28,20 @@ export class ZipManager {
|
|||||||
ttl: 30 * 60 * 1000, // 30-minute TTL
|
ttl: 30 * 60 * 1000, // 30-minute TTL
|
||||||
dispose: async (value, key) => {
|
dispose: async (value, key) => {
|
||||||
// Automatically close ZIP when evicted from cache
|
// Automatically close ZIP when evicted from cache
|
||||||
try {
|
// Track the close operation to ensure cleanup completes
|
||||||
logger.info(`[ZipManager] Auto-closing evicted ZIP: ${key}`);
|
const closePromise = (async () => {
|
||||||
await value.zip.close();
|
try {
|
||||||
} catch (error) {
|
logger.info(`[ZipManager] Auto-closing evicted ZIP: ${key}`);
|
||||||
logger.error(`[ZipManager] Error closing evicted ZIP ${key}:`, error);
|
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
|
// Update access time on get
|
||||||
updateAgeOnGet: true,
|
updateAgeOnGet: true,
|
||||||
@@ -43,35 +56,63 @@ export class ZipManager {
|
|||||||
* @param zipPath Absolute path to the ZIP file
|
* @param zipPath Absolute path to the ZIP file
|
||||||
*/
|
*/
|
||||||
async mount(id: string, zipPath: string): Promise<void> {
|
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
|
// Check if already mounted
|
||||||
if (this.mountedZips.has(id)) {
|
if (this.mountedZips.has(id)) {
|
||||||
logger.debug(`[ZipManager] ZIP already mounted: ${id}`);
|
logger.debug(`[ZipManager] ZIP already mounted: ${id}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify ZIP file exists
|
// Check if currently mounting
|
||||||
try {
|
if (this.mountingInProgress.has(id)) {
|
||||||
await fs.access(zipPath);
|
logger.debug(`[ZipManager] ZIP mount already in progress: ${id}`);
|
||||||
} catch (error) {
|
return;
|
||||||
// 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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`[ZipManager] Mounting ZIP: ${id} -> ${zipPath}`);
|
this.mountingInProgress.add(id);
|
||||||
|
|
||||||
// Create ZIP reader
|
try {
|
||||||
const zip = new StreamZip.async({ file: zipPath });
|
// 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
|
logger.info(`[ZipManager] Mounting ZIP: ${id} -> ${zipPath}`);
|
||||||
this.mountedZips.set(id, {
|
|
||||||
id,
|
|
||||||
zipPath,
|
|
||||||
zip,
|
|
||||||
mountTime: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
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
|
* @param id Unique identifier of the mounted ZIP
|
||||||
*/
|
*/
|
||||||
async unmount(id: string): Promise<boolean> {
|
async unmount(id: string): Promise<boolean> {
|
||||||
const mounted = this.mountedZips.get(id);
|
const mounted = this.mountedZips.peek(id);
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
logger.warn(`[ZipManager] ZIP not mounted: ${id}`);
|
logger.warn(`[ZipManager] ZIP not mounted: ${id}`);
|
||||||
return false;
|
return false;
|
||||||
@@ -114,6 +155,22 @@ export class ZipManager {
|
|||||||
// Normalize path (remove leading slash)
|
// Normalize path (remove leading slash)
|
||||||
const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath;
|
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}`);
|
logger.debug(`[ZipManager] Reading from ZIP ${id}: ${normalizedPath}`);
|
||||||
|
|
||||||
// Get file from ZIP
|
// Get file from ZIP
|
||||||
@@ -146,10 +203,38 @@ export class ZipManager {
|
|||||||
// Search all mounted ZIPs
|
// Search all mounted ZIPs
|
||||||
for (const [id, mounted] of this.mountedZips) {
|
for (const [id, mounted] of this.mountedZips) {
|
||||||
for (const pathVariant of pathsToTry) {
|
for (const pathVariant of pathsToTry) {
|
||||||
const data = await this.getFile(id, pathVariant);
|
// Normalize path (remove leading slash)
|
||||||
if (data) {
|
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}`);
|
logger.info(`[ZipManager] ✓ Found in ${id}: ${pathVariant}`);
|
||||||
return { data, mountId: id };
|
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,
|
id,
|
||||||
zipPath: mounted.zipPath,
|
zipPath: mounted.zipPath,
|
||||||
mountTime: mounted.mountTime,
|
mountTime: mounted.mountTime,
|
||||||
fileCount: 0, // Will be populated async
|
fileCount: mounted.entryIndex.size, // O(1) from index
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
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)
|
* List files in a mounted ZIP (for debugging)
|
||||||
*/
|
*/
|
||||||
@@ -203,8 +273,9 @@ export class ZipManager {
|
|||||||
let files = Object.keys(entries);
|
let files = Object.keys(entries);
|
||||||
|
|
||||||
if (pattern) {
|
if (pattern) {
|
||||||
const regex = new RegExp(pattern, 'i');
|
// Use simple substring matching instead of regex to prevent ReDoS
|
||||||
files = files.filter((f) => regex.test(f));
|
const lowerPattern = pattern.toLowerCase();
|
||||||
|
files = files.filter((f) => f.toLowerCase().includes(lowerPattern));
|
||||||
}
|
}
|
||||||
|
|
||||||
return files;
|
return files;
|
||||||
@@ -228,11 +299,28 @@ export class ZipManager {
|
|||||||
* Unmount all ZIPs (cleanup on shutdown)
|
* Unmount all ZIPs (cleanup on shutdown)
|
||||||
*/
|
*/
|
||||||
async unmountAll(): Promise<void> {
|
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})...`);
|
logger.info(`[ZipManager] Unmounting all ZIPs (${this.mountedZips.size})...`);
|
||||||
|
|
||||||
const promises = Array.from(this.mountedZips.keys()).map((id) => this.unmount(id));
|
const promises = Array.from(this.mountedZips.keys()).map((id) => this.unmount(id));
|
||||||
await Promise.all(promises);
|
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`);
|
logger.info(`[ZipManager] ✓ All ZIPs unmounted`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,8 +23,8 @@ declare global {
|
|||||||
* Authenticate user from JWT token
|
* Authenticate user from JWT token
|
||||||
* Requires valid token in Authorization header
|
* Requires valid token in Authorization header
|
||||||
*/
|
*/
|
||||||
export const authenticate = async (req: Request, res: Response, next: NextFunction) => {
|
export const authenticate = asyncHandler(
|
||||||
try {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
// Get token from Authorization header
|
// Get token from Authorization header
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
@@ -38,17 +38,15 @@ export const authenticate = async (req: Request, res: Response, next: NextFuncti
|
|||||||
req.user = user;
|
req.user = user;
|
||||||
|
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
}
|
||||||
};
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional authentication - allows guest access if enabled
|
* Optional authentication - allows guest access if enabled
|
||||||
* If token is provided, verifies it. Otherwise, sets guest user (if guest access 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) => {
|
export const optionalAuth = asyncHandler(
|
||||||
try {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
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');
|
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 = {
|
req.user = {
|
||||||
id: 0,
|
id: 0,
|
||||||
username: 'guest',
|
username: 'guest',
|
||||||
@@ -73,10 +72,8 @@ export const optionalAuth = async (req: Request, res: Response, next: NextFuncti
|
|||||||
}
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
}
|
||||||
};
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Soft authentication - populates req.user if token exists, otherwise leaves undefined
|
* 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.sharedAccess = payload;
|
||||||
|
req.user = {
|
||||||
|
id: 0,
|
||||||
|
username: 'shared-guest',
|
||||||
|
email: '',
|
||||||
|
role: 'guest',
|
||||||
|
permissions: ['games.read', 'playlists.read', 'games.play'],
|
||||||
|
};
|
||||||
return next();
|
return next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) throw error;
|
if (error instanceof AppError) throw error;
|
||||||
@@ -161,13 +166,14 @@ export const sharedAccessAuth = asyncHandler(
|
|||||||
throw new AppError(401, 'Authentication required', true, 'AUTH_REQUIRED');
|
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 = {
|
req.user = {
|
||||||
id: 0,
|
id: 0,
|
||||||
username: 'guest',
|
username: 'guest',
|
||||||
email: '',
|
email: '',
|
||||||
role: 'guest',
|
role: 'guest',
|
||||||
permissions: ['games.read', 'playlists.read', 'games.play'],
|
permissions: ['games.read', 'playlists.read'],
|
||||||
};
|
};
|
||||||
|
|
||||||
next();
|
next();
|
||||||
@@ -180,8 +186,8 @@ export const sharedAccessAuth = asyncHandler(
|
|||||||
*/
|
*/
|
||||||
export const validateSharedGameAccess = (gameIdParam: string = 'id') => {
|
export const validateSharedGameAccess = (gameIdParam: string = 'id') => {
|
||||||
return asyncHandler(async (req: Request, res: Response, next: NextFunction) => {
|
return asyncHandler(async (req: Request, res: Response, next: NextFunction) => {
|
||||||
// If regular user auth, skip validation
|
// If regular user auth (non-zero ID), skip validation
|
||||||
if (req.user) {
|
if (req.user && req.user.id !== 0) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { ZodError } from 'zod';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
export class AppError extends Error {
|
export class AppError extends Error {
|
||||||
@@ -43,7 +44,7 @@ function sanitizeBody(body: any): any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function errorHandler(
|
export function errorHandler(
|
||||||
err: Error | AppError,
|
err: Error | AppError | ZodError,
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
_next: NextFunction
|
_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
|
// Unhandled errors - Log full details with sanitization
|
||||||
logger.error(`[UnhandledError] ${err.message}`, {
|
logger.error(`[UnhandledError] ${err.message}`, {
|
||||||
name: err.name,
|
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(/\/[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 numeric IDs
|
||||||
.replace(/\/\d+/g, '/:id')
|
.replace(/\/\d+/g, '/:id')
|
||||||
// Replace alphanumeric IDs (game IDs, share tokens)
|
// Replace hex IDs (like game data IDs) - must contain at least one digit
|
||||||
.replace(/\/[a-zA-Z0-9_-]{8,}/g, '/:id')
|
.replace(/\/[a-f0-9]{8,}/gi, '/:id')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
import path from 'path';
|
|
||||||
import fs from 'fs/promises';
|
|
||||||
import { logger } from '../utils/logger';
|
|
||||||
|
|
||||||
export interface ServerSettings {
|
|
||||||
// Proxy server port (default: 22500)
|
|
||||||
proxyPort: number;
|
|
||||||
|
|
||||||
// GameZip server port (default: 22501)
|
|
||||||
gameZipPort: number;
|
|
||||||
|
|
||||||
// Legacy paths
|
|
||||||
legacyHTDOCSPath: string;
|
|
||||||
legacyCGIBINPath: string;
|
|
||||||
|
|
||||||
// Override paths for file resolution
|
|
||||||
legacyOverridePaths: string[];
|
|
||||||
|
|
||||||
// External fallback URLs
|
|
||||||
infinityServerURL?: string;
|
|
||||||
externalFilePaths: string[];
|
|
||||||
|
|
||||||
// MAD4FP mode settings
|
|
||||||
mad4fpEnabled: boolean;
|
|
||||||
mad4fpPaths: string[];
|
|
||||||
|
|
||||||
// Performance settings
|
|
||||||
chunkSize: number;
|
|
||||||
maxConcurrentDownloads: number;
|
|
||||||
|
|
||||||
// Feature flags
|
|
||||||
allowCrossDomain: boolean;
|
|
||||||
enableBrotli: boolean;
|
|
||||||
enableCGI: boolean;
|
|
||||||
enableHtaccess: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ConfigManager {
|
|
||||||
private static settings: ServerSettings | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load configuration from proxySettings.json and environment
|
|
||||||
*/
|
|
||||||
static async loadConfig(flashpointPath: string): Promise<ServerSettings> {
|
|
||||||
try {
|
|
||||||
const proxySettingsPath = path.join(flashpointPath, 'Server', 'proxySettings.json');
|
|
||||||
const data = await fs.readFile(proxySettingsPath, 'utf-8');
|
|
||||||
const proxySettings = JSON.parse(data);
|
|
||||||
|
|
||||||
// Build settings from proxySettings.json and defaults
|
|
||||||
this.settings = {
|
|
||||||
proxyPort: parseInt(process.env.PROXY_PORT || '22500'),
|
|
||||||
gameZipPort: parseInt(process.env.GAMEZIPSERVER_PORT || '22501'),
|
|
||||||
|
|
||||||
legacyHTDOCSPath: path.join(flashpointPath, 'Legacy', 'htdocs'),
|
|
||||||
legacyCGIBINPath: path.join(flashpointPath, 'Legacy', 'cgi-bin'),
|
|
||||||
|
|
||||||
legacyOverridePaths: proxySettings.legacyOverridePaths || [],
|
|
||||||
|
|
||||||
// Use HTTPS for external sources (servers redirect HTTP to HTTPS anyway)
|
|
||||||
infinityServerURL: proxySettings.infinityServerURL
|
|
||||||
? proxySettings.infinityServerURL.replace(/^http:\/\//, 'https://')
|
|
||||||
: 'https://infinity.flashpointarchive.org/Flashpoint/Legacy/htdocs/',
|
|
||||||
|
|
||||||
externalFilePaths: (proxySettings.externalFilePaths || []).map((url: string) =>
|
|
||||||
url.replace(/^http:\/\//, 'https://')
|
|
||||||
),
|
|
||||||
|
|
||||||
mad4fpEnabled: proxySettings.mad4fpEnabled || false,
|
|
||||||
mad4fpPaths: proxySettings.mad4fpPaths || [],
|
|
||||||
|
|
||||||
chunkSize: 64 * 1024, // 64KB chunks for streaming
|
|
||||||
maxConcurrentDownloads: 10,
|
|
||||||
|
|
||||||
allowCrossDomain: true,
|
|
||||||
enableBrotli: true,
|
|
||||||
enableCGI: process.env.ENABLE_CGI === 'true',
|
|
||||||
enableHtaccess: process.env.ENABLE_HTACCESS === 'true',
|
|
||||||
};
|
|
||||||
|
|
||||||
logger.info('[ProxyConfig] Configuration loaded successfully');
|
|
||||||
logger.info(`[ProxyConfig] Proxy port: ${this.settings.proxyPort}`);
|
|
||||||
logger.info(`[ProxyConfig] GameZip port: ${this.settings.gameZipPort}`);
|
|
||||||
logger.info(`[ProxyConfig] HTDOCS path: ${this.settings.legacyHTDOCSPath}`);
|
|
||||||
logger.info(`[ProxyConfig] Override paths: ${this.settings.legacyOverridePaths.length}`);
|
|
||||||
logger.info(`[ProxyConfig] External sources: ${this.settings.externalFilePaths.length}`);
|
|
||||||
|
|
||||||
return this.settings;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[ProxyConfig] Failed to load proxySettings.json, using defaults', error);
|
|
||||||
|
|
||||||
// Fallback to defaults
|
|
||||||
this.settings = {
|
|
||||||
proxyPort: 22500,
|
|
||||||
gameZipPort: 22501,
|
|
||||||
legacyHTDOCSPath: path.join(flashpointPath, 'Legacy', 'htdocs'),
|
|
||||||
legacyCGIBINPath: path.join(flashpointPath, 'Legacy', 'cgi-bin'),
|
|
||||||
legacyOverridePaths: [],
|
|
||||||
infinityServerURL: 'https://infinity.flashpointarchive.org/Flashpoint/Legacy/htdocs/',
|
|
||||||
externalFilePaths: [
|
|
||||||
'https://infinity.flashpointarchive.org/Flashpoint/Legacy/htdocs',
|
|
||||||
'https://infinity.unstable.life/Flashpoint/Legacy/htdocs/',
|
|
||||||
],
|
|
||||||
mad4fpEnabled: false,
|
|
||||||
mad4fpPaths: [],
|
|
||||||
chunkSize: 64 * 1024,
|
|
||||||
maxConcurrentDownloads: 10,
|
|
||||||
allowCrossDomain: true,
|
|
||||||
enableBrotli: true,
|
|
||||||
enableCGI: false,
|
|
||||||
enableHtaccess: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.settings;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static getSettings(): ServerSettings {
|
|
||||||
if (!this.settings) {
|
|
||||||
throw new Error('Configuration not loaded. Call loadConfig() first.');
|
|
||||||
}
|
|
||||||
return this.settings;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,314 +0,0 @@
|
|||||||
import http from 'http';
|
|
||||||
import { URL } from 'url';
|
|
||||||
import { lookup as mimeLookup } from 'mime-types';
|
|
||||||
import path from 'path';
|
|
||||||
import { logger } from '../utils/logger';
|
|
||||||
import { zipManager } from './zip-manager';
|
|
||||||
import { ConfigManager } from './config';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GameZip Server - Serves files from mounted ZIP archives
|
|
||||||
* Replicates the GameZip server from FlashpointGameServer (port 22501)
|
|
||||||
*/
|
|
||||||
export class GameZipServer {
|
|
||||||
private server: http.Server | null = null;
|
|
||||||
private settings = ConfigManager.getSettings();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the GameZip server
|
|
||||||
*/
|
|
||||||
async start(): Promise<http.Server> {
|
|
||||||
const server = http.createServer(async (req, res) => {
|
|
||||||
try {
|
|
||||||
await this.handleRequest(req, res);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[GameZipServer] Unhandled error:', error);
|
|
||||||
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.writeHead(500);
|
|
||||||
res.end('Internal Server Error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Configure server settings
|
|
||||||
server.timeout = 120000; // 2 minutes timeout
|
|
||||||
server.keepAliveTimeout = 65000; // 65 seconds keep-alive
|
|
||||||
|
|
||||||
// Start listening
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
server.listen(this.settings.gameZipPort, () => {
|
|
||||||
logger.info(`[GameZipServer] ========================================`);
|
|
||||||
logger.info(`[GameZipServer] GameZip Server started`);
|
|
||||||
logger.info(`[GameZipServer] Port: ${this.settings.gameZipPort}`);
|
|
||||||
logger.info(`[GameZipServer] ========================================`);
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
server.on('error', (error: NodeJS.ErrnoException) => {
|
|
||||||
if (error.code === 'EADDRINUSE') {
|
|
||||||
logger.error(`[GameZipServer] Port ${this.settings.gameZipPort} is already in use`);
|
|
||||||
} else {
|
|
||||||
logger.error('[GameZipServer] Failed to start:', error);
|
|
||||||
}
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
this.server = server;
|
|
||||||
return server;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle incoming HTTP request
|
|
||||||
*/
|
|
||||||
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
||||||
const method = req.method;
|
|
||||||
const url = req.url || '/';
|
|
||||||
|
|
||||||
logger.info(`[GameZipServer] ${method} ${url}`);
|
|
||||||
|
|
||||||
// Handle different request types
|
|
||||||
if (method === 'OPTIONS') {
|
|
||||||
this.handleOptionsRequest(req, res);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method === 'POST' && url.startsWith('/mount/')) {
|
|
||||||
await this.handleMount(req, res);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method === 'DELETE' && url.startsWith('/mount/')) {
|
|
||||||
await this.handleUnmount(req, res);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method === 'GET' && url === '/mounts') {
|
|
||||||
this.handleListMounts(req, res);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method === 'GET' || method === 'HEAD') {
|
|
||||||
await this.handleFileRequest(req, res);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unknown request
|
|
||||||
this.sendError(res, 404, 'Not Found');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle CORS preflight
|
|
||||||
*/
|
|
||||||
private handleOptionsRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
||||||
if (this.settings.allowCrossDomain) {
|
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
||||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
||||||
res.setHeader('Access-Control-Allow-Headers', '*');
|
|
||||||
res.setHeader('Access-Control-Max-Age', '86400');
|
|
||||||
}
|
|
||||||
|
|
||||||
res.writeHead(204);
|
|
||||||
res.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle POST /mount/:id - Mount a ZIP file
|
|
||||||
* Body: { zipPath: string }
|
|
||||||
*/
|
|
||||||
private async handleMount(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
||||||
// Extract ID from URL
|
|
||||||
const id = req.url?.split('/mount/')[1];
|
|
||||||
if (!id) {
|
|
||||||
this.sendError(res, 400, 'Missing mount ID');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse request body
|
|
||||||
const body = await this.readBody(req);
|
|
||||||
let zipPath: string;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(body);
|
|
||||||
zipPath = data.zipPath;
|
|
||||||
|
|
||||||
if (!zipPath) {
|
|
||||||
this.sendError(res, 400, 'Missing zipPath in request body');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.sendError(res, 400, 'Invalid JSON');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mount the ZIP
|
|
||||||
try {
|
|
||||||
await zipManager.mount(id, zipPath);
|
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'application/json');
|
|
||||||
res.writeHead(200);
|
|
||||||
res.end(JSON.stringify({ success: true, id, zipPath }));
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`[GameZipServer] Mount failed:`, error);
|
|
||||||
this.sendError(res, 500, error instanceof Error ? error.message : 'Mount failed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle DELETE /mount/:id - Unmount a ZIP file
|
|
||||||
*/
|
|
||||||
private async handleUnmount(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
||||||
const id = req.url?.split('/mount/')[1];
|
|
||||||
if (!id) {
|
|
||||||
this.sendError(res, 400, 'Missing mount ID');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = await zipManager.unmount(id);
|
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'application/json');
|
|
||||||
res.writeHead(success ? 200 : 404);
|
|
||||||
res.end(JSON.stringify({ success, id }));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle GET /mounts - List all mounted ZIPs
|
|
||||||
*/
|
|
||||||
private handleListMounts(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
||||||
const mounts = zipManager.getMountedZips();
|
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'application/json');
|
|
||||||
res.writeHead(200);
|
|
||||||
res.end(JSON.stringify({ mounts }));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle GET /* - Serve file from mounted ZIPs
|
|
||||||
* Supports proxy-style requests: GET http://domain.com/path HTTP/1.1
|
|
||||||
*/
|
|
||||||
private async handleFileRequest(
|
|
||||||
req: http.IncomingMessage,
|
|
||||||
res: http.ServerResponse
|
|
||||||
): Promise<void> {
|
|
||||||
if (!req.url) {
|
|
||||||
this.sendError(res, 400, 'Bad Request: No URL');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let hostname: string;
|
|
||||||
let urlPath: string;
|
|
||||||
|
|
||||||
// Parse URL (same logic as main proxy server)
|
|
||||||
if (req.url.startsWith('http://') || req.url.startsWith('https://')) {
|
|
||||||
// Proxy-style: GET http://domain.com/path HTTP/1.1
|
|
||||||
const targetUrl = new URL(req.url);
|
|
||||||
hostname = targetUrl.hostname;
|
|
||||||
urlPath = targetUrl.pathname + targetUrl.search;
|
|
||||||
} else if (req.url.startsWith('/http://') || req.url.startsWith('/https://')) {
|
|
||||||
// Path-based: GET /http://domain.com/path HTTP/1.1
|
|
||||||
const urlWithoutSlash = req.url.substring(1);
|
|
||||||
const targetUrl = new URL(urlWithoutSlash);
|
|
||||||
hostname = targetUrl.hostname;
|
|
||||||
urlPath = targetUrl.pathname + targetUrl.search;
|
|
||||||
} else {
|
|
||||||
// Regular: GET /path HTTP/1.1
|
|
||||||
hostname = req.headers.host || 'localhost';
|
|
||||||
urlPath = req.url;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build relative path
|
|
||||||
const relPath = path.posix.join(hostname, urlPath);
|
|
||||||
|
|
||||||
logger.info(`[GameZipServer] Looking for: ${relPath}`);
|
|
||||||
|
|
||||||
// Search mounted ZIPs
|
|
||||||
const result = await zipManager.findFile(relPath);
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
logger.debug(`[GameZipServer] File not found in any mounted ZIP: ${relPath}`);
|
|
||||||
this.sendError(res, 404, 'File not found in mounted ZIPs');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine content type
|
|
||||||
const ext = path.extname(urlPath).toLowerCase();
|
|
||||||
const contentType = mimeLookup(ext) || 'application/octet-stream';
|
|
||||||
|
|
||||||
// Send file
|
|
||||||
logger.info(
|
|
||||||
`[GameZipServer] ✓ Serving from ZIP ${result.mountId}: ${relPath} (${result.data.length} bytes)`
|
|
||||||
);
|
|
||||||
|
|
||||||
// CORS headers
|
|
||||||
if (this.settings.allowCrossDomain) {
|
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
||||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
||||||
res.setHeader('Access-Control-Allow-Headers', '*');
|
|
||||||
}
|
|
||||||
|
|
||||||
res.setHeader('Content-Type', contentType);
|
|
||||||
res.setHeader('Content-Length', result.data.length);
|
|
||||||
res.setHeader('Cache-Control', 'public, max-age=86400'); // Cache for 24 hours
|
|
||||||
res.setHeader('X-Source', `gamezipserver:${result.mountId}`);
|
|
||||||
res.setHeader('Connection', 'keep-alive');
|
|
||||||
|
|
||||||
res.writeHead(200);
|
|
||||||
res.end(result.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read request body
|
|
||||||
*/
|
|
||||||
private readBody(req: http.IncomingMessage): Promise<string> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
let body = '';
|
|
||||||
req.on('data', (chunk) => {
|
|
||||||
body += chunk.toString();
|
|
||||||
});
|
|
||||||
req.on('end', () => {
|
|
||||||
resolve(body);
|
|
||||||
});
|
|
||||||
req.on('error', reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send error response
|
|
||||||
*/
|
|
||||||
private sendError(res: http.ServerResponse, statusCode: number, message: string): void {
|
|
||||||
logger.warn(`[GameZipServer] Sending error ${statusCode}: ${message}`);
|
|
||||||
|
|
||||||
// Set CORS headers even for error responses
|
|
||||||
if (this.settings.allowCrossDomain) {
|
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
||||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
||||||
res.setHeader('Access-Control-Allow-Headers', '*');
|
|
||||||
}
|
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/plain');
|
|
||||||
res.writeHead(statusCode);
|
|
||||||
res.end(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the server
|
|
||||||
*/
|
|
||||||
async stop(): Promise<void> {
|
|
||||||
if (this.server) {
|
|
||||||
logger.info('[GameZipServer] Stopping server...');
|
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
this.server!.close((err) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info('[GameZipServer] Server stopped');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unmount all ZIPs
|
|
||||||
await zipManager.unmountAll();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import http from 'http';
|
|
||||||
import { logger } from '../utils/logger';
|
|
||||||
import { ConfigManager } from './config';
|
|
||||||
import { ProxyRequestHandler } from './proxy-request-handler';
|
|
||||||
|
|
||||||
export interface ProxyServerOptions {
|
|
||||||
proxyPort?: number;
|
|
||||||
legacyHTDOCSPath: string;
|
|
||||||
gameDataPath?: string;
|
|
||||||
externalFilePaths?: string[];
|
|
||||||
allowCrossDomain?: boolean;
|
|
||||||
chunkSize?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create and start the Flashpoint Proxy Server
|
|
||||||
* This is the HTTP proxy implementation that replicates FlashpointGameServer
|
|
||||||
*/
|
|
||||||
export async function createHTTPProxyServer(options: ProxyServerOptions): Promise<http.Server> {
|
|
||||||
// Load configuration
|
|
||||||
const flashpointPath = options.legacyHTDOCSPath.replace(/[\\/]Legacy[\\/]htdocs.*$/, '');
|
|
||||||
await ConfigManager.loadConfig(flashpointPath);
|
|
||||||
|
|
||||||
const settings = ConfigManager.getSettings();
|
|
||||||
|
|
||||||
// Override settings from options if provided
|
|
||||||
if (options.proxyPort) {
|
|
||||||
settings.proxyPort = options.proxyPort;
|
|
||||||
}
|
|
||||||
if (options.allowCrossDomain !== undefined) {
|
|
||||||
settings.allowCrossDomain = options.allowCrossDomain;
|
|
||||||
}
|
|
||||||
if (options.chunkSize) {
|
|
||||||
settings.chunkSize = options.chunkSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create request handler
|
|
||||||
const requestHandler = new ProxyRequestHandler();
|
|
||||||
|
|
||||||
// Create HTTP server
|
|
||||||
const server = http.createServer(async (req, res) => {
|
|
||||||
try {
|
|
||||||
// Handle CORS preflight
|
|
||||||
if (req.method === 'OPTIONS') {
|
|
||||||
requestHandler.handleOptionsRequest(req, res);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle proxy request
|
|
||||||
await requestHandler.handleRequest(req, res);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[HTTPProxyServer] Unhandled error:', error);
|
|
||||||
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.writeHead(500);
|
|
||||||
res.end('Internal Server Error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Configure server settings
|
|
||||||
server.timeout = 120000; // 2 minutes timeout
|
|
||||||
server.keepAliveTimeout = 65000; // 65 seconds keep-alive
|
|
||||||
|
|
||||||
// Start listening
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
server.listen(settings.proxyPort, () => {
|
|
||||||
logger.info(`[HTTPProxyServer] ========================================`);
|
|
||||||
logger.info(`[HTTPProxyServer] Flashpoint Proxy Server started`);
|
|
||||||
logger.info(`[HTTPProxyServer] Port: ${settings.proxyPort}`);
|
|
||||||
logger.info(`[HTTPProxyServer] HTDOCS: ${settings.legacyHTDOCSPath}`);
|
|
||||||
logger.info(`[HTTPProxyServer] CGI-BIN: ${settings.legacyCGIBINPath}`);
|
|
||||||
logger.info(`[HTTPProxyServer] External sources: ${settings.externalFilePaths.length}`);
|
|
||||||
logger.info(`[HTTPProxyServer] ========================================`);
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
server.on('error', (error: NodeJS.ErrnoException) => {
|
|
||||||
if (error.code === 'EADDRINUSE') {
|
|
||||||
logger.error(`[HTTPProxyServer] Port ${settings.proxyPort} is already in use`);
|
|
||||||
logger.error('[HTTPProxyServer] Please check if another instance is running');
|
|
||||||
} else {
|
|
||||||
logger.error('[HTTPProxyServer] Failed to start:', error);
|
|
||||||
}
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return server;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create and start the GameZip server
|
|
||||||
* Serves files from mounted ZIP archives (port 22501)
|
|
||||||
*/
|
|
||||||
export async function createGameZipServer(): Promise<http.Server> {
|
|
||||||
const { GameZipServer } = await import('./gamezipserver');
|
|
||||||
const gameZipServer = new GameZipServer();
|
|
||||||
return await gameZipServer.start();
|
|
||||||
}
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
import path from 'path';
|
|
||||||
import fs from 'fs/promises';
|
|
||||||
import { lookup as mimeLookup } from 'mime-types';
|
|
||||||
import { logger } from '../utils/logger';
|
|
||||||
import { ConfigManager } from './config';
|
|
||||||
import axios from 'axios';
|
|
||||||
import zlib from 'zlib';
|
|
||||||
import { promisify } from 'util';
|
|
||||||
|
|
||||||
const brotliDecompress = promisify(zlib.brotliDecompress);
|
|
||||||
|
|
||||||
// Script file extensions
|
|
||||||
const SCRIPT_EXTENSIONS = ['php', 'php5', 'phtml', 'pl'];
|
|
||||||
|
|
||||||
// Index file extensions (in priority order)
|
|
||||||
const INDEX_EXTENSIONS = ['html', 'htm', 'php', 'swf'];
|
|
||||||
|
|
||||||
export interface LegacyFileResponse {
|
|
||||||
data: Buffer;
|
|
||||||
contentType: string;
|
|
||||||
source: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class LegacyServer {
|
|
||||||
private settings = ConfigManager.getSettings();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main file serving method - replicates ServeLegacy() from Go implementation
|
|
||||||
* @param hostname The hostname from the request (e.g., "www.example.com")
|
|
||||||
* @param urlPath The path from the request (e.g., "/path/file.swf")
|
|
||||||
*/
|
|
||||||
async serveLegacy(hostname: string, urlPath: string): Promise<LegacyFileResponse> {
|
|
||||||
// Strip port from hostname
|
|
||||||
const host = hostname.split(':')[0];
|
|
||||||
|
|
||||||
// Build relative path: {host}/{path}
|
|
||||||
const relPath = path.posix.join(host, urlPath);
|
|
||||||
|
|
||||||
logger.info(`[LegacyServer] Serving: ${host}${urlPath}`);
|
|
||||||
logger.info(`[LegacyServer] Relative path: ${relPath}`);
|
|
||||||
|
|
||||||
// Step 1: Build all path candidates
|
|
||||||
const pathCandidates = this.buildPathCandidates(relPath, urlPath);
|
|
||||||
|
|
||||||
// Step 2: Try each candidate in order
|
|
||||||
for (const candidate of pathCandidates) {
|
|
||||||
logger.debug(`[LegacyServer] Trying: ${candidate.path} (${candidate.type})`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stats = await fs.stat(candidate.path);
|
|
||||||
|
|
||||||
if (stats.isFile()) {
|
|
||||||
logger.info(`[LegacyServer] ✓ Found file: ${candidate.path}`);
|
|
||||||
return await this.serveFile(candidate.path, candidate.type === 'cgi-bin');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// File doesn't exist, continue to next candidate
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: No local file found, try external sources
|
|
||||||
logger.info(`[LegacyServer] File not found locally, trying external sources...`);
|
|
||||||
return await this.tryExternalSources(relPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build all path candidates in priority order
|
|
||||||
* Replicates the path resolution algorithm from Go's ServeLegacy()
|
|
||||||
*/
|
|
||||||
private buildPathCandidates(
|
|
||||||
relPath: string,
|
|
||||||
urlPath: string
|
|
||||||
): Array<{ path: string; type: string }> {
|
|
||||||
const candidates: Array<{ path: string; type: string }> = [];
|
|
||||||
|
|
||||||
// 1. Exact file paths (with and without query string)
|
|
||||||
candidates.push({
|
|
||||||
path: path.join(this.settings.legacyHTDOCSPath, relPath),
|
|
||||||
type: 'exact',
|
|
||||||
});
|
|
||||||
|
|
||||||
// If URL has query string, try without it
|
|
||||||
const pathWithoutQuery = relPath.split('?')[0];
|
|
||||||
if (pathWithoutQuery !== relPath) {
|
|
||||||
candidates.push({
|
|
||||||
path: path.join(this.settings.legacyHTDOCSPath, pathWithoutQuery),
|
|
||||||
type: 'exact-no-query',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Override paths (exact files in override directories)
|
|
||||||
for (const override of this.settings.legacyOverridePaths) {
|
|
||||||
candidates.push({
|
|
||||||
path: path.join(this.settings.legacyHTDOCSPath, override, relPath),
|
|
||||||
type: `override:${override}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (pathWithoutQuery !== relPath) {
|
|
||||||
candidates.push({
|
|
||||||
path: path.join(this.settings.legacyHTDOCSPath, override, pathWithoutQuery),
|
|
||||||
type: `override:${override}-no-query`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. CGI-BIN paths (for script files)
|
|
||||||
if (this.isScriptUrl(urlPath)) {
|
|
||||||
candidates.push({
|
|
||||||
path: path.join(this.settings.legacyCGIBINPath, relPath),
|
|
||||||
type: 'cgi-bin',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (pathWithoutQuery !== relPath) {
|
|
||||||
candidates.push({
|
|
||||||
path: path.join(this.settings.legacyCGIBINPath, pathWithoutQuery),
|
|
||||||
type: 'cgi-bin-no-query',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Index files (for directory requests)
|
|
||||||
for (const ext of INDEX_EXTENSIONS) {
|
|
||||||
candidates.push({
|
|
||||||
path: path.join(this.settings.legacyHTDOCSPath, relPath, `index.${ext}`),
|
|
||||||
type: `index:${ext}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Index files in override paths
|
|
||||||
for (const override of this.settings.legacyOverridePaths) {
|
|
||||||
candidates.push({
|
|
||||||
path: path.join(this.settings.legacyHTDOCSPath, override, relPath, `index.${ext}`),
|
|
||||||
type: `index-override:${override}:${ext}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return candidates;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if URL is a script file
|
|
||||||
*/
|
|
||||||
private isScriptUrl(urlPath: string): boolean {
|
|
||||||
const ext = path.extname(urlPath).toLowerCase().substring(1);
|
|
||||||
return SCRIPT_EXTENSIONS.includes(ext);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serve a file from disk
|
|
||||||
* @param filePath Absolute path to file
|
|
||||||
* @param isCGI Whether to execute as CGI script
|
|
||||||
*/
|
|
||||||
private async serveFile(filePath: string, isCGI: boolean = false): Promise<LegacyFileResponse> {
|
|
||||||
// Read file
|
|
||||||
let data = await fs.readFile(filePath);
|
|
||||||
|
|
||||||
// Handle Brotli compression
|
|
||||||
if (this.settings.enableBrotli && filePath.endsWith('.br')) {
|
|
||||||
logger.debug(`[LegacyServer] Decompressing Brotli file: ${filePath}`);
|
|
||||||
data = await brotliDecompress(data);
|
|
||||||
filePath = filePath.substring(0, filePath.length - 3); // Remove .br extension
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine content type
|
|
||||||
const contentType = mimeLookup(filePath) || 'application/octet-stream';
|
|
||||||
|
|
||||||
// TODO: Execute CGI scripts if enabled
|
|
||||||
if (isCGI && this.settings.enableCGI) {
|
|
||||||
logger.warn('[LegacyServer] CGI execution not yet implemented');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
data,
|
|
||||||
contentType,
|
|
||||||
source: 'local-htdocs',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Try external sources (Infinity Server, MAD4FP, etc.)
|
|
||||||
*/
|
|
||||||
private async tryExternalSources(relPath: string): Promise<LegacyFileResponse> {
|
|
||||||
// Try Infinity Server first
|
|
||||||
if (this.settings.infinityServerURL) {
|
|
||||||
logger.info(`[LegacyServer] Trying Infinity Server...`);
|
|
||||||
const result = await this.tryExternalSource(this.settings.infinityServerURL, relPath);
|
|
||||||
if (result) {
|
|
||||||
return { ...result, source: 'infinity-server' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try other external paths
|
|
||||||
for (const externalPath of this.settings.externalFilePaths) {
|
|
||||||
logger.info(`[LegacyServer] Trying: ${externalPath}`);
|
|
||||||
const result = await this.tryExternalSource(externalPath, relPath);
|
|
||||||
if (result) {
|
|
||||||
return { ...result, source: externalPath };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try MAD4FP if enabled
|
|
||||||
if (this.settings.mad4fpEnabled) {
|
|
||||||
for (const mad4fpPath of this.settings.mad4fpPaths) {
|
|
||||||
logger.info(`[LegacyServer] Trying MAD4FP: ${mad4fpPath}`);
|
|
||||||
const result = await this.tryExternalSource(mad4fpPath, relPath);
|
|
||||||
if (result) {
|
|
||||||
return { ...result, source: `mad4fp:${mad4fpPath}` };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('File not found in any source');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Download from external source
|
|
||||||
*/
|
|
||||||
private async tryExternalSource(
|
|
||||||
baseUrl: string,
|
|
||||||
relPath: string
|
|
||||||
): Promise<{ data: Buffer; contentType: string } | null> {
|
|
||||||
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
|
||||||
const fullUrl = `${normalizedBase}${relPath}`;
|
|
||||||
|
|
||||||
logger.info(`[LegacyServer] Downloading: ${fullUrl}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await axios.get(fullUrl, {
|
|
||||||
responseType: 'arraybuffer',
|
|
||||||
timeout: 45000,
|
|
||||||
maxRedirects: 5,
|
|
||||||
validateStatus: (status) => status === 200,
|
|
||||||
headers: {
|
|
||||||
'User-Agent': 'Flashpoint-Proxy/1.0',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = Buffer.from(response.data);
|
|
||||||
const contentType = response.headers['content-type'] || 'application/octet-stream';
|
|
||||||
|
|
||||||
logger.info(`[LegacyServer] ✓ Downloaded: ${data.length} bytes (${contentType})`);
|
|
||||||
|
|
||||||
return { data, contentType };
|
|
||||||
} catch (error) {
|
|
||||||
// Log but don't throw - allow trying next source
|
|
||||||
if (axios.isAxiosError(error)) {
|
|
||||||
if (error.response?.status === 404) {
|
|
||||||
logger.debug(`[LegacyServer] ✗ Not found (404)`);
|
|
||||||
} else if (error.response?.status === 403) {
|
|
||||||
logger.warn(`[LegacyServer] ✗ Forbidden (403)`);
|
|
||||||
} else if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
|
|
||||||
logger.warn(`[LegacyServer] ✗ Timeout`);
|
|
||||||
} else {
|
|
||||||
logger.warn(`[LegacyServer] ✗ Error: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
/**
|
|
||||||
* MIME type mappings from Flashpoint proxySettings.json
|
|
||||||
* Includes 199 custom MIME types for legacy web content
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const CUSTOM_MIME_TYPES: { [key: string]: string } = {
|
|
||||||
// Legacy multimedia formats
|
|
||||||
aab: 'application/x-authorware-bin',
|
|
||||||
aam: 'application/x-authorware-map',
|
|
||||||
aas: 'application/x-authorware-seg',
|
|
||||||
afl: 'video/animaflex',
|
|
||||||
aif: 'audio/aiff',
|
|
||||||
aifc: 'audio/aiff',
|
|
||||||
aiff: 'audio/aiff',
|
|
||||||
asd: 'application/astound',
|
|
||||||
asmx: 'text/xml',
|
|
||||||
asn: 'application/astound',
|
|
||||||
au: 'audio/basic',
|
|
||||||
aut: 'application/pbautomation',
|
|
||||||
aw3: 'application/x-awingsoft-winds3d',
|
|
||||||
axs: 'application/x-MindAvenueAXELStream',
|
|
||||||
|
|
||||||
// 3D and modeling formats
|
|
||||||
blend: 'application/x-burster',
|
|
||||||
blendz: 'application/x-burster',
|
|
||||||
blz: 'video/blz',
|
|
||||||
bswrl: 'application/x-bscontact',
|
|
||||||
bub: 'application/photobubble',
|
|
||||||
bxwrl: 'application/x-blaxxuncc3d',
|
|
||||||
|
|
||||||
// Director and Shockwave
|
|
||||||
ccn: 'application/x-cnc',
|
|
||||||
cct: 'application/x-director',
|
|
||||||
cnc: 'application/x-cnc',
|
|
||||||
cst: 'application/x-director',
|
|
||||||
cxt: 'application/x-director',
|
|
||||||
dcr: 'application/x-director',
|
|
||||||
dir: 'application/x-director',
|
|
||||||
dxr: 'application/x-director',
|
|
||||||
swa: 'application/x-director',
|
|
||||||
w3d: 'application/x-director',
|
|
||||||
|
|
||||||
// Chemical and scientific formats
|
|
||||||
cdx: 'chemical/x-cdx',
|
|
||||||
chm: 'chemical/x-chemdraw',
|
|
||||||
cow: 'chemical/x-cow',
|
|
||||||
csm: 'chemical/x-csml',
|
|
||||||
csml: 'chemical/x-csml',
|
|
||||||
cub: 'chemical/x-gaussian-cube',
|
|
||||||
cube: 'chemical/x-gaussian-cube',
|
|
||||||
dx: 'chemical/x-jcamp-dx',
|
|
||||||
emb: 'chemical/x-pdb',
|
|
||||||
embl: 'chemical/x-pdb',
|
|
||||||
gau: 'chemical/x-gaussian-input',
|
|
||||||
jdx: 'chemical/x-jcamp-dx',
|
|
||||||
mol: 'chemical/x-mdl-molfile',
|
|
||||||
mop: 'chemical/x-mopac-input',
|
|
||||||
pdb: 'chemical/x-pdb',
|
|
||||||
rxn: 'chemical/x-mdl-rxnfile',
|
|
||||||
skc: 'chemical/x-mdl-tgf',
|
|
||||||
tgf: 'chemical/x-mdl-tgf',
|
|
||||||
xyz: 'chemical/x-xyz',
|
|
||||||
|
|
||||||
// Image formats
|
|
||||||
cgm: 'image/cgm',
|
|
||||||
cit: 'image/cit',
|
|
||||||
dgn: 'image/dgn',
|
|
||||||
djvu: 'image/vnd.djvu',
|
|
||||||
fh4: 'image/x-freehand4',
|
|
||||||
fh5: 'image/x-freehand5',
|
|
||||||
fh7: 'image/x-freehand7',
|
|
||||||
fhc: 'image/x-freehand',
|
|
||||||
jp2: 'image/jp2',
|
|
||||||
jp2k: 'image/jp2',
|
|
||||||
mcf: 'image/vasa',
|
|
||||||
qdgx: 'image/x-qdgx',
|
|
||||||
rle: 'image/rle',
|
|
||||||
svg: 'image/svg+xml',
|
|
||||||
svgz: 'image/svg+xml',
|
|
||||||
twf: 'image/x-twf',
|
|
||||||
twfz: 'image/x-twf-zlib-compressed',
|
|
||||||
vec: 'image/vec',
|
|
||||||
|
|
||||||
// Audio formats
|
|
||||||
it: 'audio/it',
|
|
||||||
itz: 'audio/x-zipped-it',
|
|
||||||
mdz: 'audio/x-zipped-mod',
|
|
||||||
med: 'audio/x-mod',
|
|
||||||
mid: 'audio/mid',
|
|
||||||
midi: 'audio/midi',
|
|
||||||
mod: 'audio/mod',
|
|
||||||
s3m: 'audio/s3m',
|
|
||||||
s3z: 'audio/x-mod',
|
|
||||||
sid: 'audio/x-sidtune',
|
|
||||||
wav: 'audio/wav',
|
|
||||||
xm: 'audio/xm',
|
|
||||||
xmz: 'audio/x-mod',
|
|
||||||
|
|
||||||
// Video formats
|
|
||||||
mov: 'video/quicktime',
|
|
||||||
|
|
||||||
// Application formats
|
|
||||||
class: 'application/java',
|
|
||||||
cmo: 'application/x-virtools',
|
|
||||||
co: 'application/x-cult3d-object',
|
|
||||||
css: 'text/css',
|
|
||||||
deepv: 'application/x-deepv',
|
|
||||||
dpg: 'application/vnd.dpgraph',
|
|
||||||
dpgraph: 'application/vnd.dpgraph',
|
|
||||||
dsn: 'application/x-altiadsn',
|
|
||||||
dvl: 'application/x-devalvrx',
|
|
||||||
elec: 'application/x-electrifier',
|
|
||||||
eva: 'application/x-eva',
|
|
||||||
evy: 'application/envoy',
|
|
||||||
|
|
||||||
// Compressed formats
|
|
||||||
gz: 'application/x-gzip-compressed',
|
|
||||||
wrz: 'application/x-gzip-compressed',
|
|
||||||
x3dz: 'application/x-gzip-compressed',
|
|
||||||
|
|
||||||
// Web formats
|
|
||||||
htm: 'text/html',
|
|
||||||
html: 'text/html',
|
|
||||||
js: 'text/javascript',
|
|
||||||
mjs: 'text/javascript',
|
|
||||||
json: 'application/json',
|
|
||||||
xml: 'application/xml',
|
|
||||||
|
|
||||||
// Plugin formats
|
|
||||||
ips: 'application/x-ipscript',
|
|
||||||
ipx: 'application/x-ipix',
|
|
||||||
jar: 'application/java-archive',
|
|
||||||
mwc: 'application/vnd.dpgraph',
|
|
||||||
mwf: 'application/x-mwf',
|
|
||||||
nmo: 'application/x-virtools',
|
|
||||||
nms: 'application/x-virtools',
|
|
||||||
p3d: 'application/x-p3d',
|
|
||||||
pqf: 'application/x-cprplayer',
|
|
||||||
pqi: 'application/cprplayer',
|
|
||||||
pw3: 'application/x-pulse-player-32',
|
|
||||||
pwc: 'application/x-pulse-player',
|
|
||||||
pwn: 'application/x-pulse-download',
|
|
||||||
pws: 'application/x-pulse-stream',
|
|
||||||
sca: 'application/x-supercard',
|
|
||||||
scr: 'application/x-rasmol',
|
|
||||||
smp: 'application/studiom',
|
|
||||||
spl: 'application/futuresplash',
|
|
||||||
sts: 'application/x-squeak-source',
|
|
||||||
swf: 'application/x-shockwave-flash',
|
|
||||||
tbk: 'application/toolbook',
|
|
||||||
tcl: 'application/x-tcl',
|
|
||||||
thp: 'plugin/x-theorist',
|
|
||||||
tv: 'application/x-alambik-script',
|
|
||||||
tvb: 'application/x-alambik-script',
|
|
||||||
tvd: 'application/x-alambik-script',
|
|
||||||
tvs: 'application/x-alambik-script',
|
|
||||||
tvv: 'application/x-alambik-script',
|
|
||||||
unity3d: 'application/vnd.unity',
|
|
||||||
vmo: 'application/x-virtools',
|
|
||||||
vobj: 'application/x-netscape-vae-plugin-vae',
|
|
||||||
wasm: 'application/wasm',
|
|
||||||
web: 'application/vnd.xara',
|
|
||||||
xap: 'application/x-silverlight-app',
|
|
||||||
xar: 'application/vnd.xara',
|
|
||||||
xpg: 'text/x-xpg',
|
|
||||||
|
|
||||||
// World/3D formats
|
|
||||||
d96: 'x-world/x-d96',
|
|
||||||
dae: 'model/x-bs-collada+xml',
|
|
||||||
mus: 'x-world/x-d96',
|
|
||||||
rbs: 'x-world/realibase',
|
|
||||||
svf: 'vector/x-svf',
|
|
||||||
svr: 'x-world/x-svr',
|
|
||||||
vrt: 'x-world/x-vrt',
|
|
||||||
wrl: 'model/vrml',
|
|
||||||
wvr: 'x-world/x-wvr',
|
|
||||||
x3db: 'model/x3d+binary',
|
|
||||||
xvr: 'x-world/x-xvr',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Standard MIME types for common web files
|
|
||||||
const STANDARD_MIME_TYPES: { [key: string]: string } = {
|
|
||||||
// Text
|
|
||||||
txt: 'text/plain',
|
|
||||||
css: 'text/css',
|
|
||||||
csv: 'text/csv',
|
|
||||||
|
|
||||||
// Images
|
|
||||||
png: 'image/png',
|
|
||||||
jpg: 'image/jpeg',
|
|
||||||
jpeg: 'image/jpeg',
|
|
||||||
gif: 'image/gif',
|
|
||||||
bmp: 'image/bmp',
|
|
||||||
ico: 'image/x-icon',
|
|
||||||
webp: 'image/webp',
|
|
||||||
|
|
||||||
// Audio
|
|
||||||
mp3: 'audio/mpeg',
|
|
||||||
ogg: 'audio/ogg',
|
|
||||||
oga: 'audio/ogg',
|
|
||||||
|
|
||||||
// Video
|
|
||||||
mp4: 'video/mp4',
|
|
||||||
webm: 'video/webm',
|
|
||||||
ogv: 'video/ogg',
|
|
||||||
avi: 'video/x-msvideo',
|
|
||||||
|
|
||||||
// Fonts
|
|
||||||
woff: 'font/woff',
|
|
||||||
woff2: 'font/woff2',
|
|
||||||
ttf: 'font/ttf',
|
|
||||||
otf: 'font/otf',
|
|
||||||
eot: 'application/vnd.ms-fontobject',
|
|
||||||
|
|
||||||
// Archives
|
|
||||||
zip: 'application/zip',
|
|
||||||
rar: 'application/x-rar-compressed',
|
|
||||||
'7z': 'application/x-7z-compressed',
|
|
||||||
tar: 'application/x-tar',
|
|
||||||
|
|
||||||
// Documents
|
|
||||||
pdf: 'application/pdf',
|
|
||||||
doc: 'application/msword',
|
|
||||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
||||||
xls: 'application/vnd.ms-excel',
|
|
||||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
||||||
ppt: 'application/vnd.ms-powerpoint',
|
|
||||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
||||||
|
|
||||||
// Binary
|
|
||||||
bin: 'application/octet-stream',
|
|
||||||
exe: 'application/octet-stream',
|
|
||||||
dll: 'application/octet-stream',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get MIME type for a file extension
|
|
||||||
* Checks custom Flashpoint MIME types first, then standard types
|
|
||||||
*/
|
|
||||||
export function getMimeType(extension: string): string {
|
|
||||||
const ext = extension.toLowerCase();
|
|
||||||
|
|
||||||
// Check custom MIME types first (priority for legacy content)
|
|
||||||
if (CUSTOM_MIME_TYPES[ext]) {
|
|
||||||
return CUSTOM_MIME_TYPES[ext];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check standard MIME types
|
|
||||||
if (STANDARD_MIME_TYPES[ext]) {
|
|
||||||
return STANDARD_MIME_TYPES[ext];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to octet-stream for unknown types
|
|
||||||
return 'application/octet-stream';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a file extension is a gzipped type
|
|
||||||
*/
|
|
||||||
export function isGzippedType(extension: string): boolean {
|
|
||||||
const gzippedTypes = ['svgz'];
|
|
||||||
return gzippedTypes.includes(extension.toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a file extension is a script type
|
|
||||||
*/
|
|
||||||
export function isScriptType(extension: string): boolean {
|
|
||||||
const scriptTypes = ['php', 'php5', 'phtml', 'pl'];
|
|
||||||
return scriptTypes.includes(extension.toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get list of index file types
|
|
||||||
*/
|
|
||||||
export function getIndexFileTypes(): string[] {
|
|
||||||
return ['html', 'htm', 'php', 'php5', 'phtml', 'asp', 'aspx', 'jsp', 'jspx'];
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
import { IncomingMessage, ServerResponse } from 'http';
|
|
||||||
import { URL } from 'url';
|
|
||||||
import { logger } from '../utils/logger';
|
|
||||||
import { ConfigManager } from './config';
|
|
||||||
import { LegacyServer } from './legacy-server';
|
|
||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
export class ProxyRequestHandler {
|
|
||||||
private legacyServer: LegacyServer;
|
|
||||||
private settings = ConfigManager.getSettings();
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.legacyServer = new LegacyServer();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle incoming HTTP proxy request
|
|
||||||
* Replicates the request processing flow from Go's goproxy
|
|
||||||
*/
|
|
||||||
async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
||||||
try {
|
|
||||||
logger.info(`[ProxyHandler] ${req.method} ${req.url}`);
|
|
||||||
|
|
||||||
// Parse the request URL
|
|
||||||
// For proxy requests, req.url contains the full URL: http://example.com/path
|
|
||||||
// For regular requests, req.url contains just the path: /path
|
|
||||||
if (!req.url) {
|
|
||||||
this.sendError(res, 400, 'Bad Request: No URL');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let targetUrl: URL;
|
|
||||||
let hostname: string;
|
|
||||||
let urlPath: string;
|
|
||||||
|
|
||||||
// Check if this is a proxy-style request (starts with http://)
|
|
||||||
if (req.url.startsWith('http://') || req.url.startsWith('https://')) {
|
|
||||||
// Proxy-style request: GET http://example.com/path HTTP/1.1
|
|
||||||
targetUrl = new URL(req.url);
|
|
||||||
hostname = targetUrl.hostname;
|
|
||||||
urlPath = targetUrl.pathname + targetUrl.search;
|
|
||||||
} else if (req.url.startsWith('/http://') || req.url.startsWith('/https://')) {
|
|
||||||
// Path-based proxy request: GET /http://example.com/path HTTP/1.1
|
|
||||||
// Strip leading slash and parse as URL
|
|
||||||
const urlWithoutSlash = req.url.substring(1);
|
|
||||||
targetUrl = new URL(urlWithoutSlash);
|
|
||||||
hostname = targetUrl.hostname;
|
|
||||||
urlPath = targetUrl.pathname + targetUrl.search;
|
|
||||||
} else {
|
|
||||||
// Regular request: GET /path HTTP/1.1 with Host header
|
|
||||||
hostname = req.headers.host || 'localhost';
|
|
||||||
urlPath = req.url;
|
|
||||||
targetUrl = new URL(`http://${hostname}${urlPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`[ProxyHandler] Parsed - Host: ${hostname}, Path: ${urlPath}`);
|
|
||||||
|
|
||||||
// Step 1: Try GameZip server first (if enabled)
|
|
||||||
if (this.settings.gameZipPort) {
|
|
||||||
logger.debug(`[ProxyHandler] Trying GameZip server...`);
|
|
||||||
const gameZipResult = await this.tryGameZipServer(hostname, urlPath);
|
|
||||||
if (gameZipResult) {
|
|
||||||
logger.info(`[ProxyHandler] ✓ Served from GameZip`);
|
|
||||||
this.sendResponse(res, gameZipResult.data, gameZipResult.contentType, 'gamezipserver');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Try legacy file serving (local htdocs + external fallbacks)
|
|
||||||
logger.debug(`[ProxyHandler] Trying legacy server...`);
|
|
||||||
const legacyResult = await this.legacyServer.serveLegacy(hostname, urlPath);
|
|
||||||
|
|
||||||
logger.info(`[ProxyHandler] ✓ Served from ${legacyResult.source}`);
|
|
||||||
this.sendResponse(res, legacyResult.data, legacyResult.contentType, legacyResult.source);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[ProxyHandler] Error handling request:', error);
|
|
||||||
|
|
||||||
if (error instanceof Error && error.message.includes('not found')) {
|
|
||||||
this.sendError(res, 404, 'File not found in any source');
|
|
||||||
} else {
|
|
||||||
this.sendError(res, 500, 'Internal server error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Try to fetch from GameZip server
|
|
||||||
*/
|
|
||||||
private async tryGameZipServer(
|
|
||||||
hostname: string,
|
|
||||||
urlPath: string
|
|
||||||
): Promise<{ data: Buffer; contentType: string } | null> {
|
|
||||||
try {
|
|
||||||
// Make proxy-style request to GameZip server
|
|
||||||
// The GameZip server also accepts proxy-style requests: GET http://hostname/path HTTP/1.1
|
|
||||||
const gameZipServerUrl = `http://localhost:${this.settings.gameZipPort}`;
|
|
||||||
const targetUrl = `http://${hostname}${urlPath}`;
|
|
||||||
const requestUrl = `${gameZipServerUrl}/${targetUrl}`;
|
|
||||||
|
|
||||||
const response = await axios.get(requestUrl, {
|
|
||||||
responseType: 'arraybuffer',
|
|
||||||
timeout: 5000, // Quick timeout for local server
|
|
||||||
validateStatus: (status) => status === 200,
|
|
||||||
proxy: false, // Don't use system proxy
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = Buffer.from(response.data);
|
|
||||||
const contentType = response.headers['content-type'] || 'application/octet-stream';
|
|
||||||
|
|
||||||
return { data, contentType };
|
|
||||||
} catch (error) {
|
|
||||||
// GameZip server not available or file not found
|
|
||||||
logger.debug(`[ProxyHandler] GameZip unavailable or file not found`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send successful response
|
|
||||||
*/
|
|
||||||
private sendResponse(
|
|
||||||
res: ServerResponse,
|
|
||||||
data: Buffer,
|
|
||||||
contentType: string,
|
|
||||||
source: string
|
|
||||||
): void {
|
|
||||||
// CORS headers
|
|
||||||
if (this.settings.allowCrossDomain) {
|
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
||||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
||||||
res.setHeader('Access-Control-Allow-Headers', '*');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Content headers
|
|
||||||
res.setHeader('Content-Type', contentType);
|
|
||||||
res.setHeader('Content-Length', data.length);
|
|
||||||
res.setHeader('Cache-Control', 'public, max-age=86400'); // Cache for 24 hours
|
|
||||||
res.setHeader('X-Source', source); // Debug header
|
|
||||||
|
|
||||||
// Connection keep-alive
|
|
||||||
res.setHeader('Connection', 'keep-alive');
|
|
||||||
|
|
||||||
res.writeHead(200);
|
|
||||||
res.end(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send error response
|
|
||||||
*/
|
|
||||||
private sendError(res: ServerResponse, statusCode: number, message: string): void {
|
|
||||||
logger.warn(`[ProxyHandler] Sending error ${statusCode}: ${message}`);
|
|
||||||
|
|
||||||
// IMPORTANT: Set CORS headers even for error responses
|
|
||||||
// Otherwise browsers will show CORS error instead of the actual error
|
|
||||||
if (this.settings.allowCrossDomain) {
|
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
||||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
||||||
res.setHeader('Access-Control-Allow-Headers', '*');
|
|
||||||
}
|
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/plain');
|
|
||||||
res.writeHead(statusCode);
|
|
||||||
res.end(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle OPTIONS request for CORS preflight
|
|
||||||
*/
|
|
||||||
handleOptionsRequest(req: IncomingMessage, res: ServerResponse): void {
|
|
||||||
if (this.settings.allowCrossDomain) {
|
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
||||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
||||||
res.setHeader('Access-Control-Allow-Headers', '*');
|
|
||||||
res.setHeader('Access-Control-Max-Age', '86400');
|
|
||||||
}
|
|
||||||
|
|
||||||
res.writeHead(204);
|
|
||||||
res.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,520 +0,0 @@
|
|||||||
import express, { Request, Response, NextFunction } from 'express';
|
|
||||||
import axios from 'axios';
|
|
||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import { logger } from '../utils/logger';
|
|
||||||
import { getMimeType } from './mimeTypes';
|
|
||||||
import { zipManager } from './zip-manager';
|
|
||||||
|
|
||||||
export interface ProxyConfig {
|
|
||||||
proxyPort: number;
|
|
||||||
legacyHTDOCSPath: string;
|
|
||||||
gameDataPath: string;
|
|
||||||
externalFilePaths: string[];
|
|
||||||
allowCrossDomain: boolean;
|
|
||||||
chunkSize?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GameProxyServer {
|
|
||||||
private app: express.Application;
|
|
||||||
private config: ProxyConfig;
|
|
||||||
|
|
||||||
constructor(proxyConfig: ProxyConfig) {
|
|
||||||
this.app = express();
|
|
||||||
this.config = {
|
|
||||||
...proxyConfig,
|
|
||||||
chunkSize: proxyConfig.chunkSize || 8192, // 8KB default chunk size
|
|
||||||
};
|
|
||||||
this.setupMiddleware();
|
|
||||||
this.setupRoutes();
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupMiddleware(): void {
|
|
||||||
// CORS middleware
|
|
||||||
if (this.config.allowCrossDomain) {
|
|
||||||
this.app.use((req, res, next) => {
|
|
||||||
res.header('Access-Control-Allow-Origin', '*');
|
|
||||||
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
||||||
res.header(
|
|
||||||
'Access-Control-Allow-Headers',
|
|
||||||
'Origin, X-Requested-With, Content-Type, Accept'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (req.method === 'OPTIONS') {
|
|
||||||
res.sendStatus(200);
|
|
||||||
} else {
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request logging
|
|
||||||
this.app.use((req, res, next) => {
|
|
||||||
logger.debug(`[Proxy] ${req.method} ${req.path}`);
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupRoutes(): void {
|
|
||||||
// Main proxy route - handles all requests
|
|
||||||
this.app.get('*', async (req: Request, res: Response, next: NextFunction) => {
|
|
||||||
try {
|
|
||||||
await this.handleProxyRequest(req, res);
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Error handler
|
|
||||||
this.app.use((err: Error, req: Request, res: Response, _next: NextFunction) => {
|
|
||||||
logger.error('[Proxy] Error:', err);
|
|
||||||
res.status(500).json({
|
|
||||||
error: {
|
|
||||||
message: 'Proxy server error',
|
|
||||||
statusCode: 500,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleProxyRequest(req: Request, res: Response): Promise<void> {
|
|
||||||
let requestPath = req.path.substring(1); // Remove leading slash
|
|
||||||
|
|
||||||
// Parse URL if the request path is a full URL (e.g., http://domain.com/path)
|
|
||||||
const parsedPath = this.parseProxyPath(requestPath);
|
|
||||||
requestPath = parsedPath;
|
|
||||||
|
|
||||||
logger.debug(`[Proxy] Resolved path: ${requestPath}`);
|
|
||||||
|
|
||||||
// Try to serve from local paths first
|
|
||||||
const localFile = await this.tryLocalPaths(requestPath);
|
|
||||||
|
|
||||||
if (localFile) {
|
|
||||||
await this.serveFile(localFile, res);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to serve from mounted ZIP files (mimics Flashpoint ZipServer)
|
|
||||||
const zipContent = await this.tryZipFiles(requestPath);
|
|
||||||
|
|
||||||
if (zipContent) {
|
|
||||||
await this.serveZipContent(zipContent, requestPath, res);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try external fallback URLs
|
|
||||||
const externalContent = await this.tryExternalPaths(requestPath);
|
|
||||||
|
|
||||||
if (externalContent) {
|
|
||||||
await this.serveExternalContent(externalContent, res);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// File not found
|
|
||||||
logger.warn(`[Proxy] File not found: ${requestPath}`);
|
|
||||||
res.status(404).json({
|
|
||||||
error: {
|
|
||||||
message: 'File not found',
|
|
||||||
path: requestPath,
|
|
||||||
statusCode: 404,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse proxy path - converts full URLs to local file paths
|
|
||||||
* Examples:
|
|
||||||
* http://farm.maxgames.com/game.swf -> farm.maxgames.com/game.swf
|
|
||||||
* https://example.com/path/file.html -> example.com/path/file.html
|
|
||||||
* http://www.example.com/Find%20My%20File.swf -> www.example.com/Find My File.swf
|
|
||||||
* regular/path/file.swf -> regular/path/file.swf (unchanged)
|
|
||||||
*/
|
|
||||||
private parseProxyPath(requestPath: string): string {
|
|
||||||
try {
|
|
||||||
// Check if it's a full URL (starts with http:// or https://)
|
|
||||||
if (requestPath.startsWith('http://') || requestPath.startsWith('https://')) {
|
|
||||||
const url = new URL(requestPath);
|
|
||||||
|
|
||||||
// CRITICAL FIX: Decode the pathname to handle URL-encoded characters (spaces, etc.)
|
|
||||||
// The JavaScript URL class preserves encoding, but filesystem paths need actual characters
|
|
||||||
// This matches how PHP's web server automatically decodes paths for the Flashpoint Launcher
|
|
||||||
const decodedPathname = decodeURIComponent(url.pathname);
|
|
||||||
const decodedSearch = url.search ? decodeURIComponent(url.search) : '';
|
|
||||||
|
|
||||||
// Combine hostname and decoded pathname, removing leading slash from pathname
|
|
||||||
const localPath = url.hostname + decodedPathname + decodedSearch;
|
|
||||||
logger.debug(`[Proxy] Parsed URL: ${requestPath} -> ${localPath}`);
|
|
||||||
return localPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If not a URL, return as-is
|
|
||||||
return requestPath;
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn(`[Proxy] Failed to parse URL: ${requestPath}, using as-is`);
|
|
||||||
return requestPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async tryLocalPaths(requestPath: string): Promise<string | null> {
|
|
||||||
// Priority order:
|
|
||||||
// 1. Legacy htdocs path
|
|
||||||
// 2. Game data path
|
|
||||||
|
|
||||||
const pathsToTry = [
|
|
||||||
path.join(this.config.legacyHTDOCSPath, requestPath),
|
|
||||||
path.join(this.config.gameDataPath, requestPath),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const filePath of pathsToTry) {
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(filePath)) {
|
|
||||||
const stats = fs.statSync(filePath);
|
|
||||||
|
|
||||||
if (stats.isFile()) {
|
|
||||||
return filePath;
|
|
||||||
} else if (stats.isDirectory()) {
|
|
||||||
// Try index files
|
|
||||||
const indexFiles = ['index.html', 'index.htm', 'index.php'];
|
|
||||||
for (const indexFile of indexFiles) {
|
|
||||||
const indexPath = path.join(filePath, indexFile);
|
|
||||||
if (fs.existsSync(indexPath) && fs.statSync(indexPath).isFile()) {
|
|
||||||
return indexPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.debug(`[Proxy] Error checking path ${filePath}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Try to serve file from mounted ZIP archives in Data/Games
|
|
||||||
* This replicates the ZipServer functionality from FlashpointGameServer (port 22501)
|
|
||||||
*/
|
|
||||||
private async tryZipFiles(requestPath: string): Promise<Buffer | null> {
|
|
||||||
logger.debug(`[Proxy] Checking ZIP files for: ${requestPath}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// The zipManager.findFile() already searches all mounted ZIPs
|
|
||||||
// It tries multiple path variants: content/, htdocs/, etc.
|
|
||||||
const result = await zipManager.findFile(requestPath);
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
logger.info(
|
|
||||||
`[Proxy] ✓ Found in ZIP ${result.mountId}: ${requestPath} (${result.data.length} bytes)`
|
|
||||||
);
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug(`[Proxy] File not found in any mounted ZIP: ${requestPath}`);
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`[Proxy] Error searching ZIP files:`, error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Auto-discover and mount all ZIP files in Data/Games directory
|
|
||||||
* This is called on server startup to make game ZIPs available
|
|
||||||
*/
|
|
||||||
private async autoMountZipFiles(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const gamesPath = this.config.gameDataPath;
|
|
||||||
|
|
||||||
if (!fs.existsSync(gamesPath)) {
|
|
||||||
logger.warn(`[Proxy] Games directory not found: ${gamesPath}`);
|
|
||||||
logger.warn(`[Proxy] ZIP file serving will be disabled`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`[Proxy] Auto-mounting ZIP files from: ${gamesPath}`);
|
|
||||||
|
|
||||||
// Find all .zip files in Data/Games
|
|
||||||
const files = fs.readdirSync(gamesPath);
|
|
||||||
const zipFiles = files.filter((f) => f.toLowerCase().endsWith('.zip'));
|
|
||||||
|
|
||||||
if (zipFiles.length === 0) {
|
|
||||||
logger.info(`[Proxy] No ZIP files found in ${gamesPath}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`[Proxy] Found ${zipFiles.length} ZIP file(s) to mount`);
|
|
||||||
|
|
||||||
// Mount each ZIP file
|
|
||||||
let mountedCount = 0;
|
|
||||||
let failedCount = 0;
|
|
||||||
|
|
||||||
for (const zipFile of zipFiles) {
|
|
||||||
try {
|
|
||||||
const zipPath = path.join(gamesPath, zipFile);
|
|
||||||
// Use filename (without extension) as mount ID
|
|
||||||
const mountId = path.basename(zipFile, '.zip');
|
|
||||||
|
|
||||||
await zipManager.mount(mountId, zipPath);
|
|
||||||
mountedCount++;
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn(`[Proxy] Failed to mount ${zipFile}:`, error);
|
|
||||||
failedCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`[Proxy] ========================================`);
|
|
||||||
logger.info(`[Proxy] ZIP Auto-Mount Summary:`);
|
|
||||||
logger.info(`[Proxy] Total found: ${zipFiles.length}`);
|
|
||||||
logger.info(`[Proxy] Mounted: ${mountedCount}`);
|
|
||||||
if (failedCount > 0) {
|
|
||||||
logger.warn(`[Proxy] Failed: ${failedCount}`);
|
|
||||||
}
|
|
||||||
logger.info(`[Proxy] ========================================`);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`[Proxy] Error during ZIP auto-mount:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serve content from ZIP file
|
|
||||||
*/
|
|
||||||
private async serveZipContent(
|
|
||||||
content: Buffer,
|
|
||||||
requestPath: string,
|
|
||||||
res: Response
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
// Determine MIME type from file extension
|
|
||||||
const ext = path.extname(requestPath).substring(1).toLowerCase();
|
|
||||||
const mimeType = getMimeType(ext);
|
|
||||||
|
|
||||||
res.setHeader('Content-Type', mimeType);
|
|
||||||
res.setHeader('Content-Length', content.length);
|
|
||||||
res.setHeader('X-Source', 'zip');
|
|
||||||
res.setHeader('Cache-Control', 'public, max-age=86400'); // Cache for 24 hours
|
|
||||||
|
|
||||||
res.send(content);
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
`[Proxy] Served from ZIP: ${requestPath} (${mimeType}, ${content.length} bytes)`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[Proxy] Error serving ZIP content:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encode URL for CDN access:
|
|
||||||
* - Ensure path segments are properly encoded
|
|
||||||
* - Preserve URL structure (protocol, domain, query params)
|
|
||||||
* - Handle already-encoded segments gracefully
|
|
||||||
*/
|
|
||||||
private encodeUrlForCDN(url: string): string {
|
|
||||||
try {
|
|
||||||
const urlObj = new URL(url);
|
|
||||||
|
|
||||||
// Encode each path segment individually
|
|
||||||
// This prevents double-encoding while ensuring special characters are handled
|
|
||||||
const pathSegments = urlObj.pathname.split('/');
|
|
||||||
const encodedSegments = pathSegments.map((segment) => {
|
|
||||||
if (!segment) return segment; // Preserve empty segments (leading/trailing slashes)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try to decode first - if it succeeds, the segment might be already encoded
|
|
||||||
const decoded = decodeURIComponent(segment);
|
|
||||||
// Re-encode to ensure consistency
|
|
||||||
return encodeURIComponent(decoded);
|
|
||||||
} catch {
|
|
||||||
// If decode fails, segment is either already properly encoded or has invalid encoding
|
|
||||||
// Try to encode it directly
|
|
||||||
return encodeURIComponent(segment);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
urlObj.pathname = encodedSegments.join('/');
|
|
||||||
|
|
||||||
// Also encode query parameters properly
|
|
||||||
if (urlObj.search) {
|
|
||||||
const params = new URLSearchParams(urlObj.search);
|
|
||||||
urlObj.search = params.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
return urlObj.toString();
|
|
||||||
} catch (error) {
|
|
||||||
// If URL parsing fails, return original URL
|
|
||||||
logger.warn(
|
|
||||||
`[Proxy] Failed to encode URL for CDN: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
||||||
);
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async tryExternalPaths(requestPath: string): Promise<Buffer | null> {
|
|
||||||
logger.info(`[Proxy] Trying external CDN for: ${requestPath}`);
|
|
||||||
|
|
||||||
for (const baseUrl of this.config.externalFilePaths) {
|
|
||||||
try {
|
|
||||||
const url = `${baseUrl.trim()}/${requestPath}`;
|
|
||||||
// Encode URL like PHP router does
|
|
||||||
const encodedUrl = this.encodeUrlForCDN(url);
|
|
||||||
logger.info(`[Proxy] Attempting: ${encodedUrl}`);
|
|
||||||
|
|
||||||
const response = await axios.get(encodedUrl, {
|
|
||||||
responseType: 'arraybuffer',
|
|
||||||
timeout: 30000, // 30 second timeout
|
|
||||||
maxRedirects: 5,
|
|
||||||
headers: {
|
|
||||||
// Match PHP router's Accept-Encoding header
|
|
||||||
'Accept-Encoding': 'identity',
|
|
||||||
},
|
|
||||||
// Note: Removed validateStatus to allow axios to follow redirects automatically
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.status === 200 && response.data) {
|
|
||||||
const buffer = Buffer.from(response.data);
|
|
||||||
|
|
||||||
// Archive.org serves empty files instead of 404s - check for this
|
|
||||||
if (buffer.length === 0) {
|
|
||||||
logger.info(`[Proxy] ✗ Empty file returned (treated as 404): ${encodedUrl}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`[Proxy] ✓ Successfully fetched from external: ${encodedUrl} (${buffer.length} bytes)`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Cache the downloaded file locally for future use
|
|
||||||
await this.cacheDownloadedFile(requestPath, buffer);
|
|
||||||
|
|
||||||
return buffer;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
let errorMsg = 'Unknown error';
|
|
||||||
if (error && typeof error === 'object') {
|
|
||||||
const err = error as { response?: { status?: number }; message?: string };
|
|
||||||
errorMsg = err.response?.status
|
|
||||||
? `HTTP ${err.response.status}`
|
|
||||||
: err.message || 'Unknown error';
|
|
||||||
}
|
|
||||||
logger.info(`[Proxy] ✗ Failed (${errorMsg}): ${baseUrl.trim()}/${requestPath}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.warn(`[Proxy] All CDN sources exhausted for: ${requestPath}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache downloaded file locally for future use (like Flashpoint Launcher does)
|
|
||||||
*/
|
|
||||||
private async cacheDownloadedFile(requestPath: string, content: Buffer): Promise<void> {
|
|
||||||
try {
|
|
||||||
// Determine the best local path (prefer Legacy/htdocs)
|
|
||||||
const localPath = path.join(this.config.legacyHTDOCSPath, requestPath);
|
|
||||||
const dir = path.dirname(localPath);
|
|
||||||
|
|
||||||
// Create directory structure if it doesn't exist
|
|
||||||
if (!fs.existsSync(dir)) {
|
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write the file
|
|
||||||
fs.writeFileSync(localPath, content);
|
|
||||||
logger.info(`[Proxy] Cached downloaded file: ${localPath}`);
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn(`[Proxy] Failed to cache file locally:`, error);
|
|
||||||
// Don't throw - caching is optional, we already have the content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async serveFile(filePath: string, res: Response): Promise<void> {
|
|
||||||
try {
|
|
||||||
const ext = path.extname(filePath).substring(1).toLowerCase();
|
|
||||||
const mimeType = getMimeType(ext);
|
|
||||||
|
|
||||||
res.setHeader('Content-Type', mimeType);
|
|
||||||
|
|
||||||
const stats = fs.statSync(filePath);
|
|
||||||
res.setHeader('Content-Length', stats.size);
|
|
||||||
|
|
||||||
// Stream file with chunking for large files
|
|
||||||
const readStream = fs.createReadStream(filePath, {
|
|
||||||
highWaterMark: this.config.chunkSize,
|
|
||||||
});
|
|
||||||
|
|
||||||
readStream.on('error', (error) => {
|
|
||||||
logger.error('[Proxy] Stream error:', error);
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.status(500).json({
|
|
||||||
error: {
|
|
||||||
message: 'Error streaming file',
|
|
||||||
statusCode: 500,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
readStream.pipe(res);
|
|
||||||
|
|
||||||
logger.debug(`[Proxy] Serving local file: ${filePath} (${mimeType})`);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[Proxy] Error serving file:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async serveExternalContent(content: Buffer, res: Response): Promise<void> {
|
|
||||||
try {
|
|
||||||
// Try to determine MIME type from content or default to octet-stream
|
|
||||||
const mimeType = 'application/octet-stream'; // Will be improved with actual detection
|
|
||||||
|
|
||||||
res.setHeader('Content-Type', mimeType);
|
|
||||||
res.setHeader('Content-Length', content.length);
|
|
||||||
res.send(content);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[Proxy] Error serving external content:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async start(): Promise<void> {
|
|
||||||
// Auto-mount ZIP files from Data/Games before starting the server
|
|
||||||
await this.autoMountZipFiles();
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const server = this.app.listen(this.config.proxyPort, () => {
|
|
||||||
logger.info(`[Proxy] Game proxy server listening on port ${this.config.proxyPort}`);
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
server.on('error', (error: NodeJS.ErrnoException) => {
|
|
||||||
if (error.code === 'EADDRINUSE') {
|
|
||||||
logger.error(`[Proxy] Port ${this.config.proxyPort} is already in use.`);
|
|
||||||
logger.error(
|
|
||||||
'[Proxy] Please check if another instance is running or change PROXY_PORT in .env'
|
|
||||||
);
|
|
||||||
logger.error(
|
|
||||||
'[Proxy] You can find the process using: netstat -ano | findstr :' +
|
|
||||||
this.config.proxyPort
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
logger.error('[Proxy] Failed to start proxy server:', error);
|
|
||||||
}
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public getApp(): express.Application {
|
|
||||||
return this.app;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Factory function to create and start proxy server
|
|
||||||
export async function createProxyServer(proxyConfig: ProxyConfig): Promise<GameProxyServer> {
|
|
||||||
const server = new GameProxyServer(proxyConfig);
|
|
||||||
await server.start();
|
|
||||||
return server;
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
import StreamZip from 'node-stream-zip';
|
|
||||||
import fs from 'fs/promises';
|
|
||||||
import { logger } from '../utils/logger';
|
|
||||||
|
|
||||||
interface MountedZip {
|
|
||||||
id: string;
|
|
||||||
zipPath: string;
|
|
||||||
zip: StreamZip.StreamZipAsync;
|
|
||||||
mountTime: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ZipManager {
|
|
||||||
private mountedZips: Map<string, MountedZip> = new Map();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mount a ZIP file for serving
|
|
||||||
* @param id Unique identifier for this mount (e.g., game data ID)
|
|
||||||
* @param zipPath Absolute path to the ZIP file
|
|
||||||
*/
|
|
||||||
async mount(id: string, zipPath: string): Promise<void> {
|
|
||||||
// Check if already mounted
|
|
||||||
if (this.mountedZips.has(id)) {
|
|
||||||
logger.debug(`[ZipManager] ZIP already mounted: ${id}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify ZIP file exists
|
|
||||||
try {
|
|
||||||
await fs.access(zipPath);
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`ZIP file not found: ${zipPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`[ZipManager] Mounting ZIP: ${id} -> ${zipPath}`);
|
|
||||||
|
|
||||||
// Create ZIP reader
|
|
||||||
const zip = new StreamZip.async({ file: zipPath });
|
|
||||||
|
|
||||||
// Store mounted ZIP
|
|
||||||
this.mountedZips.set(id, {
|
|
||||||
id,
|
|
||||||
zipPath,
|
|
||||||
zip,
|
|
||||||
mountTime: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(`[ZipManager] ✓ Mounted ZIP: ${id} (${await this.getFileCount(id)} files)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unmount a ZIP file
|
|
||||||
* @param id Unique identifier of the mounted ZIP
|
|
||||||
*/
|
|
||||||
async unmount(id: string): Promise<boolean> {
|
|
||||||
const mounted = this.mountedZips.get(id);
|
|
||||||
if (!mounted) {
|
|
||||||
logger.warn(`[ZipManager] ZIP not mounted: ${id}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`[ZipManager] Unmounting ZIP: ${id}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await mounted.zip.close();
|
|
||||||
this.mountedZips.delete(id);
|
|
||||||
logger.info(`[ZipManager] ✓ Unmounted ZIP: ${id}`);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`[ZipManager] Error unmounting ZIP: ${id}`, error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a file from a mounted ZIP
|
|
||||||
* @param id ZIP mount ID
|
|
||||||
* @param filePath Path within the ZIP (e.g., "htdocs/www.example.com/file.swf")
|
|
||||||
*/
|
|
||||||
async getFile(id: string, filePath: string): Promise<Buffer | null> {
|
|
||||||
const mounted = this.mountedZips.get(id);
|
|
||||||
if (!mounted) {
|
|
||||||
logger.debug(`[ZipManager] ZIP not mounted: ${id}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Normalize path (remove leading slash)
|
|
||||||
const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath;
|
|
||||||
|
|
||||||
logger.debug(`[ZipManager] Reading from ZIP ${id}: ${normalizedPath}`);
|
|
||||||
|
|
||||||
// Get file from ZIP
|
|
||||||
const data = await mounted.zip.entryData(normalizedPath);
|
|
||||||
|
|
||||||
logger.debug(`[ZipManager] ✓ Read ${data.length} bytes from ${id}:${normalizedPath}`);
|
|
||||||
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
// File not found in ZIP
|
|
||||||
logger.debug(`[ZipManager] File not found in ${id}: ${filePath}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search for a file across all mounted ZIPs
|
|
||||||
* @param relPath Relative path (e.g., "www.example.com/path/file.swf")
|
|
||||||
* @returns Buffer and mount ID if found
|
|
||||||
*/
|
|
||||||
async findFile(relPath: string): Promise<{ data: Buffer; mountId: string } | null> {
|
|
||||||
// Try with different prefixes (different ZIP structures)
|
|
||||||
const pathsToTry = [
|
|
||||||
`content/${relPath}`, // Most common: content/domain/path
|
|
||||||
`htdocs/${relPath}`, // Standard: htdocs/domain/path
|
|
||||||
relPath, // No prefix: domain/path
|
|
||||||
`Legacy/htdocs/${relPath}`, // Full path: Legacy/htdocs/domain/path
|
|
||||||
];
|
|
||||||
|
|
||||||
// Search all mounted ZIPs
|
|
||||||
for (const [id, _mounted] of this.mountedZips) {
|
|
||||||
for (const pathVariant of pathsToTry) {
|
|
||||||
const data = await this.getFile(id, pathVariant);
|
|
||||||
if (data) {
|
|
||||||
logger.info(`[ZipManager] ✓ Found in ${id}: ${pathVariant}`);
|
|
||||||
return { data, mountId: id };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get list of mounted ZIPs
|
|
||||||
*/
|
|
||||||
getMountedZips(): Array<{ id: string; zipPath: string; mountTime: Date; fileCount: number }> {
|
|
||||||
const result: Array<{ id: string; zipPath: string; mountTime: Date; fileCount: number }> = [];
|
|
||||||
|
|
||||||
for (const [id, mounted] of this.mountedZips) {
|
|
||||||
result.push({
|
|
||||||
id,
|
|
||||||
zipPath: mounted.zipPath,
|
|
||||||
mountTime: mounted.mountTime,
|
|
||||||
fileCount: 0, // Will be populated async
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get number of files in a mounted ZIP
|
|
||||||
*/
|
|
||||||
private async getFileCount(id: string): Promise<number> {
|
|
||||||
const mounted = this.mountedZips.get(id);
|
|
||||||
if (!mounted) return 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const entries = await mounted.zip.entries();
|
|
||||||
return Object.keys(entries).length;
|
|
||||||
} catch (error) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List files in a mounted ZIP (for debugging)
|
|
||||||
*/
|
|
||||||
async listFiles(id: string, pattern?: string): Promise<string[]> {
|
|
||||||
const mounted = this.mountedZips.get(id);
|
|
||||||
if (!mounted) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const entries = await mounted.zip.entries();
|
|
||||||
let files = Object.keys(entries);
|
|
||||||
|
|
||||||
if (pattern) {
|
|
||||||
const regex = new RegExp(pattern, 'i');
|
|
||||||
files = files.filter((f) => regex.test(f));
|
|
||||||
}
|
|
||||||
|
|
||||||
return files;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`[ZipManager] Error listing files in ${id}:`, error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a ZIP is mounted
|
|
||||||
*/
|
|
||||||
isMounted(id: string): boolean {
|
|
||||||
return this.mountedZips.has(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unmount all ZIPs (cleanup on shutdown)
|
|
||||||
*/
|
|
||||||
async unmountAll(): Promise<void> {
|
|
||||||
logger.info(`[ZipManager] Unmounting all ZIPs (${this.mountedZips.size})...`);
|
|
||||||
|
|
||||||
const promises = Array.from(this.mountedZips.keys()).map((id) => this.unmount(id));
|
|
||||||
await Promise.all(promises);
|
|
||||||
|
|
||||||
logger.info(`[ZipManager] ✓ All ZIPs unmounted`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Singleton instance
|
|
||||||
export const zipManager = new ZipManager();
|
|
||||||
@@ -72,7 +72,14 @@ router.get(
|
|||||||
} = queryResult.data;
|
} = queryResult.data;
|
||||||
|
|
||||||
// Build filters object
|
// 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 (userId !== undefined) filters.userId = userId;
|
||||||
if (username) filters.username = username;
|
if (username) filters.username = username;
|
||||||
if (action) filters.action = action;
|
if (action) filters.action = action;
|
||||||
@@ -96,6 +103,16 @@ router.get(
|
|||||||
requirePermission('activities.read'),
|
requirePermission('activities.read'),
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const timeRange = (req.query.timeRange as '24h' | '7d' | '30d') || DEFAULT_TIME_RANGE;
|
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 =
|
const customRange =
|
||||||
req.query.startDate && req.query.endDate
|
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 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;
|
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);
|
const result = await activityService.getTopActions(limit, timeRange);
|
||||||
|
|
||||||
res.json({
|
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);
|
const result = await activityService.getBreakdown(groupBy, limit, timeRange);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
+92
-18
@@ -5,8 +5,14 @@ import { ActivityService } from '../services/ActivityService';
|
|||||||
import { UserDatabaseService } from '../services/UserDatabaseService';
|
import { UserDatabaseService } from '../services/UserDatabaseService';
|
||||||
import { AppError } from '../middleware/errorHandler';
|
import { AppError } from '../middleware/errorHandler';
|
||||||
import { asyncHandler } from '../middleware/asyncHandler';
|
import { asyncHandler } from '../middleware/asyncHandler';
|
||||||
|
import { softAuth } from '../middleware/auth';
|
||||||
import { logActivity } from '../middleware/activityLogger';
|
import { logActivity } from '../middleware/activityLogger';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
import {
|
||||||
|
setRefreshTokenCookie,
|
||||||
|
clearRefreshTokenCookie,
|
||||||
|
getRefreshTokenFromCookie,
|
||||||
|
} from '../utils/cookies';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const router = Router();
|
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
|
// Validation schemas
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
username: z.string().min(3).max(50),
|
username: z.string().min(3).max(50),
|
||||||
@@ -73,10 +102,6 @@ const registerSchema = z.object({
|
|||||||
password: z.string().min(6),
|
password: z.string().min(6),
|
||||||
});
|
});
|
||||||
|
|
||||||
const refreshTokenSchema = z.object({
|
|
||||||
refreshToken: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/auth/setup-status
|
* GET /api/auth/setup-status
|
||||||
* Check if the system needs initial setup (no users exist)
|
* Check if the system needs initial setup (no users exist)
|
||||||
@@ -104,7 +129,7 @@ router.post(
|
|||||||
const credentials = loginSchema.parse(req.body);
|
const credentials = loginSchema.parse(req.body);
|
||||||
const ipAddress = req.ip || '';
|
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);
|
const result = await authService.login(credentials, ipAddress);
|
||||||
|
|
||||||
@@ -119,16 +144,32 @@ router.post(
|
|||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`[Auth] Login successful for user: ${credentials.username}`);
|
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) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
logger.warn(`[Auth] Validation error during login: ${error.errors[0].message}`);
|
const username =
|
||||||
throw new AppError(400, `Validation error: ${error.errors[0].message}`);
|
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
|
// Log failed login attempt
|
||||||
const ipAddress = req.ip || '';
|
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 {
|
try {
|
||||||
await activityService.log({
|
await activityService.log({
|
||||||
@@ -152,7 +193,7 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log the error with full details before passing to error handler
|
// 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),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
});
|
});
|
||||||
@@ -194,14 +235,31 @@ router.post(
|
|||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`[Auth] Registration successful for user: ${data.username}`);
|
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) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
logger.warn(`[Auth] Validation error during registration: ${error.errors[0].message}`);
|
const username =
|
||||||
throw new AppError(400, `Validation error: ${error.errors[0].message}`);
|
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),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
});
|
});
|
||||||
@@ -217,11 +275,16 @@ router.post(
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/logout',
|
'/logout',
|
||||||
|
softAuth,
|
||||||
logActivity('auth.logout', 'auth'),
|
logActivity('auth.logout', 'auth'),
|
||||||
asyncHandler(async (req, res) => {
|
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' });
|
res.json({ success: true, message: 'Logged out successfully' });
|
||||||
})
|
})
|
||||||
@@ -233,12 +296,23 @@ router.post(
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/refresh',
|
'/refresh',
|
||||||
|
refreshLimiter,
|
||||||
asyncHandler(async (req, res) => {
|
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);
|
const tokens = await authService.refreshToken(refreshToken);
|
||||||
|
|
||||||
res.json(tokens);
|
// Set new refresh token cookie (token rotation)
|
||||||
|
setRefreshTokenCookie(res, tokens.refreshToken);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
accessToken: tokens.accessToken,
|
||||||
|
expiresIn: tokens.expiresIn,
|
||||||
|
});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+26
-15
@@ -47,23 +47,34 @@ router.post(
|
|||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const { cacheType } = req.body;
|
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> = {};
|
const results: Record<string, boolean> = {};
|
||||||
|
|
||||||
if (!cacheType || cacheType === 'gameSearch') {
|
// Use switch to fix mutually exclusive cache clearing logic
|
||||||
GameSearchCache.clearCache();
|
switch (cacheType) {
|
||||||
results.gameSearch = true;
|
case 'gameSearch':
|
||||||
}
|
GameSearchCache.clearCache();
|
||||||
|
results.gameSearch = true;
|
||||||
if (!cacheType || cacheType === 'permissions') {
|
break;
|
||||||
PermissionCache.clearAll();
|
case 'permissions':
|
||||||
results.permissions = true;
|
PermissionCache.clearAll();
|
||||||
}
|
results.permissions = true;
|
||||||
|
break;
|
||||||
if (!cacheType || cacheType === 'all') {
|
default:
|
||||||
GameSearchCache.clearCache();
|
// Clear all caches when cacheType is 'all' or undefined
|
||||||
PermissionCache.clearAll();
|
GameSearchCache.clearCache();
|
||||||
results.gameSearch = true;
|
PermissionCache.clearAll();
|
||||||
results.permissions = true;
|
results.gameSearch = true;
|
||||||
|
results.permissions = true;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { Router } from 'express';
|
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 { AppError } from '../middleware/errorHandler';
|
||||||
import { asyncHandler } from '../middleware/asyncHandler';
|
import { asyncHandler } from '../middleware/asyncHandler';
|
||||||
import { optionalAuth } from '../middleware/auth';
|
import { optionalAuth } from '../middleware/auth';
|
||||||
@@ -8,6 +12,11 @@ import { logActivity } from '../middleware/activityLogger';
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
const communityPlaylistService = new CommunityPlaylistService();
|
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
|
// GET /api/community-playlists - Fetch list of community playlists from wiki
|
||||||
router.get(
|
router.get(
|
||||||
'/',
|
'/',
|
||||||
@@ -39,10 +48,25 @@ router.post(
|
|||||||
gameCount: res.locals.gameCount || 0,
|
gameCount: res.locals.gameCount || 0,
|
||||||
})),
|
})),
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const { downloadUrl } = req.body;
|
// Validate request body
|
||||||
|
const { downloadUrl } = downloadSchema.parse(req.body);
|
||||||
|
|
||||||
if (!downloadUrl) {
|
try {
|
||||||
throw new AppError(400, 'downloadUrl is required');
|
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);
|
const result = await communityPlaylistService.downloadPlaylist(downloadUrl);
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
|
import fs from 'fs';
|
||||||
import { DatabaseService } from '../services/DatabaseService';
|
import { DatabaseService } from '../services/DatabaseService';
|
||||||
import { authenticate } from '../middleware/auth';
|
import { authenticate } from '../middleware/auth';
|
||||||
import { requirePermission } from '../middleware/rbac';
|
import { requirePermission } from '../middleware/rbac';
|
||||||
import { logActivity } from '../middleware/activityLogger';
|
import { logActivity } from '../middleware/activityLogger';
|
||||||
|
import { asyncHandler } from '../middleware/asyncHandler';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -28,7 +31,7 @@ router.post(
|
|||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('settings.update'),
|
requirePermission('settings.update'),
|
||||||
logActivity('database.reload', 'database'),
|
logActivity('database.reload', 'database'),
|
||||||
async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
logger.info('Manual database reload requested');
|
logger.info('Manual database reload requested');
|
||||||
|
|
||||||
@@ -45,7 +48,7 @@ router.post(
|
|||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,11 +71,8 @@ router.get(
|
|||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('settings.update'),
|
requirePermission('settings.update'),
|
||||||
logActivity('database.status', 'database'),
|
logActivity('database.status', 'database'),
|
||||||
async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const fs = require('fs');
|
|
||||||
const { config } = require('../config');
|
|
||||||
|
|
||||||
const isConnected = DatabaseService.isConnected();
|
const isConnected = DatabaseService.isConnected();
|
||||||
const stats = fs.statSync(config.flashpointDbPath);
|
const stats = fs.statSync(config.flashpointDbPath);
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ router.get(
|
|||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
+266
-248
@@ -1,4 +1,5 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
import { DownloadManager } from '../services/DownloadManager';
|
import { DownloadManager } from '../services/DownloadManager';
|
||||||
import { PreferencesService } from '../services/PreferencesService';
|
import { PreferencesService } from '../services/PreferencesService';
|
||||||
import { GameDatabaseUpdater } from '../services/GameDatabaseUpdater';
|
import { GameDatabaseUpdater } from '../services/GameDatabaseUpdater';
|
||||||
@@ -7,8 +8,16 @@ import { ActivityService } from '../services/ActivityService';
|
|||||||
import { authenticate } from '../middleware/auth';
|
import { authenticate } from '../middleware/auth';
|
||||||
import { requirePermission } from '../middleware/rbac';
|
import { requirePermission } from '../middleware/rbac';
|
||||||
import { logActivity } from '../middleware/activityLogger';
|
import { logActivity } from '../middleware/activityLogger';
|
||||||
|
import { asyncHandler } from '../middleware/asyncHandler';
|
||||||
import { logger } from '../utils/logger';
|
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 router = Router();
|
||||||
const gameService = new GameService();
|
const gameService = new GameService();
|
||||||
const activityService = new ActivityService();
|
const activityService = new ActivityService();
|
||||||
@@ -40,131 +49,133 @@ router.post(
|
|||||||
gameTitle: res.locals.gameTitle,
|
gameTitle: res.locals.gameTitle,
|
||||||
sources: res.locals.sources,
|
sources: res.locals.sources,
|
||||||
})),
|
})),
|
||||||
async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
try {
|
const gameId = req.params.id;
|
||||||
const gameId = req.params.id;
|
|
||||||
const { gameDataId: requestedGameDataId } = req.body;
|
|
||||||
|
|
||||||
logger.info('Download request received', { gameId, requestedGameDataId });
|
// Validate request body
|
||||||
|
const bodyResult = downloadBodySchema.safeParse(req.body);
|
||||||
// Get game from database
|
if (!bodyResult.success) {
|
||||||
const game = await gameService.getGameById(gameId);
|
return res.status(400).json({
|
||||||
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({
|
|
||||||
success: false,
|
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',
|
'/:id/download/progress',
|
||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('games.download'),
|
requirePermission('games.download'),
|
||||||
async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
try {
|
const gameId = req.params.id;
|
||||||
const gameId = req.params.id;
|
|
||||||
|
|
||||||
// Set SSE headers
|
// Set SSE headers
|
||||||
res.setHeader('Content-Type', 'text/event-stream');
|
res.setHeader('Content-Type', 'text/event-stream');
|
||||||
res.setHeader('Cache-Control', 'no-cache');
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
res.setHeader('Connection', 'keep-alive');
|
res.setHeader('Connection', 'keep-alive');
|
||||||
res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering
|
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
|
// Get game to find gameDataId
|
||||||
const game = await gameService.getGameById(gameId);
|
const game = await gameService.getGameById(gameId);
|
||||||
if (!game) {
|
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
|
|
||||||
res.write(
|
res.write(
|
||||||
`data: ${JSON.stringify({
|
`data: ${JSON.stringify({
|
||||||
percent: 0,
|
status: 'error',
|
||||||
status: 'waiting',
|
error: 'Game not found',
|
||||||
details: 'Preparing download...',
|
|
||||||
})}\n\n`
|
})}\n\n`
|
||||||
);
|
);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Poll for download progress
|
const gameDataId = await gameService.getGameDataId(gameId);
|
||||||
// Note: This is a simplified implementation. In production, you'd want
|
if (!gameDataId) {
|
||||||
// a proper event emitter system to push updates rather than polling.
|
res.write(
|
||||||
const pollInterval = setInterval(async () => {
|
`data: ${JSON.stringify({
|
||||||
try {
|
status: 'error',
|
||||||
// Check if download is still active
|
error: 'Game has no game data',
|
||||||
const isActive = DownloadManager.isDownloadActive(gameDataId);
|
})}\n\n`
|
||||||
|
);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!isActive) {
|
// Send initial status
|
||||||
// Check if download completed successfully
|
res.write(
|
||||||
const isDownloaded = await GameDatabaseUpdater.isDownloaded(gameDataId);
|
`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(
|
res.write(
|
||||||
`data: ${JSON.stringify({
|
`data: ${JSON.stringify({
|
||||||
percent: 100,
|
percent: 100,
|
||||||
@@ -254,24 +273,29 @@ router.get(
|
|||||||
details: 'Download completed successfully',
|
details: 'Download completed successfully',
|
||||||
})}\n\n`
|
})}\n\n`
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Log download completion activity
|
// Log download completion activity
|
||||||
const gameData = await GameDatabaseUpdater.getGameData(gameDataId);
|
const gameData = await GameDatabaseUpdater.getGameData(gameDataId);
|
||||||
await activityService.log({
|
|
||||||
userId: req.user?.id,
|
if (closed) return; // Re-check after await
|
||||||
username: req.user?.username,
|
|
||||||
action: 'games.download.complete',
|
await activityService.log({
|
||||||
resource: 'games',
|
userId: req.user?.id,
|
||||||
resourceId: gameId,
|
username: req.user?.username,
|
||||||
details: {
|
action: 'games.download.complete',
|
||||||
gameDataId,
|
resource: 'games',
|
||||||
gameTitle: game.title,
|
resourceId: gameId,
|
||||||
sha256: gameData?.sha256,
|
details: {
|
||||||
},
|
gameDataId,
|
||||||
ipAddress: req.ip,
|
gameTitle: game.title,
|
||||||
userAgent: req.get('user-agent'),
|
sha256: gameData?.sha256,
|
||||||
});
|
},
|
||||||
} else {
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.get('user-agent'),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (!closed) {
|
||||||
res.write(
|
res.write(
|
||||||
`data: ${JSON.stringify({
|
`data: ${JSON.stringify({
|
||||||
percent: 0,
|
percent: 0,
|
||||||
@@ -280,11 +304,15 @@ router.get(
|
|||||||
})}\n\n`
|
})}\n\n`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
clearInterval(pollInterval);
|
clearInterval(pollInterval);
|
||||||
|
if (!closed) {
|
||||||
res.end();
|
res.end();
|
||||||
} else {
|
}
|
||||||
// Send heartbeat
|
} else {
|
||||||
|
// Send heartbeat
|
||||||
|
if (!closed) {
|
||||||
res.write(
|
res.write(
|
||||||
`data: ${JSON.stringify({
|
`data: ${JSON.stringify({
|
||||||
percent: 0,
|
percent: 0,
|
||||||
@@ -293,27 +321,25 @@ router.get(
|
|||||||
})}\n\n`
|
})}\n\n`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!closed) {
|
||||||
logger.error('Error in SSE poll', { error });
|
logger.error('Error in SSE poll', { error });
|
||||||
clearInterval(pollInterval);
|
}
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
if (!closed) {
|
||||||
res.end();
|
res.end();
|
||||||
}
|
}
|
||||||
}, 1000); // Poll every second
|
}
|
||||||
|
}, 1000); // Poll every second
|
||||||
|
|
||||||
// Clean up on client disconnect
|
// Clean up on client disconnect
|
||||||
req.on('close', () => {
|
req.on('close', () => {
|
||||||
logger.info('SSE connection closed', { gameId });
|
closed = true;
|
||||||
clearInterval(pollInterval);
|
logger.info('SSE connection closed', { gameId });
|
||||||
res.end();
|
clearInterval(pollInterval);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
})
|
||||||
logger.error('SSE endpoint error:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -334,52 +360,44 @@ router.delete(
|
|||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('games.download'),
|
requirePermission('games.download'),
|
||||||
logActivity('games.download_cancel', 'games'),
|
logActivity('games.download_cancel', 'games'),
|
||||||
async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
try {
|
const gameId = req.params.id;
|
||||||
const gameId = req.params.id;
|
|
||||||
|
|
||||||
logger.info('Download cancel request', { gameId });
|
logger.info('Download cancel request', { gameId });
|
||||||
|
|
||||||
// Get game to find gameDataId
|
// Get game to find gameDataId
|
||||||
const game = await gameService.getGameById(gameId);
|
const game = await gameService.getGameById(gameId);
|
||||||
if (!game) {
|
if (!game) {
|
||||||
return res.status(404).json({
|
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({
|
|
||||||
success: false,
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -3,11 +3,28 @@ import { promises as fs } from 'fs';
|
|||||||
import fsSync from 'fs';
|
import fsSync from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
|
import { z } from 'zod';
|
||||||
import { authenticate } from '../middleware/auth';
|
import { authenticate } from '../middleware/auth';
|
||||||
import { requirePermission } from '../middleware/rbac';
|
import { requirePermission } from '../middleware/rbac';
|
||||||
|
import { rateLimitStrict } from '../middleware/rateLimiter';
|
||||||
|
import { asyncHandler } from '../middleware/asyncHandler';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { config } from '../config';
|
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();
|
const router = Router();
|
||||||
|
|
||||||
// Client error logs go to the same directory as backend logs
|
// Client error logs go to the same directory as backend logs
|
||||||
@@ -75,17 +92,6 @@ interface ErrorReport {
|
|||||||
|
|
||||||
type AuthenticatedRequest = Request & { user?: { id: number } };
|
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
|
* 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[]> {
|
async function getRecentErrors(limit: number = 100): Promise<ErrorReport[]> {
|
||||||
try {
|
try {
|
||||||
await ensureLogDir();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(ERROR_LOG_FILE, 'utf8');
|
const content = await fs.readFile(ERROR_LOG_FILE, 'utf8');
|
||||||
const lines = content.trim().split('\n');
|
const lines = content.trim().split('\n');
|
||||||
@@ -164,54 +168,60 @@ async function getRecentErrors(limit: number = 100): Promise<ErrorReport[]> {
|
|||||||
* Report a client-side error
|
* Report a client-side error
|
||||||
* Public endpoint (no authentication required, but rate-limited)
|
* Public endpoint (no authentication required, but rate-limited)
|
||||||
*/
|
*/
|
||||||
router.post('/report', async (req: Request, res: Response) => {
|
router.post(
|
||||||
try {
|
'/report',
|
||||||
const report: ErrorReport = req.body;
|
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
|
const validatedData = parseResult.data;
|
||||||
if (!report.type || !report.message || !report.url) {
|
|
||||||
return res.status(400).json({
|
// 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,
|
success: false,
|
||||||
error: {
|
error: {
|
||||||
code: 'VALIDATION_ERROR',
|
code: 'INTERNAL_ERROR',
|
||||||
message: 'Missing required fields: type, message, url',
|
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
|
* GET /api/errors/recent
|
||||||
@@ -222,9 +232,9 @@ router.get(
|
|||||||
'/recent',
|
'/recent',
|
||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('settings.update'),
|
requirePermission('settings.update'),
|
||||||
async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
try {
|
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);
|
const errors = await getRecentErrors(limit);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -245,7 +255,7 @@ router.get(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -257,7 +267,7 @@ router.get(
|
|||||||
'/stats',
|
'/stats',
|
||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('settings.update'),
|
requirePermission('settings.update'),
|
||||||
async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const errors = await getRecentErrors(1000);
|
const errors = await getRecentErrors(1000);
|
||||||
|
|
||||||
@@ -310,7 +320,7 @@ router.get(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ const toggleFavoriteSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const batchAddSchema = 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({
|
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');
|
throw new AppError(401, 'Authentication required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : undefined;
|
const limit = req.query.limit
|
||||||
const offset = req.query.offset ? parseInt(req.query.offset as string, 10) : undefined;
|
? 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);
|
const favorites = favoritesService.getUserFavorites(req.user.id, limit, offset);
|
||||||
res.json(favorites);
|
res.json(favorites);
|
||||||
@@ -83,10 +87,22 @@ router.get(
|
|||||||
throw new AppError(401, 'Authentication required');
|
throw new AppError(401, 'Authentication required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : undefined;
|
const limit = req.query.limit
|
||||||
const offset = req.query.offset ? parseInt(req.query.offset as string, 10) : undefined;
|
? Math.min(parseInt(req.query.limit as string, 10) || 50, 100)
|
||||||
const sortBy = (req.query.sortBy as 'title' | 'dateAdded') || 'dateAdded';
|
: undefined;
|
||||||
const sortOrder = (req.query.sortOrder as 'asc' | 'desc') || 'desc';
|
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(
|
const games = await favoritesService.getUserFavoriteGames(
|
||||||
req.user.id,
|
req.user.id,
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
|
||||||
import { GameFileService } from '../services/GameFileService';
|
|
||||||
import { AppError } from '../middleware/errorHandler';
|
|
||||||
import { asyncHandler } from '../middleware/asyncHandler';
|
|
||||||
import { logger } from '../utils/logger';
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
const gameFileService = new GameFileService();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /game-files/* - Proxy game file requests with automatic fallback
|
|
||||||
*
|
|
||||||
* Examples:
|
|
||||||
* - /game-files/www.example.com/path/file.swf
|
|
||||||
* - /game-files/cdn.games.com/assets/game.swf
|
|
||||||
*/
|
|
||||||
router.get(
|
|
||||||
'/*',
|
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
// Extract path after /game-files/
|
|
||||||
const urlPath = req.path.substring(1); // Remove leading slash
|
|
||||||
|
|
||||||
if (!urlPath) {
|
|
||||||
throw new AppError(400, 'File path is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`[GameFilesRoute] Request: ${urlPath}`);
|
|
||||||
|
|
||||||
// Get the file (tries Game Server first, then external sources)
|
|
||||||
const result = await gameFileService.getGameFile(urlPath);
|
|
||||||
|
|
||||||
logger.info(`[GameFilesRoute] Serving from ${result.source}: ${result.data.length} bytes`);
|
|
||||||
|
|
||||||
// Set headers
|
|
||||||
res.setHeader('Content-Type', result.contentType);
|
|
||||||
res.setHeader('Content-Length', result.data.length);
|
|
||||||
res.setHeader('Cache-Control', 'public, max-age=86400'); // Cache for 24 hours
|
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
||||||
res.setHeader('X-Source', result.source); // Debug header
|
|
||||||
|
|
||||||
// Send file
|
|
||||||
res.send(result.data);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && error.message.includes('not found')) {
|
|
||||||
throw new AppError(404, 'Game file not found in any source');
|
|
||||||
} else {
|
|
||||||
logger.error('[GameFilesRoute] Error:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { IncomingMessage, ServerResponse } from 'http';
|
||||||
|
import { ProxyRequestHandler } from '../game/proxy-request-handler';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
import { asyncHandler } from '../middleware/asyncHandler';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
let proxyHandler: ProxyRequestHandler | null = null;
|
||||||
|
|
||||||
|
function getHandler(): ProxyRequestHandler {
|
||||||
|
if (!proxyHandler) {
|
||||||
|
proxyHandler = new ProxyRequestHandler();
|
||||||
|
}
|
||||||
|
return proxyHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle all game content requests: /game-proxy/*
|
||||||
|
// Express strips the mount path, so req.url is everything after /game-proxy
|
||||||
|
// e.g., /game-proxy/http://domain.com/file.swf → req.url = /http://domain.com/file.swf
|
||||||
|
router.all(
|
||||||
|
'/*',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const handler = getHandler();
|
||||||
|
|
||||||
|
// OPTIONS requests for CORS preflight
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
// Cast Express types to Node HTTP types for compatibility
|
||||||
|
// Double cast needed: Express.Request doesn't directly extend IncomingMessage
|
||||||
|
handler.handleOptionsRequest(
|
||||||
|
req as unknown as IncomingMessage,
|
||||||
|
res as unknown as ServerResponse
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delegate to ProxyRequestHandler
|
||||||
|
// Double cast needed: Express.Request doesn't directly extend IncomingMessage
|
||||||
|
await handler.handleRequest(
|
||||||
|
req as unknown as IncomingMessage,
|
||||||
|
res as unknown as ServerResponse
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { Router, Request, Response, json, NextFunction } from 'express';
|
||||||
|
import { IncomingMessage, ServerResponse } from 'http';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { gameZipServer } from '../game/gamezipserver';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
import { authenticate } from '../middleware/auth';
|
||||||
|
import { requirePermission } from '../middleware/rbac';
|
||||||
|
import { config } from '../config';
|
||||||
|
import { asyncHandler } from '../middleware/asyncHandler';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zod schema for POST /mount/:id request body
|
||||||
|
*/
|
||||||
|
const mountBodySchema = z.object({
|
||||||
|
zipPath: z.string().min(1).max(500),
|
||||||
|
gameId: z.string().optional(),
|
||||||
|
dateAdded: z.string().optional(),
|
||||||
|
sha256: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Restrictive CORS middleware for admin routes
|
||||||
|
const adminCors = (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', config.domain);
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||||
|
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.status(204).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mount a ZIP: POST /game-zip/mount/:id
|
||||||
|
router.post(
|
||||||
|
'/mount/:id',
|
||||||
|
adminCors,
|
||||||
|
authenticate,
|
||||||
|
requirePermission('settings.update'),
|
||||||
|
json(),
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
// Validate request body
|
||||||
|
const bodyResult = mountBodySchema.safeParse(req.body);
|
||||||
|
if (!bodyResult.success) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `Invalid request body: ${bodyResult.error.issues[0].message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { zipPath, gameId, dateAdded, sha256 } = bodyResult.data;
|
||||||
|
|
||||||
|
const result = await gameZipServer.mountZip({
|
||||||
|
id: req.params.id,
|
||||||
|
zipPath,
|
||||||
|
gameId,
|
||||||
|
dateAdded,
|
||||||
|
sha256,
|
||||||
|
});
|
||||||
|
res.status(result.statusCode).json(result);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Unmount a ZIP: DELETE /game-zip/mount/:id
|
||||||
|
router.delete(
|
||||||
|
'/mount/:id',
|
||||||
|
adminCors,
|
||||||
|
authenticate,
|
||||||
|
requirePermission('settings.update'),
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const success = await gameZipServer.unmountZip(req.params.id);
|
||||||
|
res.status(success ? 200 : 404).json({ success, id: req.params.id });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// List mounted ZIPs: GET /game-zip/mounts
|
||||||
|
router.get(
|
||||||
|
'/mounts',
|
||||||
|
adminCors,
|
||||||
|
authenticate,
|
||||||
|
requirePermission('settings.update'),
|
||||||
|
(_req: Request, res: Response) => {
|
||||||
|
res.json({ mounts: gameZipServer.listMounts() });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Serve files from mounted ZIPs: GET /game-zip/*
|
||||||
|
// Handles proxy-style URLs like /game-zip/http://domain.com/path
|
||||||
|
router.get('/*', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
// Delegate to gameZipServer's file handler
|
||||||
|
await gameZipServer.handleFileRequest(
|
||||||
|
req as unknown as IncomingMessage,
|
||||||
|
res as unknown as ServerResponse
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[GameZip] File request error:', error);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).send('Internal Server Error');
|
||||||
|
} else if (!res.writableEnded) {
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
+32
-33
@@ -7,7 +7,7 @@ import { asyncHandler } from '../middleware/asyncHandler';
|
|||||||
import { sharedAccessAuth, validateSharedGameAccess } from '../middleware/auth';
|
import { sharedAccessAuth, validateSharedGameAccess } from '../middleware/auth';
|
||||||
import { logActivity } from '../middleware/activityLogger';
|
import { logActivity } from '../middleware/activityLogger';
|
||||||
import { rateLimitStandard } from '../middleware/rateLimiter';
|
import { rateLimitStandard } from '../middleware/rateLimiter';
|
||||||
import { config } from '../config';
|
import { logger } from '../utils/logger';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -101,6 +101,7 @@ router.get(
|
|||||||
limit: query.limit,
|
limit: query.limit,
|
||||||
showBroken: query.showBroken,
|
showBroken: query.showBroken,
|
||||||
showExtreme: query.showExtreme,
|
showExtreme: query.showExtreme,
|
||||||
|
fields: 'list', // Only return essential columns for browse/search views
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store result count for activity logging
|
// Store result count for activity logging
|
||||||
@@ -123,7 +124,7 @@ router.get(
|
|||||||
router.get(
|
router.get(
|
||||||
'/most-played',
|
'/most-played',
|
||||||
asyncHandler(async (req, res) => {
|
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);
|
const games = await gameService.getMostPlayedGames(limit);
|
||||||
|
|
||||||
res.json({
|
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
|
// GET /api/games/:id - Get single game by ID
|
||||||
router.get(
|
router.get(
|
||||||
'/:id',
|
'/:id',
|
||||||
@@ -163,28 +181,13 @@ router.get(
|
|||||||
'/:id/related',
|
'/:id/related',
|
||||||
validateSharedGameAccess('id'),
|
validateSharedGameAccess('id'),
|
||||||
asyncHandler(async (req, res) => {
|
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);
|
const relatedGames = await gameService.getRelatedGames(req.params.id, limit);
|
||||||
|
|
||||||
res.json(relatedGames);
|
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
|
// GET /api/games/:id/launch - Get game launch data
|
||||||
router.get(
|
router.get(
|
||||||
'/:id/launch',
|
'/:id/launch',
|
||||||
@@ -201,18 +204,16 @@ router.get(
|
|||||||
throw new AppError(404, 'Game not found');
|
throw new AppError(404, 'Game not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-mount ZIP file if game has game data
|
// Mount the game ZIP (awaited so local ZIPs are ready before we return the URL).
|
||||||
// This ensures files are available from the GameZip server
|
// For downloads, mountGameZip returns quickly with { mounted: false, downloading: true }.
|
||||||
// Check if game has data by checking presentOnDisk field (not null means has game_data entries)
|
const mountResult = await gameDataService.mountGameZip(game.id);
|
||||||
if (game.presentOnDisk !== null) {
|
const downloading = mountResult.downloading || false;
|
||||||
await gameDataService.mountGameZip(game.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract launch command (usually the SWF file path for Flash games)
|
// Extract launch command (usually the SWF file path for Flash games)
|
||||||
let launchCommand = game.launchCommand || '';
|
let launchCommand = game.launchCommand || '';
|
||||||
|
|
||||||
// If no launch command but has game data, query game_data table
|
// If no launch command, try to get path from game_data table
|
||||||
if (!launchCommand && game.presentOnDisk !== null) {
|
if (!launchCommand) {
|
||||||
const gameDataPath = await gameService.getGameDataPath(game.id);
|
const gameDataPath = await gameService.getGameDataPath(game.id);
|
||||||
if (gameDataPath) {
|
if (gameDataPath) {
|
||||||
launchCommand = gameDataPath;
|
launchCommand = gameDataPath;
|
||||||
@@ -225,15 +226,12 @@ router.get(
|
|||||||
|
|
||||||
let contentUrl = '';
|
let contentUrl = '';
|
||||||
if (launchCommand) {
|
if (launchCommand) {
|
||||||
// Use the configured external game service URL
|
// Game content is served via /game-proxy route (integrated in backend)
|
||||||
// In Docker: This is /game-proxy (routed through nginx)
|
const proxyUrl = '/game-proxy';
|
||||||
// In local dev: This is http://localhost:22500 (direct access)
|
|
||||||
const proxyUrl = config.gameServiceExternalUrl;
|
|
||||||
|
|
||||||
// Check if launch command is an absolute URL
|
// Check if launch command is an absolute URL
|
||||||
if (launchCommand.startsWith('http://') || launchCommand.startsWith('https://')) {
|
if (launchCommand.startsWith('http://') || launchCommand.startsWith('https://')) {
|
||||||
// Use the full URL as-is with the proxy
|
// 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}`;
|
contentUrl = `${proxyUrl}/${launchCommand}`;
|
||||||
} else {
|
} else {
|
||||||
// For relative paths, construct full URL with domain from source
|
// For relative paths, construct full URL with domain from source
|
||||||
@@ -246,8 +244,8 @@ router.get(
|
|||||||
const domain = sourceUrl.hostname;
|
const domain = sourceUrl.hostname;
|
||||||
|
|
||||||
// Construct full URL: http://domain/path
|
// Construct full URL: http://domain/path
|
||||||
const path = launchCommand.startsWith('/') ? launchCommand : `/${launchCommand}`;
|
const urlPath = launchCommand.startsWith('/') ? launchCommand : `/${launchCommand}`;
|
||||||
fullUrl = `http://${domain}${path}`;
|
fullUrl = `http://${domain}${urlPath}`;
|
||||||
} catch {
|
} catch {
|
||||||
// If source is not a valid URL, assume launchCommand is already domain/path
|
// If source is not a valid URL, assume launchCommand is already domain/path
|
||||||
fullUrl = launchCommand.startsWith('http') ? launchCommand : `http://${launchCommand}`;
|
fullUrl = launchCommand.startsWith('http') ? launchCommand : `http://${launchCommand}`;
|
||||||
@@ -280,6 +278,7 @@ router.get(
|
|||||||
applicationPath: game.applicationPath,
|
applicationPath: game.applicationPath,
|
||||||
playMode: game.playMode,
|
playMode: game.playMode,
|
||||||
canPlayInBrowser,
|
canPlayInBrowser,
|
||||||
|
downloading,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
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();
|
const router = Router();
|
||||||
|
|
||||||
@@ -8,8 +9,9 @@ const router = Router();
|
|||||||
* Proxy endpoint for fetching GitHub star count
|
* Proxy endpoint for fetching GitHub star count
|
||||||
* Avoids CORS issues when calling GitHub API from browser
|
* Avoids CORS issues when calling GitHub API from browser
|
||||||
*/
|
*/
|
||||||
router.get('/stars', async (_req: Request, res: Response) => {
|
router.get(
|
||||||
try {
|
'/stars',
|
||||||
|
asyncHandler(async (_req: Request, res: Response) => {
|
||||||
const repoUrl = 'https://api.github.com/repos/darkraise/flashpoint-web';
|
const repoUrl = 'https://api.github.com/repos/darkraise/flashpoint-web';
|
||||||
|
|
||||||
const response = await fetch(repoUrl, {
|
const response = await fetch(repoUrl, {
|
||||||
@@ -31,15 +33,7 @@ router.get('/stars', async (_req: Request, res: Response) => {
|
|||||||
stars: data.stargazers_count,
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const router = Router();
|
|||||||
* GET /api/health
|
* GET /api/health
|
||||||
* Basic health check - publicly accessible
|
* Basic health check - publicly accessible
|
||||||
* Returns minimal status information
|
* Returns minimal status information
|
||||||
|
* NOTE: A simpler /health endpoint also exists for load balancer checks (registered before middleware)
|
||||||
*/
|
*/
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
const isHealthy = DatabaseService.isConnected() && UserDatabaseService.isConnected();
|
const isHealthy = DatabaseService.isConnected() && UserDatabaseService.isConnected();
|
||||||
@@ -138,8 +139,8 @@ router.get(
|
|||||||
},
|
},
|
||||||
services: {
|
services: {
|
||||||
gameService: {
|
gameService: {
|
||||||
proxyUrl: config.gameServerUrl || 'http://localhost:22500',
|
status: 'integrated',
|
||||||
gameZipPort: config.gameServerHttpPort || 22501,
|
routes: ['/game-proxy/*', '/game-zip/*'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
healthCheckDuration: `${healthCheckTime}ms`,
|
healthCheckDuration: `${healthCheckTime}ms`,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import tagsRouter from './tags';
|
|||||||
import playlistsRouter from './playlists';
|
import playlistsRouter from './playlists';
|
||||||
import communityPlaylistsRouter from './community-playlists';
|
import communityPlaylistsRouter from './community-playlists';
|
||||||
import proxyRouter from './proxy';
|
import proxyRouter from './proxy';
|
||||||
import gameFilesRouter from './game-files';
|
|
||||||
import updatesRouter from './updates';
|
import updatesRouter from './updates';
|
||||||
import statisticsRouter from './statistics';
|
import statisticsRouter from './statistics';
|
||||||
import downloadsRouter from './downloads';
|
import downloadsRouter from './downloads';
|
||||||
@@ -85,9 +84,6 @@ export function setupRoutes(app: Express): void {
|
|||||||
app.use('/api/statistics', statisticsRouter);
|
app.use('/api/statistics', statisticsRouter);
|
||||||
app.use('/api/database', databaseRouter);
|
app.use('/api/database', databaseRouter);
|
||||||
|
|
||||||
// Game files proxy with automatic fallback
|
|
||||||
app.use('/game-files', gameFilesRouter);
|
|
||||||
|
|
||||||
app.use('/proxy', proxyRouter);
|
app.use('/proxy', proxyRouter);
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
|
|||||||
+37
-36
@@ -2,6 +2,7 @@ import { Router, Request, Response } from 'express';
|
|||||||
import { authenticate } from '../middleware/auth';
|
import { authenticate } from '../middleware/auth';
|
||||||
import { requirePermission } from '../middleware/rbac';
|
import { requirePermission } from '../middleware/rbac';
|
||||||
import { logActivity } from '../middleware/activityLogger';
|
import { logActivity } from '../middleware/activityLogger';
|
||||||
|
import { asyncHandler } from '../middleware/asyncHandler';
|
||||||
import { JobScheduler } from '../services/JobScheduler';
|
import { JobScheduler } from '../services/JobScheduler';
|
||||||
import { JobExecutionService } from '../services/JobExecutionService';
|
import { JobExecutionService } from '../services/JobExecutionService';
|
||||||
import { CachedSystemSettingsService } from '../services/CachedSystemSettingsService';
|
import { CachedSystemSettingsService } from '../services/CachedSystemSettingsService';
|
||||||
@@ -18,7 +19,7 @@ router.get(
|
|||||||
'/',
|
'/',
|
||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('settings.update'),
|
requirePermission('settings.update'),
|
||||||
async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const jobs = JobScheduler.getAllJobsEnriched();
|
const jobs = JobScheduler.getAllJobsEnriched();
|
||||||
res.json(jobs);
|
res.json(jobs);
|
||||||
@@ -26,7 +27,28 @@ router.get(
|
|||||||
logger.error('Failed to get jobs:', error);
|
logger.error('Failed to get jobs:', error);
|
||||||
res.status(500).json({ error: { message: 'Failed to retrieve jobs' } });
|
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',
|
'/:jobId',
|
||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('settings.update'),
|
requirePermission('settings.update'),
|
||||||
async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { jobId } = req.params;
|
const { jobId } = req.params;
|
||||||
const job = JobScheduler.getJobStatusEnriched(jobId);
|
const job = JobScheduler.getJobStatusEnriched(jobId);
|
||||||
@@ -50,7 +72,7 @@ router.get(
|
|||||||
logger.error(`Failed to get job ${req.params.jobId}:`, error);
|
logger.error(`Failed to get job ${req.params.jobId}:`, error);
|
||||||
res.status(500).json({ error: { message: 'Failed to retrieve job' } });
|
res.status(500).json({ error: { message: 'Failed to retrieve job' } });
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// ===================================
|
// ===================================
|
||||||
@@ -61,7 +83,7 @@ router.patch(
|
|||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('settings.update'),
|
requirePermission('settings.update'),
|
||||||
logActivity('jobs.update', 'job'),
|
logActivity('jobs.update', 'job'),
|
||||||
async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { jobId } = req.params;
|
const { jobId } = req.params;
|
||||||
const { enabled } = req.body;
|
const { enabled } = req.body;
|
||||||
@@ -103,7 +125,7 @@ router.patch(
|
|||||||
error: { message: error instanceof Error ? error.message : 'Failed to update job' },
|
error: { message: error instanceof Error ? error.message : 'Failed to update job' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// ===================================
|
// ===================================
|
||||||
@@ -114,7 +136,7 @@ router.post(
|
|||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('settings.update'),
|
requirePermission('settings.update'),
|
||||||
logActivity('jobs.start', 'job'),
|
logActivity('jobs.start', 'job'),
|
||||||
async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { jobId } = req.params;
|
const { jobId } = req.params;
|
||||||
JobScheduler.startJob(jobId);
|
JobScheduler.startJob(jobId);
|
||||||
@@ -126,7 +148,7 @@ router.post(
|
|||||||
message: error instanceof Error ? error.message : 'Failed to start job',
|
message: error instanceof Error ? error.message : 'Failed to start job',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// ===================================
|
// ===================================
|
||||||
@@ -137,7 +159,7 @@ router.post(
|
|||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('settings.update'),
|
requirePermission('settings.update'),
|
||||||
logActivity('jobs.stop', 'job'),
|
logActivity('jobs.stop', 'job'),
|
||||||
async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { jobId } = req.params;
|
const { jobId } = req.params;
|
||||||
JobScheduler.stopJob(jobId);
|
JobScheduler.stopJob(jobId);
|
||||||
@@ -149,7 +171,7 @@ router.post(
|
|||||||
message: error instanceof Error ? error.message : 'Failed to stop job',
|
message: error instanceof Error ? error.message : 'Failed to stop job',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// ===================================
|
// ===================================
|
||||||
@@ -160,7 +182,7 @@ router.post(
|
|||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('settings.update'),
|
requirePermission('settings.update'),
|
||||||
logActivity('jobs.trigger', 'job'),
|
logActivity('jobs.trigger', 'job'),
|
||||||
async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { jobId } = req.params;
|
const { jobId } = req.params;
|
||||||
const userId = req.user!.id.toString();
|
const userId = req.user!.id.toString();
|
||||||
@@ -180,7 +202,7 @@ router.post(
|
|||||||
logger.error(`Failed to trigger job ${req.params.jobId}:`, error);
|
logger.error(`Failed to trigger job ${req.params.jobId}:`, error);
|
||||||
res.status(500).json({ success: false, message });
|
res.status(500).json({ success: false, message });
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// ===================================
|
// ===================================
|
||||||
@@ -190,11 +212,11 @@ router.get(
|
|||||||
'/:jobId/logs',
|
'/:jobId/logs',
|
||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('settings.update'),
|
requirePermission('settings.update'),
|
||||||
async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { jobId } = req.params;
|
const { jobId } = req.params;
|
||||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
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);
|
const result = executionService.getJobLogs(jobId, limit, offset);
|
||||||
res.json({ ...result, 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);
|
logger.error(`Failed to get logs for job ${req.params.jobId}:`, error);
|
||||||
res.status(500).json({ error: { message: 'Failed to retrieve job logs' } });
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const LIMITS = {
|
|||||||
router.use(requireFeature('enableStatistics'));
|
router.use(requireFeature('enableStatistics'));
|
||||||
|
|
||||||
// All routes require authentication and games.play permission
|
// All routes require authentication and games.play permission
|
||||||
|
// Note: authenticate middleware guarantees req.user is populated
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
router.use(requirePermission('games.play'));
|
router.use(requirePermission('games.play'));
|
||||||
|
|
||||||
@@ -56,11 +57,7 @@ router.post(
|
|||||||
asyncHandler(async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
const { gameId, gameTitle } = startPlaySessionSchema.parse(req.body);
|
const { gameId, gameTitle } = startPlaySessionSchema.parse(req.body);
|
||||||
|
|
||||||
if (!req.user) {
|
const sessionId = await playTrackingService.startPlaySession(req.user!.id, gameId, gameTitle);
|
||||||
throw new AppError(401, 'Authentication required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionId = await playTrackingService.startPlaySession(req.user.id, gameId, gameTitle);
|
|
||||||
|
|
||||||
// Store sessionId for activity logging
|
// Store sessionId for activity logging
|
||||||
res.locals.sessionId = sessionId;
|
res.locals.sessionId = sessionId;
|
||||||
@@ -82,7 +79,7 @@ router.post(
|
|||||||
asyncHandler(async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
const { sessionId } = endPlaySessionSchema.parse(req.body);
|
const { sessionId } = endPlaySessionSchema.parse(req.body);
|
||||||
|
|
||||||
await playTrackingService.endPlaySession(sessionId);
|
await playTrackingService.endPlaySession(sessionId, req.user!.id);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -98,11 +95,7 @@ router.post(
|
|||||||
router.get(
|
router.get(
|
||||||
'/stats',
|
'/stats',
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
if (!req.user) {
|
const stats = await playTrackingService.getUserStats(req.user!.id);
|
||||||
throw new AppError(401, 'Authentication required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = await playTrackingService.getUserStats(req.user.id);
|
|
||||||
|
|
||||||
res.json(stats);
|
res.json(stats);
|
||||||
})
|
})
|
||||||
@@ -115,17 +108,13 @@ router.get(
|
|||||||
router.get(
|
router.get(
|
||||||
'/game-stats',
|
'/game-stats',
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
if (!req.user) {
|
|
||||||
throw new AppError(401, 'Authentication required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const limit = Math.min(
|
const limit = Math.min(
|
||||||
parseInt(req.query.limit as string) || LIMITS.GAME_STATS_DEFAULT,
|
parseInt(req.query.limit as string) || LIMITS.GAME_STATS_DEFAULT,
|
||||||
LIMITS.GAME_STATS_MAX
|
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({
|
res.json({
|
||||||
data: stats,
|
data: stats,
|
||||||
@@ -142,17 +131,13 @@ router.get(
|
|||||||
router.get(
|
router.get(
|
||||||
'/history',
|
'/history',
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
if (!req.user) {
|
|
||||||
throw new AppError(401, 'Authentication required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const limit = Math.min(
|
const limit = Math.min(
|
||||||
parseInt(req.query.limit as string) || LIMITS.HISTORY_DEFAULT,
|
parseInt(req.query.limit as string) || LIMITS.HISTORY_DEFAULT,
|
||||||
LIMITS.HISTORY_MAX
|
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({
|
res.json({
|
||||||
data: history,
|
data: history,
|
||||||
@@ -169,16 +154,12 @@ router.get(
|
|||||||
router.get(
|
router.get(
|
||||||
'/top-games',
|
'/top-games',
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
if (!req.user) {
|
|
||||||
throw new AppError(401, 'Authentication required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const limit = Math.min(
|
const limit = Math.min(
|
||||||
parseInt(req.query.limit as string) || LIMITS.TOP_GAMES_DEFAULT,
|
parseInt(req.query.limit as string) || LIMITS.TOP_GAMES_DEFAULT,
|
||||||
LIMITS.TOP_GAMES_MAX
|
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);
|
res.json(topGames);
|
||||||
})
|
})
|
||||||
@@ -191,16 +172,12 @@ router.get(
|
|||||||
router.get(
|
router.get(
|
||||||
'/activity-over-time',
|
'/activity-over-time',
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
if (!req.user) {
|
|
||||||
throw new AppError(401, 'Authentication required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const days = Math.min(
|
const days = Math.min(
|
||||||
parseInt(req.query.days as string) || LIMITS.ACTIVITY_DAYS_DEFAULT,
|
parseInt(req.query.days as string) || LIMITS.ACTIVITY_DAYS_DEFAULT,
|
||||||
LIMITS.ACTIVITY_DAYS_MAX
|
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);
|
res.json(activity);
|
||||||
})
|
})
|
||||||
@@ -213,16 +190,12 @@ router.get(
|
|||||||
router.get(
|
router.get(
|
||||||
'/games-distribution',
|
'/games-distribution',
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
if (!req.user) {
|
|
||||||
throw new AppError(401, 'Authentication required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const limit = Math.min(
|
const limit = Math.min(
|
||||||
parseInt(req.query.limit as string) || LIMITS.GAMES_DISTRIBUTION_DEFAULT,
|
parseInt(req.query.limit as string) || LIMITS.GAMES_DISTRIBUTION_DEFAULT,
|
||||||
LIMITS.GAMES_DISTRIBUTION_MAX
|
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);
|
res.json(distribution);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { authenticate } from '../middleware/auth';
|
|||||||
import { requirePermission } from '../middleware/rbac';
|
import { requirePermission } from '../middleware/rbac';
|
||||||
import { requireFeature } from '../middleware/featureFlags';
|
import { requireFeature } from '../middleware/featureFlags';
|
||||||
import { logActivity } from '../middleware/activityLogger';
|
import { logActivity } from '../middleware/activityLogger';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const playlistService = new PlaylistService();
|
const playlistService = new PlaylistService();
|
||||||
@@ -21,6 +22,16 @@ const playlistService = new PlaylistService();
|
|||||||
// Admins with settings.update permission will bypass this check (via global softAuth)
|
// Admins with settings.update permission will bypass this check (via global softAuth)
|
||||||
router.use(requireFeature('enablePlaylists'));
|
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
|
// GET /api/playlists - List all playlists
|
||||||
// No additional auth needed - softAuth already runs at router level
|
// No additional auth needed - softAuth already runs at router level
|
||||||
router.get(
|
router.get(
|
||||||
@@ -53,13 +64,9 @@ router.post(
|
|||||||
requirePermission('playlists.create'),
|
requirePermission('playlists.create'),
|
||||||
logActivity('playlists.create', 'playlists'),
|
logActivity('playlists.create', 'playlists'),
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const data: CreatePlaylistDto = req.body;
|
const data = createPlaylistSchema.parse(req.body);
|
||||||
|
|
||||||
if (!data.title) {
|
const playlist = await playlistService.createPlaylist(data as CreatePlaylistDto);
|
||||||
throw new AppError(400, 'Playlist title is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const playlist = await playlistService.createPlaylist(data);
|
|
||||||
res.status(201).json(playlist);
|
res.status(201).json(playlist);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -71,13 +78,12 @@ router.post(
|
|||||||
requirePermission('playlists.update'),
|
requirePermission('playlists.update'),
|
||||||
logActivity('playlists.add_games', 'playlists'),
|
logActivity('playlists.add_games', 'playlists'),
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const data: AddGamesToPlaylistDto = req.body;
|
const data = addGamesToPlaylistSchema.parse(req.body);
|
||||||
|
|
||||||
if (!data.gameIds || !Array.isArray(data.gameIds)) {
|
const playlist = await playlistService.addGamesToPlaylist(
|
||||||
throw new AppError(400, 'gameIds array is required');
|
req.params.id,
|
||||||
}
|
data as AddGamesToPlaylistDto
|
||||||
|
);
|
||||||
const playlist = await playlistService.addGamesToPlaylist(req.params.id, data);
|
|
||||||
|
|
||||||
if (!playlist) {
|
if (!playlist) {
|
||||||
throw new AppError(404, 'Playlist not found');
|
throw new AppError(404, 'Playlist not found');
|
||||||
@@ -94,13 +100,12 @@ router.delete(
|
|||||||
requirePermission('playlists.update'),
|
requirePermission('playlists.update'),
|
||||||
logActivity('playlists.remove_games', 'playlists'),
|
logActivity('playlists.remove_games', 'playlists'),
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const data: AddGamesToPlaylistDto = req.body;
|
const data = addGamesToPlaylistSchema.parse(req.body);
|
||||||
|
|
||||||
if (!data.gameIds || !Array.isArray(data.gameIds)) {
|
const playlist = await playlistService.removeGamesFromPlaylist(
|
||||||
throw new AppError(400, 'gameIds array is required');
|
req.params.id,
|
||||||
}
|
data as AddGamesToPlaylistDto
|
||||||
|
);
|
||||||
const playlist = await playlistService.removeGamesFromPlaylist(req.params.id, data);
|
|
||||||
|
|
||||||
if (!playlist) {
|
if (!playlist) {
|
||||||
throw new AppError(404, 'Playlist not found');
|
throw new AppError(404, 'Playlist not found');
|
||||||
|
|||||||
+21
-30
@@ -1,5 +1,4 @@
|
|||||||
import { Router, Response, NextFunction } from 'express';
|
import { Router, Response, NextFunction } from 'express';
|
||||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
|
||||||
import { config, getExternalImageUrls } from '../config';
|
import { config, getExternalImageUrls } from '../config';
|
||||||
import { asyncHandler } from '../middleware/asyncHandler';
|
import { asyncHandler } from '../middleware/asyncHandler';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
@@ -55,6 +54,7 @@ async function serveFileWithFallback(
|
|||||||
// Try local file first
|
// Try local file first
|
||||||
if (fs.existsSync(localPath)) {
|
if (fs.existsSync(localPath)) {
|
||||||
logger.debug(`[Proxy] Serving local file: ${localPath}`);
|
logger.debug(`[Proxy] Serving local file: ${localPath}`);
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
return res.sendFile(localPath);
|
return res.sendFile(localPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,11 +93,26 @@ async function serveFileWithFallback(
|
|||||||
`[Proxy] Failed to cache image locally: ${cacheError instanceof Error ? cacheError.message : 'Unknown error'}`
|
`[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('Content-Type', contentType);
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
res.setHeader('Cache-Control', 'public, max-age=86400'); // Cache for 1 day
|
res.setHeader('Cache-Control', 'public, max-age=86400'); // Cache for 1 day
|
||||||
|
|
||||||
return res.send(imageBuffer);
|
return res.send(imageBuffer);
|
||||||
@@ -123,32 +138,8 @@ async function serveFileWithFallback(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Proxy to Flashpoint Game Server for game content
|
// Note: Game content is now served directly via /game-proxy and /game-zip routes
|
||||||
router.use(
|
// (registered in server.ts before middleware). No proxy to external game-service needed.
|
||||||
'/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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Serve images with CDN fallback
|
// Serve images with CDN fallback
|
||||||
router.get(
|
router.get(
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ router.get(
|
|||||||
logActivity('roles.view', 'roles'),
|
logActivity('roles.view', 'roles'),
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
|
if (isNaN(id)) {
|
||||||
|
throw new AppError(400, 'Invalid role ID');
|
||||||
|
}
|
||||||
|
|
||||||
const role = await roleService.getRoleById(id);
|
const role = await roleService.getRoleById(id);
|
||||||
|
|
||||||
if (!role) {
|
if (!role) {
|
||||||
@@ -112,6 +116,10 @@ router.patch(
|
|||||||
logActivity('roles.update', 'roles'),
|
logActivity('roles.update', 'roles'),
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
|
if (isNaN(id)) {
|
||||||
|
throw new AppError(400, 'Invalid role ID');
|
||||||
|
}
|
||||||
|
|
||||||
const data = updateRoleSchema.parse(req.body);
|
const data = updateRoleSchema.parse(req.body);
|
||||||
|
|
||||||
const role = await roleService.updateRole(id, data.name, data.description, data.priority);
|
const role = await roleService.updateRole(id, data.name, data.description, data.priority);
|
||||||
@@ -131,6 +139,10 @@ router.put(
|
|||||||
logActivity('roles.update_permissions', 'roles'),
|
logActivity('roles.update_permissions', 'roles'),
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
|
if (isNaN(id)) {
|
||||||
|
throw new AppError(400, 'Invalid role ID');
|
||||||
|
}
|
||||||
|
|
||||||
const data = updatePermissionsSchema.parse(req.body);
|
const data = updatePermissionsSchema.parse(req.body);
|
||||||
|
|
||||||
await roleService.updateRolePermissions(id, data.permissionIds);
|
await roleService.updateRolePermissions(id, data.permissionIds);
|
||||||
@@ -151,6 +163,9 @@ router.delete(
|
|||||||
logActivity('roles.delete', 'roles'),
|
logActivity('roles.delete', 'roles'),
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
|
if (isNaN(id)) {
|
||||||
|
throw new AppError(400, 'Invalid role ID');
|
||||||
|
}
|
||||||
|
|
||||||
await roleService.deleteRole(id);
|
await roleService.deleteRole(id);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
import { validateSharedPlaylist } from '../middleware/validateSharedPlaylist';
|
import { validateSharedPlaylist } from '../middleware/validateSharedPlaylist';
|
||||||
import { asyncHandler } from '../middleware/asyncHandler';
|
import { asyncHandler } from '../middleware/asyncHandler';
|
||||||
import { authenticate } from '../middleware/auth';
|
import { authenticate } from '../middleware/auth';
|
||||||
@@ -14,6 +15,11 @@ const router = Router();
|
|||||||
const playlistService = new UserPlaylistService();
|
const playlistService = new UserPlaylistService();
|
||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
|
|
||||||
|
// Validation schema
|
||||||
|
const clonePlaylistSchema = z.object({
|
||||||
|
newTitle: z.string().min(1).max(255).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/playlists/shared/:shareToken
|
* GET /api/playlists/shared/:shareToken
|
||||||
* Get shared playlist metadata (anonymous access)
|
* Get shared playlist metadata (anonymous access)
|
||||||
@@ -28,7 +34,16 @@ router.get(
|
|||||||
const playlist = req.sharedPlaylist!;
|
const playlist = req.sharedPlaylist!;
|
||||||
|
|
||||||
// Sanitize response - don't expose sensitive fields
|
// 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,
|
id: playlist.id,
|
||||||
title: playlist.title,
|
title: playlist.title,
|
||||||
description: playlist.description,
|
description: playlist.description,
|
||||||
@@ -144,7 +159,8 @@ router.post(
|
|||||||
requirePermission('playlists.create'),
|
requirePermission('playlists.create'),
|
||||||
validateSharedPlaylist,
|
validateSharedPlaylist,
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const { newTitle } = req.body;
|
// Validate request body
|
||||||
|
const { newTitle } = clonePlaylistSchema.parse(req.body);
|
||||||
|
|
||||||
const clonedPlaylist = playlistService.cloneSharedPlaylist(
|
const clonedPlaylist = playlistService.cloneSharedPlaylist(
|
||||||
req.params.shareToken,
|
req.params.shareToken,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { PermissionCache } from '../services/PermissionCache';
|
|||||||
import { authenticate } from '../middleware/auth';
|
import { authenticate } from '../middleware/auth';
|
||||||
import { requirePermission } from '../middleware/rbac';
|
import { requirePermission } from '../middleware/rbac';
|
||||||
import { logActivity } from '../middleware/activityLogger';
|
import { logActivity } from '../middleware/activityLogger';
|
||||||
|
import { asyncHandler } from '../middleware/asyncHandler';
|
||||||
import { JobScheduler } from '../services/JobScheduler';
|
import { JobScheduler } from '../services/JobScheduler';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { CategorySettings } from '../types/settings';
|
import { CategorySettings } from '../types/settings';
|
||||||
@@ -44,24 +45,18 @@ router.get(
|
|||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('settings.update'),
|
requirePermission('settings.update'),
|
||||||
logActivity('settings.list', 'system_settings'),
|
logActivity('settings.list', 'system_settings'),
|
||||||
async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
try {
|
const allSettings = systemSettings.getAll();
|
||||||
const allSettings = systemSettings.getAll();
|
res.json(allSettings);
|
||||||
res.json(allSettings);
|
})
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to get all settings:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: { message: 'Failed to retrieve settings' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// ===================================
|
// ===================================
|
||||||
// GET PUBLIC SETTINGS (No Auth Required)
|
// GET PUBLIC SETTINGS (No Auth Required)
|
||||||
// ===================================
|
// ===================================
|
||||||
router.get('/public', async (req: Request, res: Response) => {
|
router.get(
|
||||||
try {
|
'/public',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
const publicSettings: Record<
|
const publicSettings: Record<
|
||||||
string,
|
string,
|
||||||
Record<string, unknown>
|
Record<string, unknown>
|
||||||
@@ -91,14 +86,121 @@ router.get('/public', async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res.json(publicSettings);
|
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)
|
// GET CATEGORY SETTINGS (Admin Only)
|
||||||
// ===================================
|
// ===================================
|
||||||
@@ -106,20 +208,13 @@ router.get(
|
|||||||
'/:category',
|
'/:category',
|
||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('settings.read'),
|
requirePermission('settings.read'),
|
||||||
async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
try {
|
const { category } = req.params;
|
||||||
const { category } = req.params;
|
const categorySettings = systemSettings.getCategory(category);
|
||||||
const categorySettings = systemSettings.getCategory(category);
|
|
||||||
|
|
||||||
// Return settings (empty object if category has no settings yet)
|
// Return settings (empty object if category has no settings yet)
|
||||||
res.json(categorySettings);
|
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' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// ===================================
|
// ===================================
|
||||||
@@ -134,47 +229,32 @@ router.patch(
|
|||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('settings.update'),
|
requirePermission('settings.update'),
|
||||||
logActivity('settings.update', 'system_settings'),
|
logActivity('settings.update', 'system_settings'),
|
||||||
async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
try {
|
const { category } = req.params;
|
||||||
const { category } = req.params;
|
const validation = updateCategorySchema.safeParse(req.body);
|
||||||
const validation = updateCategorySchema.safeParse(req.body);
|
|
||||||
|
|
||||||
if (!validation.success) {
|
if (!validation.success) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: {
|
error: {
|
||||||
message: 'Invalid request body',
|
message: 'Invalid request body',
|
||||||
details: validation.error.errors,
|
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' },
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
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',
|
'/:category/:key',
|
||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('settings.read'),
|
requirePermission('settings.read'),
|
||||||
async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
try {
|
const { category, key } = req.params;
|
||||||
const { category, key } = req.params;
|
const fullKey = `${category}.${key}`;
|
||||||
const fullKey = `${category}.${key}`;
|
const value = systemSettings.get(fullKey);
|
||||||
const value = systemSettings.get(fullKey);
|
|
||||||
|
|
||||||
if (value === null) {
|
if (value === null) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
error: { message: `Setting '${fullKey}' not found` },
|
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' },
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
res.json({ value });
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// ===================================
|
// ===================================
|
||||||
@@ -218,168 +291,30 @@ router.patch(
|
|||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('settings.update'),
|
requirePermission('settings.update'),
|
||||||
logActivity('settings.update', 'system_settings'),
|
logActivity('settings.update', 'system_settings'),
|
||||||
async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
try {
|
const { category, key } = req.params;
|
||||||
const { category, key } = req.params;
|
const fullKey = `${category}.${key}`;
|
||||||
const fullKey = `${category}.${key}`;
|
const validation = updateSettingSchema.safeParse(req.body);
|
||||||
const validation = updateSettingSchema.safeParse(req.body);
|
|
||||||
|
|
||||||
if (!validation.success) {
|
if (!validation.success) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: {
|
error: {
|
||||||
message: 'Invalid request body',
|
message: 'Invalid request body',
|
||||||
details: validation.error.errors,
|
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',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to get permission cache stats:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: { message: 'Failed to retrieve permission cache statistics' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// ===================================
|
const { value } = validation.data;
|
||||||
// CLEAR PERMISSION CACHE (Admin Only)
|
const userId = req.user!.id;
|
||||||
// ===================================
|
|
||||||
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;
|
|
||||||
|
|
||||||
if (type === 'user' && id) {
|
// Update setting (validation happens in service)
|
||||||
PermissionCache.invalidateUser(Number(id));
|
systemSettings.set(fullKey, value, userId);
|
||||||
res.json({ message: `User ${id} permission cache cleared successfully` });
|
|
||||||
} else if (type === 'role' && id) {
|
// Return updated value
|
||||||
PermissionCache.invalidateRole(Number(id));
|
const updated = systemSettings.get(fullKey);
|
||||||
res.json({ message: `Role ${id} permission cache cleared successfully` });
|
res.json({ value: updated });
|
||||||
} 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' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import { authenticate } from '../middleware/auth';
|
|||||||
import { requirePermission } from '../middleware/rbac';
|
import { requirePermission } from '../middleware/rbac';
|
||||||
import { logActivity } from '../middleware/activityLogger';
|
import { logActivity } from '../middleware/activityLogger';
|
||||||
import { asyncHandler } from '../middleware/asyncHandler';
|
import { asyncHandler } from '../middleware/asyncHandler';
|
||||||
|
import { rateLimitStandard } from '../middleware/rateLimiter';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
router.use(rateLimitStandard);
|
||||||
const metadataUpdateService = new MetadataUpdateService();
|
const metadataUpdateService = new MetadataUpdateService();
|
||||||
const metadataSyncService = new MetadataSyncService();
|
const metadataSyncService = new MetadataSyncService();
|
||||||
|
|
||||||
|
|||||||
@@ -36,15 +36,15 @@ const updatePlaylistSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const addGamesSchema = 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({
|
const removeGamesSchema = z.object({
|
||||||
gameIds: z.array(z.string()).min(1),
|
gameIds: z.array(z.string()).min(1).max(100),
|
||||||
});
|
});
|
||||||
|
|
||||||
const reorderGamesSchema = z.object({
|
const reorderGamesSchema = z.object({
|
||||||
gameIdOrder: z.array(z.string()).min(1),
|
gameIdOrder: z.array(z.string()).min(1).max(100),
|
||||||
});
|
});
|
||||||
|
|
||||||
const copyFlashpointPlaylistSchema = z.object({
|
const copyFlashpointPlaylistSchema = z.object({
|
||||||
|
|||||||
+137
-115
@@ -26,7 +26,7 @@ const updateUserSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const changePasswordSchema = z.object({
|
const changePasswordSchema = z.object({
|
||||||
currentPassword: z.string().min(6),
|
currentPassword: z.string().min(6).optional(),
|
||||||
newPassword: z.string().min(6),
|
newPassword: z.string().min(6),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ const updateThemeSettingsSchema = z.object({
|
|||||||
primaryColor: z.string().min(1),
|
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
|
* GET /api/users
|
||||||
@@ -52,8 +52,8 @@ router.get(
|
|||||||
requirePermission('users.read'),
|
requirePermission('users.read'),
|
||||||
logActivity('users.list', 'users'),
|
logActivity('users.list', 'users'),
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const page = parseInt(req.query.page as string) || 1;
|
const page = Math.max(1, parseInt(req.query.page as string) || 1);
|
||||||
const limit = parseInt(req.query.limit as string) || 50;
|
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
||||||
|
|
||||||
const result = await userService.getUsers(page, limit);
|
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)
|
* GET /api/users/me/theme (DEPRECATED - redirects to new endpoint)
|
||||||
* Legacy endpoint for backward compatibility
|
* 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;
|
export default router;
|
||||||
|
|||||||
+95
-46
@@ -5,6 +5,7 @@ import express, { Express } from 'express';
|
|||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import compression from 'compression';
|
import compression from 'compression';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
import { config } from './config';
|
import { config } from './config';
|
||||||
import { logger, getLoggingStatus, verifyFileLogging } from './utils/logger';
|
import { logger, getLoggingStatus, verifyFileLogging } from './utils/logger';
|
||||||
import { errorHandler } from './middleware/errorHandler';
|
import { errorHandler } from './middleware/errorHandler';
|
||||||
@@ -25,6 +26,12 @@ import { RuffleUpdateJob } from './jobs/RuffleUpdateJob';
|
|||||||
import { CachedSystemSettingsService } from './services/CachedSystemSettingsService';
|
import { CachedSystemSettingsService } from './services/CachedSystemSettingsService';
|
||||||
import { PermissionCache } from './services/PermissionCache';
|
import { PermissionCache } from './services/PermissionCache';
|
||||||
import { RuffleService } from './services/RuffleService';
|
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
|
* Clean up old activity logs based on retention settings
|
||||||
@@ -57,6 +64,28 @@ async function cleanupActivityLogs(): Promise<void> {
|
|||||||
async function startServer() {
|
async function startServer() {
|
||||||
const app: Express = express();
|
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
|
// Security middleware
|
||||||
app.use(
|
app.use(
|
||||||
helmet({
|
helmet({
|
||||||
@@ -98,6 +127,9 @@ async function startServer() {
|
|||||||
app.use(express.json({ limit: '1mb' }));
|
app.use(express.json({ limit: '1mb' }));
|
||||||
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
|
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
|
||||||
|
|
||||||
|
// Cookie parsing
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
// Compression
|
// Compression
|
||||||
app.use(compression());
|
app.use(compression());
|
||||||
|
|
||||||
@@ -107,7 +139,16 @@ async function startServer() {
|
|||||||
|
|
||||||
// Request logging
|
// Request logging
|
||||||
app.use((req, res, next) => {
|
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();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -217,58 +258,31 @@ async function startServer() {
|
|||||||
// Non-fatal error - continue server startup
|
// Non-fatal error - continue server startup
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: Game proxy server functionality has been moved to game-service
|
// Initialize game service configuration (integrated into backend)
|
||||||
// The backend now proxies requests to game-service instead of serving them directly
|
try {
|
||||||
logger.info('📡 Game content will be proxied to game-service');
|
await ConfigManager.loadConfig(config.flashpointPath);
|
||||||
logger.info(` Proxy Server: ${config.gameServerUrl || 'http://localhost:22500'}`);
|
PreferencesService.initialize(config.flashpointPath);
|
||||||
logger.info(` GameZip Server: http://localhost:${config.gameServerHttpPort}`);
|
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
|
// Health check - game service is now integrated (no external dependency)
|
||||||
app.get('/health', async (req, res) => {
|
// NOTE: This endpoint is registered before middleware for load balancer access.
|
||||||
let gameServiceStatus: 'ok' | 'error' | 'unknown' = 'unknown';
|
// It performs basic health checks. For detailed health status, use /api/health (requires auth).
|
||||||
let gameServiceError: string | undefined;
|
app.get('/health', async (_req, res) => {
|
||||||
|
const isHealthy = DatabaseService.isConnected();
|
||||||
// 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';
|
|
||||||
|
|
||||||
res.status(isHealthy ? 200 : 503).json({
|
res.status(isHealthy ? 200 : 503).json({
|
||||||
status: isHealthy ? 'ok' : 'degraded',
|
status: isHealthy ? 'ok' : 'degraded',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
flashpointPath: config.flashpointPath,
|
|
||||||
services: {
|
services: {
|
||||||
database: {
|
database: {
|
||||||
status: DatabaseService.isConnected() ? 'ok' : 'error',
|
status: DatabaseService.isConnected() ? 'ok' : 'error',
|
||||||
},
|
},
|
||||||
gameService: {
|
gameService: {
|
||||||
status: gameServiceStatus,
|
status: 'ok',
|
||||||
url: config.gameServerUrl || 'http://localhost:22500',
|
note: 'integrated',
|
||||||
error: gameServiceError,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -304,10 +318,16 @@ async function startServer() {
|
|||||||
const server = app.listen(config.port, config.host, () => {
|
const server = app.listen(config.port, config.host, () => {
|
||||||
logger.info(`🚀 Flashpoint Web API server running on http://${config.host}:${config.port}`);
|
logger.info(`🚀 Flashpoint Web API server running on http://${config.host}:${config.port}`);
|
||||||
logger.info(`📁 Flashpoint path: ${config.flashpointPath}`);
|
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}`);
|
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)
|
// Start cleanup scheduler for abandoned play sessions (every 6 hours)
|
||||||
const playTrackingService = new PlayTrackingService();
|
const playTrackingService = new PlayTrackingService();
|
||||||
const playSessionCleanupInterval = setInterval(
|
const playSessionCleanupInterval = setInterval(
|
||||||
@@ -318,6 +338,7 @@ async function startServer() {
|
|||||||
},
|
},
|
||||||
6 * 60 * 60 * 1000
|
6 * 60 * 60 * 1000
|
||||||
); // 6 hours
|
); // 6 hours
|
||||||
|
playSessionCleanupInterval.unref();
|
||||||
|
|
||||||
// Run initial play session cleanup
|
// Run initial play session cleanup
|
||||||
playTrackingService.cleanupAbandonedSessions().catch((error) => {
|
playTrackingService.cleanupAbandonedSessions().catch((error) => {
|
||||||
@@ -334,6 +355,7 @@ async function startServer() {
|
|||||||
},
|
},
|
||||||
6 * 60 * 60 * 1000
|
6 * 60 * 60 * 1000
|
||||||
); // 6 hours
|
); // 6 hours
|
||||||
|
loginAttemptsCleanupInterval.unref();
|
||||||
|
|
||||||
// Run initial login attempts cleanup
|
// Run initial login attempts cleanup
|
||||||
authService.cleanupOldLoginAttempts().catch((error) => {
|
authService.cleanupOldLoginAttempts().catch((error) => {
|
||||||
@@ -349,6 +371,7 @@ async function startServer() {
|
|||||||
},
|
},
|
||||||
24 * 60 * 60 * 1000
|
24 * 60 * 60 * 1000
|
||||||
); // 24 hours
|
); // 24 hours
|
||||||
|
activityLogsCleanupInterval.unref();
|
||||||
|
|
||||||
// Run initial activity logs cleanup
|
// Run initial activity logs cleanup
|
||||||
cleanupActivityLogs().catch((error) => {
|
cleanupActivityLogs().catch((error) => {
|
||||||
@@ -356,7 +379,7 @@ async function startServer() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
const shutdown = () => {
|
const shutdown = async () => {
|
||||||
logger.info('Shutting down gracefully...');
|
logger.info('Shutting down gracefully...');
|
||||||
|
|
||||||
// Stop scheduled jobs
|
// Stop scheduled jobs
|
||||||
@@ -370,6 +393,32 @@ async function startServer() {
|
|||||||
clearInterval(loginAttemptsCleanupInterval);
|
clearInterval(loginAttemptsCleanupInterval);
|
||||||
clearInterval(activityLogsCleanupInterval);
|
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
|
// Close server
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
logger.info('Server closed');
|
logger.info('Server closed');
|
||||||
@@ -380,7 +429,7 @@ async function startServer() {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
logger.error('Could not close connections in time, forcefully shutting down');
|
logger.error('Could not close connections in time, forcefully shutting down');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}, 10000);
|
}, 10000).unref();
|
||||||
};
|
};
|
||||||
|
|
||||||
process.on('SIGTERM', shutdown);
|
process.on('SIGTERM', shutdown);
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { AUTH_ACTIONS, categorizeAction } from '../utils/activityUtils';
|
|||||||
/**
|
/**
|
||||||
* Generate SQL IN clause from AUTH_ACTIONS constant.
|
* Generate SQL IN clause from AUTH_ACTIONS constant.
|
||||||
* Keeps the constant as the single source of truth for auth action strings.
|
* 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("', '")}'`;
|
const AUTH_ACTIONS_SQL = `'${AUTH_ACTIONS.join("', '")}'`;
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,25 @@ import { hashPassword, verifyPassword } from '../utils/password';
|
|||||||
import { AppError } from '../middleware/errorHandler';
|
import { AppError } from '../middleware/errorHandler';
|
||||||
import { LoginCredentials, RegisterData, AuthTokens, AuthUser } from '../types/auth';
|
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 {
|
export class AuthService {
|
||||||
private systemSettings: SystemSettingsService;
|
private systemSettings: SystemSettingsService;
|
||||||
private cachedSystemSettings: CachedSystemSettingsService;
|
private cachedSystemSettings: CachedSystemSettingsService;
|
||||||
@@ -98,53 +117,55 @@ export class AuthService {
|
|||||||
* The method automatically detects which scenario applies based on whether users exist
|
* The method automatically detects which scenario applies based on whether users exist
|
||||||
*/
|
*/
|
||||||
async register(data: RegisterData): Promise<{ user: AuthUser; tokens: AuthTokens }> {
|
async register(data: RegisterData): Promise<{ user: AuthUser; tokens: AuthTokens }> {
|
||||||
// Check if this is initial setup (no users exist)
|
// Hash password before transaction
|
||||||
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
|
|
||||||
const passwordHash = await hashPassword(data.password);
|
const passwordHash = await hashPassword(data.password);
|
||||||
|
|
||||||
// First user becomes admin (role_id = 1), subsequent users get 'user' role (role_id = 2)
|
// Wrap setup check + uniqueness checks + insert in transaction to prevent race condition
|
||||||
const roleId = isInitialSetup ? 1 : 2;
|
const db = UserDatabaseService.getDatabase();
|
||||||
const result = UserDatabaseService.run(
|
const registerTransaction = db.transaction(() => {
|
||||||
'INSERT INTO users (username, email, password_hash, role_id) VALUES (?, ?, ?, ?)',
|
// Check username uniqueness inside transaction
|
||||||
[data.username, data.email, passwordHash, roleId]
|
const existingUser = db.prepare('SELECT id FROM users WHERE username = ?').get(data.username);
|
||||||
);
|
if (existingUser) {
|
||||||
|
throw new AppError(409, 'Username already exists');
|
||||||
|
}
|
||||||
|
|
||||||
if (isInitialSetup) {
|
// Check email uniqueness inside transaction
|
||||||
logger.info('╔═══════════════════════════════════════════════════════════════╗');
|
const existingEmail = db.prepare('SELECT id FROM users WHERE email = ?').get(data.email);
|
||||||
logger.info('║ INITIAL ADMIN ACCOUNT CREATED ║');
|
if (existingEmail) {
|
||||||
logger.info(`║ Username: ${data.username.padEnd(48)} ║`);
|
throw new AppError(409, 'Email already exists');
|
||||||
logger.info('║ The first user has been granted administrator privileges. ║');
|
}
|
||||||
logger.info('╚═══════════════════════════════════════════════════════════════╝');
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
// Apply default theme and primary color from system settings
|
||||||
const appSettings = this.cachedSystemSettings.getCategory('app');
|
const appSettings = this.cachedSystemSettings.getCategory('app');
|
||||||
@@ -319,7 +340,7 @@ export class AuthService {
|
|||||||
/**
|
/**
|
||||||
* Generate access and refresh tokens
|
* Generate access and refresh tokens
|
||||||
*/
|
*/
|
||||||
private async generateTokens(user: any): Promise<AuthTokens> {
|
private async generateTokens(user: UserTokenData): Promise<AuthTokens> {
|
||||||
const accessToken = generateAccessToken({
|
const accessToken = generateAccessToken({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
@@ -406,7 +427,7 @@ export class AuthService {
|
|||||||
/**
|
/**
|
||||||
* Get auth settings from system_settings
|
* Get auth settings from system_settings
|
||||||
*/
|
*/
|
||||||
private getAuthSettings(): any {
|
private getAuthSettings(): AuthSettings {
|
||||||
try {
|
try {
|
||||||
const authSettings = this.systemSettings.getCategory('auth');
|
const authSettings = this.systemSettings.getCategory('auth');
|
||||||
|
|
||||||
@@ -424,8 +445,12 @@ export class AuthService {
|
|||||||
? 1
|
? 1
|
||||||
: 0
|
: 0
|
||||||
: 1,
|
: 1,
|
||||||
max_login_attempts: authSettings.maxLoginAttempts || 5,
|
max_login_attempts:
|
||||||
lockout_duration_minutes: authSettings.lockoutDurationMinutes || 15,
|
typeof authSettings.maxLoginAttempts === 'number' ? authSettings.maxLoginAttempts : 5,
|
||||||
|
lockout_duration_minutes:
|
||||||
|
typeof authSettings.lockoutDurationMinutes === 'number'
|
||||||
|
? authSettings.lockoutDurationMinutes
|
||||||
|
: 15,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to get auth settings, using defaults:', error);
|
logger.error('Failed to get auth settings, using defaults:', error);
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export class AuthSettingsService {
|
|||||||
*/
|
*/
|
||||||
updateSettings(data: Partial<AuthSettings>, updatedBy: number): AuthSettings {
|
updateSettings(data: Partial<AuthSettings>, updatedBy: number): AuthSettings {
|
||||||
// Filter out non-setting fields (updatedAt, updatedBy)
|
// 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) {
|
if (Object.keys(settingsToUpdate).length === 0) {
|
||||||
return this.getSettings();
|
return this.getSettings();
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export class CachedSystemSettingsService extends SystemSettingsService {
|
|||||||
this.cacheTTL = cacheTTL;
|
this.cacheTTL = cacheTTL;
|
||||||
|
|
||||||
// Start cache cleanup interval (every 5 minutes)
|
// 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)
|
* 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',
|
'flashpointarchive.org',
|
||||||
'www.flashpointarchive.org',
|
'www.flashpointarchive.org',
|
||||||
|
'download.flashpointarchive.org',
|
||||||
|
'fpfss.unstable.life',
|
||||||
'github.com',
|
'github.com',
|
||||||
'raw.githubusercontent.com',
|
'raw.githubusercontent.com',
|
||||||
'gist.githubusercontent.com',
|
'gist.githubusercontent.com',
|
||||||
@@ -38,9 +41,17 @@ export interface CommunityPlaylistsResponse {
|
|||||||
lastFetched: string;
|
lastFetched: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PlaylistData {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
games: Array<{ gameId: string; [key: string]: unknown }>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DownloadResult {
|
export interface DownloadResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
playlist?: any;
|
playlist?: PlaylistData;
|
||||||
error?: string;
|
error?: string;
|
||||||
conflict?: boolean;
|
conflict?: boolean;
|
||||||
}
|
}
|
||||||
@@ -212,7 +223,8 @@ export class CommunityPlaylistService {
|
|||||||
|
|
||||||
// Process all headers and tables in sequence
|
// Process all headers and tables in sequence
|
||||||
$('h2, h3, table.wikitable').each((i, elem) => {
|
$('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') {
|
if (tagName === 'h2') {
|
||||||
// This is a main category header
|
// This is a main category header
|
||||||
@@ -312,26 +324,37 @@ export class CommunityPlaylistService {
|
|||||||
/**
|
/**
|
||||||
* Validate that the downloaded playlist has the required structure
|
* 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
|
// 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');
|
logger.warn('[CommunityPlaylist] Validation failed: missing or invalid id');
|
||||||
return false;
|
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');
|
logger.warn('[CommunityPlaylist] Validation failed: missing or invalid title');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Games array must exist and be valid
|
// 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');
|
logger.warn('[CommunityPlaylist] Validation failed: games is not an array');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Each game entry must have gameId
|
// Each game entry must have gameId
|
||||||
for (const game of data.games) {
|
for (const game of obj.games) {
|
||||||
if (typeof game !== 'object' || !game.gameId) {
|
if (typeof game !== 'object' || !game.gameId) {
|
||||||
logger.warn('[CommunityPlaylist] Validation failed: game entry missing gameId');
|
logger.warn('[CommunityPlaylist] Validation failed: game entry missing gameId');
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -5,19 +5,24 @@ import { measureQueryPerformance } from '../utils/queryPerformance';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
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
|
// Using dynamic import to avoid circular dependency
|
||||||
// Type is defined here to maintain type safety while avoiding circular import
|
|
||||||
interface GameSearchCacheType {
|
interface GameSearchCacheType {
|
||||||
clearCache: () => void;
|
clearCache: () => void;
|
||||||
}
|
}
|
||||||
|
interface GameServiceType {
|
||||||
|
clearFlashSwfCache: () => void;
|
||||||
|
}
|
||||||
let GameSearchCache: GameSearchCacheType | null = null;
|
let GameSearchCache: GameSearchCacheType | null = null;
|
||||||
|
let GameServiceStatic: GameServiceType | null = null;
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
GameSearchCache = require('./GameSearchCache').GameSearchCache as GameSearchCacheType;
|
GameSearchCache = require('./GameSearchCache').GameSearchCache as GameSearchCacheType;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
GameServiceStatic = require('./GameService').GameService as GameServiceType;
|
||||||
} catch {
|
} catch {
|
||||||
// GameSearchCache may not be available in all contexts
|
// May not be available in all contexts
|
||||||
logger.debug('GameSearchCache not available for cache invalidation');
|
logger.debug('GameSearchCache/GameService not available for cache invalidation');
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DatabaseService {
|
export class DatabaseService {
|
||||||
@@ -310,30 +315,38 @@ export class DatabaseService {
|
|||||||
|
|
||||||
logger.info('Source database file changed, syncing...');
|
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 using local copy, update it from source
|
||||||
if (this.isUsingLocalCopy && config.enableLocalDbCopy) {
|
if (this.isUsingLocalCopy && config.enableLocalDbCopy) {
|
||||||
await this.copyDatabaseToLocal();
|
await this.copyDatabaseToLocal();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reopen database connection
|
// Open new connection FIRST
|
||||||
this.db = this.openDatabase(this.activeDbPath);
|
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
|
// Update modification time
|
||||||
this.lastModifiedTime = stats.mtimeMs;
|
this.lastModifiedTime = stats.mtimeMs;
|
||||||
|
|
||||||
// Invalidate game search cache since database changed
|
// Invalidate caches since database changed
|
||||||
if (GameSearchCache) {
|
if (GameSearchCache) {
|
||||||
GameSearchCache.clearCache();
|
GameSearchCache.clearCache();
|
||||||
logger.info('Game search cache invalidated due to database reload');
|
|
||||||
}
|
}
|
||||||
|
if (GameServiceStatic) {
|
||||||
logger.info('Database connection reopened successfully');
|
GameServiceStatic.clearFlashSwfCache();
|
||||||
|
}
|
||||||
|
logger.info('Database reloaded, caches invalidated');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to sync and reload database:', error);
|
logger.error('Failed to sync and reload database:', error);
|
||||||
// Try to re-initialize if sync failed
|
// Try to re-initialize if sync failed
|
||||||
@@ -369,26 +382,36 @@ export class DatabaseService {
|
|||||||
if (this.isUsingLocalCopy && config.enableLocalDbCopy) {
|
if (this.isUsingLocalCopy && config.enableLocalDbCopy) {
|
||||||
logger.info('Force syncing database from source...');
|
logger.info('Force syncing database from source...');
|
||||||
|
|
||||||
// Close existing connection
|
|
||||||
if (this.db) {
|
|
||||||
this.db.close();
|
|
||||||
this.db = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy from source
|
// Copy from source
|
||||||
await this.copyDatabaseToLocal();
|
await this.copyDatabaseToLocal();
|
||||||
|
|
||||||
// Reopen connection
|
// Open new connection FIRST
|
||||||
this.db = this.openDatabase(this.activeDbPath);
|
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
|
// Update modification time
|
||||||
const stats = fs.statSync(this.sourceDbPath);
|
const stats = fs.statSync(this.sourceDbPath);
|
||||||
this.lastModifiedTime = stats.mtimeMs;
|
this.lastModifiedTime = stats.mtimeMs;
|
||||||
|
|
||||||
// Invalidate cache
|
// Invalidate caches
|
||||||
if (GameSearchCache) {
|
if (GameSearchCache) {
|
||||||
GameSearchCache.clearCache();
|
GameSearchCache.clearCache();
|
||||||
}
|
}
|
||||||
|
if (GameServiceStatic) {
|
||||||
|
GameServiceStatic.clearFlashSwfCache();
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('Force sync completed');
|
logger.info('Force sync completed');
|
||||||
} else {
|
} else {
|
||||||
@@ -533,17 +556,11 @@ export class DatabaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save database changes to disk
|
// 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
|
// This method exists for compatibility but is essentially a no-op
|
||||||
static save(): void {
|
static save(): void {
|
||||||
try {
|
// No action needed in DELETE journal mode
|
||||||
const db = this.getDatabase();
|
// Changes are automatically flushed to disk
|
||||||
// Force a checkpoint in WAL mode to ensure all changes are written
|
logger.debug('Database save called (no-op in DELETE journal mode)');
|
||||||
db.pragma('wal_checkpoint(TRUNCATE)');
|
|
||||||
logger.info('Database changes flushed to disk');
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to save database:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ export class DomainService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate and normalize a hostname
|
* Validate and normalize a hostname
|
||||||
|
* Note: Route-level Zod validation performs comprehensive checks.
|
||||||
|
* This method provides basic sanitization only.
|
||||||
*/
|
*/
|
||||||
private validateHostname(hostname: string): string {
|
private validateHostname(hostname: string): string {
|
||||||
const trimmed = hostname.trim().toLowerCase();
|
const trimmed = hostname.trim().toLowerCase();
|
||||||
@@ -93,21 +95,6 @@ export class DomainService {
|
|||||||
throw new AppError(400, 'Hostname cannot be empty');
|
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;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,10 +75,9 @@ export class DownloadManager {
|
|||||||
this.activeDownloads.set(gameDataId, controller);
|
this.activeDownloads.set(gameDataId, controller);
|
||||||
|
|
||||||
// Chain external abort signal if provided
|
// Chain external abort signal if provided
|
||||||
|
const onExternalAbort = () => controller.abort();
|
||||||
if (abortSignal) {
|
if (abortSignal) {
|
||||||
abortSignal.addEventListener('abort', () => {
|
abortSignal.addEventListener('abort', onExternalAbort);
|
||||||
controller.abort();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -215,6 +214,9 @@ export class DownloadManager {
|
|||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
// Clean up
|
// Clean up
|
||||||
|
if (abortSignal) {
|
||||||
|
abortSignal.removeEventListener('abort', onExternalAbort);
|
||||||
|
}
|
||||||
this.activeDownloads.delete(gameDataId);
|
this.activeDownloads.delete(gameDataId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,6 +250,18 @@ export class DownloadManager {
|
|||||||
const writer = fs.createWriteStream(destPath);
|
const writer = fs.createWriteStream(destPath);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
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) => {
|
response.data.on('data', (chunk: Buffer) => {
|
||||||
downloadedBytes += chunk.length;
|
downloadedBytes += chunk.length;
|
||||||
onProgress?.(downloadedBytes, totalBytes);
|
onProgress?.(downloadedBytes, totalBytes);
|
||||||
@@ -258,24 +272,21 @@ export class DownloadManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
writer.on('finish', () => {
|
writer.on('finish', () => {
|
||||||
|
cleanup();
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
writer.on('error', (error) => {
|
writer.on('error', (error) => {
|
||||||
|
cleanup();
|
||||||
reject(new Error(`Failed to write file: ${error.message}`));
|
reject(new Error(`Failed to write file: ${error.message}`));
|
||||||
});
|
});
|
||||||
|
|
||||||
response.data.on('error', (error: Error) => {
|
response.data.on('error', (error: Error) => {
|
||||||
|
cleanup();
|
||||||
writer.destroy();
|
writer.destroy();
|
||||||
reject(new Error(`Download stream error: ${error.message}`));
|
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);
|
response.data.pipe(writer);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,13 +136,13 @@ export class FavoritesService {
|
|||||||
|
|
||||||
if (limit !== undefined) {
|
if (limit !== undefined) {
|
||||||
// Validate limit is a positive integer
|
// 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 ?`;
|
query += ` LIMIT ?`;
|
||||||
params.push(safeLimit);
|
params.push(safeLimit);
|
||||||
|
|
||||||
if (offset !== undefined) {
|
if (offset !== undefined) {
|
||||||
// Validate offset is a non-negative integer
|
// 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 ?`;
|
query += ` OFFSET ?`;
|
||||||
params.push(safeOffset);
|
params.push(safeOffset);
|
||||||
}
|
}
|
||||||
@@ -220,10 +220,13 @@ export class FavoritesService {
|
|||||||
const gameIds = favorites.map((f) => f.game_id);
|
const gameIds = favorites.map((f) => f.game_id);
|
||||||
const games = await this.gameService.getGamesByIds(gameIds);
|
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
|
// Merge game data with addedAt timestamps
|
||||||
const favoriteGames: FavoriteGame[] = favorites
|
const favoriteGames: FavoriteGame[] = favorites
|
||||||
.map((fav) => {
|
.map((fav) => {
|
||||||
const game = games.find((g) => g.id === fav.game_id);
|
const game = gamesMap.get(fav.game_id);
|
||||||
if (game) {
|
if (game) {
|
||||||
return {
|
return {
|
||||||
...game,
|
...game,
|
||||||
|
|||||||
@@ -1,9 +1,22 @@
|
|||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import axios from 'axios';
|
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { config } from '../config';
|
import { config } from '../config';
|
||||||
import { DatabaseService } from './DatabaseService';
|
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
|
* Service for managing game data ZIP files
|
||||||
@@ -11,66 +24,117 @@ import { DatabaseService } from './DatabaseService';
|
|||||||
*/
|
*/
|
||||||
export class GameDataService {
|
export class GameDataService {
|
||||||
/**
|
/**
|
||||||
* Mount the ZIP file for a game if it has game data
|
* Get game data entry from the Flashpoint database
|
||||||
* @param gameId The game's UUID
|
|
||||||
* @returns true if ZIP was mounted or already mounted, false if no ZIP found
|
|
||||||
*/
|
*/
|
||||||
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 {
|
try {
|
||||||
const mountId = gameId;
|
const mountId = gameId;
|
||||||
|
|
||||||
// Find ZIP file in Data/Games/ directory
|
|
||||||
// ZIP files are named: {gameId}-{timestamp}.zip
|
|
||||||
const gamesPath = config.flashpointGamesPath;
|
const gamesPath = config.flashpointGamesPath;
|
||||||
const files = await fs.readdir(gamesPath);
|
|
||||||
|
|
||||||
// Look for ZIP file starting with gameId
|
// Get game data entry from database to get dateAdded and sha256
|
||||||
const zipPattern = `${gameId}-`;
|
const gameDataEntry = this.getGameDataEntry(gameId);
|
||||||
const zipFile = files.find((f) => f.startsWith(zipPattern) && f.endsWith('.zip'));
|
|
||||||
|
|
||||||
if (!zipFile) {
|
// Build ZIP path - try direct path first when we have dateAdded
|
||||||
logger.warn(`[GameDataService] No ZIP file found for game ${gameId}`);
|
let zipPath: string | null = null;
|
||||||
return false;
|
|
||||||
|
// 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);
|
// Only readdir as fallback when direct path doesn't exist
|
||||||
|
if (!zipPath) {
|
||||||
logger.info(
|
let files: string[];
|
||||||
`[GameDataService] Requesting game-service to mount ZIP for game ${gameId}: ${zipFile}`
|
try {
|
||||||
);
|
files = await fs.readdir(gamesPath);
|
||||||
|
} catch (err) {
|
||||||
// Call game-service API to mount the ZIP
|
logger.warn(`[GameDataService] Cannot read games directory: ${gamesPath}`, err);
|
||||||
const gameZipServerUrl = config.gameServiceGameZipUrl || 'http://localhost:22501';
|
// Fall through to attempt download via gameZipServer
|
||||||
try {
|
files = [];
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
|
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}`);
|
logger.info(`[GameDataService] ✓ ZIP mounted successfully for game ${gameId}`);
|
||||||
return true;
|
return { mounted: true, downloading: false };
|
||||||
} catch (error) {
|
} else {
|
||||||
if (axios.isAxiosError(error) && error.response?.status === 409) {
|
logger.warn(
|
||||||
// Already mounted
|
`[GameDataService] Mount failed for game ${gameId} (status: ${result.statusCode})`
|
||||||
logger.debug(`[GameDataService] ZIP already mounted for game ${gameId}`);
|
);
|
||||||
return true;
|
return { mounted: false, downloading: false };
|
||||||
}
|
|
||||||
logger.error(`[GameDataService] Failed to mount ZIP via game-service:`, error);
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[GameDataService] Error mounting ZIP for game ${gameId}:`, 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> {
|
async mountMultipleGameZips(activeDataIds: number[]): Promise<void> {
|
||||||
logger.info(`[GameDataService] Mounting ${activeDataIds.length} game ZIPs...`);
|
logger.info(`[GameDataService] Mounting ${activeDataIds.length} game ZIPs...`);
|
||||||
|
|
||||||
// Fetch game data for each activeDataId to get gameId
|
if (activeDataIds.length === 0) return;
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
const gameDataResults = await Promise.allSettled(gameDataPromises);
|
// Batch query: single IN clause instead of N individual queries
|
||||||
const validGameData = gameDataResults
|
const placeholders = activeDataIds.map(() => '?').join(', ');
|
||||||
.filter(
|
const rows = DatabaseService.all(
|
||||||
(r): r is PromiseFulfilledResult<{ gameId: string; activeDataId: number } | null> =>
|
`SELECT id, gameId FROM game_data WHERE id IN (${placeholders})`,
|
||||||
r.status === 'fulfilled' && r.value !== null
|
activeDataIds
|
||||||
)
|
) as Array<{ id: number; gameId: string }>;
|
||||||
.map((r) => r.value!);
|
|
||||||
|
const validGameData = rows.map((r) => ({ gameId: r.gameId, activeDataId: r.id }));
|
||||||
|
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
validGameData.map(({ gameId }) => this.mountGameZip(gameId))
|
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;
|
const failed = results.length - succeeded;
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -122,15 +184,20 @@ export class GameDataService {
|
|||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (file.endsWith('.zip')) {
|
if (file.endsWith('.zip')) {
|
||||||
// Extract activeDataId from filename (format: {id}-{timestamp}.zip)
|
// Extract gameId from filename (format: {gameId}-{timestamp}.zip)
|
||||||
const match = file.match(/^(\d+)-\d+\.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) {
|
if (match) {
|
||||||
const activeDataId = parseInt(match[1], 10);
|
const gameId = match[1];
|
||||||
const filePath = path.join(gamesPath, file);
|
const filePath = path.join(gamesPath, file);
|
||||||
const stats = await fs.stat(filePath);
|
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({
|
zipFiles.push({
|
||||||
activeDataId,
|
activeDataId: 0, // Placeholder - would need to query database
|
||||||
filename: file,
|
filename: file,
|
||||||
size: stats.size,
|
size: stats.size,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,9 +16,33 @@ export interface GameData {
|
|||||||
parameters: string | null;
|
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.
|
* 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 {
|
export class GameDatabaseUpdater {
|
||||||
/**
|
/**
|
||||||
@@ -53,55 +77,64 @@ export class GameDatabaseUpdater {
|
|||||||
relativePath,
|
relativePath,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update game_data table
|
// Wrap both UPDATE statements in a transaction for atomicity
|
||||||
const updateGameDataSql = `
|
const db = DatabaseService.getDatabase();
|
||||||
UPDATE game_data
|
const updateTransaction = db.transaction(() => {
|
||||||
SET presentOnDisk = 1, path = ?
|
// Update game_data table
|
||||||
WHERE id = ?
|
const updateGameDataSql = `
|
||||||
`;
|
UPDATE game_data
|
||||||
|
SET presentOnDisk = 1, path = ?
|
||||||
logger.debug('Executing UPDATE on game_data', {
|
WHERE id = ?
|
||||||
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 = ?
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
logger.debug('Executing UPDATE on game table', {
|
logger.debug('Executing UPDATE on game_data', {
|
||||||
sql: updateGameSql,
|
sql: updateGameDataSql,
|
||||||
params: [gameData.gameId, gameDataId],
|
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', {
|
logger.debug('game_data UPDATE successful');
|
||||||
gameId: gameData.gameId,
|
|
||||||
});
|
// Check if this game_data is the active data for the game
|
||||||
} else {
|
const gameSql = `
|
||||||
logger.debug('Skipping game table update - game data is not active', {
|
SELECT id, activeDataId
|
||||||
gameId: gameData.gameId,
|
FROM game
|
||||||
activeDataId: game?.activeDataId,
|
WHERE id = ?
|
||||||
requestedDataId: gameDataId,
|
`;
|
||||||
});
|
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
|
// Save database to disk
|
||||||
await this.saveDatabase();
|
await this.saveDatabase();
|
||||||
@@ -191,7 +224,7 @@ export class GameDatabaseUpdater {
|
|||||||
/**
|
/**
|
||||||
* Map database row to GameData object.
|
* Map database row to GameData object.
|
||||||
*/
|
*/
|
||||||
private static mapRowToGameData(row: any): GameData {
|
private static mapRowToGameData(row: GameDataRow): GameData {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
gameId: row.gameId,
|
gameId: row.gameId,
|
||||||
@@ -230,18 +263,28 @@ export class GameDatabaseUpdater {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark game data as not downloaded (for cleanup/error scenarios).
|
* Mark game data as not downloaded (for cleanup/error scenarios).
|
||||||
|
* Wrapped in transaction for consistency with markAsDownloaded.
|
||||||
*
|
*
|
||||||
* @param gameDataId - The game_data.id
|
* @param gameDataId - The game_data.id
|
||||||
*/
|
*/
|
||||||
static async markAsNotDownloaded(gameDataId: number): Promise<void> {
|
static async markAsNotDownloaded(gameDataId: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const sql = `
|
const db = DatabaseService.getDatabase();
|
||||||
UPDATE game_data
|
|
||||||
SET presentOnDisk = 0, path = NULL
|
// Wrap UPDATE in transaction for atomicity
|
||||||
WHERE id = ?
|
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();
|
await this.saveDatabase();
|
||||||
|
|
||||||
logger.info('Marked game data as not downloaded', { gameDataId });
|
logger.info('Marked game data as not downloaded', { gameDataId });
|
||||||
|
|||||||
@@ -1,239 +0,0 @@
|
|||||||
import axios from 'axios';
|
|
||||||
import fs from 'fs/promises';
|
|
||||||
import path from 'path';
|
|
||||||
import http from 'http';
|
|
||||||
import { logger } from '../utils/logger';
|
|
||||||
import { config } from '../config';
|
|
||||||
|
|
||||||
interface ProxySettings {
|
|
||||||
infinityServerURL?: string;
|
|
||||||
externalFilePaths?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Service to handle game file requests with automatic fallback to external archives
|
|
||||||
* This makes the web app independent of the Game Server's useInfinityServer setting
|
|
||||||
*/
|
|
||||||
export class GameFileService {
|
|
||||||
private infinityServerURL: string | null = null;
|
|
||||||
private externalFilePaths: string[] = [];
|
|
||||||
private gameServerUrl = 'http://localhost:22500';
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.loadProxySettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load external fallback URLs from proxySettings.json
|
|
||||||
*/
|
|
||||||
private async loadProxySettings() {
|
|
||||||
try {
|
|
||||||
const proxySettingsPath = path.join(config.flashpointPath, 'Server', 'proxySettings.json');
|
|
||||||
const data = await fs.readFile(proxySettingsPath, 'utf-8');
|
|
||||||
const settings: ProxySettings = JSON.parse(data);
|
|
||||||
|
|
||||||
// Use externalFilePaths directly - these have the correct HTTP URLs
|
|
||||||
this.externalFilePaths = settings.externalFilePaths || [];
|
|
||||||
|
|
||||||
// Fix infinityServerURL if it uses HTTPS (should be HTTP)
|
|
||||||
if (settings.infinityServerURL) {
|
|
||||||
this.infinityServerURL = settings.infinityServerURL.replace(/^https:\/\//, 'http://');
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`[GameFileService] Loaded proxy settings`);
|
|
||||||
logger.info(`[GameFileService] Infinity Server: ${this.infinityServerURL}`);
|
|
||||||
logger.info(`[GameFileService] External paths: ${this.externalFilePaths.length} sources`);
|
|
||||||
|
|
||||||
// Log all sources for debugging
|
|
||||||
this.externalFilePaths.forEach((path, i) => {
|
|
||||||
logger.info(`[GameFileService] [${i + 1}] ${path}`);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn('[GameFileService] Could not load proxySettings.json, using defaults');
|
|
||||||
// Use HTTP (not HTTPS) as per Flashpoint documentation
|
|
||||||
this.infinityServerURL = 'http://infinity.flashpointarchive.org/Flashpoint/Legacy/htdocs/';
|
|
||||||
this.externalFilePaths = [
|
|
||||||
'http://infinity.flashpointarchive.org/Flashpoint/Legacy/htdocs',
|
|
||||||
'http://infinity.unstable.life/Flashpoint/Legacy/htdocs/',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a game file, with automatic fallback to external sources
|
|
||||||
* @param urlPath The path like "www.example.com/path/file.swf"
|
|
||||||
* @returns File buffer and content type
|
|
||||||
*/
|
|
||||||
async getGameFile(
|
|
||||||
urlPath: string
|
|
||||||
): Promise<{ data: Buffer; contentType: string; source: string }> {
|
|
||||||
logger.info(`[GameFileService] ========================================`);
|
|
||||||
logger.info(`[GameFileService] Requested: ${urlPath}`);
|
|
||||||
|
|
||||||
// First, try the local Game Server
|
|
||||||
logger.info(`[GameFileService] [1/4] Trying local Game Server (localhost:22500)...`);
|
|
||||||
try {
|
|
||||||
const result = await this.tryGameServer(urlPath);
|
|
||||||
if (result) {
|
|
||||||
logger.info(`[GameFileService] ✓ Found in Game Server!`);
|
|
||||||
return { ...result, source: 'game-server' };
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn(`[GameFileService] ✗ Game Server error: ${error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If Game Server returns 404, try external sources
|
|
||||||
logger.info(`[GameFileService] File not found locally, trying external archives...`);
|
|
||||||
|
|
||||||
// Try Infinity Server first
|
|
||||||
if (this.infinityServerURL) {
|
|
||||||
logger.info(`[GameFileService] [2/4] Trying Infinity Server...`);
|
|
||||||
const result = await this.tryExternalSource(this.infinityServerURL, urlPath);
|
|
||||||
if (result) {
|
|
||||||
logger.info(`[GameFileService] ✓ Found in Infinity Server!`);
|
|
||||||
return { ...result, source: 'infinity-server' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try other external paths
|
|
||||||
let attemptNum = 3;
|
|
||||||
for (const externalPath of this.externalFilePaths) {
|
|
||||||
logger.info(
|
|
||||||
`[GameFileService] [${attemptNum}/${3 + this.externalFilePaths.length}] Trying: ${externalPath}`
|
|
||||||
);
|
|
||||||
const result = await this.tryExternalSource(externalPath, urlPath);
|
|
||||||
if (result) {
|
|
||||||
logger.info(`[GameFileService] ✓ Found in external source!`);
|
|
||||||
return { ...result, source: externalPath };
|
|
||||||
}
|
|
||||||
attemptNum++;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.error(
|
|
||||||
`[GameFileService] ✗ File not found in any source (tried ${2 + this.externalFilePaths.length} sources)`
|
|
||||||
);
|
|
||||||
throw new Error('File not found in Game Server or any external source');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Try to get file from the local Game Server
|
|
||||||
*/
|
|
||||||
private async tryGameServer(
|
|
||||||
urlPath: string
|
|
||||||
): Promise<{ data: Buffer; contentType: string } | null> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
// Make proxy-style HTTP request
|
|
||||||
const fullUrl = `http://${urlPath}`;
|
|
||||||
const parsedUrl = new URL(fullUrl);
|
|
||||||
|
|
||||||
const options: http.RequestOptions = {
|
|
||||||
hostname: 'localhost',
|
|
||||||
port: 22500,
|
|
||||||
path: fullUrl,
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
Host: parsedUrl.hostname,
|
|
||||||
'User-Agent': 'Flashpoint-WebApp/1.0',
|
|
||||||
},
|
|
||||||
timeout: 10000,
|
|
||||||
};
|
|
||||||
|
|
||||||
const req = http.request(options, (res) => {
|
|
||||||
if (res.statusCode === 404) {
|
|
||||||
resolve(null); // Not found, will try external sources
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.statusCode !== 200) {
|
|
||||||
reject(new Error(`HTTP ${res.statusCode}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
res.on('data', (chunk) => chunks.push(chunk));
|
|
||||||
res.on('end', () => {
|
|
||||||
const data = Buffer.concat(chunks);
|
|
||||||
const contentType = res.headers['content-type'] || 'application/octet-stream';
|
|
||||||
resolve({ data, contentType });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
req.on('error', reject);
|
|
||||||
req.on('timeout', () => {
|
|
||||||
req.destroy();
|
|
||||||
reject(new Error('Timeout'));
|
|
||||||
});
|
|
||||||
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Try to download file from an external source
|
|
||||||
*/
|
|
||||||
private async tryExternalSource(
|
|
||||||
baseUrl: string,
|
|
||||||
urlPath: string
|
|
||||||
): Promise<{ data: Buffer; contentType: string } | null> {
|
|
||||||
// Construct full URL
|
|
||||||
// baseUrl might be: http://infinity.flashpointarchive.org/Flashpoint/Legacy/htdocs/
|
|
||||||
// urlPath is: www.example.com/path/file.swf
|
|
||||||
// Result: http://infinity.flashpointarchive.org/Flashpoint/Legacy/htdocs/www.example.com/path/file.swf
|
|
||||||
//
|
|
||||||
// Special case for archive.org ZIP files:
|
|
||||||
// baseUrl: http://archive.org/download/FP90Data/FP90Data.zip/htdocs
|
|
||||||
// Result: http://archive.org/download/FP90Data/FP90Data.zip/htdocs/www.example.com/path/file.swf
|
|
||||||
|
|
||||||
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
|
||||||
const fullUrl = `${normalizedBase}${urlPath}`;
|
|
||||||
|
|
||||||
logger.info(`[GameFileService] Downloading: ${fullUrl}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await axios.get(fullUrl, {
|
|
||||||
responseType: 'arraybuffer',
|
|
||||||
timeout: 45000, // Increased timeout for slow external servers
|
|
||||||
maxRedirects: 5,
|
|
||||||
validateStatus: (status) => status === 200,
|
|
||||||
headers: {
|
|
||||||
'User-Agent': 'Flashpoint-WebApp/1.0',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = Buffer.from(response.data);
|
|
||||||
const contentType = response.headers['content-type'] || 'application/octet-stream';
|
|
||||||
|
|
||||||
logger.info(`[GameFileService] ✓ Success: ${data.length} bytes (${contentType})`);
|
|
||||||
|
|
||||||
// TODO: Optionally cache the file locally for future requests
|
|
||||||
|
|
||||||
return { data, contentType };
|
|
||||||
} catch (error) {
|
|
||||||
// Log detailed error but return null to try next source
|
|
||||||
if (axios.isAxiosError(error)) {
|
|
||||||
if (error.response?.status === 404) {
|
|
||||||
logger.warn(`[GameFileService] ✗ Not found (404)`);
|
|
||||||
} else if (error.response?.status === 403) {
|
|
||||||
logger.warn(`[GameFileService] ✗ Forbidden (403) - access denied`);
|
|
||||||
} else if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
|
|
||||||
logger.warn(`[GameFileService] ✗ Timeout after 45s`);
|
|
||||||
} else if (error.code === 'ENOTFOUND') {
|
|
||||||
logger.warn(`[GameFileService] ✗ DNS resolution failed`);
|
|
||||||
} else if (error.response) {
|
|
||||||
logger.warn(
|
|
||||||
`[GameFileService] ✗ HTTP ${error.response.status}: ${error.response.statusText}`
|
|
||||||
);
|
|
||||||
} else if (error.request) {
|
|
||||||
logger.warn(`[GameFileService] ✗ No response received: ${error.message}`);
|
|
||||||
} else {
|
|
||||||
logger.warn(`[GameFileService] ✗ Network error: ${error.message}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.warn(`[GameFileService] ✗ Unexpected error: ${error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always return null instead of throwing, so we can try the next source
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -53,14 +53,14 @@ export class GameSearchCache {
|
|||||||
private static generateCacheKey(query: GameSearchQuery): string {
|
private static generateCacheKey(query: GameSearchQuery): string {
|
||||||
const normalizedQuery = {
|
const normalizedQuery = {
|
||||||
search: query.search || '',
|
search: query.search || '',
|
||||||
platforms: query.platforms?.sort().join(',') || '',
|
platforms: query.platforms ? [...query.platforms].sort().join(',') : '',
|
||||||
series: query.series?.sort().join(',') || '',
|
series: query.series ? [...query.series].sort().join(',') : '',
|
||||||
developers: query.developers?.sort().join(',') || '',
|
developers: query.developers ? [...query.developers].sort().join(',') : '',
|
||||||
publishers: query.publishers?.sort().join(',') || '',
|
publishers: query.publishers ? [...query.publishers].sort().join(',') : '',
|
||||||
playModes: query.playModes?.sort().join(',') || '',
|
playModes: query.playModes ? [...query.playModes].sort().join(',') : '',
|
||||||
languages: query.languages?.sort().join(',') || '',
|
languages: query.languages ? [...query.languages].sort().join(',') : '',
|
||||||
library: query.library || '',
|
library: query.library || '',
|
||||||
tags: query.tags?.sort().join(',') || '',
|
tags: query.tags ? [...query.tags].sort().join(',') : '',
|
||||||
yearFrom: query.yearFrom || '',
|
yearFrom: query.yearFrom || '',
|
||||||
yearTo: query.yearTo || '',
|
yearTo: query.yearTo || '',
|
||||||
dateAddedSince: query.dateAddedSince || '',
|
dateAddedSince: query.dateAddedSince || '',
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ export interface Game {
|
|||||||
playtime?: number;
|
playtime?: number;
|
||||||
playCounter?: number;
|
playCounter?: number;
|
||||||
archiveState?: 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
|
logoPath?: string; // Infinity edition only
|
||||||
screenshotPath?: string; // Infinity edition only
|
screenshotPath?: string; // Infinity edition only
|
||||||
}
|
}
|
||||||
@@ -71,6 +73,77 @@ export interface PaginatedResult<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class GameService {
|
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)
|
* Check if running Infinity edition (has logoPath/screenshotPath columns)
|
||||||
*/
|
*/
|
||||||
@@ -86,11 +159,13 @@ export class GameService {
|
|||||||
const isInfinity = this.isInfinityEdition();
|
const isInfinity = this.isInfinityEdition();
|
||||||
|
|
||||||
if (fields === 'list') {
|
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 `
|
return `
|
||||||
g.id, g.title, g.developer, g.publisher, g.platformName,
|
g.id, g.title, g.developer, g.platformName,
|
||||||
g.library, g.orderTitle, g.releaseDate, g.tagsStr,
|
g.tagsStr, g.library, g.orderTitle
|
||||||
g.broken, g.extreme, g.series
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +177,8 @@ export class GameService {
|
|||||||
g.source, g.applicationPath, g.launchCommand, g.releaseDate,
|
g.source, g.applicationPath, g.launchCommand, g.releaseDate,
|
||||||
g.version, g.originalDescription, g.language,
|
g.version, g.originalDescription, g.language,
|
||||||
g.library, g.orderTitle, g.dateAdded, g.dateModified,
|
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
|
// Infinity edition has logoPath and screenshotPath columns
|
||||||
if (isInfinity) {
|
if (isInfinity) {
|
||||||
@@ -133,7 +209,7 @@ export class GameService {
|
|||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const params: any[] = [];
|
const params: unknown[] = [];
|
||||||
|
|
||||||
// Apply filters
|
// Apply filters
|
||||||
if (query.platforms && query.platforms.length > 0) {
|
if (query.platforms && query.platforms.length > 0) {
|
||||||
@@ -223,6 +299,9 @@ export class GameService {
|
|||||||
sql += ` AND (g.extreme = 0 OR g.extreme IS NULL)`;
|
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) {
|
if (query.search) {
|
||||||
sql += ` AND (
|
sql += ` AND (
|
||||||
g.title LIKE ? OR
|
g.title LIKE ? OR
|
||||||
@@ -314,14 +393,11 @@ export class GameService {
|
|||||||
async getGameById(id: string): Promise<Game | null> {
|
async getGameById(id: string): Promise<Game | null> {
|
||||||
try {
|
try {
|
||||||
const columns = this.getColumnSelection('detail');
|
const columns = this.getColumnSelection('detail');
|
||||||
|
// No need for LEFT JOIN - activeDataId and activeDataOnDisk are on game table
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT
|
SELECT ${columns}
|
||||||
${columns},
|
|
||||||
MAX(gd.presentOnDisk) as presentOnDisk
|
|
||||||
FROM game g
|
FROM game g
|
||||||
LEFT JOIN game_data gd ON gd.gameId = g.id
|
|
||||||
WHERE g.id = ?
|
WHERE g.id = ?
|
||||||
GROUP BY g.id
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const game = DatabaseService.get(sql, [id]) as Game | null;
|
const game = DatabaseService.get(sql, [id]) as Game | null;
|
||||||
@@ -344,18 +420,35 @@ export class GameService {
|
|||||||
|
|
||||||
const columns = this.getColumnSelection('detail');
|
const columns = this.getColumnSelection('detail');
|
||||||
const placeholders = ids.map(() => '?').join(', ');
|
const placeholders = ids.map(() => '?').join(', ');
|
||||||
|
|
||||||
|
// Query WITHOUT LEFT JOIN to avoid row explosion from multiple game_data entries
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT
|
SELECT ${columns}
|
||||||
${columns},
|
|
||||||
MAX(gd.presentOnDisk) as presentOnDisk
|
|
||||||
FROM game g
|
FROM game g
|
||||||
LEFT JOIN game_data gd ON gd.gameId = g.id
|
|
||||||
WHERE g.id IN (${placeholders})
|
WHERE g.id IN (${placeholders})
|
||||||
GROUP BY g.id
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const games = DatabaseService.all(sql, ids) as Game[];
|
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
|
// Sort games to match the order of input IDs
|
||||||
const gamesMap = new Map(games.map((g) => [g.id, g]));
|
const gamesMap = new Map(games.map((g) => [g.id, g]));
|
||||||
return ids.map((id) => gamesMap.get(id)).filter((g): g is Game => g !== undefined);
|
return ids.map((id) => gamesMap.get(id)).filter((g): g is Game => g !== undefined);
|
||||||
@@ -375,7 +468,7 @@ export class GameService {
|
|||||||
SELECT id
|
SELECT id
|
||||||
FROM game_data
|
FROM game_data
|
||||||
WHERE gameId = ?
|
WHERE gameId = ?
|
||||||
ORDER BY presentOnDisk DESC, id DESC
|
ORDER BY id DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -393,7 +486,7 @@ export class GameService {
|
|||||||
SELECT launchCommand, path, parameters
|
SELECT launchCommand, path, parameters
|
||||||
FROM game_data
|
FROM game_data
|
||||||
WHERE gameId = ?
|
WHERE gameId = ?
|
||||||
ORDER BY presentOnDisk DESC, id DESC
|
ORDER BY id DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -484,7 +577,7 @@ export class GameService {
|
|||||||
WHERE (g.broken = 0 OR g.broken IS NULL)
|
WHERE (g.broken = 0 OR g.broken IS NULL)
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const params: any[] = [];
|
const params: unknown[] = [];
|
||||||
|
|
||||||
if (library) {
|
if (library) {
|
||||||
sql += ` AND g.library = ?`;
|
sql += ` AND g.library = ?`;
|
||||||
@@ -515,6 +608,7 @@ export class GameService {
|
|||||||
/**
|
/**
|
||||||
* Get all filter options for dropdowns in a single call
|
* Get all filter options for dropdowns in a single call
|
||||||
* Returns: series, developers, publishers, playModes, languages, tags, platforms, yearRange
|
* Returns: series, developers, publishers, playModes, languages, tags, platforms, yearRange
|
||||||
|
* Note: Methods are synchronous but wrapped in async for consistent API contract
|
||||||
*/
|
*/
|
||||||
async getFilterOptions(): Promise<{
|
async getFilterOptions(): Promise<{
|
||||||
series: Array<{ name: string; count: number }>;
|
series: Array<{ name: string; count: number }>;
|
||||||
@@ -527,18 +621,15 @@ export class GameService {
|
|||||||
yearRange: { min: number; max: number };
|
yearRange: { min: number; max: number };
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
// Fetch all filter options in parallel for performance
|
// Call synchronous methods directly (no Promise.all needed)
|
||||||
const [series, developers, publishers, playModes, languages, tags, platforms, yearRange] =
|
const series = this.getSeriesOptions();
|
||||||
await Promise.all([
|
const developers = this.getDeveloperOptions();
|
||||||
this.getSeriesOptions(),
|
const publishers = this.getPublisherOptions();
|
||||||
this.getDeveloperOptions(),
|
const playModes = this.getPlayModeOptions();
|
||||||
this.getPublisherOptions(),
|
const languages = this.getLanguageOptions();
|
||||||
this.getPlayModeOptions(),
|
const tags = this.getTagOptions();
|
||||||
this.getLanguageOptions(),
|
const platforms = this.getPlatformOptions();
|
||||||
this.getTagOptions(),
|
const yearRange = this.getYearRange();
|
||||||
this.getPlatformOptions(),
|
|
||||||
this.getYearRange(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
series,
|
series,
|
||||||
@@ -728,8 +819,9 @@ export class GameService {
|
|||||||
*/
|
*/
|
||||||
getPlatformOptions(): Array<{ name: string; count: number }> {
|
getPlatformOptions(): Array<{ name: string; count: number }> {
|
||||||
try {
|
try {
|
||||||
|
// Query all platforms without Flash SWF filter (fast, no EXISTS)
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT DISTINCT platformName as name, COUNT(*) as count
|
SELECT platformName as name, COUNT(*) as count
|
||||||
FROM game
|
FROM game
|
||||||
WHERE platformName IS NOT NULL AND platformName != ''
|
WHERE platformName IS NOT NULL AND platformName != ''
|
||||||
AND (broken = 0 OR broken IS NULL)
|
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 }>;
|
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;
|
return results;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error getting platform options:', error);
|
logger.error('Error getting platform options:', error);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export class ImageUrlService {
|
|||||||
'https://infinity.unstable.life/Flashpoint/Data/Images', // Secondary fallback
|
'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,
|
baseUrl: preferences.onDemandBaseUrl,
|
||||||
constructedUrls: urls,
|
constructedUrls: urls,
|
||||||
});
|
});
|
||||||
@@ -42,7 +42,7 @@ export class ImageUrlService {
|
|||||||
'https://infinity.unstable.life/Flashpoint/Data/Images',
|
'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;
|
return defaultUrls;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,5 +72,3 @@ export class ImageUrlService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ImageUrlService;
|
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
import { UserDatabaseService } from './UserDatabaseService';
|
import { UserDatabaseService } from './UserDatabaseService';
|
||||||
import { logger } from '../utils/logger';
|
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 {
|
export interface JobExecutionLog {
|
||||||
id: number;
|
id: number;
|
||||||
jobId: string;
|
jobId: string;
|
||||||
@@ -79,7 +92,7 @@ export class JobExecutionService {
|
|||||||
ORDER BY started_at DESC
|
ORDER BY started_at DESC
|
||||||
LIMIT ? OFFSET ?`,
|
LIMIT ? OFFSET ?`,
|
||||||
[jobId, limit, offset]
|
[jobId, limit, offset]
|
||||||
) as any[];
|
) as JobExecutionRow[];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: logs.map(this.mapToJobExecutionLog),
|
data: logs.map(this.mapToJobExecutionLog),
|
||||||
@@ -101,7 +114,7 @@ export class JobExecutionService {
|
|||||||
ORDER BY started_at DESC
|
ORDER BY started_at DESC
|
||||||
LIMIT ? OFFSET ?`,
|
LIMIT ? OFFSET ?`,
|
||||||
[limit, offset]
|
[limit, offset]
|
||||||
) as any[];
|
) as JobExecutionRow[];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: logs.map(this.mapToJobExecutionLog),
|
data: logs.map(this.mapToJobExecutionLog),
|
||||||
@@ -119,7 +132,7 @@ export class JobExecutionService {
|
|||||||
ORDER BY started_at DESC
|
ORDER BY started_at DESC
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[jobId]
|
[jobId]
|
||||||
) as any;
|
) as JobExecutionRow | undefined;
|
||||||
|
|
||||||
return log ? this.mapToJobExecutionLog(log) : null;
|
return log ? this.mapToJobExecutionLog(log) : null;
|
||||||
}
|
}
|
||||||
@@ -140,17 +153,17 @@ export class JobExecutionService {
|
|||||||
return result.changes;
|
return result.changes;
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapToJobExecutionLog(row: any): JobExecutionLog {
|
private mapToJobExecutionLog(row: JobExecutionRow): JobExecutionLog {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
jobId: row.job_id,
|
jobId: row.job_id,
|
||||||
jobName: row.job_name,
|
jobName: row.job_name,
|
||||||
status: row.status,
|
status: row.status as 'running' | 'success' | 'failed',
|
||||||
startedAt: row.started_at,
|
startedAt: row.started_at,
|
||||||
completedAt: row.completed_at,
|
completedAt: row.completed_at || undefined,
|
||||||
durationSeconds: row.duration_seconds,
|
durationSeconds: row.duration_seconds || undefined,
|
||||||
message: row.message,
|
message: row.message || undefined,
|
||||||
errorDetails: row.error_details,
|
errorDetails: row.error_details || undefined,
|
||||||
triggeredBy: row.triggered_by,
|
triggeredBy: row.triggered_by,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,7 +124,9 @@ class JobSchedulerService {
|
|||||||
|
|
||||||
// Run asynchronously (don't await)
|
// Run asynchronously (don't await)
|
||||||
// runJobWithLogging will handle execution logging
|
// runJobWithLogging will handle execution logging
|
||||||
this.runJobWithLogging(jobId, triggeredBy);
|
this.runJobWithLogging(jobId, triggeredBy).catch((error) => {
|
||||||
|
logger.error(`[JobScheduler] Unexpected error in triggered job ${jobId}:`, error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -479,7 +479,7 @@ export class MetadataSyncService {
|
|||||||
)
|
)
|
||||||
.join(', ');
|
.join(', ');
|
||||||
|
|
||||||
const values: any[] = [];
|
const values: (string | number | null)[] = [];
|
||||||
sortedGames.forEach((game) => {
|
sortedGames.forEach((game) => {
|
||||||
values.push(
|
values.push(
|
||||||
game.id,
|
game.id,
|
||||||
@@ -553,7 +553,13 @@ export class MetadataSyncService {
|
|||||||
ruffleSupport = excluded.ruffleSupport
|
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`);
|
logger.debug(`[MetadataSync] Upserted ${sortedGames.length} games`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,17 +41,39 @@ export class PerformanceMetrics {
|
|||||||
// Configuration
|
// Configuration
|
||||||
private static readonly MAX_DATA_POINTS = 10000; // Keep last 10k measurements
|
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 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
|
* Record API endpoint response time
|
||||||
*/
|
*/
|
||||||
static recordEndpoint(path: string, method: string, durationMs: number): void {
|
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)) {
|
if (!this.endpointMetrics.has(key)) {
|
||||||
this.endpointMetrics.set(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)!;
|
const metrics = this.endpointMetrics.get(key)!;
|
||||||
metrics.push({
|
metrics.push({
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
|||||||
@@ -135,16 +135,12 @@ export class PermissionCache {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Invalidate role permissions (when role permissions change)
|
* 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 {
|
static invalidateRole(roleId: number): void {
|
||||||
this.rolePermissionsCache.delete(roleId);
|
this.rolePermissionsCache.delete(roleId);
|
||||||
logger.info(`[PermissionCache] Invalidated cache for role ${roleId}`);
|
this.userPermissionsCache.clear();
|
||||||
|
logger.info(`[PermissionCache] Invalidated cache for role ${roleId} and all user permissions`);
|
||||||
// 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.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { UserDatabaseService } from './UserDatabaseService';
|
import { UserDatabaseService } from './UserDatabaseService';
|
||||||
import { DatabaseService } from './DatabaseService';
|
import { DatabaseService } from './DatabaseService';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { randomBytes } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
import { AppError } from '../middleware/errorHandler';
|
||||||
import {
|
import {
|
||||||
PlaySessionRow,
|
PlaySessionRow,
|
||||||
GameStatsRow,
|
GameStatsRow,
|
||||||
@@ -44,8 +45,8 @@ export class PlayTrackingService {
|
|||||||
*/
|
*/
|
||||||
async startPlaySession(userId: number, gameId: string, gameTitle: string): Promise<string> {
|
async startPlaySession(userId: number, gameId: string, gameTitle: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
// Generate unique session ID
|
// Generate unique session ID (UUID format)
|
||||||
const sessionId = randomBytes(16).toString('hex');
|
const sessionId = randomUUID();
|
||||||
|
|
||||||
// Insert play session
|
// Insert play session
|
||||||
UserDatabaseService.run(
|
UserDatabaseService.run(
|
||||||
@@ -68,7 +69,7 @@ export class PlayTrackingService {
|
|||||||
* End a play session
|
* End a play session
|
||||||
* OPTIMIZED: Single query to get session data and calculate duration, then single UPDATE
|
* 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 {
|
try {
|
||||||
// OPTIMIZATION: Get session and calculate duration in single query
|
// OPTIMIZATION: Get session and calculate duration in single query
|
||||||
const session = UserDatabaseService.get(
|
const session = UserDatabaseService.get(
|
||||||
@@ -87,6 +88,11 @@ export class PlayTrackingService {
|
|||||||
return;
|
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;
|
const durationSeconds = session.duration_seconds || 0;
|
||||||
|
|
||||||
// Use the duration value from the SELECT query to avoid race condition
|
// 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> {
|
private async updateUserStats(userId: number, durationSeconds: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Count unique games played
|
// Single query to get both aggregates (was 2 separate queries)
|
||||||
const gamesPlayed =
|
const counts = UserDatabaseService.get(
|
||||||
UserDatabaseService.get(
|
`SELECT
|
||||||
'SELECT COUNT(DISTINCT game_id) as count FROM user_game_stats WHERE user_id = ?',
|
(SELECT COUNT(DISTINCT game_id) FROM user_game_stats WHERE user_id = ?) as games_played,
|
||||||
[userId]
|
(SELECT COUNT(*) FROM user_game_plays WHERE user_id = ? AND ended_at IS NOT NULL) as total_sessions`,
|
||||||
)?.count || 0;
|
[userId, userId]
|
||||||
|
) as { games_played: number; total_sessions: number } | undefined;
|
||||||
|
|
||||||
// Count total sessions
|
const gamesPlayed = counts?.games_played ?? 0;
|
||||||
const totalSessions =
|
const totalSessions = counts?.total_sessions ?? 0;
|
||||||
UserDatabaseService.get(
|
|
||||||
'SELECT COUNT(*) as count FROM user_game_plays WHERE user_id = ? AND ended_at IS NOT NULL',
|
|
||||||
[userId]
|
|
||||||
)?.count || 0;
|
|
||||||
|
|
||||||
// UPSERT user stats
|
// UPSERT user stats
|
||||||
UserDatabaseService.run(
|
UserDatabaseService.run(
|
||||||
@@ -395,9 +398,11 @@ export class PlayTrackingService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const session of abandoned) {
|
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 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
|
// End the session
|
||||||
UserDatabaseService.run(
|
UserDatabaseService.run(
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { config } from '../config';
|
|||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { GameService, Game } from './GameService';
|
import { GameService, Game } from './GameService';
|
||||||
|
|
||||||
|
type PlaylistGameEntry = string | { gameId?: string; [key: string]: unknown };
|
||||||
|
|
||||||
export interface Playlist {
|
export interface Playlist {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -35,36 +37,42 @@ export class PlaylistService {
|
|||||||
const playlistsPath = config.flashpointPlaylistsPath;
|
const playlistsPath = config.flashpointPlaylistsPath;
|
||||||
const files = await fs.readdir(playlistsPath);
|
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[] = [];
|
const playlists: Playlist[] = [];
|
||||||
|
for (let i = 0; i < results.length; i++) {
|
||||||
for (const file of files) {
|
const result = results[i];
|
||||||
if (file.endsWith('.json')) {
|
if (result.status === 'fulfilled') {
|
||||||
try {
|
playlists.push(result.value);
|
||||||
const filePath = path.join(playlistsPath, file);
|
} else {
|
||||||
const content = await fs.readFile(filePath, 'utf-8');
|
logger.warn(`Failed to parse playlist file: ${jsonFiles[i]}`, result.reason);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,48 +86,19 @@ export class PlaylistService {
|
|||||||
async getPlaylistById(id: string): Promise<Playlist | null> {
|
async getPlaylistById(id: string): Promise<Playlist | null> {
|
||||||
try {
|
try {
|
||||||
const playlistsPath = config.flashpointPlaylistsPath;
|
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);
|
const files = await fs.readdir(playlistsPath);
|
||||||
|
|
||||||
// Try to find playlist file by ID
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (file.endsWith('.json')) {
|
if (!file.endsWith('.json') || file === `${id}.json`) continue; // Already tried direct
|
||||||
const filePath = path.join(playlistsPath, file);
|
const filePath = path.join(playlistsPath, file);
|
||||||
const content = await fs.readFile(filePath, 'utf-8');
|
const result = await this.tryReadPlaylist(filePath, id);
|
||||||
const playlist = JSON.parse(content);
|
if (result) return result;
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
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> {
|
async createPlaylist(data: CreatePlaylistDto): Promise<Playlist> {
|
||||||
try {
|
try {
|
||||||
const playlistsPath = config.flashpointPlaylistsPath;
|
const playlistsPath = config.flashpointPlaylistsPath;
|
||||||
@@ -163,44 +180,25 @@ export class PlaylistService {
|
|||||||
data: AddGamesToPlaylistDto
|
data: AddGamesToPlaylistDto
|
||||||
): Promise<Playlist | null> {
|
): Promise<Playlist | null> {
|
||||||
try {
|
try {
|
||||||
const playlistsPath = config.flashpointPlaylistsPath;
|
const found = await this.findPlaylistFile(playlistId);
|
||||||
const files = await fs.readdir(playlistsPath);
|
if (!found) return null;
|
||||||
|
|
||||||
// Find playlist file
|
const { filePath, data: playlist } = found;
|
||||||
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');
|
// Get existing game IDs
|
||||||
|
const existingGameIds = Array.isArray(playlist.games)
|
||||||
|
? playlist.games.map((g: PlaylistGameEntry) => (typeof g === 'string' ? g : g.gameId))
|
||||||
|
: [];
|
||||||
|
|
||||||
if (id === playlistId) {
|
// Add new game IDs (avoid duplicates)
|
||||||
// Get existing game IDs
|
const newGameIds = data.gameIds.filter((gameId) => !existingGameIds.includes(gameId));
|
||||||
const existingGameIds = Array.isArray(playlist.games)
|
const newGames = newGameIds.map((gameId) => ({ gameId }));
|
||||||
? playlist.games.map((g: any) => (typeof g === 'string' ? g : g.gameId))
|
playlist.games = [...(playlist.games || []), ...newGames];
|
||||||
: [];
|
|
||||||
|
|
||||||
// Add new game IDs (avoid duplicates)
|
await fs.writeFile(filePath, JSON.stringify(playlist, null, '\t'), 'utf-8');
|
||||||
const newGameIds = data.gameIds.filter((gameId) => !existingGameIds.includes(gameId));
|
logger.info(`Added ${newGameIds.length} games to playlist: ${playlist.title}`);
|
||||||
|
|
||||||
// Convert to playlist game format
|
return await this.getPlaylistById(playlistId);
|
||||||
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;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error adding games to playlist:', error);
|
logger.error('Error adding games to playlist:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -212,36 +210,20 @@ export class PlaylistService {
|
|||||||
data: AddGamesToPlaylistDto
|
data: AddGamesToPlaylistDto
|
||||||
): Promise<Playlist | null> {
|
): Promise<Playlist | null> {
|
||||||
try {
|
try {
|
||||||
const playlistsPath = config.flashpointPlaylistsPath;
|
const found = await this.findPlaylistFile(playlistId);
|
||||||
const files = await fs.readdir(playlistsPath);
|
if (!found) return null;
|
||||||
|
|
||||||
// Find playlist file
|
const { filePath, data: playlist } = found;
|
||||||
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');
|
playlist.games = (playlist.games || []).filter((g: PlaylistGameEntry) => {
|
||||||
|
const gameId = typeof g === 'string' ? g : g.gameId;
|
||||||
|
return gameId && !data.gameIds.includes(gameId);
|
||||||
|
});
|
||||||
|
|
||||||
if (id === playlistId) {
|
await fs.writeFile(filePath, JSON.stringify(playlist, null, '\t'), 'utf-8');
|
||||||
// Remove games
|
logger.info(`Removed ${data.gameIds.length} games from playlist: ${playlist.title}`);
|
||||||
playlist.games = (playlist.games || []).filter((g: any) => {
|
|
||||||
const gameId = typeof g === 'string' ? g : g.gameId;
|
|
||||||
return !data.gameIds.includes(gameId);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Write updated playlist
|
return await this.getPlaylistById(playlistId);
|
||||||
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;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error removing games from playlist:', error);
|
logger.error('Error removing games from playlist:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -250,32 +232,55 @@ export class PlaylistService {
|
|||||||
|
|
||||||
async deletePlaylist(playlistId: string): Promise<boolean> {
|
async deletePlaylist(playlistId: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const playlistsPath = config.flashpointPlaylistsPath;
|
const found = await this.findPlaylistFile(playlistId);
|
||||||
const files = await fs.readdir(playlistsPath);
|
if (!found) return false;
|
||||||
|
|
||||||
for (const file of files) {
|
await fs.unlink(found.filePath);
|
||||||
if (file.endsWith('.json')) {
|
logger.info(`Deleted playlist: ${found.data.title} (${playlistId})`);
|
||||||
const filePath = path.join(playlistsPath, file);
|
return true;
|
||||||
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;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error deleting playlist:', error);
|
logger.error('Error deleting playlist:', error);
|
||||||
throw 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 {
|
private generateUUID(): string {
|
||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ export class RoleService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updates: string[] = [];
|
const updates: string[] = [];
|
||||||
const params: any[] = [];
|
const params: unknown[] = [];
|
||||||
|
|
||||||
if (name !== undefined) {
|
if (name !== undefined) {
|
||||||
const existing = UserDatabaseService.get('SELECT id FROM roles WHERE name = ? AND id != ?', [
|
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');
|
throw new AppError(403, 'Cannot modify system role permissions');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete existing permissions
|
// Wrap delete + insert in transaction to ensure atomicity
|
||||||
UserDatabaseService.run('DELETE FROM role_permissions WHERE role_id = ?', [roleId]);
|
const db = UserDatabaseService.getDatabase();
|
||||||
|
const updatePermissions = db.transaction(() => {
|
||||||
|
db.prepare('DELETE FROM role_permissions WHERE role_id = ?').run(roleId);
|
||||||
|
|
||||||
// Insert new permissions
|
const insert = db.prepare(
|
||||||
for (const permissionId of permissionIds) {
|
'INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)'
|
||||||
UserDatabaseService.run(
|
|
||||||
'INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)',
|
|
||||||
[roleId, permissionId]
|
|
||||||
);
|
);
|
||||||
}
|
for (const permissionId of permissionIds) {
|
||||||
|
insert.run(roleId, permissionId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updatePermissions();
|
||||||
|
|
||||||
// Invalidate permission cache for this role
|
// Invalidate permission cache for this role
|
||||||
PermissionCache.invalidateRole(roleId);
|
PermissionCache.invalidateRole(roleId);
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import AdmZip from 'adm-zip';
|
|||||||
import { AppError } from '../middleware/errorHandler';
|
import { AppError } from '../middleware/errorHandler';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
interface GitHubAsset {
|
||||||
|
name: string;
|
||||||
|
browser_download_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for managing Ruffle emulator versions
|
* Service for managing Ruffle emulator versions
|
||||||
*/
|
*/
|
||||||
@@ -84,7 +89,7 @@ export class RuffleService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Find web-selfhosted asset
|
// 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) {
|
if (!asset) {
|
||||||
throw new Error('web-selfhosted.zip not found in release');
|
throw new Error('web-selfhosted.zip not found in release');
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export class SystemSettingsService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const valueToUse = row.value || row.default_value;
|
const valueToUse = row.value ?? row.default_value;
|
||||||
return this.parseValue(valueToUse, row.data_type);
|
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')
|
// Remove category prefix from key (e.g., 'auth.guest_access_enabled' -> 'guestAccessEnabled')
|
||||||
const shortKey = row.key.replace(`${category}.`, '');
|
const shortKey = row.key.replace(`${category}.`, '');
|
||||||
const camelCaseKey = this.snakeToCamel(shortKey);
|
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);
|
const parsedValue = this.parseValue(valueToUse, row.data_type);
|
||||||
if (parsedValue !== null) {
|
if (parsedValue !== null) {
|
||||||
settings[camelCaseKey] = parsedValue;
|
settings[camelCaseKey] = parsedValue;
|
||||||
@@ -66,7 +66,7 @@ export class SystemSettingsService {
|
|||||||
|
|
||||||
const shortKey = row.key.replace(`${row.category}.`, '');
|
const shortKey = row.key.replace(`${row.category}.`, '');
|
||||||
const camelCaseKey = this.snakeToCamel(shortKey);
|
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);
|
const parsedValue = this.parseValue(valueToUse, row.data_type);
|
||||||
if (parsedValue !== null) {
|
if (parsedValue !== null) {
|
||||||
allSettings[row.category][camelCaseKey] = parsedValue;
|
allSettings[row.category][camelCaseKey] = parsedValue;
|
||||||
@@ -97,12 +97,14 @@ export class SystemSettingsService {
|
|||||||
* Update multiple settings in a category
|
* Update multiple settings in a category
|
||||||
*/
|
*/
|
||||||
updateCategory(category: string, settings: CategorySettings, updatedBy?: number): void {
|
updateCategory(category: string, settings: CategorySettings, updatedBy?: number): void {
|
||||||
// Use individual updates (transactions would require accessing private db property)
|
const db = UserDatabaseService.getDatabase();
|
||||||
Object.entries(settings).forEach(([key, value]) => {
|
db.transaction(() => {
|
||||||
const snakeKey = this.camelToSnake(key);
|
Object.entries(settings).forEach(([key, value]) => {
|
||||||
const fullKey = `${category}.${snakeKey}`;
|
const snakeKey = this.camelToSnake(key);
|
||||||
this.set(fullKey, value, updatedBy);
|
const fullKey = `${category}.${snakeKey}`;
|
||||||
});
|
this.set(fullKey, value, updatedBy);
|
||||||
|
});
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -123,7 +125,7 @@ export class SystemSettingsService {
|
|||||||
|
|
||||||
const shortKey = row.key.replace(`${row.category}.`, '');
|
const shortKey = row.key.replace(`${row.category}.`, '');
|
||||||
const camelCaseKey = this.snakeToCamel(shortKey);
|
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);
|
const parsedValue = this.parseValue(valueToUse, row.data_type);
|
||||||
if (parsedValue !== null) {
|
if (parsedValue !== null) {
|
||||||
publicSettings[row.category][camelCaseKey] = parsedValue;
|
publicSettings[row.category][camelCaseKey] = parsedValue;
|
||||||
|
|||||||
@@ -169,6 +169,15 @@ export class UpdateService {
|
|||||||
|
|
||||||
let errorOutput = '';
|
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) => {
|
fpm.stdout?.on('data', (data) => {
|
||||||
logger.info('[UpdateService] FPM Output:', data.toString());
|
logger.info('[UpdateService] FPM Output:', data.toString());
|
||||||
});
|
});
|
||||||
@@ -179,6 +188,7 @@ export class UpdateService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
fpm.on('close', (code) => {
|
fpm.on('close', (code) => {
|
||||||
|
clearTimeout(timeoutHandle);
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
logger.info('[UpdateService] Updates installed successfully');
|
logger.info('[UpdateService] Updates installed successfully');
|
||||||
resolve({
|
resolve({
|
||||||
@@ -195,18 +205,10 @@ export class UpdateService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
fpm.on('error', (error) => {
|
fpm.on('error', (error) => {
|
||||||
|
clearTimeout(timeoutHandle);
|
||||||
logger.error('[UpdateService] Failed to start FPM:', error);
|
logger.error('[UpdateService] Failed to start FPM:', error);
|
||||||
reject(error);
|
reject(error);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Timeout after 5 minutes
|
|
||||||
setTimeout(
|
|
||||||
() => {
|
|
||||||
fpm.kill();
|
|
||||||
reject(new Error('Update process timed out'));
|
|
||||||
},
|
|
||||||
5 * 60 * 1000
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[UpdateService] Error during update installation:', error);
|
logger.error('[UpdateService] Error during update installation:', error);
|
||||||
|
|||||||
@@ -162,15 +162,19 @@ export class UserDatabaseService {
|
|||||||
const checksum = this.computeChecksum(sql);
|
const checksum = this.computeChecksum(sql);
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
|
||||||
// Execute migration
|
// Wrap migration execution and registry insert in a single transaction
|
||||||
this.db!.exec(sql);
|
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;
|
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)`);
|
logger.info(`[UserDB] Migration completed: ${name} (${executionTime}ms)`);
|
||||||
} else {
|
} else {
|
||||||
logger.debug(`[UserDB] Migration already applied: ${name}`);
|
logger.debug(`[UserDB] Migration already applied: ${name}`);
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ export class UserPlaylistService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updates: string[] = [];
|
const updates: string[] = [];
|
||||||
const values: any[] = [];
|
const values: (string | number | null)[] = [];
|
||||||
|
|
||||||
if (data.title !== undefined) {
|
if (data.title !== undefined) {
|
||||||
updates.push('title = ?');
|
updates.push('title = ?');
|
||||||
@@ -285,7 +285,7 @@ export class UserPlaylistService {
|
|||||||
WHERE playlist_id = ?
|
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)
|
// Insert games (ignore duplicates)
|
||||||
const insertStmt = db.prepare(`
|
const insertStmt = db.prepare(`
|
||||||
@@ -699,41 +699,59 @@ export class UserPlaylistService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new playlist
|
// Wrap create + game inserts in transaction to ensure atomicity
|
||||||
const title = newTitle || `${sourcePlaylist.title} (Copy)`;
|
const newPlaylistId = db.transaction(() => {
|
||||||
const newPlaylist = this.createPlaylist(userId, {
|
// Create new playlist
|
||||||
title,
|
const title = newTitle || `${sourcePlaylist.title} (Copy)`;
|
||||||
description: sourcePlaylist.description || undefined,
|
const now = new Date().toISOString();
|
||||||
icon: sourcePlaylist.icon || undefined,
|
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 playlistId = result.lastInsertRowid as number;
|
||||||
const gamesStmt = db.prepare(`
|
|
||||||
SELECT game_id, order_index, notes
|
|
||||||
FROM user_playlist_games
|
|
||||||
WHERE playlist_id = ?
|
|
||||||
ORDER BY order_index
|
|
||||||
`);
|
|
||||||
|
|
||||||
const games = gamesStmt.all(sourcePlaylist.id) as {
|
// Copy games with same order
|
||||||
game_id: string;
|
const gamesStmt = db.prepare(`
|
||||||
order_index: number;
|
SELECT game_id, order_index, notes
|
||||||
notes: string | null;
|
FROM user_playlist_games
|
||||||
}[];
|
WHERE playlist_id = ?
|
||||||
|
ORDER BY order_index
|
||||||
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) {
|
const games = gamesStmt.all(sourcePlaylist.id) as {
|
||||||
insertStmt.run(newPlaylist.id, game.game_id, game.order_index, game.notes);
|
game_id: string;
|
||||||
|
order_index: number;
|
||||||
|
notes: string | null;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
if (games.length > 0) {
|
||||||
|
const insertStmt = db.prepare(`
|
||||||
|
INSERT INTO user_playlist_games (playlist_id, game_id, order_index, notes)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const game of games) {
|
||||||
|
insertStmt.run(playlistId, game.game_id, game.order_index, game.notes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Cloned shared playlist "${sourcePlaylist.title}" to user ${userId} as "${title}"`);
|
return playlistId;
|
||||||
|
})();
|
||||||
|
|
||||||
return this.getPlaylistById(newPlaylist.id, userId);
|
logger.info(
|
||||||
|
`Cloned shared playlist "${sourcePlaylist.title}" to user ${userId} as "${newTitle || `${sourcePlaylist.title} (Copy)`}"`
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.getPlaylistById(newPlaylistId, userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,20 +13,27 @@ export class UserService {
|
|||||||
async getUsers(page: number = 1, limit: number = 50): Promise<PaginatedResponse<User>> {
|
async getUsers(page: number = 1, limit: number = 50): Promise<PaginatedResponse<User>> {
|
||||||
const offset = calculateOffset(page, limit);
|
const offset = calculateOffset(page, limit);
|
||||||
|
|
||||||
|
// Single query with window function for total count (was 2 separate queries)
|
||||||
const users = UserDatabaseService.all(
|
const users = UserDatabaseService.all(
|
||||||
`SELECT u.id, u.username, u.email, u.role_id as roleId, r.name as roleName,
|
`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.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
|
FROM users u
|
||||||
JOIN roles r ON u.role_id = r.id
|
JOIN roles r ON u.role_id = r.id
|
||||||
ORDER BY u.created_at DESC
|
ORDER BY u.created_at DESC
|
||||||
LIMIT ? OFFSET ?`,
|
LIMIT ? OFFSET ?`,
|
||||||
[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
|
* Create new user
|
||||||
*/
|
*/
|
||||||
async createUser(data: CreateUserData): Promise<User> {
|
async createUser(data: CreateUserData): Promise<User> {
|
||||||
// Check if username exists
|
// Hash password before transaction
|
||||||
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
|
|
||||||
const passwordHash = await hashPassword(data.password);
|
const passwordHash = await hashPassword(data.password);
|
||||||
|
|
||||||
// Create user
|
// Wrap uniqueness checks and insert in transaction to prevent race condition
|
||||||
const result = UserDatabaseService.run(
|
const db = UserDatabaseService.getDatabase();
|
||||||
'INSERT INTO users (username, email, password_hash, role_id) VALUES (?, ?, ?, ?)',
|
const userId = db.transaction(() => {
|
||||||
[data.username, data.email, passwordHash, data.roleId]
|
// 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))!;
|
return (await this.getUserById(userId))!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +100,7 @@ export class UserService {
|
|||||||
|
|
||||||
// Build update query dynamically
|
// Build update query dynamically
|
||||||
const updates: string[] = [];
|
const updates: string[] = [];
|
||||||
const params: any[] = [];
|
const params: unknown[] = [];
|
||||||
|
|
||||||
if (data.email !== undefined) {
|
if (data.email !== undefined) {
|
||||||
// Check if email is taken by another user
|
// Check if email is taken by another user
|
||||||
@@ -164,17 +172,24 @@ export class UserService {
|
|||||||
/**
|
/**
|
||||||
* Change user password
|
* 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]);
|
const user = UserDatabaseService.get('SELECT password_hash FROM users WHERE id = ?', [id]);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new AppError(404, 'User not found');
|
throw new AppError(404, 'User not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify current password using centralized utility
|
// Verify current password using centralized utility (skip if admin reset)
|
||||||
const isValid = await verifyPassword(currentPassword, user.password_hash);
|
if (!isAdminReset) {
|
||||||
if (!isValid) {
|
const isValid = await verifyPassword(currentPassword, user.password_hash);
|
||||||
throw new AppError(401, 'Current password is incorrect');
|
if (!isValid) {
|
||||||
|
throw new AppError(401, 'Current password is incorrect');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash new password
|
// 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> {
|
async updateThemeSettings(userId: number, mode: string, primaryColor: string): Promise<void> {
|
||||||
const user = await this.getUserById(userId);
|
const user = await this.getUserById(userId);
|
||||||
@@ -247,8 +262,11 @@ export class UserService {
|
|||||||
throw new AppError(404, 'User not found');
|
throw new AppError(404, 'User not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.setUserSetting(userId, 'theme_mode', mode);
|
// Execute both updates in parallel for better performance
|
||||||
await this.setUserSetting(userId, 'primary_color', primaryColor);
|
await Promise.all([
|
||||||
|
this.setUserSetting(userId, 'theme_mode', mode),
|
||||||
|
this.setUserSetting(userId, 'primary_color', primaryColor),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { CookieOptions, Response } from 'express';
|
||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
const COOKIE_NAME = 'fp_refresh';
|
||||||
|
const isProduction = config.nodeEnv === 'production';
|
||||||
|
|
||||||
|
function getRefreshCookieOptions(): CookieOptions {
|
||||||
|
return {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isProduction,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/api/auth',
|
||||||
|
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days in ms
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setRefreshTokenCookie(res: Response, refreshToken: string): void {
|
||||||
|
res.cookie(COOKIE_NAME, refreshToken, getRefreshCookieOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearRefreshTokenCookie(res: Response): void {
|
||||||
|
res.clearCookie(COOKIE_NAME, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isProduction,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/api/auth',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRefreshTokenFromCookie(cookies: Record<string, string>): string | undefined {
|
||||||
|
return cookies?.[COOKIE_NAME];
|
||||||
|
}
|
||||||
+2
-36
@@ -27,6 +27,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${API_PORT:-3100}:3100"
|
- "${API_PORT:-3100}:3100"
|
||||||
|
# Backend now includes game content serving on /game-proxy and /game-zip
|
||||||
volumes:
|
volumes:
|
||||||
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint:ro
|
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint:ro
|
||||||
- backend-db:/app/data
|
- backend-db:/app/data
|
||||||
@@ -36,7 +37,6 @@ services:
|
|||||||
- PGID=${PGID:-1000}
|
- PGID=${PGID:-1000}
|
||||||
- NODE_ENV=${NODE_ENV:-production}
|
- NODE_ENV=${NODE_ENV:-production}
|
||||||
- TZ=${TZ:-UTC}
|
- TZ=${TZ:-UTC}
|
||||||
- GAME_SERVICE_HOST=game-service
|
|
||||||
- DOMAIN=${DOMAIN:-http://localhost:${WEB_PORT:-80}}
|
- DOMAIN=${DOMAIN:-http://localhost:${WEB_PORT:-80}}
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||||
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
|
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
|
||||||
@@ -48,11 +48,8 @@ services:
|
|||||||
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-}
|
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-}
|
||||||
- OTEL_API_KEY=${OTEL_API_KEY:-}
|
- OTEL_API_KEY=${OTEL_API_KEY:-}
|
||||||
- OTEL_SERVICE_NAME=${OTEL_SERVICE_NAME:-flashpoint-web-backend}
|
- OTEL_SERVICE_NAME=${OTEL_SERVICE_NAME:-flashpoint-web-backend}
|
||||||
depends_on:
|
|
||||||
game-service:
|
|
||||||
condition: service_healthy
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "--fail", "http://localhost:3100/api/health"]
|
test: ["CMD", "curl", "--fail", "http://localhost:3100/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
@@ -60,34 +57,6 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- flashpoint-network
|
- 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:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -103,12 +72,9 @@ services:
|
|||||||
- TZ=${TZ:-UTC}
|
- TZ=${TZ:-UTC}
|
||||||
- BACKEND_HOST=${BACKEND_HOST:-backend}
|
- BACKEND_HOST=${BACKEND_HOST:-backend}
|
||||||
- BACKEND_PORT=${BACKEND_PORT:-3100}
|
- BACKEND_PORT=${BACKEND_PORT:-3100}
|
||||||
- GAME_SERVICE_HOST=${GAME_SERVICE_HOST:-game-service}
|
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
game-service:
|
|
||||||
condition: service_healthy
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "--fail", "http://localhost:8080/"]
|
test: ["CMD", "curl", "--fail", "http://localhost:8080/"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
+2
-31
@@ -22,6 +22,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${API_PORT:-3100}:3100"
|
- "${API_PORT:-3100}:3100"
|
||||||
|
# Backend now includes game content serving on /game-proxy and /game-zip
|
||||||
volumes:
|
volumes:
|
||||||
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint:ro
|
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint:ro
|
||||||
- ${DATA_PATH:-./data}:/app/data
|
- ${DATA_PATH:-./data}:/app/data
|
||||||
@@ -31,7 +32,6 @@ services:
|
|||||||
- PGID=${PGID:-1000}
|
- PGID=${PGID:-1000}
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- TZ=${TZ:-UTC}
|
- TZ=${TZ:-UTC}
|
||||||
- GAME_SERVICE_HOST=game-service
|
|
||||||
- DOMAIN=${DOMAIN:-http://localhost:${WEB_PORT:-80}}
|
- DOMAIN=${DOMAIN:-http://localhost:${WEB_PORT:-80}}
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||||
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
|
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
|
||||||
@@ -43,39 +43,13 @@ services:
|
|||||||
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-}
|
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-}
|
||||||
- OTEL_API_KEY=${OTEL_API_KEY:-}
|
- OTEL_API_KEY=${OTEL_API_KEY:-}
|
||||||
- OTEL_SERVICE_NAME=${OTEL_SERVICE_NAME:-flashpoint-web-backend}
|
- OTEL_SERVICE_NAME=${OTEL_SERVICE_NAME:-flashpoint-web-backend}
|
||||||
depends_on:
|
|
||||||
game-service:
|
|
||||||
condition: service_healthy
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "--fail", "http://localhost:3100/api/health"]
|
test: ["CMD", "curl", "--fail", "http://localhost:3100/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
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:
|
frontend:
|
||||||
image: darkraise/flashpoint-frontend:${IMAGE_TAG:-latest}
|
image: darkraise/flashpoint-frontend:${IMAGE_TAG:-latest}
|
||||||
container_name: flashpoint-frontend
|
container_name: flashpoint-frontend
|
||||||
@@ -88,12 +62,9 @@ services:
|
|||||||
- TZ=${TZ:-UTC}
|
- TZ=${TZ:-UTC}
|
||||||
- BACKEND_HOST=${BACKEND_HOST:-backend}
|
- BACKEND_HOST=${BACKEND_HOST:-backend}
|
||||||
- BACKEND_PORT=${BACKEND_PORT:-3100}
|
- BACKEND_PORT=${BACKEND_PORT:-3100}
|
||||||
- GAME_SERVICE_HOST=${GAME_SERVICE_HOST:-game-service}
|
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
game-service:
|
|
||||||
condition: service_healthy
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "--fail", "http://localhost:8080/"]
|
test: ["CMD", "curl", "--fail", "http://localhost:8080/"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
@@ -65,27 +65,25 @@ offering:
|
|||||||
|
|
||||||
## Architecture Highlights
|
## 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
|
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
|
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:
|
This separation allows:
|
||||||
|
|
||||||
- Independent scaling of services
|
|
||||||
- Backend non-blocking on large file transfers
|
|
||||||
- Game service caching and CDN compatibility
|
|
||||||
- Clear separation of concerns
|
- 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
|
## 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
|
- **Frontend**: React 18, TypeScript, Vite, TanStack Query, Zustand, Tailwind
|
||||||
CSS, Shadcn UI
|
CSS, Shadcn UI
|
||||||
- **Game Service**: Node.js, TypeScript, node-stream-zip, Axios
|
|
||||||
- **Game Emulation**: Ruffle (Flash), Native Browser (HTML5)
|
- **Game Emulation**: Ruffle (Flash), Native Browser (HTML5)
|
||||||
|
|
||||||
## Use Cases
|
## Use Cases
|
||||||
|
|||||||
@@ -31,17 +31,6 @@ experience, and ecosystem maturity.
|
|||||||
| Shadcn UI | Latest | Component library |
|
| Shadcn UI | Latest | Component library |
|
||||||
| Ruffle | 0.2.0 | Flash emulator |
|
| 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
|
## Backend Stack
|
||||||
@@ -63,6 +52,8 @@ JavaScript runtime built on Chrome's V8 engine.
|
|||||||
- Database operations
|
- Database operations
|
||||||
- File watching (hot-reload)
|
- File watching (hot-reload)
|
||||||
- Background jobs
|
- Background jobs
|
||||||
|
- Game file serving and proxying
|
||||||
|
- ZIP archive mounting and streaming
|
||||||
|
|
||||||
### Express (v4.18)
|
### Express (v4.18)
|
||||||
|
|
||||||
@@ -122,6 +113,26 @@ const stmt = fpDb.prepare(
|
|||||||
const games = stmt.all(`%${search}%`, platform);
|
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 & bcrypt
|
||||||
|
|
||||||
**JWT**: Stateless authentication, works across instances, mobile-friendly.
|
**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
|
## Development Tools
|
||||||
@@ -393,6 +382,9 @@ For each technology, evaluated:
|
|||||||
- Average response: <50ms
|
- Average response: <50ms
|
||||||
- Game search: <100ms
|
- Game search: <100ms
|
||||||
- Database queries: <10ms (indexed)
|
- Database queries: <10ms (indexed)
|
||||||
|
- File streaming: Wire speed
|
||||||
|
- ZIP file access: <50ms
|
||||||
|
- CDN fallback: 200-500ms (network dependent)
|
||||||
|
|
||||||
**Frontend:**
|
**Frontend:**
|
||||||
|
|
||||||
@@ -400,12 +392,6 @@ For each technology, evaluated:
|
|||||||
- Time to interactive: <2s
|
- Time to interactive: <2s
|
||||||
- Game grid render: <100ms (virtualized)
|
- Game grid render: <100ms (virtualized)
|
||||||
|
|
||||||
**Game Service:**
|
|
||||||
|
|
||||||
- File streaming: Wire speed
|
|
||||||
- ZIP file access: <50ms
|
|
||||||
- CDN fallback: 200-500ms (network dependent)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Browser Support
|
## Browser Support
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ flows.
|
|||||||
|
|
||||||
Topics covered:
|
Topics covered:
|
||||||
|
|
||||||
- Three-service architecture overview (Frontend, Backend, Game Service)
|
- Two-service architecture overview (Frontend, Backend with integrated game serving)
|
||||||
- Technology stack and rationale
|
- Technology stack and rationale
|
||||||
- Service responsibilities and boundaries
|
- Service responsibilities and boundaries
|
||||||
- Database architecture (flashpoint.sqlite and user.db)
|
- Database architecture (flashpoint.sqlite and user.db)
|
||||||
|
- Game content serving architecture (integrated in backend)
|
||||||
- Security architecture (JWT, RBAC)
|
- Security architecture (JWT, RBAC)
|
||||||
- File system layout
|
- File system layout
|
||||||
- Scalability considerations
|
- Scalability considerations
|
||||||
@@ -28,13 +29,13 @@ system.
|
|||||||
|
|
||||||
### 2. [Service Communication](./service-communication.md)
|
### 2. [Service Communication](./service-communication.md)
|
||||||
|
|
||||||
**Inter-service communication patterns and protocols**
|
**Communication patterns and protocols between Frontend and Backend**
|
||||||
|
|
||||||
Topics covered:
|
Topics covered:
|
||||||
|
|
||||||
- Frontend ↔ Backend communication (REST API)
|
- Frontend ↔ Backend communication (REST API)
|
||||||
- Backend ↔ Game Service communication (internal APIs)
|
- Integrated game file serving in backend
|
||||||
- Frontend ↔ Game Service communication (direct file loading)
|
- Backend game routes (`/game-proxy/*` and `/game-zip/*`)
|
||||||
- API endpoints by domain
|
- API endpoints by domain
|
||||||
- Authentication token flow
|
- Authentication token flow
|
||||||
- Request/response formats
|
- Request/response formats
|
||||||
@@ -233,12 +234,12 @@ Express middleware chains handle cross-cutting concerns:
|
|||||||
- Single deployment
|
- Single deployment
|
||||||
- Consistent tooling
|
- Consistent tooling
|
||||||
|
|
||||||
### Why Three Services?
|
### Why Two Services?
|
||||||
|
|
||||||
- Clear separation of concerns
|
- Clear separation of concerns
|
||||||
- Independent scaling
|
- Frontend focuses on UI and client-side state
|
||||||
- Backend doesn't block on file I/O
|
- Backend handles all business logic and content serving
|
||||||
- Game service can be replicated
|
- Simplified deployment (2 containers instead of 3)
|
||||||
- Easier to reason about
|
- Easier to reason about
|
||||||
|
|
||||||
## Common Workflows
|
## Common Workflows
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ Flashpoint Web uses JWT-based authentication with role-based access control
|
|||||||
graph TB
|
graph TB
|
||||||
subgraph "Frontend"
|
subgraph "Frontend"
|
||||||
LoginForm[Login Form]
|
LoginForm[Login Form]
|
||||||
AuthStore[Zustand Auth Store]
|
AuthStore[Zustand Auth Store<br/>Access Token Only]
|
||||||
APIClient[Axios API Client]
|
APIClient[Axios API Client]
|
||||||
LocalStorage[localStorage]
|
Cookies[HTTP-only Cookies<br/>fp_refresh]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph "Backend"
|
subgraph "Backend"
|
||||||
@@ -30,15 +30,16 @@ graph TB
|
|||||||
end
|
end
|
||||||
|
|
||||||
LoginForm --> AuthStore
|
LoginForm --> AuthStore
|
||||||
|
AuthRoute -.Set Cookie.-> Cookies
|
||||||
|
AuthRoute -.accessToken.-> APIClient
|
||||||
AuthStore --> APIClient
|
AuthStore --> APIClient
|
||||||
APIClient --> AuthRoute
|
APIClient --> AuthRoute
|
||||||
AuthRoute --> AuthService
|
AuthRoute --> AuthService
|
||||||
AuthService --> UserDB
|
AuthService --> UserDB
|
||||||
UserDB -.return user.-> AuthService
|
UserDB -.return user.-> AuthService
|
||||||
AuthService -.JWT tokens.-> AuthRoute
|
AuthService -.JWT tokens.-> AuthRoute
|
||||||
AuthRoute -.tokens + user.-> APIClient
|
AuthRoute -.accessToken only.-> APIClient
|
||||||
APIClient --> AuthStore
|
APIClient --> AuthStore
|
||||||
AuthStore --> LocalStorage
|
|
||||||
|
|
||||||
APIClient -.JWT Bearer.-> GameRoutes
|
APIClient -.JWT Bearer.-> GameRoutes
|
||||||
GameRoutes --> AuthMiddleware
|
GameRoutes --> AuthMiddleware
|
||||||
@@ -48,6 +49,7 @@ graph TB
|
|||||||
style AuthStore fill:#61dafb
|
style AuthStore fill:#61dafb
|
||||||
style AuthService fill:#90ee90
|
style AuthService fill:#90ee90
|
||||||
style UserDB fill:#ff9999
|
style UserDB fill:#ff9999
|
||||||
|
style Cookies fill:#ffd700
|
||||||
```
|
```
|
||||||
|
|
||||||
## 1. User Registration Flow
|
## 1. User Registration Flow
|
||||||
@@ -87,9 +89,9 @@ sequenceDiagram
|
|||||||
|
|
||||||
AuthService->>AuthService: Generate JWT tokens
|
AuthService->>AuthService: Generate JWT tokens
|
||||||
AuthService-->>Backend: {user, accessToken, refreshToken}
|
AuthService-->>Backend: {user, accessToken, refreshToken}
|
||||||
Backend-->>API: 201 Created
|
Backend->>Backend: Set fp_refresh HTTP-only cookie
|
||||||
API->>AuthStore: setAuth(user, tokens)
|
Backend-->>API: 201 Created (only accessToken in body)
|
||||||
AuthStore->>LocalStorage: Persist tokens
|
API->>AuthStore: setAuth(user, accessToken)
|
||||||
AuthStore-->>RegisterForm: Success
|
AuthStore-->>RegisterForm: Success
|
||||||
RegisterForm->>User: Redirect to dashboard
|
RegisterForm->>User: Redirect to dashboard
|
||||||
end
|
end
|
||||||
@@ -141,12 +143,12 @@ sequenceDiagram
|
|||||||
else Valid credentials
|
else Valid credentials
|
||||||
AuthService->>UserDB: SELECT user permissions
|
AuthService->>UserDB: SELECT user permissions
|
||||||
AuthService->>AuthService: Generate JWT access token (15m expiry)
|
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}
|
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)
|
API->>AuthStore: setAuth(user, accessToken)
|
||||||
AuthStore->>LocalStorage: Persist auth state
|
|
||||||
AuthStore->>ThemeStore: loadThemeFromServer()
|
AuthStore->>ThemeStore: loadThemeFromServer()
|
||||||
AuthStore-->>LoginForm: Success
|
AuthStore-->>LoginForm: Success
|
||||||
LoginForm->>User: Redirect to /browse
|
LoginForm->>User: Redirect to /browse
|
||||||
@@ -167,12 +169,12 @@ sequenceDiagram
|
|||||||
exp: 1705580100 // 15 minutes
|
exp: 1705580100 // 15 minutes
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh Token
|
// Refresh Token (stored in HTTP-only cookie)
|
||||||
{
|
{
|
||||||
userId: 1,
|
userId: 1,
|
||||||
type: "refresh",
|
type: "refresh",
|
||||||
iat: 1705579200,
|
iat: 1705579200,
|
||||||
exp: 1706184000 // 7 days
|
exp: 1708257600 // 30 days
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -183,6 +185,7 @@ sequenceDiagram
|
|||||||
participant Component
|
participant Component
|
||||||
participant API Client
|
participant API Client
|
||||||
participant Interceptor
|
participant Interceptor
|
||||||
|
participant AuthStore
|
||||||
participant Backend
|
participant Backend
|
||||||
participant AuthMiddleware
|
participant AuthMiddleware
|
||||||
participant RBACMiddleware
|
participant RBACMiddleware
|
||||||
@@ -191,10 +194,13 @@ sequenceDiagram
|
|||||||
Component->>API Client: gamesApi.search(filters)
|
Component->>API Client: gamesApi.search(filters)
|
||||||
|
|
||||||
API Client->>Interceptor: Request interceptor
|
API Client->>Interceptor: Request interceptor
|
||||||
|
Interceptor->>AuthStore: Get accessToken from store
|
||||||
Interceptor->>Interceptor: Add Authorization header
|
Interceptor->>Interceptor: Add Authorization header
|
||||||
Note over Interceptor: Authorization: Bearer {token}
|
Note over Interceptor: Authorization: Bearer {accessToken}
|
||||||
|
|
||||||
Interceptor->>Backend: GET /api/games?filters
|
Interceptor->>Backend: GET /api/games?filters
|
||||||
|
Note over Interceptor,Backend: Cookies sent automatically
|
||||||
|
|
||||||
Backend->>AuthMiddleware: authenticate middleware
|
Backend->>AuthMiddleware: authenticate middleware
|
||||||
|
|
||||||
AuthMiddleware->>AuthMiddleware: Extract & verify JWT
|
AuthMiddleware->>AuthMiddleware: Extract & verify JWT
|
||||||
@@ -267,6 +273,7 @@ sequenceDiagram
|
|||||||
participant API Client
|
participant API Client
|
||||||
participant Interceptor
|
participant Interceptor
|
||||||
participant AuthStore
|
participant AuthStore
|
||||||
|
participant Cookies
|
||||||
participant Backend
|
participant Backend
|
||||||
participant AuthService
|
participant AuthService
|
||||||
|
|
||||||
@@ -275,22 +282,27 @@ sequenceDiagram
|
|||||||
Backend-->>API Client: 401 Unauthorized
|
Backend-->>API Client: 401 Unauthorized
|
||||||
|
|
||||||
API Client->>Interceptor: Response interceptor (401 check)
|
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->>AuthStore: clearAuth()
|
||||||
Interceptor->>Component: Redirect to login
|
Interceptor->>Component: Redirect to login
|
||||||
else Has refresh token
|
else Has refresh token
|
||||||
Interceptor->>Backend: POST /api/auth/refresh {refreshToken}
|
Backend->>AuthService: refreshAccessToken(token from cookie)
|
||||||
Backend->>AuthService: refreshAccessToken(token)
|
|
||||||
|
|
||||||
alt Invalid refresh token
|
alt Invalid refresh token
|
||||||
AuthService-->>Backend: 401 Unauthorized
|
AuthService-->>Backend: 401 Unauthorized
|
||||||
|
Backend-->>Interceptor: 401 Unauthorized
|
||||||
Interceptor->>AuthStore: clearAuth()
|
Interceptor->>AuthStore: clearAuth()
|
||||||
Interceptor->>Component: Redirect to login
|
Interceptor->>Component: Redirect to login
|
||||||
else Valid
|
else Valid
|
||||||
AuthService->>AuthService: Generate new tokens
|
AuthService->>AuthService: Generate new accessToken
|
||||||
Backend-->>Interceptor: {accessToken, refreshToken}
|
AuthService->>AuthService: Generate new refreshToken
|
||||||
|
Backend->>Cookies: Set new fp_refresh cookie
|
||||||
|
Backend-->>Interceptor: {accessToken, expiresIn}
|
||||||
Interceptor->>AuthStore: updateAccessToken(newToken)
|
Interceptor->>AuthStore: updateAccessToken(newToken)
|
||||||
Interceptor->>Interceptor: Retry original request
|
Interceptor->>Interceptor: Retry original request
|
||||||
Backend-->>API Client: 200 OK
|
Backend-->>API Client: 200 OK
|
||||||
@@ -311,16 +323,10 @@ api.interceptors.response.use(
|
|||||||
originalRequest._retry = true;
|
originalRequest._retry = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const refreshToken = useAuthStore.getState().refreshToken;
|
// Refresh token is sent in HTTP-only cookie automatically
|
||||||
if (!refreshToken) {
|
const { data } = await authApi.refreshToken();
|
||||||
useAuthStore.getState().clearAuth();
|
useAuthStore.getState().updateAccessToken(data.accessToken);
|
||||||
window.location.href = '/login';
|
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokens = await authApi.refreshToken(refreshToken);
|
|
||||||
useAuthStore.getState().updateAccessToken(tokens.accessToken);
|
|
||||||
originalRequest.headers.Authorization = `Bearer ${tokens.accessToken}`;
|
|
||||||
return api(originalRequest);
|
return api(originalRequest);
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
useAuthStore.getState().clearAuth();
|
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
|
## 5. Logout Flow
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -342,17 +367,19 @@ sequenceDiagram
|
|||||||
participant Header
|
participant Header
|
||||||
participant AuthStore
|
participant AuthStore
|
||||||
participant API
|
participant API
|
||||||
|
participant Cookies
|
||||||
participant Backend
|
participant Backend
|
||||||
|
|
||||||
User->>Header: Click "Logout"
|
User->>Header: Click "Logout"
|
||||||
Header->>AuthStore: Get refreshToken
|
Header->>API: authApi.logout()
|
||||||
Header->>API: authApi.logout(refreshToken)
|
API->>Backend: POST /api/auth/logout (empty body)
|
||||||
API->>Backend: POST /api/auth/logout
|
Note over API,Backend: Cookies (fp_refresh) sent automatically
|
||||||
|
Backend->>Backend: Read refresh token from cookie
|
||||||
Backend->>Backend: Invalidate refresh token
|
Backend->>Backend: Invalidate refresh token
|
||||||
|
Backend->>Cookies: Clear fp_refresh cookie
|
||||||
Backend-->>API: 200 OK
|
Backend-->>API: 200 OK
|
||||||
|
|
||||||
API->>AuthStore: clearAuth()
|
API->>AuthStore: clearAuth()
|
||||||
AuthStore->>LocalStorage: Clear tokens
|
|
||||||
AuthStore-->>Header: Success
|
AuthStore-->>Header: Success
|
||||||
Header->>User: Redirect to /login
|
Header->>User: Redirect to /login
|
||||||
```
|
```
|
||||||
@@ -488,10 +515,12 @@ export const optionalAuth = async (req, res, next) => {
|
|||||||
|
|
||||||
**JWT Security**:
|
**JWT Security**:
|
||||||
|
|
||||||
- Short-lived access tokens (15 minutes)
|
- Short-lived access tokens (15 minutes) - held in memory only
|
||||||
- Long-lived refresh tokens (7 days)
|
- 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
|
- Signed with random 256-bit JWT_SECRET
|
||||||
- Verify algorithm: HS256
|
- Verify algorithm: HS256
|
||||||
|
- HTTP-only cookie prevents XSS attacks from accessing the refresh token
|
||||||
|
|
||||||
**Rate Limiting**: 5 login attempts per 15 minutes
|
**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
|
**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
|
## Troubleshooting
|
||||||
|
|
||||||
| Issue | Cause | Solution |
|
| Issue | Cause | Solution |
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user