chore: apply consistent code formatting across codebase

This commit is contained in:
Darkraise
2026-02-04 06:03:39 +07:00
parent 5ca30044d6
commit 12792db185
396 changed files with 11050 additions and 8820 deletions
+14 -9
View File
@@ -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
View File
@@ -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.
+8 -8
View File
@@ -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
View File
@@ -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)
+1 -4
View File
@@ -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);
-1
View File
@@ -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
+9 -6
View File
@@ -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,
};
/**
+4 -5
View File
@@ -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
+1 -3
View File
@@ -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}`);
+68 -21
View File
@@ -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)
+1 -1
View File
@@ -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
+1 -5
View File
@@ -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>;
+14 -4
View File
@@ -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();
+2 -2
View File
@@ -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',
});
}
}
+8 -8
View File
@@ -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,
},
});
}
+4 -1
View File
@@ -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();
+23 -26
View File
@@ -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')
);
}
+1 -3
View File
@@ -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'));
+9 -9
View File
@@ -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,
};
+2 -6
View File
@@ -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));
}
+27 -9
View File
@@ -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)
+25 -21
View File
@@ -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.)
+1 -1
View File
@@ -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: [],
+8 -3
View File
@@ -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', () => {
+13 -10
View File
@@ -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',
};
}
+2 -2
View File
@@ -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',
};
/**
+6 -2
View File
@@ -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
View File
@@ -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);
}
+5 -5
View File
@@ -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`);
+27 -15
View File
@@ -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,
});
})
);
+8 -5
View File
@@ -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
View File
@@ -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;
+4 -4
View File
@@ -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,
});
})
);
+2 -2
View File
@@ -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;
+34 -34
View File
@@ -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',
});
}
}
);
+7 -1
View File
@@ -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
View File
@@ -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'
});
}
}
);
+29 -24
View File
@@ -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',
},
});
}
}
+10 -4
View File
@@ -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);
})
);
+35 -32
View File
@@ -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
View File
@@ -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,
});
})
);
+8 -8
View File
@@ -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',
},
});
}
});
+18 -14
View File
@@ -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`,
});
})
);
+2 -2
View File
@@ -95,8 +95,8 @@ export function setupRoutes(app: Express): void {
res.status(404).json({
error: {
message: 'Route not found',
statusCode: 404
}
statusCode: 404,
},
});
});
}
+5 -5
View File
@@ -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';
+6 -6
View File
@@ -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',
});
})
);
+8 -5
View File
@@ -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;
+79 -65
View File
@@ -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;
+22 -12
View File
@@ -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
View File
@@ -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;
+4 -9
View File
@@ -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);
})
+19 -9
View File
@@ -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;
+9 -7
View File
@@ -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);
})
+7 -4
View File
@@ -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;
+27 -25
View File
@@ -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
View File
@@ -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;
+26 -19
View File
@@ -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',
});
}
});
+17 -17
View File
@@ -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
View File
@@ -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
View File
@@ -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);
});
+60 -42
View File
@@ -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,
},
};
}
}
+60 -37
View File
@@ -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 -8
View File
@@ -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);
}
/**
+61 -45
View File
@@ -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
+10 -10
View File
@@ -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;
+24 -31
View File
@@ -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);
+5 -5
View File
@@ -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,
};
}
}
+1 -1
View File
@@ -219,7 +219,7 @@ export class FavoritesService {
if (game) {
return {
...game,
addedAt: fav.added_at
addedAt: fav.added_at,
};
}
return null;
+8 -6
View File
@@ -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
}
+27 -17
View File
@@ -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,
});
}
}
+16 -12
View File
@@ -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,
};
}
+21 -8
View File
@@ -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 {
+38 -18
View File
@@ -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);
}
+24 -18
View File
@@ -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;
+10 -4
View File
@@ -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,
};
}
}
+8 -6
View File
@@ -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',
};
}
}
+9 -11
View File
@@ -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,
};
}
}
+5 -3
View File
@@ -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,
};
}
+73 -41
View File
@@ -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;
}
}
}
+23 -13
View File
@@ -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',
});
}
}
+11 -11
View File
@@ -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(),
};
}
}
+18 -9
View File
@@ -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,
};
}
}
+34 -22
View File
@@ -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);
+23 -12
View File
@@ -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);
});
}
+5 -3
View File
@@ -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 -17
View File
@@ -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`);
+9 -11
View File
@@ -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);
+7 -4
View File
@@ -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;
+3 -3
View File
@@ -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');
+12 -9
View File
@@ -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}`);
+35 -21
View File
@@ -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,
};
}
}
+49 -38
View File
@@ -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
);
}
/**
+27 -31
View File
@@ -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
+31 -36
View File
@@ -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',
};
}
}
+6 -1
View File
@@ -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';
+18 -16
View File
@@ -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
+3 -12
View File
@@ -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