chore: apply consistent code formatting across codebase
This commit is contained in:
@@ -361,8 +361,8 @@ LOG_LEVEL=info
|
||||
> stored in the `system_settings` table as `metadata.flashpoint_edition` and
|
||||
> `metadata.flashpoint_version`.
|
||||
|
||||
See `backend/.env.example` for complete configuration options including
|
||||
rate limiting, SQLite optimization, and OpenTelemetry settings.
|
||||
See `backend/.env.example` for complete configuration options including rate
|
||||
limiting, SQLite optimization, and OpenTelemetry settings.
|
||||
|
||||
### Game Service (.env)
|
||||
|
||||
@@ -698,8 +698,8 @@ without extra API calls.
|
||||
**Change:** Replaced the `FLASHPOINT_EDITION` environment variable with
|
||||
automatic detection from `{FLASHPOINT_PATH}/version.txt`. Edition and version
|
||||
are held in the backend `config` object — **not** stored in the
|
||||
`system_settings` database table. Backend services read `config` directly.
|
||||
The frontend receives edition/version via `/api/settings/public` (injected from
|
||||
`system_settings` database table. Backend services read `config` directly. The
|
||||
frontend receives edition/version via `/api/settings/public` (injected from
|
||||
`config`).
|
||||
|
||||
**How it works:**
|
||||
@@ -712,7 +712,8 @@ The frontend receives edition/version via `/api/settings/public` (injected from
|
||||
import and read `config` directly
|
||||
- The `/api/settings/public` endpoint injects edition/version from `config` into
|
||||
the response (same pattern as `homeRecentHours`)
|
||||
- Frontend uses the `usePublicSettings()` hook (already cached, no extra request)
|
||||
- Frontend uses the `usePublicSettings()` hook (already cached, no extra
|
||||
request)
|
||||
- Falls back to `'infinity'` if `version.txt` is missing or unparseable
|
||||
|
||||
**Files changed:**
|
||||
@@ -780,10 +781,14 @@ migrations with checksums and execution times.
|
||||
|
||||
**After ANY code change, always:**
|
||||
|
||||
1. Verify no build errors: `npm run typecheck` and `npm run build`
|
||||
2. Check if documentation needs updates
|
||||
3. Ask the user if relevant documentation should be updated
|
||||
4. Update the following docs as needed:
|
||||
1. **Run Prettier** on all new and modified files:
|
||||
`npx prettier --write <file1> <file2> ...` When using sub-agents (Task tool),
|
||||
instruct each agent to run Prettier on every file it creates or modifies
|
||||
before finishing.
|
||||
2. Verify no build errors: `npm run typecheck` and `npm run build`
|
||||
3. Check if documentation needs updates
|
||||
4. Ask the user if relevant documentation should be updated
|
||||
5. Update the following docs as needed:
|
||||
- Architecture changes → `docs/02-architecture/`
|
||||
- New/modified API endpoints → `docs/06-api-reference/`
|
||||
- Database schema changes → `docs/12-reference/database-schema-reference.md`
|
||||
|
||||
+36
-24
@@ -1,6 +1,7 @@
|
||||
# Contributing to Flashpoint Web
|
||||
|
||||
Thank you for your interest in contributing to Flashpoint Web! This document provides guidelines and instructions for contributing to this project.
|
||||
Thank you for your interest in contributing to Flashpoint Web! This document
|
||||
provides guidelines and instructions for contributing to this project.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -19,7 +20,8 @@ Thank you for your interest in contributing to Flashpoint Web! This document pro
|
||||
|
||||
### Our Pledge
|
||||
|
||||
We are committed to providing a welcoming and inspiring community for all. By participating in this project, you agree to:
|
||||
We are committed to providing a welcoming and inspiring community for all. By
|
||||
participating in this project, you agree to:
|
||||
|
||||
- Be respectful and inclusive
|
||||
- Accept constructive criticism gracefully
|
||||
@@ -90,14 +92,17 @@ cd ../frontend && npm test
|
||||
cd ../game-service && npm test
|
||||
```
|
||||
|
||||
For detailed setup instructions, see [Setup Guide](docs/08-development/setup-guide.md).
|
||||
For detailed setup instructions, see
|
||||
[Setup Guide](docs/08-development/setup-guide.md).
|
||||
|
||||
## How to Contribute
|
||||
|
||||
### Reporting Bugs
|
||||
|
||||
Before reporting a bug:
|
||||
1. Search [existing issues](https://github.com/OWNER/flashpoint-web/issues) to avoid duplicates
|
||||
|
||||
1. Search [existing issues](https://github.com/OWNER/flashpoint-web/issues) to
|
||||
avoid duplicates
|
||||
2. Use the [Bug Report template](.github/ISSUE_TEMPLATE/bug_report.md)
|
||||
3. Include:
|
||||
- Clear description of the issue
|
||||
@@ -125,6 +130,7 @@ Before reporting a bug:
|
||||
### First-Time Contributors
|
||||
|
||||
Look for issues labeled:
|
||||
|
||||
- `good first issue` - Simple issues for newcomers
|
||||
- `help wanted` - Issues where we need community help
|
||||
- `documentation` - Documentation improvements
|
||||
@@ -134,12 +140,14 @@ Look for issues labeled:
|
||||
### Before Submitting
|
||||
|
||||
1. **Sync with upstream**:
|
||||
|
||||
```bash
|
||||
git fetch upstream
|
||||
git rebase upstream/master
|
||||
```
|
||||
|
||||
2. **Run all checks**:
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm run format:check
|
||||
@@ -161,6 +169,7 @@ Look for issues labeled:
|
||||
### Branch Naming
|
||||
|
||||
Use descriptive branch names:
|
||||
|
||||
```
|
||||
feature/add-playlist-export
|
||||
fix/game-search-pagination
|
||||
@@ -285,16 +294,16 @@ footer (optional)
|
||||
|
||||
### Types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `feat` | New feature |
|
||||
| `fix` | Bug fix |
|
||||
| `docs` | Documentation changes |
|
||||
| `style` | Code style changes (formatting, semicolons) |
|
||||
| `refactor` | Code refactoring |
|
||||
| `test` | Adding or updating tests |
|
||||
| `chore` | Maintenance tasks |
|
||||
| `perf` | Performance improvements |
|
||||
| Type | Description |
|
||||
| ---------- | ------------------------------------------- |
|
||||
| `feat` | New feature |
|
||||
| `fix` | Bug fix |
|
||||
| `docs` | Documentation changes |
|
||||
| `style` | Code style changes (formatting, semicolons) |
|
||||
| `refactor` | Code refactoring |
|
||||
| `test` | Adding or updating tests |
|
||||
| `chore` | Maintenance tasks |
|
||||
| `perf` | Performance improvements |
|
||||
|
||||
### Examples
|
||||
|
||||
@@ -376,6 +385,7 @@ describe('GameService', () => {
|
||||
### When to Update Docs
|
||||
|
||||
Update documentation when you:
|
||||
|
||||
- Add new features
|
||||
- Change API endpoints
|
||||
- Modify configuration options
|
||||
@@ -453,6 +463,7 @@ flashpoint-web/
|
||||
## Recognition
|
||||
|
||||
Contributors are recognized in:
|
||||
|
||||
- GitHub contributors list
|
||||
- Release notes for significant contributions
|
||||
- README acknowledgments section
|
||||
@@ -461,16 +472,17 @@ Contributors are recognized in:
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Install dependencies | `npm run install:all` |
|
||||
| Start dev servers | `npm run dev` |
|
||||
| Type check | `npm run typecheck` |
|
||||
| Format code | `npm run format` |
|
||||
| Run backend tests | `cd backend && npm test` |
|
||||
| Run frontend tests | `cd frontend && npm test` |
|
||||
| Build all | `npm run build` |
|
||||
| Task | Command |
|
||||
| -------------------- | ------------------------- |
|
||||
| Install dependencies | `npm run install:all` |
|
||||
| Start dev servers | `npm run dev` |
|
||||
| Type check | `npm run typecheck` |
|
||||
| Format code | `npm run format` |
|
||||
| Run backend tests | `cd backend && npm test` |
|
||||
| Run frontend tests | `cd frontend && npm test` |
|
||||
| Build all | `npm run build` |
|
||||
|
||||
---
|
||||
|
||||
Thank you for contributing to Flashpoint Web! Your efforts help preserve gaming history for future generations.
|
||||
Thank you for contributing to Flashpoint Web! Your efforts help preserve gaming
|
||||
history for future generations.
|
||||
|
||||
@@ -6,15 +6,15 @@
|
||||
"project": true
|
||||
},
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-unused-vars": ["error", {
|
||||
"argsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_"
|
||||
}],
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||
|
||||
+5
-1
@@ -54,9 +54,11 @@ src/
|
||||
## Database
|
||||
|
||||
Uses sql.js (pure JavaScript SQLite) for direct read-only access to:
|
||||
|
||||
- `D:\Flashpoint\Data\flashpoint.sqlite`
|
||||
|
||||
**No native compilation required!** sql.js is a WebAssembly-based SQLite implementation that works without Visual Studio or build tools.
|
||||
**No native compilation required!** sql.js is a WebAssembly-based SQLite
|
||||
implementation that works without Visual Studio or build tools.
|
||||
|
||||
Schema includes tables for games, platforms, tags, and more.
|
||||
|
||||
@@ -73,10 +75,12 @@ npm run lint # ESLint
|
||||
## Environment Variables
|
||||
|
||||
Required:
|
||||
|
||||
- `FLASHPOINT_PATH` - Path to Flashpoint installation
|
||||
- `FLASHPOINT_DB_PATH` - Path to SQLite database
|
||||
|
||||
Optional:
|
||||
|
||||
- `PORT` - Server port (default: 3100)
|
||||
- `NODE_ENV` - Environment (development/production)
|
||||
- `GAME_SERVER_URL` - Game Server URL (default: http://localhost:22500)
|
||||
|
||||
@@ -77,9 +77,7 @@ async function fixAbsolutePaths() {
|
||||
}
|
||||
|
||||
// Filter entries with absolute paths
|
||||
const absolutePathEntries = rows.filter(row =>
|
||||
row.path && isAbsolutePath(row.path)
|
||||
);
|
||||
const absolutePathEntries = rows.filter((row) => row.path && isAbsolutePath(row.path));
|
||||
|
||||
console.log(`Found ${absolutePathEntries.length} entries with absolute paths\n`);
|
||||
|
||||
@@ -155,7 +153,6 @@ async function fixAbsolutePaths() {
|
||||
console.log();
|
||||
console.log('✓ All paths have been converted to relative format');
|
||||
console.log('✓ Your games are now fully compatible with Flashpoint Launcher');
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n✗ Migration failed:', error);
|
||||
process.exit(1);
|
||||
|
||||
@@ -56,7 +56,6 @@ try {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -20,8 +20,8 @@ function getJwtSecret(): string {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error(
|
||||
'FATAL: JWT_SECRET environment variable is required in production.\n' +
|
||||
'Generate a secure secret: openssl rand -base64 64\n' +
|
||||
'Set it in your .env file or environment variables.'
|
||||
'Generate a secure secret: openssl rand -base64 64\n' +
|
||||
'Set it in your .env file or environment variables.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,7 +106,8 @@ export const config = {
|
||||
// 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 ||
|
||||
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)
|
||||
@@ -120,7 +121,9 @@ export const config = {
|
||||
// In Docker (production): defaults to /app/logs/backend.log
|
||||
// In local dev: no file logging unless LOG_FILE is explicitly set
|
||||
logLevel: process.env.LOG_LEVEL || 'info',
|
||||
logFile: process.env.LOG_FILE || (process.env.NODE_ENV === 'production' ? '/app/logs/backend.log' : undefined),
|
||||
logFile:
|
||||
process.env.LOG_FILE ||
|
||||
(process.env.NODE_ENV === 'production' ? '/app/logs/backend.log' : undefined),
|
||||
|
||||
// User Database
|
||||
// In Docker (production): /app/data/user.db
|
||||
@@ -141,7 +144,7 @@ export const config = {
|
||||
// When enabled, copies flashpoint.sqlite to local storage for faster access
|
||||
// Stored in /app/data alongside user.db
|
||||
enableLocalDbCopy: process.env.ENABLE_LOCAL_DB_COPY === 'true',
|
||||
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
|
||||
sqliteCacheSize: parseInt(process.env.SQLITE_CACHE_SIZE || '-64000', 10), // Negative = KB, -64000 = 64MB
|
||||
@@ -156,7 +159,7 @@ export const config = {
|
||||
flashpointEdition: flashpointVersion.edition,
|
||||
|
||||
// Full version string from version.txt (e.g., "Flashpoint 14.0.3 Infinity - Kingfisher")
|
||||
flashpointVersionString: flashpointVersion.versionString
|
||||
flashpointVersionString: flashpointVersion.versionString,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -54,17 +54,16 @@ export class MetadataSyncJob {
|
||||
|
||||
logger.info(
|
||||
`[MetadataSyncJob] Sync completed successfully - ` +
|
||||
`Games: ${result.gamesUpdated}, ` +
|
||||
`Tags: ${result.tagsUpdated}, ` +
|
||||
`Platforms: ${result.platformsUpdated}, ` +
|
||||
`Deleted: ${result.gamesDeleted}`
|
||||
`Games: ${result.gamesUpdated}, ` +
|
||||
`Tags: ${result.tagsUpdated}, ` +
|
||||
`Platforms: ${result.platformsUpdated}, ` +
|
||||
`Deleted: ${result.gamesDeleted}`
|
||||
);
|
||||
return message;
|
||||
} else {
|
||||
logger.error(`[MetadataSyncJob] Sync failed: ${result.error}`);
|
||||
throw new Error(result.error || 'Metadata sync failed');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[MetadataSyncJob] Error running metadata sync job:', error);
|
||||
throw error; // Re-throw so JobScheduler marks execution as failed
|
||||
|
||||
@@ -46,9 +46,7 @@ export class RuffleUpdateJob {
|
||||
const result = await this.ruffleService.updateRuffle();
|
||||
|
||||
if (result.success) {
|
||||
logger.info(
|
||||
`[RuffleUpdateJob] Update completed successfully - Version: ${result.version}`
|
||||
);
|
||||
logger.info(`[RuffleUpdateJob] Update completed successfully - Version: ${result.version}`);
|
||||
return `Ruffle updated to ${result.version}`;
|
||||
} else {
|
||||
logger.error(`[RuffleUpdateJob] Update failed: ${result.message}`);
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
## Overview
|
||||
|
||||
The maintenance mode middleware provides a way to temporarily restrict access to the application while allowing administrators to continue working. This is useful for:
|
||||
The maintenance mode middleware provides a way to temporarily restrict access to
|
||||
the application while allowing administrators to continue working. This is
|
||||
useful for:
|
||||
|
||||
- Performing database migrations
|
||||
- Applying critical updates
|
||||
@@ -11,25 +13,33 @@ The maintenance mode middleware provides a way to temporarily restrict access to
|
||||
|
||||
## How It Works
|
||||
|
||||
The middleware uses a **simplified 4-step approach** to control access during maintenance:
|
||||
The middleware uses a **simplified 4-step approach** to control access during
|
||||
maintenance:
|
||||
|
||||
### Prerequisites
|
||||
|
||||
**IMPORTANT:** The `softAuth` middleware MUST be applied globally **before** this middleware. `softAuth` populates `req.user` with user data if a valid token exists, without throwing errors. This allows the maintenance middleware to identify admin users.
|
||||
**IMPORTANT:** The `softAuth` middleware MUST be applied globally **before**
|
||||
this middleware. `softAuth` populates `req.user` with user data if a valid token
|
||||
exists, without throwing errors. This allows the maintenance middleware to
|
||||
identify admin users.
|
||||
|
||||
**Middleware order in server.ts:**
|
||||
|
||||
```typescript
|
||||
app.use(softAuth); // 1. Populate req.user
|
||||
app.use(maintenanceMode); // 2. Check maintenance and admin status
|
||||
app.use(softAuth); // 1. Populate req.user
|
||||
app.use(maintenanceMode); // 2. Check maintenance and admin status
|
||||
```
|
||||
|
||||
### Step 1: Allow Minimal Public Paths
|
||||
|
||||
**What:** A minimal set of endpoints that must work for admin to LOGIN during maintenance.
|
||||
**What:** A minimal set of endpoints that must work for admin to LOGIN during
|
||||
maintenance.
|
||||
|
||||
**Why:** Admin needs to be able to log in to access the system and disable maintenance mode.
|
||||
**Why:** Admin needs to be able to log in to access the system and disable
|
||||
maintenance mode.
|
||||
|
||||
**Minimal public paths:**
|
||||
|
||||
- `/health` - Health check endpoint (monitoring)
|
||||
- `/api/auth/login` - Admin needs to login
|
||||
- `/api/auth/refresh` - Token refresh
|
||||
@@ -48,11 +58,14 @@ app.use(maintenanceMode); // 2. Check maintenance and admin status
|
||||
**What:** Users with `settings.update` permission can access **ALL endpoints**.
|
||||
|
||||
**How it works:**
|
||||
- `req.user` is populated by `softAuth` middleware (applied globally before this)
|
||||
|
||||
- `req.user` is populated by `softAuth` middleware (applied globally before
|
||||
this)
|
||||
- Check if `req.user.permissions` includes `'settings.update'`
|
||||
- If yes → **ALLOW EVERYTHING** (all features, all APIs)
|
||||
|
||||
**Admin actions allowed during maintenance:**
|
||||
|
||||
- ✅ Browse games, playlists, platforms
|
||||
- ✅ View and modify all settings
|
||||
- ✅ Manage users and roles
|
||||
@@ -61,9 +74,11 @@ app.use(maintenanceMode); // 2. Check maintenance and admin status
|
||||
|
||||
### Step 4: Block Everyone Else
|
||||
|
||||
**What:** Non-admin users and guests are blocked from ALL endpoints (except public paths).
|
||||
**What:** Non-admin users and guests are blocked from ALL endpoints (except
|
||||
public paths).
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Service Unavailable",
|
||||
@@ -74,6 +89,7 @@ app.use(maintenanceMode); // 2. Check maintenance and admin status
|
||||
```
|
||||
|
||||
**HTTP Headers:**
|
||||
|
||||
- Status: `503 Service Unavailable`
|
||||
- `Retry-After: 3600` (retry after 1 hour)
|
||||
|
||||
@@ -82,23 +98,32 @@ app.use(maintenanceMode); // 2. Check maintenance and admin status
|
||||
The middleware provides detailed logging to help troubleshoot issues:
|
||||
|
||||
### Info Logs (admin access):
|
||||
- `[Maintenance] Admin <username> accessing GET /api/games` - Admin accessing endpoint during maintenance
|
||||
|
||||
- `[Maintenance] Admin <username> accessing GET /api/games` - Admin accessing
|
||||
endpoint during maintenance
|
||||
|
||||
### Warning Logs (when requests are blocked):
|
||||
- `[Maintenance] Blocked GET /api/games from user john (IP: 127.0.0.1)` - Authenticated user blocked
|
||||
- `[Maintenance] Blocked GET /api/games from unauthenticated user (IP: 127.0.0.1)` - Guest blocked
|
||||
|
||||
- `[Maintenance] Blocked GET /api/games from user john (IP: 127.0.0.1)` -
|
||||
Authenticated user blocked
|
||||
- `[Maintenance] Blocked GET /api/games from unauthenticated user (IP: 127.0.0.1)` -
|
||||
Guest blocked
|
||||
|
||||
### Error Logs (critical failures):
|
||||
- `[Maintenance] Critical error in maintenance middleware: <error>` - Middleware error (request blocked)
|
||||
|
||||
- `[Maintenance] Critical error in maintenance middleware: <error>` - Middleware
|
||||
error (request blocked)
|
||||
|
||||
## Enabling Maintenance Mode
|
||||
|
||||
### Via Settings UI (Admin only):
|
||||
|
||||
1. Navigate to Settings → App Settings
|
||||
2. Toggle "Maintenance Mode" on
|
||||
3. Confirm the change
|
||||
|
||||
### Via API:
|
||||
|
||||
```bash
|
||||
curl -X PATCH http://localhost:3001/api/settings/app \
|
||||
-H "Authorization: Bearer <admin-token>" \
|
||||
@@ -107,6 +132,7 @@ curl -X PATCH http://localhost:3001/api/settings/app \
|
||||
```
|
||||
|
||||
### Via Database:
|
||||
|
||||
```sql
|
||||
UPDATE system_settings
|
||||
SET value = '1'
|
||||
@@ -116,12 +142,14 @@ WHERE key = 'app.maintenance_mode';
|
||||
## Testing Maintenance Mode
|
||||
|
||||
### Test as Admin:
|
||||
|
||||
1. Enable maintenance mode
|
||||
2. Verify you can still access all pages
|
||||
3. Check logs - should see debug messages about admin access
|
||||
4. Disable maintenance mode
|
||||
|
||||
### Test as Regular User:
|
||||
|
||||
1. Enable maintenance mode (as admin)
|
||||
2. Log in as regular user in a different browser/incognito
|
||||
3. Verify API requests are blocked with 503
|
||||
@@ -129,6 +157,7 @@ WHERE key = 'app.maintenance_mode';
|
||||
5. Disable maintenance mode (as admin)
|
||||
|
||||
### Test as Guest:
|
||||
|
||||
1. Enable maintenance mode (as admin)
|
||||
2. Open site in incognito/private window (not logged in)
|
||||
3. Verify API requests are blocked with 503
|
||||
@@ -139,17 +168,21 @@ WHERE key = 'app.maintenance_mode';
|
||||
|
||||
### Issue: "Admin sees blocked requests in logs"
|
||||
|
||||
**Cause:** Some browser requests (prefetch, favicon, extensions) may not include the Authorization header.
|
||||
**Cause:** Some browser requests (prefetch, favicon, extensions) may not include
|
||||
the Authorization header.
|
||||
|
||||
**Solution:** This is normal. Only API requests to non-whitelisted endpoints need admin auth.
|
||||
**Solution:** This is normal. Only API requests to non-whitelisted endpoints
|
||||
need admin auth.
|
||||
|
||||
### Issue: "Can't disable maintenance mode"
|
||||
|
||||
**Cause:** Admin token may have expired or `/api/settings` is being blocked.
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. Log out and log back in to get fresh token
|
||||
2. Or update database directly:
|
||||
|
||||
```sql
|
||||
UPDATE system_settings SET value = '0' WHERE key = 'app.maintenance_mode';
|
||||
```
|
||||
@@ -164,21 +197,26 @@ UPDATE system_settings SET value = '0' WHERE key = 'app.maintenance_mode';
|
||||
|
||||
### Fail-Closed Strategy (Secure by Default)
|
||||
|
||||
**IMPORTANT:** If the middleware encounters an error (e.g., database connection fails), it **blocks the request** rather than allowing it through.
|
||||
**IMPORTANT:** If the middleware encounters an error (e.g., database connection
|
||||
fails), it **blocks the request** rather than allowing it through.
|
||||
|
||||
**Why fail-closed:**
|
||||
|
||||
- Maintenance mode is a security-critical feature
|
||||
- Errors during maintenance could indicate database issues
|
||||
- Safer to block requests than expose potentially broken systems
|
||||
- Admin can still disable maintenance via database if needed
|
||||
|
||||
**Impact:**
|
||||
|
||||
- If settings service fails → all requests blocked (except public paths)
|
||||
- Better to be overly cautious during maintenance
|
||||
|
||||
### Token Validation
|
||||
|
||||
The middleware relies on `softAuth` middleware (applied globally before it) which:
|
||||
The middleware relies on `softAuth` middleware (applied globally before it)
|
||||
which:
|
||||
|
||||
- Extracts JWT from Authorization header
|
||||
- Verifies token signature and expiration
|
||||
- Populates `req.user` with user data
|
||||
@@ -188,16 +226,20 @@ The middleware relies on `softAuth` middleware (applied globally before it) whic
|
||||
|
||||
### Admin Detection
|
||||
|
||||
Only users with the `settings.update` permission can bypass maintenance mode. This is typically only the "admin" role.
|
||||
Only users with the `settings.update` permission can bypass maintenance mode.
|
||||
This is typically only the "admin" role.
|
||||
|
||||
**Permission check:**
|
||||
|
||||
```typescript
|
||||
const isAdmin = req.user?.permissions?.includes('settings.update');
|
||||
```
|
||||
|
||||
### Singleton Service Instance
|
||||
|
||||
The middleware uses a **shared singleton** instance of `CachedSystemSettingsService`:
|
||||
The middleware uses a **shared singleton** instance of
|
||||
`CachedSystemSettingsService`:
|
||||
|
||||
- Prevents per-request service instantiation
|
||||
- Shares cache across all requests
|
||||
- Better performance and memory usage
|
||||
@@ -207,7 +249,8 @@ The middleware uses a **shared singleton** instance of `CachedSystemSettingsServ
|
||||
### Optimizations
|
||||
|
||||
- **Cache TTL:** 60 seconds (settings cached in singleton service)
|
||||
- **Single token verification:** Token verified once by `softAuth`, result reused
|
||||
- **Single token verification:** Token verified once by `softAuth`, result
|
||||
reused
|
||||
- **Early returns:** Requests exit as soon as they're allowed
|
||||
- **Singleton service:** Shared instance prevents per-request instantiation
|
||||
- **Minimal processing:** Simple permission checks, no heavy computations
|
||||
@@ -223,9 +266,11 @@ The middleware uses a **shared singleton** instance of `CachedSystemSettingsServ
|
||||
|
||||
### What Changed
|
||||
|
||||
The maintenance mode middleware was recently **completely revamped** to fix critical issues:
|
||||
The maintenance mode middleware was recently **completely revamped** to fix
|
||||
critical issues:
|
||||
|
||||
**Old architecture issues:**
|
||||
|
||||
- ❌ Duplicate token verification (performance cost)
|
||||
- ❌ Per-request service instantiation
|
||||
- ❌ Fail-open security flaw
|
||||
@@ -233,6 +278,7 @@ The maintenance mode middleware was recently **completely revamped** to fix crit
|
||||
- ❌ Admin users getting blocked
|
||||
|
||||
**New architecture benefits:**
|
||||
|
||||
- ✅ Single token verification (via `softAuth` middleware)
|
||||
- ✅ Singleton service instance (shared cache)
|
||||
- ✅ Fail-closed security (secure by default)
|
||||
@@ -251,6 +297,7 @@ If you're upgrading from the old version:
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements:
|
||||
|
||||
- Configurable maintenance message (via system settings)
|
||||
- Scheduled maintenance windows
|
||||
- Read-only mode (allow reads, block writes)
|
||||
|
||||
@@ -39,7 +39,7 @@ export const logActivity = (
|
||||
resourceId,
|
||||
details,
|
||||
ipAddress: req.ip,
|
||||
userAgent: req.headers['user-agent']
|
||||
userAgent: req.headers['user-agent'],
|
||||
});
|
||||
} catch (error) {
|
||||
// Log error but don't fail request
|
||||
|
||||
@@ -50,8 +50,4 @@ export const asyncHandler = (
|
||||
* router.get('/games', asyncHandler(getGames));
|
||||
* ```
|
||||
*/
|
||||
export type AsyncRouteHandler = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => Promise<any>;
|
||||
export type AsyncRouteHandler = (req: Request, res: Response, next: NextFunction) => Promise<any>;
|
||||
|
||||
@@ -68,7 +68,7 @@ export const optionalAuth = async (req: Request, res: Response, next: NextFuncti
|
||||
username: 'guest',
|
||||
email: '',
|
||||
role: 'guest',
|
||||
permissions: ['games.read', 'playlists.read']
|
||||
permissions: ['games.read', 'playlists.read'],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,7 +131,12 @@ export const sharedAccessAuth = asyncHandler(
|
||||
// Validate the shareToken is still valid
|
||||
const playlist = playlistService.getPlaylistByShareToken(payload.shareToken);
|
||||
if (!playlist) {
|
||||
throw new AppError(401, 'Shared access token invalid or expired', true, 'SHARED_ACCESS_INVALID');
|
||||
throw new AppError(
|
||||
401,
|
||||
'Shared access token invalid or expired',
|
||||
true,
|
||||
'SHARED_ACCESS_INVALID'
|
||||
);
|
||||
}
|
||||
|
||||
req.sharedAccess = payload;
|
||||
@@ -162,7 +167,7 @@ export const sharedAccessAuth = asyncHandler(
|
||||
username: 'guest',
|
||||
email: '',
|
||||
role: 'guest',
|
||||
permissions: ['games.read', 'playlists.read', 'games.play']
|
||||
permissions: ['games.read', 'playlists.read', 'games.play'],
|
||||
};
|
||||
|
||||
next();
|
||||
@@ -193,7 +198,12 @@ export const validateSharedGameAccess = (gameIdParam: string = 'id') => {
|
||||
);
|
||||
|
||||
if (!isInPlaylist) {
|
||||
throw new AppError(403, 'Game not accessible via this shared playlist', true, 'GAME_NOT_IN_SHARED_PLAYLIST');
|
||||
throw new AppError(
|
||||
403,
|
||||
'Game not accessible via this shared playlist',
|
||||
true,
|
||||
'GAME_NOT_IN_SHARED_PLAYLIST'
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
|
||||
@@ -43,14 +43,14 @@ export function validateDateRange(
|
||||
if (isNaN(startDateObj.getTime()) || isNaN(endDateObj.getTime())) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid date format'
|
||||
error: 'Invalid date format',
|
||||
});
|
||||
}
|
||||
|
||||
if (endDateObj < startDateObj) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'End date must be after or equal to start date'
|
||||
error: 'End date must be after or equal to start date',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,10 +30,10 @@ function sanitizeBody(body: any): any {
|
||||
'refreshToken',
|
||||
'secret',
|
||||
'apiKey',
|
||||
'accessToken'
|
||||
'accessToken',
|
||||
];
|
||||
|
||||
sensitiveFields.forEach(field => {
|
||||
sensitiveFields.forEach((field) => {
|
||||
if (sanitized[field]) {
|
||||
sanitized[field] = '[REDACTED]';
|
||||
}
|
||||
@@ -53,15 +53,15 @@ export function errorHandler(
|
||||
statusCode: err.statusCode,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
stack: err.stack
|
||||
stack: err.stack,
|
||||
});
|
||||
|
||||
return res.status(err.statusCode).json({
|
||||
error: {
|
||||
message: err.message,
|
||||
statusCode: err.statusCode,
|
||||
...(err.code && { code: err.code })
|
||||
}
|
||||
...(err.code && { code: err.code }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -74,14 +74,14 @@ export function errorHandler(
|
||||
method: req.method,
|
||||
body: sanitizeBody(req.body),
|
||||
query: req.query,
|
||||
params: req.params
|
||||
params: req.params,
|
||||
});
|
||||
|
||||
// Return generic error message to client (prevent information leakage)
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: 'Internal server error',
|
||||
statusCode: 500
|
||||
}
|
||||
statusCode: 500,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -29,7 +29,10 @@ export function requireFeature(featureKey: string) {
|
||||
logger.warn(
|
||||
`[FeatureFlag] Feature '${featureKey}' is disabled, blocked ${req.method} ${req.path} from ${req.user?.username || 'guest'} (IP: ${req.ip})`
|
||||
);
|
||||
throw new AppError(403, `This feature is currently disabled. Please contact your administrator.`);
|
||||
throw new AppError(
|
||||
403,
|
||||
`This feature is currently disabled. Please contact your administrator.`
|
||||
);
|
||||
}
|
||||
|
||||
next();
|
||||
|
||||
@@ -8,14 +8,14 @@ const systemSettings = new CachedSystemSettingsService();
|
||||
// MINIMAL whitelist - only endpoints needed for admin to LOGIN
|
||||
// Everything else requires admin authentication
|
||||
const MINIMAL_PUBLIC_PATHS = [
|
||||
'/health', // Health checks for monitoring
|
||||
'/api/auth/login', // Admin needs to login
|
||||
'/api/auth/register', // Initial admin setup (first user)
|
||||
'/api/auth/setup-status', // Check if system needs initial setup
|
||||
'/api/auth/refresh', // Token refresh
|
||||
'/api/settings/public', // UI needs public settings
|
||||
'/proxy', // Game images, logos, screenshots, and game files
|
||||
'/api/playlists/shared', // Anonymous shared playlist access (bypasses guest access settings)
|
||||
'/health', // Health checks for monitoring
|
||||
'/api/auth/login', // Admin needs to login
|
||||
'/api/auth/register', // Initial admin setup (first user)
|
||||
'/api/auth/setup-status', // Check if system needs initial setup
|
||||
'/api/auth/refresh', // Token refresh
|
||||
'/api/settings/public', // UI needs public settings
|
||||
'/proxy', // Game images, logos, screenshots, and game files
|
||||
'/api/playlists/shared', // Anonymous shared playlist access (bypasses guest access settings)
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -36,12 +36,16 @@ const MINIMAL_PUBLIC_PATHS = [
|
||||
* 3. If enabled: only allow admins through, block everyone else
|
||||
* 4. If disabled: allow everyone (normal operation)
|
||||
*/
|
||||
export async function maintenanceMode(req: Request, res: Response, next: NextFunction): Promise<void | Response> {
|
||||
export async function maintenanceMode(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void | Response> {
|
||||
try {
|
||||
// Step 1: Check if path is in minimal public whitelist
|
||||
// These paths must work for admin to LOGIN during maintenance
|
||||
const isPublicPath = MINIMAL_PUBLIC_PATHS.some(path =>
|
||||
req.path === path || req.path.startsWith(path + '/')
|
||||
const isPublicPath = MINIMAL_PUBLIC_PATHS.some(
|
||||
(path) => req.path === path || req.path.startsWith(path + '/')
|
||||
);
|
||||
|
||||
if (isPublicPath) {
|
||||
@@ -73,27 +77,20 @@ export async function maintenanceMode(req: Request, res: Response, next: NextFun
|
||||
if (isAdmin) {
|
||||
// Admin user - ALLOW EVERYTHING (all endpoints, all features)
|
||||
// user is guaranteed to exist if isAdmin is true
|
||||
logger.info(
|
||||
`[Maintenance] Admin ${user!.username} accessing ${req.method} ${req.path}`
|
||||
);
|
||||
logger.info(`[Maintenance] Admin ${user!.username} accessing ${req.method} ${req.path}`);
|
||||
return next();
|
||||
}
|
||||
|
||||
// Step 4: Non-admin user or guest - BLOCK EVERYTHING
|
||||
const userInfo = user ? `user ${user.username}` : 'unauthenticated user';
|
||||
logger.warn(
|
||||
`[Maintenance] Blocked ${req.method} ${req.path} from ${userInfo} (IP: ${req.ip})`
|
||||
);
|
||||
|
||||
return res.status(503)
|
||||
.set('Retry-After', '3600')
|
||||
.json({
|
||||
error: 'Service Unavailable',
|
||||
message: 'The application is currently undergoing maintenance. Please try again later.',
|
||||
maintenanceMode: true,
|
||||
retryAfter: 3600,
|
||||
});
|
||||
logger.warn(`[Maintenance] Blocked ${req.method} ${req.path} from ${userInfo} (IP: ${req.ip})`);
|
||||
|
||||
return res.status(503).set('Retry-After', '3600').json({
|
||||
error: 'Service Unavailable',
|
||||
message: 'The application is currently undergoing maintenance. Please try again later.',
|
||||
maintenanceMode: true,
|
||||
retryAfter: 3600,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[Maintenance] Critical error in maintenance middleware:', error);
|
||||
|
||||
|
||||
@@ -34,11 +34,13 @@ export function performanceTracking(req: Request, res: Response, next: NextFunct
|
||||
* - /api/users/789 → /api/users/:id
|
||||
*/
|
||||
function normalizePath(path: string): string {
|
||||
return path
|
||||
// Replace UUIDs
|
||||
.replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '/:id')
|
||||
// Replace numeric IDs
|
||||
.replace(/\/\d+/g, '/:id')
|
||||
// Replace alphanumeric IDs (game IDs, share tokens)
|
||||
.replace(/\/[a-zA-Z0-9_-]{8,}/g, '/:id');
|
||||
return (
|
||||
path
|
||||
// Replace UUIDs
|
||||
.replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '/:id')
|
||||
// Replace numeric IDs
|
||||
.replace(/\/\d+/g, '/:id')
|
||||
// Replace alphanumeric IDs (game IDs, share tokens)
|
||||
.replace(/\/[a-zA-Z0-9_-]{8,}/g, '/:id')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,9 +14,7 @@ export const requirePermission = (...permissions: string[]) => {
|
||||
const userPermissions = req.user.permissions || [];
|
||||
|
||||
// Check if user has any of the required permissions
|
||||
const hasPermission = permissions.some(permission =>
|
||||
userPermissions.includes(permission)
|
||||
);
|
||||
const hasPermission = permissions.some((permission) => userPermissions.includes(permission));
|
||||
|
||||
if (!hasPermission) {
|
||||
return next(new AppError(403, 'Insufficient permissions'));
|
||||
|
||||
@@ -50,7 +50,7 @@ export function requestTimeout(timeoutMs: number = DEFAULT_TIMEOUT_MS) {
|
||||
timeout: `${timeoutMs}ms`,
|
||||
elapsed: `${elapsed}ms`,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('user-agent')
|
||||
userAgent: req.get('user-agent'),
|
||||
});
|
||||
|
||||
res.status(408).json({
|
||||
@@ -58,8 +58,8 @@ export function requestTimeout(timeoutMs: number = DEFAULT_TIMEOUT_MS) {
|
||||
message: 'Request timeout',
|
||||
statusCode: 408,
|
||||
timeout: timeoutMs,
|
||||
details: 'The server took too long to respond. Please try again.'
|
||||
}
|
||||
details: 'The server took too long to respond. Please try again.',
|
||||
},
|
||||
});
|
||||
|
||||
hasResponded = true;
|
||||
@@ -71,17 +71,17 @@ export function requestTimeout(timeoutMs: number = DEFAULT_TIMEOUT_MS) {
|
||||
const originalJson = res.json.bind(res);
|
||||
const originalEnd = res.end.bind(res);
|
||||
|
||||
res.send = function(body?: any) {
|
||||
res.send = function (body?: any) {
|
||||
clearTimeoutAndLog();
|
||||
return originalSend(body);
|
||||
};
|
||||
|
||||
res.json = function(body?: any) {
|
||||
res.json = function (body?: any) {
|
||||
clearTimeoutAndLog();
|
||||
return originalJson(body);
|
||||
};
|
||||
|
||||
res.end = function(chunk?: any, encoding?: any, callback?: any) {
|
||||
res.end = function (chunk?: any, encoding?: any, callback?: any) {
|
||||
clearTimeoutAndLog();
|
||||
return originalEnd(chunk, encoding, callback);
|
||||
};
|
||||
@@ -100,7 +100,7 @@ export function requestTimeout(timeoutMs: number = DEFAULT_TIMEOUT_MS) {
|
||||
url: req.url,
|
||||
elapsed: `${elapsed}ms`,
|
||||
timeout: `${timeoutMs}ms`,
|
||||
percentage: `${Math.round((elapsed / timeoutMs) * 100)}%`
|
||||
percentage: `${Math.round((elapsed / timeoutMs) * 100)}%`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -115,7 +115,7 @@ export function requestTimeout(timeoutMs: number = DEFAULT_TIMEOUT_MS) {
|
||||
if (isTimedOut) {
|
||||
logger.info('[Request Aborted] Client closed connection after timeout', {
|
||||
method: req.method,
|
||||
url: req.url
|
||||
url: req.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -142,5 +142,5 @@ export const TimeoutConfig = {
|
||||
LONG: 120000,
|
||||
|
||||
/** Extended timeout for batch operations (300s / 5 minutes) */
|
||||
EXTENDED: 300000
|
||||
EXTENDED: 300000,
|
||||
};
|
||||
|
||||
@@ -52,9 +52,7 @@ export function validateRequest<T extends z.ZodType>(
|
||||
if (error instanceof z.ZodError) {
|
||||
// Extract first validation error message
|
||||
const firstError = error.errors[0];
|
||||
const message = firstError
|
||||
? `Validation error: ${firstError.message}`
|
||||
: 'Validation error';
|
||||
const message = firstError ? `Validation error: ${firstError.message}` : 'Validation error';
|
||||
|
||||
return next(new AppError(400, message));
|
||||
}
|
||||
@@ -104,9 +102,7 @@ export function validateMultiple(schemas: Partial<Record<RequestProperty, z.ZodT
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const firstError = error.errors[0];
|
||||
const message = firstError
|
||||
? `Validation error: ${firstError.message}`
|
||||
: 'Validation error';
|
||||
const message = firstError ? `Validation error: ${firstError.message}` : 'Validation error';
|
||||
|
||||
return next(new AppError(400, message));
|
||||
}
|
||||
|
||||
@@ -4,54 +4,64 @@ This directory contains SQL migration scripts for the user database (`user.db`).
|
||||
|
||||
## Current Migration Strategy (Simplified)
|
||||
|
||||
**Date:** 2026-01-30
|
||||
**Status:** Consolidated into single migration file
|
||||
**Date:** 2026-01-30 **Status:** Consolidated into single migration file
|
||||
|
||||
All previous migrations have been consolidated into a single comprehensive migration file:
|
||||
All previous migrations have been consolidated into a single comprehensive
|
||||
migration file:
|
||||
|
||||
- **`001_complete_schema.sql`** - Complete database schema with all tables, indexes, triggers, seed data
|
||||
- **`001_complete_schema.sql`** - Complete database schema with all tables,
|
||||
indexes, triggers, seed data
|
||||
|
||||
This migration is **idempotent** and safe to run multiple times.
|
||||
|
||||
### What's Included:
|
||||
|
||||
✅ **All Tables** (15 total):
|
||||
- Users & Authentication (users, roles, permissions, role_permissions, refresh_tokens)
|
||||
|
||||
- Users & Authentication (users, roles, permissions, role_permissions,
|
||||
refresh_tokens)
|
||||
- Activity Tracking (activity_logs, login_attempts)
|
||||
- Game Play Tracking (user_game_plays, user_game_stats, user_stats)
|
||||
- User Data (user_settings, user_playlists, user_playlist_games, user_favorites)
|
||||
- System (system_settings, job_execution_logs)
|
||||
|
||||
✅ **All Indexes** (50+ optimized indexes):
|
||||
|
||||
- Basic indexes for foreign keys and lookups
|
||||
- Composite indexes for complex queries
|
||||
- Partial indexes for conditional queries
|
||||
- Performance indexes for frequently-accessed patterns
|
||||
|
||||
✅ **All Triggers**:
|
||||
|
||||
- Auto-update playlist game_count
|
||||
|
||||
✅ **All Seed Data**:
|
||||
|
||||
- Default roles (admin, user, guest)
|
||||
- Default permissions (18 permissions)
|
||||
- Role-permission mappings
|
||||
- System settings (35+ default settings)
|
||||
|
||||
✅ **Playlist Sharing Features**:
|
||||
|
||||
- share_token, share_expires_at, show_owner columns
|
||||
- Unique indexes for secure sharing
|
||||
|
||||
## Migration Execution
|
||||
|
||||
Migrations are automatically executed on server startup by `UserDatabaseService.ts`:
|
||||
Migrations are automatically executed on server startup by
|
||||
`UserDatabaseService.ts`:
|
||||
|
||||
1. **Bootstrap** - Creates migration registry table (`migrations_applied`)
|
||||
2. **Schema Creation** - Runs `001_complete_schema.sql` if database doesn't exist
|
||||
2. **Schema Creation** - Runs `001_complete_schema.sql` if database doesn't
|
||||
exist
|
||||
3. **Idempotent** - Safe to run multiple times (uses `IF NOT EXISTS` everywhere)
|
||||
|
||||
### Migration Registry (`bootstrap.sql`)
|
||||
|
||||
The migration system tracks applied migrations in the `migrations_applied` table:
|
||||
The migration system tracks applied migrations in the `migrations_applied`
|
||||
table:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS migrations_applied (
|
||||
@@ -65,6 +75,7 @@ CREATE TABLE IF NOT EXISTS migrations_applied (
|
||||
```
|
||||
|
||||
This allows:
|
||||
|
||||
- Detecting which migrations have been applied
|
||||
- Preventing duplicate execution
|
||||
- Verifying migration integrity with checksums
|
||||
@@ -72,7 +83,8 @@ This allows:
|
||||
|
||||
## Archived Migrations
|
||||
|
||||
Previous migration files (001-014) have been moved to `archived/` directory for reference:
|
||||
Previous migration files (001-014) have been moved to `archived/` directory for
|
||||
reference:
|
||||
|
||||
- `001_initialize_schema_OLD.sql` - Original schema definition
|
||||
- `002_seed_default_data.sql` - Original seed data
|
||||
@@ -91,6 +103,7 @@ When you need to modify the database schema after deployment:
|
||||
For new columns, indexes, or seed data:
|
||||
|
||||
1. **Create a new migration file** with the next version number:
|
||||
|
||||
```bash
|
||||
cd backend/src/migrations
|
||||
touch 002_your_migration_name.sql
|
||||
@@ -99,6 +112,7 @@ For new columns, indexes, or seed data:
|
||||
Current version: **001** (next available: **002**)
|
||||
|
||||
2. **Write idempotent SQL** using defensive checks:
|
||||
|
||||
```sql
|
||||
-- Migration 002: Description of changes
|
||||
-- Created: 2026-MM-DD
|
||||
@@ -114,6 +128,7 @@ For new columns, indexes, or seed data:
|
||||
```
|
||||
|
||||
3. **Update `UserDatabaseService.ts`** to run the new migration:
|
||||
|
||||
```typescript
|
||||
// In runMigrations() method, after the 001 migration:
|
||||
|
||||
@@ -258,6 +273,7 @@ COMMIT;
|
||||
### Migration checksum mismatch
|
||||
|
||||
If you see checksum warnings:
|
||||
|
||||
- Migration file was modified after being applied
|
||||
- This is usually safe if changes are additive
|
||||
- Check `migrations_applied` table for recorded checksum
|
||||
@@ -315,6 +331,7 @@ VALUES
|
||||
### Consolidation (2026-01-30)
|
||||
|
||||
Previous migration strategy used 14+ separate files:
|
||||
|
||||
- `001_initialize_schema.sql` - Base tables
|
||||
- `002_seed_default_data.sql` - Seed data
|
||||
- `003-013` - Incremental changes
|
||||
@@ -323,6 +340,7 @@ Previous migration strategy used 14+ separate files:
|
||||
**Consolidated into:** Single `001_complete_schema.sql` file
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- ✅ Simpler deployment (one file vs many)
|
||||
- ✅ Guaranteed consistency (all-or-nothing)
|
||||
- ✅ Easier testing (single execution)
|
||||
|
||||
@@ -1,42 +1,46 @@
|
||||
# Archived Migrations
|
||||
|
||||
This directory contains historical migration files that have been consolidated into the current migration system.
|
||||
This directory contains historical migration files that have been consolidated
|
||||
into the current migration system.
|
||||
|
||||
## Migration Consolidation (2026-01-28)
|
||||
|
||||
The original 16 migration files (001-016) have been consolidated into:
|
||||
|
||||
- `001_initialize_schema.sql` - Complete database schema with all tables
|
||||
- `002_seed_default_data.sql` - All default roles, permissions, and settings
|
||||
|
||||
These archived files are kept for reference and historical documentation purposes only. **They are no longer executed** by the migration system.
|
||||
These archived files are kept for reference and historical documentation
|
||||
purposes only. **They are no longer executed** by the migration system.
|
||||
|
||||
## Historical Migrations
|
||||
|
||||
| Migration | Description | Consolidated Into |
|
||||
|-----------|-------------|-------------------|
|
||||
| 001_user-schema.sql | Base schema with users, roles, permissions, etc. | 001_initialize_schema.sql |
|
||||
| 002_create-user-settings.sql | User settings table (key-value) | 001_initialize_schema.sql |
|
||||
| 003_create-system-settings.sql | System settings table + seed data | 001/002_initialize |
|
||||
| 004_add-validation-schemas.sql | JSON schema validation for settings | 002_seed_default_data.sql |
|
||||
| 005_user-playlists-and-favorites.sql | User playlists and favorites tables | 001_initialize_schema.sql |
|
||||
| 006_create-jobs-settings.sql | Background job settings | 002_seed_default_data.sql |
|
||||
| 007_create-job-execution-logs.sql | Job execution logs table | 001_initialize_schema.sql |
|
||||
| 008_convert-interval-to-cron.sql | Convert interval to cron expressions | 002_seed_default_data.sql |
|
||||
| 009_update-public-settings.sql | Update is_public flags | 002_seed_default_data.sql |
|
||||
| 010_make-features-public.sql | Make feature flags public | 002_seed_default_data.sql |
|
||||
| 011_make-game-settings-public.sql | Make game settings public | (Obsolete - see 012) |
|
||||
| 012_remove-game-settings.sql | Remove game settings | Not included |
|
||||
| 013_add-datetime-format-settings.sql | Add date/time format settings | 002_seed_default_data.sql |
|
||||
| 014_add-user-datetime-format-settings.sql | User date/time format settings | (Handled in app code) |
|
||||
| 015_standardize-datetime-to-iso8601.sql | Convert to ISO 8601 UTC format | 001_initialize_schema.sql |
|
||||
| 016_add-ruffle-update-job-settings.sql | Ruffle update job settings | 002_seed_default_data.sql |
|
||||
| Migration | Description | Consolidated Into |
|
||||
| ----------------------------------------- | ------------------------------------------------ | ------------------------- |
|
||||
| 001_user-schema.sql | Base schema with users, roles, permissions, etc. | 001_initialize_schema.sql |
|
||||
| 002_create-user-settings.sql | User settings table (key-value) | 001_initialize_schema.sql |
|
||||
| 003_create-system-settings.sql | System settings table + seed data | 001/002_initialize |
|
||||
| 004_add-validation-schemas.sql | JSON schema validation for settings | 002_seed_default_data.sql |
|
||||
| 005_user-playlists-and-favorites.sql | User playlists and favorites tables | 001_initialize_schema.sql |
|
||||
| 006_create-jobs-settings.sql | Background job settings | 002_seed_default_data.sql |
|
||||
| 007_create-job-execution-logs.sql | Job execution logs table | 001_initialize_schema.sql |
|
||||
| 008_convert-interval-to-cron.sql | Convert interval to cron expressions | 002_seed_default_data.sql |
|
||||
| 009_update-public-settings.sql | Update is_public flags | 002_seed_default_data.sql |
|
||||
| 010_make-features-public.sql | Make feature flags public | 002_seed_default_data.sql |
|
||||
| 011_make-game-settings-public.sql | Make game settings public | (Obsolete - see 012) |
|
||||
| 012_remove-game-settings.sql | Remove game settings | Not included |
|
||||
| 013_add-datetime-format-settings.sql | Add date/time format settings | 002_seed_default_data.sql |
|
||||
| 014_add-user-datetime-format-settings.sql | User date/time format settings | (Handled in app code) |
|
||||
| 015_standardize-datetime-to-iso8601.sql | Convert to ISO 8601 UTC format | 001_initialize_schema.sql |
|
||||
| 016_add-ruffle-update-job-settings.sql | Ruffle update job settings | 002_seed_default_data.sql |
|
||||
|
||||
## New Migration System
|
||||
|
||||
The new system uses a **migration registry** approach:
|
||||
|
||||
1. **migrations table** tracks applied migrations by name with checksums
|
||||
2. **Backward compatibility** - Existing databases are detected and marked as migrated
|
||||
2. **Backward compatibility** - Existing databases are detected and marked as
|
||||
migrated
|
||||
3. **Fresh installs** use consolidated schema for faster setup
|
||||
4. **Future migrations** can be added as new numbered files (003, 004, etc.)
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ export class ConfigManager {
|
||||
infinityServerURL: 'https://infinity.flashpointarchive.org/Flashpoint/Legacy/htdocs/',
|
||||
externalFilePaths: [
|
||||
'https://infinity.flashpointarchive.org/Flashpoint/Legacy/htdocs',
|
||||
'https://infinity.unstable.life/Flashpoint/Legacy/htdocs/'
|
||||
'https://infinity.unstable.life/Flashpoint/Legacy/htdocs/',
|
||||
],
|
||||
mad4fpEnabled: false,
|
||||
mad4fpPaths: [],
|
||||
|
||||
@@ -187,7 +187,10 @@ export class GameZipServer {
|
||||
* 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> {
|
||||
private async handleFileRequest(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse
|
||||
): Promise<void> {
|
||||
if (!req.url) {
|
||||
this.sendError(res, 400, 'Bad Request: No URL');
|
||||
return;
|
||||
@@ -233,7 +236,9 @@ export class GameZipServer {
|
||||
const contentType = mimeLookup(ext) || 'application/octet-stream';
|
||||
|
||||
// Send file
|
||||
logger.info(`[GameZipServer] ✓ Serving from ZIP ${result.mountId}: ${relPath} (${result.data.length} bytes)`);
|
||||
logger.info(
|
||||
`[GameZipServer] ✓ Serving from ZIP ${result.mountId}: ${relPath} (${result.data.length} bytes)`
|
||||
);
|
||||
|
||||
// CORS headers
|
||||
if (this.settings.allowCrossDomain) {
|
||||
@@ -258,7 +263,7 @@ export class GameZipServer {
|
||||
private readBody(req: http.IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = '';
|
||||
req.on('data', chunk => {
|
||||
req.on('data', (chunk) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
req.on('end', () => {
|
||||
|
||||
@@ -68,13 +68,16 @@ export class LegacyServer {
|
||||
* 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 }> {
|
||||
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'
|
||||
type: 'exact',
|
||||
});
|
||||
|
||||
// If URL has query string, try without it
|
||||
@@ -82,7 +85,7 @@ export class LegacyServer {
|
||||
if (pathWithoutQuery !== relPath) {
|
||||
candidates.push({
|
||||
path: path.join(this.settings.legacyHTDOCSPath, pathWithoutQuery),
|
||||
type: 'exact-no-query'
|
||||
type: 'exact-no-query',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,13 +93,13 @@ export class LegacyServer {
|
||||
for (const override of this.settings.legacyOverridePaths) {
|
||||
candidates.push({
|
||||
path: path.join(this.settings.legacyHTDOCSPath, override, relPath),
|
||||
type: `override:${override}`
|
||||
type: `override:${override}`,
|
||||
});
|
||||
|
||||
if (pathWithoutQuery !== relPath) {
|
||||
candidates.push({
|
||||
path: path.join(this.settings.legacyHTDOCSPath, override, pathWithoutQuery),
|
||||
type: `override:${override}-no-query`
|
||||
type: `override:${override}-no-query`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -105,13 +108,13 @@ export class LegacyServer {
|
||||
if (this.isScriptUrl(urlPath)) {
|
||||
candidates.push({
|
||||
path: path.join(this.settings.legacyCGIBINPath, relPath),
|
||||
type: 'cgi-bin'
|
||||
type: 'cgi-bin',
|
||||
});
|
||||
|
||||
if (pathWithoutQuery !== relPath) {
|
||||
candidates.push({
|
||||
path: path.join(this.settings.legacyCGIBINPath, pathWithoutQuery),
|
||||
type: 'cgi-bin-no-query'
|
||||
type: 'cgi-bin-no-query',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -120,14 +123,14 @@ export class LegacyServer {
|
||||
for (const ext of INDEX_EXTENSIONS) {
|
||||
candidates.push({
|
||||
path: path.join(this.settings.legacyHTDOCSPath, relPath, `index.${ext}`),
|
||||
type: `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}`
|
||||
type: `index-override:${override}:${ext}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -170,7 +173,7 @@ export class LegacyServer {
|
||||
return {
|
||||
data,
|
||||
contentType,
|
||||
source: 'local-htdocs'
|
||||
source: 'local-htdocs',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -175,7 +175,7 @@ export const CUSTOM_MIME_TYPES: { [key: string]: string } = {
|
||||
wrl: 'model/vrml',
|
||||
wvr: 'x-world/x-wvr',
|
||||
x3db: 'model/x3d+binary',
|
||||
xvr: 'x-world/x-xvr'
|
||||
xvr: 'x-world/x-xvr',
|
||||
};
|
||||
|
||||
// Standard MIME types for common web files
|
||||
@@ -230,7 +230,7 @@ const STANDARD_MIME_TYPES: { [key: string]: string } = {
|
||||
// Binary
|
||||
bin: 'application/octet-stream',
|
||||
exe: 'application/octet-stream',
|
||||
dll: 'application/octet-stream'
|
||||
dll: 'application/octet-stream',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -72,7 +72,6 @@ export class ProxyRequestHandler {
|
||||
|
||||
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);
|
||||
|
||||
@@ -119,7 +118,12 @@ export class ProxyRequestHandler {
|
||||
/**
|
||||
* Send successful response
|
||||
*/
|
||||
private sendResponse(res: ServerResponse, data: Buffer, contentType: string, source: string): void {
|
||||
private sendResponse(
|
||||
res: ServerResponse,
|
||||
data: Buffer,
|
||||
contentType: string,
|
||||
source: string
|
||||
): void {
|
||||
// CORS headers
|
||||
if (this.settings.allowCrossDomain) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
|
||||
+41
-21
@@ -23,7 +23,7 @@ export class GameProxyServer {
|
||||
this.app = express();
|
||||
this.config = {
|
||||
...proxyConfig,
|
||||
chunkSize: proxyConfig.chunkSize || 8192 // 8KB default chunk size
|
||||
chunkSize: proxyConfig.chunkSize || 8192, // 8KB default chunk size
|
||||
};
|
||||
this.setupMiddleware();
|
||||
this.setupRoutes();
|
||||
@@ -35,7 +35,10 @@ export class GameProxyServer {
|
||||
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');
|
||||
res.header(
|
||||
'Access-Control-Allow-Headers',
|
||||
'Origin, X-Requested-With, Content-Type, Accept'
|
||||
);
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.sendStatus(200);
|
||||
@@ -68,8 +71,8 @@ export class GameProxyServer {
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: 'Proxy server error',
|
||||
statusCode: 500
|
||||
}
|
||||
statusCode: 500,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -113,8 +116,8 @@ export class GameProxyServer {
|
||||
error: {
|
||||
message: 'File not found',
|
||||
path: requestPath,
|
||||
statusCode: 404
|
||||
}
|
||||
statusCode: 404,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -159,7 +162,7 @@ export class GameProxyServer {
|
||||
|
||||
const pathsToTry = [
|
||||
path.join(this.config.legacyHTDOCSPath, requestPath),
|
||||
path.join(this.config.gameDataPath, requestPath)
|
||||
path.join(this.config.gameDataPath, requestPath),
|
||||
];
|
||||
|
||||
for (const filePath of pathsToTry) {
|
||||
@@ -201,7 +204,9 @@ export class GameProxyServer {
|
||||
const result = await zipManager.findFile(requestPath);
|
||||
|
||||
if (result) {
|
||||
logger.info(`[Proxy] ✓ Found in ZIP ${result.mountId}: ${requestPath} (${result.data.length} bytes)`);
|
||||
logger.info(
|
||||
`[Proxy] ✓ Found in ZIP ${result.mountId}: ${requestPath} (${result.data.length} bytes)`
|
||||
);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
@@ -231,7 +236,7 @@ export class GameProxyServer {
|
||||
|
||||
// Find all .zip files in Data/Games
|
||||
const files = fs.readdirSync(gamesPath);
|
||||
const zipFiles = files.filter(f => f.toLowerCase().endsWith('.zip'));
|
||||
const zipFiles = files.filter((f) => f.toLowerCase().endsWith('.zip'));
|
||||
|
||||
if (zipFiles.length === 0) {
|
||||
logger.info(`[Proxy] No ZIP files found in ${gamesPath}`);
|
||||
@@ -274,7 +279,11 @@ export class GameProxyServer {
|
||||
/**
|
||||
* Serve content from ZIP file
|
||||
*/
|
||||
private async serveZipContent(content: Buffer, requestPath: string, res: Response): Promise<void> {
|
||||
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();
|
||||
@@ -287,7 +296,9 @@ export class GameProxyServer {
|
||||
|
||||
res.send(content);
|
||||
|
||||
logger.debug(`[Proxy] Served from ZIP: ${requestPath} (${mimeType}, ${content.length} bytes)`);
|
||||
logger.debug(
|
||||
`[Proxy] Served from ZIP: ${requestPath} (${mimeType}, ${content.length} bytes)`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('[Proxy] Error serving ZIP content:', error);
|
||||
throw error;
|
||||
@@ -307,7 +318,7 @@ export class GameProxyServer {
|
||||
// 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 => {
|
||||
const encodedSegments = pathSegments.map((segment) => {
|
||||
if (!segment) return segment; // Preserve empty segments (leading/trailing slashes)
|
||||
|
||||
try {
|
||||
@@ -333,7 +344,9 @@ export class GameProxyServer {
|
||||
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'}`);
|
||||
logger.warn(
|
||||
`[Proxy] Failed to encode URL for CDN: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
return url;
|
||||
}
|
||||
}
|
||||
@@ -354,8 +367,8 @@ export class GameProxyServer {
|
||||
maxRedirects: 5,
|
||||
headers: {
|
||||
// Match PHP router's Accept-Encoding header
|
||||
'Accept-Encoding': 'identity'
|
||||
}
|
||||
'Accept-Encoding': 'identity',
|
||||
},
|
||||
// Note: Removed validateStatus to allow axios to follow redirects automatically
|
||||
});
|
||||
|
||||
@@ -368,7 +381,9 @@ export class GameProxyServer {
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info(`[Proxy] ✓ Successfully fetched from external: ${encodedUrl} (${buffer.length} bytes)`);
|
||||
logger.info(
|
||||
`[Proxy] ✓ Successfully fetched from external: ${encodedUrl} (${buffer.length} bytes)`
|
||||
);
|
||||
|
||||
// Cache the downloaded file locally for future use
|
||||
await this.cacheDownloadedFile(requestPath, buffer);
|
||||
@@ -426,7 +441,7 @@ export class GameProxyServer {
|
||||
|
||||
// Stream file with chunking for large files
|
||||
const readStream = fs.createReadStream(filePath, {
|
||||
highWaterMark: this.config.chunkSize
|
||||
highWaterMark: this.config.chunkSize,
|
||||
});
|
||||
|
||||
readStream.on('error', (error) => {
|
||||
@@ -435,8 +450,8 @@ export class GameProxyServer {
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: 'Error streaming file',
|
||||
statusCode: 500
|
||||
}
|
||||
statusCode: 500,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -477,8 +492,13 @@ export class GameProxyServer {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -110,9 +110,9 @@ export class ZipManager {
|
||||
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
|
||||
`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
|
||||
];
|
||||
|
||||
@@ -176,7 +176,7 @@ export class ZipManager {
|
||||
|
||||
if (pattern) {
|
||||
const regex = new RegExp(pattern, 'i');
|
||||
files = files.filter(f => regex.test(f));
|
||||
files = files.filter((f) => regex.test(f));
|
||||
}
|
||||
|
||||
return files;
|
||||
@@ -199,7 +199,7 @@ export class ZipManager {
|
||||
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));
|
||||
const promises = Array.from(this.mountedZips.keys()).map((id) => this.unmount(id));
|
||||
await Promise.all(promises);
|
||||
|
||||
logger.info(`[ZipManager] ✓ All ZIPs unmounted`);
|
||||
|
||||
@@ -31,7 +31,7 @@ const activityQuerySchema = z.object({
|
||||
startDate: z.string().optional(),
|
||||
endDate: z.string().optional(),
|
||||
sortBy: z.enum(['createdAt', 'username', 'action', 'resource', 'ipAddress']).default('createdAt'),
|
||||
sortOrder: z.enum(['asc', 'desc']).default('desc')
|
||||
sortOrder: z.enum(['asc', 'desc']).default('desc'),
|
||||
});
|
||||
|
||||
// Apply feature flag check to all routes in this router
|
||||
@@ -54,11 +54,22 @@ router.get(
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid query parameters',
|
||||
details: queryResult.error.errors
|
||||
details: queryResult.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
const { page, limit, userId, username, action, resource, startDate, endDate, sortBy, sortOrder } = queryResult.data;
|
||||
const {
|
||||
page,
|
||||
limit,
|
||||
userId,
|
||||
username,
|
||||
action,
|
||||
resource,
|
||||
startDate,
|
||||
endDate,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
} = queryResult.data;
|
||||
|
||||
// Build filters object
|
||||
const filters: any = {};
|
||||
@@ -85,12 +96,13 @@ router.get(
|
||||
requirePermission('activities.read'),
|
||||
asyncHandler(async (req, res) => {
|
||||
const timeRange = (req.query.timeRange as '24h' | '7d' | '30d') || DEFAULT_TIME_RANGE;
|
||||
const customRange = req.query.startDate && req.query.endDate
|
||||
? {
|
||||
startDate: req.query.startDate as string,
|
||||
endDate: req.query.endDate as string
|
||||
}
|
||||
: undefined;
|
||||
const customRange =
|
||||
req.query.startDate && req.query.endDate
|
||||
? {
|
||||
startDate: req.query.startDate as string,
|
||||
endDate: req.query.endDate as string,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const stats = await activityService.getStats(timeRange, customRange);
|
||||
|
||||
@@ -99,8 +111,8 @@ router.get(
|
||||
data: stats,
|
||||
meta: {
|
||||
calculatedAt: new Date().toISOString(),
|
||||
timeRange
|
||||
}
|
||||
timeRange,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
@@ -120,7 +132,7 @@ router.get(
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
...result
|
||||
...result,
|
||||
});
|
||||
})
|
||||
);
|
||||
@@ -141,7 +153,7 @@ router.get(
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
...result
|
||||
...result,
|
||||
});
|
||||
})
|
||||
);
|
||||
@@ -163,7 +175,7 @@ router.get(
|
||||
if (!['resource', 'user', 'ip'].includes(groupBy)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid groupBy parameter. Must be one of: resource, user, ip'
|
||||
error: 'Invalid groupBy parameter. Must be one of: resource, user, ip',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -171,7 +183,7 @@ router.get(
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
...result
|
||||
...result,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
@@ -17,17 +17,20 @@ const updateSettingsSchema = z.object({
|
||||
requireEmailVerification: z.boolean().optional(),
|
||||
sessionTimeoutMinutes: z.number().int().positive().optional(),
|
||||
maxLoginAttempts: z.number().int().positive().optional(),
|
||||
lockoutDurationMinutes: z.number().int().positive().optional()
|
||||
lockoutDurationMinutes: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/settings/auth
|
||||
* Get current auth settings (public endpoint)
|
||||
*/
|
||||
router.get('/', asyncHandler(async (req, res) => {
|
||||
const settings = authSettingsService.getSettings();
|
||||
res.json(settings);
|
||||
}));
|
||||
router.get(
|
||||
'/',
|
||||
asyncHandler(async (req, res) => {
|
||||
const settings = authSettingsService.getSettings();
|
||||
res.json(settings);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* PATCH /api/settings/auth
|
||||
|
||||
+126
-111
@@ -21,8 +21,8 @@ const loginLimiter = rateLimit({
|
||||
message: {
|
||||
error: {
|
||||
message: 'Too many login attempts from this IP, please try again after 15 minutes',
|
||||
statusCode: 429
|
||||
}
|
||||
statusCode: 429,
|
||||
},
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
@@ -32,10 +32,10 @@ const loginLimiter = rateLimit({
|
||||
res.status(429).json({
|
||||
error: {
|
||||
message: 'Too many login attempts from this IP, please try again after 15 minutes',
|
||||
statusCode: 429
|
||||
}
|
||||
statusCode: 429,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Moderate limits on registration
|
||||
@@ -45,8 +45,8 @@ const registerLimiter = rateLimit({
|
||||
message: {
|
||||
error: {
|
||||
message: 'Too many registration attempts from this IP, please try again after an hour',
|
||||
statusCode: 429
|
||||
}
|
||||
statusCode: 429,
|
||||
},
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
@@ -55,26 +55,26 @@ const registerLimiter = rateLimit({
|
||||
res.status(429).json({
|
||||
error: {
|
||||
message: 'Too many registration attempts from this IP, please try again after an hour',
|
||||
statusCode: 429
|
||||
}
|
||||
statusCode: 429,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Validation schemas
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(3).max(50),
|
||||
password: z.string().min(6)
|
||||
password: z.string().min(6),
|
||||
});
|
||||
|
||||
const registerSchema = z.object({
|
||||
username: z.string().min(3).max(50),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6)
|
||||
password: z.string().min(6),
|
||||
});
|
||||
|
||||
const refreshTokenSchema = z.object({
|
||||
refreshToken: z.string()
|
||||
refreshToken: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -88,7 +88,7 @@ router.get('/setup-status', (req, res) => {
|
||||
needsSetup,
|
||||
message: needsSetup
|
||||
? 'No users found. Please create an administrator account.'
|
||||
: 'System is configured'
|
||||
: 'System is configured',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,66 +96,71 @@ router.get('/setup-status', (req, res) => {
|
||||
* POST /api/auth/login
|
||||
* Login with username and password
|
||||
*/
|
||||
router.post('/login', loginLimiter, asyncHandler(async (req, res) => {
|
||||
try {
|
||||
const credentials = loginSchema.parse(req.body);
|
||||
const ipAddress = req.ip || '';
|
||||
|
||||
logger.info(`[Auth] Login attempt for user: ${credentials.username} from ${ipAddress}`);
|
||||
|
||||
const result = await authService.login(credentials, ipAddress);
|
||||
|
||||
// Log activity
|
||||
await activityService.log({
|
||||
userId: result.user.id,
|
||||
username: result.user.username,
|
||||
action: 'login',
|
||||
resource: 'auth',
|
||||
ipAddress,
|
||||
userAgent: req.headers['user-agent']
|
||||
});
|
||||
|
||||
logger.info(`[Auth] Login successful for user: ${credentials.username}`);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[Auth] Validation error during login: ${error.errors[0].message}`);
|
||||
throw new AppError(400, `Validation error: ${error.errors[0].message}`);
|
||||
}
|
||||
|
||||
// Log failed login attempt
|
||||
const ipAddress = req.ip || '';
|
||||
const username = req.body.username || 'unknown';
|
||||
|
||||
router.post(
|
||||
'/login',
|
||||
loginLimiter,
|
||||
asyncHandler(async (req, res) => {
|
||||
try {
|
||||
const credentials = loginSchema.parse(req.body);
|
||||
const ipAddress = req.ip || '';
|
||||
|
||||
logger.info(`[Auth] Login attempt for user: ${credentials.username} from ${ipAddress}`);
|
||||
|
||||
const result = await authService.login(credentials, ipAddress);
|
||||
|
||||
// Log activity
|
||||
await activityService.log({
|
||||
userId: undefined,
|
||||
username,
|
||||
action: 'auth.login.failed',
|
||||
userId: result.user.id,
|
||||
username: result.user.username,
|
||||
action: 'login',
|
||||
resource: 'auth',
|
||||
details: {
|
||||
reason: error instanceof Error && error.message.includes('Invalid')
|
||||
? 'invalid_credentials'
|
||||
: 'other',
|
||||
errorMessage: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
ipAddress,
|
||||
userAgent: req.headers['user-agent']
|
||||
userAgent: req.headers['user-agent'],
|
||||
});
|
||||
} catch (logError) {
|
||||
// Don't fail request if logging fails
|
||||
logger.error('[Auth] Failed to log failed login attempt:', logError);
|
||||
|
||||
logger.info(`[Auth] Login successful for user: ${credentials.username}`);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[Auth] Validation error during login: ${error.errors[0].message}`);
|
||||
throw new AppError(400, `Validation error: ${error.errors[0].message}`);
|
||||
}
|
||||
|
||||
// Log failed login attempt
|
||||
const ipAddress = req.ip || '';
|
||||
const username = req.body.username || 'unknown';
|
||||
|
||||
try {
|
||||
await activityService.log({
|
||||
userId: undefined,
|
||||
username,
|
||||
action: 'auth.login.failed',
|
||||
resource: 'auth',
|
||||
details: {
|
||||
reason:
|
||||
error instanceof Error && error.message.includes('Invalid')
|
||||
? 'invalid_credentials'
|
||||
: 'other',
|
||||
errorMessage: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
ipAddress,
|
||||
userAgent: req.headers['user-agent'],
|
||||
});
|
||||
} catch (logError) {
|
||||
// Don't fail request if logging fails
|
||||
logger.error('[Auth] Failed to log failed login attempt:', logError);
|
||||
}
|
||||
|
||||
// Log the error with full details before passing to error handler
|
||||
logger.error(`[Auth] Login failed for user: ${username}`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Log the error with full details before passing to error handler
|
||||
logger.error(`[Auth] Login failed for user: ${username}`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}));
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/auth/register
|
||||
@@ -167,40 +172,44 @@ router.post('/login', loginLimiter, asyncHandler(async (req, res) => {
|
||||
*
|
||||
* The AuthService.register() method automatically detects which scenario applies
|
||||
*/
|
||||
router.post('/register', registerLimiter, asyncHandler(async (req, res) => {
|
||||
try {
|
||||
const data = registerSchema.parse(req.body);
|
||||
router.post(
|
||||
'/register',
|
||||
registerLimiter,
|
||||
asyncHandler(async (req, res) => {
|
||||
try {
|
||||
const data = registerSchema.parse(req.body);
|
||||
|
||||
logger.info(`[Auth] Registration attempt for user: ${data.username}`);
|
||||
logger.info(`[Auth] Registration attempt for user: ${data.username}`);
|
||||
|
||||
const result = await authService.register(data);
|
||||
const result = await authService.register(data);
|
||||
|
||||
// Log activity
|
||||
await activityService.log({
|
||||
userId: result.user.id,
|
||||
username: result.user.username,
|
||||
action: 'register',
|
||||
resource: 'auth',
|
||||
ipAddress: req.ip,
|
||||
userAgent: req.headers['user-agent']
|
||||
});
|
||||
// Log activity
|
||||
await activityService.log({
|
||||
userId: result.user.id,
|
||||
username: result.user.username,
|
||||
action: 'register',
|
||||
resource: 'auth',
|
||||
ipAddress: req.ip,
|
||||
userAgent: req.headers['user-agent'],
|
||||
});
|
||||
|
||||
logger.info(`[Auth] Registration successful for user: ${data.username}`);
|
||||
res.status(201).json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[Auth] Validation error during registration: ${error.errors[0].message}`);
|
||||
throw new AppError(400, `Validation error: ${error.errors[0].message}`);
|
||||
logger.info(`[Auth] Registration successful for user: ${data.username}`);
|
||||
res.status(201).json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[Auth] Validation error during registration: ${error.errors[0].message}`);
|
||||
throw new AppError(400, `Validation error: ${error.errors[0].message}`);
|
||||
}
|
||||
|
||||
logger.error(`[Auth] Registration failed for user: ${req.body.username}`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.error(`[Auth] Registration failed for user: ${req.body.username}`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}));
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
@@ -222,28 +231,34 @@ router.post(
|
||||
* POST /api/auth/refresh
|
||||
* Refresh access token using refresh token
|
||||
*/
|
||||
router.post('/refresh', asyncHandler(async (req, res) => {
|
||||
const { refreshToken } = refreshTokenSchema.parse(req.body);
|
||||
router.post(
|
||||
'/refresh',
|
||||
asyncHandler(async (req, res) => {
|
||||
const { refreshToken } = refreshTokenSchema.parse(req.body);
|
||||
|
||||
const tokens = await authService.refreshToken(refreshToken);
|
||||
const tokens = await authService.refreshToken(refreshToken);
|
||||
|
||||
res.json(tokens);
|
||||
}));
|
||||
res.json(tokens);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/auth/me
|
||||
* Get current authenticated user
|
||||
*/
|
||||
router.get('/me', asyncHandler(async (req, res) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new AppError(401, 'No token provided');
|
||||
}
|
||||
router.get(
|
||||
'/me',
|
||||
asyncHandler(async (req, res) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new AppError(401, 'No token provided');
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const user = await authService.verifyAccessToken(token);
|
||||
const token = authHeader.substring(7);
|
||||
const user = await authService.verifyAccessToken(token);
|
||||
|
||||
res.json(user);
|
||||
}));
|
||||
res.json(user);
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -24,13 +24,13 @@ router.get(
|
||||
size: gameSearchStats.primary.size,
|
||||
max: gameSearchStats.primary.max,
|
||||
ttl: gameSearchStats.primary.ttl,
|
||||
hitRate: gameSearchStats.primary.size > 0 ? 'active' : 'idle'
|
||||
hitRate: gameSearchStats.primary.size > 0 ? 'active' : 'idle',
|
||||
},
|
||||
permissions: {
|
||||
enabled: true,
|
||||
ttl: '5 minutes',
|
||||
note: 'Permission cache size not exposed'
|
||||
}
|
||||
note: 'Permission cache size not exposed',
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
@@ -68,7 +68,7 @@ router.post(
|
||||
|
||||
res.json({
|
||||
message: 'Cache cleared successfully',
|
||||
cleared: results
|
||||
cleared: results,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ router.get(
|
||||
'/',
|
||||
optionalAuth,
|
||||
logActivity('playlists.community.browse', 'community_playlists', (req, res) => ({
|
||||
playlistCount: res.locals.playlistCount || 0
|
||||
playlistCount: res.locals.playlistCount || 0,
|
||||
})),
|
||||
asyncHandler(async (req, res) => {
|
||||
const playlists = await communityPlaylistService.fetchCommunityPlaylists();
|
||||
@@ -36,7 +36,7 @@ router.post(
|
||||
downloadUrl: req.body.downloadUrl,
|
||||
playlistTitle: res.locals.playlistTitle || null,
|
||||
playlistId: res.locals.playlistId || null,
|
||||
gameCount: res.locals.gameCount || 0
|
||||
gameCount: res.locals.gameCount || 0,
|
||||
})),
|
||||
asyncHandler(async (req, res) => {
|
||||
const { downloadUrl } = req.body;
|
||||
|
||||
@@ -29,22 +29,22 @@ router.post(
|
||||
requirePermission('settings.update'),
|
||||
logActivity('database.reload', 'database'),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
logger.info('Manual database reload requested');
|
||||
try {
|
||||
logger.info('Manual database reload requested');
|
||||
|
||||
await DatabaseService.reload();
|
||||
await DatabaseService.reload();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Database reloaded successfully from disk'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Manual database reload failed:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Database reloaded successfully from disk',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Manual database reload failed:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -69,28 +69,28 @@ router.get(
|
||||
requirePermission('settings.update'),
|
||||
logActivity('database.status', 'database'),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const { config } = require('../config');
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const { config } = require('../config');
|
||||
|
||||
const isConnected = DatabaseService.isConnected();
|
||||
const stats = fs.statSync(config.flashpointDbPath);
|
||||
const isConnected = DatabaseService.isConnected();
|
||||
const stats = fs.statSync(config.flashpointDbPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
connected: isConnected,
|
||||
dbPath: config.flashpointDbPath,
|
||||
fileSizeBytes: stats.size,
|
||||
fileSizeMB: (stats.size / (1024 * 1024)).toFixed(2),
|
||||
lastModified: stats.mtime.toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error getting database status:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
connected: isConnected,
|
||||
dbPath: config.flashpointDbPath,
|
||||
fileSizeBytes: stats.size,
|
||||
fileSizeMB: (stats.size / (1024 * 1024)).toFixed(2),
|
||||
lastModified: stats.mtime.toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error getting database status:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -57,7 +57,13 @@ router.post(
|
||||
const domain = domainService.addDomain(validation.data.hostname, req.user!.id);
|
||||
res.status(201).json(domain);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && (error.message.includes('already exists') || error.message.includes('must not') || error.message.includes('Invalid') || error.message.includes('cannot be empty'))) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.message.includes('already exists') ||
|
||||
error.message.includes('must not') ||
|
||||
error.message.includes('Invalid') ||
|
||||
error.message.includes('cannot be empty'))
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: { message: error.message },
|
||||
});
|
||||
|
||||
+254
-241
@@ -38,131 +38,132 @@ router.post(
|
||||
gameId: req.params.id,
|
||||
gameDataId: req.body.gameDataId || res.locals.gameDataId,
|
||||
gameTitle: res.locals.gameTitle,
|
||||
sources: res.locals.sources
|
||||
sources: res.locals.sources,
|
||||
})),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const gameId = req.params.id;
|
||||
const { gameDataId: requestedGameDataId } = req.body;
|
||||
try {
|
||||
const gameId = req.params.id;
|
||||
const { gameDataId: requestedGameDataId } = req.body;
|
||||
|
||||
logger.info('Download request received', { gameId, requestedGameDataId });
|
||||
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) {
|
||||
// Get game from database
|
||||
const game = await gameService.getGameById(gameId);
|
||||
if (!game) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Game has no game data to download'
|
||||
error: 'Game not found',
|
||||
});
|
||||
}
|
||||
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'
|
||||
});
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Get game data info
|
||||
const gameData = await GameDatabaseUpdater.getGameData(gameDataId);
|
||||
if (!gameData) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Game data not found'
|
||||
});
|
||||
}
|
||||
// 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 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'
|
||||
});
|
||||
}
|
||||
// Get game data info
|
||||
const gameData = await GameDatabaseUpdater.getGameData(gameDataId);
|
||||
if (!gameData) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Game data not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Store metadata for activity logging
|
||||
res.locals.gameDataId = gameDataId;
|
||||
res.locals.gameTitle = game.title;
|
||||
res.locals.sources = sources.map(s => s.name);
|
||||
// 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',
|
||||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
};
|
||||
// Store metadata for activity logging
|
||||
res.locals.gameDataId = gameDataId;
|
||||
res.locals.gameTitle = game.title;
|
||||
res.locals.sources = sources.map((s) => s.name);
|
||||
|
||||
// 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', {
|
||||
// Capture metadata for background error logging
|
||||
const downloadMetadata = {
|
||||
gameId,
|
||||
gameDataId,
|
||||
error: errorMessage
|
||||
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'),
|
||||
});
|
||||
});
|
||||
|
||||
// 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,
|
||||
});
|
||||
});
|
||||
|
||||
// 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,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Download endpoint error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -188,118 +189,130 @@ router.get(
|
||||
authenticate,
|
||||
requirePermission('games.download'),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const gameId = req.params.id;
|
||||
try {
|
||||
const gameId = req.params.id;
|
||||
|
||||
// Set SSE headers
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering
|
||||
// Set SSE headers
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering
|
||||
|
||||
logger.info('SSE connection established', { gameId });
|
||||
logger.info('SSE connection established', { gameId });
|
||||
|
||||
// Get game to find gameDataId
|
||||
const game = await gameService.getGameById(gameId);
|
||||
if (!game) {
|
||||
res.write(`data: ${JSON.stringify({
|
||||
status: 'error',
|
||||
error: 'Game not found'
|
||||
})}\n\n`);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
// Get game to find gameDataId
|
||||
const game = await gameService.getGameById(gameId);
|
||||
if (!game) {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
status: 'error',
|
||||
error: 'Game not found',
|
||||
})}\n\n`
|
||||
);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const gameDataId = await gameService.getGameDataId(gameId);
|
||||
if (!gameDataId) {
|
||||
res.write(`data: ${JSON.stringify({
|
||||
status: 'error',
|
||||
error: 'Game has no game data'
|
||||
})}\n\n`);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
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(`data: ${JSON.stringify({
|
||||
percent: 0,
|
||||
status: 'waiting',
|
||||
details: 'Preparing download...'
|
||||
})}\n\n`);
|
||||
// Send initial status
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
percent: 0,
|
||||
status: 'waiting',
|
||||
details: 'Preparing download...',
|
||||
})}\n\n`
|
||||
);
|
||||
|
||||
// Poll for download progress
|
||||
// Note: This is a simplified implementation. In production, you'd want
|
||||
// a proper event emitter system to push updates rather than polling.
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
// Check if download is still active
|
||||
const isActive = DownloadManager.isDownloadActive(gameDataId);
|
||||
// Poll for download progress
|
||||
// Note: This is a simplified implementation. In production, you'd want
|
||||
// a proper event emitter system to push updates rather than polling.
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
// Check if download is still active
|
||||
const isActive = DownloadManager.isDownloadActive(gameDataId);
|
||||
|
||||
if (!isActive) {
|
||||
// Check if download completed successfully
|
||||
const isDownloaded = await GameDatabaseUpdater.isDownloaded(gameDataId);
|
||||
if (!isActive) {
|
||||
// Check if download completed successfully
|
||||
const isDownloaded = await GameDatabaseUpdater.isDownloaded(gameDataId);
|
||||
|
||||
if (isDownloaded) {
|
||||
res.write(`data: ${JSON.stringify({
|
||||
percent: 100,
|
||||
status: 'complete',
|
||||
details: 'Download completed successfully'
|
||||
})}\n\n`);
|
||||
if (isDownloaded) {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
percent: 100,
|
||||
status: 'complete',
|
||||
details: 'Download completed successfully',
|
||||
})}\n\n`
|
||||
);
|
||||
|
||||
// Log download completion activity
|
||||
const gameData = await GameDatabaseUpdater.getGameData(gameDataId);
|
||||
await activityService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
action: 'games.download.complete',
|
||||
resource: 'games',
|
||||
resourceId: gameId,
|
||||
details: {
|
||||
gameDataId,
|
||||
gameTitle: game.title,
|
||||
sha256: gameData?.sha256
|
||||
},
|
||||
ipAddress: req.ip,
|
||||
userAgent: req.get('user-agent')
|
||||
});
|
||||
// Log download completion activity
|
||||
const gameData = await GameDatabaseUpdater.getGameData(gameDataId);
|
||||
await activityService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
action: 'games.download.complete',
|
||||
resource: 'games',
|
||||
resourceId: gameId,
|
||||
details: {
|
||||
gameDataId,
|
||||
gameTitle: game.title,
|
||||
sha256: gameData?.sha256,
|
||||
},
|
||||
ipAddress: req.ip,
|
||||
userAgent: req.get('user-agent'),
|
||||
});
|
||||
} else {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
percent: 0,
|
||||
status: 'error',
|
||||
details: 'Download not active',
|
||||
})}\n\n`
|
||||
);
|
||||
}
|
||||
|
||||
clearInterval(pollInterval);
|
||||
res.end();
|
||||
} else {
|
||||
res.write(`data: ${JSON.stringify({
|
||||
percent: 0,
|
||||
status: 'error',
|
||||
details: 'Download not active'
|
||||
})}\n\n`);
|
||||
// Send heartbeat
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
percent: 0,
|
||||
status: 'downloading',
|
||||
details: 'Download in progress...',
|
||||
})}\n\n`
|
||||
);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error in SSE poll', { error });
|
||||
clearInterval(pollInterval);
|
||||
res.end();
|
||||
} else {
|
||||
// Send heartbeat
|
||||
res.write(`data: ${JSON.stringify({
|
||||
percent: 0,
|
||||
status: 'downloading',
|
||||
details: 'Download in progress...'
|
||||
})}\n\n`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error in SSE poll', { error });
|
||||
}, 1000); // Poll every second
|
||||
|
||||
// Clean up on client disconnect
|
||||
req.on('close', () => {
|
||||
logger.info('SSE connection closed', { gameId });
|
||||
clearInterval(pollInterval);
|
||||
res.end();
|
||||
}
|
||||
}, 1000); // Poll every second
|
||||
|
||||
// Clean up on client disconnect
|
||||
req.on('close', () => {
|
||||
logger.info('SSE connection closed', { gameId });
|
||||
clearInterval(pollInterval);
|
||||
res.end();
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('SSE endpoint error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('SSE endpoint error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -322,50 +335,50 @@ router.delete(
|
||||
requirePermission('games.download'),
|
||||
logActivity('games.download_cancel', 'games'),
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
const gameId = req.params.id;
|
||||
try {
|
||||
const gameId = req.params.id;
|
||||
|
||||
logger.info('Download cancel request', { gameId });
|
||||
logger.info('Download cancel request', { gameId });
|
||||
|
||||
// Get game to find gameDataId
|
||||
const game = await gameService.getGameById(gameId);
|
||||
if (!game) {
|
||||
return res.status(404).json({
|
||||
// Get game to find gameDataId
|
||||
const game = await gameService.getGameById(gameId);
|
||||
if (!game) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Game not found',
|
||||
});
|
||||
}
|
||||
|
||||
const gameDataId = await gameService.getGameDataId(gameId);
|
||||
if (!gameDataId) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Game has no game data',
|
||||
});
|
||||
}
|
||||
|
||||
// Attempt to cancel download
|
||||
const cancelled = DownloadManager.cancelDownload(gameDataId);
|
||||
|
||||
if (!cancelled) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'No active download found for this game',
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
cancelled: true,
|
||||
message: 'Download cancelled successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Cancel download error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Game not found'
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -24,10 +24,10 @@ if (!fsSync.existsSync(LOG_DIR)) {
|
||||
*/
|
||||
function getSeverityNumber(type: string): number {
|
||||
const severityMap: Record<string, number> = {
|
||||
client_error: 17, // ERROR
|
||||
api_error: 17, // ERROR
|
||||
network_error: 13, // WARN (may be transient)
|
||||
route_error: 13, // WARN
|
||||
client_error: 17, // ERROR
|
||||
api_error: 17, // ERROR
|
||||
network_error: 13, // WARN (may be transient)
|
||||
route_error: 13, // WARN
|
||||
};
|
||||
return severityMap[type] || 17;
|
||||
}
|
||||
@@ -55,9 +55,9 @@ const clientErrorLogger = winston.createLogger({
|
||||
filename: ERROR_LOG_FILE,
|
||||
maxsize: 10485760, // 10MB
|
||||
maxFiles: 5,
|
||||
tailable: true
|
||||
})
|
||||
]
|
||||
tailable: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
logger.info(`[ClientErrors] Log file: ${ERROR_LOG_FILE}`);
|
||||
@@ -98,7 +98,7 @@ async function logError(report: ErrorReport): Promise<void> {
|
||||
userAgent: report.userAgent,
|
||||
userId: report.userId,
|
||||
stack: report.stack,
|
||||
context: report.context
|
||||
context: report.context,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to log client error:', error);
|
||||
@@ -132,7 +132,7 @@ async function getRecentErrors(limit: number = 100): Promise<ErrorReport[]> {
|
||||
timestamp: logEntry.clientTimestamp || logEntry.timestamp,
|
||||
userAgent: logEntry.userAgent,
|
||||
userId: logEntry.userId,
|
||||
context: logEntry.context
|
||||
context: logEntry.context,
|
||||
});
|
||||
} catch (parseError) {
|
||||
logger.warn('Failed to parse error log line:', parseError);
|
||||
@@ -143,7 +143,12 @@ async function getRecentErrors(limit: number = 100): Promise<ErrorReport[]> {
|
||||
return errors;
|
||||
} catch (readError) {
|
||||
// File doesn't exist yet - handle ENOENT error
|
||||
if (readError && typeof readError === 'object' && 'code' in readError && readError.code === 'ENOENT') {
|
||||
if (
|
||||
readError &&
|
||||
typeof readError === 'object' &&
|
||||
'code' in readError &&
|
||||
readError.code === 'ENOENT'
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
throw readError;
|
||||
@@ -169,8 +174,8 @@ router.post('/report', async (req: Request, res: Response) => {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Missing required fields: type, message, url'
|
||||
}
|
||||
message: 'Missing required fields: type, message, url',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -202,8 +207,8 @@ router.post('/report', async (req: Request, res: Response) => {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'Failed to log error report'
|
||||
}
|
||||
message: 'Failed to log error report',
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -227,8 +232,8 @@ router.get(
|
||||
data: {
|
||||
errors,
|
||||
count: errors.length,
|
||||
limit
|
||||
}
|
||||
limit,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error in /errors/recent:', error);
|
||||
@@ -236,8 +241,8 @@ router.get(
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'Failed to retrieve error reports'
|
||||
}
|
||||
message: 'Failed to retrieve error reports',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -263,11 +268,11 @@ router.get(
|
||||
|
||||
const stats = {
|
||||
total: errors.length,
|
||||
last24h: errors.filter(e => new Date(e.timestamp) >= last24h).length,
|
||||
last7d: errors.filter(e => new Date(e.timestamp) >= last7d).length,
|
||||
last24h: errors.filter((e) => new Date(e.timestamp) >= last24h).length,
|
||||
last7d: errors.filter((e) => new Date(e.timestamp) >= last7d).length,
|
||||
byType: {} as Record<string, number>,
|
||||
topUrls: {} as Record<string, number>,
|
||||
topMessages: {} as Record<string, number>
|
||||
topMessages: {} as Record<string, number>,
|
||||
};
|
||||
|
||||
// Group by type
|
||||
@@ -293,7 +298,7 @@ router.get(
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: stats
|
||||
data: stats,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error in /errors/stats:', error);
|
||||
@@ -301,8 +306,8 @@ router.get(
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'Failed to retrieve error statistics'
|
||||
}
|
||||
message: 'Failed to retrieve error statistics',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,15 +20,15 @@ router.use(requireFeature('enableFavorites'));
|
||||
|
||||
// Validation schemas
|
||||
const toggleFavoriteSchema = z.object({
|
||||
gameId: z.string()
|
||||
gameId: z.string(),
|
||||
});
|
||||
|
||||
const batchAddSchema = z.object({
|
||||
gameIds: z.array(z.string()).min(1)
|
||||
gameIds: z.array(z.string()).min(1),
|
||||
});
|
||||
|
||||
const batchRemoveSchema = z.object({
|
||||
gameIds: z.array(z.string()).min(1)
|
||||
gameIds: z.array(z.string()).min(1),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -88,7 +88,13 @@ router.get(
|
||||
const sortBy = (req.query.sortBy as 'title' | 'dateAdded') || 'dateAdded';
|
||||
const sortOrder = (req.query.sortOrder as 'asc' | 'desc') || 'desc';
|
||||
|
||||
const games = await favoritesService.getUserFavoriteGames(req.user.id, limit, offset, sortBy, sortOrder);
|
||||
const games = await favoritesService.getUserFavoriteGames(
|
||||
req.user.id,
|
||||
limit,
|
||||
offset,
|
||||
sortBy,
|
||||
sortOrder
|
||||
);
|
||||
res.json(games);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -14,39 +14,42 @@ const gameFileService = new GameFileService();
|
||||
* - /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
|
||||
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');
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
+61
-35
@@ -41,12 +41,14 @@ const searchQuerySchema = z.object({
|
||||
yearTo: z.coerce.number().int().min(1970).max(2100).optional(),
|
||||
dateAddedSince: z.string().datetime().optional(),
|
||||
dateModifiedSince: z.string().datetime().optional(),
|
||||
sortBy: z.enum(['title', 'releaseDate', 'dateAdded', 'dateModified', 'developer']).default('title'),
|
||||
sortBy: z
|
||||
.enum(['title', 'releaseDate', 'dateAdded', 'dateModified', 'developer'])
|
||||
.default('title'),
|
||||
sortOrder: z.enum(['asc', 'desc']).default('asc'),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||
showBroken: booleanSchema.default(false),
|
||||
showExtreme: booleanSchema.default(false)
|
||||
showExtreme: booleanSchema.default(false),
|
||||
});
|
||||
|
||||
// GET /api/games - List games with pagination and filters
|
||||
@@ -54,14 +56,25 @@ router.get(
|
||||
'/',
|
||||
logActivity('games.search', 'games', (req, res) => {
|
||||
// Count active filters
|
||||
const filters = ['platform', 'series', 'developers', 'publishers', 'playModes', 'languages', 'library', 'tags', 'yearFrom', 'yearTo'];
|
||||
const activeFilters = filters.filter(f => req.query[f]).length;
|
||||
const filters = [
|
||||
'platform',
|
||||
'series',
|
||||
'developers',
|
||||
'publishers',
|
||||
'playModes',
|
||||
'languages',
|
||||
'library',
|
||||
'tags',
|
||||
'yearFrom',
|
||||
'yearTo',
|
||||
];
|
||||
const activeFilters = filters.filter((f) => req.query[f]).length;
|
||||
|
||||
return {
|
||||
query: req.query.search || null,
|
||||
filterCount: activeFilters,
|
||||
resultCount: res.locals.resultCount || 0,
|
||||
hasSearch: !!req.query.search
|
||||
hasSearch: !!req.query.search,
|
||||
};
|
||||
}),
|
||||
asyncHandler(async (req, res) => {
|
||||
@@ -87,7 +100,7 @@ router.get(
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
showBroken: query.showBroken,
|
||||
showExtreme: query.showExtreme
|
||||
showExtreme: query.showExtreme,
|
||||
});
|
||||
|
||||
// Store result count for activity logging
|
||||
@@ -98,22 +111,28 @@ router.get(
|
||||
);
|
||||
|
||||
// GET /api/games/filter-options - Get all filter options for dropdowns
|
||||
router.get('/filter-options', asyncHandler(async (req, res) => {
|
||||
const filterOptions = await gameService.getFilterOptions();
|
||||
res.json(filterOptions);
|
||||
}));
|
||||
router.get(
|
||||
'/filter-options',
|
||||
asyncHandler(async (req, res) => {
|
||||
const filterOptions = await gameService.getFilterOptions();
|
||||
res.json(filterOptions);
|
||||
})
|
||||
);
|
||||
|
||||
// GET /api/games/most-played - Get most played games globally
|
||||
router.get('/most-played', asyncHandler(async (req, res) => {
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
|
||||
const games = await gameService.getMostPlayedGames(limit);
|
||||
router.get(
|
||||
'/most-played',
|
||||
asyncHandler(async (req, res) => {
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
|
||||
const games = await gameService.getMostPlayedGames(limit);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: games,
|
||||
total: games.length
|
||||
});
|
||||
}));
|
||||
res.json({
|
||||
success: true,
|
||||
data: games,
|
||||
total: games.length,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// GET /api/games/:id - Get single game by ID
|
||||
router.get(
|
||||
@@ -123,7 +142,7 @@ router.get(
|
||||
platform: res.locals.game?.platformName,
|
||||
library: res.locals.game?.library,
|
||||
broken: res.locals.game?.broken,
|
||||
extreme: res.locals.game?.extreme
|
||||
extreme: res.locals.game?.extreme,
|
||||
})),
|
||||
asyncHandler(async (req, res) => {
|
||||
const game = await gameService.getGameById(req.params.id);
|
||||
@@ -140,24 +159,31 @@ router.get(
|
||||
);
|
||||
|
||||
// GET /api/games/:id/related - Get related games
|
||||
router.get('/:id/related', validateSharedGameAccess('id'), asyncHandler(async (req, res) => {
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 10, 50);
|
||||
const relatedGames = await gameService.getRelatedGames(req.params.id, limit);
|
||||
router.get(
|
||||
'/:id/related',
|
||||
validateSharedGameAccess('id'),
|
||||
asyncHandler(async (req, res) => {
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 10, 50);
|
||||
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);
|
||||
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');
|
||||
}
|
||||
if (!game) {
|
||||
throw new AppError(404, 'No games found');
|
||||
}
|
||||
|
||||
res.json(game);
|
||||
}));
|
||||
res.json(game);
|
||||
})
|
||||
);
|
||||
|
||||
// GET /api/games/:id/launch - Get game launch data
|
||||
router.get(
|
||||
@@ -166,7 +192,7 @@ router.get(
|
||||
logActivity('games.launch.request', 'games', (req, res) => ({
|
||||
platform: res.locals.platform,
|
||||
canPlayInBrowser: res.locals.canPlayInBrowser,
|
||||
launchCommand: res.locals.launchCommand
|
||||
launchCommand: res.locals.launchCommand,
|
||||
})),
|
||||
asyncHandler(async (req, res) => {
|
||||
const game = await gameService.getGameById(req.params.id);
|
||||
@@ -253,7 +279,7 @@ router.get(
|
||||
contentUrl,
|
||||
applicationPath: game.applicationPath,
|
||||
playMode: game.playMode,
|
||||
canPlayInBrowser
|
||||
canPlayInBrowser,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
@@ -14,30 +14,30 @@ router.get('/stars', async (_req: Request, res: Response) => {
|
||||
|
||||
const response = await fetch(repoUrl, {
|
||||
headers: {
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'Flashpoint-Web-App'
|
||||
}
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'Flashpoint-Web-App',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as { stargazers_count: number };
|
||||
const data = (await response.json()) as { stargazers_count: number };
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
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'
|
||||
}
|
||||
message: 'Failed to fetch GitHub star count',
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ router.get('/', (req, res) => {
|
||||
|
||||
res.status(isHealthy ? 200 : 503).json({
|
||||
status: isHealthy ? 'healthy' : 'unhealthy',
|
||||
timestamp: new Date().toISOString()
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,7 +43,9 @@ router.get(
|
||||
try {
|
||||
if (DatabaseService.isConnected()) {
|
||||
// Test query to verify database is accessible
|
||||
const result = DatabaseService.get('SELECT COUNT(*) as count FROM game', []) as { count: number } | null;
|
||||
const result = DatabaseService.get('SELECT COUNT(*) as count FROM game', []) as {
|
||||
count: number;
|
||||
} | null;
|
||||
flashpointDbRecordCount = result?.count || 0;
|
||||
flashpointDbStatus = 'connected';
|
||||
}
|
||||
@@ -60,7 +62,9 @@ router.get(
|
||||
try {
|
||||
if (UserDatabaseService.isConnected()) {
|
||||
// Test query to verify database is accessible
|
||||
const result = UserDatabaseService.get('SELECT COUNT(*) as count FROM users', []) as { count: number } | null;
|
||||
const result = UserDatabaseService.get('SELECT COUNT(*) as count FROM users', []) as {
|
||||
count: number;
|
||||
} | null;
|
||||
userCount = result?.count || 0;
|
||||
userDbStatus = 'connected';
|
||||
}
|
||||
@@ -88,7 +92,7 @@ router.get(
|
||||
rss: formatBytes(memoryUsage.rss),
|
||||
heapUsed: formatBytes(memoryUsage.heapUsed),
|
||||
heapTotal: formatBytes(memoryUsage.heapTotal),
|
||||
external: formatBytes(memoryUsage.external)
|
||||
external: formatBytes(memoryUsage.external),
|
||||
};
|
||||
|
||||
const healthCheckTime = Math.round(performance.now() - startTime);
|
||||
@@ -101,20 +105,20 @@ router.get(
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: {
|
||||
seconds: uptimeSeconds,
|
||||
formatted: uptimeFormatted
|
||||
formatted: uptimeFormatted,
|
||||
},
|
||||
databases: {
|
||||
flashpoint: {
|
||||
status: flashpointDbStatus,
|
||||
path: config.flashpointDbPath,
|
||||
gameCount: flashpointDbRecordCount,
|
||||
error: flashpointDbError
|
||||
error: flashpointDbError,
|
||||
},
|
||||
user: {
|
||||
status: userDbStatus,
|
||||
userCount,
|
||||
error: userDbError
|
||||
}
|
||||
error: userDbError,
|
||||
},
|
||||
},
|
||||
caches: {
|
||||
permissions: permissionCacheStats,
|
||||
@@ -122,23 +126,23 @@ router.get(
|
||||
size: gameSearchCacheStats.primary.size,
|
||||
max: gameSearchCacheStats.primary.max,
|
||||
ttl: '5 minutes',
|
||||
hitRate: gameSearchCacheStats.primary.size > 0 ? 'active' : 'idle'
|
||||
}
|
||||
hitRate: gameSearchCacheStats.primary.size > 0 ? 'active' : 'idle',
|
||||
},
|
||||
},
|
||||
memory: memoryFormatted,
|
||||
environment: {
|
||||
nodeEnv: config.nodeEnv,
|
||||
nodeVersion: process.version,
|
||||
platform: process.platform,
|
||||
arch: process.arch
|
||||
arch: process.arch,
|
||||
},
|
||||
services: {
|
||||
gameService: {
|
||||
proxyUrl: config.gameServerUrl || 'http://localhost:22500',
|
||||
gameZipPort: config.gameServerHttpPort || 22501
|
||||
}
|
||||
gameZipPort: config.gameServerHttpPort || 22501,
|
||||
},
|
||||
},
|
||||
healthCheckDuration: `${healthCheckTime}ms`
|
||||
healthCheckDuration: `${healthCheckTime}ms`,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
@@ -95,8 +95,8 @@ export function setupRoutes(app: Express): void {
|
||||
res.status(404).json({
|
||||
error: {
|
||||
message: 'Route not found',
|
||||
statusCode: 404
|
||||
}
|
||||
statusCode: 404,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ router.patch(
|
||||
// Validate request body
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return res.status(400).json({
|
||||
error: { message: 'Invalid request: enabled must be a boolean' }
|
||||
error: { message: 'Invalid request: enabled must be a boolean' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ router.patch(
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update job ${req.params.jobId}:`, error);
|
||||
res.status(500).json({
|
||||
error: { message: error instanceof Error ? error.message : 'Failed to update job' }
|
||||
error: { message: error instanceof Error ? error.message : 'Failed to update job' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -123,7 +123,7 @@ router.post(
|
||||
logger.error(`Failed to start job ${req.params.jobId}:`, error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to start job'
|
||||
message: error instanceof Error ? error.message : 'Failed to start job',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -146,7 +146,7 @@ router.post(
|
||||
logger.error(`Failed to stop job ${req.params.jobId}:`, error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to stop job'
|
||||
message: error instanceof Error ? error.message : 'Failed to stop job',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -168,7 +168,7 @@ router.post(
|
||||
await JobScheduler.triggerJob(jobId, userId);
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Job triggered successfully'
|
||||
message: 'Job triggered successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to trigger job';
|
||||
|
||||
@@ -20,7 +20,7 @@ router.get(
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: summary
|
||||
data: summary,
|
||||
});
|
||||
})
|
||||
);
|
||||
@@ -39,7 +39,7 @@ router.get(
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: stats
|
||||
data: stats,
|
||||
});
|
||||
})
|
||||
);
|
||||
@@ -59,7 +59,7 @@ router.get(
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: slowest
|
||||
data: slowest,
|
||||
});
|
||||
})
|
||||
);
|
||||
@@ -78,7 +78,7 @@ router.get(
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: cacheStats
|
||||
data: cacheStats,
|
||||
});
|
||||
})
|
||||
);
|
||||
@@ -97,7 +97,7 @@ router.get(
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: queryStats
|
||||
data: queryStats,
|
||||
});
|
||||
})
|
||||
);
|
||||
@@ -116,7 +116,7 @@ router.post(
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Performance metrics reset successfully'
|
||||
message: 'Performance metrics reset successfully',
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
@@ -13,8 +13,10 @@ router.use(rateLimitStandard);
|
||||
router.use(optionalAuth);
|
||||
|
||||
// GET /api/platforms - List all platforms
|
||||
router.get('/', asyncHandler(async (req, res) => {
|
||||
const sql = `
|
||||
router.get(
|
||||
'/',
|
||||
asyncHandler(async (req, res) => {
|
||||
const sql = `
|
||||
SELECT DISTINCT platformName as platform, COUNT(*) as count
|
||||
FROM game
|
||||
WHERE platformName IS NOT NULL AND platformName != ''
|
||||
@@ -22,9 +24,10 @@ router.get('/', asyncHandler(async (req, res) => {
|
||||
ORDER BY platformName ASC
|
||||
`;
|
||||
|
||||
const platforms = DatabaseService.all(sql, []) as Array<{ platform: string; count: number }>;
|
||||
const platforms = DatabaseService.all(sql, []) as Array<{ platform: string; count: number }>;
|
||||
|
||||
res.json(platforms);
|
||||
}));
|
||||
res.json(platforms);
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -26,7 +26,7 @@ router.post(
|
||||
logActivity('play.start', 'games', (req, res) => ({
|
||||
sessionId: res.locals.sessionId,
|
||||
gameTitle: req.body.gameTitle,
|
||||
gameId: req.body.gameId
|
||||
gameId: req.body.gameId,
|
||||
})),
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const { gameId, gameTitle } = req.body;
|
||||
@@ -39,18 +39,14 @@ router.post(
|
||||
throw new AppError(401, 'Authentication required');
|
||||
}
|
||||
|
||||
const sessionId = await playTrackingService.startPlaySession(
|
||||
req.user.id,
|
||||
gameId,
|
||||
gameTitle
|
||||
);
|
||||
const sessionId = await playTrackingService.startPlaySession(req.user.id, gameId, gameTitle);
|
||||
|
||||
// Store sessionId for activity logging
|
||||
res.locals.sessionId = sessionId;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
sessionId
|
||||
sessionId,
|
||||
});
|
||||
})
|
||||
);
|
||||
@@ -73,7 +69,7 @@ router.post(
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Play session ended successfully'
|
||||
message: 'Play session ended successfully',
|
||||
});
|
||||
})
|
||||
);
|
||||
@@ -82,104 +78,122 @@ router.post(
|
||||
* GET /api/play/stats
|
||||
* Get current user's overall stats
|
||||
*/
|
||||
router.get('/stats', asyncHandler(async (req: Request, res: Response) => {
|
||||
if (!req.user) {
|
||||
throw new AppError(401, 'Authentication required');
|
||||
}
|
||||
router.get(
|
||||
'/stats',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
if (!req.user) {
|
||||
throw new AppError(401, 'Authentication required');
|
||||
}
|
||||
|
||||
const stats = await playTrackingService.getUserStats(req.user.id);
|
||||
const stats = await playTrackingService.getUserStats(req.user.id);
|
||||
|
||||
res.json(stats);
|
||||
}));
|
||||
res.json(stats);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/play/game-stats
|
||||
* Get current user's game-specific stats
|
||||
*/
|
||||
router.get('/game-stats', asyncHandler(async (req: Request, res: Response) => {
|
||||
if (!req.user) {
|
||||
throw new AppError(401, 'Authentication required');
|
||||
}
|
||||
router.get(
|
||||
'/game-stats',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
if (!req.user) {
|
||||
throw new AppError(401, 'Authentication required');
|
||||
}
|
||||
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
||||
const offset = parseInt(req.query.offset as string) || 0;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
||||
const offset = parseInt(req.query.offset as string) || 0;
|
||||
|
||||
const stats = await playTrackingService.getUserGameStats(req.user.id, limit, offset);
|
||||
const stats = await playTrackingService.getUserGameStats(req.user.id, limit, offset);
|
||||
|
||||
res.json({
|
||||
data: stats,
|
||||
limit,
|
||||
offset
|
||||
});
|
||||
}));
|
||||
res.json({
|
||||
data: stats,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/play/history
|
||||
* Get current user's play history
|
||||
*/
|
||||
router.get('/history', asyncHandler(async (req: Request, res: Response) => {
|
||||
if (!req.user) {
|
||||
throw new AppError(401, 'Authentication required');
|
||||
}
|
||||
router.get(
|
||||
'/history',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
if (!req.user) {
|
||||
throw new AppError(401, 'Authentication required');
|
||||
}
|
||||
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
||||
const offset = parseInt(req.query.offset as string) || 0;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
||||
const offset = parseInt(req.query.offset as string) || 0;
|
||||
|
||||
const history = await playTrackingService.getUserPlayHistory(req.user.id, limit, offset);
|
||||
const history = await playTrackingService.getUserPlayHistory(req.user.id, limit, offset);
|
||||
|
||||
res.json({
|
||||
data: history,
|
||||
limit,
|
||||
offset
|
||||
});
|
||||
}));
|
||||
res.json({
|
||||
data: history,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/play/top-games
|
||||
* Get current user's top played games
|
||||
*/
|
||||
router.get('/top-games', asyncHandler(async (req: Request, res: Response) => {
|
||||
if (!req.user) {
|
||||
throw new AppError(401, 'Authentication required');
|
||||
}
|
||||
router.get(
|
||||
'/top-games',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
if (!req.user) {
|
||||
throw new AppError(401, 'Authentication required');
|
||||
}
|
||||
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 10, 50);
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 10, 50);
|
||||
|
||||
const topGames = await playTrackingService.getTopGames(req.user.id, limit);
|
||||
const topGames = await playTrackingService.getTopGames(req.user.id, limit);
|
||||
|
||||
res.json(topGames);
|
||||
}));
|
||||
res.json(topGames);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/play/activity-over-time
|
||||
* Get play activity over time (daily aggregation)
|
||||
*/
|
||||
router.get('/activity-over-time', asyncHandler(async (req: Request, res: Response) => {
|
||||
if (!req.user) {
|
||||
throw new AppError(401, 'Authentication required');
|
||||
}
|
||||
router.get(
|
||||
'/activity-over-time',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
if (!req.user) {
|
||||
throw new AppError(401, 'Authentication required');
|
||||
}
|
||||
|
||||
const days = Math.min(parseInt(req.query.days as string) || 30, 365);
|
||||
const days = Math.min(parseInt(req.query.days as string) || 30, 365);
|
||||
|
||||
const activity = await playTrackingService.getPlayActivityOverTime(req.user.id, days);
|
||||
const activity = await playTrackingService.getPlayActivityOverTime(req.user.id, days);
|
||||
|
||||
res.json(activity);
|
||||
}));
|
||||
res.json(activity);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/play/games-distribution
|
||||
* Get games distribution by playtime
|
||||
*/
|
||||
router.get('/games-distribution', asyncHandler(async (req: Request, res: Response) => {
|
||||
if (!req.user) {
|
||||
throw new AppError(401, 'Authentication required');
|
||||
}
|
||||
router.get(
|
||||
'/games-distribution',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
if (!req.user) {
|
||||
throw new AppError(401, 'Authentication required');
|
||||
}
|
||||
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 10, 20);
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 10, 20);
|
||||
|
||||
const distribution = await playTrackingService.getGamesDistribution(req.user.id, limit);
|
||||
const distribution = await playTrackingService.getGamesDistribution(req.user.id, limit);
|
||||
|
||||
res.json(distribution);
|
||||
}));
|
||||
res.json(distribution);
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Router } from 'express';
|
||||
import { PlaylistService, CreatePlaylistDto, AddGamesToPlaylistDto } from '../services/PlaylistService';
|
||||
import {
|
||||
PlaylistService,
|
||||
CreatePlaylistDto,
|
||||
AddGamesToPlaylistDto,
|
||||
} from '../services/PlaylistService';
|
||||
import { AppError } from '../middleware/errorHandler';
|
||||
import { asyncHandler } from '../middleware/asyncHandler';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
@@ -19,22 +23,28 @@ router.use(requireFeature('enablePlaylists'));
|
||||
|
||||
// GET /api/playlists - List all playlists
|
||||
// No additional auth needed - softAuth already runs at router level
|
||||
router.get('/', asyncHandler(async (req, res) => {
|
||||
const playlists = await playlistService.getAllPlaylists();
|
||||
res.json(playlists);
|
||||
}));
|
||||
router.get(
|
||||
'/',
|
||||
asyncHandler(async (req, res) => {
|
||||
const playlists = await playlistService.getAllPlaylists();
|
||||
res.json(playlists);
|
||||
})
|
||||
);
|
||||
|
||||
// GET /api/playlists/:id - Get playlist by ID with games
|
||||
// No additional auth needed - softAuth already runs at router level
|
||||
router.get('/:id', asyncHandler(async (req, res) => {
|
||||
const playlist = await playlistService.getPlaylistById(req.params.id);
|
||||
router.get(
|
||||
'/:id',
|
||||
asyncHandler(async (req, res) => {
|
||||
const playlist = await playlistService.getPlaylistById(req.params.id);
|
||||
|
||||
if (!playlist) {
|
||||
throw new AppError(404, 'Playlist not found');
|
||||
}
|
||||
if (!playlist) {
|
||||
throw new AppError(404, 'Playlist not found');
|
||||
}
|
||||
|
||||
res.json(playlist);
|
||||
}));
|
||||
res.json(playlist);
|
||||
})
|
||||
);
|
||||
|
||||
// POST /api/playlists - Create new playlist
|
||||
router.post(
|
||||
|
||||
+92
-94
@@ -69,7 +69,7 @@ async function serveFileWithFallback(
|
||||
const response = await axios.get(externalUrl, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 10000, // 10 second timeout
|
||||
validateStatus: (status) => status === 200
|
||||
validateStatus: (status) => status === 200,
|
||||
});
|
||||
|
||||
if (response.status === 200 && response.data) {
|
||||
@@ -89,7 +89,9 @@ async function serveFileWithFallback(
|
||||
logger.info(`[Proxy] Cached image locally: ${localPath}`);
|
||||
} catch (cacheError) {
|
||||
// Log cache errors but don't fail the request
|
||||
logger.warn(`[Proxy] Failed to cache image locally: ${cacheError instanceof Error ? cacheError.message : 'Unknown error'}`);
|
||||
logger.warn(
|
||||
`[Proxy] Failed to cache image locally: ${cacheError instanceof Error ? cacheError.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -101,7 +103,9 @@ async function serveFileWithFallback(
|
||||
return res.send(imageBuffer);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug(`[Proxy] Failed to fetch from ${baseUrl}: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
logger.debug(
|
||||
`[Proxy] Failed to fetch from ${baseUrl}: ${err instanceof Error ? err.message : 'Unknown error'}`
|
||||
);
|
||||
// Continue to next URL
|
||||
}
|
||||
}
|
||||
@@ -111,8 +115,8 @@ async function serveFileWithFallback(
|
||||
res.status(404).json({
|
||||
error: {
|
||||
message: 'Image not found',
|
||||
statusCode: 404
|
||||
}
|
||||
statusCode: 404,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -120,114 +124,108 @@ async function serveFileWithFallback(
|
||||
}
|
||||
|
||||
// Proxy to Flashpoint Game Server for game content
|
||||
router.use('/game', createProxyMiddleware({
|
||||
target: config.gameServerUrl,
|
||||
changeOrigin: true,
|
||||
pathRewrite: {
|
||||
'^/proxy/game': '' // Remove /proxy/game prefix
|
||||
},
|
||||
onProxyReq: (proxyReq, req, _res) => {
|
||||
// Add headers if needed
|
||||
if (req.ip) {
|
||||
proxyReq.setHeader('X-Forwarded-For', req.ip);
|
||||
}
|
||||
},
|
||||
onError: (err, req, res) => {
|
||||
logger.error('Proxy error:', err);
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to proxy request to game server',
|
||||
statusCode: 500
|
||||
router.use(
|
||||
'/game',
|
||||
createProxyMiddleware({
|
||||
target: config.gameServerUrl,
|
||||
changeOrigin: true,
|
||||
pathRewrite: {
|
||||
'^/proxy/game': '', // Remove /proxy/game prefix
|
||||
},
|
||||
onProxyReq: (proxyReq, req, _res) => {
|
||||
// Add headers if needed
|
||||
if (req.ip) {
|
||||
proxyReq.setHeader('X-Forwarded-For', req.ip);
|
||||
}
|
||||
});
|
||||
}
|
||||
}));
|
||||
},
|
||||
onError: (err, req, res) => {
|
||||
logger.error('Proxy error:', err);
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to proxy request to game server',
|
||||
statusCode: 500,
|
||||
},
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Serve images with CDN fallback
|
||||
router.get('/images/:path(*)', asyncHandler(async (req, res, next) => {
|
||||
const relativePath = req.params.path;
|
||||
router.get(
|
||||
'/images/:path(*)',
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const relativePath = req.params.path;
|
||||
|
||||
// Validate path to prevent directory traversal
|
||||
const localPath = validatePath(relativePath, config.flashpointImagesPath);
|
||||
// Validate path to prevent directory traversal
|
||||
const localPath = validatePath(relativePath, config.flashpointImagesPath);
|
||||
|
||||
if (!localPath) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: 'Access denied',
|
||||
statusCode: 403
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!localPath) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: 'Access denied',
|
||||
statusCode: 403,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Get external image URLs dynamically from preferences
|
||||
const externalImageUrls = await getExternalImageUrls();
|
||||
// Get external image URLs dynamically from preferences
|
||||
const externalImageUrls = await getExternalImageUrls();
|
||||
|
||||
await serveFileWithFallback(
|
||||
localPath,
|
||||
relativePath,
|
||||
externalImageUrls,
|
||||
res,
|
||||
next
|
||||
);
|
||||
}));
|
||||
await serveFileWithFallback(localPath, relativePath, externalImageUrls, res, next);
|
||||
})
|
||||
);
|
||||
|
||||
// Serve logos with CDN fallback
|
||||
router.get('/logos/:path(*)', asyncHandler(async (req, res, next) => {
|
||||
const relativePath = req.params.path;
|
||||
router.get(
|
||||
'/logos/:path(*)',
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const relativePath = req.params.path;
|
||||
|
||||
// Validate path to prevent directory traversal
|
||||
const localPath = validatePath(relativePath, config.flashpointLogosPath);
|
||||
// Validate path to prevent directory traversal
|
||||
const localPath = validatePath(relativePath, config.flashpointLogosPath);
|
||||
|
||||
if (!localPath) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: 'Access denied',
|
||||
statusCode: 403
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!localPath) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: 'Access denied',
|
||||
statusCode: 403,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Get external image URLs dynamically from preferences
|
||||
const externalImageUrls = await getExternalImageUrls();
|
||||
// Get external image URLs dynamically from preferences
|
||||
const externalImageUrls = await getExternalImageUrls();
|
||||
|
||||
// For logos, the external path should include 'Logos' subdirectory
|
||||
const externalUrls = externalImageUrls.map(url => `${url}/../Logos`);
|
||||
// For logos, the external path should include 'Logos' subdirectory
|
||||
const externalUrls = externalImageUrls.map((url) => `${url}/../Logos`);
|
||||
|
||||
await serveFileWithFallback(
|
||||
localPath,
|
||||
relativePath,
|
||||
externalUrls,
|
||||
res,
|
||||
next
|
||||
);
|
||||
}));
|
||||
await serveFileWithFallback(localPath, relativePath, externalUrls, res, next);
|
||||
})
|
||||
);
|
||||
|
||||
// Serve screenshots with CDN fallback
|
||||
router.get('/screenshots/:path(*)', asyncHandler(async (req, res, next) => {
|
||||
const relativePath = `Screenshots/${req.params.path}`;
|
||||
router.get(
|
||||
'/screenshots/:path(*)',
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const relativePath = `Screenshots/${req.params.path}`;
|
||||
|
||||
// Validate path to prevent directory traversal
|
||||
const localPath = validatePath(relativePath, config.flashpointImagesPath);
|
||||
// Validate path to prevent directory traversal
|
||||
const localPath = validatePath(relativePath, config.flashpointImagesPath);
|
||||
|
||||
if (!localPath) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: 'Access denied',
|
||||
statusCode: 403
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!localPath) {
|
||||
return res.status(403).json({
|
||||
error: {
|
||||
message: 'Access denied',
|
||||
statusCode: 403,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Get external image URLs dynamically from preferences
|
||||
const externalImageUrls = await getExternalImageUrls();
|
||||
// Get external image URLs dynamically from preferences
|
||||
const externalImageUrls = await getExternalImageUrls();
|
||||
|
||||
await serveFileWithFallback(
|
||||
localPath,
|
||||
relativePath,
|
||||
externalImageUrls,
|
||||
res,
|
||||
next
|
||||
);
|
||||
}));
|
||||
await serveFileWithFallback(localPath, relativePath, externalImageUrls, res, next);
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -15,17 +15,17 @@ const createRoleSchema = z.object({
|
||||
name: z.string().min(3).max(50),
|
||||
description: z.string().optional(),
|
||||
priority: z.number().int().default(0),
|
||||
permissionIds: z.array(z.number().int().positive()).optional()
|
||||
permissionIds: z.array(z.number().int().positive()).optional(),
|
||||
});
|
||||
|
||||
const updateRoleSchema = z.object({
|
||||
name: z.string().min(3).max(50).optional(),
|
||||
description: z.string().optional(),
|
||||
priority: z.number().int().optional()
|
||||
priority: z.number().int().optional(),
|
||||
});
|
||||
|
||||
const updatePermissionsSchema = z.object({
|
||||
permissionIds: z.array(z.number().int().positive())
|
||||
permissionIds: z.array(z.number().int().positive()),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -114,12 +114,7 @@ router.patch(
|
||||
const id = parseInt(req.params.id);
|
||||
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);
|
||||
|
||||
res.json(role);
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@ router.get('/version', (req, res, next) => {
|
||||
|
||||
res.json({
|
||||
currentVersion,
|
||||
isInstalled
|
||||
isInstalled,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -29,18 +29,28 @@ router.get('/version', (req, res, next) => {
|
||||
* GET /api/ruffle/check-update
|
||||
* Check for Ruffle updates (admin only)
|
||||
*/
|
||||
router.get('/check-update', authenticate, requirePermission('settings.update'), asyncHandler(async (req, res) => {
|
||||
const updateInfo = await ruffleService.checkForUpdate();
|
||||
res.json(updateInfo);
|
||||
}));
|
||||
router.get(
|
||||
'/check-update',
|
||||
authenticate,
|
||||
requirePermission('settings.update'),
|
||||
asyncHandler(async (req, res) => {
|
||||
const updateInfo = await ruffleService.checkForUpdate();
|
||||
res.json(updateInfo);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/ruffle/update
|
||||
* Update Ruffle to latest version (admin only)
|
||||
*/
|
||||
router.post('/update', authenticate, requirePermission('settings.update'), asyncHandler(async (req, res) => {
|
||||
const result = await ruffleService.updateRuffle();
|
||||
res.json(result);
|
||||
}));
|
||||
router.post(
|
||||
'/update',
|
||||
authenticate,
|
||||
requirePermission('settings.update'),
|
||||
asyncHandler(async (req, res) => {
|
||||
const result = await ruffleService.updateRuffle();
|
||||
res.json(result);
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -62,11 +62,11 @@ router.get(
|
||||
rateLimitStrict,
|
||||
validateSharedPlaylist,
|
||||
asyncHandler(async (req, res) => {
|
||||
const games = await playlistService.getSharedPlaylistGames(
|
||||
req.params.shareToken
|
||||
);
|
||||
const games = await playlistService.getSharedPlaylistGames(req.params.shareToken);
|
||||
|
||||
logger.debug(`Shared playlist ${req.sharedPlaylist!.id} games accessed (${games.length} games)`);
|
||||
logger.debug(
|
||||
`Shared playlist ${req.sharedPlaylist!.id} games accessed (${games.length} games)`
|
||||
);
|
||||
|
||||
res.json(games);
|
||||
})
|
||||
@@ -119,7 +119,7 @@ router.post(
|
||||
|
||||
const accessToken = generateSharedAccessToken({
|
||||
shareToken: req.params.shareToken,
|
||||
playlistId: playlist.id
|
||||
playlistId: playlist.id,
|
||||
});
|
||||
|
||||
logger.debug(`Generated shared access token for playlist ${playlist.id}`);
|
||||
@@ -127,7 +127,7 @@ router.post(
|
||||
res.json({
|
||||
accessToken,
|
||||
expiresIn: 3600, // 60 minutes in seconds
|
||||
playlistId: playlist.id
|
||||
playlistId: playlist.id,
|
||||
});
|
||||
})
|
||||
);
|
||||
@@ -156,7 +156,9 @@ router.post(
|
||||
throw new AppError(400, 'Unable to clone playlist');
|
||||
}
|
||||
|
||||
logger.info(`User ${req.user!.id} cloned shared playlist ${req.sharedPlaylist!.id} as "${clonedPlaylist.title}"`);
|
||||
logger.info(
|
||||
`User ${req.user!.id} cloned shared playlist ${req.sharedPlaylist!.id} as "${clonedPlaylist.title}"`
|
||||
);
|
||||
|
||||
res.status(201).json(clonedPlaylist);
|
||||
})
|
||||
|
||||
@@ -17,9 +17,12 @@ router.use(requireFeature('enableStatistics'));
|
||||
* GET /api/statistics
|
||||
* Get overall Flashpoint archive statistics
|
||||
*/
|
||||
router.get('/', asyncHandler(async (req, res) => {
|
||||
const statistics = await statisticsService.getStatistics();
|
||||
res.json(statistics);
|
||||
}));
|
||||
router.get(
|
||||
'/',
|
||||
asyncHandler(async (req, res) => {
|
||||
const statistics = await statisticsService.getStatistics();
|
||||
res.json(statistics);
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -19,11 +19,12 @@ function updateJobScheduler(category: string, settings: Record<string, any>): vo
|
||||
if ('metadataSyncEnabled' in settings || 'metadataSyncSchedule' in settings) {
|
||||
const currentSettings = systemSettings.getCategory('jobs');
|
||||
const enabled = settings.metadataSyncEnabled ?? currentSettings.metadataSyncEnabled ?? false;
|
||||
const cronSchedule = settings.metadataSyncSchedule ?? currentSettings.metadataSyncSchedule ?? '0 * * * *';
|
||||
const cronSchedule =
|
||||
settings.metadataSyncSchedule ?? currentSettings.metadataSyncSchedule ?? '0 * * * *';
|
||||
|
||||
JobScheduler.updateJob('metadata-sync', {
|
||||
enabled,
|
||||
cronSchedule
|
||||
cronSchedule,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -44,7 +45,7 @@ router.get(
|
||||
} catch (error) {
|
||||
logger.error('Failed to get all settings:', error);
|
||||
res.status(500).json({
|
||||
error: { message: 'Failed to retrieve settings' }
|
||||
error: { message: 'Failed to retrieve settings' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -85,7 +86,7 @@ router.get('/public', async (req: Request, res: Response) => {
|
||||
} catch (error) {
|
||||
logger.error('Failed to get public settings:', error);
|
||||
res.status(500).json({
|
||||
error: { message: 'Failed to retrieve public settings' }
|
||||
error: { message: 'Failed to retrieve public settings' },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -107,7 +108,7 @@ router.get(
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get ${req.params.category} settings:`, error);
|
||||
res.status(500).json({
|
||||
error: { message: 'Failed to retrieve settings' }
|
||||
error: { message: 'Failed to retrieve settings' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -132,8 +133,8 @@ router.patch(
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Invalid request body',
|
||||
details: validation.error.errors
|
||||
}
|
||||
details: validation.error.errors,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -155,12 +156,12 @@ router.patch(
|
||||
// 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 }
|
||||
error: { message: error.message },
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: { message: 'Failed to update settings' }
|
||||
error: { message: 'Failed to update settings' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -181,7 +182,7 @@ router.get(
|
||||
|
||||
if (value === null) {
|
||||
return res.status(404).json({
|
||||
error: { message: `Setting '${fullKey}' not found` }
|
||||
error: { message: `Setting '${fullKey}' not found` },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -189,7 +190,7 @@ router.get(
|
||||
} 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' }
|
||||
error: { message: 'Failed to retrieve setting' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -199,7 +200,7 @@ router.get(
|
||||
// UPDATE SINGLE SETTING (Admin Only)
|
||||
// ===================================
|
||||
const updateSettingSchema = z.object({
|
||||
value: z.any()
|
||||
value: z.any(),
|
||||
});
|
||||
|
||||
router.patch(
|
||||
@@ -217,8 +218,8 @@ router.patch(
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Invalid request body',
|
||||
details: validation.error.errors
|
||||
}
|
||||
details: validation.error.errors,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -237,12 +238,12 @@ router.patch(
|
||||
// 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 }
|
||||
error: { message: error.message },
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: { message: 'Failed to update setting' }
|
||||
error: { message: 'Failed to update setting' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -262,7 +263,7 @@ router.get(
|
||||
} catch (error) {
|
||||
logger.error('Failed to get cache stats:', error);
|
||||
res.status(500).json({
|
||||
error: { message: 'Failed to retrieve cache statistics' }
|
||||
error: { message: 'Failed to retrieve cache statistics' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -290,7 +291,7 @@ router.post(
|
||||
} catch (error) {
|
||||
logger.error('Failed to clear cache:', error);
|
||||
res.status(500).json({
|
||||
error: { message: 'Failed to clear cache' }
|
||||
error: { message: 'Failed to clear cache' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -314,14 +315,14 @@ router.get(
|
||||
totalSize: 'Total cached entries',
|
||||
ttl: {
|
||||
userPermissions: '5 minutes',
|
||||
rolePermissions: '10 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' }
|
||||
error: { message: 'Failed to retrieve permission cache statistics' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -357,14 +358,15 @@ router.post(
|
||||
} else {
|
||||
res.status(400).json({
|
||||
error: {
|
||||
message: 'Invalid request. Specify type: "user", "role", "users", "roles", or "all". For "user" or "role", provide an id.'
|
||||
}
|
||||
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' }
|
||||
error: { message: 'Failed to clear permission cache' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+23
-17
@@ -13,27 +13,33 @@ router.use(rateLimitStandard);
|
||||
router.use(optionalAuth);
|
||||
|
||||
// GET /api/tags - List all unique tags
|
||||
router.get('/', asyncHandler(async (req, res) => {
|
||||
// Get all games with tags
|
||||
const sql = `SELECT DISTINCT tagsStr FROM game WHERE tagsStr IS NOT NULL AND tagsStr != ''`;
|
||||
const results = DatabaseService.all(sql, []) as Array<{ tagsStr: string }>;
|
||||
router.get(
|
||||
'/',
|
||||
asyncHandler(async (req, res) => {
|
||||
// Get all games with tags
|
||||
const sql = `SELECT DISTINCT tagsStr FROM game WHERE tagsStr IS NOT NULL AND tagsStr != ''`;
|
||||
const results = DatabaseService.all(sql, []) as Array<{ tagsStr: string }>;
|
||||
|
||||
// Parse tags (semicolon-separated) and count occurrences
|
||||
const tagCounts = new Map<string, number>();
|
||||
// Parse tags (semicolon-separated) and count occurrences
|
||||
const tagCounts = new Map<string, number>();
|
||||
|
||||
results.forEach(row => {
|
||||
const tags = row.tagsStr.split(';').map(t => t.trim()).filter(Boolean);
|
||||
tags.forEach(tag => {
|
||||
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
|
||||
results.forEach((row) => {
|
||||
const tags = row.tagsStr
|
||||
.split(';')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
tags.forEach((tag) => {
|
||||
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Convert to array and sort by count
|
||||
const tags = Array.from(tagCounts.entries())
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
// Convert to array and sort by count
|
||||
const tags = Array.from(tagCounts.entries())
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
res.json(tags);
|
||||
}));
|
||||
res.json(tags);
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -17,11 +17,14 @@ const metadataSyncService = new MetadataSyncService();
|
||||
* GET /api/updates/check
|
||||
* Check for available updates
|
||||
*/
|
||||
router.get('/check', asyncHandler(async (req, res) => {
|
||||
logger.info('[Updates API] Checking for updates...');
|
||||
const updateInfo = await updateService.checkForUpdates();
|
||||
res.json(updateInfo);
|
||||
}));
|
||||
router.get(
|
||||
'/check',
|
||||
asyncHandler(async (req, res) => {
|
||||
logger.info('[Updates API] Checking for updates...');
|
||||
const updateInfo = await updateService.checkForUpdates();
|
||||
res.json(updateInfo);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/updates/install
|
||||
@@ -64,12 +67,15 @@ router.get(
|
||||
* Returns info about game database updates from FPFSS
|
||||
* Includes edition info so frontend knows whether sync is available
|
||||
*/
|
||||
router.get('/metadata', asyncHandler(async (req, res) => {
|
||||
const edition = metadataUpdateService.getEdition();
|
||||
logger.info(`[Updates API] Checking for metadata updates... (edition: ${edition})`);
|
||||
const metadataInfo = await metadataUpdateService.getMetadataUpdateInfo();
|
||||
res.json({ ...metadataInfo, edition });
|
||||
}));
|
||||
router.get(
|
||||
'/metadata',
|
||||
asyncHandler(async (req, res) => {
|
||||
const edition = metadataUpdateService.getEdition();
|
||||
logger.info(`[Updates API] Checking for metadata updates... (edition: ${edition})`);
|
||||
const metadataInfo = await metadataUpdateService.getMetadataUpdateInfo();
|
||||
res.json({ ...metadataInfo, edition });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/updates/metadata/sync
|
||||
@@ -92,23 +98,24 @@ router.post(
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
error: 'Sync already in progress',
|
||||
status: syncStatus.getStatus()
|
||||
status: syncStatus.getStatus(),
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`[Updates API] Starting metadata sync in background (requested by ${req.user?.username})...`);
|
||||
logger.info(
|
||||
`[Updates API] Starting metadata sync in background (requested by ${req.user?.username})...`
|
||||
);
|
||||
|
||||
// Start sync in background (don't await - let it run async)
|
||||
metadataSyncService.syncMetadata()
|
||||
.catch((error) => {
|
||||
logger.error('[Updates API] Background sync error:', error);
|
||||
});
|
||||
metadataSyncService.syncMetadata().catch((error) => {
|
||||
logger.error('[Updates API] Background sync error:', error);
|
||||
});
|
||||
|
||||
// Return immediately
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Sync started',
|
||||
status: syncStatus.getStatus()
|
||||
status: syncStatus.getStatus(),
|
||||
});
|
||||
})
|
||||
);
|
||||
@@ -129,7 +136,7 @@ router.get('/metadata/sync/status', (req, res) => {
|
||||
stage: 'error',
|
||||
progress: 0,
|
||||
message: 'Error getting sync status',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -26,30 +26,30 @@ router.use(requireFeature('enablePlaylists'));
|
||||
const createPlaylistSchema = z.object({
|
||||
title: z.string().min(1).max(255),
|
||||
description: z.string().optional(),
|
||||
icon: z.string().optional()
|
||||
icon: z.string().optional(),
|
||||
});
|
||||
|
||||
const updatePlaylistSchema = z.object({
|
||||
title: z.string().min(1).max(255).optional(),
|
||||
description: z.string().optional(),
|
||||
icon: z.string().optional()
|
||||
icon: z.string().optional(),
|
||||
});
|
||||
|
||||
const addGamesSchema = z.object({
|
||||
gameIds: z.array(z.string()).min(1)
|
||||
gameIds: z.array(z.string()).min(1),
|
||||
});
|
||||
|
||||
const removeGamesSchema = z.object({
|
||||
gameIds: z.array(z.string()).min(1)
|
||||
gameIds: z.array(z.string()).min(1),
|
||||
});
|
||||
|
||||
const reorderGamesSchema = z.object({
|
||||
gameIdOrder: z.array(z.string()).min(1)
|
||||
gameIdOrder: z.array(z.string()).min(1),
|
||||
});
|
||||
|
||||
const copyFlashpointPlaylistSchema = z.object({
|
||||
flashpointPlaylistId: z.string(),
|
||||
newTitle: z.string().optional()
|
||||
newTitle: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -61,7 +61,7 @@ router.get(
|
||||
authenticate,
|
||||
requirePermission('playlists.read'),
|
||||
logActivity('playlists.view.list', 'user_playlists', (req, res) => ({
|
||||
playlistCount: res.locals.playlistCount || 0
|
||||
playlistCount: res.locals.playlistCount || 0,
|
||||
})),
|
||||
asyncHandler(async (req, res) => {
|
||||
if (!req.user) {
|
||||
@@ -106,7 +106,7 @@ router.get(
|
||||
logActivity('playlists.view.detail', 'user_playlists', (req, res) => ({
|
||||
playlistId: req.params.id,
|
||||
playlistTitle: res.locals.playlistTitle || null,
|
||||
gameCount: res.locals.gameCount || 0
|
||||
gameCount: res.locals.gameCount || 0,
|
||||
})),
|
||||
asyncHandler(async (req, res) => {
|
||||
if (!req.user) {
|
||||
@@ -185,7 +185,7 @@ router.patch(
|
||||
requirePermission('playlists.update'),
|
||||
logActivity('playlists.update.metadata', 'user_playlists', (req, _res) => ({
|
||||
playlistId: req.params.id,
|
||||
fieldsUpdated: Object.keys(req.body)
|
||||
fieldsUpdated: Object.keys(req.body),
|
||||
})),
|
||||
asyncHandler(async (req, res) => {
|
||||
if (!req.user) {
|
||||
@@ -248,7 +248,7 @@ router.post(
|
||||
requirePermission('playlists.update'),
|
||||
logActivity('playlists.games.add', 'user_playlist_games', (req) => ({
|
||||
playlistId: req.params.id,
|
||||
gamesAdded: req.body.gameIds?.length || 0
|
||||
gamesAdded: req.body.gameIds?.length || 0,
|
||||
})),
|
||||
asyncHandler(async (req, res) => {
|
||||
if (!req.user) {
|
||||
@@ -273,7 +273,7 @@ router.post(
|
||||
|
||||
res.json({
|
||||
...updatedPlaylist,
|
||||
games
|
||||
games,
|
||||
});
|
||||
})
|
||||
);
|
||||
@@ -289,7 +289,7 @@ router.delete(
|
||||
requirePermission('playlists.update'),
|
||||
logActivity('playlists.games.remove', 'user_playlist_games', (req) => ({
|
||||
playlistId: req.params.id,
|
||||
gamesRemoved: req.body.gameIds?.length || 0
|
||||
gamesRemoved: req.body.gameIds?.length || 0,
|
||||
})),
|
||||
asyncHandler(async (req, res) => {
|
||||
if (!req.user) {
|
||||
@@ -314,7 +314,7 @@ router.delete(
|
||||
|
||||
res.json({
|
||||
...updatedPlaylist,
|
||||
games
|
||||
games,
|
||||
});
|
||||
})
|
||||
);
|
||||
@@ -330,7 +330,7 @@ router.put(
|
||||
requirePermission('playlists.update'),
|
||||
logActivity('playlists.games.reorder', 'user_playlist_games', (req) => ({
|
||||
playlistId: req.params.id,
|
||||
gameCount: req.body.gameIdOrder?.length || 0
|
||||
gameCount: req.body.gameIdOrder?.length || 0,
|
||||
})),
|
||||
asyncHandler(async (req, res) => {
|
||||
if (!req.user) {
|
||||
@@ -355,7 +355,7 @@ router.put(
|
||||
|
||||
res.json({
|
||||
...updatedPlaylist,
|
||||
games
|
||||
games,
|
||||
});
|
||||
})
|
||||
);
|
||||
@@ -412,7 +412,7 @@ router.post(
|
||||
|
||||
const shareData = playlistService.enableSharing(playlistId, req.user.id, {
|
||||
expiresAt,
|
||||
showOwner
|
||||
showOwner,
|
||||
});
|
||||
|
||||
if (!shareData) {
|
||||
@@ -504,7 +504,7 @@ router.patch(
|
||||
|
||||
const shareData = playlistService.updateShareSettings(playlistId, req.user.id, {
|
||||
expiresAt,
|
||||
showOwner
|
||||
showOwner,
|
||||
});
|
||||
|
||||
if (!shareData) {
|
||||
|
||||
+10
-14
@@ -16,28 +16,28 @@ const createUserSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6),
|
||||
roleId: z.number().int().positive(),
|
||||
isActive: z.boolean().optional()
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const updateUserSchema = z.object({
|
||||
email: z.string().email().optional(),
|
||||
roleId: z.number().int().positive().optional(),
|
||||
isActive: z.boolean().optional()
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const changePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(6),
|
||||
newPassword: z.string().min(6)
|
||||
newPassword: z.string().min(6),
|
||||
});
|
||||
|
||||
const updateThemeSchema = z.object({
|
||||
themeColor: z.string().min(1),
|
||||
surfaceColor: z.string().min(1).optional()
|
||||
surfaceColor: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
const updateThemeSettingsSchema = z.object({
|
||||
mode: z.enum(['light', 'dark', 'system']),
|
||||
primaryColor: z.string().min(1)
|
||||
primaryColor: z.string().min(1),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -97,7 +97,7 @@ router.post(
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
roleId: data.roleId,
|
||||
isActive: data.isActive ?? true
|
||||
isActive: data.isActive ?? true,
|
||||
});
|
||||
|
||||
res.status(201).json(user);
|
||||
@@ -114,7 +114,7 @@ router.patch(
|
||||
requirePermission('users.update'),
|
||||
logActivity('users.update.profile', 'users', (req) => ({
|
||||
userId: req.params.id,
|
||||
fieldsUpdated: Object.keys(req.body)
|
||||
fieldsUpdated: Object.keys(req.body),
|
||||
})),
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
@@ -185,7 +185,7 @@ router.get(
|
||||
// Return in old format for compatibility
|
||||
res.json({
|
||||
themeColor: `${themeSettings.primaryColor}-500`,
|
||||
surfaceColor: 'slate-700'
|
||||
surfaceColor: 'slate-700',
|
||||
});
|
||||
})
|
||||
);
|
||||
@@ -212,7 +212,7 @@ router.patch(
|
||||
res.json({
|
||||
success: true,
|
||||
themeColor: data.themeColor,
|
||||
surfaceColor: data.surfaceColor || 'slate-700'
|
||||
surfaceColor: data.surfaceColor || 'slate-700',
|
||||
});
|
||||
})
|
||||
);
|
||||
@@ -245,11 +245,7 @@ router.patch(
|
||||
const userId = req.user!.id;
|
||||
const validated = updateThemeSettingsSchema.parse(req.body);
|
||||
|
||||
await userService.updateThemeSettings(
|
||||
userId,
|
||||
validated.mode,
|
||||
validated.primaryColor
|
||||
);
|
||||
await userService.updateThemeSettings(userId, validated.mode, validated.primaryColor);
|
||||
|
||||
const updated = await userService.getThemeSettings(userId);
|
||||
res.json(updated);
|
||||
|
||||
+58
-39
@@ -29,36 +29,40 @@ async function startServer() {
|
||||
const app: Express = express();
|
||||
|
||||
// Security middleware
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: false, // Will be configured in frontend via meta tag
|
||||
crossOriginEmbedderPolicy: false,
|
||||
frameguard: {
|
||||
action: 'deny' // Equivalent to frame-ancestors 'none'
|
||||
}
|
||||
}));
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: false, // Will be configured in frontend via meta tag
|
||||
crossOriginEmbedderPolicy: false,
|
||||
frameguard: {
|
||||
action: 'deny', // Equivalent to frame-ancestors 'none'
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// CORS - dynamic origin to support configured domains
|
||||
app.use(cors({
|
||||
origin: (origin, callback) => {
|
||||
// Allow same-origin and server-to-server requests
|
||||
if (!origin) return callback(null, true);
|
||||
app.use(
|
||||
cors({
|
||||
origin: (origin, callback) => {
|
||||
// Allow same-origin and server-to-server requests
|
||||
if (!origin) return callback(null, true);
|
||||
|
||||
// Always allow the configured domain from env
|
||||
if (origin === config.domain) return callback(null, true);
|
||||
// Always allow the configured domain from env
|
||||
if (origin === config.domain) return callback(null, true);
|
||||
|
||||
// Check domains from the database
|
||||
try {
|
||||
const domainService = new DomainService();
|
||||
const allowed = domainService.getAllowedOrigins();
|
||||
if (allowed.has(origin)) return callback(null, true);
|
||||
} catch {
|
||||
// DB not ready during startup - fall through
|
||||
}
|
||||
// Check domains from the database
|
||||
try {
|
||||
const domainService = new DomainService();
|
||||
const allowed = domainService.getAllowedOrigins();
|
||||
if (allowed.has(origin)) return callback(null, true);
|
||||
} catch {
|
||||
// DB not ready during startup - fall through
|
||||
}
|
||||
|
||||
callback(new Error('Not allowed by CORS'));
|
||||
},
|
||||
credentials: true
|
||||
}));
|
||||
callback(new Error('Not allowed by CORS'));
|
||||
},
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Body parsing with size limits (prevent DoS attacks)
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
@@ -116,7 +120,7 @@ async function startServer() {
|
||||
|
||||
// Pre-warm game search cache for instant first page load
|
||||
// Run asynchronously to not block server startup
|
||||
GameSearchCache.prewarmCache().catch(error => {
|
||||
GameSearchCache.prewarmCache().catch((error) => {
|
||||
logger.warn('Failed to pre-warm game search cache:', error);
|
||||
// Non-fatal: server continues without pre-warmed cache
|
||||
});
|
||||
@@ -148,9 +152,15 @@ async function startServer() {
|
||||
JobScheduler.registerJob({
|
||||
id: 'metadata-sync',
|
||||
name: 'Metadata Sync',
|
||||
enabled: typeof jobSettings.metadataSyncEnabled === 'boolean' ? jobSettings.metadataSyncEnabled : false,
|
||||
cronSchedule: typeof jobSettings.metadataSyncSchedule === 'string' ? jobSettings.metadataSyncSchedule : '0 * * * *', // Default: hourly
|
||||
run: () => metadataSyncJob.run()
|
||||
enabled:
|
||||
typeof jobSettings.metadataSyncEnabled === 'boolean'
|
||||
? jobSettings.metadataSyncEnabled
|
||||
: false,
|
||||
cronSchedule:
|
||||
typeof jobSettings.metadataSyncSchedule === 'string'
|
||||
? jobSettings.metadataSyncSchedule
|
||||
: '0 * * * *', // Default: hourly
|
||||
run: () => metadataSyncJob.run(),
|
||||
});
|
||||
|
||||
// Register Ruffle update job
|
||||
@@ -158,9 +168,15 @@ async function startServer() {
|
||||
JobScheduler.registerJob({
|
||||
id: 'ruffle-update',
|
||||
name: 'Ruffle Update',
|
||||
enabled: typeof jobSettings.ruffleUpdateEnabled === 'boolean' ? jobSettings.ruffleUpdateEnabled : false,
|
||||
cronSchedule: typeof jobSettings.ruffleUpdateSchedule === 'string' ? jobSettings.ruffleUpdateSchedule : '0 0 * * *', // Default: daily at midnight
|
||||
run: () => ruffleUpdateJob.run()
|
||||
enabled:
|
||||
typeof jobSettings.ruffleUpdateEnabled === 'boolean'
|
||||
? jobSettings.ruffleUpdateEnabled
|
||||
: false,
|
||||
cronSchedule:
|
||||
typeof jobSettings.ruffleUpdateSchedule === 'string'
|
||||
? jobSettings.ruffleUpdateSchedule
|
||||
: '0 0 * * *', // Default: daily at midnight
|
||||
run: () => ruffleUpdateJob.run(),
|
||||
});
|
||||
|
||||
// Start all enabled jobs
|
||||
@@ -184,7 +200,7 @@ async function startServer() {
|
||||
timestamp: new Date().toISOString(),
|
||||
flashpointPath: config.flashpointPath,
|
||||
databaseConnected: DatabaseService.isConnected(),
|
||||
gameServiceUrl: config.gameServerUrl
|
||||
gameServiceUrl: config.gameServerUrl,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -224,14 +240,17 @@ async function startServer() {
|
||||
|
||||
// Start cleanup scheduler for abandoned play sessions (every 6 hours)
|
||||
const playTrackingService = new PlayTrackingService();
|
||||
const cleanupInterval = setInterval(() => {
|
||||
playTrackingService.cleanupAbandonedSessions().catch(error => {
|
||||
logger.error('Failed to cleanup abandoned sessions:', error);
|
||||
});
|
||||
}, 6 * 60 * 60 * 1000); // 6 hours
|
||||
const cleanupInterval = setInterval(
|
||||
() => {
|
||||
playTrackingService.cleanupAbandonedSessions().catch((error) => {
|
||||
logger.error('Failed to cleanup abandoned sessions:', error);
|
||||
});
|
||||
},
|
||||
6 * 60 * 60 * 1000
|
||||
); // 6 hours
|
||||
|
||||
// Run initial cleanup
|
||||
playTrackingService.cleanupAbandonedSessions().catch(error => {
|
||||
playTrackingService.cleanupAbandonedSessions().catch((error) => {
|
||||
logger.error('Failed to cleanup abandoned sessions:', error);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { UserDatabaseService } from './UserDatabaseService';
|
||||
import { ActivityLog, LogActivityData } from '../types/auth';
|
||||
import { PaginatedResponse, createPaginatedResponse, calculateOffset } from '../utils/pagination';
|
||||
import { ActivityBreakdownRow, UserBreakdownRow, ResourceBreakdownRow, IpBreakdownRow } from '../types/database-rows';
|
||||
import {
|
||||
ActivityBreakdownRow,
|
||||
UserBreakdownRow,
|
||||
ResourceBreakdownRow,
|
||||
IpBreakdownRow,
|
||||
} from '../types/database-rows';
|
||||
import { TimeRange, getHoursFromTimeRange } from '../utils/timeRangeUtils';
|
||||
import { AUTH_ACTIONS, categorizeAction } from '../utils/activityUtils';
|
||||
|
||||
@@ -58,9 +63,7 @@ export class ActivityService {
|
||||
params.push(filters.endDate);
|
||||
}
|
||||
|
||||
const sql = conditions.length > 0
|
||||
? 'WHERE ' + conditions.join(' AND ')
|
||||
: '';
|
||||
const sql = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '';
|
||||
|
||||
return { sql, params };
|
||||
}
|
||||
@@ -83,7 +86,7 @@ export class ActivityService {
|
||||
data.resourceId || null,
|
||||
detailsJson,
|
||||
data.ipAddress || null,
|
||||
data.userAgent || null
|
||||
data.userAgent || null,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -105,7 +108,7 @@ export class ActivityService {
|
||||
username: 'username',
|
||||
action: 'action',
|
||||
resource: 'resource',
|
||||
ipAddress: 'ip_address'
|
||||
ipAddress: 'ip_address',
|
||||
};
|
||||
const dbColumn = columnMap[sortBy] || 'created_at';
|
||||
const order = sortOrder.toUpperCase();
|
||||
@@ -121,10 +124,12 @@ export class ActivityService {
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
|
||||
const logs = UserDatabaseService.all<ActivityLog>(sql, [...whereParams, limit, offset]).map(log => ({
|
||||
...log,
|
||||
details: log.details ? JSON.parse(log.details as string) : null
|
||||
}));
|
||||
const logs = UserDatabaseService.all<ActivityLog>(sql, [...whereParams, limit, offset]).map(
|
||||
(log) => ({
|
||||
...log,
|
||||
details: log.details ? JSON.parse(log.details as string) : null,
|
||||
})
|
||||
);
|
||||
|
||||
const countSql = `SELECT COUNT(*) as count FROM activity_logs ${whereClause}`;
|
||||
const total = UserDatabaseService.get<{ count: number }>(countSql, whereParams)?.count || 0;
|
||||
@@ -146,7 +151,10 @@ export class ActivityService {
|
||||
/**
|
||||
* Get aggregate statistics for dashboard
|
||||
*/
|
||||
async getStats(timeRange: TimeRange = '24h', customRange?: { startDate?: string; endDate?: string }) {
|
||||
async getStats(
|
||||
timeRange: TimeRange = '24h',
|
||||
customRange?: { startDate?: string; endDate?: string }
|
||||
) {
|
||||
let startDate: string;
|
||||
let endDate: string = new Date().toISOString();
|
||||
|
||||
@@ -195,7 +203,9 @@ export class ActivityService {
|
||||
) as { hour: number; count: number } | null;
|
||||
|
||||
// Get previous period stats for trends
|
||||
const previousPeriodStart = new Date(new Date(startDate).getTime() - (new Date(endDate).getTime() - new Date(startDate).getTime())).toISOString();
|
||||
const previousPeriodStart = new Date(
|
||||
new Date(startDate).getTime() - (new Date(endDate).getTime() - new Date(startDate).getTime())
|
||||
).toISOString();
|
||||
const previousStats = UserDatabaseService.get(
|
||||
`SELECT
|
||||
COUNT(*) as total,
|
||||
@@ -235,21 +245,21 @@ export class ActivityService {
|
||||
? {
|
||||
hour: peakHourData.hour,
|
||||
count: peakHourData.count,
|
||||
formattedRange: formatHourRange(peakHourData.hour)
|
||||
formattedRange: formatHourRange(peakHourData.hour),
|
||||
}
|
||||
: { hour: 0, count: 0, formattedRange: '12:00 AM - 1:00 AM' },
|
||||
authEvents: {
|
||||
total: currentStats?.authEvents || 0,
|
||||
successful: currentStats?.authSuccessful || 0,
|
||||
failed: currentStats?.authFailed || 0
|
||||
failed: currentStats?.authFailed || 0,
|
||||
},
|
||||
failedOperations: currentStats?.failedOperations || 0,
|
||||
systemEvents: currentStats?.systemEvents || 0,
|
||||
trends: {
|
||||
totalChange: calculateTrend(currentStats?.total || 0, previousStats?.total || 0),
|
||||
userChange: calculateTrend(currentStats?.uniqueUsers || 0, previousStats?.uniqueUsers || 0),
|
||||
authChange: calculateTrend(currentStats?.authEvents || 0, previousStats?.authEvents || 0)
|
||||
}
|
||||
authChange: calculateTrend(currentStats?.authEvents || 0, previousStats?.authEvents || 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -264,7 +274,7 @@ export class ActivityService {
|
||||
if (granularity === 'hour') {
|
||||
groupFormat = "datetime(strftime('%Y-%m-%d %H:00:00', created_at))";
|
||||
} else {
|
||||
groupFormat = "date(created_at)";
|
||||
groupFormat = 'date(created_at)';
|
||||
}
|
||||
|
||||
const trendData = UserDatabaseService.all(
|
||||
@@ -293,8 +303,8 @@ export class ActivityService {
|
||||
granularity,
|
||||
startDate,
|
||||
endDate: new Date().toISOString(),
|
||||
dataPoints: trendData.length
|
||||
}
|
||||
dataPoints: trendData.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -353,7 +363,7 @@ export class ActivityService {
|
||||
}>;
|
||||
|
||||
return {
|
||||
data: topActions.map(action => ({
|
||||
data: topActions.map((action) => ({
|
||||
action: action.action,
|
||||
count: action.count,
|
||||
percentage: totalActivities > 0 ? (action.count / totalActivities) * 100 : 0,
|
||||
@@ -361,13 +371,13 @@ export class ActivityService {
|
||||
topResource: action.topResource,
|
||||
exampleActivity: {
|
||||
username: action.exampleUsername || 'System',
|
||||
timestamp: action.exampleTimestamp
|
||||
}
|
||||
timestamp: action.exampleTimestamp,
|
||||
},
|
||||
})),
|
||||
meta: {
|
||||
totalActivities,
|
||||
uniqueActions: topActions.length
|
||||
}
|
||||
uniqueActions: topActions.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -450,33 +460,41 @@ export class ActivityService {
|
||||
throw new Error(`Invalid groupBy parameter: ${groupBy}`);
|
||||
}
|
||||
|
||||
const results = UserDatabaseService.all(sql, groupBy === 'user' || groupBy === 'resource' ? [startDate, startDate, limit] : [startDate, limit]) as ActivityBreakdownRow[];
|
||||
const results = UserDatabaseService.all(
|
||||
sql,
|
||||
groupBy === 'user' || groupBy === 'resource'
|
||||
? [startDate, startDate, limit]
|
||||
: [startDate, limit]
|
||||
) as ActivityBreakdownRow[];
|
||||
|
||||
return {
|
||||
data: results.map((row: ActivityBreakdownRow) => ({
|
||||
key: row.key || 'Unknown',
|
||||
count: row.count,
|
||||
percentage: totalActivities > 0 ? (row.count / totalActivities) * 100 : 0,
|
||||
metadata: groupBy === 'user'
|
||||
? {
|
||||
userId: (row as UserBreakdownRow).userId,
|
||||
lastActivity: (row as UserBreakdownRow).lastActivity,
|
||||
topAction: (row as UserBreakdownRow).topAction
|
||||
}
|
||||
: groupBy === 'ip'
|
||||
? {
|
||||
associatedUserCount: (row as IpBreakdownRow).associatedUserCount,
|
||||
associatedUsers: (row as IpBreakdownRow).associatedUsers ? (row as IpBreakdownRow).associatedUsers.split(',').slice(0, 5) : [],
|
||||
failedAttempts: (row as IpBreakdownRow).failedAttempts
|
||||
}
|
||||
: {
|
||||
topAction: (row as ResourceBreakdownRow).topAction
|
||||
}
|
||||
metadata:
|
||||
groupBy === 'user'
|
||||
? {
|
||||
userId: (row as UserBreakdownRow).userId,
|
||||
lastActivity: (row as UserBreakdownRow).lastActivity,
|
||||
topAction: (row as UserBreakdownRow).topAction,
|
||||
}
|
||||
: groupBy === 'ip'
|
||||
? {
|
||||
associatedUserCount: (row as IpBreakdownRow).associatedUserCount,
|
||||
associatedUsers: (row as IpBreakdownRow).associatedUsers
|
||||
? (row as IpBreakdownRow).associatedUsers.split(',').slice(0, 5)
|
||||
: [],
|
||||
failedAttempts: (row as IpBreakdownRow).failedAttempts,
|
||||
}
|
||||
: {
|
||||
topAction: (row as ResourceBreakdownRow).topAction,
|
||||
},
|
||||
})),
|
||||
meta: {
|
||||
groupBy,
|
||||
total: totalActivities
|
||||
}
|
||||
total: totalActivities,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,10 @@ export class AuthService {
|
||||
/**
|
||||
* Login user with username/password
|
||||
*/
|
||||
async login(credentials: LoginCredentials, ipAddress: string): Promise<{ user: AuthUser; tokens: AuthTokens }> {
|
||||
async login(
|
||||
credentials: LoginCredentials,
|
||||
ipAddress: string
|
||||
): Promise<{ user: AuthUser; tokens: AuthTokens }> {
|
||||
try {
|
||||
const { username, password } = credentials;
|
||||
|
||||
@@ -52,10 +55,10 @@ export class AuthService {
|
||||
await this.recordLoginAttempt(username, ipAddress, true);
|
||||
|
||||
// Update last login
|
||||
UserDatabaseService.run(
|
||||
"UPDATE users SET last_login_at = ? WHERE id = ?",
|
||||
[new Date().toISOString(), user.id]
|
||||
);
|
||||
UserDatabaseService.run('UPDATE users SET last_login_at = ? WHERE id = ?', [
|
||||
new Date().toISOString(),
|
||||
user.id,
|
||||
]);
|
||||
|
||||
// Get user permissions
|
||||
const permissions = this.getUserPermissions(user.id);
|
||||
@@ -71,7 +74,7 @@ export class AuthService {
|
||||
role: user.role_name,
|
||||
permissions,
|
||||
themeColor: user.theme_color || 'blue-500',
|
||||
surfaceColor: user.surface_color || 'slate-700'
|
||||
surfaceColor: user.surface_color || 'slate-700',
|
||||
};
|
||||
|
||||
return { user: authUser, tokens };
|
||||
@@ -79,7 +82,7 @@ export class AuthService {
|
||||
logger.error('[AuthService] Login error:', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
username: credentials.username
|
||||
username: credentials.username,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
@@ -108,19 +111,17 @@ export class AuthService {
|
||||
}
|
||||
|
||||
// Check if username exists
|
||||
const existingUser = UserDatabaseService.get(
|
||||
'SELECT id FROM users WHERE username = ?',
|
||||
[data.username]
|
||||
);
|
||||
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]
|
||||
);
|
||||
const existingEmail = UserDatabaseService.get('SELECT id FROM users WHERE email = ?', [
|
||||
data.email,
|
||||
]);
|
||||
if (existingEmail) {
|
||||
throw new AppError(409, 'Email already exists');
|
||||
}
|
||||
@@ -158,7 +159,9 @@ export class AuthService {
|
||||
[userId, defaultTheme, userId, defaultPrimaryColor]
|
||||
);
|
||||
|
||||
logger.info(`[AuthService] Created user ${data.username} with default theme: ${defaultTheme}, primary color: ${defaultPrimaryColor}`);
|
||||
logger.info(
|
||||
`[AuthService] Created user ${data.username} with default theme: ${defaultTheme}, primary color: ${defaultPrimaryColor}`
|
||||
);
|
||||
|
||||
// Get created user
|
||||
const user = UserDatabaseService.get(
|
||||
@@ -182,7 +185,7 @@ export class AuthService {
|
||||
role: user.role_name,
|
||||
permissions,
|
||||
themeColor: user.theme_color || 'blue-500',
|
||||
surfaceColor: user.surface_color || 'slate-700'
|
||||
surfaceColor: user.surface_color || 'slate-700',
|
||||
};
|
||||
|
||||
return { user: authUser, tokens };
|
||||
@@ -192,10 +195,10 @@ export class AuthService {
|
||||
* Logout user (revoke refresh token)
|
||||
*/
|
||||
async logout(refreshToken: string): Promise<void> {
|
||||
UserDatabaseService.run(
|
||||
"UPDATE refresh_tokens SET revoked_at = ? WHERE token = ?",
|
||||
[new Date().toISOString(), refreshToken]
|
||||
);
|
||||
UserDatabaseService.run('UPDATE refresh_tokens SET revoked_at = ? WHERE token = ?', [
|
||||
new Date().toISOString(),
|
||||
refreshToken,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,7 +207,7 @@ export class AuthService {
|
||||
*/
|
||||
async revokeAllUserTokens(userId: number): Promise<void> {
|
||||
UserDatabaseService.run(
|
||||
"UPDATE refresh_tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL",
|
||||
'UPDATE refresh_tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL',
|
||||
[new Date().toISOString(), userId]
|
||||
);
|
||||
|
||||
@@ -240,10 +243,10 @@ export class AuthService {
|
||||
}
|
||||
|
||||
// Revoke the old refresh token (security best practice)
|
||||
UserDatabaseService.run(
|
||||
"UPDATE refresh_tokens SET revoked_at = ? WHERE token = ?",
|
||||
[new Date().toISOString(), refreshToken]
|
||||
);
|
||||
UserDatabaseService.run('UPDATE refresh_tokens SET revoked_at = ? WHERE token = ?', [
|
||||
new Date().toISOString(),
|
||||
refreshToken,
|
||||
]);
|
||||
|
||||
logger.debug(`[AuthService] Revoked old refresh token for user ${user.id}`);
|
||||
|
||||
@@ -278,7 +281,7 @@ export class AuthService {
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role_name,
|
||||
permissions
|
||||
permissions,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new AppError(401, 'Invalid or expired token');
|
||||
@@ -305,7 +308,7 @@ export class AuthService {
|
||||
[userId]
|
||||
);
|
||||
|
||||
const permissionNames = permissions.map(p => p.name);
|
||||
const permissionNames = permissions.map((p) => p.name);
|
||||
|
||||
// Store in cache
|
||||
PermissionCache.setUserPermissions(userId, permissionNames);
|
||||
@@ -320,7 +323,7 @@ export class AuthService {
|
||||
const accessToken = generateAccessToken({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
role: user.role_name
|
||||
role: user.role_name,
|
||||
});
|
||||
|
||||
const refreshToken = generateRefreshToken();
|
||||
@@ -335,7 +338,7 @@ export class AuthService {
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: 3600 // 1 hour
|
||||
expiresIn: 3600, // 1 hour
|
||||
};
|
||||
}
|
||||
|
||||
@@ -349,7 +352,10 @@ export class AuthService {
|
||||
|
||||
// Validate and sanitize lockoutDuration to prevent SQL injection
|
||||
// Ensure it's a number between 1 and 1440 (24 hours)
|
||||
const safeLockoutDuration = Math.min(Math.max(parseInt(String(lockoutDuration), 10) || 15, 1), 1440);
|
||||
const safeLockoutDuration = Math.min(
|
||||
Math.max(parseInt(String(lockoutDuration), 10) || 15, 1),
|
||||
1440
|
||||
);
|
||||
|
||||
// Count failed attempts in lockout window - using parameterized query
|
||||
const failedAttempts = UserDatabaseService.get(
|
||||
@@ -361,14 +367,21 @@ export class AuthService {
|
||||
);
|
||||
|
||||
if (failedAttempts?.count >= maxAttempts) {
|
||||
throw new AppError(429, `Too many login attempts. Please try again in ${safeLockoutDuration} minutes.`);
|
||||
throw new AppError(
|
||||
429,
|
||||
`Too many login attempts. Please try again in ${safeLockoutDuration} minutes.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record login attempt
|
||||
*/
|
||||
private async recordLoginAttempt(username: string, ipAddress: string, success: boolean): Promise<void> {
|
||||
private async recordLoginAttempt(
|
||||
username: string,
|
||||
ipAddress: string,
|
||||
success: boolean
|
||||
): Promise<void> {
|
||||
UserDatabaseService.run(
|
||||
'INSERT INTO login_attempts (username, ip_address, success) VALUES (?, ?, ?)',
|
||||
[username, ipAddress, success ? 1 : 0]
|
||||
@@ -389,10 +402,20 @@ export class AuthService {
|
||||
|
||||
// Return settings with defaults if not found
|
||||
return {
|
||||
guest_access_enabled: authSettings.guestAccessEnabled !== undefined ? (authSettings.guestAccessEnabled ? 1 : 0) : 1,
|
||||
user_registration_enabled: authSettings.userRegistrationEnabled !== undefined ? (authSettings.userRegistrationEnabled ? 1 : 0) : 1,
|
||||
guest_access_enabled:
|
||||
authSettings.guestAccessEnabled !== undefined
|
||||
? authSettings.guestAccessEnabled
|
||||
? 1
|
||||
: 0
|
||||
: 1,
|
||||
user_registration_enabled:
|
||||
authSettings.userRegistrationEnabled !== undefined
|
||||
? authSettings.userRegistrationEnabled
|
||||
? 1
|
||||
: 0
|
||||
: 1,
|
||||
max_login_attempts: authSettings.maxLoginAttempts || 5,
|
||||
lockout_duration_minutes: authSettings.lockoutDurationMinutes || 15
|
||||
lockout_duration_minutes: authSettings.lockoutDurationMinutes || 15,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get auth settings, using defaults:', error);
|
||||
@@ -401,7 +424,7 @@ export class AuthService {
|
||||
guest_access_enabled: 1,
|
||||
user_registration_enabled: 1,
|
||||
max_login_attempts: 5,
|
||||
lockout_duration_minutes: 15
|
||||
lockout_duration_minutes: 15,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,14 +24,30 @@ export class AuthSettingsService {
|
||||
const latestUpdate = this.getLatestUpdate();
|
||||
|
||||
return {
|
||||
guestAccessEnabled: typeof authSettings.guestAccessEnabled === 'boolean' ? authSettings.guestAccessEnabled : true,
|
||||
userRegistrationEnabled: typeof authSettings.userRegistrationEnabled === 'boolean' ? authSettings.userRegistrationEnabled : true,
|
||||
requireEmailVerification: typeof authSettings.requireEmailVerification === 'boolean' ? authSettings.requireEmailVerification : false,
|
||||
sessionTimeoutMinutes: typeof authSettings.sessionTimeoutMinutes === 'number' ? authSettings.sessionTimeoutMinutes : 60,
|
||||
maxLoginAttempts: typeof authSettings.maxLoginAttempts === 'number' ? authSettings.maxLoginAttempts : 5,
|
||||
lockoutDurationMinutes: typeof authSettings.lockoutDurationMinutes === 'number' ? authSettings.lockoutDurationMinutes : 15,
|
||||
guestAccessEnabled:
|
||||
typeof authSettings.guestAccessEnabled === 'boolean'
|
||||
? authSettings.guestAccessEnabled
|
||||
: true,
|
||||
userRegistrationEnabled:
|
||||
typeof authSettings.userRegistrationEnabled === 'boolean'
|
||||
? authSettings.userRegistrationEnabled
|
||||
: true,
|
||||
requireEmailVerification:
|
||||
typeof authSettings.requireEmailVerification === 'boolean'
|
||||
? authSettings.requireEmailVerification
|
||||
: false,
|
||||
sessionTimeoutMinutes:
|
||||
typeof authSettings.sessionTimeoutMinutes === 'number'
|
||||
? authSettings.sessionTimeoutMinutes
|
||||
: 60,
|
||||
maxLoginAttempts:
|
||||
typeof authSettings.maxLoginAttempts === 'number' ? authSettings.maxLoginAttempts : 5,
|
||||
lockoutDurationMinutes:
|
||||
typeof authSettings.lockoutDurationMinutes === 'number'
|
||||
? authSettings.lockoutDurationMinutes
|
||||
: 15,
|
||||
updatedAt: latestUpdate.updatedAt,
|
||||
updatedBy: latestUpdate.updatedBy
|
||||
updatedBy: latestUpdate.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,7 +85,7 @@ export class AuthSettingsService {
|
||||
|
||||
return {
|
||||
updatedAt: result?.updated_at || new Date().toISOString(),
|
||||
updatedBy: result?.updated_by
|
||||
updatedBy: result?.updated_by,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export class CachedSystemSettingsService extends SystemSettingsService {
|
||||
if (value !== null) {
|
||||
this.cache.set(key, {
|
||||
value,
|
||||
timestamp: Date.now()
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export class CachedSystemSettingsService extends SystemSettingsService {
|
||||
|
||||
this.categoryCache.set(category, {
|
||||
value,
|
||||
timestamp: Date.now()
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return value;
|
||||
@@ -137,7 +137,7 @@ export class CachedSystemSettingsService extends SystemSettingsService {
|
||||
keyCount: this.cache.size,
|
||||
categoryCount: this.categoryCache.size,
|
||||
hitRate: 0, // Would need hit/miss tracking for accurate calculation
|
||||
size: this.estimateCacheSize()
|
||||
size: this.estimateCacheSize(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -46,18 +46,20 @@ export class CommunityPlaylistService {
|
||||
const response = await axios.get(PLAYLISTS_WIKI_URL, {
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'User-Agent': 'Flashpoint-Webapp/1.0'
|
||||
}
|
||||
'User-Agent': 'Flashpoint-Webapp/1.0',
|
||||
},
|
||||
});
|
||||
|
||||
// Parse HTML and extract playlists
|
||||
const categories = this.parsePlaylistTables(response.data);
|
||||
|
||||
logger.info(`[CommunityPlaylist] Found ${categories.length} categories with ${categories.reduce((sum, cat) => sum + cat.playlists.length, 0)} total playlists`);
|
||||
logger.info(
|
||||
`[CommunityPlaylist] Found ${categories.length} categories with ${categories.reduce((sum, cat) => sum + cat.playlists.length, 0)} total playlists`
|
||||
);
|
||||
|
||||
return {
|
||||
categories,
|
||||
lastFetched: new Date().toISOString()
|
||||
lastFetched: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[CommunityPlaylist] Failed to fetch community playlists:', error);
|
||||
@@ -86,8 +88,8 @@ export class CommunityPlaylistService {
|
||||
const response = await axios.get(downloadUrl, {
|
||||
timeout: 60000,
|
||||
headers: {
|
||||
'User-Agent': 'Flashpoint-Webapp/1.0'
|
||||
}
|
||||
'User-Agent': 'Flashpoint-Webapp/1.0',
|
||||
},
|
||||
});
|
||||
|
||||
const playlistData = response.data;
|
||||
@@ -97,7 +99,7 @@ export class CommunityPlaylistService {
|
||||
logger.error('[CommunityPlaylist] Invalid playlist structure');
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid playlist format'
|
||||
error: 'Invalid playlist format',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,7 +114,7 @@ export class CommunityPlaylistService {
|
||||
return {
|
||||
success: false,
|
||||
conflict: true,
|
||||
error: 'A playlist with this ID already exists'
|
||||
error: 'A playlist with this ID already exists',
|
||||
};
|
||||
} catch {
|
||||
// File doesn't exist - good to proceed
|
||||
@@ -121,11 +123,13 @@ export class CommunityPlaylistService {
|
||||
// Save playlist to disk
|
||||
await fs.writeFile(playlistFilePath, JSON.stringify(playlistData, null, '\t'), 'utf-8');
|
||||
|
||||
logger.info(`[CommunityPlaylist] Downloaded playlist: ${playlistData.title} (${playlistData.id})`);
|
||||
logger.info(
|
||||
`[CommunityPlaylist] Downloaded playlist: ${playlistData.title} (${playlistData.id})`
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
playlist: playlistData
|
||||
playlist: playlistData,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[CommunityPlaylist] Failed to download playlist:', error);
|
||||
@@ -134,26 +138,26 @@ export class CommunityPlaylistService {
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Download timeout - please try again'
|
||||
error: 'Download timeout - please try again',
|
||||
};
|
||||
}
|
||||
if (error.response?.status === 404) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Playlist not found on server'
|
||||
error: 'Playlist not found on server',
|
||||
};
|
||||
}
|
||||
if (error.response?.status && error.response.status >= 500) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Server error - please try again later'
|
||||
error: 'Server error - please try again later',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to download playlist'
|
||||
error: 'Failed to download playlist',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -177,9 +181,11 @@ export class CommunityPlaylistService {
|
||||
const categoryName = headerText.replace(/\[edit.*?\]/g, '').trim();
|
||||
|
||||
// Skip certain headers
|
||||
if (categoryName.toLowerCase().includes('contents') ||
|
||||
categoryName.toLowerCase().includes('navigation') ||
|
||||
categoryName.toLowerCase().includes('tutorial')) {
|
||||
if (
|
||||
categoryName.toLowerCase().includes('contents') ||
|
||||
categoryName.toLowerCase().includes('navigation') ||
|
||||
categoryName.toLowerCase().includes('tutorial')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -202,36 +208,39 @@ export class CommunityPlaylistService {
|
||||
const playlists: CommunityPlaylist[] = [];
|
||||
|
||||
// Parse table rows (skip header row)
|
||||
$(elem).find('tr').slice(1).each((j, row) => {
|
||||
const cells = $(row).find('td');
|
||||
$(elem)
|
||||
.find('tr')
|
||||
.slice(1)
|
||||
.each((j, row) => {
|
||||
const cells = $(row).find('td');
|
||||
|
||||
if (cells.length >= 4) {
|
||||
const name = $(cells[0]).text().trim();
|
||||
const author = $(cells[1]).text().trim();
|
||||
const description = $(cells[2]).text().trim();
|
||||
const downloadLink = $(cells[3]).find('a').attr('href');
|
||||
if (cells.length >= 4) {
|
||||
const name = $(cells[0]).text().trim();
|
||||
const author = $(cells[1]).text().trim();
|
||||
const description = $(cells[2]).text().trim();
|
||||
const downloadLink = $(cells[3]).find('a').attr('href');
|
||||
|
||||
if (name && downloadLink) {
|
||||
// Make URL absolute
|
||||
const absoluteUrl = this.makeAbsoluteUrl(downloadLink);
|
||||
if (name && downloadLink) {
|
||||
// Make URL absolute
|
||||
const absoluteUrl = this.makeAbsoluteUrl(downloadLink);
|
||||
|
||||
// Determine category name
|
||||
let categoryName = mainCategory;
|
||||
if (mainCategory === 'Games' && subCategory) {
|
||||
// For Games category, create separate categories for each subcategory
|
||||
categoryName = `Games - ${subCategory}`;
|
||||
// Determine category name
|
||||
let categoryName = mainCategory;
|
||||
if (mainCategory === 'Games' && subCategory) {
|
||||
// For Games category, create separate categories for each subcategory
|
||||
categoryName = `Games - ${subCategory}`;
|
||||
}
|
||||
|
||||
playlists.push({
|
||||
name,
|
||||
author: author || 'Unknown',
|
||||
description: description || '',
|
||||
downloadUrl: absoluteUrl,
|
||||
category: categoryName,
|
||||
});
|
||||
}
|
||||
|
||||
playlists.push({
|
||||
name,
|
||||
author: author || 'Unknown',
|
||||
description: description || '',
|
||||
downloadUrl: absoluteUrl,
|
||||
category: categoryName
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Only add category if it has playlists (non-empty)
|
||||
if (playlists.length > 0) {
|
||||
@@ -242,11 +251,11 @@ export class CommunityPlaylistService {
|
||||
}
|
||||
|
||||
// Find or create category
|
||||
let category = categories.find(cat => cat.name === categoryName);
|
||||
let category = categories.find((cat) => cat.name === categoryName);
|
||||
if (!category) {
|
||||
category = {
|
||||
name: categoryName,
|
||||
playlists: []
|
||||
playlists: [],
|
||||
};
|
||||
categories.push(category);
|
||||
}
|
||||
@@ -258,7 +267,7 @@ export class CommunityPlaylistService {
|
||||
});
|
||||
|
||||
// Filter out empty categories
|
||||
return categories.filter(cat => cat.playlists.length > 0);
|
||||
return categories.filter((cat) => cat.playlists.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -52,7 +52,7 @@ export class DatabaseService {
|
||||
logger.info('Database connection opened successfully', {
|
||||
sourcePath: this.sourceDbPath,
|
||||
activePath: this.activeDbPath,
|
||||
usingLocalCopy: this.isUsingLocalCopy
|
||||
usingLocalCopy: this.isUsingLocalCopy,
|
||||
});
|
||||
|
||||
// Start watching source database for changes
|
||||
@@ -128,7 +128,7 @@ export class DatabaseService {
|
||||
mmapSize: this.isUsingLocalCopy ? config.sqliteMmapSize : 0,
|
||||
tempStore: 'MEMORY',
|
||||
journalMode: 'DELETE',
|
||||
synchronous: 'NORMAL'
|
||||
synchronous: 'NORMAL',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to apply some PRAGMA optimizations:', error);
|
||||
@@ -180,7 +180,7 @@ export class DatabaseService {
|
||||
if (sourceStats.mtimeMs > localStats.mtimeMs) {
|
||||
logger.info('Source database is newer than local copy, will update', {
|
||||
sourceModified: new Date(sourceStats.mtimeMs).toISOString(),
|
||||
localModified: new Date(localStats.mtimeMs).toISOString()
|
||||
localModified: new Date(localStats.mtimeMs).toISOString(),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -189,7 +189,7 @@ export class DatabaseService {
|
||||
if (sourceStats.size !== localStats.size) {
|
||||
logger.info('Source database size differs from local copy, will update', {
|
||||
sourceSize: sourceStats.size,
|
||||
localSize: localStats.size
|
||||
localSize: localStats.size,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -211,7 +211,7 @@ export class DatabaseService {
|
||||
try {
|
||||
logger.info('Copying database to local storage...', {
|
||||
source: this.sourceDbPath,
|
||||
destination: config.localDbPath
|
||||
destination: config.localDbPath,
|
||||
});
|
||||
|
||||
// Copy to temp file first (atomic operation)
|
||||
@@ -226,7 +226,7 @@ export class DatabaseService {
|
||||
|
||||
logger.info(`Database copied to local storage in ${duration}ms`, {
|
||||
size: `${sizeMB} MB`,
|
||||
path: config.localDbPath
|
||||
path: config.localDbPath,
|
||||
});
|
||||
} catch (error) {
|
||||
// Clean up temp file if it exists
|
||||
@@ -266,7 +266,7 @@ export class DatabaseService {
|
||||
});
|
||||
|
||||
logger.info('Database file watcher started', {
|
||||
watching: this.sourceDbPath
|
||||
watching: this.sourceDbPath,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to start database file watcher:', error);
|
||||
@@ -418,7 +418,7 @@ export class DatabaseService {
|
||||
sourcePath: this.sourceDbPath,
|
||||
activePath: this.activeDbPath,
|
||||
usingLocalCopy: this.isUsingLocalCopy,
|
||||
lastModified: this.lastModifiedTime ? new Date(this.lastModifiedTime) : null
|
||||
lastModified: this.lastModifiedTime ? new Date(this.lastModifiedTime) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -437,61 +437,77 @@ export class DatabaseService {
|
||||
static exec<T = any>(sql: string, params: any[] = []): T[] {
|
||||
const db = this.getDatabase();
|
||||
|
||||
return measureQueryPerformance(() => {
|
||||
try {
|
||||
const stmt = db.prepare(sql);
|
||||
return stmt.all(params) as T[];
|
||||
} catch (error) {
|
||||
logger.error('Database query error:', { sql, params, error });
|
||||
throw error;
|
||||
}
|
||||
}, sql, params);
|
||||
return measureQueryPerformance(
|
||||
() => {
|
||||
try {
|
||||
const stmt = db.prepare(sql);
|
||||
return stmt.all(params) as T[];
|
||||
} catch (error) {
|
||||
logger.error('Database query error:', { sql, params, error });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
sql,
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
// Helper method to get a single row
|
||||
static get<T = any>(sql: string, params: any[] = []): T | undefined {
|
||||
const db = this.getDatabase();
|
||||
|
||||
return measureQueryPerformance(() => {
|
||||
try {
|
||||
const stmt = db.prepare(sql);
|
||||
const result = stmt.get(params);
|
||||
return result as T | undefined;
|
||||
} catch (error) {
|
||||
logger.error('Database get error:', error);
|
||||
throw error;
|
||||
}
|
||||
}, sql, params);
|
||||
return measureQueryPerformance(
|
||||
() => {
|
||||
try {
|
||||
const stmt = db.prepare(sql);
|
||||
const result = stmt.get(params);
|
||||
return result as T | undefined;
|
||||
} catch (error) {
|
||||
logger.error('Database get error:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
sql,
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
// Helper method to get all rows
|
||||
static all<T = any>(sql: string, params: any[] = []): T[] {
|
||||
const db = this.getDatabase();
|
||||
|
||||
return measureQueryPerformance(() => {
|
||||
try {
|
||||
const stmt = db.prepare(sql);
|
||||
return stmt.all(params) as T[];
|
||||
} catch (error) {
|
||||
logger.error('Database all error:', error);
|
||||
throw error;
|
||||
}
|
||||
}, sql, params);
|
||||
return measureQueryPerformance(
|
||||
() => {
|
||||
try {
|
||||
const stmt = db.prepare(sql);
|
||||
return stmt.all(params) as T[];
|
||||
} catch (error) {
|
||||
logger.error('Database all error:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
sql,
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
// Helper method to run INSERT/UPDATE/DELETE queries (doesn't return rows)
|
||||
static run(sql: string, params: any[] = []): void {
|
||||
const db = this.getDatabase();
|
||||
|
||||
measureQueryPerformance(() => {
|
||||
try {
|
||||
const stmt = db.prepare(sql);
|
||||
stmt.run(params);
|
||||
} catch (error) {
|
||||
logger.error('Database run error:', { sql, params, error });
|
||||
throw error;
|
||||
}
|
||||
}, sql, params);
|
||||
measureQueryPerformance(
|
||||
() => {
|
||||
try {
|
||||
const stmt = db.prepare(sql);
|
||||
stmt.run(params);
|
||||
} catch (error) {
|
||||
logger.error('Database run error:', { sql, params, error });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
sql,
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
// Save database changes to disk
|
||||
|
||||
@@ -50,7 +50,9 @@ export class DomainService {
|
||||
}
|
||||
|
||||
const rows = this.db
|
||||
.prepare('SELECT id, hostname, is_default, created_at FROM domains ORDER BY is_default DESC, hostname ASC')
|
||||
.prepare(
|
||||
'SELECT id, hostname, is_default, created_at FROM domains ORDER BY is_default DESC, hostname ASC'
|
||||
)
|
||||
.all() as DomainRow[];
|
||||
|
||||
cachedDomains = rows.map(this.mapRow);
|
||||
@@ -102,17 +104,15 @@ export class DomainService {
|
||||
const validated = this.validateHostname(hostname);
|
||||
|
||||
// Check for duplicate
|
||||
const existing = this.db
|
||||
.prepare('SELECT id FROM domains WHERE hostname = ?')
|
||||
.get(validated) as { id: number } | undefined;
|
||||
const existing = this.db.prepare('SELECT id FROM domains WHERE hostname = ?').get(validated) as
|
||||
| { id: number }
|
||||
| undefined;
|
||||
|
||||
if (existing) {
|
||||
throw new Error(`Domain "${validated}" already exists`);
|
||||
}
|
||||
|
||||
const stmt = this.db.prepare(
|
||||
'INSERT INTO domains (hostname, created_by) VALUES (?, ?)'
|
||||
);
|
||||
const stmt = this.db.prepare('INSERT INTO domains (hostname, created_by) VALUES (?, ?)');
|
||||
const result = stmt.run(validated, createdBy);
|
||||
|
||||
this.invalidateCache();
|
||||
@@ -129,9 +129,9 @@ export class DomainService {
|
||||
* Delete a domain by ID
|
||||
*/
|
||||
deleteDomain(id: number): boolean {
|
||||
const existing = this.db
|
||||
.prepare('SELECT hostname FROM domains WHERE id = ?')
|
||||
.get(id) as { hostname: string } | undefined;
|
||||
const existing = this.db.prepare('SELECT hostname FROM domains WHERE id = ?').get(id) as
|
||||
| { hostname: string }
|
||||
| undefined;
|
||||
|
||||
if (!existing) {
|
||||
return false;
|
||||
|
||||
@@ -99,7 +99,7 @@ export class DownloadManager {
|
||||
gameDataId,
|
||||
gameId: gameData.gameId,
|
||||
filename,
|
||||
sourceCount: sources.length
|
||||
sourceCount: sources.length,
|
||||
});
|
||||
|
||||
// Collect errors from failed sources
|
||||
@@ -112,7 +112,7 @@ export class DownloadManager {
|
||||
try {
|
||||
logger.info(`Attempting download from source ${i + 1}/${sources.length}`, {
|
||||
source: source.name,
|
||||
gameDataId
|
||||
gameDataId,
|
||||
});
|
||||
|
||||
// Update progress
|
||||
@@ -123,7 +123,7 @@ export class DownloadManager {
|
||||
filename,
|
||||
source: source.name,
|
||||
bytesDownloaded: 0,
|
||||
totalBytes: 0
|
||||
totalBytes: 0,
|
||||
});
|
||||
|
||||
// Construct URL
|
||||
@@ -131,27 +131,22 @@ export class DownloadManager {
|
||||
logger.debug('Download URL constructed', { url, source: source.name });
|
||||
|
||||
// Download to temp file
|
||||
await this.downloadFile(
|
||||
url,
|
||||
tempFilePath,
|
||||
controller.signal,
|
||||
(downloaded, total) => {
|
||||
const percent = total > 0 ? Math.floor((downloaded / total) * 100) : 0;
|
||||
onProgress?.(percent);
|
||||
onDetails?.({
|
||||
gameId: gameData.gameId,
|
||||
gameDataId,
|
||||
filename,
|
||||
source: source.name,
|
||||
bytesDownloaded: downloaded,
|
||||
totalBytes: total
|
||||
});
|
||||
}
|
||||
);
|
||||
await this.downloadFile(url, tempFilePath, controller.signal, (downloaded, total) => {
|
||||
const percent = total > 0 ? Math.floor((downloaded / total) * 100) : 0;
|
||||
onProgress?.(percent);
|
||||
onDetails?.({
|
||||
gameId: gameData.gameId,
|
||||
gameDataId,
|
||||
filename,
|
||||
source: source.name,
|
||||
bytesDownloaded: downloaded,
|
||||
totalBytes: total,
|
||||
});
|
||||
});
|
||||
|
||||
logger.info('Download completed, validating hash', {
|
||||
gameDataId,
|
||||
source: source.name
|
||||
source: source.name,
|
||||
});
|
||||
|
||||
// Update status to validating
|
||||
@@ -161,7 +156,7 @@ export class DownloadManager {
|
||||
filename,
|
||||
source: source.name,
|
||||
bytesDownloaded: 0,
|
||||
totalBytes: 0
|
||||
totalBytes: 0,
|
||||
});
|
||||
|
||||
// Validate hash
|
||||
@@ -169,7 +164,7 @@ export class DownloadManager {
|
||||
|
||||
logger.info('Hash validation successful, importing file', {
|
||||
gameDataId,
|
||||
source: source.name
|
||||
source: source.name,
|
||||
});
|
||||
|
||||
// Import to final location
|
||||
@@ -181,7 +176,7 @@ export class DownloadManager {
|
||||
logger.info('Download workflow completed successfully', {
|
||||
gameDataId,
|
||||
finalPath,
|
||||
source: source.name
|
||||
source: source.name,
|
||||
});
|
||||
|
||||
// Success! Return final path
|
||||
@@ -191,12 +186,12 @@ export class DownloadManager {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.warn(`Download failed from source: ${source.name}`, {
|
||||
gameDataId,
|
||||
error: errorMessage
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
errors.push({
|
||||
source: source.name,
|
||||
error: errorMessage
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
// Clean up temp file for next attempt
|
||||
@@ -213,9 +208,7 @@ export class DownloadManager {
|
||||
}
|
||||
|
||||
// All sources failed
|
||||
const errorSummary = errors
|
||||
.map(e => `${e.source}: ${e.error}`)
|
||||
.join('\n');
|
||||
const errorSummary = errors.map((e) => `${e.source}: ${e.error}`).join('\n');
|
||||
|
||||
throw new Error(
|
||||
`Failed to download game data from all ${sources.length} sources:\n${errorSummary}`
|
||||
@@ -245,8 +238,8 @@ export class DownloadManager {
|
||||
timeout: this.DOWNLOAD_TIMEOUT_MS,
|
||||
signal: abortSignal as any,
|
||||
headers: {
|
||||
'User-Agent': 'Flashpoint-WebApp/1.0'
|
||||
}
|
||||
'User-Agent': 'Flashpoint-WebApp/1.0',
|
||||
},
|
||||
});
|
||||
|
||||
const totalBytes = parseInt(response.headers['content-length'] || '0', 10);
|
||||
|
||||
@@ -16,7 +16,7 @@ export class FallbackCache<T = any> {
|
||||
max: maxSize,
|
||||
ttl: this.FALLBACK_TTL,
|
||||
updateAgeOnGet: true,
|
||||
allowStale: true // Allow stale data in fallback mode
|
||||
allowStale: true, // Allow stale data in fallback mode
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export class FallbackCache<T = any> {
|
||||
set(key: string, data: T): void {
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
cachedAt: Date.now()
|
||||
cachedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -45,13 +45,13 @@ export class FallbackCache<T = any> {
|
||||
logger.debug('[FallbackCache] Serving cached data', {
|
||||
key: key.substring(0, 50),
|
||||
age: `${Math.round(age / 1000)}s`,
|
||||
cachedAt: new Date(cached.cachedAt).toISOString()
|
||||
cachedAt: new Date(cached.cachedAt).toISOString(),
|
||||
});
|
||||
|
||||
return {
|
||||
data: cached.data,
|
||||
cachedAt: cached.cachedAt,
|
||||
age
|
||||
age,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export class FallbackCache<T = any> {
|
||||
getStats(): { size: number; max: number } {
|
||||
return {
|
||||
size: this.cache.size,
|
||||
max: this.cache.max
|
||||
max: this.cache.max,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ export class FavoritesService {
|
||||
if (game) {
|
||||
return {
|
||||
...game,
|
||||
addedAt: fav.added_at
|
||||
addedAt: fav.added_at,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -40,7 +40,7 @@ export class FileImporter {
|
||||
logger.info('Starting file import', {
|
||||
gameId,
|
||||
tempFile: tempFilePath,
|
||||
size: stats.size
|
||||
size: stats.size,
|
||||
});
|
||||
|
||||
// Get destination directory
|
||||
@@ -63,13 +63,15 @@ export class FileImporter {
|
||||
logger.info('File copied successfully', {
|
||||
gameId,
|
||||
finalPath,
|
||||
size: stats.size
|
||||
size: stats.size,
|
||||
});
|
||||
|
||||
// Verify copy succeeded
|
||||
const finalStats = await fs.promises.stat(finalPath);
|
||||
if (finalStats.size !== stats.size) {
|
||||
throw new Error(`File copy verification failed: size mismatch (expected ${stats.size}, got ${finalStats.size})`);
|
||||
throw new Error(
|
||||
`File copy verification failed: size mismatch (expected ${stats.size}, got ${finalStats.size})`
|
||||
);
|
||||
}
|
||||
|
||||
// Delete temporary file
|
||||
@@ -77,7 +79,7 @@ export class FileImporter {
|
||||
|
||||
logger.info('File import completed successfully', {
|
||||
gameId,
|
||||
finalPath
|
||||
finalPath,
|
||||
});
|
||||
|
||||
return finalPath;
|
||||
@@ -85,7 +87,7 @@ export class FileImporter {
|
||||
logger.error('File import failed', {
|
||||
gameId,
|
||||
tempFilePath,
|
||||
error
|
||||
error,
|
||||
});
|
||||
|
||||
// Re-throw with more context
|
||||
@@ -117,7 +119,7 @@ export class FileImporter {
|
||||
} catch (error) {
|
||||
logger.warn('Failed to delete temporary file', {
|
||||
tempFilePath,
|
||||
error
|
||||
error,
|
||||
});
|
||||
// Don't throw - cleanup is best-effort
|
||||
}
|
||||
|
||||
@@ -26,9 +26,7 @@ export class GameDataService {
|
||||
|
||||
// Look for ZIP file starting with gameId
|
||||
const zipPattern = `${gameId}-`;
|
||||
const zipFile = files.find(f =>
|
||||
f.startsWith(zipPattern) && f.endsWith('.zip')
|
||||
);
|
||||
const zipFile = files.find((f) => f.startsWith(zipPattern) && f.endsWith('.zip'));
|
||||
|
||||
if (!zipFile) {
|
||||
logger.warn(`[GameDataService] No ZIP file found for game ${gameId}`);
|
||||
@@ -37,17 +35,23 @@ export class GameDataService {
|
||||
|
||||
const zipPath = path.join(gamesPath, zipFile);
|
||||
|
||||
logger.info(`[GameDataService] Requesting game-service to mount ZIP for game ${gameId}: ${zipFile}`);
|
||||
logger.info(
|
||||
`[GameDataService] Requesting game-service to mount ZIP for game ${gameId}: ${zipFile}`
|
||||
);
|
||||
|
||||
// Call game-service API to mount the ZIP
|
||||
const gameZipServerUrl = config.gameServiceGameZipUrl || 'http://localhost:22501';
|
||||
try {
|
||||
const response = await axios.post(`${gameZipServerUrl}/mount/${mountId}`, {
|
||||
zipPath: zipPath
|
||||
}, {
|
||||
timeout: 5000,
|
||||
validateStatus: (status) => status === 200 || status === 409 // 409 = already mounted
|
||||
});
|
||||
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}`);
|
||||
@@ -64,7 +68,6 @@ export class GameDataService {
|
||||
logger.error(`[GameDataService] Failed to mount ZIP via game-service:`, error);
|
||||
return false;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`[GameDataService] Error mounting ZIP for game ${gameId}:`, error);
|
||||
return false;
|
||||
@@ -87,23 +90,30 @@ export class GameDataService {
|
||||
|
||||
const gameDataResults = await Promise.allSettled(gameDataPromises);
|
||||
const validGameData = gameDataResults
|
||||
.filter((r): r is PromiseFulfilledResult<{ gameId: string; activeDataId: number } | null> => r.status === 'fulfilled' && r.value !== null)
|
||||
.map(r => r.value!);
|
||||
.filter(
|
||||
(r): r is PromiseFulfilledResult<{ gameId: string; activeDataId: number } | null> =>
|
||||
r.status === 'fulfilled' && r.value !== null
|
||||
)
|
||||
.map((r) => r.value!);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
validGameData.map(({ gameId }) => this.mountGameZip(gameId))
|
||||
);
|
||||
|
||||
const succeeded = results.filter(r => r.status === 'fulfilled' && r.value).length;
|
||||
const succeeded = results.filter((r) => r.status === 'fulfilled' && r.value).length;
|
||||
const failed = results.length - succeeded;
|
||||
|
||||
logger.info(`[GameDataService] Mounted ${succeeded}/${results.length} game ZIPs (${failed} failed)`);
|
||||
logger.info(
|
||||
`[GameDataService] Mounted ${succeeded}/${results.length} game ZIPs (${failed} failed)`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of all available game data ZIP files
|
||||
*/
|
||||
async listAvailableGameDataZips(): Promise<Array<{ activeDataId: number; filename: string; size: number }>> {
|
||||
async listAvailableGameDataZips(): Promise<
|
||||
Array<{ activeDataId: number; filename: string; size: number }>
|
||||
> {
|
||||
try {
|
||||
const gamesPath = config.flashpointGamesPath;
|
||||
const files = await fs.readdir(gamesPath);
|
||||
@@ -122,7 +132,7 @@ export class GameDataService {
|
||||
zipFiles.push({
|
||||
activeDataId,
|
||||
filename: file,
|
||||
size: stats.size
|
||||
size: stats.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export class GameDatabaseUpdater {
|
||||
try {
|
||||
logger.info('Updating database for downloaded game data', {
|
||||
gameDataId,
|
||||
filePath
|
||||
filePath,
|
||||
});
|
||||
|
||||
// First, fetch the current game data to get the gameId
|
||||
@@ -50,7 +50,7 @@ export class GameDatabaseUpdater {
|
||||
|
||||
logger.debug('Path conversion for database storage', {
|
||||
absolutePath: filePath,
|
||||
relativePath
|
||||
relativePath,
|
||||
});
|
||||
|
||||
// Update game_data table
|
||||
@@ -62,7 +62,7 @@ export class GameDatabaseUpdater {
|
||||
|
||||
logger.debug('Executing UPDATE on game_data', {
|
||||
sql: updateGameDataSql,
|
||||
params: [relativePath, gameDataId]
|
||||
params: [relativePath, gameDataId],
|
||||
});
|
||||
|
||||
DatabaseService.run(updateGameDataSql, [relativePath, gameDataId]);
|
||||
@@ -87,19 +87,19 @@ export class GameDatabaseUpdater {
|
||||
|
||||
logger.debug('Executing UPDATE on game table', {
|
||||
sql: updateGameSql,
|
||||
params: [gameData.gameId, gameDataId]
|
||||
params: [gameData.gameId, gameDataId],
|
||||
});
|
||||
|
||||
DatabaseService.run(updateGameSql, [gameData.gameId, gameDataId]);
|
||||
|
||||
logger.debug('game table UPDATE successful - activeDataOnDisk set to 1', {
|
||||
gameId: gameData.gameId
|
||||
gameId: gameData.gameId,
|
||||
});
|
||||
} else {
|
||||
logger.debug('Skipping game table update - game data is not active', {
|
||||
gameId: gameData.gameId,
|
||||
activeDataId: game?.activeDataId,
|
||||
requestedDataId: gameDataId
|
||||
requestedDataId: gameDataId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ export class GameDatabaseUpdater {
|
||||
logger.info('Database updated successfully', {
|
||||
gameDataId,
|
||||
gameId: gameData.gameId,
|
||||
filePath
|
||||
filePath,
|
||||
});
|
||||
|
||||
// Fetch and return updated game data
|
||||
@@ -123,12 +123,14 @@ export class GameDatabaseUpdater {
|
||||
logger.error('Failed to update database', {
|
||||
gameDataId,
|
||||
filePath,
|
||||
error
|
||||
error,
|
||||
});
|
||||
|
||||
// Attempt to rollback by reloading database (sql.js limitation)
|
||||
// In a real scenario, we'd use a proper transaction system
|
||||
throw new Error(`Database update failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
throw new Error(
|
||||
`Database update failed: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,11 +178,13 @@ export class GameDatabaseUpdater {
|
||||
DatabaseService.save();
|
||||
|
||||
logger.info('Database changes flushed to disk', {
|
||||
path: config.flashpointDbPath
|
||||
path: config.flashpointDbPath,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to save database to disk', { error });
|
||||
throw new Error(`Failed to persist database changes: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
throw new Error(
|
||||
`Failed to persist database changes: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +202,7 @@ export class GameDatabaseUpdater {
|
||||
presentOnDisk: row.presentOnDisk,
|
||||
path: row.path,
|
||||
size: row.size,
|
||||
parameters: row.parameters
|
||||
parameters: row.parameters,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ export class GameFileService {
|
||||
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/'
|
||||
'http://infinity.unstable.life/Flashpoint/Legacy/htdocs/',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,9 @@ export class GameFileService {
|
||||
* @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 }> {
|
||||
async getGameFile(
|
||||
urlPath: string
|
||||
): Promise<{ data: Buffer; contentType: string; source: string }> {
|
||||
logger.info(`[GameFileService] ========================================`);
|
||||
logger.info(`[GameFileService] Requested: ${urlPath}`);
|
||||
|
||||
@@ -96,7 +98,9 @@ export class GameFileService {
|
||||
// Try other external paths
|
||||
let attemptNum = 3;
|
||||
for (const externalPath of this.externalFilePaths) {
|
||||
logger.info(`[GameFileService] [${attemptNum}/${3 + this.externalFilePaths.length}] Trying: ${externalPath}`);
|
||||
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!`);
|
||||
@@ -105,14 +109,18 @@ export class GameFileService {
|
||||
attemptNum++;
|
||||
}
|
||||
|
||||
logger.error(`[GameFileService] ✗ File not found in any source (tried ${2 + this.externalFilePaths.length} sources)`);
|
||||
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> {
|
||||
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}`;
|
||||
@@ -124,7 +132,7 @@ export class GameFileService {
|
||||
path: fullUrl,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Host': parsedUrl.hostname,
|
||||
Host: parsedUrl.hostname,
|
||||
'User-Agent': 'Flashpoint-WebApp/1.0',
|
||||
},
|
||||
timeout: 10000,
|
||||
@@ -163,7 +171,10 @@ export class GameFileService {
|
||||
/**
|
||||
* Try to download file from an external source
|
||||
*/
|
||||
private async tryExternalSource(baseUrl: string, urlPath: string): Promise<{ data: Buffer; contentType: string } | null> {
|
||||
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
|
||||
@@ -209,7 +220,9 @@ export class GameFileService {
|
||||
} 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}`);
|
||||
logger.warn(
|
||||
`[GameFileService] ✗ HTTP ${error.response.status}: ${error.response.statusText}`
|
||||
);
|
||||
} else if (error.request) {
|
||||
logger.warn(`[GameFileService] ✗ No response received: ${error.message}`);
|
||||
} else {
|
||||
|
||||
@@ -37,7 +37,7 @@ export class GameSearchCache {
|
||||
// Optional: Log evictions for monitoring
|
||||
dispose: (value, key) => {
|
||||
logger.debug(`[GameSearchCache] Evicted cache entry: ${key}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
private static gameService = new GameService();
|
||||
@@ -68,7 +68,7 @@ export class GameSearchCache {
|
||||
limit: query.limit,
|
||||
showBroken: query.showBroken,
|
||||
showExtreme: query.showExtreme,
|
||||
fields: query.fields || 'detail'
|
||||
fields: query.fields || 'detail',
|
||||
};
|
||||
|
||||
return JSON.stringify(normalizedQuery);
|
||||
@@ -79,7 +79,9 @@ export class GameSearchCache {
|
||||
* Checks cache first, falls back to database query if cache miss
|
||||
* If database fails, returns stale data from fallback cache
|
||||
*/
|
||||
static async searchGames(query: GameSearchQuery): Promise<PaginatedResult<Game> & { fromCache?: boolean; cacheAge?: number }> {
|
||||
static async searchGames(
|
||||
query: GameSearchQuery
|
||||
): Promise<PaginatedResult<Game> & { fromCache?: boolean; cacheAge?: number }> {
|
||||
const cacheKey = this.generateCacheKey(query);
|
||||
|
||||
// Try to get from primary cache
|
||||
@@ -105,7 +107,7 @@ export class GameSearchCache {
|
||||
if (!result || !result.data || !Array.isArray(result.data)) {
|
||||
logger.error('[GameSearchCache] Invalid result from database query', {
|
||||
result,
|
||||
cacheKey: cacheKey.substring(0, 200)
|
||||
cacheKey: cacheKey.substring(0, 200),
|
||||
});
|
||||
throw new Error('Invalid database query result');
|
||||
}
|
||||
@@ -118,21 +120,21 @@ export class GameSearchCache {
|
||||
} catch (error) {
|
||||
// Database query failed - try fallback cache
|
||||
logger.warn('[GameSearchCache] Database query failed, attempting fallback cache', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
const fallback = this.fallbackCache.get(cacheKey);
|
||||
if (fallback) {
|
||||
logger.info('[GameSearchCache] Serving stale data from fallback cache', {
|
||||
age: `${Math.round(fallback.age / 1000)}s`,
|
||||
cachedAt: new Date(fallback.cachedAt).toISOString()
|
||||
cachedAt: new Date(fallback.cachedAt).toISOString(),
|
||||
});
|
||||
|
||||
// Return stale data with metadata
|
||||
return {
|
||||
...fallback.data,
|
||||
fromCache: true,
|
||||
cacheAge: fallback.age
|
||||
cacheAge: fallback.age,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -150,7 +152,9 @@ export class GameSearchCache {
|
||||
static clearCache(): void {
|
||||
const size = this.cache.size;
|
||||
this.cache.clear();
|
||||
logger.info(`[GameSearchCache] Cleared primary cache (${size} entries), fallback cache preserved`);
|
||||
logger.info(
|
||||
`[GameSearchCache] Cleared primary cache (${size} entries), fallback cache preserved`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,7 +167,9 @@ export class GameSearchCache {
|
||||
this.cache.clear();
|
||||
this.fallbackCache.clear();
|
||||
|
||||
logger.info(`[GameSearchCache] Cleared all caches (primary: ${primarySize}, fallback: ${fallbackSize} entries)`);
|
||||
logger.info(
|
||||
`[GameSearchCache] Cleared all caches (primary: ${primarySize}, fallback: ${fallbackSize} entries)`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -177,9 +183,9 @@ export class GameSearchCache {
|
||||
primary: {
|
||||
size: this.cache.size,
|
||||
max: this.cache.max,
|
||||
ttl: 1000 * 60 * 5 // 5 minutes
|
||||
ttl: 1000 * 60 * 5, // 5 minutes
|
||||
},
|
||||
fallback: this.fallbackCache.getStats()
|
||||
fallback: this.fallbackCache.getStats(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -216,9 +222,23 @@ export class GameSearchCache {
|
||||
// Default home page - animations library
|
||||
{ library: 'theatre', page: 1, limit: 50, sortBy: 'title', sortOrder: 'asc', fields: 'list' },
|
||||
// Recently added games
|
||||
{ library: 'arcade', page: 1, limit: 50, sortBy: 'dateAdded', sortOrder: 'desc', fields: 'list' },
|
||||
{
|
||||
library: 'arcade',
|
||||
page: 1,
|
||||
limit: 50,
|
||||
sortBy: 'dateAdded',
|
||||
sortOrder: 'desc',
|
||||
fields: 'list',
|
||||
},
|
||||
// Recently added animations
|
||||
{ library: 'theatre', page: 1, limit: 50, sortBy: 'dateAdded', sortOrder: 'desc', fields: 'list' },
|
||||
{
|
||||
library: 'theatre',
|
||||
page: 1,
|
||||
limit: 50,
|
||||
sortBy: 'dateAdded',
|
||||
sortOrder: 'desc',
|
||||
fields: 'list',
|
||||
},
|
||||
];
|
||||
|
||||
let successCount = 0;
|
||||
@@ -234,7 +254,7 @@ export class GameSearchCache {
|
||||
fields: 'list',
|
||||
showBroken: false,
|
||||
showExtreme: false,
|
||||
...partialQuery
|
||||
...partialQuery,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -244,14 +264,14 @@ export class GameSearchCache {
|
||||
|
||||
logger.debug(`[GameSearchCache] Pre-warmed query in ${queryDuration}ms`, {
|
||||
library: query.library,
|
||||
sortBy: query.sortBy
|
||||
sortBy: query.sortBy,
|
||||
});
|
||||
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
logger.warn('[GameSearchCache] Failed to pre-warm query', {
|
||||
query: partialQuery,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
failCount++;
|
||||
}
|
||||
@@ -261,7 +281,7 @@ export class GameSearchCache {
|
||||
logger.info(`[GameSearchCache] Cache pre-warming completed in ${totalDuration}ms`, {
|
||||
success: successCount,
|
||||
failed: failCount,
|
||||
total: commonQueries.length
|
||||
total: commonQueries.length,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -279,7 +299,7 @@ export class GameSearchCache {
|
||||
sortOrder: 'asc',
|
||||
fields: 'list',
|
||||
showBroken: false,
|
||||
showExtreme: false
|
||||
showExtreme: false,
|
||||
};
|
||||
return this.has(defaultQuery);
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ export interface Game {
|
||||
playtime?: number;
|
||||
playCounter?: number;
|
||||
archiveState?: number;
|
||||
logoPath?: string; // Infinity edition only
|
||||
logoPath?: string; // Infinity edition only
|
||||
screenshotPath?: string; // Infinity edition only
|
||||
}
|
||||
|
||||
@@ -106,8 +106,11 @@ export class GameService {
|
||||
|
||||
// Infinity edition has logoPath and screenshotPath columns
|
||||
if (isInfinity) {
|
||||
return baseColumns + `,
|
||||
g.logoPath, g.screenshotPath`;
|
||||
return (
|
||||
baseColumns +
|
||||
`,
|
||||
g.logoPath, g.screenshotPath`
|
||||
);
|
||||
}
|
||||
|
||||
return baseColumns;
|
||||
@@ -183,7 +186,7 @@ export class GameService {
|
||||
// OPTIMIZATION: Use INSTR instead of LIKE for tag matching (faster in SQLite)
|
||||
if (query.tags && query.tags.length > 0) {
|
||||
// Check if tags are in tagsStr field using INSTR with semicolon boundaries
|
||||
query.tags.forEach(tag => {
|
||||
query.tags.forEach((tag) => {
|
||||
sql += ` AND INSTR(';' || g.tagsStr || ';', ?) > 0`;
|
||||
params.push(`;${tag};`);
|
||||
});
|
||||
@@ -245,7 +248,7 @@ export class GameService {
|
||||
dateAddedSince: query.dateAddedSince,
|
||||
dateModifiedSince: query.dateModifiedSince,
|
||||
sortBy: query.sortBy,
|
||||
params: params
|
||||
params: params,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -258,7 +261,7 @@ export class GameService {
|
||||
// OPTIMIZATION: Fetch presentOnDisk separately for returned games (post-processing pattern)
|
||||
// This avoids the LEFT JOIN row explosion and GROUP BY aggregation
|
||||
if (games.length > 0) {
|
||||
const gameIds = games.map(g => g.id);
|
||||
const gameIds = games.map((g) => g.id);
|
||||
const placeholders = gameIds.map(() => '?').join(', ');
|
||||
|
||||
const presentOnDiskSql = `
|
||||
@@ -275,7 +278,7 @@ export class GameService {
|
||||
|
||||
// Create a map for O(1) lookup
|
||||
const presentOnDiskMap = new Map(
|
||||
presentOnDiskResults.map(r => [r.gameId, r.presentOnDisk])
|
||||
presentOnDiskResults.map((r) => [r.gameId, r.presentOnDisk])
|
||||
);
|
||||
|
||||
// Merge presentOnDisk into game objects and remove total_count
|
||||
@@ -290,7 +293,7 @@ export class GameService {
|
||||
total,
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
totalPages: Math.ceil(total / query.limit)
|
||||
totalPages: Math.ceil(total / query.limit),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error searching games:', {
|
||||
@@ -301,8 +304,8 @@ export class GameService {
|
||||
sortBy: query.sortBy,
|
||||
library: query.library,
|
||||
page: query.page,
|
||||
limit: query.limit
|
||||
}
|
||||
limit: query.limit,
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
@@ -354,8 +357,8 @@ export class GameService {
|
||||
const games = DatabaseService.all(sql, ids) as Game[];
|
||||
|
||||
// Sort games to match the order of input IDs
|
||||
const gamesMap = new Map(games.map(g => [g.id, g]));
|
||||
return ids.map(id => gamesMap.get(id)).filter((g): g is Game => g !== undefined);
|
||||
const gamesMap = new Map(games.map((g) => [g.id, g]));
|
||||
return ids.map((id) => gamesMap.get(id)).filter((g): g is Game => g !== undefined);
|
||||
} catch (error) {
|
||||
logger.error('Error getting games by IDs:', error);
|
||||
throw error;
|
||||
@@ -460,7 +463,7 @@ export class GameService {
|
||||
sourceGame.platformName,
|
||||
sourceGame.developer,
|
||||
sourceGame.platformName,
|
||||
limit
|
||||
limit,
|
||||
]) as Game[];
|
||||
|
||||
return games;
|
||||
@@ -504,7 +507,7 @@ export class GameService {
|
||||
releaseDate: 'g.releaseDate',
|
||||
dateAdded: 'g.dateAdded',
|
||||
dateModified: 'g.dateModified',
|
||||
developer: 'g.developer'
|
||||
developer: 'g.developer',
|
||||
};
|
||||
return columnMap[sortBy] || 'g.orderTitle';
|
||||
}
|
||||
@@ -697,7 +700,10 @@ export class GameService {
|
||||
const tagCounts = new Map<string, number>();
|
||||
|
||||
for (const row of results) {
|
||||
const tags = row.tagsStr.split(';').map(tag => tag.trim()).filter(tag => tag);
|
||||
const tags = row.tagsStr
|
||||
.split(';')
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag);
|
||||
for (const tag of tags) {
|
||||
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
|
||||
}
|
||||
@@ -792,7 +798,7 @@ export class GameService {
|
||||
}
|
||||
|
||||
// Get game IDs for query
|
||||
const gameIds = mostPlayedStats.map(stat => stat.game_id);
|
||||
const gameIds = mostPlayedStats.map((stat) => stat.game_id);
|
||||
|
||||
// Fetch full game details from flashpoint database using list fields
|
||||
const placeholders = gameIds.map(() => '?').join(',');
|
||||
@@ -805,9 +811,9 @@ export class GameService {
|
||||
const games = DatabaseService.all(sql, gameIds) as Game[];
|
||||
|
||||
// Sort games by the original play count order
|
||||
const gameMap = new Map(games.map(game => [game.id, game]));
|
||||
const gameMap = new Map(games.map((game) => [game.id, game]));
|
||||
const sortedGames = gameIds
|
||||
.map(id => gameMap.get(id))
|
||||
.map((id) => gameMap.get(id))
|
||||
.filter((game): game is Game => game !== undefined);
|
||||
|
||||
return sortedGames;
|
||||
|
||||
@@ -59,7 +59,7 @@ export class HashValidator {
|
||||
logger.warn('Hash validation failed', {
|
||||
filePath,
|
||||
expected,
|
||||
actual: actualHash
|
||||
actual: actualHash,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -74,12 +74,18 @@ export class HashValidator {
|
||||
* @param source - Source name for error messages
|
||||
* @throws Error if hash doesn't match or file cannot be read
|
||||
*/
|
||||
static async validateOrThrow(filePath: string, expectedHash: string, source?: string): Promise<void> {
|
||||
static async validateOrThrow(
|
||||
filePath: string,
|
||||
expectedHash: string,
|
||||
source?: string
|
||||
): Promise<void> {
|
||||
const isValid = await this.validate(filePath, expectedHash);
|
||||
|
||||
if (!isValid) {
|
||||
const sourceInfo = source ? ` from ${source}` : '';
|
||||
throw new Error(`Hash validation failed${sourceInfo}: File hash does not match expected SHA256`);
|
||||
throw new Error(
|
||||
`Hash validation failed${sourceInfo}: File hash does not match expected SHA256`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +102,7 @@ export class HashValidator {
|
||||
|
||||
return {
|
||||
hash,
|
||||
size: stats.size
|
||||
size: stats.size,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,24 +20,26 @@ export class ImageUrlService {
|
||||
// Construct full image URL from Flashpoint CDN base URL
|
||||
const urls = [
|
||||
`${baseUrl}/Flashpoint/Data/Images`,
|
||||
'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', {
|
||||
baseUrl: preferences.onDemandBaseUrl,
|
||||
constructedUrls: urls
|
||||
constructedUrls: urls,
|
||||
});
|
||||
|
||||
return urls;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to read image URLs from Flashpoint preferences, using defaults', { error });
|
||||
logger.warn('Failed to read image URLs from Flashpoint preferences, using defaults', {
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback to hardcoded defaults
|
||||
const defaultUrls = [
|
||||
'https://infinity.flashpointarchive.org/Flashpoint/Data/Images',
|
||||
'https://infinity.unstable.life/Flashpoint/Data/Images'
|
||||
'https://infinity.unstable.life/Flashpoint/Data/Images',
|
||||
];
|
||||
|
||||
logger.info('Using default image URLs', { urls: defaultUrls });
|
||||
@@ -57,7 +59,7 @@ export class ImageUrlService {
|
||||
return {
|
||||
images: preferences.imageFolderPath || 'Data/Images',
|
||||
logos: preferences.logoFolderPath || 'Data/Logos',
|
||||
playlists: preferences.playlistFolderPath || 'Data/Playlists'
|
||||
playlists: preferences.playlistFolderPath || 'Data/Playlists',
|
||||
};
|
||||
} catch (error) {
|
||||
// Return defaults if preferences unavailable
|
||||
@@ -65,7 +67,7 @@ export class ImageUrlService {
|
||||
return {
|
||||
images: 'Data/Images',
|
||||
logos: 'Data/Logos',
|
||||
playlists: 'Data/Playlists'
|
||||
playlists: 'Data/Playlists',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,10 +41,9 @@ export class JobExecutionService {
|
||||
const completedAt = new Date().toISOString();
|
||||
|
||||
// Calculate duration
|
||||
const log = UserDatabaseService.get(
|
||||
'SELECT started_at FROM job_execution_logs WHERE id = ?',
|
||||
[id]
|
||||
) as { started_at: string } | null;
|
||||
const log = UserDatabaseService.get('SELECT started_at FROM job_execution_logs WHERE id = ?', [
|
||||
id,
|
||||
]) as { started_at: string } | null;
|
||||
|
||||
if (!log) {
|
||||
logger.error(`[JobExecution] Cannot complete: log ${id} not found`);
|
||||
@@ -84,7 +83,7 @@ export class JobExecutionService {
|
||||
|
||||
return {
|
||||
data: logs.map(this.mapToJobExecutionLog),
|
||||
total: total.count
|
||||
total: total.count,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -106,7 +105,7 @@ export class JobExecutionService {
|
||||
|
||||
return {
|
||||
data: logs.map(this.mapToJobExecutionLog),
|
||||
total: total.count
|
||||
total: total.count,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -133,10 +132,9 @@ export class JobExecutionService {
|
||||
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
|
||||
const cutoffISO = cutoffDate.toISOString();
|
||||
|
||||
const result = UserDatabaseService.run(
|
||||
'DELETE FROM job_execution_logs WHERE started_at < ?',
|
||||
[cutoffISO]
|
||||
);
|
||||
const result = UserDatabaseService.run('DELETE FROM job_execution_logs WHERE started_at < ?', [
|
||||
cutoffISO,
|
||||
]);
|
||||
|
||||
logger.info(`[JobExecution] Cleaned up ${result.changes} old logs`);
|
||||
return result.changes;
|
||||
@@ -153,7 +151,7 @@ export class JobExecutionService {
|
||||
durationSeconds: row.duration_seconds,
|
||||
message: row.message,
|
||||
errorDetails: row.error_details,
|
||||
triggeredBy: row.triggered_by
|
||||
triggeredBy: row.triggered_by,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,9 @@ class JobSchedulerService {
|
||||
|
||||
// Validate cron expression
|
||||
if (!cron.validate(job.cronSchedule)) {
|
||||
logger.error(`Cannot start job: Invalid cron expression '${job.cronSchedule}' for job ${job.name}`);
|
||||
logger.error(
|
||||
`Cannot start job: Invalid cron expression '${job.cronSchedule}' for job ${job.name}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -189,7 +191,7 @@ class JobSchedulerService {
|
||||
return {
|
||||
enabled: job.enabled,
|
||||
running: this.cronTasks.has(jobId),
|
||||
cronSchedule: job.cronSchedule
|
||||
cronSchedule: job.cronSchedule,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -229,7 +231,7 @@ class JobSchedulerService {
|
||||
running,
|
||||
cronSchedule: job.cronSchedule,
|
||||
lastExecution,
|
||||
nextRunEstimate
|
||||
nextRunEstimate,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import { DatabaseService } from './DatabaseService';
|
||||
import { GameMetadataSource } from './MetadataUpdateService';
|
||||
import { SyncStatusService } from './SyncStatusService';
|
||||
|
||||
|
||||
interface SyncResult {
|
||||
success: boolean;
|
||||
gamesUpdated: number;
|
||||
@@ -57,7 +56,7 @@ interface ApiTag {
|
||||
description: string | null;
|
||||
category: string | null;
|
||||
date_modified: string;
|
||||
aliases?: string; // semicolon-separated
|
||||
aliases?: string; // semicolon-separated
|
||||
user_id?: number;
|
||||
Deleted?: boolean;
|
||||
}
|
||||
@@ -67,7 +66,7 @@ interface ApiPlatform {
|
||||
name: string;
|
||||
description: string | null;
|
||||
date_modified: string;
|
||||
aliases?: string; // semicolon-separated
|
||||
aliases?: string; // semicolon-separated
|
||||
user_id?: number;
|
||||
Deleted?: boolean;
|
||||
}
|
||||
@@ -124,7 +123,7 @@ export class MetadataSyncService {
|
||||
tagsUpdated: 0,
|
||||
platformsUpdated: 0,
|
||||
error: 'Metadata sync is not available for Flashpoint Ultimate edition',
|
||||
timestamp: new Date().toISOString()
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
const startTime = new Date();
|
||||
@@ -185,7 +184,7 @@ export class MetadataSyncService {
|
||||
await this.updatePreferencesTimestamps(source, preferences, {
|
||||
gamesLatestDate: latestGameDate,
|
||||
tagsLatestDate: latestTagDate,
|
||||
platformsLatestDate: latestPlatformDate
|
||||
platformsLatestDate: latestPlatformDate,
|
||||
});
|
||||
|
||||
const result: SyncResult = {
|
||||
@@ -194,7 +193,7 @@ export class MetadataSyncService {
|
||||
gamesDeleted,
|
||||
tagsUpdated,
|
||||
platformsUpdated,
|
||||
timestamp: startTime.toISOString()
|
||||
timestamp: startTime.toISOString(),
|
||||
};
|
||||
|
||||
// Mark sync as completed
|
||||
@@ -202,7 +201,6 @@ export class MetadataSyncService {
|
||||
|
||||
logger.info(`[MetadataSync] Sync completed successfully:`, result);
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
@@ -217,7 +215,7 @@ export class MetadataSyncService {
|
||||
tagsUpdated,
|
||||
platformsUpdated,
|
||||
error: errorMessage,
|
||||
timestamp: startTime.toISOString()
|
||||
timestamp: startTime.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -225,7 +223,9 @@ export class MetadataSyncService {
|
||||
/**
|
||||
* Sync platforms from FPFSS API
|
||||
*/
|
||||
private async syncPlatforms(source: GameMetadataSource): Promise<{ count: number; latestDate: string | null }> {
|
||||
private async syncPlatforms(
|
||||
source: GameMetadataSource
|
||||
): Promise<{ count: number; latestDate: string | null }> {
|
||||
try {
|
||||
const after = source.platforms?.latestUpdateTime || '1970-01-01';
|
||||
const url = `${source.baseUrl}/api/platforms?after=${after}`;
|
||||
@@ -261,7 +261,9 @@ export class MetadataSyncService {
|
||||
/**
|
||||
* Sync tags from FPFSS API
|
||||
*/
|
||||
private async syncTags(source: GameMetadataSource): Promise<{ count: number; latestDate: string | null }> {
|
||||
private async syncTags(
|
||||
source: GameMetadataSource
|
||||
): Promise<{ count: number; latestDate: string | null }> {
|
||||
try {
|
||||
const after = source.tags?.latestUpdateTime || '1970-01-01';
|
||||
const url = `${source.baseUrl}/api/tags?after=${after}`;
|
||||
@@ -297,7 +299,9 @@ export class MetadataSyncService {
|
||||
/**
|
||||
* Sync games from FPFSS API (with pagination)
|
||||
*/
|
||||
private async syncGames(source: GameMetadataSource): Promise<{ updated: number; deleted: number; latestDate: string | null }> {
|
||||
private async syncGames(
|
||||
source: GameMetadataSource
|
||||
): Promise<{ updated: number; deleted: number; latestDate: string | null }> {
|
||||
try {
|
||||
const after = source.games?.latestUpdateTime || '1970-01-01';
|
||||
let totalUpdated = 0;
|
||||
@@ -316,8 +320,12 @@ export class MetadataSyncService {
|
||||
}
|
||||
|
||||
logger.debug(`[MetadataSync] Fetching games batch ${batchCount} from: ${url}`);
|
||||
const progress = Math.min(80, 40 + (batchCount * 5)); // Cap at 80% during game sync
|
||||
this.syncStatusService.updateProgress('syncing-games', progress, `Syncing games (batch ${batchCount})...`);
|
||||
const progress = Math.min(80, 40 + batchCount * 5); // Cap at 80% during game sync
|
||||
this.syncStatusService.updateProgress(
|
||||
'syncing-games',
|
||||
progress,
|
||||
`Syncing games (batch ${batchCount})...`
|
||||
);
|
||||
|
||||
const response = await axios.get(url, { timeout: 60000 });
|
||||
// FPFSS API returns {"games": [...]} not just an array
|
||||
@@ -342,14 +350,19 @@ export class MetadataSyncService {
|
||||
// Get last game ID for pagination
|
||||
afterId = games[games.length - 1].id;
|
||||
|
||||
logger.info(`[MetadataSync] Batch ${batchCount}: ${games.length} games processed (total: ${totalUpdated})`);
|
||||
logger.info(
|
||||
`[MetadataSync] Batch ${batchCount}: ${games.length} games processed (total: ${totalUpdated})`
|
||||
);
|
||||
}
|
||||
|
||||
// Sync deleted games
|
||||
const deleted = await this.syncDeletedGames(source);
|
||||
|
||||
return { updated: totalUpdated, deleted, latestDate: latestDate !== after ? latestDate : null };
|
||||
|
||||
return {
|
||||
updated: totalUpdated,
|
||||
deleted,
|
||||
latestDate: latestDate !== after ? latestDate : null,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[MetadataSync] Error syncing games:', error);
|
||||
throw error;
|
||||
@@ -382,7 +395,9 @@ export class MetadataSyncService {
|
||||
deletedIds = response.data.deletedGames;
|
||||
} else if (response.data.games && Array.isArray(response.data.games)) {
|
||||
// Wrapped in games property
|
||||
deletedIds = response.data.games.map((g: unknown) => typeof g === 'string' ? g : (g as { id: string }).id);
|
||||
deletedIds = response.data.games.map((g: unknown) =>
|
||||
typeof g === 'string' ? g : (g as { id: string }).id
|
||||
);
|
||||
} else {
|
||||
logger.warn('[MetadataSync] Unexpected deleted games API response format:', response.data);
|
||||
}
|
||||
@@ -396,7 +411,6 @@ export class MetadataSyncService {
|
||||
|
||||
logger.info(`[MetadataSync] Deleted ${deletedIds.length} games`);
|
||||
return deletedIds.length;
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[MetadataSync] Error syncing deleted games:', error);
|
||||
throw error;
|
||||
@@ -420,7 +434,9 @@ export class MetadataSyncService {
|
||||
// Platform data is embedded in game records (game.platformName column)
|
||||
// No separate platform table exists in flashpoint.sqlite
|
||||
// The settings toggle works, but the actual data is synced via game records
|
||||
logger.debug(`[MetadataSync] Received ${platforms.length} platform updates (data synced via game records)`);
|
||||
logger.debug(
|
||||
`[MetadataSync] Received ${platforms.length} platform updates (data synced via game records)`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -440,7 +456,9 @@ export class MetadataSyncService {
|
||||
// Tag data is embedded in game records (game.tagsStr column as semicolon-separated strings)
|
||||
// No separate tag table exists in flashpoint.sqlite
|
||||
// The settings toggle works, but the actual data is synced via game records
|
||||
logger.debug(`[MetadataSync] Received ${tags.length} tag updates (data synced via game records)`);
|
||||
logger.debug(
|
||||
`[MetadataSync] Received ${tags.length} tag updates (data synced via game records)`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -454,12 +472,15 @@ export class MetadataSyncService {
|
||||
const sortedGames = this.sortGamesByParentDependency(games);
|
||||
|
||||
// Build SQL for bulk upsert (29 columns per row)
|
||||
const placeholders = sortedGames.map(() =>
|
||||
'(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).join(', ');
|
||||
const placeholders = sortedGames
|
||||
.map(
|
||||
() =>
|
||||
'(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
.join(', ');
|
||||
|
||||
const values: any[] = [];
|
||||
sortedGames.forEach(game => {
|
||||
sortedGames.forEach((game) => {
|
||||
values.push(
|
||||
game.id,
|
||||
game.parent_game_id || null,
|
||||
@@ -547,7 +568,7 @@ export class MetadataSyncService {
|
||||
const visiting = new Set<string>(); // Track games currently being visited to detect cycles
|
||||
|
||||
// Build game map
|
||||
games.forEach(game => gameMap.set(game.id, game));
|
||||
games.forEach((game) => gameMap.set(game.id, game));
|
||||
|
||||
// Recursive function to add game and its parents first
|
||||
const addGame = (game: ApiGame): void => {
|
||||
@@ -556,7 +577,9 @@ export class MetadataSyncService {
|
||||
|
||||
// Circular dependency detected - break the cycle by skipping parent
|
||||
if (visiting.has(game.id)) {
|
||||
logger.warn(`[MetadataSync] Circular parent dependency detected for game: ${game.id} (${game.title})`);
|
||||
logger.warn(
|
||||
`[MetadataSync] Circular parent dependency detected for game: ${game.id} (${game.title})`
|
||||
);
|
||||
visited.add(game.id);
|
||||
sorted.push(game);
|
||||
return;
|
||||
@@ -578,7 +601,7 @@ export class MetadataSyncService {
|
||||
};
|
||||
|
||||
// Process all games
|
||||
games.forEach(game => addGame(game));
|
||||
games.forEach((game) => addGame(game));
|
||||
|
||||
return sorted;
|
||||
}
|
||||
@@ -621,7 +644,9 @@ export class MetadataSyncService {
|
||||
const updateSql = `UPDATE game SET parentGameId = NULL WHERE id IN (${updatePlaceholders})`;
|
||||
const updateStmt = db.prepare(updateSql);
|
||||
updateStmt.run(childGameIds);
|
||||
logger.debug(`[MetadataSync] Orphaned ${childGameIds.length} child games (set parentGameId to NULL)`);
|
||||
logger.debug(
|
||||
`[MetadataSync] Orphaned ${childGameIds.length} child games (set parentGameId to NULL)`
|
||||
);
|
||||
}
|
||||
|
||||
// Now safe to delete the games
|
||||
@@ -667,25 +692,33 @@ export class MetadataSyncService {
|
||||
// Update latestUpdateTime to the latest game/tag/platform dateModified
|
||||
// This is what the update check uses to determine what's new
|
||||
if (latestDates.gamesLatestDate) {
|
||||
preferences.gameMetadataSources[sourceIndex].games.latestUpdateTime = latestDates.gamesLatestDate;
|
||||
logger.info(`[MetadataSync] Updated games latestUpdateTime to: ${latestDates.gamesLatestDate}`);
|
||||
preferences.gameMetadataSources[sourceIndex].games.latestUpdateTime =
|
||||
latestDates.gamesLatestDate;
|
||||
logger.info(
|
||||
`[MetadataSync] Updated games latestUpdateTime to: ${latestDates.gamesLatestDate}`
|
||||
);
|
||||
}
|
||||
if (latestDates.tagsLatestDate) {
|
||||
preferences.gameMetadataSources[sourceIndex].tags.latestUpdateTime = latestDates.tagsLatestDate;
|
||||
logger.info(`[MetadataSync] Updated tags latestUpdateTime to: ${latestDates.tagsLatestDate}`);
|
||||
preferences.gameMetadataSources[sourceIndex].tags.latestUpdateTime =
|
||||
latestDates.tagsLatestDate;
|
||||
logger.info(
|
||||
`[MetadataSync] Updated tags latestUpdateTime to: ${latestDates.tagsLatestDate}`
|
||||
);
|
||||
}
|
||||
if (latestDates.platformsLatestDate && preferences.gameMetadataSources[sourceIndex].platforms) {
|
||||
preferences.gameMetadataSources[sourceIndex].platforms.latestUpdateTime = latestDates.platformsLatestDate;
|
||||
logger.info(`[MetadataSync] Updated platforms latestUpdateTime to: ${latestDates.platformsLatestDate}`);
|
||||
if (
|
||||
latestDates.platformsLatestDate &&
|
||||
preferences.gameMetadataSources[sourceIndex].platforms
|
||||
) {
|
||||
preferences.gameMetadataSources[sourceIndex].platforms.latestUpdateTime =
|
||||
latestDates.platformsLatestDate;
|
||||
logger.info(
|
||||
`[MetadataSync] Updated platforms latestUpdateTime to: ${latestDates.platformsLatestDate}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Write back to preferences.json
|
||||
await fs.writeFile(
|
||||
this.preferencesPath,
|
||||
JSON.stringify(preferences, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
await fs.writeFile(this.preferencesPath, JSON.stringify(preferences, null, 2), 'utf-8');
|
||||
|
||||
logger.info(`[MetadataSync] Updated preferences.json timestamps. actualUpdateTime: ${now}`);
|
||||
} catch (error) {
|
||||
@@ -693,5 +726,4 @@ export class MetadataSyncService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import axios from 'axios';
|
||||
import { config } from '../config';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
|
||||
export interface GameMetadataSource {
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
@@ -63,7 +62,7 @@ export class MetadataUpdateService {
|
||||
return {
|
||||
hasUpdates: false,
|
||||
gamesUpdateAvailable: false,
|
||||
tagsUpdateAvailable: false
|
||||
tagsUpdateAvailable: false,
|
||||
};
|
||||
}
|
||||
// Read preferences file
|
||||
@@ -78,7 +77,7 @@ export class MetadataUpdateService {
|
||||
return {
|
||||
hasUpdates: false,
|
||||
gamesUpdateAvailable: false,
|
||||
tagsUpdateAvailable: false
|
||||
tagsUpdateAvailable: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -98,7 +97,9 @@ export class MetadataUpdateService {
|
||||
const hasUpdates = gamesUpdateAvailable || tagsUpdateAvailable;
|
||||
|
||||
logger.info(`[MetadataUpdate] Games: ${gamesUpdateCount} updates available`);
|
||||
logger.info(`[MetadataUpdate] Tags: ${tagsUpdateCount} updates available (synced via game records)`);
|
||||
logger.info(
|
||||
`[MetadataUpdate] Tags: ${tagsUpdateCount} updates available (synced via game records)`
|
||||
);
|
||||
|
||||
return {
|
||||
hasUpdates,
|
||||
@@ -108,7 +109,7 @@ export class MetadataUpdateService {
|
||||
tagsUpdateCount,
|
||||
lastCheckedTime: source.games.actualUpdateTime,
|
||||
lastUpdateTime: source.games.latestUpdateTime,
|
||||
source
|
||||
source,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[MetadataUpdate] Failed to read metadata update info:', error);
|
||||
@@ -117,7 +118,7 @@ export class MetadataUpdateService {
|
||||
return {
|
||||
hasUpdates: false,
|
||||
gamesUpdateAvailable: false,
|
||||
tagsUpdateAvailable: false
|
||||
tagsUpdateAvailable: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -127,7 +128,10 @@ export class MetadataUpdateService {
|
||||
* Queries: ${baseUrl}/api/games/updates?after=${timestamp}
|
||||
* Returns the actual count of updates from the server
|
||||
*/
|
||||
private async getUpdateCount(source: GameMetadataSource, type: 'games' | 'tags'): Promise<number> {
|
||||
private async getUpdateCount(
|
||||
source: GameMetadataSource,
|
||||
type: 'games' | 'tags'
|
||||
): Promise<number> {
|
||||
try {
|
||||
// Add 2 seconds to update time to prevent rounding down errors (like Launcher does)
|
||||
const d = new Date(source[type].latestUpdateTime);
|
||||
@@ -138,7 +142,7 @@ export class MetadataUpdateService {
|
||||
logger.debug(`[MetadataUpdate] Querying ${type} updates: ${countUrl}`);
|
||||
|
||||
const response = await axios.get(countUrl, {
|
||||
timeout: 10000
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
if (response.data && typeof response.data.total === 'number') {
|
||||
@@ -148,7 +152,10 @@ export class MetadataUpdateService {
|
||||
logger.warn(`[MetadataUpdate] Invalid response from ${countUrl}`);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
logger.error(`[MetadataUpdate] Error fetching ${type} update count:`, error instanceof Error ? error.message : 'Unknown error');
|
||||
logger.error(
|
||||
`[MetadataUpdate] Error fetching ${type} update count:`,
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
);
|
||||
return -1; // Return -1 on error (like Launcher does)
|
||||
}
|
||||
}
|
||||
@@ -176,18 +183,21 @@ export class MetadataUpdateService {
|
||||
// Query FPFSS API for latest metadata info
|
||||
const response = await axios.get(fpfssUrl, {
|
||||
timeout: 10000,
|
||||
validateStatus: (status) => status === 200
|
||||
validateStatus: (status) => status === 200,
|
||||
});
|
||||
|
||||
if (response.data && response.data.games && response.data.games.latestUpdateTime) {
|
||||
return {
|
||||
latestUpdateTime: response.data.games.latestUpdateTime
|
||||
latestUpdateTime: response.data.games.latestUpdateTime,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.warn('[MetadataUpdate] Failed to check remote updates:', error instanceof Error ? error.message : 'Unknown error');
|
||||
logger.warn(
|
||||
'[MetadataUpdate] Failed to check remote updates:',
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -214,7 +224,7 @@ export class MetadataUpdateService {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export class PerformanceMetrics {
|
||||
const metrics = this.endpointMetrics.get(key)!;
|
||||
metrics.push({
|
||||
timestamp: Date.now(),
|
||||
value: durationMs
|
||||
value: durationMs,
|
||||
});
|
||||
|
||||
// Trim old data points
|
||||
@@ -67,7 +67,7 @@ export class PerformanceMetrics {
|
||||
if (durationMs > 1000) {
|
||||
logger.warn('[PerformanceMetrics] Slow endpoint detected', {
|
||||
endpoint: key,
|
||||
duration: `${durationMs}ms`
|
||||
duration: `${durationMs}ms`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,7 @@ export class PerformanceMetrics {
|
||||
static recordQuery(durationMs: number): void {
|
||||
this.queryMetrics.push({
|
||||
timestamp: Date.now(),
|
||||
value: durationMs
|
||||
value: durationMs,
|
||||
});
|
||||
|
||||
// Trim old data points
|
||||
@@ -151,7 +151,7 @@ export class PerformanceMetrics {
|
||||
stats[cacheName] = {
|
||||
hits: metrics.hits,
|
||||
misses: metrics.misses,
|
||||
hitRate: Math.round(hitRate * 100) / 100 // Round to 2 decimal places
|
||||
hitRate: Math.round(hitRate * 100) / 100, // Round to 2 decimal places
|
||||
};
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ export class PerformanceMetrics {
|
||||
return Object.entries(endpointStats)
|
||||
.map(([endpoint, stats]) => ({
|
||||
endpoint,
|
||||
avgDuration: Math.round(stats.avg)
|
||||
avgDuration: Math.round(stats.avg),
|
||||
}))
|
||||
.sort((a, b) => b.avgDuration - a.avgDuration)
|
||||
.slice(0, limit);
|
||||
@@ -188,7 +188,7 @@ export class PerformanceMetrics {
|
||||
* Calculate statistics from metric points
|
||||
*/
|
||||
private static calculateStats(points: MetricPoint[]): MetricStats {
|
||||
const values = points.map(p => p.value).sort((a, b) => a - b);
|
||||
const values = points.map((p) => p.value).sort((a, b) => a - b);
|
||||
const count = values.length;
|
||||
const sum = values.reduce((acc, val) => acc + val, 0);
|
||||
|
||||
@@ -200,7 +200,7 @@ export class PerformanceMetrics {
|
||||
avg: sum / count,
|
||||
p50: this.percentile(values, 0.5),
|
||||
p95: this.percentile(values, 0.95),
|
||||
p99: this.percentile(values, 0.99)
|
||||
p99: this.percentile(values, 0.99),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -220,7 +220,7 @@ export class PerformanceMetrics {
|
||||
|
||||
// Cleanup endpoint metrics
|
||||
for (const [key, metrics] of this.endpointMetrics.entries()) {
|
||||
const filtered = metrics.filter(m => m.timestamp > cutoff);
|
||||
const filtered = metrics.filter((m) => m.timestamp > cutoff);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
this.endpointMetrics.delete(key);
|
||||
@@ -230,7 +230,7 @@ export class PerformanceMetrics {
|
||||
}
|
||||
|
||||
// Cleanup query metrics
|
||||
this.queryMetrics = this.queryMetrics.filter(m => m.timestamp > cutoff);
|
||||
this.queryMetrics = this.queryMetrics.filter((m) => m.timestamp > cutoff);
|
||||
|
||||
logger.debug('[PerformanceMetrics] Cleaned up old metrics');
|
||||
}
|
||||
@@ -256,10 +256,10 @@ export class PerformanceMetrics {
|
||||
return {
|
||||
endpoints: {
|
||||
total: this.endpointMetrics.size,
|
||||
slowest: this.getSlowestEndpoints(5)
|
||||
slowest: this.getSlowestEndpoints(5),
|
||||
},
|
||||
caches: this.getCacheStats(),
|
||||
queries: this.getQueryStats()
|
||||
queries: this.getQueryStats(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,9 +34,12 @@ export class PermissionCache {
|
||||
if (this.cleanupInterval) return;
|
||||
|
||||
// Run cleanup every 5 minutes
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.cleanup();
|
||||
}, 5 * 60 * 1000);
|
||||
this.cleanupInterval = setInterval(
|
||||
() => {
|
||||
this.cleanup();
|
||||
},
|
||||
5 * 60 * 1000
|
||||
);
|
||||
|
||||
logger.info('[PermissionCache] Cleanup interval started');
|
||||
}
|
||||
@@ -79,10 +82,12 @@ export class PermissionCache {
|
||||
static setUserPermissions(userId: number, permissions: string[]): void {
|
||||
this.userPermissionsCache.set(userId, {
|
||||
data: permissions,
|
||||
expiresAt: Date.now() + this.USER_PERMISSIONS_TTL
|
||||
expiresAt: Date.now() + this.USER_PERMISSIONS_TTL,
|
||||
});
|
||||
|
||||
logger.debug(`[PermissionCache] Cached permissions for user ${userId} (${permissions.length} permissions, TTL: 5min)`);
|
||||
logger.debug(
|
||||
`[PermissionCache] Cached permissions for user ${userId} (${permissions.length} permissions, TTL: 5min)`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,10 +117,12 @@ export class PermissionCache {
|
||||
static setRolePermissions(roleId: number, permissions: string[]): void {
|
||||
this.rolePermissionsCache.set(roleId, {
|
||||
data: permissions,
|
||||
expiresAt: Date.now() + this.ROLE_PERMISSIONS_TTL
|
||||
expiresAt: Date.now() + this.ROLE_PERMISSIONS_TTL,
|
||||
});
|
||||
|
||||
logger.debug(`[PermissionCache] Cached permissions for role ${roleId} (${permissions.length} permissions, TTL: 10min)`);
|
||||
logger.debug(
|
||||
`[PermissionCache] Cached permissions for role ${roleId} (${permissions.length} permissions, TTL: 10min)`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -196,7 +203,9 @@ export class PermissionCache {
|
||||
}
|
||||
|
||||
if (userExpired > 0 || roleExpired > 0) {
|
||||
logger.debug(`[PermissionCache] Cleanup: removed ${userExpired} user entries, ${roleExpired} role entries`);
|
||||
logger.debug(
|
||||
`[PermissionCache] Cleanup: removed ${userExpired} user entries, ${roleExpired} role entries`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,7 +220,7 @@ export class PermissionCache {
|
||||
return {
|
||||
userCacheSize: this.userPermissionsCache.size,
|
||||
roleCacheSize: this.rolePermissionsCache.size,
|
||||
totalSize: this.userPermissionsCache.size + this.rolePermissionsCache.size
|
||||
totalSize: this.userPermissionsCache.size + this.rolePermissionsCache.size,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,12 @@ import { UserDatabaseService } from './UserDatabaseService';
|
||||
import { DatabaseService } from './DatabaseService';
|
||||
import { logger } from '../utils/logger';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { PlaySessionRow, GameStatsRow, PlaytimeActivityRow, DistributionRow } from '../types/database-rows';
|
||||
import {
|
||||
PlaySessionRow,
|
||||
GameStatsRow,
|
||||
PlaytimeActivityRow,
|
||||
DistributionRow,
|
||||
} from '../types/database-rows';
|
||||
|
||||
export interface PlaySession {
|
||||
id: number;
|
||||
@@ -49,7 +54,9 @@ export class PlayTrackingService {
|
||||
[userId, gameId, gameTitle, sessionId]
|
||||
);
|
||||
|
||||
logger.info(`Play session started: userId=${userId}, gameId=${gameId}, sessionId=${sessionId}`);
|
||||
logger.info(
|
||||
`Play session started: userId=${userId}, gameId=${gameId}, sessionId=${sessionId}`
|
||||
);
|
||||
return sessionId;
|
||||
} catch (error) {
|
||||
logger.error('Failed to start play session:', error);
|
||||
@@ -92,7 +99,12 @@ export class PlayTrackingService {
|
||||
);
|
||||
|
||||
// Update aggregated stats
|
||||
await this.updateGameStats(session.user_id, session.game_id, session.game_title, durationSeconds);
|
||||
await this.updateGameStats(
|
||||
session.user_id,
|
||||
session.game_id,
|
||||
session.game_title,
|
||||
durationSeconds
|
||||
);
|
||||
await this.updateUserStats(session.user_id, durationSeconds);
|
||||
|
||||
// Update aggregate stats in Flashpoint database for compatibility with Launcher
|
||||
@@ -150,10 +162,9 @@ export class PlayTrackingService {
|
||||
private async updateUserStats(userId: number, durationSeconds: number): Promise<void> {
|
||||
try {
|
||||
// Check if stats exist
|
||||
const existing = UserDatabaseService.get(
|
||||
'SELECT * FROM user_stats WHERE user_id = ?',
|
||||
[userId]
|
||||
);
|
||||
const existing = UserDatabaseService.get('SELECT * FROM user_stats WHERE user_id = ?', [
|
||||
userId,
|
||||
]);
|
||||
|
||||
// Count unique games played
|
||||
const gamesPlayed = UserDatabaseService.get(
|
||||
@@ -221,12 +232,7 @@ export class PlayTrackingService {
|
||||
playtime = ?,
|
||||
playCounter = ?
|
||||
WHERE id = ?`,
|
||||
[
|
||||
aggregateStats.lastPlayed,
|
||||
aggregateStats.totalPlaytime,
|
||||
aggregateStats.totalPlays,
|
||||
gameId
|
||||
]
|
||||
[aggregateStats.lastPlayed, aggregateStats.totalPlaytime, aggregateStats.totalPlays, gameId]
|
||||
);
|
||||
|
||||
logger.info(
|
||||
@@ -256,7 +262,7 @@ export class PlayTrackingService {
|
||||
totalPlaytimeSeconds: 0,
|
||||
totalSessions: 0,
|
||||
firstPlayAt: null,
|
||||
lastPlayAt: null
|
||||
lastPlayAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -266,7 +272,7 @@ export class PlayTrackingService {
|
||||
totalPlaytimeSeconds: stats.total_playtime_seconds,
|
||||
totalSessions: stats.total_sessions,
|
||||
firstPlayAt: stats.first_play_at,
|
||||
lastPlayAt: stats.last_play_at
|
||||
lastPlayAt: stats.last_play_at,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get user stats:', error);
|
||||
@@ -294,7 +300,7 @@ export class PlayTrackingService {
|
||||
totalPlays: stat.total_plays,
|
||||
totalPlaytimeSeconds: stat.total_playtime_seconds,
|
||||
firstPlayedAt: stat.first_played_at,
|
||||
lastPlayedAt: stat.last_played_at
|
||||
lastPlayedAt: stat.last_played_at,
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get user game stats:', error);
|
||||
@@ -324,7 +330,7 @@ export class PlayTrackingService {
|
||||
startedAt: session.started_at,
|
||||
endedAt: session.ended_at,
|
||||
durationSeconds: session.duration_seconds,
|
||||
sessionId: session.session_id
|
||||
sessionId: session.session_id,
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get user play history:', error);
|
||||
@@ -352,7 +358,7 @@ export class PlayTrackingService {
|
||||
totalPlays: stat.total_plays,
|
||||
totalPlaytimeSeconds: stat.total_playtime_seconds,
|
||||
firstPlayedAt: stat.first_played_at,
|
||||
lastPlayedAt: stat.last_played_at
|
||||
lastPlayedAt: stat.last_played_at,
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get top games:', error);
|
||||
@@ -363,7 +369,10 @@ export class PlayTrackingService {
|
||||
/**
|
||||
* Get play activity over time (daily aggregation)
|
||||
*/
|
||||
async getPlayActivityOverTime(userId: number, days = 30): Promise<Array<{ date: string; playtime: number; sessions: number }>> {
|
||||
async getPlayActivityOverTime(
|
||||
userId: number,
|
||||
days = 30
|
||||
): Promise<Array<{ date: string; playtime: number; sessions: number }>> {
|
||||
try {
|
||||
// Validate and sanitize days parameter to prevent SQL injection
|
||||
// Ensure it's a positive integer between 1 and 365
|
||||
@@ -386,7 +395,7 @@ export class PlayTrackingService {
|
||||
return data.map((row: PlaytimeActivityRow) => ({
|
||||
date: row.date,
|
||||
playtime: row.total_playtime || 0,
|
||||
sessions: row.session_count || 0
|
||||
sessions: row.session_count || 0,
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get play activity over time:', error);
|
||||
@@ -397,7 +406,10 @@ export class PlayTrackingService {
|
||||
/**
|
||||
* Get games distribution by playtime
|
||||
*/
|
||||
async getGamesDistribution(userId: number, limit = 10): Promise<Array<{ name: string; value: number }>> {
|
||||
async getGamesDistribution(
|
||||
userId: number,
|
||||
limit = 10
|
||||
): Promise<Array<{ name: string; value: number }>> {
|
||||
try {
|
||||
const stats = UserDatabaseService.all(
|
||||
`SELECT game_title, total_playtime_seconds
|
||||
@@ -410,7 +422,7 @@ export class PlayTrackingService {
|
||||
|
||||
return stats.map((stat: DistributionRow) => ({
|
||||
name: stat.game_title,
|
||||
value: stat.total_playtime_seconds
|
||||
value: stat.total_playtime_seconds,
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Failed to get games distribution:', error);
|
||||
|
||||
@@ -45,7 +45,11 @@ export class PlaylistService {
|
||||
|
||||
// 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)
|
||||
? playlist.games
|
||||
.map((g: unknown) =>
|
||||
typeof g === 'string' ? g : (g as { gameId?: string }).gameId
|
||||
)
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
playlists.push({
|
||||
@@ -55,7 +59,7 @@ export class PlaylistService {
|
||||
author: playlist.author,
|
||||
library: playlist.library,
|
||||
icon: playlist.icon,
|
||||
gameIds
|
||||
gameIds,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to parse playlist file: ${file}`, error);
|
||||
@@ -91,7 +95,8 @@ export class PlaylistService {
|
||||
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;
|
||||
const gameId =
|
||||
typeof playlistGame === 'string' ? playlistGame : playlistGame.gameId;
|
||||
|
||||
if (gameId) {
|
||||
const game = await this.gameService.getGameById(gameId);
|
||||
@@ -109,7 +114,7 @@ export class PlaylistService {
|
||||
author: playlist.author,
|
||||
library: playlist.library,
|
||||
icon: playlist.icon,
|
||||
games
|
||||
games,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -135,7 +140,7 @@ export class PlaylistService {
|
||||
description: data.description || '',
|
||||
author: data.author || 'Unknown',
|
||||
library: data.library || 'arcade',
|
||||
games: []
|
||||
games: [],
|
||||
};
|
||||
|
||||
// Write playlist file
|
||||
@@ -151,7 +156,10 @@ export class PlaylistService {
|
||||
}
|
||||
}
|
||||
|
||||
async addGamesToPlaylist(playlistId: string, data: AddGamesToPlaylistDto): Promise<Playlist | null> {
|
||||
async addGamesToPlaylist(
|
||||
playlistId: string,
|
||||
data: AddGamesToPlaylistDto
|
||||
): Promise<Playlist | null> {
|
||||
try {
|
||||
const playlistsPath = config.flashpointPlaylistsPath;
|
||||
const files = await fs.readdir(playlistsPath);
|
||||
@@ -168,14 +176,14 @@ export class PlaylistService {
|
||||
if (id === playlistId) {
|
||||
// Get existing game IDs
|
||||
const existingGameIds = Array.isArray(playlist.games)
|
||||
? playlist.games.map((g: any) => typeof g === 'string' ? g : g.gameId)
|
||||
? playlist.games.map((g: any) => (typeof g === 'string' ? g : g.gameId))
|
||||
: [];
|
||||
|
||||
// Add new game IDs (avoid duplicates)
|
||||
const newGameIds = data.gameIds.filter(gameId => !existingGameIds.includes(gameId));
|
||||
const newGameIds = data.gameIds.filter((gameId) => !existingGameIds.includes(gameId));
|
||||
|
||||
// Convert to playlist game format
|
||||
const newGames = newGameIds.map(gameId => ({ gameId }));
|
||||
const newGames = newGameIds.map((gameId) => ({ gameId }));
|
||||
|
||||
playlist.games = [...(playlist.games || []), ...newGames];
|
||||
|
||||
@@ -197,7 +205,10 @@ export class PlaylistService {
|
||||
}
|
||||
}
|
||||
|
||||
async removeGamesFromPlaylist(playlistId: string, data: AddGamesToPlaylistDto): Promise<Playlist | null> {
|
||||
async removeGamesFromPlaylist(
|
||||
playlistId: string,
|
||||
data: AddGamesToPlaylistDto
|
||||
): Promise<Playlist | null> {
|
||||
try {
|
||||
const playlistsPath = config.flashpointPlaylistsPath;
|
||||
const files = await fs.readdir(playlistsPath);
|
||||
@@ -266,8 +277,8 @@ export class PlaylistService {
|
||||
private generateUUID(): string {
|
||||
// Simple UUID v4 generator
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export class PreferencesService {
|
||||
const now = Date.now();
|
||||
|
||||
// Return cached preferences if still valid
|
||||
if (this.preferences && (now - this.lastLoadTime) < this.CACHE_TTL) {
|
||||
if (this.preferences && now - this.lastLoadTime < this.CACHE_TTL) {
|
||||
return this.preferences;
|
||||
}
|
||||
|
||||
@@ -78,11 +78,13 @@ export class PreferencesService {
|
||||
sourceCount: parsed.gameDataSources?.length || 0,
|
||||
dataPacksPath: parsed.dataPacksFolderPath,
|
||||
imageFolderPath: parsed.imageFolderPath,
|
||||
onDemandBaseUrl: parsed.onDemandBaseUrl
|
||||
onDemandBaseUrl: parsed.onDemandBaseUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to load preferences:', error);
|
||||
throw new Error(`Failed to load Flashpoint preferences: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
throw new Error(
|
||||
`Failed to load Flashpoint preferences: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,9 +18,9 @@ export class RoleService {
|
||||
);
|
||||
|
||||
// Get permissions for each role
|
||||
return roles.map(role => ({
|
||||
return roles.map((role) => ({
|
||||
...role,
|
||||
permissions: this.getRolePermissions(role.id)
|
||||
permissions: this.getRolePermissions(role.id),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export class RoleService {
|
||||
|
||||
return {
|
||||
...role,
|
||||
permissions: this.getRolePermissions(id)
|
||||
permissions: this.getRolePermissions(id),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -100,7 +100,12 @@ export class RoleService {
|
||||
/**
|
||||
* Update role
|
||||
*/
|
||||
async updateRole(id: number, name?: string, description?: string, priority?: number): Promise<Role> {
|
||||
async updateRole(
|
||||
id: number,
|
||||
name?: string,
|
||||
description?: string,
|
||||
priority?: number
|
||||
): Promise<Role> {
|
||||
const role = await this.getRoleById(id);
|
||||
if (!role) {
|
||||
throw new AppError(404, 'Role not found');
|
||||
@@ -115,10 +120,10 @@ export class RoleService {
|
||||
const params: any[] = [];
|
||||
|
||||
if (name !== undefined) {
|
||||
const existing = UserDatabaseService.get(
|
||||
'SELECT id FROM roles WHERE name = ? AND id != ?',
|
||||
[name, id]
|
||||
);
|
||||
const existing = UserDatabaseService.get('SELECT id FROM roles WHERE name = ? AND id != ?', [
|
||||
name,
|
||||
id,
|
||||
]);
|
||||
if (existing) {
|
||||
throw new AppError(409, 'Role name already exists');
|
||||
}
|
||||
@@ -140,14 +145,11 @@ export class RoleService {
|
||||
return role;
|
||||
}
|
||||
|
||||
updates.push("updated_at = ?");
|
||||
updates.push('updated_at = ?');
|
||||
params.push(new Date().toISOString());
|
||||
params.push(id);
|
||||
|
||||
UserDatabaseService.run(
|
||||
`UPDATE roles SET ${updates.join(', ')} WHERE id = ?`,
|
||||
params
|
||||
);
|
||||
UserDatabaseService.run(`UPDATE roles SET ${updates.join(', ')} WHERE id = ?`, params);
|
||||
|
||||
return (await this.getRoleById(id))!;
|
||||
}
|
||||
@@ -201,10 +203,9 @@ export class RoleService {
|
||||
}
|
||||
|
||||
// Check if role is assigned to users
|
||||
const userCount = UserDatabaseService.get(
|
||||
'SELECT COUNT(*) as count FROM users WHERE role_id = ?',
|
||||
[id]
|
||||
)?.count || 0;
|
||||
const userCount =
|
||||
UserDatabaseService.get('SELECT COUNT(*) as count FROM users WHERE role_id = ?', [id])
|
||||
?.count || 0;
|
||||
|
||||
if (userCount > 0) {
|
||||
throw new AppError(409, `Cannot delete role: ${userCount} users are assigned to this role`);
|
||||
|
||||
@@ -73,9 +73,9 @@ export class RuffleService {
|
||||
const response = await axios.get(`${this.githubApiUrl}?per_page=1`, {
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'Flashpoint-Web'
|
||||
}
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'Flashpoint-Web',
|
||||
},
|
||||
});
|
||||
|
||||
const release = response.data[0];
|
||||
@@ -84,9 +84,7 @@ export class RuffleService {
|
||||
}
|
||||
|
||||
// Find web-selfhosted asset
|
||||
const asset = release.assets.find((a: any) =>
|
||||
a.name.includes('web-selfhosted.zip')
|
||||
);
|
||||
const asset = release.assets.find((a: any) => a.name.includes('web-selfhosted.zip'));
|
||||
|
||||
if (!asset) {
|
||||
throw new Error('web-selfhosted.zip not found in release');
|
||||
@@ -96,7 +94,7 @@ export class RuffleService {
|
||||
version: release.tag_name.replace('nightly-', ''),
|
||||
downloadUrl: asset.browser_download_url,
|
||||
publishedAt: release.published_at,
|
||||
changelog: release.body || 'No changelog available.'
|
||||
changelog: release.body || 'No changelog available.',
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error fetching latest Ruffle version:', error);
|
||||
@@ -128,7 +126,7 @@ export class RuffleService {
|
||||
latestVersion: latest.version,
|
||||
updateAvailable,
|
||||
changelog: latest.changelog,
|
||||
publishedAt: latest.publishedAt
|
||||
publishedAt: latest.publishedAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -149,8 +147,8 @@ export class RuffleService {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 60000, // 60 seconds for download
|
||||
headers: {
|
||||
'User-Agent': 'Flashpoint-Web'
|
||||
}
|
||||
'User-Agent': 'Flashpoint-Web',
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('[RuffleService] Download complete, extracting...');
|
||||
@@ -198,7 +196,7 @@ export class RuffleService {
|
||||
return {
|
||||
success: true,
|
||||
version: latest.version,
|
||||
message: `Successfully updated Ruffle to version ${latest.version}`
|
||||
message: `Successfully updated Ruffle to version ${latest.version}`,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[RuffleService] Error updating Ruffle:', error);
|
||||
|
||||
@@ -15,7 +15,7 @@ export class StatisticsService {
|
||||
private playlistService = new PlaylistService();
|
||||
private cache: { data: Statistics | null; timestamp: number } = {
|
||||
data: null,
|
||||
timestamp: 0
|
||||
timestamp: 0,
|
||||
};
|
||||
private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
@@ -73,7 +73,10 @@ export class StatisticsService {
|
||||
const uniqueTags = new Set<string>();
|
||||
|
||||
for (const row of results) {
|
||||
const tags = row.tagsStr.split(';').map(tag => tag.trim()).filter(tag => tag);
|
||||
const tags = row.tagsStr
|
||||
.split(';')
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag);
|
||||
for (const tag of tags) {
|
||||
uniqueTags.add(tag);
|
||||
}
|
||||
@@ -103,7 +106,7 @@ export class StatisticsService {
|
||||
totalPlatforms,
|
||||
webPlayableGames,
|
||||
totalPlaylists,
|
||||
totalTags
|
||||
totalTags,
|
||||
};
|
||||
|
||||
logger.debug('Statistics calculated:', statistics);
|
||||
@@ -111,7 +114,7 @@ export class StatisticsService {
|
||||
// Update cache
|
||||
this.cache = {
|
||||
data: statistics,
|
||||
timestamp: Date.now()
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
return statistics;
|
||||
|
||||
@@ -31,7 +31,7 @@ export class SyncStatusService {
|
||||
isRunning: false,
|
||||
stage: 'idle',
|
||||
progress: 0,
|
||||
message: 'No sync in progress'
|
||||
message: 'No sync in progress',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ export class SyncStatusService {
|
||||
stage: 'initializing',
|
||||
progress: 0,
|
||||
message: 'Starting metadata sync...',
|
||||
startTime: new Date().toISOString()
|
||||
startTime: new Date().toISOString(),
|
||||
};
|
||||
|
||||
logger.info('[SyncStatus] Sync started');
|
||||
@@ -121,7 +121,7 @@ export class SyncStatusService {
|
||||
isRunning: false,
|
||||
stage: 'idle',
|
||||
progress: 0,
|
||||
message: 'No sync in progress'
|
||||
message: 'No sync in progress',
|
||||
};
|
||||
|
||||
logger.debug('[SyncStatus] Status reset');
|
||||
|
||||
@@ -137,10 +137,7 @@ export class SystemSettingsService {
|
||||
* Check if a setting exists
|
||||
*/
|
||||
exists(key: string): boolean {
|
||||
const row = UserDatabaseService.get(
|
||||
'SELECT 1 FROM system_settings WHERE key = ?',
|
||||
[key]
|
||||
);
|
||||
const row = UserDatabaseService.get('SELECT 1 FROM system_settings WHERE key = ?', [key]);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
@@ -182,7 +179,7 @@ export class SystemSettingsService {
|
||||
category: row.category,
|
||||
description: row.description,
|
||||
isPublic: row.is_public === 1,
|
||||
defaultValue: this.parseValue(row.default_value, row.data_type)
|
||||
defaultValue: this.parseValue(row.default_value, row.data_type),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -203,21 +200,27 @@ export class SystemSettingsService {
|
||||
const schema = JSON.parse(row.validation_schema);
|
||||
this.validateAgainstSchema(value, schema, key);
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid validation schema for ${key}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
throw new Error(
|
||||
`Invalid validation schema for ${key}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate value against JSON schema
|
||||
*/
|
||||
private validateAgainstSchema(value: SettingValue, schema: Record<string, unknown>, key: string): void {
|
||||
private validateAgainstSchema(
|
||||
value: SettingValue,
|
||||
schema: Record<string, unknown>,
|
||||
key: string
|
||||
): void {
|
||||
// Type validation
|
||||
if (typeof schema.type === 'string') {
|
||||
const actualType = this.getType(value);
|
||||
|
||||
// Allow integers when schema expects 'number' (e.g., 0, 1 are valid for float settings)
|
||||
const isValidType = actualType === schema.type ||
|
||||
(schema.type === 'number' && actualType === 'integer');
|
||||
const isValidType =
|
||||
actualType === schema.type || (schema.type === 'number' && actualType === 'integer');
|
||||
|
||||
if (!isValidType) {
|
||||
throw new Error(`Setting '${key}' must be of type ${schema.type}, got ${actualType}`);
|
||||
|
||||
@@ -45,7 +45,7 @@ export class UpdateService {
|
||||
{ id: 'core-manager', name: 'Manager' },
|
||||
{ id: 'core-database', name: 'Database' },
|
||||
{ id: 'core-server-legacy', name: 'Legacy Server' },
|
||||
{ id: 'core-configuration', name: 'Configuration' }
|
||||
{ id: 'core-configuration', name: 'Configuration' },
|
||||
];
|
||||
|
||||
// Read components directory to check installed versions
|
||||
@@ -58,7 +58,10 @@ export class UpdateService {
|
||||
const componentPath = path.join(componentsDir, component.id);
|
||||
|
||||
// Check if component file exists
|
||||
const exists = await fs.access(componentPath).then(() => true).catch(() => false);
|
||||
const exists = await fs
|
||||
.access(componentPath)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (exists) {
|
||||
// Read component file - first line contains hash, size, and dependencies
|
||||
@@ -76,14 +79,14 @@ export class UpdateService {
|
||||
id: component.id,
|
||||
name: component.name,
|
||||
state: 'UP_TO_DATE',
|
||||
currentVersion
|
||||
currentVersion,
|
||||
});
|
||||
} else {
|
||||
logger.warn(`[UpdateService] Component file not found: ${componentPath}`);
|
||||
componentsStatus.push({
|
||||
id: component.id,
|
||||
name: component.name,
|
||||
state: 'ERROR'
|
||||
state: 'ERROR',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -91,7 +94,7 @@ export class UpdateService {
|
||||
componentsStatus.push({
|
||||
id: component.id,
|
||||
name: component.name,
|
||||
state: 'ERROR'
|
||||
state: 'ERROR',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -104,7 +107,6 @@ export class UpdateService {
|
||||
} catch (error) {
|
||||
logger.warn('[UpdateService] Could not fetch remote component list:', error);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[UpdateService] Error during update check:', error);
|
||||
}
|
||||
@@ -136,7 +138,7 @@ export class UpdateService {
|
||||
version,
|
||||
buildDate,
|
||||
componentsStatus,
|
||||
lastChecked: new Date().toISOString()
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -145,13 +147,16 @@ export class UpdateService {
|
||||
|
||||
try {
|
||||
// Check if Flashpoint Manager exists
|
||||
const managerExists = await fs.access(this.managerPath).then(() => true).catch(() => false);
|
||||
const managerExists = await fs
|
||||
.access(this.managerPath)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (!managerExists) {
|
||||
logger.warn('[UpdateService] Flashpoint Manager not found at:', this.managerPath);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Flashpoint Manager not found. Please update manually.'
|
||||
message: 'Flashpoint Manager not found. Please update manually.',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -159,7 +164,7 @@ export class UpdateService {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fpm = spawn(this.managerPath, ['update'], {
|
||||
cwd: path.join(this.flashpointRoot, 'Manager'),
|
||||
windowsHide: true
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
let errorOutput = '';
|
||||
@@ -178,13 +183,13 @@ export class UpdateService {
|
||||
logger.info('[UpdateService] Updates installed successfully');
|
||||
resolve({
|
||||
success: true,
|
||||
message: 'Updates installed successfully. Please restart the application.'
|
||||
message: 'Updates installed successfully. Please restart the application.',
|
||||
});
|
||||
} else {
|
||||
logger.error('[UpdateService] Update failed with code:', code);
|
||||
resolve({
|
||||
success: false,
|
||||
message: `Update failed: ${errorOutput || 'Unknown error'}`
|
||||
message: `Update failed: ${errorOutput || 'Unknown error'}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -195,28 +200,37 @@ export class UpdateService {
|
||||
});
|
||||
|
||||
// Timeout after 5 minutes
|
||||
setTimeout(() => {
|
||||
fpm.kill();
|
||||
reject(new Error('Update process timed out'));
|
||||
}, 5 * 60 * 1000);
|
||||
setTimeout(
|
||||
() => {
|
||||
fpm.kill();
|
||||
reject(new Error('Update process timed out'));
|
||||
},
|
||||
5 * 60 * 1000
|
||||
);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[UpdateService] Error during update installation:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to install updates'
|
||||
message: error instanceof Error ? error.message : 'Failed to install updates',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async getSystemInfo(): Promise<{ flashpointRoot: string; managerPath: string; managerExists: boolean }> {
|
||||
const managerExists = await fs.access(this.managerPath).then(() => true).catch(() => false);
|
||||
async getSystemInfo(): Promise<{
|
||||
flashpointRoot: string;
|
||||
managerPath: string;
|
||||
managerExists: boolean;
|
||||
}> {
|
||||
const managerExists = await fs
|
||||
.access(this.managerPath)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
return {
|
||||
flashpointRoot: this.flashpointRoot,
|
||||
managerPath: this.managerPath,
|
||||
managerExists
|
||||
managerExists,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ export class UserDatabaseService {
|
||||
).all() as { name: string }[];
|
||||
|
||||
// Filter out the migrations table itself to check for other tables
|
||||
const otherTables = tables.filter(t => t.name !== 'migrations');
|
||||
const otherTables = tables.filter((t) => t.name !== 'migrations');
|
||||
|
||||
if (otherTables.length === 0) {
|
||||
// New database, no detection needed
|
||||
@@ -113,7 +113,7 @@ export class UserDatabaseService {
|
||||
|
||||
// Detect 001_complete_schema (check for users table)
|
||||
// This migration includes all schema, seed data, and indexes
|
||||
if (otherTables.some(t => t.name === 'users')) {
|
||||
if (otherTables.some((t) => t.name === 'users')) {
|
||||
markApplied('001_complete_schema', 'Detected existing schema and seed data');
|
||||
}
|
||||
|
||||
@@ -140,8 +140,9 @@ export class UserDatabaseService {
|
||||
const migrationDir = path.join(__dirname, '../migrations');
|
||||
|
||||
// Get all migration files (*.sql files with numeric prefix)
|
||||
const files = fs.readdirSync(migrationDir)
|
||||
.filter(f => f.endsWith('.sql') && /^\d{3}_/.test(f))
|
||||
const files = fs
|
||||
.readdirSync(migrationDir)
|
||||
.filter((f) => f.endsWith('.sql') && /^\d{3}_/.test(f))
|
||||
.sort(); // Alphabetical sort ensures correct order (001, 002, etc.)
|
||||
|
||||
logger.info(`[UserDB] Found ${files.length} migration file(s)`);
|
||||
@@ -151,9 +152,7 @@ export class UserDatabaseService {
|
||||
const migrationPath = path.join(migrationDir, file);
|
||||
|
||||
// Check if migration has already been applied
|
||||
const applied = this.db!.prepare(
|
||||
'SELECT 1 FROM migrations WHERE name = ?'
|
||||
).get(name);
|
||||
const applied = this.db!.prepare('SELECT 1 FROM migrations WHERE name = ?').get(name);
|
||||
|
||||
if (!applied) {
|
||||
logger.info(`[UserDB] Running migration: ${name}`);
|
||||
@@ -226,15 +225,19 @@ export class UserDatabaseService {
|
||||
static exec<T = any>(sql: string, params: any[] = []): T[] {
|
||||
const db = this.getDatabase();
|
||||
|
||||
return measureQueryPerformance(() => {
|
||||
try {
|
||||
const stmt = db.prepare(sql);
|
||||
return stmt.all(params) as T[];
|
||||
} catch (error) {
|
||||
logger.error('[UserDB] Query error:', { sql, params, error });
|
||||
throw error;
|
||||
}
|
||||
}, sql, params);
|
||||
return measureQueryPerformance(
|
||||
() => {
|
||||
try {
|
||||
const stmt = db.prepare(sql);
|
||||
return stmt.all(params) as T[];
|
||||
} catch (error) {
|
||||
logger.error('[UserDB] Query error:', { sql, params, error });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
sql,
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -243,16 +246,20 @@ export class UserDatabaseService {
|
||||
static get<T = any>(sql: string, params: any[] = []): T | undefined {
|
||||
const db = this.getDatabase();
|
||||
|
||||
return measureQueryPerformance(() => {
|
||||
try {
|
||||
const stmt = db.prepare(sql);
|
||||
const result = stmt.get(params);
|
||||
return result as T | undefined;
|
||||
} catch (error) {
|
||||
logger.error('[UserDB] Get error:', { sql, params, error });
|
||||
throw error;
|
||||
}
|
||||
}, sql, params);
|
||||
return measureQueryPerformance(
|
||||
() => {
|
||||
try {
|
||||
const stmt = db.prepare(sql);
|
||||
const result = stmt.get(params);
|
||||
return result as T | undefined;
|
||||
} catch (error) {
|
||||
logger.error('[UserDB] Get error:', { sql, params, error });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
sql,
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -268,19 +275,23 @@ export class UserDatabaseService {
|
||||
static run(sql: string, params: any[] = []): BetterSqlite3.RunResult {
|
||||
const db = this.getDatabase();
|
||||
|
||||
return measureQueryPerformance(() => {
|
||||
try {
|
||||
const stmt = db.prepare(sql);
|
||||
return stmt.run(params);
|
||||
} catch (error) {
|
||||
logger.error('[UserDB] Run error:', { sql, params });
|
||||
logger.error('[UserDB] Error details:', error);
|
||||
if (error instanceof Error) {
|
||||
logger.error('[UserDB] Error stack:', error.stack);
|
||||
return measureQueryPerformance(
|
||||
() => {
|
||||
try {
|
||||
const stmt = db.prepare(sql);
|
||||
return stmt.run(params);
|
||||
} catch (error) {
|
||||
logger.error('[UserDB] Run error:', { sql, params });
|
||||
logger.error('[UserDB] Error details:', error);
|
||||
if (error instanceof Error) {
|
||||
logger.error('[UserDB] Error stack:', error.stack);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}, sql, params);
|
||||
},
|
||||
sql,
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -171,12 +171,7 @@ export class UserPlaylistService {
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const result = stmt.run(
|
||||
userId,
|
||||
data.title,
|
||||
data.description || null,
|
||||
data.icon || null
|
||||
);
|
||||
const result = stmt.run(userId, data.title, data.description || null, data.icon || null);
|
||||
|
||||
logger.info(`Created playlist "${data.title}" for user ${userId}`);
|
||||
|
||||
@@ -219,7 +214,7 @@ export class UserPlaylistService {
|
||||
return playlist;
|
||||
}
|
||||
|
||||
updates.push('updated_at = datetime(\'now\')');
|
||||
updates.push("updated_at = datetime('now')");
|
||||
values.push(playlistId, userId);
|
||||
|
||||
const stmt = db.prepare(`
|
||||
@@ -381,7 +376,7 @@ export class UserPlaylistService {
|
||||
// Create new user playlist
|
||||
const userPlaylist = this.createPlaylist(userId, {
|
||||
title: newTitle || `${flashpointPlaylist.title} (Copy)`,
|
||||
description: flashpointPlaylist.description || undefined
|
||||
description: flashpointPlaylist.description || undefined,
|
||||
});
|
||||
|
||||
// Extract game IDs from flashpoint playlist
|
||||
@@ -430,7 +425,9 @@ export class UserPlaylistService {
|
||||
// Verify ownership
|
||||
const playlist = this.getPlaylistById(playlistId, userId);
|
||||
if (!playlist) {
|
||||
logger.warn(`Cannot enable sharing for playlist ${playlistId}: not found or not owned by user ${userId}`);
|
||||
logger.warn(
|
||||
`Cannot enable sharing for playlist ${playlistId}: not found or not owned by user ${userId}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -448,20 +445,14 @@ export class UserPlaylistService {
|
||||
WHERE id = ? AND user_id = ?
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
shareToken,
|
||||
options.expiresAt || null,
|
||||
options.showOwner ? 1 : 0,
|
||||
playlistId,
|
||||
userId
|
||||
);
|
||||
stmt.run(shareToken, options.expiresAt || null, options.showOwner ? 1 : 0, playlistId, userId);
|
||||
|
||||
logger.info(`Enabled sharing for playlist ${playlistId} (user ${userId})`);
|
||||
|
||||
return {
|
||||
shareToken,
|
||||
expiresAt: options.expiresAt || null,
|
||||
showOwner: options.showOwner || false
|
||||
showOwner: options.showOwner || false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -475,7 +466,9 @@ export class UserPlaylistService {
|
||||
// Verify ownership
|
||||
const playlist = this.getPlaylistById(playlistId, userId);
|
||||
if (!playlist) {
|
||||
logger.warn(`Cannot disable sharing for playlist ${playlistId}: not found or not owned by user ${userId}`);
|
||||
logger.warn(
|
||||
`Cannot disable sharing for playlist ${playlistId}: not found or not owned by user ${userId}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -502,7 +495,9 @@ export class UserPlaylistService {
|
||||
// Verify ownership
|
||||
const playlist = this.getPlaylistById(playlistId, userId);
|
||||
if (!playlist) {
|
||||
logger.warn(`Cannot regenerate token for playlist ${playlistId}: not found or not owned by user ${userId}`);
|
||||
logger.warn(
|
||||
`Cannot regenerate token for playlist ${playlistId}: not found or not owned by user ${userId}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -523,7 +518,7 @@ export class UserPlaylistService {
|
||||
return {
|
||||
shareToken: newToken,
|
||||
expiresAt: playlist.shareExpiresAt || null,
|
||||
showOwner: playlist.showOwner || false
|
||||
showOwner: playlist.showOwner || false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -540,7 +535,9 @@ export class UserPlaylistService {
|
||||
// Verify ownership
|
||||
const playlist = this.getPlaylistById(playlistId, userId);
|
||||
if (!playlist) {
|
||||
logger.warn(`Cannot update share settings for playlist ${playlistId}: not found or not owned by user ${userId}`);
|
||||
logger.warn(
|
||||
`Cannot update share settings for playlist ${playlistId}: not found or not owned by user ${userId}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -559,7 +556,7 @@ export class UserPlaylistService {
|
||||
|
||||
stmt.run(
|
||||
options.expiresAt !== undefined ? options.expiresAt : playlist.shareExpiresAt,
|
||||
options.showOwner !== undefined ? (options.showOwner ? 1 : 0) : (playlist.showOwner ? 1 : 0),
|
||||
options.showOwner !== undefined ? (options.showOwner ? 1 : 0) : playlist.showOwner ? 1 : 0,
|
||||
playlistId,
|
||||
userId
|
||||
);
|
||||
@@ -568,8 +565,9 @@ export class UserPlaylistService {
|
||||
|
||||
return {
|
||||
shareToken: playlist.shareToken,
|
||||
expiresAt: options.expiresAt !== undefined ? options.expiresAt : (playlist.shareExpiresAt || null),
|
||||
showOwner: options.showOwner !== undefined ? options.showOwner : (playlist.showOwner || false)
|
||||
expiresAt:
|
||||
options.expiresAt !== undefined ? options.expiresAt : playlist.shareExpiresAt || null,
|
||||
showOwner: options.showOwner !== undefined ? options.showOwner : playlist.showOwner || false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -591,7 +589,9 @@ export class UserPlaylistService {
|
||||
const playlist = stmt.get(shareToken) as UserPlaylist | undefined;
|
||||
|
||||
if (!playlist) {
|
||||
logger.debug(`Shared playlist not found or expired for token: ${shareToken.substring(0, 8)}...`);
|
||||
logger.debug(
|
||||
`Shared playlist not found or expired for token: ${shareToken.substring(0, 8)}...`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -661,11 +661,7 @@ export class UserPlaylistService {
|
||||
* Creates new playlist with same title (+ " (Copy)"), description, icon
|
||||
* Copies all games with same order
|
||||
*/
|
||||
cloneSharedPlaylist(
|
||||
shareToken: string,
|
||||
userId: number,
|
||||
newTitle?: string
|
||||
): UserPlaylist | null {
|
||||
cloneSharedPlaylist(shareToken: string, userId: number, newTitle?: string): UserPlaylist | null {
|
||||
const db = this.userDb.getDatabase();
|
||||
|
||||
// Get shared playlist
|
||||
@@ -680,7 +676,7 @@ export class UserPlaylistService {
|
||||
const newPlaylist = this.createPlaylist(userId, {
|
||||
title,
|
||||
description: sourcePlaylist.description || undefined,
|
||||
icon: sourcePlaylist.icon || undefined
|
||||
icon: sourcePlaylist.icon || undefined,
|
||||
});
|
||||
|
||||
// Copy games with same order
|
||||
|
||||
@@ -51,19 +51,17 @@ export class UserService {
|
||||
*/
|
||||
async createUser(data: CreateUserData): Promise<User> {
|
||||
// Check if username exists
|
||||
const existingUser = UserDatabaseService.get(
|
||||
'SELECT id FROM users WHERE username = ?',
|
||||
[data.username]
|
||||
);
|
||||
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]
|
||||
);
|
||||
const existingEmail = UserDatabaseService.get('SELECT id FROM users WHERE email = ?', [
|
||||
data.email,
|
||||
]);
|
||||
if (existingEmail) {
|
||||
throw new AppError(409, 'Email already exists');
|
||||
}
|
||||
@@ -98,10 +96,10 @@ export class UserService {
|
||||
|
||||
if (data.email !== undefined) {
|
||||
// Check if email is taken by another user
|
||||
const existing = UserDatabaseService.get(
|
||||
'SELECT id FROM users WHERE email = ? AND id != ?',
|
||||
[data.email, id]
|
||||
);
|
||||
const existing = UserDatabaseService.get('SELECT id FROM users WHERE email = ? AND id != ?', [
|
||||
data.email,
|
||||
id,
|
||||
]);
|
||||
if (existing) {
|
||||
throw new AppError(409, 'Email already exists');
|
||||
}
|
||||
@@ -123,14 +121,11 @@ export class UserService {
|
||||
return user; // No changes
|
||||
}
|
||||
|
||||
updates.push("updated_at = ?");
|
||||
updates.push('updated_at = ?');
|
||||
params.push(new Date().toISOString());
|
||||
params.push(id);
|
||||
|
||||
UserDatabaseService.run(
|
||||
`UPDATE users SET ${updates.join(', ')} WHERE id = ?`,
|
||||
params
|
||||
);
|
||||
UserDatabaseService.run(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, params);
|
||||
|
||||
// Invalidate permission cache if role changed
|
||||
if (data.roleId !== undefined) {
|
||||
@@ -152,10 +147,11 @@ export class UserService {
|
||||
|
||||
// Prevent deleting the last admin
|
||||
if (user.roleName === 'admin') {
|
||||
const adminCount = UserDatabaseService.get(
|
||||
'SELECT COUNT(*) as count FROM users u JOIN roles r ON u.role_id = r.id WHERE r.name = "admin"',
|
||||
[]
|
||||
)?.count || 0;
|
||||
const adminCount =
|
||||
UserDatabaseService.get(
|
||||
'SELECT COUNT(*) as count FROM users u JOIN roles r ON u.role_id = r.id WHERE r.name = "admin"',
|
||||
[]
|
||||
)?.count || 0;
|
||||
|
||||
if (adminCount <= 1) {
|
||||
throw new AppError(403, 'Cannot delete the last admin user');
|
||||
@@ -169,10 +165,7 @@ export class UserService {
|
||||
* Change user password
|
||||
*/
|
||||
async changePassword(id: number, currentPassword: string, newPassword: string): 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) {
|
||||
throw new AppError(404, 'User not found');
|
||||
@@ -189,14 +182,13 @@ export class UserService {
|
||||
const passwordHash = await hashPassword(newPassword);
|
||||
|
||||
// Update password
|
||||
UserDatabaseService.run(
|
||||
"UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?",
|
||||
[passwordHash, new Date().toISOString(), id]
|
||||
);
|
||||
UserDatabaseService.run('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?', [
|
||||
passwordHash,
|
||||
new Date().toISOString(),
|
||||
id,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Get user setting by key
|
||||
*/
|
||||
@@ -218,10 +210,13 @@ export class UserService {
|
||||
[userId]
|
||||
);
|
||||
|
||||
return results.reduce((acc: Record<string, string>, row: any) => {
|
||||
acc[row.setting_key] = row.setting_value;
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
return results.reduce(
|
||||
(acc: Record<string, string>, row: any) => {
|
||||
acc[row.setting_key] = row.setting_value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -266,7 +261,7 @@ export class UserService {
|
||||
|
||||
return {
|
||||
mode: mode || 'dark',
|
||||
primaryColor: primaryColor || 'blue'
|
||||
primaryColor: primaryColor || 'blue',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,4 +18,9 @@ import { initializeTelemetry } from './otel';
|
||||
initializeTelemetry();
|
||||
|
||||
// Re-export for convenience
|
||||
export { initializeTelemetry, shutdownTelemetry, isTelemetryEnabled, getTelemetryConfig } from './otel';
|
||||
export {
|
||||
initializeTelemetry,
|
||||
shutdownTelemetry,
|
||||
isTelemetryEnabled,
|
||||
getTelemetryConfig,
|
||||
} from './otel';
|
||||
|
||||
@@ -4,7 +4,10 @@ import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
|
||||
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
|
||||
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
|
||||
import { resourceFromAttributes } from '@opentelemetry/resources';
|
||||
import { SEMRESATTRS_SERVICE_NAME, SEMRESATTRS_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
|
||||
import {
|
||||
SEMRESATTRS_SERVICE_NAME,
|
||||
SEMRESATTRS_SERVICE_VERSION,
|
||||
} from '@opentelemetry/semantic-conventions';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
@@ -42,7 +45,7 @@ function getOtelConfig() {
|
||||
tracesEnabled: process.env.OTEL_TRACES_ENABLED !== 'false',
|
||||
metricsEnabled: process.env.OTEL_METRICS_ENABLED !== 'false',
|
||||
metricsExportInterval: parseInt(process.env.OTEL_METRICS_EXPORT_INTERVAL || '60000', 10),
|
||||
logLevel: process.env.OTEL_LOG_LEVEL || 'info'
|
||||
logLevel: process.env.OTEL_LOG_LEVEL || 'info',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -54,7 +57,7 @@ function getExporterHeaders(apiKey: string): Record<string, string> {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
'Authorization': `Api-Key ${apiKey}`
|
||||
Authorization: `Api-Key ${apiKey}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,14 +79,14 @@ export function initializeTelemetry(): void {
|
||||
endpoint: config.endpoint,
|
||||
service: config.serviceName,
|
||||
traces: config.tracesEnabled,
|
||||
metrics: config.metricsEnabled
|
||||
metrics: config.metricsEnabled,
|
||||
});
|
||||
|
||||
// Create resource (service metadata)
|
||||
const resource = resourceFromAttributes({
|
||||
[SEMRESATTRS_SERVICE_NAME]: config.serviceName,
|
||||
[SEMRESATTRS_SERVICE_VERSION]: config.serviceVersion,
|
||||
environment: process.env.NODE_ENV || 'development'
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
});
|
||||
|
||||
// Build exporter headers
|
||||
@@ -117,10 +120,10 @@ export function initializeTelemetry(): void {
|
||||
getNodeAutoInstrumentations({
|
||||
// Disable specific instrumentations if needed
|
||||
'@opentelemetry/instrumentation-fs': {
|
||||
enabled: false // File system instrumentation can be noisy
|
||||
enabled: false, // File system instrumentation can be noisy
|
||||
},
|
||||
'@opentelemetry/instrumentation-dns': {
|
||||
enabled: false
|
||||
enabled: false,
|
||||
},
|
||||
// HTTP instrumentation
|
||||
'@opentelemetry/instrumentation-http': {
|
||||
@@ -134,16 +137,16 @@ export function initializeTelemetry(): void {
|
||||
headersToSpanAttributes: {
|
||||
server: {
|
||||
requestHeaders: ['user-agent', 'content-type'],
|
||||
responseHeaders: ['content-type']
|
||||
}
|
||||
}
|
||||
responseHeaders: ['content-type'],
|
||||
},
|
||||
},
|
||||
},
|
||||
// Express instrumentation
|
||||
'@opentelemetry/instrumentation-express': {
|
||||
enabled: true
|
||||
}
|
||||
})
|
||||
]
|
||||
enabled: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Start SDK
|
||||
@@ -151,14 +154,13 @@ export function initializeTelemetry(): void {
|
||||
|
||||
logger.info('[OpenTelemetry] Telemetry initialized successfully', {
|
||||
traces: config.tracesEnabled ? 'enabled' : 'disabled',
|
||||
metrics: config.metricsEnabled ? 'enabled' : 'disabled'
|
||||
metrics: config.metricsEnabled ? 'enabled' : 'disabled',
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
shutdownTelemetry();
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[OpenTelemetry] Failed to initialize telemetry:', error);
|
||||
// Non-fatal - application continues without telemetry
|
||||
|
||||
@@ -4,14 +4,9 @@ export type ActionCategory = 'auth' | 'crud' | 'error' | 'system';
|
||||
* Authentication-related actions used in SQL queries and analytics.
|
||||
* Used for aggregating auth events in getStats(), getTrend(), and getPreviousPeriodStats().
|
||||
*/
|
||||
export const AUTH_ACTIONS = [
|
||||
'login',
|
||||
'logout',
|
||||
'register',
|
||||
'auth.login.failed'
|
||||
] as const;
|
||||
export const AUTH_ACTIONS = ['login', 'logout', 'register', 'auth.login.failed'] as const;
|
||||
|
||||
export type AuthAction = typeof AUTH_ACTIONS[number];
|
||||
export type AuthAction = (typeof AUTH_ACTIONS)[number];
|
||||
|
||||
/**
|
||||
* Categorize an action string into one of four categories.
|
||||
@@ -29,11 +24,7 @@ export function categorizeAction(action: string): ActionCategory {
|
||||
) {
|
||||
return 'auth';
|
||||
}
|
||||
if (
|
||||
action.includes('schedule') ||
|
||||
action.includes('cleanup') ||
|
||||
action.includes('maintenance')
|
||||
) {
|
||||
if (action.includes('schedule') || action.includes('cleanup') || action.includes('maintenance')) {
|
||||
return 'system';
|
||||
}
|
||||
return 'crud';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user