chore: comments cleanup

This commit is contained in:
Darkraise
2026-02-09 17:26:17 +07:00
parent e173ce721e
commit 2f641c8441
262 changed files with 404 additions and 5911 deletions
+8 -56
View File
@@ -5,10 +5,7 @@ import path from 'path';
dotenv.config();
/**
* Generate a secure JWT secret for development environments
* In production, JWT_SECRET MUST be provided as an environment variable
*/
/** In production, JWT_SECRET MUST be set. In dev, generates an ephemeral random secret. */
function getJwtSecret(): string {
const secret = process.env.JWT_SECRET;
@@ -16,7 +13,6 @@ function getJwtSecret(): string {
return secret;
}
// In production, fail fast if JWT_SECRET is not set
if (process.env.NODE_ENV === 'production') {
throw new Error(
'FATAL: JWT_SECRET environment variable is required in production.\n' +
@@ -25,7 +21,6 @@ function getJwtSecret(): string {
);
}
// In development, generate a random secret and warn
const devSecret = `INSECURE-DEV-ONLY-${crypto.randomBytes(32).toString('hex')}`;
// NOTE: console.warn used intentionally - logger not yet initialized at config load time
console.warn('\n⚠️ WARNING: Using auto-generated JWT secret for development.');
@@ -34,9 +29,6 @@ function getJwtSecret(): string {
return devSecret;
}
// Get base Flashpoint path and derive all other paths from it
// Docker (production): /data/flashpoint (volume mount point)
// Local dev: D:/Flashpoint or FLASHPOINT_PATH env var
const getFlashpointPath = (): string => {
if (process.env.FLASHPOINT_PATH) {
return process.env.FLASHPOINT_PATH;
@@ -46,14 +38,6 @@ const getFlashpointPath = (): string => {
const flashpointPath = getFlashpointPath();
/**
* Parse version.txt from Flashpoint installation directory.
* Examples:
* "Flashpoint 14.0.3 Infinity - Kingfisher"
* "Flashpoint 14 Ultimate - Kingfisher"
*
* Returns { edition, versionString } or defaults if file is missing/unparseable.
*/
function parseVersionFile(): { edition: 'infinity' | 'ultimate'; versionString: string } {
const defaults = { edition: 'infinity' as const, versionString: '' };
@@ -74,7 +58,6 @@ function parseVersionFile(): { edition: 'infinity' | 'ultimate'; versionString:
return { edition, versionString: content };
} catch {
// File doesn't exist or can't be read - use defaults
return defaults;
}
}
@@ -82,12 +65,10 @@ function parseVersionFile(): { edition: 'infinity' | 'ultimate'; versionString:
const flashpointVersion = parseVersionFile();
export const config = {
// Server (hardcoded - use docker-compose port mapping to expose on different ports)
nodeEnv: process.env.NODE_ENV || 'development',
port: 3100,
host: '0.0.0.0',
// Flashpoint paths (all derived from FLASHPOINT_PATH)
flashpointPath,
flashpointDbPath: `${flashpointPath}/Data/flashpoint.sqlite`,
flashpointHtdocsPath: `${flashpointPath}/Legacy/htdocs`,
@@ -96,68 +77,39 @@ export const config = {
flashpointPlaylistsPath: `${flashpointPath}/Data/Playlists`,
flashpointGamesPath: `${flashpointPath}/Data/Games`,
// Frontend domain (used for CORS and share URL generation)
domain: process.env.DOMAIN || 'http://localhost:5173',
// Rate limiting
rateLimitWindowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '', 10) || 60000,
rateLimitMaxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '', 10) || 100,
// Logging
// 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),
// User Database
// In Docker (production): /app/data/user.db
// In local dev: ./user.db in the current directory
userDbPath: process.env.NODE_ENV === 'production' ? '/app/data/user.db' : './user.db',
// JWT Configuration
jwtSecret: getJwtSecret(),
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '1h',
// Security
bcryptSaltRounds: parseInt(process.env.BCRYPT_SALT_ROUNDS || '', 10) || 10,
// Home Page Configuration
homeRecentHours: parseInt(process.env.HOME_RECENT_HOURS || '', 10) || 24,
// Local Database Copy (for network storage optimization)
// When enabled, copies flashpoint.sqlite to local storage for faster access
// Stored in /app/data alongside user.db
// When enabled, copies flashpoint.sqlite to local storage for faster network access
enableLocalDbCopy: process.env.ENABLE_LOCAL_DB_COPY === 'true',
localDbPath: '/app/data/flashpoint.sqlite', // Only used when enableLocalDbCopy is true
localDbPath: '/app/data/flashpoint.sqlite',
// SQLite Performance Tuning
sqliteCacheSize: parseInt(process.env.SQLITE_CACHE_SIZE || '', 10) || -64000, // Negative = KB, -64000 = 64MB
sqliteMmapSize: parseInt(process.env.SQLITE_MMAP_SIZE || '', 10) || 268435456, // 256MB default
sqliteCacheSize: parseInt(process.env.SQLITE_CACHE_SIZE || '', 10) || -64000, // Negative = KB (-64000 = 64MB)
sqliteMmapSize: parseInt(process.env.SQLITE_MMAP_SIZE || '', 10) || 268435456, // 256MB
enableCachePrewarm: process.env.ENABLE_CACHE_PREWARM !== 'false',
// Cache Pre-warming
enableCachePrewarm: process.env.ENABLE_CACHE_PREWARM !== 'false', // Enabled by default
// Flashpoint Edition (auto-detected from version.txt)
// Infinity: has metadata source for sync, game table includes logoPath/screenshotPath
// Ultimate: no metadata source, game table lacks logoPath/screenshotPath
// Auto-detected from version.txt; affects metadata sync and image path availability
flashpointEdition: flashpointVersion.edition,
// Full version string from version.txt (e.g., "Flashpoint 14.0.3 Infinity - Kingfisher")
flashpointVersionString: flashpointVersion.versionString,
};
/**
* Get external image URLs for CDN fallback.
* This is resolved dynamically from Flashpoint preferences.
*
* Priority:
* 1. Flashpoint preferences (onDemandBaseUrl)
* 2. Hardcoded defaults
*
* @returns Promise<string[]> Array of image CDN URLs
*/
/** Resolves external image CDN URLs from Flashpoint preferences, with hardcoded fallbacks. */
export async function getExternalImageUrls(): Promise<string[]> {
const { ImageUrlService } = await import('./services/ImageUrlService');
return ImageUrlService.getExternalImageUrls();
+4 -79
View File
@@ -4,9 +4,6 @@ import fs from 'fs/promises';
import { logger } from '../../utils/logger';
import { sanitizeAndValidatePath } from '../utils/pathSecurity';
/**
* CGI Request representation
*/
export interface CgiRequest {
method: string;
url: URL;
@@ -14,36 +11,22 @@ export interface CgiRequest {
body?: Buffer;
}
/**
* CGI Response representation
*/
export interface CgiResponse {
statusCode: number;
headers: Record<string, string>;
body: Buffer;
}
/**
* CGI Executor configuration
*/
export interface CgiExecutorConfig {
/** Path to php-cgi executable */
phpCgiPath: string;
/** Document root (htdocs path) */
documentRoot: string;
/** CGI-BIN path for script validation */
cgiBinPath: string;
/** Execution timeout in milliseconds (default: 30000) */
timeout: number;
/** Maximum request body size in bytes (default: 10MB) */
maxBodySize: number;
/** Maximum response size in bytes (default: 50MB) */
maxResponseSize: number;
}
/**
* Allowed environment variables to pass to CGI scripts
*/
// Security: only these environment variables are passed to CGI scripts
const ALLOWED_ENV_VARS = [
'PATH',
'TEMP',
@@ -56,9 +39,6 @@ const ALLOWED_ENV_VARS = [
'LC_ALL',
];
/**
* Allowed HTTP headers to pass to CGI scripts
*/
const ALLOWED_CGI_HEADERS = new Set([
'host',
'user-agent',
@@ -74,12 +54,7 @@ const ALLOWED_CGI_HEADERS = new Set([
'pragma',
]);
/**
* CGI Executor - Executes PHP scripts via php-cgi
*
* Implements CGI/1.1 specification (RFC 3875) for executing PHP scripts
* in a secure manner with proper environment setup and output parsing.
*/
/** Implements CGI/1.1 (RFC 3875) for executing PHP scripts via php-cgi */
export class CgiExecutor {
private config: CgiExecutorConfig;
@@ -87,9 +62,6 @@ export class CgiExecutor {
this.config = config;
}
/**
* Validate that php-cgi binary exists and is executable
*/
async validateBinary(): Promise<boolean> {
try {
await fs.access(this.config.phpCgiPath, fs.constants.X_OK);
@@ -101,19 +73,8 @@ export class CgiExecutor {
}
}
/**
* Execute a CGI script and return the response
*
* @param scriptPath - Absolute path to the script file
* @param request - The incoming HTTP request
* @returns CGI response with status code, headers, and body
* @throws Error if execution fails or times out
*/
async execute(scriptPath: string, request: CgiRequest): Promise<CgiResponse> {
// Validate script path is within allowed directories (resolves symlinks)
await this.validateScriptPath(scriptPath);
// Build CGI environment variables
const env = this.buildCgiEnvironment(scriptPath, request);
logger.info(`[CGI] Executing: ${scriptPath}`);
@@ -137,7 +98,6 @@ export class CgiExecutor {
let processExited = false;
let forceKillHandle: NodeJS.Timeout | null = null;
// Set up timeout
const timeoutHandle = setTimeout(() => {
timedOut = true;
logger.warn(`[CGI] Script execution timed out after ${this.config.timeout}ms`);
@@ -151,7 +111,6 @@ export class CgiExecutor {
}, 5000);
}, this.config.timeout);
// Handle stdout
proc.stdout.on('data', (chunk: Buffer) => {
stdoutSize += chunk.length;
if (stdoutSize > this.config.maxResponseSize) {
@@ -162,17 +121,13 @@ export class CgiExecutor {
stdout.push(chunk);
});
// Handle stderr (for logging)
proc.stderr.on('data', (chunk: Buffer) => {
stderrSize += chunk.length;
// Limit stderr collection to prevent memory issues
if (stderrSize <= 1024 * 1024) {
// 1MB max for stderr
stderr.push(chunk);
}
});
// Handle process completion
proc.on('close', (code, signal) => {
processExited = true;
clearTimeout(timeoutHandle);
@@ -180,7 +135,6 @@ export class CgiExecutor {
clearTimeout(forceKillHandle);
}
// Log stderr if present
if (stderr.length > 0) {
const stderrOutput = Buffer.concat(stderr).toString('utf-8');
if (stderrOutput.trim()) {
@@ -214,7 +168,6 @@ export class CgiExecutor {
}
});
// Handle process errors
proc.on('error', (error) => {
processExited = true;
clearTimeout(timeoutHandle);
@@ -225,7 +178,6 @@ export class CgiExecutor {
reject(new Error(`Failed to execute CGI script: ${error.message}`));
});
// Write request body to stdin
if (request.body && request.body.length > 0) {
proc.stdin.write(request.body);
}
@@ -233,10 +185,7 @@ export class CgiExecutor {
});
}
/**
* Validate that the script path is within allowed directories
* Resolves symlinks to prevent bypass attacks
*/
/** Resolves symlinks to prevent bypass attacks */
private async validateScriptPath(scriptPath: string): Promise<void> {
let realPath: string;
try {
@@ -245,7 +194,6 @@ export class CgiExecutor {
throw new Error('CGI script not found');
}
// Script must be in either htdocs or cgi-bin
try {
sanitizeAndValidatePath(this.config.documentRoot, realPath);
return;
@@ -288,30 +236,24 @@ export class CgiExecutor {
return safeParams.join('&');
}
/**
* Build CGI environment variables according to RFC 3875
*/
/** Build CGI environment variables according to RFC 3875 */
private buildCgiEnvironment(scriptPath: string, request: CgiRequest): Record<string, string> {
const url = request.url;
const headers = request.headers;
// Sanitize query string to prevent argument injection (CVE-2012-1823)
const rawQueryString = url.search.startsWith('?') ? url.search.substring(1) : url.search;
const sanitizedQueryString = this.sanitizeQueryString(rawQueryString);
// Build base CGI environment
const env: Record<string, string> = {
// Required by PHP-CGI security (prevents direct execution attacks)
REDIRECT_STATUS: 'CGI',
// CGI/1.1 specification variables (RFC 3875)
GATEWAY_INTERFACE: 'CGI/1.1',
SERVER_SOFTWARE: 'flashpoint-game-service/1.0',
SERVER_PROTOCOL: 'HTTP/1.1',
SERVER_NAME: url.hostname || 'localhost',
SERVER_PORT: url.port || '80',
// Request information
REQUEST_METHOD: request.method.toUpperCase(),
REQUEST_URI: url.pathname + url.search,
SCRIPT_NAME: url.pathname,
@@ -320,15 +262,12 @@ export class CgiExecutor {
PATH_TRANSLATED: scriptPath,
QUERY_STRING: sanitizedQueryString,
// Document root
DOCUMENT_ROOT: this.config.documentRoot,
// Client information (localhost since this is proxied)
REMOTE_ADDR: '127.0.0.1',
REMOTE_HOST: 'localhost',
};
// Add content headers if body is present
if (request.body && request.body.length > 0) {
env.CONTENT_LENGTH = request.body.length.toString();
// Sanitize content-type: strip null bytes, CR, LF (injection vectors)
@@ -336,21 +275,17 @@ export class CgiExecutor {
env.CONTENT_TYPE = rawContentType.replace(/[\x00\x0a\x0d]/g, '');
}
// Convert HTTP headers to CGI HTTP_* variables
for (const [key, value] of Object.entries(headers)) {
const lowerKey = key.toLowerCase();
// Skip content headers (handled separately)
if (lowerKey === 'content-type' || lowerKey === 'content-length') {
continue;
}
// Only pass allowed headers
if (!ALLOWED_CGI_HEADERS.has(lowerKey)) {
continue;
}
// Convert header name to CGI format: X-Custom-Header → HTTP_X_CUSTOM_HEADER
const cgiKey = 'HTTP_' + key.toUpperCase().replace(/-/g, '_');
env[cgiKey] = value;
}
@@ -358,9 +293,6 @@ export class CgiExecutor {
return env;
}
/**
* Pass only allowed environment variables to CGI scripts
*/
private sanitizeProcessEnv(): Record<string, string> {
const env: Record<string, string> = {};
@@ -386,14 +318,11 @@ export class CgiExecutor {
* Note: Some PHP configurations may use \n instead of \r\n.
*/
private parseCgiOutput(output: Buffer): CgiResponse {
// Find the header/body separator (empty line)
// Try CRLF first (standard), then LF-only (some PHP configs)
const outputStr = output.toString('binary');
let separatorIndex = outputStr.indexOf('\r\n\r\n');
let separatorLength = 4;
let lineEnding = '\r\n';
// Fallback to LF-only if CRLF not found
if (separatorIndex === -1) {
separatorIndex = outputStr.indexOf('\n\n');
separatorLength = 2;
@@ -410,12 +339,10 @@ export class CgiExecutor {
};
}
// Split headers and body
const headerSection = outputStr.substring(0, separatorIndex);
const bodyStart = separatorIndex + separatorLength;
const body = output.slice(bodyStart);
// Parse headers
const headers: Record<string, string> = {};
let statusCode = 200;
@@ -428,7 +355,6 @@ export class CgiExecutor {
const value = line.substring(colonIndex + 1).trim();
if (name.toLowerCase() === 'status') {
// Parse status code from "Status: 200 OK" or "Status: 302 Found"
const statusMatch = value.match(/^(\d{3})/);
if (statusMatch) {
statusCode = parseInt(statusMatch[1], 10);
@@ -438,7 +364,6 @@ export class CgiExecutor {
}
}
// Ensure Content-Type is set
if (!headers['Content-Type'] && !headers['content-type']) {
headers['Content-Type'] = 'text/html';
}
+5 -34
View File
@@ -3,47 +3,28 @@ import fs from 'fs/promises';
import { logger } from '../utils/logger';
import { z } from 'zod';
/**
* Maximum file size for buffering in memory (50MB)
* Used consistently across gamezipserver, zip-manager, and legacy-server
*/
/** Maximum file size for buffering in memory (used across gamezipserver, zip-manager, legacy-server) */
export const MAX_BUFFERED_FILE_SIZE = 50 * 1024 * 1024;
export interface ServerSettings {
// Proxy server port (default: 22500)
proxyPort: number;
// GameZip server port (default: 22501)
gameZipPort: number;
// Legacy paths
legacyHTDOCSPath: string;
legacyCGIBINPath: string;
// Override paths for file resolution
legacyOverridePaths: string[];
// External fallback URLs
infinityServerURL?: string;
externalFilePaths: string[];
// MAD4FP mode settings
mad4fpEnabled: boolean;
mad4fpPaths: string[];
// Feature flags
allowCrossDomain: boolean;
enableBrotli: boolean;
enableCGI: boolean;
// CGI settings
phpCgiPath: string;
cgiTimeout: number;
cgiMaxBodySize: number;
cgiMaxResponseSize: number;
}
// Zod schema for proxySettings.json validation
const proxySettingsSchema = z
.object({
legacyOverridePaths: z.array(z.string()).default([]),
@@ -63,9 +44,6 @@ const proxySettingsSchema = z
export class ConfigManager {
private static settings: ServerSettings | null = null;
/**
* Load configuration from proxySettings.json and environment
*/
static async loadConfig(flashpointPath: string): Promise<ServerSettings> {
if (ConfigManager.settings) {
logger.warn('[ConfigManager] Re-initializing configuration - this may indicate a bug');
@@ -76,7 +54,6 @@ export class ConfigManager {
const data = await fs.readFile(proxySettingsPath, 'utf-8');
const parsed = JSON.parse(data);
// Validate with Zod schema
let proxySettings;
try {
proxySettings = proxySettingsSchema.parse(parsed);
@@ -89,8 +66,6 @@ export class ConfigManager {
throw error;
}
// Build settings from proxySettings.json and defaults
// Ports hardcoded - use docker-compose port mapping to expose on different ports
this.settings = {
proxyPort: 22500,
gameZipPort: 22501,
@@ -100,7 +75,7 @@ export class ConfigManager {
legacyOverridePaths: proxySettings.legacyOverridePaths || [],
// Use HTTPS for external sources (servers redirect HTTP to HTTPS anyway)
// Servers redirect HTTP to HTTPS anyway, so use HTTPS directly
infinityServerURL: proxySettings.infinityServerURL
? proxySettings.infinityServerURL.replace(/^http:\/\//, 'https://')
: 'https://infinity.flashpointarchive.org/Flashpoint/Legacy/htdocs/',
@@ -116,7 +91,6 @@ export class ConfigManager {
enableBrotli: true,
enableCGI: process.env.ENABLE_CGI === 'true',
// CGI settings
phpCgiPath:
proxySettings.phpCgiPath ||
path.join(
@@ -124,9 +98,9 @@ export class ConfigManager {
'Legacy',
process.platform === 'win32' ? 'php-cgi.exe' : 'php-cgi'
),
cgiTimeout: proxySettings.cgiTimeout ?? 30000, // 30 seconds
cgiMaxBodySize: proxySettings.cgiMaxBodySize ?? 10 * 1024 * 1024, // 10MB
cgiMaxResponseSize: proxySettings.cgiMaxResponseSize ?? 50 * 1024 * 1024, // 50MB
cgiTimeout: proxySettings.cgiTimeout ?? 30000,
cgiMaxBodySize: proxySettings.cgiMaxBodySize ?? 10 * 1024 * 1024,
cgiMaxResponseSize: proxySettings.cgiMaxResponseSize ?? 50 * 1024 * 1024,
};
logger.info('[ConfigManager] Configuration loaded successfully');
@@ -138,7 +112,6 @@ export class ConfigManager {
return this.settings;
} catch (error: unknown) {
// Differentiate between file not found and JSON parse errors
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
logger.warn('[ConfigManager] proxySettings.json not found, using defaults');
} else {
@@ -146,7 +119,6 @@ export class ConfigManager {
logger.error('[ConfigManager] Failed to parse proxySettings.json:', errorMsg);
}
// Fallback to defaults
this.settings = {
proxyPort: 22500,
gameZipPort: 22501,
@@ -164,7 +136,6 @@ export class ConfigManager {
enableBrotli: true,
enableCGI: false,
// CGI settings (defaults when proxySettings.json not available)
phpCgiPath: path.join(
flashpointPath,
'Legacy',
+8 -97
View File
@@ -11,7 +11,7 @@ import { sanitizeUrlPath, sanitizeErrorMessage } from './utils/pathSecurity';
import { validateGameId, validateHostname } from './validation/schemas';
import { gameDataDownloader } from './services';
/** Escape HTML special characters to prevent XSS in loading page */
/** Escape HTML special characters to prevent XSS */
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
@@ -22,10 +22,6 @@ function escapeHtml(str: string): string {
.replace(/`/g, '&#96;');
}
/**
* Download progress tracking for in-progress downloads
* Used to show loading page while game is being downloaded
*/
interface DownloadProgress {
startTime: number;
progress: number;
@@ -34,33 +30,21 @@ interface DownloadProgress {
zipPath: string;
}
/** Map of gameId -> download progress for in-progress downloads */
const downloadsInProgress = new Map<string, DownloadProgress>();
/** Maximum concurrent downloads */
const MAX_CONCURRENT_DOWNLOADS = 3;
/** Cleanup stale downloads older than 10 minutes */
const DOWNLOAD_STALE_MS = 10 * 60 * 1000;
/**
* GameZip Server - Direct-call interface for mounting/serving files from ZIP archives
* Refactored from game-service to expose methods instead of running an HTTP server
*/
/** Direct-call interface for mounting/serving files from ZIP archives */
export class GameZipServer {
private cleanupInterval: NodeJS.Timeout | null = null;
constructor() {
// Start periodic cleanup of stale downloads (every 60 seconds)
this.cleanupInterval = setInterval(() => {
this.cleanupStaleDownloads();
}, 60000);
this.cleanupInterval.unref(); // Don't prevent graceful shutdown
this.cleanupInterval.unref();
}
/**
* Dispose of the server and stop cleanup intervals
*/
dispose(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
@@ -68,10 +52,6 @@ export class GameZipServer {
}
}
/**
* Mount a ZIP file directly (no HTTP)
* Returns { success, downloading?, statusCode }
*/
async mountZip(params: {
id: string;
zipPath: string;
@@ -81,7 +61,6 @@ export class GameZipServer {
}): Promise<{ success: boolean; downloading?: boolean; statusCode: number }> {
const { id, zipPath, gameId, dateAdded, sha256 } = params;
// Validate mount ID using Zod schema
try {
validateGameId(id);
} catch (error) {
@@ -93,7 +72,6 @@ export class GameZipServer {
return { success: false, statusCode: 400 };
}
// Validate ZIP path to prevent directory traversal
const flashpointPath =
process.env.FLASHPOINT_PATH ||
(process.env.NODE_ENV === 'production' ? '/data/flashpoint' : 'D:/Flashpoint');
@@ -117,7 +95,6 @@ export class GameZipServer {
return { success: false, statusCode: 400 };
}
// Check if ZIP file exists locally
let zipExists = false;
try {
await fs.access(zipPath);
@@ -126,14 +103,12 @@ export class GameZipServer {
zipExists = false;
}
// If ZIP doesn't exist and we have download parameters, start background download
if (!zipExists && gameId && dateAdded) {
if (downloadsInProgress.has(gameId)) {
logger.info(`[GameZipServer] Download already in progress for game ${gameId}`);
return { success: true, downloading: true, statusCode: 202 };
}
// Check if we've hit the concurrent download limit
if (downloadsInProgress.size >= MAX_CONCURRENT_DOWNLOADS) {
logger.warn(
`[GameZipServer] Too many concurrent downloads (${downloadsInProgress.size}), rejecting ${gameId}`
@@ -167,11 +142,10 @@ export class GameZipServer {
return { success: false, statusCode: 404 };
}
// Mount the ZIP (file exists locally)
try {
await zipManager.mount(id, zipPath);
// After the mount succeeds, verify the real path hasn't changed (TOCTOU protection)
// TOCTOU protection: verify real path hasn't changed via symlink
try {
const realPath = await fs.realpath(zipPath);
const resolvedReal = path.resolve(realPath);
@@ -180,7 +154,6 @@ export class GameZipServer {
!resolvedReal.startsWith(resolvedGamesPath + path.sep) &&
resolvedReal !== resolvedGamesPath
) {
// Symlink pointed outside allowed directory - unmount immediately
logger.warn(`[GameZipServer] Symlink detected: ${zipPath} -> ${realPath}. Unmounting.`);
await zipManager.unmount(id);
return { success: false, statusCode: 403 };
@@ -201,9 +174,6 @@ export class GameZipServer {
}
}
/**
* Unmount a ZIP file directly (no HTTP)
*/
async unmountZip(id: string): Promise<boolean> {
try {
validateGameId(id);
@@ -214,17 +184,11 @@ export class GameZipServer {
return await zipManager.unmount(id);
}
/**
* List all mounted ZIPs
*/
listMounts(): Array<{ id: string; zipPath: string; mountTime: Date; fileCount: number }> {
return zipManager.getMountedZips();
}
/**
* Handle file request from mounted ZIPs (compatible with both raw HTTP and Express)
* Supports proxy-style requests: GET http://domain.com/path HTTP/1.1
*/
/** Handle file request from mounted ZIPs (supports proxy-style and regular requests) */
async handleFileRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
if (!req.url) {
this.sendError(res, 400, 'Bad Request: No URL');
@@ -234,31 +198,24 @@ export class GameZipServer {
let hostname: string;
let urlPath: string;
// Normalize malformed URLs: /http:/domain → /http://domain
// This is a common pattern in legacy Flash games
// Legacy Flash games sometimes emit /http:/domain instead of /http://domain
let normalizedUrl = req.url;
if (normalizedUrl.match(/^\/https?:\/[^\/]/)) {
// Single slash after protocol - insert the missing slash
normalizedUrl = normalizedUrl.replace(/^(\/https?:)\//, '$1//');
logger.debug(`[GameZipServer] Normalized malformed URL: ${req.url} → ${normalizedUrl}`);
}
// Parse URL (same logic as main proxy server)
try {
if (normalizedUrl.startsWith('http://') || normalizedUrl.startsWith('https://')) {
// Proxy-style: GET http://domain.com/path HTTP/1.1
const targetUrl = new URL(normalizedUrl);
hostname = targetUrl.hostname;
urlPath = targetUrl.pathname + targetUrl.search;
} else if (normalizedUrl.startsWith('/http://') || normalizedUrl.startsWith('/https://')) {
// Path-based: GET /http://domain.com/path HTTP/1.1
const urlWithoutSlash = normalizedUrl.substring(1);
const targetUrl = new URL(urlWithoutSlash);
hostname = targetUrl.hostname;
urlPath = targetUrl.pathname + targetUrl.search;
} else {
// Regular: GET /path HTTP/1.1
// Strip port from hostname (e.g., "localhost:3100" -> "localhost")
hostname = (req.headers.host || 'localhost').split(':')[0];
urlPath = normalizedUrl;
}
@@ -268,7 +225,6 @@ export class GameZipServer {
return;
}
// Validate hostname using Zod schema
try {
hostname = validateHostname(hostname);
} catch (error) {
@@ -277,7 +233,6 @@ export class GameZipServer {
return;
}
// Sanitize URL path to prevent null bytes and dangerous patterns
try {
urlPath = sanitizeUrlPath(urlPath);
} catch (error) {
@@ -286,33 +241,24 @@ export class GameZipServer {
return;
}
// Strip query string from path for ZIP file lookup
// Files in ZIPs don't have query strings (e.g., file.swf?token=xyz → file.swf)
const pathWithoutQuery = urlPath.split('?')[0];
// Build relative path
const relPath = path.posix.join(hostname, pathWithoutQuery);
logger.debug(`[GameZipServer] Looking for: ${relPath}`);
// Check if client already disconnected before doing the heavy ZIP lookup
if (res.destroyed) {
logger.debug(`[GameZipServer] Client disconnected before file lookup: ${relPath}`);
return;
}
// Search mounted ZIPs
const result = await zipManager.findFile(relPath);
// Check again after async operation (client may have disconnected during lookup)
if (res.destroyed) {
logger.debug(`[GameZipServer] Client disconnected during file lookup: ${relPath}`);
return;
}
if (!result) {
// Check if a download is in progress for any game
// The mount ID is typically the gameId
const downloadStatus = this.findActiveDownload(relPath);
if (downloadStatus) {
@@ -328,31 +274,25 @@ export class GameZipServer {
return;
}
// Determine content type (use path without query string)
const ext = path.extname(pathWithoutQuery).substring(1).toLowerCase();
const contentType = getMimeType(ext);
// Process HTML files to inject polyfills for better game compatibility
let fileData = result.data;
if (ext === 'html' || ext === 'htm') {
fileData = injectPolyfills(result.data);
logger.info(`[GameZipServer] Injected polyfills into HTML file: ${relPath}`);
}
// Send file
logger.info(
`[GameZipServer] ✓ Serving from ZIP ${result.mountId}: ${relPath} (${fileData.length} bytes)`
);
// Get settings for CORS configuration
const settings = ConfigManager.getSettings();
// CORS headers
setCorsHeaders(res, settings);
res.setHeader('Content-Type', contentType);
res.setHeader('Content-Length', fileData.length);
res.setHeader('Cache-Control', 'public, max-age=86400'); // Cache for 24 hours
res.setHeader('Cache-Control', 'public, max-age=86400');
if (process.env.NODE_ENV !== 'production') {
res.setHeader('X-Source', `gamezipserver:${result.mountId}`);
}
@@ -362,10 +302,6 @@ export class GameZipServer {
res.end(fileData);
}
/**
* Download ZIP and mount it in background
* This runs asynchronously - errors are logged but don't affect the HTTP response
*/
private async downloadAndMountInBackground(
mountId: string,
gameId: string,
@@ -384,7 +320,6 @@ export class GameZipServer {
},
(downloaded, total, source) => {
try {
// Update progress tracking
const existing = downloadsInProgress.get(gameId);
if (existing) {
downloadsInProgress.set(gameId, {
@@ -407,7 +342,6 @@ export class GameZipServer {
if (result.success && result.filePath) {
logger.info(`[GameZipServer] Successfully downloaded ZIP from "${result.sourceUsed}"`);
// Validate downloaded file path before mounting
const resolvedFilePath = path.resolve(result.filePath);
const resolvedAllowedPath = path.resolve(targetPath);
if (
@@ -420,7 +354,6 @@ export class GameZipServer {
return;
}
// Mount the downloaded ZIP
try {
await zipManager.mount(mountId, result.filePath);
logger.info(`[GameZipServer] ✓ ZIP downloaded and mounted for game ${gameId}`);
@@ -435,14 +368,10 @@ export class GameZipServer {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
logger.error(`[GameZipServer] Background download error for ${gameId}: ${errorMsg}`);
} finally {
// Remove from in-progress tracking
downloadsInProgress.delete(gameId);
}
}
/**
* Clean up stale downloads older than DOWNLOAD_STALE_MS
*/
private cleanupStaleDownloads(): void {
const now = Date.now();
for (const [gameId, progress] of downloadsInProgress.entries()) {
@@ -453,27 +382,18 @@ export class GameZipServer {
}
}
/**
* Find an active download that might be related to the requested file path
* Returns the download status if found, null otherwise
*/
private findActiveDownload(
relPath: string
): { gameId: string; progress: DownloadProgress } | null {
// Clean up stale downloads first
this.cleanupStaleDownloads();
// Extract hostname from relPath (format: hostname/path/to/file)
const hostname = relPath.split('/')[0]?.toLowerCase();
// Try to find a download matching this hostname
if (hostname) {
for (const [gameId, download] of downloadsInProgress.entries()) {
// Check if mount ID or ZIP path contains the hostname
if (gameId.toLowerCase().includes(hostname)) {
return { gameId, progress: download };
}
// Also check the ZIP path for hostname match
const zipFilename = download.zipPath.toLowerCase();
if (zipFilename.includes(hostname)) {
return { gameId, progress: download };
@@ -481,14 +401,9 @@ export class GameZipServer {
}
}
// No matching download found - return null for a proper 404
return null;
}
/**
* Send a loading page while game is being downloaded
* Uses auto-refresh to poll until game is ready
*/
private sendLoadingPage(
res: http.ServerResponse,
gameId: string,
@@ -600,13 +515,10 @@ export class GameZipServer {
'Content-Security-Policy',
"default-src 'none'; style-src 'unsafe-inline'; img-src 'none'; script-src 'none'"
);
res.writeHead(202); // 202 Accepted - still processing
res.writeHead(202);
res.end(html);
}
/**
* Send error response
*/
private sendError(res: http.ServerResponse, statusCode: number, message: string): void {
if (res.headersSent) {
res.end();
@@ -615,7 +527,6 @@ export class GameZipServer {
logger.warn(`[GameZipServer] Sending error ${statusCode}: ${message}`);
// Set CORS headers even for error responses
const settings = ConfigManager.getSettings();
setCorsHeaders(res, settings);
+14 -146
View File
@@ -17,10 +17,9 @@ import { promisify } from 'util';
import http from 'http';
import https from 'https';
// Shared HTTP agents with connection pool limits to prevent exhaustion
const httpAgent = new http.Agent({
keepAlive: true,
maxSockets: 10, // Max 10 concurrent connections per host
maxSockets: 10,
maxFreeSockets: 2,
timeout: 30000,
});
@@ -37,22 +36,17 @@ const axiosInstance = axios.create({
httpsAgent,
});
// HTTP request timeout constants (G-H2)
const EXTERNAL_REQUEST_TIMEOUT_MS = 15000; // 15 seconds for external sources
const EXTERNAL_REQUEST_TIMEOUT_MS = 15000;
const EXTERNAL_REQUEST_MAX_REDIRECTS = 5;
// Retry configuration for external requests
const MAX_RETRIES = 2;
const RETRY_BASE_DELAY_MS = 1000; // 1 second base delay
const RETRY_BASE_DELAY_MS = 1000;
const brotliDecompress = promisify(zlib.brotliDecompress);
// Script file extensions
const SCRIPT_EXTENSIONS = ['php', 'php5', 'phtml', 'pl'];
// Index files that legacy Flashpoint games commonly use as entry points.
// These are checked when a directory is requested without a specific file.
// Note: 'swf' is included because many Flash game directories have index.swf as the entry point.
// 'swf' is included because many Flash game directories have index.swf as the entry point
const INDEX_EXTENSIONS = ['html', 'htm', 'php', 'swf'];
// Common subdomain prefixes used in Flashpoint content
@@ -77,15 +71,10 @@ export interface LegacyFileResponse {
size?: number;
contentType: string;
source: string;
/** HTTP status code (default: 200) */
statusCode?: number;
/** Additional response headers from CGI */
headers?: Record<string, string>;
}
/**
* HTTP request context for CGI execution
*/
export interface HttpRequestContext {
method: string;
headers: Record<string, string>;
@@ -96,22 +85,15 @@ export class LegacyServer {
private cgiExecutor: CgiExecutor | null = null;
private cgiValidated = false;
// Negative lookup cache to avoid repeated fs.stat() calls for missing files
private static readonly negativeLookupCache = new Map<string, number>();
private static readonly NEGATIVE_CACHE_TTL_MS = 30000; // 30 seconds
private static readonly NEGATIVE_CACHE_TTL_MS = 30000;
private static readonly MAX_NEGATIVE_CACHE_SIZE = 5000;
/**
* Clear the negative lookup cache (called during shutdown)
*/
static clearCache(): void {
LegacyServer.negativeLookupCache.clear();
logger.info('[LegacyServer] Negative lookup cache cleared');
}
/**
* Check if a path is in the negative lookup cache
*/
private isNegativelyCached(fullPath: string): boolean {
const cachedAt = LegacyServer.negativeLookupCache.get(fullPath);
if (cachedAt === undefined) return false;
@@ -122,11 +104,7 @@ export class LegacyServer {
return true;
}
/**
* Cache a negative lookup (file not found) with TTL and size limit
*/
private cacheNegativeLookup(fullPath: string): void {
// Evict oldest entries if cache is full
if (LegacyServer.negativeLookupCache.size >= LegacyServer.MAX_NEGATIVE_CACHE_SIZE) {
const firstKey = LegacyServer.negativeLookupCache.keys().next().value;
if (firstKey) LegacyServer.negativeLookupCache.delete(firstKey);
@@ -134,9 +112,6 @@ export class LegacyServer {
LegacyServer.negativeLookupCache.set(fullPath, Date.now());
}
/**
* Initialize CGI executor if enabled
*/
private async initCgiExecutor(): Promise<CgiExecutor | null> {
const settings = ConfigManager.getSettings();
@@ -157,7 +132,6 @@ export class LegacyServer {
maxResponseSize: settings.cgiMaxResponseSize,
});
// Validate binary exists (only once)
if (!this.cgiValidated) {
const valid = await this.cgiExecutor.validateBinary();
if (!valid) {
@@ -170,21 +144,14 @@ export class LegacyServer {
return this.cgiExecutor;
}
/**
* Main file serving method - replicates ServeLegacy() from Go implementation
* @param hostname The hostname from the request (e.g., "www.example.com")
* @param urlPath The path from the request (e.g., "/path/file.swf")
* @param requestContext Optional HTTP request context for CGI execution
*/
/** Replicates ServeLegacy() from Go implementation */
async serveLegacy(
hostname: string,
urlPath: string,
requestContext?: HttpRequestContext
): Promise<LegacyFileResponse> {
// Get fresh settings at the start of the request
const settings = ConfigManager.getSettings();
// Sanitize URL path to prevent null bytes and dangerous patterns
try {
urlPath = sanitizeUrlPath(urlPath);
} catch (error) {
@@ -192,30 +159,23 @@ export class LegacyServer {
throw new Error('Invalid URL path');
}
// Strip port from hostname
const host = hostname.split(':')[0];
// Build relative path: {host}/{path}
const relPath = path.posix.join(host, urlPath);
logger.debug(`[LegacyServer] Serving: ${host}${urlPath}`);
logger.debug(`[LegacyServer] Relative path: ${relPath}`);
// Step 1: Build all path candidates
const pathCandidates = this.buildPathCandidates(relPath, urlPath, settings);
// Step 2: Try each candidate in order
for (const candidate of pathCandidates) {
logger.debug(`[LegacyServer] Trying: ${candidate.path} (${candidate.type})`);
// Check negative cache to skip known-missing files
if (this.isNegativelyCached(candidate.path)) {
logger.debug(`[LegacyServer] Skipping cached negative lookup: ${candidate.path}`);
continue;
}
try {
// Validate path is within allowed base directory before accessing
this.validateCandidatePath(candidate.path, candidate.type, settings);
const stats = await fs.stat(candidate.path);
@@ -238,15 +198,11 @@ export class LegacyServer {
}
}
// Step 3: No local file found, try external sources
logger.info(`[LegacyServer] File not found locally, trying external sources...`);
return await this.tryExternalSources(relPath, settings);
}
/**
* Build all path candidates in priority order
* Replicates the path resolution algorithm from Go's ServeLegacy()
*/
/** Build all path candidates in priority order (replicates Go's ServeLegacy() algorithm) */
private buildPathCandidates(
relPath: string,
urlPath: string,
@@ -255,7 +211,6 @@ export class LegacyServer {
const candidates: Array<{ path: string; type: string }> = [];
const seenPaths = new Set<string>();
// Helper to add candidate only if not seen before
const addCandidate = (candidatePath: string, type: string) => {
const normalizedPath = path.normalize(candidatePath).toLowerCase();
if (!seenPaths.has(normalizedPath)) {
@@ -264,26 +219,20 @@ export class LegacyServer {
}
};
// Extract hostname and path from relPath
const parts = relPath.split('/');
const hostname = parts[0];
const pathAfterHost = parts.slice(1).join('/');
// Generate subdomain variations
// For mochibot.com, try: mochibot.com, www.mochibot.com, core.mochibot.com, etc.
const hostnameVariations = this.getHostnameVariations(hostname);
// For each hostname variation, try exact paths
for (const hostVariation of hostnameVariations) {
const variantRelPath = path.posix.join(hostVariation, pathAfterHost);
// 1. Exact file paths (with and without query string)
addCandidate(
path.join(settings.legacyHTDOCSPath, variantRelPath),
hostVariation === hostname ? 'exact' : `subdomain:${hostVariation}`
);
// If URL has query string, try without it
const pathWithoutQuery = variantRelPath.split('?')[0];
if (pathWithoutQuery !== variantRelPath) {
addCandidate(
@@ -293,10 +242,8 @@ export class LegacyServer {
}
}
// Use the original relPath for the rest of the logic
const pathWithoutQuery = relPath.split('?')[0];
// 2. Override paths (exact files in override directories)
for (const override of settings.legacyOverridePaths) {
addCandidate(path.join(settings.legacyHTDOCSPath, override, relPath), `override:${override}`);
@@ -308,7 +255,6 @@ export class LegacyServer {
}
}
// 3. CGI-BIN paths (for script files)
if (this.isScriptUrl(urlPath)) {
addCandidate(path.join(settings.legacyCGIBINPath, relPath), 'cgi-bin');
@@ -317,11 +263,9 @@ export class LegacyServer {
}
}
// 4. Index files (for directory requests)
for (const ext of INDEX_EXTENSIONS) {
addCandidate(path.join(settings.legacyHTDOCSPath, relPath, `index.${ext}`), `index:${ext}`);
// Index files in override paths
for (const override of settings.legacyOverridePaths) {
addCandidate(
path.join(settings.legacyHTDOCSPath, override, relPath, `index.${ext}`),
@@ -333,30 +277,19 @@ export class LegacyServer {
return candidates;
}
/**
* Validate that a candidate path is within its allowed base directory
* @param candidatePath The full path to validate
* @param candidateType The type of candidate (e.g., 'exact', 'cgi-bin', 'override:*')
* @param settings Current configuration settings
* @throws Error if the path escapes its allowed base directory
*/
private validateCandidatePath(
candidatePath: string,
candidateType: string,
settings: ReturnType<typeof ConfigManager.getSettings>
): void {
// Determine the allowed base directory based on candidate type
let allowedBase: string;
if (candidateType === 'cgi-bin' || candidateType === 'cgi-bin-no-query') {
allowedBase = settings.legacyCGIBINPath;
} else {
// All other types use htdocs path
allowedBase = settings.legacyHTDOCSPath;
}
// Validate that the candidate path is within the allowed base
// This will throw an error if directory traversal is detected
try {
sanitizeAndValidatePath(allowedBase, candidatePath);
} catch (error) {
@@ -365,46 +298,31 @@ export class LegacyServer {
}
}
/**
* Check if URL is a script file
*/
private isScriptUrl(urlPath: string): boolean {
const ext = path.extname(urlPath).toLowerCase().substring(1);
return SCRIPT_EXTENSIONS.includes(ext);
}
/**
* Serve a file from disk
* @param filePath Absolute path to file
* @param isCGI Whether to execute as CGI script
* @param requestContext HTTP request context for CGI execution
* @param settings Current configuration settings
*/
private async serveFile(
filePath: string,
isCGI: boolean = false,
requestContext?: HttpRequestContext,
settings?: ReturnType<typeof ConfigManager.getSettings>
): Promise<LegacyFileResponse> {
// Get settings if not provided
if (!settings) {
settings = ConfigManager.getSettings();
}
// Determine content type first
const ext = path.extname(filePath).substring(1).toLowerCase();
const contentType = getMimeType(ext);
// HTML files need polyfill injection, so buffer them
// Brotli-compressed files also need buffering for decompression
// HTML needs polyfill injection, Brotli needs decompression — both require buffering
let needsBuffering =
contentType.includes('text/html') ||
(settings.enableBrotli && filePath.endsWith('.br')) ||
isCGI;
// Get file stats for size check
const stat = await fs.stat(filePath);
// If file is too large for buffering, skip polyfill injection and stream instead
if (needsBuffering && stat.size > MAX_BUFFERED_FILE_SIZE) {
logger.warn(
`[LegacyServer] File too large for buffering (${stat.size} bytes), streaming without polyfill injection: ${filePath}`
@@ -413,17 +331,14 @@ export class LegacyServer {
}
if (needsBuffering) {
// Read entire file into memory
let data = await fs.readFile(filePath);
// Handle Brotli compression
if (settings.enableBrotli && filePath.endsWith('.br')) {
logger.debug(`[LegacyServer] Decompressing Brotli file: ${filePath}`);
data = await brotliDecompress(data);
filePath = filePath.substring(0, filePath.length - 3); // Remove .br extension
filePath = filePath.substring(0, filePath.length - 3);
}
// Execute CGI scripts if enabled
if (isCGI && settings.enableCGI) {
return await this.executeCgiScript(filePath, requestContext, settings);
}
@@ -435,7 +350,6 @@ export class LegacyServer {
};
}
// Non-HTML, non-compressed files: stream for memory efficiency
const stream = createReadStream(filePath);
logger.debug(
@@ -450,18 +364,11 @@ export class LegacyServer {
};
}
/**
* Execute a CGI script and return the response
* @param scriptPath Absolute path to the script file
* @param requestContext HTTP request context (method, headers, body)
* @param settings Current configuration settings
*/
private async executeCgiScript(
scriptPath: string,
requestContext?: HttpRequestContext,
settings?: ReturnType<typeof ConfigManager.getSettings>
): Promise<LegacyFileResponse> {
// Get settings if not provided
if (!settings) {
settings = ConfigManager.getSettings();
}
@@ -477,21 +384,17 @@ export class LegacyServer {
};
}
// Build URL from the script path for CGI environment
// Extract the relative path from the script path
let relativePath = scriptPath;
if (scriptPath.startsWith(settings.legacyHTDOCSPath)) {
relativePath = scriptPath.substring(settings.legacyHTDOCSPath.length);
} else if (scriptPath.startsWith(settings.legacyCGIBINPath)) {
relativePath = scriptPath.substring(settings.legacyCGIBINPath.length);
}
// Normalize path separators for URL
relativePath = relativePath.replace(/\\/g, '/');
if (!relativePath.startsWith('/')) {
relativePath = '/' + relativePath;
}
// Build CgiRequest from HTTP context
const cgiRequest: CgiRequest = {
method: requestContext?.method || 'GET',
url: new URL(`http://localhost${relativePath}`),
@@ -515,7 +418,6 @@ export class LegacyServer {
`[LegacyServer] CGI execution failed: ${error instanceof Error ? error.message : 'Unknown error'}`
);
// Return error response
const errorMessage = 'CGI script execution failed';
return {
data: Buffer.from(errorMessage),
@@ -526,14 +428,10 @@ export class LegacyServer {
}
}
/**
* Try external sources (Infinity Server, MAD4FP, etc.)
*/
private async tryExternalSources(
relPath: string,
settings: ReturnType<typeof ConfigManager.getSettings>
): Promise<LegacyFileResponse> {
// Try Infinity Server first
if (settings.infinityServerURL) {
logger.info(`[LegacyServer] Trying Infinity Server...`);
const result = await this.tryExternalSource(settings.infinityServerURL, relPath);
@@ -542,7 +440,6 @@ export class LegacyServer {
}
}
// Try other external paths
for (const externalPath of settings.externalFilePaths) {
logger.info(`[LegacyServer] Trying: ${externalPath}`);
const result = await this.tryExternalSource(externalPath, relPath);
@@ -551,7 +448,6 @@ export class LegacyServer {
}
}
// Try MAD4FP if enabled
if (settings.mad4fpEnabled) {
for (const mad4fpPath of settings.mad4fpPaths) {
logger.info(`[LegacyServer] Trying MAD4FP: ${mad4fpPath}`);
@@ -565,24 +461,18 @@ export class LegacyServer {
throw new Error('File not found in any source');
}
/**
* Check if an error is retryable (network/server errors, not client errors)
*/
private isRetryableError(error: unknown): boolean {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
// Don't retry client errors (4xx)
if (status && status >= 400 && status < 500) {
return false;
}
// Retry server errors (5xx)
if (status && status >= 500) {
return true;
}
// Retry on network errors
const retryableCodes = [
'ECONNRESET',
'ETIMEDOUT',
@@ -598,10 +488,7 @@ export class LegacyServer {
return false;
}
/**
* Download from external source with exponential backoff retry
* Implements timeout enforcement (G-H2) and safe error handling (G-H4)
*/
/** Download from external source with exponential backoff retry */
private async tryExternalSource(
baseUrl: string,
relPath: string
@@ -610,7 +497,6 @@ export class LegacyServer {
let fullUrl: string;
try {
const resolved = new URL(relPath, normalizedBase);
// Verify resolved URL still starts with base
if (!resolved.href.startsWith(normalizedBase)) {
logger.warn(`[LegacyServer] Path traversal blocked in external URL: ${relPath}`);
return null;
@@ -622,9 +508,7 @@ export class LegacyServer {
logger.info(`[LegacyServer] Downloading: ${fullUrl}`);
// Retry loop with exponential backoff
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
// Create abort controller for request timeout (G-H2)
const abortController = new AbortController();
const timeoutId = setTimeout(() => {
abortController.abort();
@@ -640,7 +524,6 @@ export class LegacyServer {
headers: {
'User-Agent': 'Flashpoint-Proxy/1.0',
},
// Limit response size to prevent memory exhaustion (100MB max)
maxContentLength: 100 * 1024 * 1024,
maxBodyLength: 100 * 1024 * 1024,
});
@@ -648,12 +531,10 @@ export class LegacyServer {
clearTimeout(timeoutId);
const data = Buffer.from(response.data);
// Determine content type from file extension, not from response header (G-H5)
// This prevents MIME type spoofing from malicious external sources
// Derive content type from file extension, not response header, to prevent MIME spoofing
const ext = path.extname(relPath).substring(1).toLowerCase();
const contentType = ext ? getMimeType(ext) : 'application/octet-stream';
// Log if server-provided content type differs (for debugging)
const serverContentType = response.headers['content-type'];
if (serverContentType && !serverContentType.includes(contentType.split('/')[1])) {
logger.debug(
@@ -667,11 +548,9 @@ export class LegacyServer {
} catch (error) {
clearTimeout(timeoutId);
// Determine if we should retry
const isLastAttempt = attempt === MAX_RETRIES;
const shouldRetry = !isLastAttempt && this.isRetryableError(error);
// Log the error appropriately
if (axios.isAxiosError(error)) {
if (error.response?.status === 404) {
logger.debug(`[LegacyServer] ✗ Not found (404)`);
@@ -692,7 +571,6 @@ export class LegacyServer {
);
}
} else {
// Sanitize error message to prevent internal details leakage
const safeMessage = sanitizeErrorMessage(error.message || 'Unknown error');
if (shouldRetry) {
logger.warn(
@@ -713,39 +591,29 @@ export class LegacyServer {
}
}
// Retry if applicable, otherwise return null
if (shouldRetry) {
// Exponential backoff: 1s, 2s, 4s
const delayMs = RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
logger.info(`[LegacyServer] Retrying in ${delayMs}ms...`);
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}
// No retry, return null to try next source
return null;
}
}
// Should never reach here, but TypeScript needs it
return null;
}
/**
* Generate hostname variations with common subdomain prefixes
* For example, "mochibot.com" returns:
* ["mochibot.com", "www.mochibot.com", "core.mochibot.com", ...]
*
* This handles cases where files are stored with subdomain prefixes
* (e.g., core.mochibot.com/my/core.swf) but requested without them
* (e.g., mochibot.com/my/core.swf)
* Generate hostname variations with common subdomain prefixes.
* Handles cases where files are stored under a subdomain prefix
* (e.g., core.mochibot.com/) but requested without one (e.g., mochibot.com/).
*/
private getHostnameVariations(hostname: string): string[] {
const variations: string[] = [hostname];
// Add subdomain variations (skip if hostname already has that prefix)
for (const subdomain of COMMON_SUBDOMAINS) {
// Avoid creating nonsensical prefixes like "www.www.example.com"
if (!hostname.startsWith(`${subdomain}.`)) {
variations.push(`${subdomain}.${hostname}`);
}
+1 -33
View File
@@ -1,10 +1,4 @@
/**
* MIME type mappings from Flashpoint proxySettings.json
* Includes 199 custom MIME types for legacy web content
*/
const CUSTOM_MIME_TYPES: { [key: string]: string } = {
// Legacy multimedia formats
aab: 'application/x-authorware-bin',
aam: 'application/x-authorware-map',
aas: 'application/x-authorware-seg',
@@ -20,7 +14,6 @@ const CUSTOM_MIME_TYPES: { [key: string]: string } = {
aw3: 'application/x-awingsoft-winds3d',
axs: 'application/x-MindAvenueAXELStream',
// 3D and modeling formats
blend: 'application/x-burster',
blendz: 'application/x-burster',
blz: 'video/blz',
@@ -28,7 +21,6 @@ const CUSTOM_MIME_TYPES: { [key: string]: string } = {
bub: 'application/photobubble',
bxwrl: 'application/x-blaxxuncc3d',
// Director and Shockwave
ccn: 'application/x-cnc',
cct: 'application/x-director',
cnc: 'application/x-cnc',
@@ -40,7 +32,6 @@ const CUSTOM_MIME_TYPES: { [key: string]: string } = {
swa: 'application/x-director',
w3d: 'application/x-director',
// Chemical and scientific formats
cdx: 'chemical/x-cdx',
chm: 'chemical/x-chemdraw',
cow: 'chemical/x-cow',
@@ -61,7 +52,6 @@ const CUSTOM_MIME_TYPES: { [key: string]: string } = {
tgf: 'chemical/x-mdl-tgf',
xyz: 'chemical/x-xyz',
// Image formats
cgm: 'image/cgm',
cit: 'image/cit',
dgn: 'image/dgn',
@@ -81,7 +71,6 @@ const CUSTOM_MIME_TYPES: { [key: string]: string } = {
twfz: 'image/x-twf-zlib-compressed',
vec: 'image/vec',
// Audio formats
it: 'audio/it',
itz: 'audio/x-zipped-it',
mdz: 'audio/x-zipped-mod',
@@ -96,10 +85,8 @@ const CUSTOM_MIME_TYPES: { [key: string]: string } = {
xm: 'audio/xm',
xmz: 'audio/x-mod',
// Video formats
mov: 'video/quicktime',
// Application formats
class: 'application/java',
cmo: 'application/x-virtools',
co: 'application/x-cult3d-object',
@@ -113,12 +100,10 @@ const CUSTOM_MIME_TYPES: { [key: string]: string } = {
eva: 'application/x-eva',
evy: 'application/envoy',
// Compressed formats
gz: 'application/x-gzip-compressed',
wrz: 'application/x-gzip-compressed',
x3dz: 'application/x-gzip-compressed',
// Web formats
htm: 'text/html',
html: 'text/html',
js: 'text/javascript',
@@ -126,7 +111,6 @@ const CUSTOM_MIME_TYPES: { [key: string]: string } = {
json: 'application/json',
xml: 'application/xml',
// Plugin formats
ips: 'application/x-ipscript',
ipx: 'application/x-ipix',
jar: 'application/java-archive',
@@ -164,7 +148,6 @@ const CUSTOM_MIME_TYPES: { [key: string]: string } = {
xar: 'application/vnd.xara',
xpg: 'text/x-xpg',
// World/3D formats
d96: 'x-world/x-d96',
dae: 'model/x-bs-collada+xml',
mus: 'x-world/x-d96',
@@ -178,13 +161,10 @@ const CUSTOM_MIME_TYPES: { [key: string]: string } = {
xvr: 'x-world/x-xvr',
};
// Standard MIME types for common web files
const STANDARD_MIME_TYPES: { [key: string]: string } = {
// Text
txt: 'text/plain',
csv: 'text/csv',
// Images
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
@@ -193,31 +173,26 @@ const STANDARD_MIME_TYPES: { [key: string]: string } = {
ico: 'image/x-icon',
webp: 'image/webp',
// Audio
mp3: 'audio/mpeg',
ogg: 'audio/ogg',
oga: 'audio/ogg',
// Video
mp4: 'video/mp4',
webm: 'video/webm',
ogv: 'video/ogg',
avi: 'video/x-msvideo',
// Fonts
woff: 'font/woff',
woff2: 'font/woff2',
ttf: 'font/ttf',
otf: 'font/otf',
eot: 'application/vnd.ms-fontobject',
// Archives
zip: 'application/zip',
rar: 'application/x-rar-compressed',
'7z': 'application/x-7z-compressed',
tar: 'application/x-tar',
// Documents
pdf: 'application/pdf',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
@@ -226,29 +201,22 @@ const STANDARD_MIME_TYPES: { [key: string]: string } = {
ppt: 'application/vnd.ms-powerpoint',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
// Binary
bin: 'application/octet-stream',
exe: 'application/octet-stream',
dll: 'application/octet-stream',
};
/**
* Get MIME type for a file extension
* Checks custom Flashpoint MIME types first, then standard types
*/
/** Custom Flashpoint MIME types take priority over standard types */
export function getMimeType(extension: string): string {
const ext = extension.toLowerCase();
// Check custom MIME types first (priority for legacy content)
if (CUSTOM_MIME_TYPES[ext]) {
return CUSTOM_MIME_TYPES[ext];
}
// Check standard MIME types
if (STANDARD_MIME_TYPES[ext]) {
return STANDARD_MIME_TYPES[ext];
}
// Default to octet-stream for unknown types
return 'application/octet-stream';
}
+9 -75
View File
@@ -10,17 +10,11 @@ import { zipManager } from './zip-manager';
import { getMimeType } from './mimeTypes';
import { setCorsHeaders } from './utils/cors';
// Maximum request body size for CGI (10MB)
const MAX_REQUEST_BODY_SIZE = 10 * 1024 * 1024;
const STREAM_TIMEOUT_MS = 120000;
const CACHE_MAX_AGE_SECONDS = 86400;
// Internal request timeout constants (G-H2)
const STREAM_TIMEOUT_MS = 120000; // 2 minutes for streaming responses
// Cache control constants
const CACHE_MAX_AGE_SECONDS = 86400; // 24 hours (in seconds)
// Allowlist of safe CGI response headers to forward
// Prevents header injection attacks (e.g., Set-Cookie, Location, CORS overrides)
// Only these CGI headers are forwarded -- prevents header injection (Set-Cookie, Location, etc.)
const ALLOWED_CGI_HEADERS = new Set([
'cache-control',
'expires',
@@ -39,17 +33,11 @@ export class ProxyRequestHandler {
this.legacyServer = new LegacyServer();
}
/**
* Handle incoming HTTP proxy request
* Replicates the request processing flow from Go's goproxy
*/
/** Replicates the request processing flow from Go's goproxy */
async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
try {
logger.info(`[ProxyHandler] ${req.method} ${req.url}`);
// Parse the request URL
// For proxy requests, req.url contains the full URL: http://example.com/path
// For regular requests, req.url contains just the path: /path
if (!req.url) {
this.sendError(res, 400, 'Bad Request: No URL');
return;
@@ -59,39 +47,30 @@ export class ProxyRequestHandler {
let hostname: string;
let urlPath: string;
// Normalize malformed URLs: /http:/domain → /http://domain
// This is a common pattern in legacy Flash games
// Legacy Flash games sometimes emit /http:/domain instead of /http://domain
let normalizedUrl = req.url;
if (normalizedUrl.match(/^\/https?:\/[^\/]/)) {
// Single slash after protocol - insert the missing slash
normalizedUrl = normalizedUrl.replace(/^(\/https?:)\//, '$1//');
logger.debug(`[ProxyHandler] Normalized malformed URL`);
}
// Check if this is a proxy-style request (starts with http://)
if (normalizedUrl.startsWith('http://') || normalizedUrl.startsWith('https://')) {
// Proxy-style request: GET http://example.com/path HTTP/1.1
targetUrl = new URL(normalizedUrl);
hostname = targetUrl.hostname;
urlPath = sanitizeUrlPath(targetUrl.pathname + targetUrl.search);
} else if (normalizedUrl.startsWith('/http://') || normalizedUrl.startsWith('/https://')) {
// Path-based proxy request: GET /http://example.com/path HTTP/1.1
// Strip leading slash and parse as URL
const urlWithoutSlash = normalizedUrl.substring(1);
targetUrl = new URL(urlWithoutSlash);
hostname = targetUrl.hostname;
urlPath = sanitizeUrlPath(targetUrl.pathname + targetUrl.search);
} else {
// Regular request: GET /path HTTP/1.1 with Host header
hostname = req.headers.host || 'localhost';
urlPath = sanitizeUrlPath(req.url);
targetUrl = new URL(`http://${hostname}${urlPath}`);
}
// Strip port from hostname (Host header may include port, e.g., "example.com:8080")
hostname = hostname.split(':')[0];
// Validate hostname to prevent SSRF and other host-based attacks
try {
hostname = validateHostname(hostname);
} catch (error) {
@@ -101,7 +80,6 @@ export class ProxyRequestHandler {
logger.info(`[ProxyHandler] Parsed - Host: ${hostname}, Path: ${urlPath}`);
// Step 1: Try GameZip server first (in-process)
{
logger.debug(`[ProxyHandler] Trying GameZip server...`);
const gameZipResult = await this.tryGameZipServer(hostname, urlPath);
@@ -112,8 +90,6 @@ export class ProxyRequestHandler {
}
}
// Step 2: Try legacy file serving (local htdocs + external CDN)
// This handles games whose content is in htdocs or available from external sources.
const requestContext = await this.buildRequestContext(req);
logger.debug(`[ProxyHandler] Trying legacy server...`);
const legacyResult = await this.legacyServer.serveLegacy(hostname, urlPath, requestContext);
@@ -121,21 +97,15 @@ export class ProxyRequestHandler {
logger.info(`[ProxyHandler] ✓ Served from ${legacyResult.source}`);
this.sendResponse(res, legacyResult, legacyResult.source);
} catch (error) {
// Sanitize error message before logging to prevent path leakage (G-H4)
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
const safeMessage = sanitizeErrorMessage(errorMessage);
logger.error(`[ProxyHandler] Error handling request: ${safeMessage}`);
// Never expose internal error details to client (G-H4)
this.sendError(res, 500, 'Internal server error');
}
}
/**
* Try to find file from GameZip server (direct in-process call)
* Returns the file data if found in a mounted ZIP, or null to fall through
* to the legacy server fallback chain.
*/
/** Returns file data from a mounted ZIP, or null to fall through to legacy server */
private async tryGameZipServer(
hostname: string,
urlPath: string
@@ -152,15 +122,10 @@ export class ProxyRequestHandler {
const ext = path.extname(pathWithoutQuery).substring(1).toLowerCase();
const contentType = getMimeType(ext);
// Don't inject polyfills here - sendResponse() handles it for all HTML responses
// sendResponse() handles polyfill injection for all HTML responses
return { data: result.data, contentType, statusCode: 200 };
}
// File not found in any mounted ZIP.
// Return null so the request falls through to the legacy server,
// which can serve the file from local htdocs or external CDN sources.
// If a ZIP download is in progress, the frontend already shows a
// download UI and won't try to load game content until it completes.
return null;
} catch (error) {
logger.debug(`[ProxyHandler] GameZip lookup failed`);
@@ -168,33 +133,25 @@ export class ProxyRequestHandler {
}
}
/**
* Send successful response (supports both buffered and streamed responses)
*/
private sendResponse(res: ServerResponse, result: LegacyFileResponse, source: string): void {
const settings = ConfigManager.getSettings();
const contentType = result.contentType;
const statusCode = result.statusCode || 200;
// CORS headers
setCorsHeaders(res, settings);
// Common headers
res.setHeader('Cache-Control', `public, max-age=${CACHE_MAX_AGE_SECONDS}`);
if (process.env.NODE_ENV !== 'production') {
res.setHeader('X-Source', source); // Debug header
res.setHeader('X-Source', source);
}
res.setHeader('Connection', 'keep-alive');
// Add CGI response headers if present (allowlist approach to prevent header injection)
if (result.headers) {
for (const [key, value] of Object.entries(result.headers)) {
const lowerKey = key.toLowerCase();
// Only forward safe, known headers from CGI responses
if (!ALLOWED_CGI_HEADERS.has(lowerKey)) {
continue;
}
// Reject header values containing CR or LF to prevent header injection
if (/[\r\n]/.test(value)) {
logger.warn(`[Security] CGI header value contains CRLF, skipping: ${key}`);
continue;
@@ -204,15 +161,13 @@ export class ProxyRequestHandler {
}
if (result.stream) {
// Streaming response for large non-HTML files
res.setHeader('Content-Type', contentType);
if (result.size !== undefined) {
res.setHeader('Content-Length', result.size);
}
res.writeHead(statusCode);
// Set up streaming timeout (G-H2)
// This prevents hung connections from consuming resources indefinitely
// Prevents hung connections from consuming resources indefinitely
const streamTimeout = setTimeout(() => {
logger.warn('[ProxyHandler] Stream timeout - closing connection');
result.stream?.destroy(new Error('Stream timeout'));
@@ -221,7 +176,6 @@ export class ProxyRequestHandler {
}
}, STREAM_TIMEOUT_MS);
// Clean up stream if client disconnects
res.on('close', () => {
clearTimeout(streamTimeout);
if (result.stream && !result.stream.destroyed) {
@@ -233,7 +187,6 @@ export class ProxyRequestHandler {
result.stream.on('error', (err) => {
clearTimeout(streamTimeout);
// Sanitize stream errors to prevent path leakage (G-H4)
const safeMessage = sanitizeErrorMessage(err.message || 'Unknown error');
logger.error(`[ProxyHandler] Stream error: ${safeMessage}`);
if (!res.headersSent) {
@@ -252,7 +205,6 @@ export class ProxyRequestHandler {
clearTimeout(streamTimeout);
});
} else if (result.data) {
// Buffered response (HTML files that need polyfill injection)
let fileData = result.data;
if (contentType.includes('text/html')) {
fileData = injectPolyfills(result.data);
@@ -264,15 +216,11 @@ export class ProxyRequestHandler {
res.writeHead(statusCode);
res.end(fileData);
} else {
// Should never happen, but handle gracefully
logger.error('[ProxyHandler] Invalid response: neither data nor stream provided');
this.sendError(res, 500, 'Internal server error: invalid response format');
}
}
/**
* Send error response
*/
private sendError(res: ServerResponse, statusCode: number, message: string): void {
if (res.headersSent) {
if (!res.writableEnded) {
@@ -284,8 +232,6 @@ export class ProxyRequestHandler {
logger.warn(`[ProxyHandler] Sending error ${statusCode}: ${message}`);
const settings = ConfigManager.getSettings();
// IMPORTANT: Set CORS headers even for error responses
// Otherwise browsers will show CORS error instead of the actual error
setCorsHeaders(res, settings);
res.setHeader('Content-Type', 'text/plain');
@@ -293,9 +239,6 @@ export class ProxyRequestHandler {
res.end(message);
}
/**
* Handle OPTIONS request for CORS preflight
*/
handleOptionsRequest(req: IncomingMessage, res: ServerResponse): void {
const settings = ConfigManager.getSettings();
setCorsHeaders(res, settings);
@@ -308,12 +251,7 @@ export class ProxyRequestHandler {
res.end();
}
/**
* Build HTTP request context for CGI execution
* Collects method, headers, and body from the incoming request
*/
private async buildRequestContext(req: IncomingMessage): Promise<HttpRequestContext> {
// Convert IncomingHttpHeaders to Record<string, string>
const headers: Record<string, string> = {};
for (const [key, value] of Object.entries(req.headers)) {
if (typeof value === 'string') {
@@ -323,7 +261,6 @@ export class ProxyRequestHandler {
}
}
// Collect request body for POST/PUT requests
let body: Buffer | undefined;
if (req.method === 'POST' || req.method === 'PUT' || req.method === 'PATCH') {
body = await this.collectRequestBody(req);
@@ -336,9 +273,6 @@ export class ProxyRequestHandler {
};
}
/**
* Collect request body with size limit and timeout
*/
private collectRequestBody(req: IncomingMessage): Promise<Buffer | undefined> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
@@ -12,9 +12,6 @@ import { PreferencesService, GameDataSource } from './PreferencesService';
const dnsLookup = promisify(dns.lookup);
/**
* Parameters for downloading a game data ZIP
*/
export interface GameDataDownloadParams {
gameId: string;
dateAdded: string; // ISO date string from game_data table
@@ -22,9 +19,6 @@ export interface GameDataDownloadParams {
targetPath?: string; // Override target path (default: Data/Games/)
}
/**
* Result of a download attempt
*/
export interface DownloadResult {
success: boolean;
filePath?: string;
@@ -32,25 +26,19 @@ export interface DownloadResult {
sourceUsed?: string;
}
/**
* Progress callback for download tracking
*/
export type DownloadProgressCallback = (
downloadedBytes: number,
totalBytes: number | null,
sourceName: string
) => void;
/**
* Service for downloading game data ZIPs from configured sources
* Implements the same logic as Flashpoint Launcher's download.ts
*/
/** Implements the same logic as Flashpoint Launcher's download.ts */
export class GameDataDownloader {
private static instance: GameDataDownloader;
private readonly MAX_RETRIES = 3;
private readonly RETRY_DELAY_MS = 1000;
private readonly DOWNLOAD_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
private readonly MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MB max
private readonly DOWNLOAD_TIMEOUT_MS = 5 * 60 * 1000;
private readonly MAX_FILE_SIZE = 500 * 1024 * 1024;
/**
* Private/reserved IPv4 and IPv6 CIDR ranges that must be blocked
@@ -83,25 +71,18 @@ export class GameDataDownloader {
return GameDataDownloader.instance;
}
/**
* Generate the filename for a game data ZIP
* Matches Flashpoint Launcher's getGameDataFilename() logic
*/
/** Matches Flashpoint Launcher's getGameDataFilename() logic */
static getFilename(gameId: string, dateAdded: string): string {
// Handle different date formats (with or without 'T')
let cleanDate: string;
if (dateAdded.includes('T')) {
cleanDate = dateAdded;
} else {
// Insert 'T' between date and time at position 10
cleanDate = dateAdded.slice(0, 10) + 'T' + dateAdded.slice(11);
}
const timestamp = new Date(cleanDate).getTime();
// Validate timestamp with fallback
if (isNaN(timestamp)) {
// Try fallback: append UTC suffix
const fallbackDate = `${dateAdded} +0000 UTC`;
const fallbackTimestamp = new Date(fallbackDate).getTime();
if (!isNaN(fallbackTimestamp)) {
@@ -113,10 +94,6 @@ export class GameDataDownloader {
return `${gameId}-${timestamp}.zip`;
}
/**
* Download a game data ZIP from configured sources
* Tries each source sequentially until one succeeds
*/
async download(
params: GameDataDownloadParams,
onProgress?: DownloadProgressCallback
@@ -126,7 +103,6 @@ export class GameDataDownloader {
logger.info(`[GameDataDownloader] Starting download for ${filename}`);
// Get configured sources
const prefsService = PreferencesService.getInstance();
const sources = await prefsService.getGameDataSources();
@@ -138,12 +114,10 @@ export class GameDataDownloader {
};
}
// Determine target path
const targetDir = params.targetPath || (await prefsService.getDataPacksFolderPath());
const targetPath = path.join(targetDir, filename);
const tempPath = `${targetPath}.temp`;
// Ensure target directory exists
try {
await fs.mkdir(targetDir, { recursive: true });
} catch (err) {
@@ -151,11 +125,9 @@ export class GameDataDownloader {
return { success: false, error: `Failed to create directory: ${errorMsg}` };
}
// Try each source sequentially
const errors: string[] = [];
for (const source of sources) {
// Check if source has arguments and at least one URL
if (!source.arguments?.length || !source.arguments[0]) {
logger.warn(`[GameDataDownloader] Source "${source.name}" has no arguments, skipping`);
continue;
@@ -163,23 +135,19 @@ export class GameDataDownloader {
let baseUrl = source.arguments[0];
// Enforce HTTPS for download sources
if (new URL(baseUrl).protocol === 'http:') {
const secureUrl = baseUrl.replace(/^http:/, 'https:');
logger.warn(`[GameDataDownloader] Upgrading insecure source URL to HTTPS: ${baseUrl}`);
baseUrl = secureUrl;
}
// Normalize baseUrl trailing slash before URL construction
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/';
const fullUrl = new URL(filename, normalizedBase).href;
logger.info(`[GameDataDownloader] Trying source "${source.name}": ${fullUrl}`);
try {
// Download to temp file
await this.downloadFile(fullUrl, tempPath, source.name, onProgress);
// Verify SHA256 if provided
if (sha256) {
const fileHash = await this.calculateSha256(tempPath);
if (fileHash.toLowerCase() !== sha256.toLowerCase()) {
@@ -195,7 +163,6 @@ export class GameDataDownloader {
logger.info(`[GameDataDownloader] SHA256 verified: ${fileHash.substring(0, 16)}...`);
}
// Move temp file to final location
await fs.rename(tempPath, targetPath);
logger.info(
@@ -212,12 +179,10 @@ export class GameDataDownloader {
logger.error(`[GameDataDownloader] Source "${source.name}" failed: ${errorMsg}`);
errors.push(`Source "${source.name}": ${errorMsg}`);
// Clean up temp file if exists
await this.safeUnlink(tempPath);
}
}
// All sources failed
const errorSummary = errors.join('\n');
logger.error(`[GameDataDownloader] All sources failed for ${filename}:\n${errorSummary}`);
@@ -227,9 +192,6 @@ export class GameDataDownloader {
};
}
/**
* Download a file from URL to local path with retry logic
*/
private async downloadFile(
url: string,
destPath: string,
@@ -276,7 +238,6 @@ export class GameDataDownloader {
throw new Error('Too many redirects');
}
// Validate URL protocol - only HTTP(S) allowed
const parsedUrl = new URL(url);
if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') {
throw new Error(
@@ -284,7 +245,6 @@ export class GameDataDownloader {
);
}
// Validate URL is not targeting a private/reserved IP
if (this.isPrivateHost(parsedUrl.hostname)) {
logger.warn(
`[GameDataDownloader] Blocked download to private/reserved address: ${parsedUrl.hostname}`
@@ -359,7 +319,6 @@ export class GameDataDownloader {
let writeStream: ReturnType<typeof createWriteStream> | null = null;
const request = protocol.get(url, requestOptions, (response) => {
// Handle redirects — signal back instead of recursing inside Promise
if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400) {
const redirectUrl = response.headers.location;
if (redirectUrl) {
@@ -377,14 +336,12 @@ export class GameDataDownloader {
}
}
// Check for success status
if (response.statusCode !== 200) {
response.resume();
safeReject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`));
return;
}
// Check content length
const contentLength = response.headers['content-length'];
const totalBytes = contentLength ? parseInt(contentLength, 10) : null;
@@ -394,7 +351,6 @@ export class GameDataDownloader {
return;
}
// Create write stream
writeStream = createWriteStream(destPath);
let downloadedBytes = 0;
@@ -454,7 +410,6 @@ export class GameDataDownloader {
});
});
// Handle redirect outside the Promise (async recursion is safe here)
if (result.redirect) {
logger.info(`[GameDataDownloader] Following redirect to: ${result.redirect}`);
return this.downloadFileOnce(
@@ -467,11 +422,7 @@ export class GameDataDownloader {
}
}
/**
* Validate a redirect URL to prevent SSRF attacks.
* Rejects non-HTTP(S) protocols and private/reserved IP addresses.
* Throws an Error if the URL is not safe to follow.
*/
/** Rejects non-HTTP(S) protocols and private/reserved IP addresses */
private validateRedirectUrl(redirectUrl: string): void {
let parsed: URL;
try {
@@ -480,7 +431,6 @@ export class GameDataDownloader {
throw new Error(`Invalid redirect URL: ${redirectUrl}`);
}
// Only allow http: and https: protocols
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
logger.warn(
`[GameDataDownloader] Blocked redirect to disallowed protocol: ${parsed.protocol} (${redirectUrl})`
@@ -490,7 +440,6 @@ export class GameDataDownloader {
);
}
// Block private/reserved IP addresses
if (this.isPrivateHost(parsed.hostname)) {
logger.warn(
`[GameDataDownloader] Blocked redirect to private/reserved address: ${parsed.hostname} (${redirectUrl})`
@@ -501,25 +450,18 @@ export class GameDataDownloader {
}
}
/**
* Check if a hostname is a private or reserved IP address.
* Handles both IPv4 and IPv6, including bracketed IPv6 notation.
*/
/** Handles both IPv4 and IPv6, including bracketed IPv6 notation */
private isPrivateHost(hostname: string): boolean {
// Strip brackets from IPv6 addresses (e.g., [::1] -> ::1)
const cleanHostname = hostname.replace(/^\[|\]$/g, '');
// Check for 'localhost' hostname
if (cleanHostname.toLowerCase() === 'localhost') {
return true;
}
// Try to parse as IPv4
if (net.isIPv4(cleanHostname)) {
return this.isPrivateIPv4(cleanHostname);
}
// Try to parse as IPv6
if (net.isIPv6(cleanHostname)) {
return this.isPrivateIPv6(cleanHostname);
}
@@ -531,9 +473,6 @@ export class GameDataDownloader {
return false;
}
/**
* Check if an IPv4 address falls within a private/reserved CIDR range
*/
private isPrivateIPv4(ip: string): boolean {
const ipNum = this.ipv4ToNumber(ip);
if (ipNum === null) return false;
@@ -551,22 +490,13 @@ export class GameDataDownloader {
return false;
}
/**
* Check if an IPv6 address falls within a private/reserved range
*/
private isPrivateIPv6(ip: string): boolean {
const normalized = ip.toLowerCase();
// Exact loopback check
if (normalized === '::1') return true;
// Unique local addresses: fc00::/7 (fc00:: - fdff::)
if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true;
// Link-local: fe80::/10
if (normalized.startsWith('fe80')) return true;
// IPv4-mapped IPv6 (::ffff:x.x.x.x) - check the embedded IPv4
const v4mappedMatch = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (v4mappedMatch) {
return this.isPrivateIPv4(v4mappedMatch[1]);
@@ -575,9 +505,6 @@ export class GameDataDownloader {
return false;
}
/**
* Convert an IPv4 address string to a 32-bit unsigned integer
*/
private ipv4ToNumber(ip: string): number | null {
const parts = ip.split('.');
if (parts.length !== 4) return null;
@@ -591,9 +518,6 @@ export class GameDataDownloader {
return num;
}
/**
* Calculate SHA256 hash of a file
*/
private calculateSha256(filePath: string): Promise<string> {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
@@ -605,9 +529,6 @@ export class GameDataDownloader {
});
}
/**
* Safely delete a file (ignore errors if file doesn't exist)
*/
private async safeUnlink(filePath: string): Promise<void> {
try {
await fs.unlink(filePath);
@@ -616,16 +537,10 @@ export class GameDataDownloader {
}
}
/**
* Sleep for specified milliseconds
*/
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Check if a game data ZIP exists locally
*/
async exists(gameId: string, dateAdded: string, targetDir?: string): Promise<boolean> {
try {
const filename = GameDataDownloader.getFilename(gameId, dateAdded);
@@ -645,9 +560,6 @@ export class GameDataDownloader {
}
}
/**
* Get the full path to a game data ZIP
*/
async getFilePath(gameId: string, dateAdded: string, targetDir?: string): Promise<string> {
try {
const filename = GameDataDownloader.getFilename(gameId, dateAdded);
@@ -663,5 +575,4 @@ export class GameDataDownloader {
}
}
// Export singleton instance
export const gameDataDownloader = GameDataDownloader.getInstance();
@@ -2,29 +2,18 @@ import fs from 'fs/promises';
import path from 'path';
import { logger } from '../../utils/logger';
/**
* Game data source configuration from preferences.json
* Used for downloading game ZIPs when not present locally
*/
export interface GameDataSource {
type: string; // Currently only "raw" is used
name: string; // Display name for the source
arguments: string[]; // Array of arguments, first is the base URL
type: string;
name: string;
arguments: string[]; // First element is the base URL
}
/**
* Relevant fields from Flashpoint preferences.json
*/
export interface FlashpointPreferences {
gameDataSources: GameDataSource[];
dataPacksFolderPath: string;
onDemandBaseUrl?: string;
}
/**
* Service for reading Flashpoint preferences.json
* Provides access to gameDataSources for downloading game ZIPs
*/
export class PreferencesService {
private static instance: PreferencesService;
private preferences: FlashpointPreferences | null = null;
@@ -32,17 +21,14 @@ export class PreferencesService {
private preferencesPath: string;
private lastLoadTime: number = 0;
private lastLoadFailed: boolean = false;
private readonly CACHE_TTL = 60 * 1000; // 1 minute cache
private readonly ERROR_CACHE_TTL = 5000; // 5 seconds cache for errors
private readonly CACHE_TTL = 60 * 1000;
private readonly ERROR_CACHE_TTL = 5000;
private constructor(flashpointPath: string) {
this.flashpointPath = flashpointPath;
this.preferencesPath = path.join(flashpointPath, 'preferences.json');
}
/**
* Initialize the PreferencesService singleton
*/
static initialize(flashpointPath: string): PreferencesService {
if (PreferencesService.instance) {
if (flashpointPath !== PreferencesService.instance.flashpointPath) {
@@ -56,9 +42,6 @@ export class PreferencesService {
return PreferencesService.instance;
}
/**
* Get the singleton instance
*/
static getInstance(): PreferencesService {
if (!PreferencesService.instance) {
throw new Error('PreferencesService not initialized. Call initialize() first.');
@@ -66,16 +49,11 @@ export class PreferencesService {
return PreferencesService.instance;
}
/**
* Load preferences from file with caching
*/
async loadPreferences(): Promise<FlashpointPreferences> {
const now = Date.now();
// Use shorter TTL if last load failed
const cacheTTL = this.lastLoadFailed ? this.ERROR_CACHE_TTL : this.CACHE_TTL;
// Return cached preferences if still valid
if (this.preferences && now - this.lastLoadTime < cacheTTL) {
return this.preferences;
}
@@ -101,7 +79,6 @@ export class PreferencesService {
} catch (error) {
logger.error('[PreferencesService] Failed to load preferences.json:', error);
// Return defaults if file doesn't exist or is invalid
this.preferences = {
gameDataSources: [],
dataPacksFolderPath: 'Data/Games',
@@ -114,25 +91,16 @@ export class PreferencesService {
}
}
/**
* Get game data sources for downloading ZIPs
*/
async getGameDataSources(): Promise<GameDataSource[]> {
const prefs = await this.loadPreferences();
return prefs.gameDataSources;
}
/**
* Get the data packs folder path (relative to FLASHPOINT_PATH)
*/
async getDataPacksFolderPath(): Promise<string> {
const prefs = await this.loadPreferences();
return path.join(this.flashpointPath, prefs.dataPacksFolderPath);
}
/**
* Invalidate the cache to force reload on next access
*/
invalidateCache(): void {
this.lastLoadTime = 0;
logger.info('[PreferencesService] Cache invalidated');
-17
View File
@@ -1,26 +1,9 @@
import { ServerResponse } from 'http';
/**
* CORS Headers Utility
*
* Provides consistent CORS header setting across all game-service responses
*/
interface CorsSettings {
allowCrossDomain: boolean;
}
/**
* Set CORS headers on HTTP response
*
* @param res - HTTP ServerResponse object
* @param settings - CORS configuration settings
*
* @example
* ```typescript
* setCorsHeaders(res, { allowCrossDomain: true });
* ```
*/
export function setCorsHeaders(res: ServerResponse, settings: CorsSettings): void {
if (settings.allowCrossDomain) {
res.setHeader('Access-Control-Allow-Origin', '*');
+3 -27
View File
@@ -1,14 +1,6 @@
/**
* HTML Injector - Injects polyfills and shims into HTML files
* for better compatibility with Unity WebGL and other game engines
*/
import { logger } from '../../utils/logger';
/**
* Unity WebGL polyfills
* These are minimal stubs to prevent errors when Unity games load
*/
/** Minimal stubs to prevent errors when Unity games load */
const UNITY_POLYFILLS = `
<script>
// Unity WebGL Polyfills
@@ -54,10 +46,7 @@ if (typeof UnityLoader2020 === 'undefined') {
</script>
`;
/**
* General game polyfills
* These help with compatibility for various game engines
*/
/** Compatibility polyfills for various game engines */
const GENERAL_POLYFILLS = `
<script>
// General game compatibility polyfills
@@ -74,9 +63,6 @@ if (typeof AudioContext === 'undefined' && typeof webkitAudioContext !== 'undefi
</script>
`;
/**
* Check if HTML content needs Unity polyfills
*/
function needsUnityPolyfills(html: string): boolean {
const unityIndicators = [
/UnityProgress/i,
@@ -90,31 +76,23 @@ function needsUnityPolyfills(html: string): boolean {
return unityIndicators.some((pattern) => pattern.test(html));
}
/**
* Inject polyfills into HTML content
* Adds necessary polyfills in the <head> section before other scripts load
*/
/** Injects polyfills into the <head> section before other scripts load */
export function injectPolyfills(html: Buffer | string): Buffer {
let htmlString = Buffer.isBuffer(html) ? html.toString('utf-8') : html;
// Check if this is actually HTML
if (!htmlString.includes('<html') && !htmlString.includes('<head')) {
// Not an HTML file, return as-is
return Buffer.from(htmlString);
}
let polyfillsToInject = '';
// Add Unity polyfills if needed
if (needsUnityPolyfills(htmlString)) {
polyfillsToInject += UNITY_POLYFILLS;
logger.debug('[HTMLInjector] Injecting Unity WebGL polyfills');
}
// Always add general polyfills for compatibility
polyfillsToInject += GENERAL_POLYFILLS;
// Try to inject into <head> tag
const headMatch = htmlString.match(/<head[^>]*>/i);
if (headMatch) {
const headOpenTag = headMatch[0];
@@ -122,7 +100,6 @@ export function injectPolyfills(html: Buffer | string): Buffer {
htmlString =
htmlString.slice(0, insertPosition) + polyfillsToInject + htmlString.slice(insertPosition);
} else {
// No <head> tag, try to inject after <html>
const htmlMatch = htmlString.match(/<html[^>]*>/i);
if (htmlMatch) {
const htmlOpenTag = htmlMatch[0];
@@ -134,7 +111,6 @@ export function injectPolyfills(html: Buffer | string): Buffer {
'</head>' +
htmlString.slice(insertPosition);
} else {
// No <html> or <head> tag, prepend to entire content
htmlString =
'<!DOCTYPE html><html><head>' +
polyfillsToInject +
+11 -70
View File
@@ -2,24 +2,13 @@ import path from 'path';
import { logger } from '../../utils/logger';
/**
* Path Security Utilities
* Prevents directory traversal attacks and ensures paths stay within allowed directories
*/
/**
* Check if a URL path has been double-encoded (indicates potential attack)
* Double encoding: %252e%252e = %2e%2e (after first decode) = .. (after second decode)
*
* @param urlPath - The URL path to check
* @returns true if double encoding is detected
* Check if a URL path has been double-encoded (indicates potential attack).
* %25 encodes '%', so %252e decodes to %2e then to '.', bypassing single-decode filters.
*/
export function hasDoubleEncoding(urlPath: string): boolean {
// Check for double-encoded sequences that would decode to dangerous patterns
// %25 is the encoding for '%', so %252e = %2e, %252f = %2f, etc.
// Double-encoding is a technique to bypass security filters that only decode once.
const doubleEncodedPatterns = [
/%25(?:2[eEfF]|5[cC])/i, // %252e (%2e = .), %252f (%2f = /), %255c (%5c = \)
/%25(?:00)/i, // %2500 = double-encoded null byte
/%25(?:2[eEfF]|5[cC])/i, // %252e (.), %252f (/), %255c (\)
/%25(?:00)/i, // %2500 (null byte)
];
for (const pattern of doubleEncodedPatterns) {
@@ -31,54 +20,26 @@ export function hasDoubleEncoding(urlPath: string): boolean {
return false;
}
/**
* Sanitize an error message to prevent path leakage
* Removes or redacts internal file system paths from error messages
*
* @param message - The error message to sanitize
* @returns Sanitized error message safe for client response
*/
/** Redacts internal file system paths from error messages to prevent path leakage */
export function sanitizeErrorMessage(message: string): string {
// Remove Windows absolute paths (e.g., C:\Users\..., D:\Flashpoint\...)
let sanitized = message.replace(/[A-Za-z]:\\[^:\s'"]+/g, '[path]');
// Remove Unix absolute paths (e.g., /home/user/..., /data/flashpoint/...)
sanitized = sanitized.replace(/\/(?:home|data|usr|var|tmp|opt|etc)[^\s'"]+/gi, '[path]');
// Remove network paths (e.g., \\server\share\...)
sanitized = sanitized.replace(/\\\\[^\s'"]+/g, '[path]');
// Remove any remaining paths that look like file paths with extensions
sanitized = sanitized.replace(/[^\s'"]+\.[a-zA-Z]{2,4}(?:\s|$|["'])/g, '[file] ');
return sanitized;
}
/**
* Sanitize and validate a file path to prevent directory traversal
*
* @param basePath - The base directory that the file must be within
* @param requestPath - The requested path (may contain ../ or other traversal attempts)
* @returns The validated absolute path
* @throws Error if the path attempts to escape the base directory
*/
/** Validates that a path stays within the allowed base directory (prevents directory traversal) */
export function sanitizeAndValidatePath(basePath: string, requestPath: string): string {
// Normalize the base path (resolve to absolute path)
const resolvedBase = path.resolve(basePath);
// Normalize and join the request path with base
// path.normalize() converts '../' sequences and removes redundant separators
const normalizedRequest = path.normalize(requestPath);
// Join with base path and resolve to absolute path
const resolvedPath = path.resolve(resolvedBase, normalizedRequest);
// CRITICAL SECURITY CHECK: Ensure resolved path is within base directory
// On Windows, paths are case-insensitive, so we need to handle that
// Case-insensitive comparison on Windows
const isWindows = process.platform === 'win32';
if (isWindows) {
// Case-insensitive comparison for Windows
const normalizedResolvedPath = resolvedPath.toLowerCase();
const normalizedResolvedBase = resolvedBase.toLowerCase();
@@ -89,8 +50,7 @@ export function sanitizeAndValidatePath(basePath: string, requestPath: string):
throw new Error('Invalid path: Directory traversal detected');
}
// Verify path boundary to prevent sibling directory access
// e.g., "htdocs-backup" should not pass when base is "htdocs"
// Prevent sibling directory access (e.g., "htdocs-backup" when base is "htdocs")
if (
normalizedResolvedPath !== normalizedResolvedBase &&
!normalizedResolvedPath.startsWith(normalizedResolvedBase + path.sep)
@@ -101,7 +61,6 @@ export function sanitizeAndValidatePath(basePath: string, requestPath: string):
throw new Error('Invalid path: Path escapes allowed directory');
}
} else {
// Case-sensitive comparison for Unix-like systems
if (!resolvedPath.startsWith(resolvedBase)) {
logger.warn(`[Security] Path traversal attempt blocked: ${requestPath}`);
logger.warn(`[Security] Resolved to: ${resolvedPath}`);
@@ -109,7 +68,6 @@ export function sanitizeAndValidatePath(basePath: string, requestPath: string):
throw new Error('Invalid path: Directory traversal detected');
}
// Verify path boundary to prevent sibling directory access
if (resolvedPath !== resolvedBase && !resolvedPath.startsWith(resolvedBase + path.sep)) {
logger.warn(`[Security] Sibling directory access blocked: ${requestPath}`);
logger.warn(`[Security] Resolved to: ${resolvedPath}`);
@@ -122,33 +80,21 @@ export function sanitizeAndValidatePath(basePath: string, requestPath: string):
}
/**
* Sanitize a URL path component to prevent null bytes and other dangerous characters
*
* IMPORTANT: This function should be called BEFORE any URL decoding to catch
* double-encoded attacks. The http module already decodes URLs once, so we
* check for patterns that would become dangerous after a second decode.
*
* @param urlPath - The URL path to sanitize
* @returns The sanitized path
* @throws Error if dangerous characters are detected
* Sanitize a URL path. Must be called BEFORE URL decoding to catch double-encoded attacks.
* The http module already decodes once, so we detect patterns dangerous after a second decode.
*/
export function sanitizeUrlPath(urlPath: string): string {
// Check for null bytes (can be used to bypass file extension checks)
if (urlPath.includes('\0')) {
logger.warn(`[Security] Null byte detected in URL path`);
throw new Error('Invalid path: Null byte detected');
}
// Check for double-encoding attacks (G-H1)
// Double-encoding can bypass security checks if the URL is decoded twice
if (hasDoubleEncoding(urlPath)) {
logger.warn(`[Security] Double-encoded path detected (potential attack)`);
throw new Error('Invalid path: Double encoding detected');
}
// Check for other dangerous patterns
// Note: We allow '..' because it will be handled by sanitizeAndValidatePath
// But we check for obviously malicious patterns
// '..' itself is allowed (handled by sanitizeAndValidatePath), but encoded traversal is not
const dangerousPatterns = [
/\.\.\\/, // ..\ (Windows path traversal)
/\.\.%2[fF]/, // URL encoded ../
@@ -163,19 +109,14 @@ export function sanitizeUrlPath(urlPath: string): string {
}
}
// Decode URL once and check for traversal patterns after decoding
// This catches cases where traversal sequences are single-encoded
try {
const decoded = decodeURIComponent(urlPath);
// After decoding, check for null bytes that were encoded
if (decoded.includes('\0')) {
logger.warn(`[Security] Encoded null byte detected in URL path`);
throw new Error('Invalid path: Null byte detected');
}
} catch (error) {
// decodeURIComponent can throw on malformed sequences
// This is acceptable - malformed URLs should be rejected
if (error instanceof URIError) {
logger.warn(`[Security] Malformed URL encoding detected`);
throw new Error('Invalid path: Malformed URL encoding');
-22
View File
@@ -1,14 +1,5 @@
import { z } from 'zod';
/**
* Validation schemas for game-service endpoints
* Provides input validation to prevent security issues and malformed requests
*/
/**
* Schema for mount endpoint
* POST /mount/:gameId
*/
export const mountGameSchema = z.object({
gameId: z
.string()
@@ -17,15 +8,8 @@ export const mountGameSchema = z.object({
.regex(/^[a-zA-Z0-9\-_]+$/, 'Game ID contains invalid characters'),
});
/**
* Schema for unmount endpoint (reuses mountGameSchema)
* DELETE /mount/:gameId
*/
export const unmountGameSchema = mountGameSchema;
/**
* Schema for hostname validation
*/
export const hostnameSchema = z
.string()
.min(2, 'Hostname is required') // Intentionally requires 2+ chars; single-char hostnames not supported for legacy content
@@ -34,9 +18,6 @@ export const hostnameSchema = z
message: 'Invalid hostname format',
});
/**
* Validate and sanitize a game ID
*/
export function validateGameId(gameId: unknown): string {
const result = mountGameSchema.shape.gameId.safeParse(gameId);
@@ -47,9 +28,6 @@ export function validateGameId(gameId: unknown): string {
return result.data;
}
/**
* Validate and sanitize a hostname
*/
export function validateHostname(hostname: unknown): string {
const result = hostnameSchema.safeParse(hostname);
+2 -60
View File
@@ -31,14 +31,12 @@ export class ZipManager {
constructor() {
this.mountedZips = new LRUCache<string, MountedZip>({
max: 100, // Maximum 100 ZIPs mounted simultaneously
ttl: 30 * 60 * 1000, // 30-minute TTL
max: 100,
ttl: 30 * 60 * 1000,
dispose: async (value, key) => {
// Skip if already manually closed (prevents double-close from unmount + dispose)
if (value._manuallyClosed) return;
// Automatically close ZIP when evicted from cache
// Track the close operation to ensure cleanup completes
const closePromise = (async () => {
try {
logger.info(`[ZipManager] Auto-closing evicted ZIP: ${key}`);
@@ -53,31 +51,21 @@ export class ZipManager {
this.pendingCloses.delete(closePromise);
});
},
// Update access time on get
updateAgeOnGet: true,
// Don't update on has() check
updateAgeOnHas: false,
});
}
/**
* Mount a ZIP file for serving
* @param id Unique identifier for this mount (e.g., game data ID)
* @param zipPath Absolute path to the ZIP file
*/
async mount(id: string, zipPath: string): Promise<void> {
// Check if shutting down
if (this.shuttingDown) {
throw new Error('ZipManager is shutting down');
}
// Check if already mounted
if (this.mountedZips.has(id)) {
logger.debug(`[ZipManager] ZIP already mounted: ${id}`);
return;
}
// Check if currently mounting — await the existing mount instead of returning early
const existingMount = this.mountingInProgress.get(id);
if (existingMount) {
logger.debug(`[ZipManager] ZIP mount already in progress, awaiting: ${id}`);
@@ -85,7 +73,6 @@ export class ZipManager {
return;
}
// Create a deferred promise so concurrent callers can await this mount
let resolveMounting!: () => void;
let rejectMounting!: (err: Error) => void;
const mountPromise = new Promise<void>((resolve, reject) => {
@@ -95,7 +82,6 @@ export class ZipManager {
this.mountingInProgress.set(id, mountPromise);
try {
// Verify ZIP file exists
try {
await fs.access(zipPath);
} catch (error) {
@@ -106,11 +92,9 @@ export class ZipManager {
logger.info(`[ZipManager] Mounting ZIP: ${id} -> ${zipPath}`);
// Create ZIP reader
const zip = new StreamZip.async({ file: zipPath });
try {
// Build entry index and metadata cache for O(1) lookup
const entries = await zip.entries();
const entryIndex = new Set<string>();
const entryMeta = new Map<string, EntryMeta>();
@@ -122,7 +106,6 @@ export class ZipManager {
});
}
// Store mounted ZIP
this.mountedZips.set(id, {
id,
zipPath,
@@ -147,10 +130,6 @@ export class ZipManager {
}
}
/**
* Unmount a ZIP file
* @param id Unique identifier of the mounted ZIP
*/
async unmount(id: string): Promise<boolean> {
const mounted = this.mountedZips.peek(id);
if (!mounted) {
@@ -173,11 +152,6 @@ export class ZipManager {
}
}
/**
* Get a file from a mounted ZIP
* @param id ZIP mount ID
* @param filePath Path within the ZIP (e.g., "htdocs/www.example.com/file.swf")
*/
async getFile(id: string, filePath: string): Promise<Buffer | null> {
const mounted = this.mountedZips.get(id);
if (!mounted) {
@@ -186,16 +160,13 @@ export class ZipManager {
}
try {
// Normalize path (remove leading slash)
const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath;
// Check index before reading file (O(1) vs reading entire file)
if (!mounted.entryIndex.has(normalizedPath)) {
logger.debug(`[ZipManager] File not in index ${id}: ${normalizedPath}`);
return null;
}
// Check file size before buffering (use cached metadata)
const meta = mounted.entryMeta.get(normalizedPath);
if (meta && meta.size > MAX_BUFFERED_FILE_SIZE) {
logger.warn(
@@ -206,7 +177,6 @@ export class ZipManager {
logger.debug(`[ZipManager] Reading from ZIP ${id}: ${normalizedPath}`);
// Get file from ZIP
const data = await mounted.zip.entryData(normalizedPath);
logger.debug(`[ZipManager] ✓ Read ${data.length} bytes from ${id}:${normalizedPath}`);
@@ -219,13 +189,7 @@ export class ZipManager {
}
}
/**
* Search for a file across all mounted ZIPs
* @param relPath Relative path (e.g., "www.example.com/path/file.swf")
* @returns Buffer and mount ID if found
*/
async findFile(relPath: string): Promise<{ data: Buffer; mountId: string } | null> {
// Try with different prefixes (different ZIP structures)
const pathsToTry = [
`content/${relPath}`, // Most common: content/domain/path
`htdocs/${relPath}`, // Standard: htdocs/domain/path
@@ -233,7 +197,6 @@ export class ZipManager {
`Legacy/htdocs/${relPath}`, // Full path: Legacy/htdocs/domain/path
];
// Search all mounted ZIPs
// Collect IDs first to avoid issues with LRU iterator during async operations
const mountIds = Array.from(this.mountedZips.keys());
@@ -243,21 +206,17 @@ export class ZipManager {
if (!mounted) continue; // Evicted between keys() and get()
for (const pathVariant of pathsToTry) {
// Normalize path (remove leading slash)
const normalizedPath = pathVariant.startsWith('/') ? pathVariant.substring(1) : pathVariant;
// Check index first (O(1))
if (!mounted.entryIndex.has(normalizedPath)) {
continue;
}
// Check cached metadata for directory/size (avoids re-reading entries)
const meta = mounted.entryMeta.get(normalizedPath);
if (!meta || meta.isDirectory) {
continue;
}
// Check size before reading
if (meta.size > MAX_BUFFERED_FILE_SIZE) {
logger.warn(
`[ZipManager] File too large to buffer: ${normalizedPath} (${meta.size} bytes)`
@@ -265,7 +224,6 @@ export class ZipManager {
continue;
}
// Read entry data
try {
const data = await mounted.zip.entryData(normalizedPath);
logger.info(`[ZipManager] ✓ Found in ${id}: ${pathVariant}`);
@@ -281,13 +239,9 @@ export class ZipManager {
return null;
}
/**
* Get list of mounted ZIPs
*/
getMountedZips(): Array<{ id: string; zipPath: string; mountTime: Date; fileCount: number }> {
const result: Array<{ id: string; zipPath: string; mountTime: Date; fileCount: number }> = [];
// LRU cache provides entries() iterator
for (const [id, mounted] of this.mountedZips.entries()) {
result.push({
id,
@@ -300,9 +254,6 @@ export class ZipManager {
return result;
}
/**
* List files in a mounted ZIP (for debugging)
*/
async listFiles(id: string, pattern?: string): Promise<string[]> {
const mounted = this.mountedZips.get(id);
if (!mounted) return [];
@@ -327,20 +278,13 @@ export class ZipManager {
}
}
/**
* Check if a ZIP is mounted
*/
isMounted(id: string): boolean {
return this.mountedZips.has(id);
}
/**
* Unmount all ZIPs (cleanup on shutdown)
*/
async unmountAll(): Promise<void> {
this.shuttingDown = true;
// Wait for in-progress mounts to complete
if (this.mountingInProgress.size > 0) {
logger.info(`[ZipManager] Waiting for ${this.mountingInProgress.size} in-progress mounts...`);
await Promise.allSettled(Array.from(this.mountingInProgress.values()));
@@ -351,7 +295,6 @@ export class ZipManager {
const promises = Array.from(this.mountedZips.keys()).map((id) => this.unmount(id));
await Promise.all(promises);
// Wait for all pending close operations from LRU cache disposal
if (this.pendingCloses.size > 0) {
logger.info(
`[ZipManager] Waiting for ${this.pendingCloses.size} pending close operations...`
@@ -363,5 +306,4 @@ export class ZipManager {
}
}
// Singleton instance
export const zipManager = new ZipManager();
+1 -16
View File
@@ -4,32 +4,19 @@ import { logger } from '../utils/logger';
const activityService = new ActivityService();
/**
* Log activity middleware
* Logs user activity after request completes
*
* @param action - The action being performed (e.g., 'games.view', 'auth.login')
* @param resource - Optional resource type (e.g., 'games', 'users')
* @param detailsExtractor - Optional function to extract additional details from request/response
*/
/** Logs user activity after request completes (non-blocking). */
export const logActivity = (
action: string,
resource?: string,
detailsExtractor?: (req: Request, res: Response) => Record<string, unknown>
) => {
return async (req: Request, res: Response, next: NextFunction) => {
// Store original end function
const originalEnd = res.end;
// Override end function to log activity after response
// eslint-disable-next-line @typescript-eslint/no-explicit-any
res.end = function (this: Response, ...args: unknown[]): Response {
// Log activity (non-blocking)
setImmediate(async () => {
try {
const resourceId = req.params.id || req.body?.id;
// Extract additional details if extractor provided
const details = detailsExtractor ? detailsExtractor(req, res) : undefined;
await activityService.log({
@@ -43,12 +30,10 @@ export const logActivity = (
userAgent: req.headers['user-agent'],
});
} catch (error) {
// Log error but don't fail request
logger.error('Failed to log activity:', error);
}
});
// Call original end function
return originalEnd.apply(this, args as Parameters<typeof originalEnd>);
};
+1 -42
View File
@@ -1,33 +1,6 @@
import { Request, Response, NextFunction, RequestHandler } from 'express';
/**
* Async Handler Middleware
*
* Wraps async route handlers to automatically catch errors and pass them to error middleware.
* Eliminates the need for try-catch blocks in every route handler.
*
* @example
* ```typescript
* // Before (with try-catch)
* router.get('/games', async (req, res, next) => {
* try {
* const games = await GameService.searchGames(...);
* res.json(games);
* } catch (error) {
* next(error);
* }
* });
*
* // After (with asyncHandler)
* router.get('/games', asyncHandler(async (req, res) => {
* const games = await GameService.searchGames(...);
* res.json(games);
* }));
* ```
*
* @param fn - The async route handler function
* @returns Express middleware function that handles promise rejections
*/
/** Wraps async route handlers to catch errors and pass them to error middleware. */
export const asyncHandler = (
fn: (req: Request, res: Response, next: NextFunction) => Promise<any>
): RequestHandler => {
@@ -36,18 +9,4 @@ export const asyncHandler = (
};
};
/**
* Type alias for async route handlers
* Useful for maintaining type safety when using asyncHandler
*
* @example
* ```typescript
* const getGames: AsyncRouteHandler = async (req, res) => {
* const games = await GameService.searchGames(...);
* res.json(games);
* };
*
* router.get('/games', asyncHandler(getGames));
* ```
*/
export type AsyncRouteHandler = (req: Request, res: Response, next: NextFunction) => Promise<any>;
+1 -20
View File
@@ -9,10 +9,6 @@ import { getAccessTokenFromCookie } from '../utils/cookies';
const authService = new AuthService();
const playlistService = new UserPlaylistService();
/**
* Extract access token from HTTP-only cookie or Authorization header.
* Cookie takes priority (regular authenticated user).
*/
function getAccessToken(req: Request): string | undefined {
const cookieToken = getAccessTokenFromCookie(req.cookies);
if (cookieToken) return cookieToken;
@@ -25,9 +21,6 @@ function getAccessToken(req: Request): string | undefined {
return undefined;
}
/**
* Extend Express Request to include sharedAccess property
*/
declare global {
namespace Express {
interface Request {
@@ -36,10 +29,6 @@ declare global {
}
}
/**
* Authenticate user from JWT token
* Reads access token from HTTP-only cookie first, falls back to Authorization header
*/
export const authenticate = asyncHandler(
async (req: Request, res: Response, next: NextFunction) => {
const token = getAccessToken(req);
@@ -54,10 +43,6 @@ export const authenticate = asyncHandler(
}
);
/**
* Optional authentication - allows guest access if enabled
* Reads token from cookie or header. If absent, sets guest user (if guest access enabled)
*/
export const optionalAuth = asyncHandler(
async (req: Request, res: Response, next: NextFunction) => {
const token = getAccessToken(req);
@@ -66,7 +51,6 @@ export const optionalAuth = asyncHandler(
const user = await authService.verifyAccessToken(token);
req.user = user;
} else {
// No token - check if guest access is enabled
if (!authService.isGuestAccessEnabled()) {
throw new AppError(401, 'Authentication required', true, 'AUTH_REQUIRED');
}
@@ -107,9 +91,8 @@ export const softAuth = async (req: Request, res: Response, next: NextFunction)
req.user = undefined;
}
next(); // Always continue, never throw errors
next();
} catch {
// Unexpected error - continue anyway with undefined user
req.user = undefined;
next();
}
@@ -137,8 +120,6 @@ export const sharedAccessAuth = asyncHandler(
const token = authHeader.substring(13);
try {
const payload = verifySharedAccessToken(token);
// Validate the shareToken is still valid
const playlist = playlistService.getPlaylistByShareToken(payload.shareToken);
if (!playlist) {
throw new AppError(
+1 -28
View File
@@ -1,33 +1,6 @@
import { Request, Response, NextFunction } from 'express';
/**
* Date Range Validation Middleware
*
* Validates that startDate and endDate query parameters are valid dates
* and that endDate is not before startDate.
*
* @param startDateKey - Query parameter name for start date (default: 'startDate')
* @param endDateKey - Query parameter name for end date (default: 'endDate')
* @returns Express middleware function
*
* @example
* ```typescript
* router.get('/activities',
* validateDateRange(),
* async (req, res) => {
* // Date range is validated
* }
* );
*
* // With custom parameter names
* router.get('/reports',
* validateDateRange('from', 'to'),
* async (req, res) => {
* // Date range is validated using 'from' and 'to' params
* }
* );
* ```
*/
/** Validates that startDate/endDate query params are valid dates and that endDate >= startDate. */
export function validateDateRange(
startDateKey: string = 'startDate',
endDateKey: string = 'endDate'
-5
View File
@@ -14,9 +14,6 @@ export class AppError extends Error {
}
}
/**
* Sanitize request body by redacting sensitive fields
*/
function sanitizeBody(body: unknown): unknown {
if (!body || typeof body !== 'object') {
return body;
@@ -75,7 +72,6 @@ export function errorHandler(
});
}
// Handle Zod validation errors
if (err instanceof ZodError) {
logger.error(`[ValidationError] Zod validation failed`, {
path: req.path,
@@ -91,7 +87,6 @@ export function errorHandler(
});
}
// Unhandled errors - Log full details with sanitization
logger.error(`[UnhandledError] ${err.message}`, {
name: err.name,
message: err.message,
+2 -12
View File
@@ -5,16 +5,10 @@ import { logger } from '../utils/logger';
const systemSettings = CachedSystemSettingsService.getInstance();
/**
* Middleware to check if a feature is enabled
* Admins with settings.update permission bypass feature flag checks
* @param featureKey - The feature flag key (e.g., 'enablePlaylists')
* @returns Middleware function
*/
/** Middleware to check if a feature is enabled. Admins bypass feature flag checks. */
export function requireFeature(featureKey: string) {
return (req: Request, res: Response, next: NextFunction): void => {
try {
// Admins bypass feature flag checks
if (req.user?.permissions?.includes('settings.update')) {
logger.debug(
`[FeatureFlag] Admin '${req.user.username}' bypassing feature flag check for '${featureKey}'`
@@ -42,11 +36,7 @@ export function requireFeature(featureKey: string) {
};
}
/**
* Check if a feature is enabled (for use in route handlers or services)
* @param featureKey - The feature flag key (e.g., 'enablePlaylists')
* @returns boolean
*/
/** Check if a feature is enabled (for use in route handlers or services). */
export function isFeatureEnabled(featureKey: string): boolean {
try {
const features = systemSettings.getCategory('features');
+15 -40
View File
@@ -2,39 +2,24 @@ import { Request, Response, NextFunction } from 'express';
import { CachedSystemSettingsService } from '../services/CachedSystemSettingsService';
import { logger } from '../utils/logger';
// Singleton instance - shared across all requests
const systemSettings = CachedSystemSettingsService.getInstance();
// MINIMAL whitelist - only endpoints needed for admin to LOGIN
// Everything else requires admin authentication
// Only endpoints needed for admin to LOGIN during maintenance
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',
'/api/auth/login',
'/api/auth/register',
'/api/auth/setup-status',
'/api/auth/refresh',
'/api/settings/public',
'/proxy',
'/api/playlists/shared',
];
/**
* Maintenance mode middleware - SIMPLIFIED VERSION
*
* Prerequisites:
* - softAuth middleware MUST be applied globally before this middleware
* - softAuth populates req.user with user data if valid token exists
*
* When maintenance mode is ON:
* - Admin users (with 'settings.update' permission) can access EVERYTHING
* - Non-admin users are blocked from EVERYTHING except minimal public paths
* - Guests are blocked from EVERYTHING except minimal public paths
*
* Strategy:
* 1. Allow minimal public paths (login, health, etc.)
* 2. Check if maintenance mode enabled
* 3. If enabled: only allow admins through, block everyone else
* 4. If disabled: allow everyone (normal operation)
* Maintenance mode middleware.
* Requires softAuth to run first (populates req.user).
* When enabled: admins pass through, everyone else is blocked except MINIMAL_PUBLIC_PATHS.
*/
export async function maintenanceMode(
req: Request,
@@ -42,30 +27,24 @@ export async function maintenanceMode(
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 + '/')
);
if (isPublicPath) {
return next(); // Allow public paths for everyone
return next();
}
// Step 2: Check if maintenance mode is enabled
const appSettings = systemSettings.getCategory('app');
const isMaintenanceEnabled = appSettings.maintenanceMode === true;
if (!isMaintenanceEnabled) {
return next(); // Normal operation - allow everyone
return next();
}
// Step 3: Maintenance mode is ON - check if user is admin
// req.user is populated by softAuth middleware (applied globally before this)
const user = req.user;
const isAdmin = user?.permissions?.includes('settings.update');
// Debug logging to help diagnose permission issues
if (user) {
logger.debug(
`[Maintenance] User ${user.username} (role: ${user.role}) - permissions: ${user.permissions?.join(', ') || 'none'}`
@@ -75,13 +54,10 @@ export async function maintenanceMode(
}
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}`);
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})`);
@@ -94,8 +70,7 @@ export async function maintenanceMode(
} catch (error) {
logger.error('[Maintenance] Critical error in maintenance middleware:', error);
// FAIL CLOSED - Block requests on error (secure by default)
// This is the opposite of the previous behavior
// FAIL CLOSED - block requests on error (secure by default)
return res.status(503).json({
error: 'Service Unavailable',
message: 'System maintenance in progress',
+2 -20
View File
@@ -1,38 +1,20 @@
import { Request, Response, NextFunction } from 'express';
import { PerformanceMetrics } from '../services/PerformanceMetrics';
/**
* Performance tracking middleware
*
* Automatically tracks response times for all API endpoints
* Integrates with PerformanceMetrics service
*/
/** Tracks response times for all API endpoints via PerformanceMetrics. */
export function performanceTracking(req: Request, res: Response, next: NextFunction): void {
const startTime = performance.now();
// Track when response finishes
res.on('finish', () => {
const duration = Math.round(performance.now() - startTime);
// Normalize path (remove IDs and dynamic segments for aggregation)
const normalizedPath = normalizePath(req.path);
// Record metric
PerformanceMetrics.recordEndpoint(normalizedPath, req.method, duration);
});
next();
}
/**
* Normalize API path for metric aggregation
*
* Replaces dynamic segments (IDs, UUIDs) with placeholders
* Examples:
* - /api/games/abc-123 → /api/games/:id
* - /api/playlists/456/games → /api/playlists/:id/games
* - /api/users/789 → /api/users/:id
*/
/** Replaces dynamic segments (IDs, UUIDs) with :id placeholders for metric aggregation. */
function normalizePath(path: string): string {
return (
path
+9 -20
View File
@@ -1,37 +1,26 @@
import rateLimit from 'express-rate-limit';
import { config } from '../config';
/**
* Standard rate limiter for most API endpoints
* Default: 100 requests per minute per IP
*/
export const rateLimitStandard = rateLimit({
windowMs: config.rateLimitWindowMs, // 1 minute
max: config.rateLimitMaxRequests, // 100 requests per window
windowMs: config.rateLimitWindowMs,
max: config.rateLimitMaxRequests,
message: 'Too many requests from this IP, please try again later',
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
standardHeaders: true,
legacyHeaders: false,
});
/**
* Strict rate limiter for sensitive endpoints (shared playlists, auth)
* 10 requests per minute per IP to prevent scraping and brute force
*/
/** Strict rate limiter for sensitive endpoints (auth, shared playlists) to prevent scraping/brute force. */
export const rateLimitStrict = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 10, // 10 requests per window
windowMs: 60 * 1000,
max: 10,
message: 'Too many requests, please try again later',
standardHeaders: true,
legacyHeaders: false,
});
/**
* Lenient rate limiter for authenticated users
* 30 requests per minute per IP
*/
export const rateLimitAuth = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 30, // 30 requests per window
windowMs: 60 * 1000,
max: 30,
message: 'Too many requests, please slow down',
standardHeaders: true,
legacyHeaders: false,
-10
View File
@@ -1,10 +1,6 @@
import { Request, Response, NextFunction } from 'express';
import { AppError } from './errorHandler';
/**
* Check if user has required permission
* User must have at least one of the specified permissions
*/
export const requirePermission = (...permissions: string[]) => {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
@@ -12,8 +8,6 @@ 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));
if (!hasPermission) {
@@ -24,10 +18,6 @@ export const requirePermission = (...permissions: string[]) => {
};
};
/**
* Check if user has required role
* User must have one of the specified roles
*/
export const requireRole = (...roles: string[]) => {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
+1 -43
View File
@@ -1,46 +1,18 @@
import { Request, Response, NextFunction } from 'express';
import { logger } from '../utils/logger';
/**
* Default timeout in milliseconds (30 seconds)
*/
const DEFAULT_TIMEOUT_MS = 30000;
/**
* Request timeout middleware
*
* Terminates requests that exceed the specified timeout duration.
* Prevents hanging requests from consuming server resources.
*
* Features:
* - Configurable timeout per route or globally
* - Automatic cleanup of timed-out requests
* - Detailed logging for debugging
* - 408 Request Timeout status code
*
* Usage:
* ```typescript
* // Global timeout (30s default)
* app.use(requestTimeout());
*
* // Custom timeout (60s)
* app.use(requestTimeout(60000));
*
* // Per-route timeout
* router.get('/slow-endpoint', requestTimeout(120000), handler);
* ```
*/
/** Terminates requests that exceed the specified timeout, preventing hanging connections. */
export function requestTimeout(timeoutMs: number = DEFAULT_TIMEOUT_MS) {
return (req: Request, res: Response, next: NextFunction): void => {
const startTime = Date.now();
let isTimedOut = false;
let hasResponded = false;
// Set up timeout handler
const timeoutId = setTimeout(() => {
isTimedOut = true;
// Only send response if not already sent
if (!hasResponded && !res.headersSent) {
const elapsed = Date.now() - startTime;
@@ -66,7 +38,6 @@ export function requestTimeout(timeoutMs: number = DEFAULT_TIMEOUT_MS) {
}
}, timeoutMs);
// Override res.send/json/end to clear timeout on response
const originalSend = res.send.bind(res);
const originalJson = res.json.bind(res);
const originalEnd = res.end.bind(res);
@@ -106,7 +77,6 @@ export function requestTimeout(timeoutMs: number = DEFAULT_TIMEOUT_MS) {
}
}
// Handle request abortion
req.on('close', () => {
if (!hasResponded) {
hasResponded = true;
@@ -125,22 +95,10 @@ export function requestTimeout(timeoutMs: number = DEFAULT_TIMEOUT_MS) {
};
}
/**
* Timeout configurations for different endpoint types
*/
export const TimeoutConfig = {
/** Default timeout for most endpoints (30s) */
DEFAULT: 30000,
/** Short timeout for fast operations (10s) */
SHORT: 10000,
/** Medium timeout for database queries (60s) */
MEDIUM: 60000,
/** Long timeout for file operations (120s) */
LONG: 120000,
/** Extended timeout for batch operations (300s / 5 minutes) */
EXTENDED: 300000,
};
@@ -5,7 +5,6 @@ import { UserPlaylistService, UserPlaylist } from '../services/UserPlaylistServi
const playlistService = new UserPlaylistService();
// Extend Express Request type to include sharedPlaylist
declare global {
namespace Express {
interface Request {
@@ -15,15 +14,9 @@ declare global {
}
/**
* Validates share token and attaches playlist to req.sharedPlaylist
* Does NOT require authentication - bypasses guest access settings
*
* Flow:
* 1. Extract shareToken from route params
* 2. Validate UUID format (prevent DB query for junk tokens)
* 3. Query database: WHERE share_token = ? AND is_public = 1 AND expiry valid
* 4. If valid → attach to req.sharedPlaylist
* 5. If invalid → throw 404 (don't leak existence)
* Validates share token and attaches playlist to req.sharedPlaylist.
* Does NOT require authentication - bypasses guest access settings.
* Returns 404 (not 403) to avoid leaking token validity.
*/
export const validateSharedPlaylist = asyncHandler(
async (req: Request, res: Response, next: NextFunction) => {
@@ -33,22 +26,18 @@ export const validateSharedPlaylist = asyncHandler(
throw new AppError(400, 'Share token required');
}
// Validate UUID format (prevent DB query for junk tokens)
// UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
// Validate UUID v4 format to avoid unnecessary DB queries
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!UUID_REGEX.test(shareToken)) {
throw new AppError(400, 'Invalid share token format');
}
// Get playlist (checks is_public and expiry)
const playlist = playlistService.getPlaylistByShareToken(shareToken);
if (!playlist) {
// Return 404 (not 403) to avoid leaking token validity
throw new AppError(404, 'Shared playlist not found or no longer available');
}
// Attach to request for route handler
req.sharedPlaylist = playlist;
next();
}
-62
View File
@@ -2,95 +2,33 @@ import { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { AppError } from './errorHandler';
/**
* Validation Middleware Factory
*
* Creates a middleware that validates request data against a Zod schema
*/
type RequestProperty = 'body' | 'query' | 'params';
/**
* Create a validation middleware for a Zod schema
*
* @param schema - Zod schema to validate against
* @param property - Which request property to validate ('body', 'query', or 'params')
* @returns Express middleware function
*
* @example
* ```typescript
* const createUserSchema = z.object({
* username: z.string().min(3).max(50),
* email: z.string().email(),
* password: z.string().min(6)
* });
*
* router.post('/users',
* validateRequest(createUserSchema, 'body'),
* async (req, res) => {
* // req.body is now validated and typed
* const user = await createUser(req.body);
* res.json(user);
* }
* );
* ```
*/
export function validateRequest<T extends z.ZodType>(
schema: T,
property: RequestProperty = 'body'
) {
return (req: Request, res: Response, next: NextFunction) => {
try {
// Validate and parse the request property
const validated = schema.parse(req[property]);
// Replace the request property with validated data
req[property] = validated;
next();
} catch (error) {
if (error instanceof z.ZodError) {
// Extract first validation error message
const firstError = error.errors[0];
const message = firstError ? `Validation error: ${firstError.message}` : 'Validation error';
return next(new AppError(400, message));
}
// Pass other errors to error handler
next(error);
}
};
}
/**
* Validate multiple request properties at once
*
* @param schemas - Object mapping request properties to their schemas
* @returns Express middleware function
*
* @example
* ```typescript
* router.get('/users/:id/posts',
* validateMultiple({
* params: z.object({ id: z.string().uuid() }),
* query: z.object({
* limit: z.coerce.number().int().min(1).max(100).optional(),
* offset: z.coerce.number().int().min(0).optional()
* })
* }),
* async (req, res) => {
* // Both params and query are validated
* const posts = await getUserPosts(req.params.id, req.query);
* res.json(posts);
* }
* );
* ```
*/
export function validateMultiple(schemas: Partial<Record<RequestProperty, z.ZodType>>) {
return (req: Request, res: Response, next: NextFunction) => {
try {
// Validate each specified property
for (const [property, schema] of Object.entries(schemas)) {
if (schema) {
const validated = schema.parse(req[property as RequestProperty]);
-29
View File
@@ -10,7 +10,6 @@ import { validateDateRange } from '../middleware/dateValidation';
const router = Router();
const activityService = new ActivityService();
// Pagination and limit constants
const DEFAULT_PAGE = 1;
const DEFAULT_LIMIT = 50;
const MAX_PAGE_LIMIT = 100;
@@ -20,7 +19,6 @@ const DEFAULT_TREND_DAYS = 7;
const MAX_TREND_DAYS = 30;
const DEFAULT_TIME_RANGE = '24h' as const;
// Validation schema for activity query parameters
const activityQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(DEFAULT_PAGE),
limit: z.coerce.number().int().min(1).max(MAX_PAGE_LIMIT).default(DEFAULT_LIMIT),
@@ -34,20 +32,14 @@ const activityQuerySchema = z.object({
sortOrder: z.enum(['asc', 'desc']).default('desc'),
});
// Apply feature flag check to all routes in this router
router.use(requireFeature('enableStatistics'));
/**
* GET /api/activities
* List activity logs with pagination and filters
*/
router.get(
'/',
authenticate,
requirePermission('activities.read'),
validateDateRange(),
asyncHandler(async (req, res) => {
// Validate and parse query parameters
const queryResult = activityQuerySchema.safeParse(req.query);
if (!queryResult.success) {
@@ -71,7 +63,6 @@ router.get(
sortOrder,
} = queryResult.data;
// Build filters object
const filters: {
userId?: number;
username?: string;
@@ -93,16 +84,11 @@ router.get(
})
);
/**
* GET /api/activities/stats
* Get aggregate statistics for dashboard
*/
router.get(
'/stats',
authenticate,
requirePermission('activities.read'),
asyncHandler(async (req, res) => {
// Validate timeRange before casting
const validTimeRanges = ['24h', '7d', '30d'] as const;
const rawTimeRange = req.query.timeRange as string | undefined;
if (
@@ -137,10 +123,6 @@ router.get(
})
);
/**
* GET /api/activities/trend
* Get activity trend over time
*/
router.get(
'/trend',
authenticate,
@@ -157,17 +139,12 @@ router.get(
})
);
/**
* GET /api/activities/top-actions
* Get top actions by frequency
*/
router.get(
'/top-actions',
authenticate,
requirePermission('activities.read'),
asyncHandler(async (req, res) => {
const limit = Math.min(parseInt(req.query.limit as string) || 10, MAX_ACTIONS_LIMIT);
// Validate timeRange before casting
const validTimeRanges = ['24h', '7d', '30d'] as const;
const rawTimeRange = req.query.timeRange as string | undefined;
if (
@@ -190,10 +167,6 @@ router.get(
})
);
/**
* GET /api/activities/breakdown
* Get activity breakdown by dimension
*/
router.get(
'/breakdown',
authenticate,
@@ -203,7 +176,6 @@ router.get(
const limit = Math.min(parseInt(req.query.limit as string) || 10, MAX_BREAKDOWN_LIMIT);
const timeRange = (req.query.timeRange as '24h' | '7d' | '30d') || DEFAULT_TIME_RANGE;
// Validate groupBy parameter
if (!['resource', 'user', 'ip'].includes(groupBy)) {
return res.status(400).json({
success: false,
@@ -211,7 +183,6 @@ router.get(
});
}
// Validate timeRange
const validTimeRanges = ['24h', '7d', '30d'] as const;
if (req.query.timeRange && !validTimeRanges.includes(timeRange)) {
return res.status(400).json({
-9
View File
@@ -10,7 +10,6 @@ import { z } from 'zod';
const router = Router();
const authSettingsService = new AuthSettingsService();
// Validation schema
const updateSettingsSchema = z.object({
guestAccessEnabled: z.boolean().optional(),
userRegistrationEnabled: z.boolean().optional(),
@@ -20,10 +19,6 @@ const updateSettingsSchema = z.object({
lockoutDurationMinutes: z.number().int().positive().optional(),
});
/**
* GET /api/settings/auth
* Get current auth settings (public endpoint)
*/
router.get(
'/',
asyncHandler(async (req, res) => {
@@ -32,10 +27,6 @@ router.get(
})
);
/**
* PATCH /api/settings/auth
* Update auth settings (admin only)
*/
router.patch(
'/',
authenticate,
+11 -51
View File
@@ -22,11 +22,9 @@ const router = Router();
const authService = new AuthService();
const activityService = new ActivityService();
// Rate limiters for authentication endpoints
// Stricter limits on login to prevent brute force attacks
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 requests per window per IP
windowMs: 15 * 60 * 1000,
max: 5,
message: {
error: {
message: 'Too many login attempts from this IP, please try again after 15 minutes',
@@ -35,7 +33,7 @@ const loginLimiter = rateLimit({
},
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: false, // Count all attempts
skipSuccessfulRequests: false,
handler: (req, res) => {
logger.warn(`[Security] Rate limit exceeded for login from IP: ${req.ip}`);
res.status(429).json({
@@ -47,10 +45,9 @@ const loginLimiter = rateLimit({
},
});
// Moderate limits on registration
const registerLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 3, // 3 registration attempts per hour per IP
windowMs: 60 * 60 * 1000,
max: 3,
message: {
error: {
message: 'Too many registration attempts from this IP, please try again after an hour',
@@ -70,10 +67,9 @@ const registerLimiter = rateLimit({
},
});
// Moderate limits on token refresh
const refreshLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 30, // 30 refresh requests per minute per IP
windowMs: 60 * 1000,
max: 30,
message: {
error: {
message: 'Too many token refresh attempts from this IP, please try again later',
@@ -93,7 +89,6 @@ const refreshLimiter = rateLimit({
},
});
// Validation schemas
const loginSchema = z.object({
username: z.string().min(3).max(50),
password: z.string().min(6),
@@ -105,11 +100,7 @@ const registerSchema = z.object({
password: z.string().min(6),
});
/**
* GET /api/auth/setup-status
* Check if the system needs initial setup (no users exist)
* Public endpoint - no authentication required
*/
// Public: no auth required. Returns whether the system needs initial admin setup.
router.get('/setup-status', (req, res) => {
const needsSetup = UserDatabaseService.needsInitialSetup();
res.json({
@@ -120,10 +111,6 @@ router.get('/setup-status', (req, res) => {
});
});
/**
* POST /api/auth/login
* Login with username and password
*/
router.post(
'/login',
loginLimiter,
@@ -136,7 +123,6 @@ router.post(
const result = await authService.login(credentials, ipAddress);
// Log activity
await activityService.log({
userId: result.user.id,
username: result.user.username,
@@ -148,7 +134,6 @@ router.post(
logger.info(`[Auth] Login successful for user: ${credentials.username}`);
// Set tokens as HTTP-only cookies
setRefreshTokenCookie(res, result.tokens.refreshToken);
setAccessTokenCookie(res, result.tokens.accessToken);
@@ -169,7 +154,6 @@ router.post(
);
}
// Log failed login attempt
const ipAddress = req.ip || '';
const username =
typeof req.body?.username === 'string' ? req.body.username.substring(0, 50) : 'unknown';
@@ -195,7 +179,6 @@ router.post(
logger.error('[Auth] Failed to log failed login attempt:', logError);
}
// Log the error with full details before passing to error handler
logger.warn(`[Auth] Login failed for user: ${username}`, {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
@@ -206,16 +189,8 @@ router.post(
})
);
/**
* POST /api/auth/register
* Register new user
*
* Dual Purpose:
* 1. Initial Setup: When no users exist, creates first admin account (bypasses registration settings)
* 2. Regular Registration: When users exist, creates regular user account (requires registration enabled)
*
* The AuthService.register() method automatically detects which scenario applies
*/
// Dual purpose: first call creates admin (bypasses settings); subsequent calls create regular users.
// AuthService.register() auto-detects which scenario applies.
router.post(
'/register',
registerLimiter,
@@ -227,7 +202,6 @@ router.post(
const result = await authService.register(data);
// Log activity
await activityService.log({
userId: result.user.id,
username: result.user.username,
@@ -239,7 +213,6 @@ router.post(
logger.info(`[Auth] Registration successful for user: ${data.username}`);
// Set tokens as HTTP-only cookies
setRefreshTokenCookie(res, result.tokens.refreshToken);
setAccessTokenCookie(res, result.tokens.accessToken);
@@ -272,10 +245,6 @@ router.post(
})
);
/**
* POST /api/auth/logout
* Logout and revoke refresh token
*/
router.post(
'/logout',
softAuth,
@@ -294,10 +263,6 @@ router.post(
})
);
/**
* POST /api/auth/refresh
* Refresh access token using refresh token
*/
router.post(
'/refresh',
refreshLimiter,
@@ -310,7 +275,7 @@ router.post(
const tokens = await authService.refreshToken(refreshToken);
// Set new token cookies (token rotation)
// Token rotation: both tokens are replaced on every refresh
setRefreshTokenCookie(res, tokens.refreshToken);
setAccessTokenCookie(res, tokens.accessToken);
@@ -320,14 +285,9 @@ router.post(
})
);
/**
* GET /api/auth/me
* Get current authenticated user
*/
router.get(
'/me',
asyncHandler(async (req, res) => {
// Read access token from cookie first, fallback to Authorization header
const token =
getAccessTokenFromCookie(req.cookies) ||
(req.headers.authorization?.startsWith('Bearer ')
-11
View File
@@ -7,11 +7,6 @@ import { asyncHandler } from '../middleware/asyncHandler';
const router = Router();
/**
* GET /api/cache/stats
* Get cache statistics
* Requires: settings.view permission
*/
router.get(
'/stats',
authenticate,
@@ -35,11 +30,6 @@ router.get(
})
);
/**
* POST /api/cache/clear
* Clear all caches
* Requires: settings.update permission
*/
router.post(
'/clear',
authenticate,
@@ -47,7 +37,6 @@ router.post(
asyncHandler(async (req, res) => {
const { cacheType } = req.body;
// Validate cacheType
const validCacheTypes = ['gameSearch', 'permissions', 'all'];
if (cacheType && !validCacheTypes.includes(cacheType)) {
return res.status(400).json({
@@ -12,12 +12,10 @@ import { logActivity } from '../middleware/activityLogger';
const router = Router();
const communityPlaylistService = new CommunityPlaylistService();
// Validation schema
const downloadSchema = z.object({
downloadUrl: z.string().url().max(2000),
});
// GET /api/community-playlists - Fetch list of community playlists from wiki
router.get(
'/',
optionalAuth,
@@ -27,7 +25,6 @@ router.get(
asyncHandler(async (req, res) => {
const playlists = await communityPlaylistService.fetchCommunityPlaylists();
// Store count for activity logging (sum all playlists across categories)
res.locals.playlistCount = playlists.categories.reduce(
(total, category) => total + category.playlists.length,
0
@@ -37,7 +34,6 @@ router.get(
})
);
// POST /api/community-playlists/download - Download a specific playlist
router.post(
'/download',
optionalAuth,
@@ -48,7 +44,6 @@ router.post(
gameCount: res.locals.gameCount || 0,
})),
asyncHandler(async (req, res) => {
// Validate request body
const { downloadUrl } = downloadSchema.parse(req.body);
try {
@@ -78,7 +73,6 @@ router.post(
throw new AppError(500, 'Failed to download playlist');
}
// Store metadata for activity logging
res.locals.playlistTitle = result.playlist?.title;
res.locals.playlistId = result.playlist?.id;
res.locals.gameCount = result.playlist?.games?.length || 0;
+1 -31
View File
@@ -10,22 +10,7 @@ import { config } from '../config';
const router = Router();
/**
* POST /api/database/reload
* Manually reload database from disk
*
* Useful when Flashpoint Launcher has updated the database
* and you want to see changes immediately without waiting
* for the automatic file watcher.
*
* Requires: Admin permission (settings.update)
*
* Responses:
* - 200: Database reloaded successfully
* - 401: Not authenticated
* - 403: Insufficient permissions
* - 500: Failed to reload database
*/
// Manually trigger reload when changes need to be visible before the file watcher fires
router.post(
'/reload',
authenticate,
@@ -51,21 +36,6 @@ router.post(
})
);
/**
* GET /api/database/status
* Get database connection status and file info
*
* Returns information about the database connection,
* file size, and last modification time.
*
* Requires: Admin permission (settings.update)
*
* Responses:
* - 200: Status information
* - 401: Not authenticated
* - 403: Insufficient permissions
* - 500: Error getting status
*/
router.get(
'/status',
authenticate,
-12
View File
@@ -10,9 +10,6 @@ import { AppError } from '../middleware/errorHandler';
const router = Router();
const domainService = DomainService.getInstance();
// ===================================
// GET ALL DOMAINS
// ===================================
router.get(
'/',
authenticate,
@@ -23,9 +20,6 @@ router.get(
})
);
// ===================================
// ADD DOMAIN
// ===================================
const hostnameSchema = z
.string()
.trim()
@@ -144,9 +138,6 @@ router.post(
})
);
// ===================================
// DELETE DOMAIN
// ===================================
router.delete(
'/:id',
authenticate,
@@ -163,9 +154,6 @@ router.delete(
})
);
// ===================================
// SET DEFAULT DOMAIN
// ===================================
router.patch(
'/:id/default',
authenticate,
+28 -105
View File
@@ -11,9 +11,6 @@ import { logActivity } from '../middleware/activityLogger';
import { asyncHandler } from '../middleware/asyncHandler';
import { logger } from '../utils/logger';
/**
* Zod schema for POST /:id/download request body
*/
const downloadBodySchema = z.object({
gameDataId: z.number().int().positive().optional(),
});
@@ -22,23 +19,6 @@ const router = Router();
const gameService = new GameService();
const activityService = new ActivityService();
/**
* POST /api/games/:id/download
* Start downloading a game's data.
*
* Request body: { gameDataId?: number }
* - If gameDataId not provided, uses game.activeDataId
*
* Requires: games.download permission
*
* Responses:
* - 200: Download started successfully
* - 401: Not authenticated
* - 403: Insufficient permissions
* - 404: Game not found or has no game data
* - 409: Game data already downloaded
* - 500: Download failed
*/
router.post(
'/:id/download',
authenticate,
@@ -52,7 +32,6 @@ router.post(
asyncHandler(async (req: Request, res: Response) => {
const gameId = req.params.id;
// Validate request body
const bodyResult = downloadBodySchema.safeParse(req.body);
if (!bodyResult.success) {
return res.status(400).json({
@@ -65,7 +44,6 @@ router.post(
logger.info('Download request received', { gameId, requestedGameDataId });
// Get game from database
const game = await gameService.getGameById(gameId);
if (!game) {
return res.status(404).json({
@@ -74,10 +52,8 @@ router.post(
});
}
// 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({
@@ -88,7 +64,6 @@ router.post(
gameDataId = foundGameDataId;
}
// Check if already downloaded
const isDownloaded = await GameDatabaseUpdater.isDownloaded(gameDataId);
if (isDownloaded) {
return res.status(409).json({
@@ -97,7 +72,6 @@ router.post(
});
}
// Get game data info
const gameData = await GameDatabaseUpdater.getGameData(gameDataId);
if (!gameData) {
return res.status(404).json({
@@ -106,7 +80,6 @@ router.post(
});
}
// Get data sources from preferences
const sources = await PreferencesService.getGameDataSources();
if (sources.length === 0) {
return res.status(500).json({
@@ -116,12 +89,10 @@ router.post(
});
}
// Store metadata for activity logging
res.locals.gameDataId = gameDataId;
res.locals.gameTitle = game.title;
res.locals.sources = sources.map((s) => s.name);
// Capture metadata for background error logging
const downloadMetadata = {
gameId,
gameDataId,
@@ -133,42 +104,35 @@ router.post(
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,
// Non-blocking: client uses SSE endpoint for progress
DownloadManager.downloadGameData(gameDataId, sources, undefined, undefined, undefined).catch(
async (error) => {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('Background download failed', {
gameId,
gameDataId,
error: errorMessage,
},
ipAddress: downloadMetadata.ipAddress,
userAgent: req.get('user-agent'),
});
});
});
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',
@@ -178,23 +142,6 @@ router.post(
})
);
/**
* GET /api/games/:id/download/progress
* Server-Sent Events endpoint for download progress tracking.
*
* Streams progress updates for active download.
* Closes connection when download completes or errors.
*
* Requires: games.download permission
*
* Event data format:
* {
* percent: number (0-100),
* status: 'downloading' | 'validating' | 'importing' | 'complete' | 'error',
* details: string,
* error?: string
* }
*/
router.get(
'/:id/download/progress',
authenticate,
@@ -202,7 +149,6 @@ router.get(
asyncHandler(async (req: Request, res: Response) => {
const gameId = req.params.id;
// Set SSE headers
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
@@ -210,7 +156,6 @@ router.get(
logger.info('SSE connection established', { gameId });
// Get game to find gameDataId
const game = await gameService.getGameById(gameId);
if (!game) {
res.write(
@@ -235,7 +180,6 @@ router.get(
return;
}
// Send initial status
res.write(
`data: ${JSON.stringify({
percent: 0,
@@ -244,7 +188,6 @@ router.get(
})}\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.
let closed = false;
@@ -253,13 +196,11 @@ router.get(
if (closed) return;
try {
// Check if download is still active
const isActive = DownloadManager.isDownloadActive(gameDataId);
if (closed) return; // Re-check after await
if (!isActive) {
// Check if download completed successfully
const isDownloaded = await GameDatabaseUpdater.isDownloaded(gameDataId);
if (closed) return; // Re-check after await
@@ -275,7 +216,6 @@ router.get(
);
}
// Log download completion activity
const gameData = await GameDatabaseUpdater.getGameData(gameDataId);
if (closed) return; // Re-check after await
@@ -311,7 +251,6 @@ router.get(
res.end();
}
} else {
// Send heartbeat
if (!closed) {
res.write(
`data: ${JSON.stringify({
@@ -331,9 +270,8 @@ router.get(
res.end();
}
}
}, 1000); // Poll every second
}, 1000);
// Clean up on client disconnect
req.on('close', () => {
closed = true;
logger.info('SSE connection closed', { gameId });
@@ -342,19 +280,6 @@ router.get(
})
);
/**
* DELETE /api/games/:id/download
* Cancel an active download.
*
* Requires: games.download permission
*
* Responses:
* - 200: Download cancelled successfully
* - 401: Not authenticated
* - 403: Insufficient permissions
* - 404: No active download found
* - 500: Error cancelling download
*/
router.delete(
'/:id/download',
authenticate,
@@ -365,7 +290,6 @@ router.delete(
logger.info('Download cancel request', { gameId });
// Get game to find gameDataId
const game = await gameService.getGameById(gameId);
if (!game) {
return res.status(404).json({
@@ -382,7 +306,6 @@ router.delete(
});
}
// Attempt to cancel download
const cancelled = DownloadManager.cancelDownload(gameDataId);
if (!cancelled) {
-44
View File
@@ -11,10 +11,6 @@ import { asyncHandler } from '../middleware/asyncHandler';
import { logger } from '../utils/logger';
import { config } from '../config';
/**
* Zod schema for error report validation
* Enforces strict types and size limits on client-submitted error reports
*/
const errorReportSchema = z.object({
type: z.enum(['client_error', 'network_error', 'api_error', 'route_error']),
message: z.string().max(2000),
@@ -32,18 +28,13 @@ const errorReportSchema = z.object({
const router = Router();
// Client error logs go to the same directory as backend logs
const LOG_DIR = config.logFile ? path.dirname(config.logFile) : path.join(__dirname, '../../logs');
const ERROR_LOG_FILE = path.join(LOG_DIR, 'client-errors.log');
// Ensure logs directory exists for file transport at startup
if (!fsSync.existsSync(LOG_DIR)) {
fsSync.mkdirSync(LOG_DIR, { recursive: true });
}
/**
* Map client error types to OTEL severity numbers
*/
function getSeverityNumber(type: string): number {
const severityMap: Record<string, number> = {
client_error: 17, // ERROR
@@ -54,16 +45,12 @@ function getSeverityNumber(type: string): number {
return severityMap[type] || 17;
}
/**
* Client error logger with OTEL-compatible format
*/
const clientErrorLogger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DDTHH:mm:ss.SSSZ' }), // ISO 8601
winston.format.errors({ stack: true }),
winston.format((info) => {
// Add OTEL severity fields based on error type
const errorType = (info.errorType as string) || 'client_error';
info.severity = errorType === 'network_error' ? 'WARN' : 'ERROR';
info.severityNumber = getSeverityNumber(errorType);
@@ -97,9 +84,6 @@ interface ErrorReport {
type AuthenticatedRequest = Request & { user?: { id: number } };
/**
* Log error report to file in OTEL-compatible JSON format
*/
async function logError(report: ErrorReport): Promise<void> {
try {
clientErrorLogger.info(report.message, {
@@ -116,12 +100,8 @@ async function logError(report: ErrorReport): Promise<void> {
}
}
/**
* Read recent error reports from log file
*/
async function getRecentErrors(limit: number = 100): Promise<ErrorReport[]> {
try {
// Stream the file line by line to avoid loading the entire file into memory
const { createReadStream } = await import('fs');
const { createInterface } = await import('readline');
@@ -140,7 +120,6 @@ async function getRecentErrors(limit: number = 100): Promise<ErrorReport[]> {
});
rl.on('close', () => {
// Parse lines newest-first
const errors: ErrorReport[] = [];
for (let i = allLines.length - 1; i >= 0 && errors.length < limit; i--) {
try {
@@ -182,17 +161,11 @@ async function getRecentErrors(limit: number = 100): Promise<ErrorReport[]> {
}
}
/**
* POST /api/errors/report
* Report a client-side error
* Public endpoint (no authentication required, but rate-limited)
*/
router.post(
'/report',
rateLimitStrict,
asyncHandler(async (req: Request, res: Response) => {
try {
// Validate request body with Zod schema
const parseResult = errorReportSchema.safeParse(req.body);
if (!parseResult.success) {
return res.status(400).json({
@@ -206,7 +179,6 @@ router.post(
const validatedData = parseResult.data;
// Build report from validated data
const report: ErrorReport = {
type: validatedData.type,
message: validatedData.message,
@@ -217,13 +189,11 @@ router.post(
context: validatedData.context,
};
// Add user ID if authenticated
const authReq = req as AuthenticatedRequest;
if (authReq.user) {
report.userId = authReq.user.id;
}
// Log to file
await logError(report);
logger.info(`[Client Error] ${report.type} - ${report.message} (${report.url})`);
@@ -242,11 +212,6 @@ router.post(
})
);
/**
* GET /api/errors/recent
* Get recent client error reports
* Requires: settings.update permission (admin only)
*/
router.get(
'/recent',
authenticate,
@@ -277,11 +242,6 @@ router.get(
})
);
/**
* GET /api/errors/stats
* Get error statistics for dashboard
* Requires: settings.update permission (admin only)
*/
router.get(
'/stats',
authenticate,
@@ -290,7 +250,6 @@ router.get(
try {
const errors = await getRecentErrors(1000);
// Calculate stats
const now = new Date();
const last24h = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const last7d = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
@@ -304,17 +263,14 @@ router.get(
topMessages: {} as Record<string, number>,
};
// Group by type
for (const error of errors) {
stats.byType[error.type] = (stats.byType[error.type] || 0) + 1;
stats.topUrls[error.url] = (stats.topUrls[error.url] || 0) + 1;
// Truncate message for grouping
const shortMessage = error.message.substring(0, 100);
stats.topMessages[shortMessage] = (stats.topMessages[shortMessage] || 0) + 1;
}
// Sort and limit top items
const sortAndLimit = (obj: Record<string, number>, limit: number) => {
return Object.entries(obj)
.sort((a, b) => b[1] - a[1])
+2 -49
View File
@@ -11,14 +11,8 @@ import { z } from 'zod';
const router = Router();
const favoritesService = new FavoritesService();
// Global softAuth (from server.ts) already populates req.user for all routes
// No need to apply softAuth again at router level
// Apply feature flag check to all routes in this router
// Admins with settings.update permission will bypass this check (via global softAuth)
router.use(requireFeature('enableFavorites'));
// Validation schemas
const toggleFavoriteSchema = z.object({
gameId: z.string(),
});
@@ -31,10 +25,6 @@ const batchRemoveSchema = z.object({
gameIds: z.array(z.string()).min(1).max(100),
});
/**
* GET /api/favorites
* Get all favorites for the authenticated user
*/
router.get(
'/',
authenticate,
@@ -56,10 +46,6 @@ router.get(
})
);
/**
* GET /api/favorites/game-ids
* Get favorite game IDs for the authenticated user
*/
router.get(
'/game-ids',
authenticate,
@@ -74,10 +60,6 @@ router.get(
})
);
/**
* GET /api/favorites/games
* Get favorite games with full game data for the authenticated user
*/
router.get(
'/games',
authenticate,
@@ -94,14 +76,12 @@ router.get(
? Math.max(0, parseInt(req.query.offset as string, 10) || 0)
: undefined;
// Validate sortBy
const allowedSortBy = ['title', 'dateAdded'];
const sortBy =
req.query.sortBy && allowedSortBy.includes(req.query.sortBy as string)
? (req.query.sortBy as 'title' | 'dateAdded')
: 'dateAdded';
// Validate sortOrder
const sortOrder = req.query.sortOrder === 'asc' ? 'asc' : 'desc';
const games = await favoritesService.getUserFavoriteGames(
@@ -115,10 +95,6 @@ router.get(
})
);
/**
* GET /api/favorites/stats
* Get favorites statistics for the authenticated user
*/
router.get(
'/stats',
authenticate,
@@ -133,10 +109,6 @@ router.get(
})
);
/**
* POST /api/favorites/toggle
* Toggle favorite status for a game
*/
router.post(
'/toggle',
authenticate,
@@ -154,10 +126,6 @@ router.post(
})
);
/**
* POST /api/favorites
* Add a game to favorites
*/
router.post(
'/',
authenticate,
@@ -175,10 +143,6 @@ router.post(
})
);
/**
* POST /api/favorites/batch
* Batch add favorites
*/
router.post(
'/batch',
authenticate,
@@ -196,11 +160,8 @@ router.post(
})
);
/**
* DELETE /api/favorites/batch
* Batch remove favorites
* NOTE: Static route must be registered before parameterized /:gameId route
*/
// Must be registered before /:gameId to avoid being shadowed
router.delete(
'/batch',
authenticate,
@@ -218,10 +179,6 @@ router.delete(
})
);
/**
* DELETE /api/favorites/:gameId
* Remove a game from favorites
*/
router.delete(
'/:gameId',
authenticate,
@@ -243,10 +200,6 @@ router.delete(
})
);
/**
* DELETE /api/favorites
* Clear all favorites for the authenticated user
*/
router.delete(
'/',
authenticate,
+2 -7
View File
@@ -15,18 +15,14 @@ function getHandler(): ProxyRequestHandler {
return proxyHandler;
}
// Handle all game content requests: /game-proxy/*
// Express strips the mount path, so req.url is everything after /game-proxy
// e.g., /game-proxy/http://domain.com/file.swf → req.url = /http://domain.com/file.swf
router.all(
'/*',
asyncHandler(async (req: Request, res: Response) => {
const handler = getHandler();
// OPTIONS requests for CORS preflight
if (req.method === 'OPTIONS') {
// Cast Express types to Node HTTP types for compatibility
// Double cast needed: Express.Request doesn't directly extend IncomingMessage
// Double cast: Express.Request doesn't directly extend IncomingMessage
handler.handleOptionsRequest(
req as unknown as IncomingMessage,
res as unknown as ServerResponse
@@ -34,8 +30,7 @@ router.all(
return;
}
// Delegate to ProxyRequestHandler
// Double cast needed: Express.Request doesn't directly extend IncomingMessage
// Double cast: Express.Request doesn't directly extend IncomingMessage
await handler.handleRequest(
req as unknown as IncomingMessage,
res as unknown as ServerResponse
+1 -10
View File
@@ -8,9 +8,6 @@ import { requirePermission } from '../middleware/rbac';
import { config } from '../config';
import { asyncHandler } from '../middleware/asyncHandler';
/**
* Zod schema for POST /mount/:id request body
*/
const mountBodySchema = z.object({
zipPath: z.string().min(1).max(500),
gameId: z.string().optional(),
@@ -20,7 +17,7 @@ const mountBodySchema = z.object({
const router = Router();
// Restrictive CORS middleware for admin routes
// Restrictive CORS: only allow requests from the configured domain (admin routes)
const adminCors = (req: Request, res: Response, next: NextFunction) => {
res.setHeader('Access-Control-Allow-Origin', config.domain);
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
@@ -33,7 +30,6 @@ const adminCors = (req: Request, res: Response, next: NextFunction) => {
next();
};
// Mount a ZIP: POST /game-zip/mount/:id
router.post(
'/mount/:id',
adminCors,
@@ -41,7 +37,6 @@ router.post(
requirePermission('settings.update'),
json(),
asyncHandler(async (req: Request, res: Response) => {
// Validate request body
const bodyResult = mountBodySchema.safeParse(req.body);
if (!bodyResult.success) {
return res.status(400).json({
@@ -63,7 +58,6 @@ router.post(
})
);
// Unmount a ZIP: DELETE /game-zip/mount/:id
router.delete(
'/mount/:id',
adminCors,
@@ -75,7 +69,6 @@ router.delete(
})
);
// List mounted ZIPs: GET /game-zip/mounts
router.get(
'/mounts',
adminCors,
@@ -86,13 +79,11 @@ router.get(
}
);
// Serve files from mounted ZIPs: GET /game-zip/*
// Handles proxy-style URLs like /game-zip/http://domain.com/path
router.get(
'/*',
asyncHandler(async (req: Request, res: Response) => {
try {
// Delegate to gameZipServer's file handler
await gameZipServer.handleFileRequest(
req as unknown as IncomingMessage,
res as unknown as ServerResponse
+13 -44
View File
@@ -13,28 +13,24 @@ import { z } from 'zod';
const router = Router();
const gameService = new GameService();
// Apply rate limiting to prevent abuse
router.use(rateLimitStandard);
// Apply shared access auth middleware to all routes
router.use(sharedAccessAuth);
// Custom boolean parser that handles string 'false' correctly
// z.coerce.boolean() treats 'false' as truthy; this handles string→boolean correctly
const booleanSchema = z.preprocess((val) => {
if (val === 'false' || val === '0' || val === false) return false;
if (val === 'true' || val === '1' || val === true) return true;
return val;
}, z.boolean());
// Validation schemas
const searchQuerySchema = z.object({
search: z.string().optional(),
platform: z.string().optional(), // Comma-separated platforms
series: z.string().optional(), // Comma-separated series
developers: z.string().optional(), // Comma-separated developers
publishers: z.string().optional(), // Comma-separated publishers
playModes: z.string().optional(), // Comma-separated play modes
languages: z.string().optional(), // Comma-separated languages
platform: z.string().optional(),
series: z.string().optional(),
developers: z.string().optional(),
publishers: z.string().optional(),
playModes: z.string().optional(),
languages: z.string().optional(),
library: z.enum(['arcade', 'theatre']).optional(),
tags: z.string().optional(),
yearFrom: z.coerce.number().int().min(1970).max(2100).optional(),
@@ -51,11 +47,9 @@ const searchQuerySchema = z.object({
showExtreme: booleanSchema.default(false),
});
// GET /api/games - List games with pagination and filters
router.get(
'/',
logActivity('games.search', 'games', (req, res) => {
// Count active filters
const filters = [
'platform',
'series',
@@ -80,7 +74,6 @@ router.get(
asyncHandler(async (req, res) => {
const query = searchQuerySchema.parse(req.query);
// Use cached search for performance (5-minute TTL)
const result = await GameSearchCache.searchGames({
search: query.search,
platforms: query.platform?.split(',').filter(Boolean),
@@ -101,17 +94,15 @@ router.get(
limit: query.limit,
showBroken: query.showBroken,
showExtreme: query.showExtreme,
fields: 'list', // Only return essential columns for browse/search views
fields: 'list',
});
// Store result count for activity logging
res.locals.resultCount = result.total;
res.json(result);
})
);
// GET /api/games/filter-options - Get all filter options for dropdowns
router.get(
'/filter-options',
asyncHandler(async (req, res) => {
@@ -120,7 +111,6 @@ router.get(
})
);
// GET /api/games/most-played - Get most played games globally
router.get(
'/most-played',
asyncHandler(async (req, res) => {
@@ -135,8 +125,7 @@ router.get(
})
);
// GET /api/games/random - Get random game
// IMPORTANT: Must be defined BEFORE /:id to avoid being shadowed by the wildcard
// Must be defined BEFORE /:id to avoid being shadowed
router.get(
'/random',
asyncHandler(async (req, res) => {
@@ -152,7 +141,6 @@ router.get(
})
);
// GET /api/games/:id - Get single game by ID
router.get(
'/:id',
validateSharedGameAccess('id'),
@@ -169,14 +157,12 @@ router.get(
throw new AppError(404, 'Game not found');
}
// Store game for activity logging
res.locals.game = game;
res.json(game);
})
);
// GET /api/games/:id/related - Get related games
router.get(
'/:id/related',
validateSharedGameAccess('id'),
@@ -188,7 +174,6 @@ router.get(
})
);
// GET /api/games/:id/launch - Get game launch data
router.get(
'/:id/launch',
validateSharedGameAccess('id'),
@@ -204,15 +189,14 @@ router.get(
throw new AppError(404, 'Game not found');
}
// Mount the game ZIP (awaited so local ZIPs are ready before we return the URL).
// For downloads, mountGameZip returns quickly with { mounted: false, downloading: true }.
// Awaited so local ZIPs are ready before we return the URL.
// For downloads, returns quickly with { mounted: false, downloading: true }.
const mountResult = await gameDataService.mountGameZip(game.id);
const downloading = mountResult.downloading || false;
// Extract launch command (usually the SWF file path for Flash games)
let launchCommand = game.launchCommand || '';
// If no launch command, try to get path from game_data table
// Fallback: get path from game_data table
if (!launchCommand) {
const gameDataPath = await gameService.getGameDataPath(game.id);
if (gameDataPath) {
@@ -220,34 +204,23 @@ router.get(
}
}
// For Flash games, the launch command is typically the SWF file path
// For HTML5 games, it's usually an index.html file
// The path is relative to the htdocs folder OR an absolute URL
let contentUrl = '';
if (launchCommand) {
// Game content is served via /game-proxy route (integrated in backend)
const proxyUrl = '/game-proxy';
// Check if launch command is an absolute URL
if (launchCommand.startsWith('http://') || launchCommand.startsWith('https://')) {
// Use the full URL as-is with the proxy
contentUrl = `${proxyUrl}/${launchCommand}`;
} else {
// For relative paths, construct full URL with domain from source
// Relative paths need a domain derived from game.source
let fullUrl = launchCommand;
if (game.source) {
try {
// Extract domain from source URL
const sourceUrl = new URL(game.source);
const domain = sourceUrl.hostname;
// Construct full URL: http://domain/path
const urlPath = launchCommand.startsWith('/') ? launchCommand : `/${launchCommand}`;
fullUrl = `http://${domain}${urlPath}`;
} catch {
// If source is not a valid URL, assume launchCommand is already domain/path
fullUrl = launchCommand.startsWith('http') ? launchCommand : `http://${launchCommand}`;
}
}
@@ -258,13 +231,9 @@ router.get(
const hasContent = launchCommand.trim().length > 0;
// Game can be played in browser if:
// 1. It's a web-playable platform (Flash or HTML5)
// 2. It has content (launch command)
const isWebPlayablePlatform = game.platformName === 'Flash' || game.platformName === 'HTML5';
const canPlayInBrowser = isWebPlayablePlatform && hasContent;
// Store in res.locals for activity logging
res.locals.platform = game.platformName;
res.locals.canPlayInBrowser = canPlayInBrowser;
res.locals.launchCommand = launchCommand;
+2 -9
View File
@@ -6,18 +6,12 @@ const router = Router();
// In-memory cache for GitHub star count (avoid hitting 60/hr unauthenticated rate limit)
let cachedStars: { count: number; fetchedAt: number } | null = null;
const CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes
const CACHE_TTL_MS = 15 * 60 * 1000;
/**
* GET /api/github/stars
* Proxy endpoint for fetching GitHub star count
* Avoids CORS issues when calling GitHub API from browser
* Cached for 15 minutes to respect GitHub rate limits (60/hr unauthenticated)
*/
// Proxy to avoid browser CORS issues with GitHub API
router.get(
'/stars',
asyncHandler(async (_req: Request, res: Response) => {
// Return cached value if still fresh
if (cachedStars && Date.now() - cachedStars.fetchedAt < CACHE_TTL_MS) {
return res.json({
success: true,
@@ -48,7 +42,6 @@ router.get(
const data = (await response.json()) as { stargazers_count: number };
// Update cache
cachedStars = { count: data.stargazers_count, fetchedAt: Date.now() };
res.json({
+1 -26
View File
@@ -9,12 +9,7 @@ import { requirePermission } from '../middleware/rbac';
const router = Router();
/**
* GET /api/health
* Basic health check - publicly accessible
* Returns minimal status information
* NOTE: A simpler /health endpoint also exists for load balancer checks (registered before middleware)
*/
// A simpler /health endpoint also exists for load balancer checks (registered before middleware)
router.get('/', (req, res) => {
const isHealthy = DatabaseService.isConnected() && UserDatabaseService.isConnected();
@@ -24,11 +19,6 @@ router.get('/', (req, res) => {
});
});
/**
* GET /api/health/detailed
* Detailed health check - requires authentication and admin permissions
* Returns comprehensive system status including database connections, cache stats, and uptime
*/
router.get(
'/detailed',
authenticate,
@@ -36,14 +26,12 @@ router.get(
asyncHandler(async (req, res) => {
const startTime = performance.now();
// Check Flashpoint database connection
let flashpointDbStatus: 'connected' | 'error' = 'error';
let flashpointDbError: string | null = null;
let flashpointDbRecordCount = 0;
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;
@@ -55,14 +43,12 @@ router.get(
flashpointDbError = error instanceof Error ? error.message : String(error);
}
// Check User database connection
let userDbStatus: 'connected' | 'error' = 'error';
let userDbError: string | null = null;
let userCount = 0;
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;
@@ -74,20 +60,16 @@ router.get(
userDbError = error instanceof Error ? error.message : String(error);
}
// Get cache statistics
const permissionCacheStats = {
enabled: true,
ttl: '5 minutes',
// Note: PermissionCache doesn't expose size, just that it's active
};
const gameSearchCacheStats = GameSearchCache.getStats();
// Calculate uptime
const uptimeSeconds = Math.floor(process.uptime());
const uptimeFormatted = formatUptime(uptimeSeconds);
// Get memory usage
const memoryUsage = process.memoryUsage();
const memoryFormatted = {
rss: formatBytes(memoryUsage.rss),
@@ -98,7 +80,6 @@ router.get(
const healthCheckTime = Math.round(performance.now() - startTime);
// Determine overall health status
const isHealthy = flashpointDbStatus === 'connected' && userDbStatus === 'connected';
res.status(isHealthy ? 200 : 503).json({
@@ -148,9 +129,6 @@ router.get(
})
);
/**
* Format uptime in human-readable format
*/
function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
@@ -166,9 +144,6 @@ function formatUptime(seconds: number): string {
return parts.join(' ');
}
/**
* Format bytes in human-readable format
*/
function formatBytes(bytes: number): string {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
-29
View File
@@ -29,51 +29,24 @@ import cacheRouter from './cache';
import metricsRouter from './metrics';
export function setupRoutes(app: Express): void {
// Health check routes
app.use('/api/health', healthRouter);
// Cache management routes
app.use('/api/cache', cacheRouter);
// Performance metrics routes
app.use('/api/metrics', metricsRouter);
// Authentication routes
app.use('/api/auth', authRouter);
// Error reporting routes
app.use('/api/errors', errorsRouter);
// Settings routes
app.use('/api/settings/auth', authSettingsRouter);
app.use('/api/settings', systemSettingsRouter);
// Ruffle management routes
app.use('/api/ruffle', ruffleRouter);
// GitHub integration routes
app.use('/api/github', githubRouter);
// Domain settings routes
app.use('/api/domains', domainsRouter);
// Jobs routes
app.use('/api/jobs', jobsRouter);
// User management routes
app.use('/api/users', usersRouter);
app.use('/api/roles', rolesRouter);
app.use('/api/activities', activitiesRouter);
// Play tracking routes
app.use('/api/play', playTrackingRouter);
// User playlists and favorites
app.use('/api/user-playlists', userPlaylistsRouter);
app.use('/api/playlists/shared', sharedPlaylistsRouter);
app.use('/api/favorites', favoritesRouter);
// API routes
app.use('/api/games', gamesRouter);
app.use('/api/games', downloadsRouter);
app.use('/api/platforms', platformsRouter);
@@ -83,10 +56,8 @@ export function setupRoutes(app: Express): void {
app.use('/api/updates', updatesRouter);
app.use('/api/statistics', statisticsRouter);
app.use('/api/database', databaseRouter);
app.use('/proxy', proxyRouter);
// 404 handler
app.use('*', (req, res) => {
res.status(404).json({
error: {
-30
View File
@@ -12,9 +12,6 @@ const router = Router();
const executionService = new JobExecutionService();
const systemSettings = CachedSystemSettingsService.getInstance();
// ===================================
// GET ALL JOBS WITH STATUS
// ===================================
router.get(
'/',
authenticate,
@@ -30,9 +27,6 @@ router.get(
})
);
// ===================================
// GET ALL EXECUTION LOGS
// ===================================
router.get(
'/logs/all',
authenticate,
@@ -51,9 +45,6 @@ router.get(
})
);
// ===================================
// GET SPECIFIC JOB STATUS
// ===================================
router.get(
'/:jobId',
authenticate,
@@ -75,9 +66,6 @@ router.get(
})
);
// ===================================
// UPDATE JOB SETTINGS (ENABLE/DISABLE)
// ===================================
router.patch(
'/:jobId',
authenticate,
@@ -88,35 +76,29 @@ router.patch(
const { jobId } = req.params;
const { enabled } = req.body;
// Validate request body
if (typeof enabled !== 'boolean') {
return res.status(400).json({
error: { message: 'Invalid request: enabled must be a boolean' },
});
}
// Verify job exists
const job = JobScheduler.getJobStatusEnriched(jobId);
if (!job) {
return res.status(404).json({ error: { message: 'Job not found' } });
}
// Update the job's enabled state in system_settings
const settingKey = `jobs.${jobId.replaceAll('-', '_')}_enabled`;
const userId = req.user!.id;
systemSettings.set(settingKey, enabled ? '1' : '0', userId);
// Update the job in JobScheduler
JobScheduler.updateJobEnabled(jobId, enabled);
// Start or stop the job based on enabled state
if (enabled) {
JobScheduler.startJob(jobId);
} else {
JobScheduler.stopJob(jobId);
}
// Return updated job status
const updatedJob = JobScheduler.getJobStatusEnriched(jobId);
res.json(updatedJob);
} catch (error) {
@@ -128,9 +110,6 @@ router.patch(
})
);
// ===================================
// START A JOB
// ===================================
router.post(
'/:jobId/start',
authenticate,
@@ -151,9 +130,6 @@ router.post(
})
);
// ===================================
// STOP A JOB
// ===================================
router.post(
'/:jobId/stop',
authenticate,
@@ -174,9 +150,6 @@ router.post(
})
);
// ===================================
// TRIGGER MANUAL JOB EXECUTION
// ===================================
router.post(
'/:jobId/trigger',
authenticate,
@@ -205,9 +178,6 @@ router.post(
})
);
// ===================================
// GET EXECUTION LOGS FOR SPECIFIC JOB
// ===================================
router.get(
'/:jobId/logs',
authenticate,
-30
View File
@@ -6,11 +6,6 @@ import { asyncHandler } from '../middleware/asyncHandler';
const router = Router();
/**
* GET /api/metrics/summary
* Get performance metrics summary
* Requires: settings.view permission
*/
router.get(
'/summary',
authenticate,
@@ -25,11 +20,6 @@ router.get(
})
);
/**
* GET /api/metrics/endpoints
* Get detailed endpoint statistics
* Requires: settings.view permission
*/
router.get(
'/endpoints',
authenticate,
@@ -44,11 +34,6 @@ router.get(
})
);
/**
* GET /api/metrics/endpoints/slowest
* Get slowest endpoints
* Requires: settings.view permission
*/
router.get(
'/endpoints/slowest',
authenticate,
@@ -64,11 +49,6 @@ router.get(
})
);
/**
* GET /api/metrics/caches
* Get cache hit rates
* Requires: settings.view permission
*/
router.get(
'/caches',
authenticate,
@@ -83,11 +63,6 @@ router.get(
})
);
/**
* GET /api/metrics/queries
* Get database query statistics
* Requires: settings.view permission
*/
router.get(
'/queries',
authenticate,
@@ -102,11 +77,6 @@ router.get(
})
);
/**
* POST /api/metrics/reset
* Reset all metrics
* Requires: settings.update permission
*/
router.post(
'/reset',
authenticate,
-4
View File
@@ -6,13 +6,9 @@ import { rateLimitStandard } from '../middleware/rateLimiter';
const router = Router();
// Apply rate limiting to prevent abuse
router.use(rateLimitStandard);
// Apply optional auth middleware to all routes
router.use(optionalAuth);
// GET /api/platforms - List all platforms
router.get(
'/',
asyncHandler(async (req, res) => {
-39
View File
@@ -11,7 +11,6 @@ import { z } from 'zod';
const router = Router();
const playTrackingService = new PlayTrackingService();
// Validation schemas
const startPlaySessionSchema = z.object({
gameId: z.string().min(1).max(36),
gameTitle: z.string().min(1).max(255),
@@ -21,7 +20,6 @@ const endPlaySessionSchema = z.object({
sessionId: z.string().uuid(),
});
// Pagination and limit constants
const LIMITS = {
GAME_STATS_DEFAULT: 50,
GAME_STATS_MAX: 100,
@@ -35,18 +33,10 @@ const LIMITS = {
GAMES_DISTRIBUTION_MAX: 20,
} as const;
// Apply feature flag check to all routes in this router
router.use(requireFeature('enableStatistics'));
// All routes require authentication and games.play permission
// Note: authenticate middleware guarantees req.user is populated
router.use(authenticate);
router.use(requirePermission('games.play'));
/**
* POST /api/play/start
* Start a new play session
*/
router.post(
'/start',
logActivity('play.start', 'games', (req, res) => ({
@@ -59,7 +49,6 @@ router.post(
const sessionId = await playTrackingService.startPlaySession(req.user!.id, gameId, gameTitle);
// Store sessionId for activity logging
res.locals.sessionId = sessionId;
res.json({
@@ -69,10 +58,6 @@ router.post(
})
);
/**
* POST /api/play/end
* End a play session
*/
router.post(
'/end',
logActivity('play.end', 'games'),
@@ -88,10 +73,6 @@ router.post(
})
);
/**
* GET /api/play/stats
* Get current user's overall stats
*/
router.get(
'/stats',
asyncHandler(async (req: Request, res: Response) => {
@@ -101,10 +82,6 @@ router.get(
})
);
/**
* GET /api/play/game-stats
* Get current user's game-specific stats
*/
router.get(
'/game-stats',
asyncHandler(async (req: Request, res: Response) => {
@@ -124,10 +101,6 @@ router.get(
})
);
/**
* GET /api/play/history
* Get current user's play history
*/
router.get(
'/history',
asyncHandler(async (req: Request, res: Response) => {
@@ -147,10 +120,6 @@ router.get(
})
);
/**
* GET /api/play/top-games
* Get current user's top played games
*/
router.get(
'/top-games',
asyncHandler(async (req: Request, res: Response) => {
@@ -165,10 +134,6 @@ router.get(
})
);
/**
* GET /api/play/activity-over-time
* Get play activity over time (daily aggregation)
*/
router.get(
'/activity-over-time',
asyncHandler(async (req: Request, res: Response) => {
@@ -183,10 +148,6 @@ router.get(
})
);
/**
* GET /api/play/games-distribution
* Get games distribution by playtime
*/
router.get(
'/games-distribution',
asyncHandler(async (req: Request, res: Response) => {
-14
View File
@@ -15,14 +15,8 @@ import { z } from 'zod';
const router = Router();
const playlistService = new PlaylistService();
// Global softAuth (from server.ts) already populates req.user for all routes
// No need to apply softAuth again at router level
// Apply feature flag check to all routes in this router
// Admins with settings.update permission will bypass this check (via global softAuth)
router.use(requireFeature('enablePlaylists'));
// Validation schemas
const createPlaylistSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
@@ -32,8 +26,6 @@ const addGamesToPlaylistSchema = z.object({
gameIds: z.array(z.string()).min(1).max(100),
});
// GET /api/playlists - List all playlists
// No additional auth needed - softAuth already runs at router level
router.get(
'/',
asyncHandler(async (req, res) => {
@@ -42,8 +34,6 @@ router.get(
})
);
// 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) => {
@@ -57,7 +47,6 @@ router.get(
})
);
// POST /api/playlists - Create new playlist
router.post(
'/',
authenticate,
@@ -71,7 +60,6 @@ router.post(
})
);
// POST /api/playlists/:id/games - Add games to playlist
router.post(
'/:id/games',
authenticate,
@@ -93,7 +81,6 @@ router.post(
})
);
// DELETE /api/playlists/:id/games - Remove games from playlist
router.delete(
'/:id/games',
authenticate,
@@ -115,7 +102,6 @@ router.delete(
})
);
// DELETE /api/playlists/:id - Delete playlist
router.delete(
'/:id',
authenticate,
+5 -41
View File
@@ -9,31 +9,19 @@ import axios from 'axios';
const router = Router();
/**
* Validate path to prevent directory traversal attacks
* @param requestedPath - The path from the request
* @param allowedBasePath - The allowed base directory
* @returns Validated safe path or null if invalid
*/
/** Prevent directory traversal: returns resolved path if within base, null otherwise */
function validatePath(requestedPath: string, allowedBasePath: string): string | null {
try {
// Reject null bytes (prevents path truncation attacks)
if (requestedPath.includes('\0')) {
logger.warn(`[Security] Null byte in path rejected: ${requestedPath}`);
return null;
}
// Normalize the requested path to remove . and .. sequences
const normalizedPath = path.normalize(requestedPath).replace(/^(\.\.(\/|\\|$))+/, '');
// Join with base path
const fullPath = path.join(allowedBasePath, normalizedPath);
// Resolve to absolute path
const resolvedPath = path.resolve(fullPath);
const resolvedBase = path.resolve(allowedBasePath);
// Verify the resolved path is within the allowed directory
if (!resolvedPath.startsWith(resolvedBase)) {
logger.warn(`[Security] Path traversal attempt detected: ${requestedPath}`);
return null;
@@ -46,12 +34,9 @@ function validatePath(requestedPath: string, allowedBasePath: string): string |
}
}
// Track in-progress cache writes to prevent duplicate concurrent writes for same file
// Prevent duplicate concurrent cache writes for the same file
const pendingCacheWrites = new Set<string>();
/**
* Helper function to serve file with external CDN fallback
*/
async function serveFileWithFallback(
localPath: string,
relativePath: string,
@@ -60,14 +45,12 @@ async function serveFileWithFallback(
next: NextFunction
) {
try {
// Try local file first
if (fs.existsSync(localPath)) {
logger.debug(`[Proxy] Serving local file: ${localPath}`);
res.setHeader('X-Content-Type-Options', 'nosniff');
return res.sendFile(localPath);
}
// Try external CDN fallback
logger.debug(`[Proxy] Local file not found, trying external CDN: ${relativePath}`);
for (const baseUrl of externalBaseUrls) {
@@ -77,8 +60,8 @@ async function serveFileWithFallback(
const response = await axios.get(externalUrl, {
responseType: 'arraybuffer',
timeout: 10000, // 10 second timeout
maxContentLength: 10 * 1024 * 1024, // 10MB max
timeout: 10000,
maxContentLength: 10 * 1024 * 1024,
maxBodyLength: 10 * 1024 * 1024,
validateStatus: (status) => status === 200,
});
@@ -88,8 +71,6 @@ async function serveFileWithFallback(
const imageBuffer = Buffer.from(response.data);
// Cache the image locally for future use (async, don't wait)
// Skip if another request is already caching this file
if (!pendingCacheWrites.has(localPath)) {
pendingCacheWrites.add(localPath);
(async () => {
@@ -111,7 +92,6 @@ async function serveFileWithFallback(
});
}
// Determine content type from file extension (not from CDN response)
const ext = path.extname(relativePath).slice(1).toLowerCase();
const mimeMap: Record<string, string> = {
jpg: 'image/jpeg',
@@ -135,11 +115,9 @@ async function serveFileWithFallback(
logger.debug(
`[Proxy] Failed to fetch from ${baseUrl}: ${err instanceof Error ? err.message : 'Unknown error'}`
);
// Continue to next URL
}
}
// If all attempts failed, return 404
logger.warn(`[Proxy] Image not found locally or on CDN: ${relativePath}`);
res.status(404).json({
error: {
@@ -152,16 +130,11 @@ async function serveFileWithFallback(
}
}
// Note: Game content is now served directly via /game-proxy and /game-zip routes
// (registered in server.ts before middleware). No proxy to external game-service needed.
// Serve images with CDN fallback
router.get(
'/images/:path(*)',
asyncHandler(async (req, res, next) => {
const relativePath = req.params.path;
// Validate path to prevent directory traversal
const localPath = validatePath(relativePath, config.flashpointImagesPath);
if (!localPath) {
@@ -173,20 +146,17 @@ router.get(
});
}
// Get external image URLs dynamically from preferences
const externalImageUrls = await getExternalImageUrls();
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;
// Validate path to prevent directory traversal
const localPath = validatePath(relativePath, config.flashpointLogosPath);
if (!localPath) {
@@ -198,20 +168,17 @@ router.get(
});
}
// Get external image URLs dynamically from preferences
const externalImageUrls = await getExternalImageUrls();
// For logos, derive external Logos URL from image base URLs
// Derive Logos URL from image base URLs by replacing last path segment
const externalUrls = externalImageUrls.map((url) => {
try {
const parsed = new URL(url);
// Replace last path segment with 'Logos'
const segments = parsed.pathname.replace(/\/$/, '').split('/');
segments[segments.length - 1] = 'Logos';
parsed.pathname = segments.join('/');
return parsed.toString().replace(/\/$/, '');
} catch {
// Fallback: use simple parent + Logos approach
return url.replace(/\/[^/]*\/?$/, '/Logos');
}
});
@@ -220,13 +187,11 @@ router.get(
})
);
// Serve screenshots with CDN fallback
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);
if (!localPath) {
@@ -238,7 +203,6 @@ router.get(
});
}
// Get external image URLs dynamically from preferences
const externalImageUrls = await getExternalImageUrls();
await serveFileWithFallback(localPath, relativePath, externalImageUrls, res, next);
-29
View File
@@ -10,7 +10,6 @@ import { z } from 'zod';
const router = Router();
const roleService = new RoleService();
// Validation schemas
const createRoleSchema = z.object({
name: z.string().min(3).max(50),
description: z.string().optional(),
@@ -28,10 +27,6 @@ const updatePermissionsSchema = z.object({
permissionIds: z.array(z.number().int().positive()),
});
/**
* GET /api/roles
* List all roles with their permissions
*/
router.get(
'/',
authenticate,
@@ -43,10 +38,6 @@ router.get(
})
);
/**
* GET /api/roles/permissions
* List all available permissions
*/
router.get(
'/permissions',
authenticate,
@@ -57,10 +48,6 @@ router.get(
})
);
/**
* GET /api/roles/:id
* Get single role by ID with permissions
*/
router.get(
'/:id',
authenticate,
@@ -82,10 +69,6 @@ router.get(
})
);
/**
* POST /api/roles
* Create new role with permissions
*/
router.post(
'/',
authenticate,
@@ -105,10 +88,6 @@ router.post(
})
);
/**
* PATCH /api/roles/:id
* Update role metadata (name, description, priority)
*/
router.patch(
'/:id',
authenticate,
@@ -128,10 +107,6 @@ router.patch(
})
);
/**
* PUT /api/roles/:id/permissions
* Update role permissions (replaces all permissions)
*/
router.put(
'/:id/permissions',
authenticate,
@@ -152,10 +127,6 @@ router.put(
})
);
/**
* DELETE /api/roles/:id
* Delete role (prevents deleting system roles)
*/
router.delete(
'/:id',
authenticate,
-12
View File
@@ -7,10 +7,6 @@ import { asyncHandler } from '../middleware/asyncHandler';
const router = Router();
const ruffleService = new RuffleService();
/**
* GET /api/ruffle/version
* Get current Ruffle version (public endpoint)
*/
router.get('/version', (req, res, next) => {
try {
const currentVersion = ruffleService.getCurrentVersion();
@@ -25,10 +21,6 @@ router.get('/version', (req, res, next) => {
}
});
/**
* GET /api/ruffle/check-update
* Check for Ruffle updates (admin only)
*/
router.get(
'/check-update',
authenticate,
@@ -39,10 +31,6 @@ router.get(
})
);
/**
* POST /api/ruffle/update
* Update Ruffle to latest version (admin only)
*/
router.post(
'/update',
authenticate,
+2 -39
View File
@@ -15,25 +15,18 @@ const router = Router();
const playlistService = new UserPlaylistService();
const userService = new UserService();
// Validation schema
const clonePlaylistSchema = z.object({
newTitle: z.string().min(1).max(255).optional(),
});
/**
* GET /api/playlists/shared/:shareToken
* Get shared playlist metadata (anonymous access)
*
* Rate limited to 10 requests/minute per IP to prevent scraping
*/
router.get(
'/:shareToken',
rateLimitStrict, // 10 req/min per IP
rateLimitStrict,
validateSharedPlaylist,
asyncHandler(async (req, res) => {
const playlist = req.sharedPlaylist!;
// Sanitize response - don't expose sensitive fields
// Don't expose userId, shareToken, isPublic, shareExpiresAt, showOwner
const response: {
id: number;
title: string;
@@ -53,25 +46,17 @@ router.get(
updatedAt: playlist.updatedAt,
};
// Conditionally include owner username if showOwner flag is true
if (playlist.showOwner) {
const user = await userService.getUserById(playlist.userId);
response.ownerUsername = user?.username || 'Unknown';
}
// Don't expose: userId, shareToken, isPublic, shareExpiresAt, showOwner
logger.debug(`Shared playlist ${playlist.id} accessed via token`);
res.json(response);
})
);
/**
* GET /api/playlists/shared/:shareToken/games
* Get games in shared playlist (anonymous access)
*
* Rate limited to prevent scraping
*/
router.get(
'/:shareToken/games',
rateLimitStrict,
@@ -87,13 +72,6 @@ router.get(
})
);
/**
* GET /api/playlists/shared/:shareToken/games/:gameId/validate
* Validate if a game is accessible via shared playlist (anonymous access)
*
* Returns: { valid: boolean }
* Rate limited to prevent abuse
*/
router.get(
'/:shareToken/games/:gameId/validate',
rateLimitStrict,
@@ -101,13 +79,11 @@ router.get(
asyncHandler(async (req, res) => {
const { shareToken, gameId } = req.params;
// Validate UUID format for shareToken
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!UUID_REGEX.test(shareToken)) {
return res.json({ valid: false });
}
// Validate gameId format (Flashpoint uses UUIDs for game IDs)
if (!gameId || typeof gameId !== 'string') {
return res.json({ valid: false });
}
@@ -120,12 +96,6 @@ router.get(
})
);
/**
* POST /api/playlists/shared/:shareToken/generate-access-token
* Generate a temporary shared access token (60 min expiry)
* Returns: { accessToken, expiresIn, playlistId }
* Rate limited to prevent abuse
*/
router.post(
'/:shareToken/generate-access-token',
rateLimitStrict,
@@ -148,19 +118,12 @@ router.post(
})
);
/**
* POST /api/playlists/shared/:shareToken/clone
* Clone shared playlist to authenticated user's account
*
* Requires authentication and playlists.create permission
*/
router.post(
'/:shareToken/clone',
authenticate,
requirePermission('playlists.create'),
validateSharedPlaylist,
asyncHandler(async (req, res) => {
// Validate request body
const { newTitle } = clonePlaylistSchema.parse(req.body);
const clonedPlaylist = playlistService.cloneSharedPlaylist(
-9
View File
@@ -6,17 +6,8 @@ import { asyncHandler } from '../middleware/asyncHandler';
const router = Router();
const statisticsService = new StatisticsService();
// Global softAuth (from server.ts) already populates req.user for all routes
// No need to apply softAuth again at router level
// Apply feature flag check to all routes in this router
// Admins with settings.update permission will bypass this check (via global softAuth)
router.use(requireFeature('enableStatistics'));
/**
* GET /api/statistics
* Get overall Flashpoint archive statistics
*/
router.get(
'/',
asyncHandler(async (req, res) => {
-42
View File
@@ -16,10 +16,8 @@ const router = Router();
const systemSettings = CachedSystemSettingsService.getInstance();
const domainService = DomainService.getInstance();
// Helper function to update job scheduler when jobs settings change
function updateJobScheduler(category: string, settings: CategorySettings): void {
if (category === 'jobs') {
// Update metadata sync job if its settings changed
if ('metadataSyncEnabled' in settings || 'metadataSyncSchedule' in settings) {
const currentSettings = systemSettings.getCategory('jobs');
const enabled = (settings.metadataSyncEnabled ??
@@ -37,9 +35,6 @@ function updateJobScheduler(category: string, settings: CategorySettings): void
}
}
// ===================================
// GET ALL SETTINGS (Admin Only)
// ===================================
router.get(
'/',
authenticate,
@@ -51,9 +46,6 @@ router.get(
})
);
// ===================================
// GET PUBLIC SETTINGS (No Auth Required)
// ===================================
router.get(
'/public',
asyncHandler(async (req: Request, res: Response) => {
@@ -62,7 +54,6 @@ router.get(
Record<string, unknown>
> = systemSettings.getPublicSettings();
// Add environment-based config values to public settings
if (!publicSettings.app) {
publicSettings.app = {};
}
@@ -89,9 +80,6 @@ router.get(
})
);
// ===================================
// GET CACHE STATISTICS (Admin Only)
// ===================================
router.get(
'/_cache/stats',
authenticate,
@@ -102,9 +90,6 @@ router.get(
})
);
// ===================================
// CLEAR CACHE (Admin Only)
// ===================================
router.post(
'/_cache/clear',
authenticate,
@@ -123,9 +108,6 @@ router.post(
})
);
// ===================================
// GET PERMISSION CACHE STATISTICS (Admin Only)
// ===================================
router.get(
'/_cache/permissions/stats',
authenticate,
@@ -147,15 +129,11 @@ router.get(
})
);
// Validation schema for permission cache clear
const clearPermissionCacheSchema = z.object({
type: z.enum(['all', 'user', 'role', 'users', 'roles']),
id: z.number().int().positive().optional(),
});
// ===================================
// CLEAR PERMISSION CACHE (Admin Only)
// ===================================
router.post(
'/_cache/permissions/clear',
authenticate,
@@ -201,10 +179,6 @@ router.post(
})
);
// ===================================
// GET CATEGORY SETTINGS (Admin Only)
// ===================================
// Valid settings categories
const VALID_CATEGORIES = ['app', 'auth', 'jobs', 'metadata', 'theme', 'features', 'maintenance'];
router.get(
@@ -222,14 +196,10 @@ router.get(
const categorySettings = systemSettings.getCategory(category);
// Return settings (empty object if category has no settings yet)
res.json(categorySettings);
})
);
// ===================================
// UPDATE CATEGORY SETTINGS (Admin Only)
// ===================================
const updateCategorySchema = z.record(
z.union([z.string(), z.number(), z.boolean(), z.record(z.unknown())])
);
@@ -262,21 +232,14 @@ router.patch(
const settings = validation.data;
const userId = req.user!.id;
// Update settings (validation happens in service)
systemSettings.updateCategory(category, settings, userId);
// Update job scheduler if jobs settings changed
updateJobScheduler(category, settings);
// Return updated settings
const updated = systemSettings.getCategory(category);
res.json(updated);
})
);
// ===================================
// GET SINGLE SETTING (Admin Only)
// ===================================
router.get(
'/:category/:key',
authenticate,
@@ -301,9 +264,6 @@ router.get(
})
);
// ===================================
// UPDATE SINGLE SETTING (Admin Only)
// ===================================
const updateSettingSchema = z.object({
value: z.union([z.string(), z.number(), z.boolean(), z.record(z.unknown())]),
});
@@ -335,10 +295,8 @@ router.patch(
const { value } = validation.data;
const userId = req.user!.id;
// Update setting (validation happens in service)
systemSettings.set(fullKey, value, userId);
// Return updated value
const updated = systemSettings.get(fullKey);
res.json({ value: updated });
})
-7
View File
@@ -6,21 +6,15 @@ import { rateLimitStandard } from '../middleware/rateLimiter';
const router = Router();
// Apply rate limiting to prevent abuse
router.use(rateLimitStandard);
// Apply optional auth middleware to all routes
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 }>;
// Parse tags (semicolon-separated) and count occurrences
const tagCounts = new Map<string, number>();
results.forEach((row) => {
@@ -33,7 +27,6 @@ router.get(
});
});
// 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);
+2 -38
View File
@@ -15,10 +15,6 @@ router.use(rateLimitStandard);
const metadataUpdateService = new MetadataUpdateService();
const metadataSyncService = new MetadataSyncService();
/**
* GET /api/updates/check
* Check for available updates
*/
router.get(
'/check',
asyncHandler(async (req, res) => {
@@ -28,12 +24,6 @@ router.get(
})
);
/**
* POST /api/updates/install
* Install available updates
*
* Requires: Admin permission (settings.update)
*/
router.post(
'/install',
authenticate,
@@ -46,12 +36,6 @@ router.post(
})
);
/**
* GET /api/updates/system-info
* Get system information for debugging
*
* Requires: Admin permission (settings.update)
*/
router.get(
'/system-info',
authenticate,
@@ -63,12 +47,7 @@ router.get(
})
);
/**
* GET /api/updates/metadata
* Check for game metadata updates (like Flashpoint Launcher)
* Returns info about game database updates from FPFSS
* Includes edition info so frontend knows whether sync is available
*/
// Includes edition info so frontend knows whether sync is available
router.get(
'/metadata',
asyncHandler(async (req, res) => {
@@ -79,14 +58,7 @@ router.get(
})
);
/**
* POST /api/updates/metadata/sync
* Start metadata sync in background (non-blocking)
* Returns immediately with sync started status
* Frontend should poll /api/updates/metadata/sync/status for progress
*
* Requires: Admin permission (settings.update)
*/
// Non-blocking: returns immediately. Frontend polls /api/updates/metadata/sync/status for progress.
router.post(
'/metadata/sync',
authenticate,
@@ -95,7 +67,6 @@ router.post(
asyncHandler(async (req, res) => {
const syncStatus = SyncStatusService.getInstance();
// Check if sync is already running
if (syncStatus.isRunning()) {
return res.status(409).json({
success: false,
@@ -108,12 +79,10 @@ router.post(
`[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);
});
// Return immediately
res.json({
success: true,
message: 'Sync started',
@@ -122,11 +91,6 @@ router.post(
})
);
/**
* GET /api/updates/metadata/sync/status
* Get current metadata sync status and progress
* Frontend polls this endpoint to get real-time updates
*/
router.get(
'/metadata/sync/status',
asyncHandler(async (req, res) => {
-76
View File
@@ -12,17 +12,9 @@ import { z } from 'zod';
const router = Router();
const playlistService = new UserPlaylistService();
// Apply rate limiting to prevent abuse
router.use(rateLimitStandard);
// Global softAuth (from server.ts) already populates req.user for all routes
// No need to apply softAuth again at router level
// Apply feature flag check to all routes in this router
// Admins with settings.update permission will bypass this check (via global softAuth)
router.use(requireFeature('enablePlaylists'));
// Validation schemas
const createPlaylistSchema = z.object({
title: z.string().min(1).max(255),
description: z.string().max(2000).optional(),
@@ -62,10 +54,6 @@ const updateShareSettingsSchema = z.object({
showOwner: z.boolean().optional(),
});
/**
* GET /api/user-playlists
* Get all playlists for the authenticated user
*/
router.get(
'/',
authenticate,
@@ -80,17 +68,12 @@ router.get(
const playlists = playlistService.getUserPlaylists(req.user.id);
// Store count for activity logging
res.locals.playlistCount = playlists.length;
res.json(playlists);
})
);
/**
* GET /api/user-playlists/stats
* Get playlist statistics for the authenticated user
*/
router.get(
'/stats',
authenticate,
@@ -105,10 +88,6 @@ router.get(
})
);
/**
* GET /api/user-playlists/:id
* Get a specific playlist by ID
*/
router.get(
'/:id',
authenticate,
@@ -133,7 +112,6 @@ router.get(
throw new AppError(404, 'Playlist not found');
}
// Store for activity logging
res.locals.playlistTitle = playlist.title;
res.locals.gameCount = playlist.gameCount;
@@ -141,10 +119,6 @@ router.get(
})
);
/**
* GET /api/user-playlists/:id/games
* Get all games in a playlist with full game data
*/
router.get(
'/:id/games',
authenticate,
@@ -164,10 +138,6 @@ router.get(
})
);
/**
* POST /api/user-playlists
* Create a new playlist
*/
router.post(
'/',
authenticate,
@@ -185,10 +155,6 @@ router.post(
})
);
/**
* PATCH /api/user-playlists/:id
* Update a playlist
*/
router.patch(
'/:id',
authenticate,
@@ -218,10 +184,6 @@ router.patch(
})
);
/**
* DELETE /api/user-playlists/:id
* Delete a playlist
*/
router.delete(
'/:id',
authenticate,
@@ -247,11 +209,6 @@ router.delete(
})
);
/**
* POST /api/user-playlists/:id/games
* Add games to a playlist
* Returns the updated playlist with game data
*/
router.post(
'/:id/games',
authenticate,
@@ -277,7 +234,6 @@ router.post(
throw new AppError(404, 'Playlist not found');
}
// Return updated playlist with game data
const updatedPlaylist = playlistService.getPlaylistById(playlistId, req.user.id);
const games = await playlistService.getPlaylistGamesWithData(playlistId, req.user.id);
@@ -288,11 +244,6 @@ router.post(
})
);
/**
* DELETE /api/user-playlists/:id/games
* Remove games from a playlist
* Returns the updated playlist with game data
*/
router.delete(
'/:id/games',
authenticate,
@@ -318,7 +269,6 @@ router.delete(
throw new AppError(404, 'Playlist not found');
}
// Return updated playlist with game data
const updatedPlaylist = playlistService.getPlaylistById(playlistId, req.user.id);
const games = await playlistService.getPlaylistGamesWithData(playlistId, req.user.id);
@@ -329,11 +279,6 @@ router.delete(
})
);
/**
* PUT /api/user-playlists/:id/games/reorder
* Reorder games in a playlist
* Returns the updated playlist with game data
*/
router.put(
'/:id/games/reorder',
authenticate,
@@ -359,7 +304,6 @@ router.put(
throw new AppError(404, 'Playlist not found');
}
// Return updated playlist with game data
const updatedPlaylist = playlistService.getPlaylistById(playlistId, req.user.id);
const games = await playlistService.getPlaylistGamesWithData(playlistId, req.user.id);
@@ -370,10 +314,6 @@ router.put(
})
);
/**
* POST /api/user-playlists/copy-flashpoint
* Copy a Flashpoint playlist to user's playlists
*/
router.post(
'/copy-flashpoint',
authenticate,
@@ -399,10 +339,6 @@ router.post(
})
);
/**
* POST /api/user-playlists/:id/share/enable
* Enable sharing for a playlist
*/
router.post(
'/:id/share/enable',
authenticate,
@@ -433,10 +369,6 @@ router.post(
})
);
/**
* POST /api/user-playlists/:id/share/disable
* Disable sharing for a playlist
*/
router.post(
'/:id/share/disable',
authenticate,
@@ -462,10 +394,6 @@ router.post(
})
);
/**
* POST /api/user-playlists/:id/share/regenerate
* Regenerate share token (invalidates old links)
*/
router.post(
'/:id/share/regenerate',
authenticate,
@@ -491,10 +419,6 @@ router.post(
})
);
/**
* PATCH /api/user-playlists/:id/share/settings
* Update share settings (expiry, show_owner) without regenerating token
*/
router.patch(
'/:id/share/settings',
authenticate,
+10 -57
View File
@@ -10,7 +10,6 @@ import { z } from 'zod';
const router = Router();
const userService = new UserService();
// Validation schemas
const createUserSchema = z.object({
username: z.string().min(3).max(50),
email: z.string().email(),
@@ -42,10 +41,6 @@ const updateThemeSettingsSchema = z.object({
const updateUserSettingsSchema = z.record(z.string().max(50), z.string().max(500));
/**
* GET /api/users
* List all users with pagination
*/
router.get(
'/',
authenticate,
@@ -61,11 +56,7 @@ router.get(
})
);
/**
* GET /api/users/me/theme (DEPRECATED - redirects to new endpoint)
* Legacy endpoint for backward compatibility
* @deprecated Use GET /api/users/me/settings/theme instead
*/
/** @deprecated Use GET /api/users/me/settings/theme instead */
router.get(
'/me/theme',
authenticate,
@@ -81,11 +72,7 @@ router.get(
})
);
/**
* PATCH /api/users/me/theme (DEPRECATED - redirects to new endpoint)
* Legacy endpoint for backward compatibility
* @deprecated Use PATCH /api/users/me/settings/theme instead
*/
/** @deprecated Use PATCH /api/users/me/settings/theme instead */
router.patch(
'/me/theme',
authenticate,
@@ -97,7 +84,6 @@ router.patch(
// Extract color name from format like 'blue-500' -> 'blue'
const primaryColor = data.themeColor.split('-')[0];
// Update using new settings table
await userService.updateThemeSettings(userId, 'dark', primaryColor);
res.json({
@@ -108,11 +94,7 @@ router.patch(
})
);
/**
* GET /api/users/me/settings/theme (DEPRECATED)
* Get current user's theme settings from user_settings table
* @deprecated Use GET /api/users/me/settings instead and extract theme_mode and primary_color
*/
/** @deprecated Use GET /api/users/me/settings and extract theme_mode + primary_color */
router.get(
'/me/settings/theme',
authenticate,
@@ -123,11 +105,7 @@ router.get(
})
);
/**
* PATCH /api/users/me/settings/theme (DEPRECATED)
* Update current user's theme settings in user_settings table
* @deprecated Use PATCH /api/users/me/settings with theme_mode and primary_color keys instead
*/
/** @deprecated Use PATCH /api/users/me/settings with theme_mode + primary_color keys */
router.patch(
'/me/settings/theme',
authenticate,
@@ -143,10 +121,6 @@ router.patch(
})
);
/**
* GET /api/users/me/settings
* Get all settings for current user
*/
router.get(
'/me/settings',
authenticate,
@@ -157,10 +131,6 @@ router.get(
})
);
/**
* PATCH /api/users/me/settings
* Update multiple settings for current user
*/
router.patch(
'/me/settings',
authenticate,
@@ -178,10 +148,6 @@ router.patch(
})
);
/**
* GET /api/users/:id
* Get single user by ID
*/
router.get(
'/:id',
authenticate,
@@ -203,10 +169,6 @@ router.get(
})
);
/**
* POST /api/users
* Create new user (admin only)
*/
router.post(
'/',
authenticate,
@@ -227,10 +189,6 @@ router.post(
})
);
/**
* PATCH /api/users/:id
* Update existing user
*/
router.patch(
'/:id',
authenticate,
@@ -253,10 +211,6 @@ router.patch(
})
);
/**
* DELETE /api/users/:id
* Delete user (prevents deleting last admin)
*/
router.delete(
'/:id',
authenticate,
@@ -274,11 +228,6 @@ router.delete(
})
);
/**
* POST /api/users/:id/change-password
* Change user password
* Users can change their own password, admins can change any password
*/
router.post(
'/:id/change-password',
authenticate,
@@ -290,7 +239,6 @@ router.post(
const data = changePasswordSchema.parse(req.body);
// Check if user is changing their own password or is an admin
const isOwnPassword = req.user?.id === id;
const isAdmin = req.user?.permissions.includes('users.update');
@@ -305,7 +253,12 @@ router.post(
throw new AppError(400, 'Current password is required');
}
await userService.changePassword(id, data.currentPassword || '', data.newPassword, isAdminReset);
await userService.changePassword(
id,
data.currentPassword || '',
data.newPassword,
isAdminReset
);
res.json({ success: true, message: 'Password changed successfully' });
})
+10 -85
View File
@@ -33,10 +33,6 @@ import { gameZipServer } from './game/gamezipserver';
import gameProxyRouter from './routes/game-proxy';
import gameZipRouter from './routes/game-zip';
/**
* Clean up old activity logs based on retention settings
* Default retention: 30 days (from storage.log_retention_days setting)
*/
async function cleanupActivityLogs(): Promise<void> {
try {
const systemSettings = CachedSystemSettingsService.getInstance();
@@ -64,14 +60,10 @@ async function cleanupActivityLogs(): Promise<void> {
async function startServer() {
const app: Express = express();
// Trust proxy - Required for Docker/reverse proxy deployments
// Enables Express to correctly read X-Forwarded-* headers from reverse proxies
// Without this, req.ip returns the proxy IP instead of the client IP, causing all rate limiters to share a single counter
// Without trust proxy, req.ip returns the proxy IP, causing rate limiters to share a single counter
app.set('trust proxy', 1);
// --- Game content routes (BEFORE all middleware) ---
// These bypass auth, maintenance mode, restrictive CORS, and body parsing.
// Only a permissive CORS middleware is applied to game routes.
// Game content routes registered BEFORE auth/maintenance middleware (need cross-origin access)
const gameCors = (_req: express.Request, res: express.Response, next: express.NextFunction) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS, HEAD');
@@ -84,32 +76,23 @@ async function startServer() {
};
app.use('/game-proxy', gameCors, gameProxyRouter);
app.use('/game-zip', gameCors, gameZipRouter);
// --- End game content routes ---
// Security middleware
app.use(
helmet({
contentSecurityPolicy: false, // Will be configured in frontend via meta tag
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false,
frameguard: {
action: 'deny', // Equivalent to frame-ancestors 'none'
},
frameguard: { action: 'deny' },
})
);
const domainService = DomainService.getInstance();
// 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);
// Always allow the configured domain from env
if (origin === config.domain) return callback(null, true);
// Check domains from the database
try {
const allowed = domainService.getAllowedOrigins();
if (allowed.has(origin)) return callback(null, true);
@@ -123,23 +106,14 @@ async function startServer() {
})
);
// Body parsing with size limits (prevent DoS attacks)
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
// Cookie parsing
app.use(cookieParser());
// Compression
app.use(compression());
// Request timeout (30 seconds default)
// Prevents hanging requests from consuming resources
app.use(requestTimeout(TimeoutConfig.DEFAULT));
// Request logging
app.use((req, res, next) => {
// Skip logging for high-frequency paths
if (
req.path === '/health' ||
req.path.startsWith('/game-proxy/') ||
@@ -152,19 +126,12 @@ async function startServer() {
next();
});
// Performance tracking
// Track response times for all endpoints
app.use(performanceTracking);
// Soft authentication - populates req.user if token exists (doesn't throw errors)
// MUST be applied before maintenance mode check so it can identify admins
// softAuth MUST run before maintenanceMode so it can identify admins
app.use(softAuth);
// Maintenance mode middleware - blocks non-admin users when enabled
// Relies on req.user being populated by softAuth above
app.use(maintenanceMode);
// Initialize database
try {
await DatabaseService.initialize();
logger.info('Database connection established');
@@ -173,7 +140,6 @@ async function startServer() {
process.exit(1);
}
// Initialize user database
try {
await UserDatabaseService.initialize();
logger.info('User database connection established');
@@ -182,21 +148,15 @@ async function startServer() {
process.exit(1);
}
// Log auto-detected Flashpoint edition and version
// Edition is detected from {FLASHPOINT_PATH}/version.txt on startup (see config.ts)
logger.info(`[Edition] Flashpoint edition: ${config.flashpointEdition} (auto-detected)`);
if (config.flashpointVersionString) {
logger.info(`[Edition] Flashpoint version: ${config.flashpointVersionString}`);
}
// Pre-warm game search cache for instant first page load
// Run asynchronously to not block server startup
GameSearchCache.prewarmCache().catch((error) => {
logger.warn('Failed to pre-warm game search cache:', error);
// Non-fatal: server continues without pre-warmed cache
});
// Check if Ruffle is installed, download if not
try {
const ruffleService = new RuffleService();
if (!ruffleService.verifyInstallation()) {
@@ -210,15 +170,12 @@ async function startServer() {
} catch (error) {
logger.error('Failed to install Ruffle:', error);
logger.warn('⚠️ Continuing without Ruffle - Flash games will not work');
// Non-fatal error - continue server startup
}
// Initialize job scheduler
try {
const systemSettings = CachedSystemSettingsService.getInstance();
const jobSettings = systemSettings.getCategory('jobs');
// Register metadata sync job
const metadataSyncJob = new MetadataSyncJob();
JobScheduler.registerJob({
id: 'metadata-sync',
@@ -230,11 +187,10 @@ async function startServer() {
cronSchedule:
typeof jobSettings.metadataSyncSchedule === 'string'
? jobSettings.metadataSyncSchedule
: '0 * * * *', // Default: hourly
: '0 * * * *',
run: () => metadataSyncJob.run(),
});
// Register Ruffle update job
const ruffleUpdateJob = new RuffleUpdateJob();
JobScheduler.registerJob({
id: 'ruffle-update',
@@ -246,19 +202,16 @@ async function startServer() {
cronSchedule:
typeof jobSettings.ruffleUpdateSchedule === 'string'
? jobSettings.ruffleUpdateSchedule
: '0 0 * * *', // Default: daily at midnight
: '0 0 * * *',
run: () => ruffleUpdateJob.run(),
});
// Start all enabled jobs
JobScheduler.startAllEnabledJobs();
logger.info('✅ Job scheduler initialized');
} catch (error) {
logger.error('Failed to initialize job scheduler:', error);
// Non-fatal error - continue server startup
}
// Initialize game service configuration (integrated into backend)
try {
await ConfigManager.loadConfig(config.flashpointPath);
PreferencesService.initialize(config.flashpointPath);
@@ -267,9 +220,6 @@ async function startServer() {
logger.warn('⚠️ Failed to load game service config (non-fatal):', error);
}
// Health check - game service is now integrated (no external dependency)
// NOTE: This endpoint is registered before middleware for load balancer access.
// It performs basic health checks. For detailed health status, use /api/health (requires auth).
app.get('/health', async (_req, res) => {
const isHealthy = DatabaseService.isConnected();
@@ -288,21 +238,15 @@ async function startServer() {
});
});
// API routes
setupRoutes(app);
// Error handling
app.use(errorHandler);
// Start permission cache cleanup
PermissionCache.startCleanup();
logger.info('✅ Permission cache initialized');
// Log logging system status
const loggingStatus = getLoggingStatus();
logger.info('Logging system status', loggingStatus);
if (loggingStatus.fileEnabled) {
// Verify file logging works after a short delay to allow winston to initialize
setTimeout(() => {
if (verifyFileLogging()) {
logger.info('File logging verified successfully');
@@ -314,7 +258,6 @@ async function startServer() {
logger.warn(`File logging unavailable: ${loggingStatus.fileError}`);
}
// Start server
const server = app.listen(config.port, config.host, () => {
logger.info(`🚀 Flashpoint Web API server running on http://${config.host}:${config.port}`);
logger.info(`📁 Flashpoint path: ${config.flashpointPath}`);
@@ -328,7 +271,6 @@ async function startServer() {
server.timeout = 120000; // 2 min max for any request (including game file streaming)
// Note: Connection limiting is handled by the reverse proxy (nginx)
// Start cleanup scheduler for abandoned play sessions (every 6 hours)
const playTrackingService = new PlayTrackingService();
const playSessionCleanupInterval = setInterval(
() => {
@@ -337,15 +279,13 @@ async function startServer() {
});
},
6 * 60 * 60 * 1000
); // 6 hours
);
playSessionCleanupInterval.unref();
// Run initial play session cleanup
playTrackingService.cleanupAbandonedSessions().catch((error) => {
logger.error('Failed to cleanup abandoned sessions:', error);
});
// Start cleanup scheduler for old login attempts (every 6 hours)
const authService = new AuthService();
const loginAttemptsCleanupInterval = setInterval(
() => {
@@ -354,15 +294,13 @@ async function startServer() {
});
},
6 * 60 * 60 * 1000
); // 6 hours
);
loginAttemptsCleanupInterval.unref();
// Run initial login attempts cleanup
authService.cleanupOldLoginAttempts().catch((error) => {
logger.error('Failed to cleanup old login attempts:', error);
});
// Start cleanup scheduler for old activity logs (daily)
const activityLogsCleanupInterval = setInterval(
() => {
cleanupActivityLogs().catch((error) => {
@@ -370,44 +308,34 @@ async function startServer() {
});
},
24 * 60 * 60 * 1000
); // 24 hours
);
activityLogsCleanupInterval.unref();
// Run initial activity logs cleanup
cleanupActivityLogs().catch((error) => {
logger.error('Failed to cleanup old activity logs:', error);
});
// Graceful shutdown
const shutdown = async () => {
logger.info('Shutting down gracefully...');
// Stop scheduled jobs
JobScheduler.stopAllJobs();
// Stop permission cache cleanup
PermissionCache.stopCleanup();
// Stop cleanup intervals
clearInterval(playSessionCleanupInterval);
clearInterval(loginAttemptsCleanupInterval);
clearInterval(activityLogsCleanupInterval);
// Dispose game zip server (stops download cleanup interval)
try {
gameZipServer.dispose();
} catch (err) {
logger.error('Failed to dispose game zip server during shutdown:', err);
}
// Clean up mounted ZIPs (await to release file handles)
try {
await zipManager.unmountAll();
} catch (err) {
logger.error('Failed to unmount ZIPs during shutdown:', err);
}
// Close database connections
try {
DatabaseService.close();
} catch (err) {
@@ -419,13 +347,11 @@ async function startServer() {
logger.error('Failed to close user database during shutdown:', err);
}
// Close server
server.close(() => {
logger.info('Server closed');
process.exit(0);
});
// Force exit after 10 seconds
setTimeout(() => {
logger.error('Could not close connections in time, forcefully shutting down');
process.exit(1);
@@ -436,7 +362,6 @@ async function startServer() {
process.on('SIGINT', shutdown);
}
// Handle uncaught errors
process.on('unhandledRejection', (error) => {
logger.error('Unhandled rejection:', error);
process.exit(1);
-21
View File
@@ -82,9 +82,6 @@ export class ActivityService {
return { sql, params };
}
/**
* Log activity
*/
async log(data: LogActivityData): Promise<void> {
const detailsJson = data.details ? JSON.stringify(data.details) : null;
@@ -105,9 +102,6 @@ export class ActivityService {
);
}
/**
* Get activity logs with pagination
*/
async getLogs(
page: number = 1,
limit: number = 50,
@@ -151,9 +145,6 @@ export class ActivityService {
return createPaginatedResponse(logs, total, page, limit);
}
/**
* Clean up old logs (retention policy)
*/
async cleanup(retentionDays: number = 90): Promise<number> {
const result = UserDatabaseService.run(
`DELETE FROM activity_logs WHERE created_at < datetime('now', '-' || ? || ' days')`,
@@ -163,9 +154,6 @@ export class ActivityService {
return result.changes;
}
/**
* Get aggregate statistics for dashboard
*/
async getStats(
timeRange: TimeRange = '24h',
customRange?: { startDate?: string; endDate?: string }
@@ -278,9 +266,6 @@ export class ActivityService {
};
}
/**
* Get activity trend over time
*/
async getTrend(days: number = 7) {
const granularity = days <= 1 ? 'hour' : 'day';
const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
@@ -323,9 +308,6 @@ export class ActivityService {
};
}
/**
* Get top actions by frequency
*/
async getTopActions(limit: number = 10, timeRange: TimeRange = '24h') {
const hours = getHoursFromTimeRange(timeRange);
const startDate = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
@@ -396,9 +378,6 @@ export class ActivityService {
};
}
/**
* Get activity breakdown by dimension
*/
async getBreakdown(
groupBy: 'resource' | 'user' | 'ip',
limit: number = 10,
+2 -63
View File
@@ -8,18 +8,12 @@ import { hashPassword, verifyPassword } from '../utils/password';
import { AppError } from '../middleware/errorHandler';
import { LoginCredentials, RegisterData, AuthTokens, AuthUser } from '../types/auth';
/**
* User data for token generation
*/
interface UserTokenData {
id: number;
username: string;
role_name: string;
}
/**
* Auth settings shape from system_settings
*/
interface AuthSettings {
guest_access_enabled: number;
user_registration_enabled: number;
@@ -36,9 +30,6 @@ export class AuthService {
this.cachedSystemSettings = CachedSystemSettingsService.getInstance();
}
/**
* Login user with username/password
*/
async login(
credentials: LoginCredentials,
ipAddress: string
@@ -46,10 +37,9 @@ export class AuthService {
try {
const { username, password } = credentials;
// Check login attempts and lockout
await this.checkLoginAttempts(username, ipAddress);
// Find user (select only needed columns — avoid passing password_hash beyond verification)
// Select only needed columns — avoid passing password_hash beyond verification
const user = UserDatabaseService.get(
`SELECT u.id, u.username, u.email, u.password_hash,
r.name as role_name, r.priority
@@ -64,27 +54,22 @@ export class AuthService {
throw new AppError(401, 'Invalid username or password');
}
// Verify password
const isValid = await verifyPassword(password, user.password_hash);
if (!isValid) {
await this.recordLoginAttempt(username, ipAddress, false);
throw new AppError(401, 'Invalid username or password');
}
// Record successful login
await this.recordLoginAttempt(username, ipAddress, true);
// Update last login
UserDatabaseService.run('UPDATE users SET last_login_at = ? WHERE id = ?', [
new Date().toISOString(),
user.id,
]);
// Get user permissions
const permissions = this.getUserPermissions(user.id);
logger.debug(`[AuthService] User ${username} has ${permissions.length} permissions`);
// Generate tokens
const tokens = await this.generateTokens(user);
const authUser: AuthUser = {
@@ -116,19 +101,16 @@ export class AuthService {
* The method automatically detects which scenario applies based on whether users exist
*/
async register(data: RegisterData): Promise<{ user: AuthUser; tokens: AuthTokens }> {
// Hash password before transaction
const passwordHash = await hashPassword(data.password);
// Wrap setup check + uniqueness checks + insert in transaction to prevent race condition
// Wrap setup check + uniqueness checks + insert in transaction to prevent TOCTOU race
const db = UserDatabaseService.getDatabase();
const registerTransaction = db.transaction(() => {
// Check username uniqueness inside transaction
const existingUser = db.prepare('SELECT id FROM users WHERE username = ?').get(data.username);
if (existingUser) {
throw new AppError(409, 'Username already exists');
}
// Check email uniqueness inside transaction
const existingEmail = db.prepare('SELECT id FROM users WHERE email = ?').get(data.email);
if (existingEmail) {
throw new AppError(409, 'Email already exists');
@@ -166,12 +148,10 @@ export class AuthService {
const userId = registerTransaction();
// Apply default theme and primary color from system settings
const appSettings = this.cachedSystemSettings.getCategory('app');
const defaultTheme = appSettings.defaultTheme || 'dark';
const defaultPrimaryColor = appSettings.defaultPrimaryColor || 'blue';
// Insert default user settings
UserDatabaseService.run(
`INSERT INTO user_settings (user_id, setting_key, setting_value) VALUES
(?, 'theme_mode', ?),
@@ -183,7 +163,6 @@ export class AuthService {
`[AuthService] Created user ${data.username} with default theme: ${defaultTheme}, primary color: ${defaultPrimaryColor}`
);
// Get created user
const user = UserDatabaseService.get(
`SELECT u.id, u.username, u.email, r.name as role_name
FROM users u
@@ -192,10 +171,7 @@ export class AuthService {
[userId]
);
// Get permissions
const permissions = this.getUserPermissions(userId);
// Generate tokens
const tokens = await this.generateTokens(user);
const authUser: AuthUser = {
@@ -209,9 +185,6 @@ export class AuthService {
return { user: authUser, tokens };
}
/**
* Logout user (revoke refresh token)
*/
async logout(refreshToken: string): Promise<void> {
UserDatabaseService.run('UPDATE refresh_tokens SET revoked_at = ? WHERE token = ?', [
new Date().toISOString(),
@@ -232,11 +205,7 @@ export class AuthService {
logger.info(`[AuthService] Revoked all refresh tokens for user ${userId}`);
}
/**
* Refresh access token using refresh token
*/
async refreshToken(refreshToken: string): Promise<AuthTokens> {
// Verify token is valid and not revoked
const tokenRecord = UserDatabaseService.get(
`SELECT * FROM refresh_tokens
WHERE token = ? AND revoked_at IS NULL AND expires_at > ?`,
@@ -247,7 +216,6 @@ export class AuthService {
throw new AppError(401, 'Invalid or expired refresh token');
}
// Get user
const user = UserDatabaseService.get(
`SELECT u.id, u.username, u.email, r.name as role_name
FROM users u
@@ -268,18 +236,13 @@ export class AuthService {
logger.debug(`[AuthService] Revoked old refresh token for user ${user.id}`);
// Generate new tokens
return this.generateTokens(user);
}
/**
* Verify access token and get user
*/
async verifyAccessToken(token: string): Promise<AuthUser> {
try {
const payload = verifyToken(token);
// Get user with current data
const user = UserDatabaseService.get(
`SELECT u.id, u.username, u.email, r.name as role_name
FROM users u
@@ -306,17 +269,12 @@ export class AuthService {
}
}
/**
* Get user permissions (with caching)
*/
private getUserPermissions(userId: number): string[] {
// Try to get from cache first
const cached = PermissionCache.getUserPermissions(userId);
if (cached !== null) {
return cached;
}
// Cache miss - query database
const permissions = UserDatabaseService.all(
`SELECT DISTINCT p.name
FROM permissions p
@@ -328,15 +286,11 @@ export class AuthService {
const permissionNames = permissions.map((p) => p.name);
// Store in cache
PermissionCache.setUserPermissions(userId, permissionNames);
return permissionNames;
}
/**
* Generate access and refresh tokens
*/
private async generateTokens(user: UserTokenData): Promise<AuthTokens> {
const accessToken = generateAccessToken({
userId: user.id,
@@ -346,7 +300,6 @@ export class AuthService {
const refreshToken = generateRefreshToken();
// Store refresh token
UserDatabaseService.run(
`INSERT INTO refresh_tokens (user_id, token, expires_at)
VALUES (?, ?, datetime('now', '+30 days'))`,
@@ -360,9 +313,6 @@ export class AuthService {
};
}
/**
* Check login attempts and enforce lockout
*/
private async checkLoginAttempts(username: string, ipAddress: string): Promise<void> {
const settings = this.getAuthSettings();
const lockoutDuration = settings.lockout_duration_minutes;
@@ -403,9 +353,6 @@ export class AuthService {
}
}
/**
* Record login attempt
*/
private async recordLoginAttempt(
username: string,
ipAddress: string,
@@ -432,14 +379,10 @@ export class AuthService {
}
}
/**
* Get auth settings from system_settings
*/
private getAuthSettings(): AuthSettings {
try {
const authSettings = this.systemSettings.getCategory('auth');
// Return settings with defaults if not found
return {
guest_access_enabled:
authSettings.guestAccessEnabled !== undefined
@@ -462,7 +405,6 @@ export class AuthService {
};
} catch (error) {
logger.error('Failed to get auth settings, using defaults:', error);
// Return defaults if settings can't be retrieved
return {
guest_access_enabled: 1,
user_registration_enabled: 1,
@@ -472,9 +414,6 @@ export class AuthService {
}
}
/**
* Check if guest access is enabled
*/
isGuestAccessEnabled(): boolean {
const settings = this.getAuthSettings();
return settings.guest_access_enabled === 1;
@@ -2,26 +2,16 @@ import { CachedSystemSettingsService } from './CachedSystemSettingsService';
import { UserDatabaseService } from './UserDatabaseService';
import { AuthSettings } from '../types/auth';
/**
* Auth Settings Service
* Provides typed interface for auth-related settings from system_settings table
* Uses CachedSystemSettingsService for performance optimization
*/
export class AuthSettingsService {
private systemSettings: CachedSystemSettingsService;
constructor() {
// Use cached singleton for better performance (auth settings accessed frequently)
this.systemSettings = CachedSystemSettingsService.getInstance();
}
/**
* Get auth settings
*/
getSettings(): AuthSettings {
const authSettings = this.systemSettings.getCategory('auth');
// Get the latest updated_at and updated_by from any auth setting
const latestUpdate = this.getLatestUpdate();
return {
@@ -52,28 +42,19 @@ export class AuthSettingsService {
};
}
/**
* Update auth settings
*/
updateSettings(data: Partial<AuthSettings>, updatedBy: number): AuthSettings {
// Filter out non-setting fields (updatedAt, updatedBy)
const { updatedAt: _updatedAt, updatedBy: _updatedBy, ...settingsToUpdate } = data;
if (Object.keys(settingsToUpdate).length === 0) {
return this.getSettings();
}
// Update each setting individually to track updated_by
this.systemSettings.updateCategory('auth', settingsToUpdate, updatedBy);
return this.getSettings();
}
/**
* Get the latest update timestamp and user from auth settings
*/
private getLatestUpdate(): { updatedAt: string; updatedBy: number | undefined } {
// Get metadata for the most recently updated auth setting
const query = `
SELECT updated_at, updated_by
FROM system_settings
@@ -65,9 +65,6 @@ export class CachedSystemSettingsService extends SystemSettingsService {
this.clearCache();
}
/**
* Get a single setting by key (with caching)
*/
get(key: string): SettingValue | null {
const cached = this.cache.get(key);
@@ -75,10 +72,9 @@ export class CachedSystemSettingsService extends SystemSettingsService {
return cached.value as SettingValue | null;
}
// Cache miss - fetch from database
const value = super.get(key);
// Cache both non-null and null values to avoid repeated DB queries for missing keys
// Cache null values too to avoid repeated DB queries for missing keys
this.cache.set(key, {
value,
timestamp: Date.now(),
@@ -87,9 +83,6 @@ export class CachedSystemSettingsService extends SystemSettingsService {
return value;
}
/**
* Get all settings in a category (with caching)
*/
getCategory(category: string): CategorySettings {
const cached = this.categoryCache.get(category);
@@ -97,7 +90,6 @@ export class CachedSystemSettingsService extends SystemSettingsService {
return cached.value as CategorySettings;
}
// Cache miss - fetch from database
const value = super.getCategory(category);
this.categoryCache.set(category, {
@@ -108,63 +100,43 @@ export class CachedSystemSettingsService extends SystemSettingsService {
return value;
}
/**
* Set a single setting (invalidates cache)
*/
set(key: string, value: SettingValue, updatedBy?: number): void {
super.set(key, value, updatedBy);
// Invalidate cache for this key
this.cache.delete(key);
// Invalidate category cache (key format is "category.setting_name")
// Key format is "category.setting_name"
const category = key.split('.')[0];
this.categoryCache.delete(category);
}
/**
* Update multiple settings in a category (invalidates cache)
*/
updateCategory(category: string, settings: CategorySettings, updatedBy?: number): void {
super.updateCategory(category, settings, updatedBy);
// Invalidate all keys in this category
for (const key of this.cache.keys()) {
if (key.startsWith(`${category}.`)) {
this.cache.delete(key);
}
}
// Invalidate category cache
this.categoryCache.delete(category);
}
/**
* Clear all caches
*/
clearCache(): void {
this.cache.clear();
this.categoryCache.clear();
}
/**
* Clear cache for a specific category
*/
clearCategoryCache(category: string): void {
// Clear individual key caches for this category
for (const key of this.cache.keys()) {
if (key.startsWith(`${category}.`)) {
this.cache.delete(key);
}
}
// Clear category cache
this.categoryCache.delete(category);
}
/**
* Get cache statistics
*/
getCacheStats(): {
keyCount: number;
categoryCount: number;
@@ -179,27 +151,19 @@ export class CachedSystemSettingsService extends SystemSettingsService {
};
}
/**
* Check if cached entry is still valid
*/
private isValid(timestamp: number): boolean {
return Date.now() - timestamp < this.cacheTTL;
}
/**
* Clean up expired cache entries
*/
private cleanup(): void {
const now = Date.now();
// Clean key cache
for (const [key, entry] of this.cache.entries()) {
if (now - entry.timestamp >= this.cacheTTL) {
this.cache.delete(key);
}
}
// Clean category cache
for (const [category, entry] of this.categoryCache.entries()) {
if (now - entry.timestamp >= this.cacheTTL) {
this.categoryCache.delete(category);
@@ -207,9 +171,6 @@ export class CachedSystemSettingsService extends SystemSettingsService {
}
}
/**
* Estimate cache size in bytes (approximate)
*/
private estimateCacheSize(): number {
let size = 0;
@@ -57,14 +57,10 @@ export interface DownloadResult {
}
export class CommunityPlaylistService {
/**
* Fetch and parse community playlists from the Flashpoint Archive wiki
*/
async fetchCommunityPlaylists(): Promise<CommunityPlaylistsResponse> {
try {
logger.info('[CommunityPlaylist] Fetching community playlists from wiki');
// Fetch HTML from wiki page
const response = await axios.get(PLAYLISTS_WIKI_URL, {
timeout: 30000,
headers: {
@@ -72,7 +68,6 @@ export class CommunityPlaylistService {
},
});
// Parse HTML and extract playlists
const categories = this.parsePlaylistTables(response.data);
logger.info(
@@ -99,15 +94,12 @@ export class CommunityPlaylistService {
}
}
/**
* Validate that a URL is from an allowed domain (SSRF protection)
*/
/** SSRF protection: validate URL is from an allowed domain */
private isAllowedDownloadUrl(url: string): boolean {
try {
const parsedUrl = new URL(url);
const hostname = parsedUrl.hostname.toLowerCase();
// Check if hostname matches or is a subdomain of an allowed domain
return ALLOWED_DOWNLOAD_DOMAINS.some((domain) => {
const lowerDomain = domain.toLowerCase();
return hostname === lowerDomain || hostname.endsWith(`.${lowerDomain}`);
@@ -118,9 +110,6 @@ export class CommunityPlaylistService {
}
}
/**
* Download a specific playlist from the given URL and save it to disk
*/
async downloadPlaylist(downloadUrl: string): Promise<DownloadResult> {
try {
logger.info(`[CommunityPlaylist] Downloading playlist from: ${downloadUrl}`);
@@ -134,7 +123,6 @@ export class CommunityPlaylistService {
};
}
// Fetch playlist JSON
// maxRedirects: 0 prevents SSRF via open redirects on allowed domains
const response = await axios.get(downloadUrl, {
timeout: 60000,
@@ -147,7 +135,6 @@ export class CommunityPlaylistService {
const playlistData = response.data;
// Validate playlist structure
if (!this.validatePlaylistStructure(playlistData)) {
logger.error('[CommunityPlaylist] Invalid playlist structure');
return {
@@ -156,7 +143,6 @@ export class CommunityPlaylistService {
};
}
// Check if playlist already exists (conflict detection)
const playlistsPath = config.flashpointPlaylistsPath;
const playlistFilePath = path.join(playlistsPath, `${playlistData.id}.json`);
@@ -173,7 +159,6 @@ export class CommunityPlaylistService {
// File doesn't exist - good to proceed
}
// Save playlist to disk
await fs.writeFile(playlistFilePath, JSON.stringify(playlistData, null, '\t'), 'utf-8');
logger.info(
@@ -215,26 +200,20 @@ export class CommunityPlaylistService {
}
}
/**
* Parse HTML tables from wiki page to extract playlists
*/
private parsePlaylistTables(html: string): CommunityPlaylistCategory[] {
const $ = cheerio.load(html);
const categories: CommunityPlaylistCategory[] = [];
let mainCategory = ''; // h2 header
let subCategory = ''; // h3 header
// Process all headers and tables in sequence
$('h2, h3, table.wikitable').each((i, elem) => {
const tagName = elem.type === 'tag' ? elem.name : null;
if (!tagName) return;
if (tagName === 'h2') {
// This is a main category header
const headerText = $(elem).text().trim();
const categoryName = headerText.replace(/\[edit.*?\]/g, '').trim();
// Skip certain headers
if (
categoryName.toLowerCase().includes('contents') ||
categoryName.toLowerCase().includes('navigation') ||
@@ -244,9 +223,8 @@ export class CommunityPlaylistService {
}
mainCategory = categoryName;
subCategory = ''; // Reset subcategory when entering new main category
subCategory = '';
} else if (tagName === 'h3') {
// This is a subcategory header
const headerText = $(elem).text().trim();
const categoryName = headerText.replace(/\[edit.*?\]/g, '').trim();
@@ -258,10 +236,8 @@ export class CommunityPlaylistService {
subCategory = categoryName;
} else if (tagName === 'table' && mainCategory) {
// This is a playlist table
const playlists: CommunityPlaylist[] = [];
// Parse table rows (skip header row)
$(elem)
.find('tr')
.slice(1)
@@ -275,13 +251,10 @@ export class CommunityPlaylistService {
const downloadLink = $(cells[3]).find('a').attr('href');
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}`;
}
@@ -296,15 +269,12 @@ export class CommunityPlaylistService {
}
});
// Only add category if it has playlists (non-empty)
if (playlists.length > 0) {
// Determine final category name
let categoryName = mainCategory;
if (mainCategory === 'Games' && subCategory) {
categoryName = `Games - ${subCategory}`;
}
// Find or create category
let category = categories.find((cat) => cat.name === categoryName);
if (!category) {
category = {
@@ -314,26 +284,20 @@ export class CommunityPlaylistService {
categories.push(category);
}
// Add playlists to category
category.playlists.push(...playlists);
}
}
});
// Filter out empty categories
return categories.filter((cat) => cat.playlists.length > 0);
}
/**
* Validate that the downloaded playlist has the required structure
*/
private validatePlaylistStructure(data: unknown): boolean {
if (!data || typeof data !== 'object') return false;
const obj = data as Record<string, unknown>;
// UUID validation regex for path traversal protection
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// Must have required fields
if (!obj.id || typeof obj.id !== 'string') {
logger.warn('[CommunityPlaylist] Validation failed: missing or invalid id');
return false;
@@ -350,13 +314,11 @@ export class CommunityPlaylistService {
return false;
}
// Games array must exist and be valid
if (!Array.isArray(obj.games)) {
logger.warn('[CommunityPlaylist] Validation failed: games is not an array');
return false;
}
// Each game entry must have gameId
for (const game of obj.games) {
if (typeof game !== 'object' || !game.gameId) {
logger.warn('[CommunityPlaylist] Validation failed: game entry missing gameId');
@@ -367,15 +329,11 @@ export class CommunityPlaylistService {
return true;
}
/**
* Convert relative URLs to absolute URLs
*/
private makeAbsoluteUrl(relativeUrl: string): string {
if (relativeUrl.startsWith('http://') || relativeUrl.startsWith('https://')) {
return relativeUrl;
}
// Handle different types of relative URLs
if (relativeUrl.startsWith('/')) {
return `${WIKI_BASE_URL}${relativeUrl}`;
}
-9
View File
@@ -73,9 +73,6 @@ export class DatabaseService {
}
}
/**
* Open database with optimized PRAGMA settings
*/
private static openDatabase(dbPath: string): BetterSqlite3.Database {
const db = new BetterSqlite3(dbPath, {
fileMustExist: true,
@@ -284,9 +281,6 @@ export class DatabaseService {
}
}
/**
* Stop watching the database file
*/
private static stopWatching(): void {
if (this.watcher) {
this.watcher.close();
@@ -427,9 +421,6 @@ export class DatabaseService {
return this.db !== null;
}
/**
* Get database status information
*/
static getStatus(): {
connected: boolean;
sourcePath: string;
+1 -22
View File
@@ -54,9 +54,6 @@ export class DomainService {
};
}
/**
* Get all configured domains
*/
getAllDomains(): Domain[] {
const now = Date.now();
if (this.cachedDomains && now - this.cacheTimestamp < this.CACHE_TTL_MS) {
@@ -74,20 +71,13 @@ export class DomainService {
return this.cachedDomains;
}
/**
* Get the default domain hostname, or null if none configured
*/
getDefaultDomain(): string | null {
const domains = this.getAllDomains();
const defaultDomain = domains.find((d) => d.isDefault);
return defaultDomain?.hostname ?? null;
}
/**
* Validate and normalize a hostname
* Note: Route-level Zod validation performs comprehensive checks.
* This method provides basic sanitization only.
*/
/** Route-level Zod validation performs comprehensive checks; this is basic sanitization only */
private validateHostname(hostname: string): string {
const trimmed = hostname.trim().toLowerCase();
@@ -98,15 +88,11 @@ export class DomainService {
return trimmed;
}
/**
* Add a new domain
*/
addDomain(hostname: string, createdBy: number): Domain {
const validated = this.validateHostname(hostname);
// Wrap count check + duplicate check + insert in a transaction to prevent TOCTOU races
const transaction = this.db.transaction(() => {
// Enforce maximum domain limit
const count = this.db.prepare('SELECT COUNT(*) as count FROM domains').get() as {
count: number;
};
@@ -114,7 +100,6 @@ export class DomainService {
throw new AppError(400, `Maximum of ${this.MAX_DOMAINS} domains allowed`);
}
// Check for duplicate
const existing = this.db
.prepare('SELECT id FROM domains WHERE hostname = ?')
.get(validated) as { id: number } | undefined;
@@ -139,9 +124,6 @@ export class DomainService {
return this.mapRow(row);
}
/**
* Delete a domain by ID
*/
deleteDomain(id: number): void {
const existing = this.db.prepare('SELECT hostname FROM domains WHERE id = ?').get(id) as
| { hostname: string }
@@ -179,9 +161,6 @@ export class DomainService {
logger.info(`[Domains] Deleted domain "${existing.hostname}" (id: ${id})`);
}
/**
* Set a domain as the default (clears previous default in a transaction)
*/
setDefault(id: number): Domain {
const existing = this.db
.prepare('SELECT id, hostname, is_default, created_at FROM domains WHERE id = ?')
-6
View File
@@ -327,16 +327,10 @@ export class DownloadManager {
return this.activeDownloads.has(gameDataId);
}
/**
* Get count of active downloads.
*/
static getActiveDownloadCount(): number {
return this.activeDownloads.size;
}
/**
* Ensure temp directory exists.
*/
private static async ensureTempDir(): Promise<void> {
try {
await fs.promises.mkdir(this.TEMP_DIR, { recursive: true });
-16
View File
@@ -20,9 +20,6 @@ export class FallbackCache<T = any> {
});
}
/**
* Store data in fallback cache
*/
set(key: string, data: T): void {
this.cache.set(key, {
data,
@@ -30,10 +27,6 @@ export class FallbackCache<T = any> {
});
}
/**
* Get data from fallback cache
* Returns both data and metadata about cache age
*/
get(key: string): { data: T; cachedAt: number; age: number } | null {
const cached = this.cache.get(key);
if (!cached) {
@@ -55,23 +48,14 @@ export class FallbackCache<T = any> {
};
}
/**
* Check if key exists in cache
*/
has(key: string): boolean {
return this.cache.has(key);
}
/**
* Clear cache
*/
clear(): void {
this.cache.clear();
}
/**
* Get cache statistics
*/
getStats(): { size: number; max: number } {
return {
size: this.cache.size,
+1 -47
View File
@@ -26,9 +26,6 @@ export class FavoritesService {
this.gameService = new GameService();
}
/**
* Check if game is favorited by user
*/
isFavorited(userId: number, gameId: string): boolean {
const db = this.userDb.getDatabase();
@@ -41,9 +38,6 @@ export class FavoritesService {
return stmt.get(userId, gameId) !== undefined;
}
/**
* Toggle favorite status for a game
*/
toggleFavorite(userId: number, gameId: string): { isFavorited: boolean } {
const db = this.userDb.getDatabase();
@@ -72,9 +66,6 @@ export class FavoritesService {
return toggle();
}
/**
* Add game to favorites
*/
addFavorite(userId: number, gameId: string): boolean {
const db = this.userDb.getDatabase();
@@ -98,9 +89,6 @@ export class FavoritesService {
}
}
/**
* Remove game from favorites
*/
removeFavorite(userId: number, gameId: string): boolean {
const db = this.userDb.getDatabase();
@@ -119,9 +107,6 @@ export class FavoritesService {
return false;
}
/**
* Get all favorites for a user
*/
getUserFavorites(userId: number, limit?: number, offset?: number): Favorite[] {
const db = this.userDb.getDatabase();
@@ -135,13 +120,11 @@ export class FavoritesService {
const params: (number | string)[] = [userId];
if (limit !== undefined) {
// Validate limit is a positive integer
const safeLimit = Math.max(1, Math.min(limit ?? 50, 1000));
query += ` LIMIT ?`;
params.push(safeLimit);
if (offset !== undefined) {
// Validate offset is a non-negative integer
const safeOffset = Math.max(0, offset ?? 0);
query += ` OFFSET ?`;
params.push(safeOffset);
@@ -153,9 +136,6 @@ export class FavoritesService {
return stmt.all(...params) as Favorite[];
}
/**
* Get favorite game IDs for a user
*/
getUserFavoriteGameIds(userId: number): string[] {
const db = this.userDb.getDatabase();
@@ -170,9 +150,6 @@ export class FavoritesService {
return results.map((r) => r.game_id);
}
/**
* Get favorite games with full game data for a user
*/
async getUserFavoriteGames(
userId: number,
limit?: number,
@@ -182,18 +159,15 @@ export class FavoritesService {
): Promise<FavoriteGame[]> {
const db = this.userDb.getDatabase();
// Default values
const effectiveSortBy = sortBy || 'dateAdded';
const effectiveSortOrder = sortOrder || 'desc';
// Get favorites with addedAt
let query = `
SELECT game_id, added_at
FROM user_favorites
WHERE user_id = ?
`;
// Sort by date added at SQL level (efficient)
if (effectiveSortBy === 'dateAdded') {
query += ` ORDER BY added_at ${effectiveSortOrder.toUpperCase()}`;
}
@@ -216,14 +190,12 @@ export class FavoritesService {
return [];
}
// Get full game data for these IDs
const gameIds = favorites.map((f) => f.game_id);
const games = await this.gameService.getGamesByIds(gameIds);
// Build Map for O(1) lookup (was O(N²) with .find() in loop)
// Map for O(1) lookup instead of O(N^2) with .find() in loop
const gamesMap = new Map(games.map((g) => [g.id, g]));
// Merge game data with addedAt timestamps
const favoriteGames: FavoriteGame[] = favorites
.map((fav) => {
const game = gamesMap.get(fav.game_id);
@@ -237,7 +209,6 @@ export class FavoritesService {
})
.filter((game): game is FavoriteGame => game !== null);
// Sort by title if requested (after fetching game data)
if (effectiveSortBy === 'title') {
favoriteGames.sort((a, b) => {
const comparison = a.title.localeCompare(b.title);
@@ -248,11 +219,7 @@ export class FavoritesService {
return favoriteGames;
}
/**
* Batch add favorites
*/
addFavoritesBatch(userId: number, gameIds: string[]): { added: number } {
// Validate batch size
if (gameIds.length > FavoritesService.MAX_BATCH_SIZE) {
throw new AppError(
400,
@@ -285,11 +252,7 @@ export class FavoritesService {
return { added };
}
/**
* Batch remove favorites
*/
removeFavoritesBatch(userId: number, gameIds: string[]): { removed: number } {
// Validate batch size
if (gameIds.length > FavoritesService.MAX_BATCH_SIZE) {
throw new AppError(
400,
@@ -322,9 +285,6 @@ export class FavoritesService {
return { removed };
}
/**
* Get favorites count for a user
*/
getFavoritesCount(userId: number): number {
const db = this.userDb.getDatabase();
@@ -338,9 +298,6 @@ export class FavoritesService {
return result.count;
}
/**
* Clear all favorites for a user
*/
clearAllFavorites(userId: number): number {
const db = this.userDb.getDatabase();
@@ -356,9 +313,6 @@ export class FavoritesService {
return result.changes;
}
/**
* Get favorites statistics
*/
getFavoritesStats(userId: number): {
totalFavorites: number;
oldestFavoriteDate: string | null;
+4 -54
View File
@@ -3,39 +3,19 @@ import path from 'path';
import { logger } from '../utils/logger';
import { PreferencesService } from './PreferencesService';
/**
* Service for importing downloaded game data files into the Flashpoint installation.
* Handles file naming, copying to final location, and cleanup.
*/
export class FileImporter {
/**
* Generate a filename for a downloaded game data file.
* Format: {gameId}-{timestamp}.zip
*
* @param gameId - The game ID
* @returns Generated filename
*/
/** Format: {gameId}-{timestamp}.zip */
static generateFilename(gameId: string): string {
const timestamp = Date.now();
return `${gameId}-${timestamp}.zip`;
}
/**
* Import a temporary downloaded file to the final location.
*
* @param gameId - The game ID
* @param tempFilePath - Absolute path to the temporary file
* @returns Promise resolving to the final absolute file path
* @throws Error if copy fails or disk space is insufficient
*/
static async import(gameId: string, tempFilePath: string): Promise<string> {
try {
// Verify temp file exists
if (!fs.existsSync(tempFilePath)) {
throw new Error(`Temporary file not found: ${tempFilePath}`);
}
// Get stats for logging and validation
const stats = await fs.promises.stat(tempFilePath);
logger.info('Starting file import', {
gameId,
@@ -43,22 +23,16 @@ export class FileImporter {
size: stats.size,
});
// Get destination directory
const dataPacksPath = await PreferencesService.getDataPacksPath();
// Ensure destination directory exists
await fs.promises.mkdir(dataPacksPath, { recursive: true });
// Generate final filename
const filename = this.generateFilename(gameId);
const finalPath = path.join(dataPacksPath, filename);
// Check if destination already exists (shouldn't happen with timestamp)
if (fs.existsSync(finalPath)) {
logger.warn('Destination file already exists, overwriting', { finalPath });
}
// Copy file to final location (atomic operation)
await fs.promises.copyFile(tempFilePath, finalPath);
logger.info('File copied successfully', {
gameId,
@@ -66,7 +40,6 @@ export class FileImporter {
size: stats.size,
});
// Verify copy succeeded
const finalStats = await fs.promises.stat(finalPath);
if (finalStats.size !== stats.size) {
throw new Error(
@@ -74,7 +47,6 @@ export class FileImporter {
);
}
// Delete temporary file
await this.cleanupTempFile(tempFilePath);
logger.info('File import completed successfully', {
@@ -90,7 +62,6 @@ export class FileImporter {
error,
});
// Re-throw with more context
if (error instanceof Error) {
if (error.message.includes('ENOSPC')) {
throw new Error(`Insufficient disk space to import file for game ${gameId}`);
@@ -104,12 +75,7 @@ export class FileImporter {
}
}
/**
* Clean up a temporary file.
* Logs errors but doesn't throw (cleanup is best-effort).
*
* @param tempFilePath - Absolute path to the temporary file
*/
/** Logs errors but doesn't throw (cleanup is best-effort) */
static async cleanupTempFile(tempFilePath: string): Promise<void> {
try {
if (fs.existsSync(tempFilePath)) {
@@ -125,23 +91,13 @@ export class FileImporter {
}
}
/**
* Check if there is sufficient disk space for a file.
* Note: This is an approximation as fs doesn't provide disk space API on all platforms.
*
* @param _size - Required size in bytes
* @returns Promise resolving to true if space check passes
*/
/** Approximation - fs doesn't provide disk space API on all platforms */
static async checkDiskSpace(_size: number): Promise<boolean> {
try {
// Get destination directory
const dataPacksPath = await PreferencesService.getDataPacksPath();
// Ensure directory exists
await fs.promises.mkdir(dataPacksPath, { recursive: true });
// On Windows, we can't easily check disk space without native modules
// This is a basic check - just ensure we can write a test file
// On Windows, no easy disk space check without native modules - just verify write access
const testPath = path.join(dataPacksPath, '.diskspace-test');
await fs.promises.writeFile(testPath, 'test');
await fs.promises.unlink(testPath);
@@ -156,12 +112,6 @@ export class FileImporter {
}
}
/**
* Get the size of a file.
*
* @param filePath - Absolute path to the file
* @returns Promise resolving to file size in bytes
*/
static async getFileSize(filePath: string): Promise<number> {
const stats = await fs.promises.stat(filePath);
return stats.size;
+3 -37
View File
@@ -6,9 +6,6 @@ import { DatabaseService } from './DatabaseService';
import { gameZipServer } from '../game/gamezipserver';
import { GameDataDownloader } from '../game/services/GameDataDownloader';
/**
* Game data entry from the Flashpoint database
*/
interface GameDataEntry {
id: number;
gameId: string;
@@ -18,14 +15,7 @@ interface GameDataEntry {
presentOnDisk: number;
}
/**
* Service for managing game data ZIP files
* Handles auto-mounting of ZIP files for games with game data
*/
export class GameDataService {
/**
* Get game data entry from the Flashpoint database
*/
private getGameDataEntry(gameId: string): GameDataEntry | null {
try {
const sql = `
@@ -45,14 +35,10 @@ export class GameDataService {
}
/**
* Mount the ZIP file for a game if it has game data.
* Calls gameZipServer directly (in-process, no HTTP).
* If the ZIP doesn't exist locally, triggers background download
* from the configured gameDataSources in preferences.json.
* If ZIP doesn't exist locally, triggers background download from gameDataSources.
*
* @param gameId The game's UUID
* @returns { mounted: true } if ZIP is mounted and ready,
* { downloading: true } if a background download was started,
* @returns { mounted: true } if ready, { downloading: true } if download started,
* { mounted: false, downloading: false } if no ZIP could be found
*/
async mountGameZip(gameId: string): Promise<{ mounted: boolean; downloading: boolean }> {
@@ -60,35 +46,28 @@ export class GameDataService {
const mountId = gameId;
const gamesPath = config.flashpointGamesPath;
// Get game data entry from database to get dateAdded and sha256
const gameDataEntry = this.getGameDataEntry(gameId);
// Build ZIP path - try direct path first when we have dateAdded
let zipPath: string | null = null;
// Try direct path first when we have enough info (avoids expensive readdir)
// Try direct path first to avoid expensive readdir
if (gameDataEntry?.dateAdded) {
try {
const expectedFilename = GameDataDownloader.getFilename(gameId, gameDataEntry.dateAdded);
const expectedPath = path.join(gamesPath, expectedFilename);
await fs.access(expectedPath);
// File exists at expected path - use it directly
zipPath = expectedPath;
logger.debug(`[GameDataService] Found ZIP at expected path: ${expectedFilename}`);
} catch {
// Expected file not found, fall through to readdir
logger.debug(`[GameDataService] Expected ZIP not found, trying readdir fallback`);
}
}
// Only readdir as fallback when direct path doesn't exist
if (!zipPath) {
let files: string[];
try {
files = await fs.readdir(gamesPath);
} catch (err) {
logger.warn(`[GameDataService] Cannot read games directory: ${gamesPath}`, err);
// Fall through to attempt download via gameZipServer
files = [];
}
const zipPattern = `${gameId}-`;
@@ -97,7 +76,6 @@ export class GameDataService {
if (zipFile) {
zipPath = path.join(gamesPath, zipFile);
} else if (gameDataEntry?.dateAdded) {
// Use GameDataDownloader.getFilename() to construct expected filename
const expectedFilename = GameDataDownloader.getFilename(gameId, gameDataEntry.dateAdded);
zipPath = path.join(gamesPath, expectedFilename);
logger.info(
@@ -111,7 +89,6 @@ export class GameDataService {
logger.info(`[GameDataService] Mounting ZIP for game ${gameId}`);
// Direct call to gameZipServer (in-process, no HTTP)
const result = await gameZipServer.mountZip({
id: mountId,
zipPath,
@@ -138,16 +115,11 @@ export class GameDataService {
}
}
/**
* Mount ZIP files for multiple games
* @param activeDataIds Array of activeDataId values
*/
async mountMultipleGameZips(activeDataIds: number[]): Promise<void> {
logger.info(`[GameDataService] Mounting ${activeDataIds.length} game ZIPs...`);
if (activeDataIds.length === 0) return;
// Batch query: single IN clause instead of N individual queries
const placeholders = activeDataIds.map(() => '?').join(', ');
const rows = DatabaseService.all(
`SELECT id, gameId FROM game_data WHERE id IN (${placeholders})`,
@@ -170,9 +142,6 @@ export class GameDataService {
);
}
/**
* Get list of all available game data ZIP files
*/
async listAvailableGameDataZips(): Promise<
Array<{ activeDataId: number; filename: string; size: number }>
> {
@@ -184,8 +153,6 @@ export class GameDataService {
for (const file of files) {
if (file.endsWith('.zip')) {
// Extract gameId from filename (format: {gameId}-{timestamp}.zip)
// gameId is a UUID, not a numeric ID
const match = file.match(
/^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-(\d+)\.zip$/i
);
@@ -213,5 +180,4 @@ export class GameDataService {
}
}
// Singleton instance
export const gameDataService = new GameDataService();
+2 -70
View File
@@ -16,9 +16,6 @@ export interface GameData {
parameters: string | null;
}
/**
* Internal interface for database rows returned by SQL queries.
*/
interface GameDataRow {
id: number;
gameId: string;
@@ -33,27 +30,13 @@ interface GameDataRow {
}
/**
* Service for updating the Flashpoint database after game data downloads.
*
* NOTE: This service intentionally writes to flashpoint.sqlite to update
* presentOnDisk and path fields in game_data/game tables. This matches
* the Flashpoint Launcher's behavior when downloading game data.
* better-sqlite3 handles file locking, and the backend's DatabaseService
* file watcher handles reloads when the Launcher makes changes.
*
* All write operations are wrapped in transactions for atomicity to prevent
* database inconsistencies if any step fails.
*/
export class GameDatabaseUpdater {
/**
* Update database to mark game data as downloaded and set its path.
* Updates both game_data and game tables in a transaction.
*
* @param gameDataId - The game_data.id to update
* @param filePath - Absolute path to the downloaded file
* @returns Promise resolving to updated GameData object
* @throws Error if update fails or database transaction fails
*/
static async markAsDownloaded(gameDataId: number, filePath: string): Promise<GameData> {
try {
logger.info('Updating database for downloaded game data', {
@@ -61,15 +44,12 @@ export class GameDatabaseUpdater {
filePath,
});
// First, fetch the current game data to get the gameId
const gameData = await this.getGameData(gameDataId);
if (!gameData) {
throw new Error(`Game data not found with id: ${gameDataId}`);
}
// Convert absolute path to relative path (Flashpoint Launcher compatibility)
// Flashpoint stores paths relative to the Flashpoint installation directory
// Example: "Data/Games/uuid-timestamp.zip" instead of "D:/Flashpoint/Data/Games/uuid-timestamp.zip"
// Flashpoint stores paths relative to installation dir (e.g. "Data/Games/uuid-timestamp.zip")
const relativePath = this.makeRelativePath(filePath);
logger.debug('Path conversion for database storage', {
@@ -77,10 +57,8 @@ export class GameDatabaseUpdater {
relativePath,
});
// Wrap both UPDATE statements in a transaction for atomicity
const db = DatabaseService.getDatabase();
const updateTransaction = db.transaction(() => {
// Update game_data table
const updateGameDataSql = `
UPDATE game_data
SET presentOnDisk = 1, path = ?
@@ -96,7 +74,6 @@ export class GameDatabaseUpdater {
logger.debug('game_data UPDATE successful');
// Check if this game_data is the active data for the game
const gameSql = `
SELECT id, activeDataId
FROM game
@@ -107,7 +84,6 @@ export class GameDatabaseUpdater {
| undefined;
if (game && game.activeDataId === gameDataId) {
// Update game table to mark active data as on disk
const updateGameSql = `
UPDATE game
SET activeDataOnDisk = 1
@@ -133,10 +109,7 @@ export class GameDatabaseUpdater {
}
});
// Execute the transaction
updateTransaction();
// Save database to disk
await this.saveDatabase();
logger.info('Database updated successfully', {
@@ -145,7 +118,6 @@ export class GameDatabaseUpdater {
filePath,
});
// Fetch and return updated game data
const updated = await this.getGameData(gameDataId);
if (!updated) {
throw new Error('Failed to fetch updated game data');
@@ -159,20 +131,12 @@ export class GameDatabaseUpdater {
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'}`
);
}
}
/**
* Get game data by ID.
*
* @param gameDataId - The game_data.id
* @returns Promise resolving to GameData object or null if not found
*/
static async getGameData(gameDataId: number): Promise<GameData | null> {
try {
const sql = `
@@ -199,15 +163,9 @@ export class GameDatabaseUpdater {
}
}
/**
* Save the in-memory database to disk.
* Uses atomic write with temp file + rename for safety.
* Properly handles database locking on Windows with retries and fallback.
*/
private static async saveDatabase(): Promise<void> {
try {
// With better-sqlite3, changes are automatically written to disk
// We just need to ensure they're flushed
// better-sqlite3 writes automatically; just ensure changes are flushed
DatabaseService.save();
logger.info('Database changes flushed to disk', {
@@ -221,9 +179,6 @@ export class GameDatabaseUpdater {
}
}
/**
* Map database row to GameData object.
*/
private static mapRowToGameData(row: GameDataRow): GameData {
return {
id: row.id,
@@ -239,12 +194,6 @@ export class GameDatabaseUpdater {
};
}
/**
* Check if game data is already downloaded.
*
* @param gameDataId - The game_data.id
* @returns Promise resolving to true if already downloaded
*/
static async isDownloaded(gameDataId: number): Promise<boolean> {
try {
const sql = `
@@ -261,17 +210,10 @@ export class GameDatabaseUpdater {
}
}
/**
* Mark game data as not downloaded (for cleanup/error scenarios).
* Wrapped in transaction for consistency with markAsDownloaded.
*
* @param gameDataId - The game_data.id
*/
static async markAsNotDownloaded(gameDataId: number): Promise<void> {
try {
const db = DatabaseService.getDatabase();
// Wrap UPDATE in transaction for atomicity
const updateTransaction = db.transaction(() => {
const sql = `
UPDATE game_data
@@ -282,7 +224,6 @@ export class GameDatabaseUpdater {
db.prepare(sql).run(gameDataId);
});
// Execute the transaction
updateTransaction();
await this.saveDatabase();
@@ -294,19 +235,10 @@ export class GameDatabaseUpdater {
}
}
/**
* Convert an absolute file path to a relative path for Flashpoint Launcher compatibility.
* Flashpoint stores paths relative to the Flashpoint installation directory.
*
* @param absolutePath - Absolute file path (e.g., "D:/Flashpoint/Data/Games/uuid-123.zip")
* @returns Relative path (e.g., "Data/Games/uuid-123.zip")
*/
private static makeRelativePath(absolutePath: string): string {
// Get relative path from Flashpoint installation directory
const relativePath = path.relative(config.flashpointPath, absolutePath);
// Flashpoint uses forward slashes for cross-platform compatibility
// Convert Windows backslashes to forward slashes
const normalizedPath = relativePath.replace(/\\/g, '/');
return normalizedPath;
+2 -26
View File
@@ -27,7 +27,6 @@ export class GameSearchCache {
updateAgeOnGet: true, // Reset TTL on cache hit
allowStale: false, // Never return stale data
// Calculate size based on number of results (larger result sets = more memory)
sizeCalculation: (value) => {
if (!value || !value.data || !Array.isArray(value.data)) {
logger.error('[GameSearchCache] Invalid value in sizeCalculation', { value });
@@ -37,7 +36,6 @@ export class GameSearchCache {
return value.data.length + 1;
},
// Optional: Log evictions for monitoring
dispose: (value, key) => {
logger.debug(`[GameSearchCache] Evicted cache entry: ${key}`);
},
@@ -46,10 +44,7 @@ export class GameSearchCache {
private static gameService = new GameService();
private static fallbackCache = new FallbackCache<PaginatedResult<Game>>(500);
/**
* Generate cache key from search query
* Ensures consistent key generation for same queries regardless of parameter order
*/
/** Ensures consistent key generation regardless of parameter order */
private static generateCacheKey(query: GameSearchQuery): string {
const normalizedQuery = {
search: query.search || '',
@@ -77,19 +72,11 @@ export class GameSearchCache {
return JSON.stringify(normalizedQuery);
}
/**
* Search games with caching, request coalescing, and graceful degradation
* - Checks cache first
* - Coalesces concurrent requests for the same query (cache stampede prevention)
* - 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 }> {
const cacheKey = this.generateCacheKey(query);
// Try to get from primary cache
const cached = this.cache.get(cacheKey);
if (cached) {
logger.debug(`[GameSearchCache] Cache HIT: ${cacheKey.substring(0, 100)}...`);
@@ -97,18 +84,16 @@ export class GameSearchCache {
return cached;
}
// Check if request is already in-flight (cache stampede prevention)
// Cache stampede prevention: coalesce concurrent requests for the same query
const inFlight = this.inFlightRequests.get(cacheKey);
if (inFlight) {
logger.debug(`[GameSearchCache] Coalescing request for: ${cacheKey.substring(0, 100)}...`);
return inFlight;
}
// Cache miss - query database
logger.debug(`[GameSearchCache] Cache MISS: ${cacheKey.substring(0, 100)}...`);
PerformanceMetrics.recordCacheMiss('gameSearch');
// Execute query and track the promise for coalescing
const queryPromise = this.executeQuery(query, cacheKey);
this.inFlightRequests.set(cacheKey, queryPromise);
@@ -204,9 +189,6 @@ export class GameSearchCache {
);
}
/**
* Get cache statistics for monitoring
*/
static getStats(): {
primary: { size: number; max: number; ttl: number };
fallback: { size: number; max: number };
@@ -221,9 +203,6 @@ export class GameSearchCache {
};
}
/**
* Check if a query is cached
*/
static has(query: GameSearchQuery): boolean {
const cacheKey = this.generateCacheKey(query);
return this.cache.has(cacheKey);
@@ -317,9 +296,6 @@ export class GameSearchCache {
});
}
/**
* Get pre-warm status
*/
static isPrewarmed(): boolean {
// Check if at least the default games query is cached
const defaultQuery: GameSearchQuery = {
+1 -1
View File
@@ -794,7 +794,7 @@ export class GameService {
const results = DatabaseService.all(sql, []) as Array<{ tagsStr: string }>;
// Parse pipe-delimited tags and count occurrences
// Tags are semicolon-delimited
const tagCounts = new Map<string, number>();
for (const row of results) {
-3
View File
@@ -46,9 +46,6 @@ export class ImageUrlService {
return defaultUrls;
}
/**
* Get local image folder paths from preferences
*/
static async getImageFolderPaths(): Promise<{
images: string;
logos: string;
@@ -28,9 +28,6 @@ export interface JobExecutionLog {
}
export class JobExecutionService {
/**
* Start logging a job execution
*/
startExecution(jobId: string, jobName: string, triggeredBy: string): number {
const startedAt = new Date().toISOString();
const result = UserDatabaseService.run(
@@ -42,9 +39,6 @@ export class JobExecutionService {
return result.lastInsertRowid as number;
}
/**
* Complete a job execution log
*/
completeExecution(
id: number,
status: 'success' | 'failed',
@@ -77,9 +71,6 @@ export class JobExecutionService {
logger.info(`[JobExecution] Completed log ${id}: ${status} (${durationSeconds}s)`);
}
/**
* Get logs for a specific job
*/
getJobLogs(jobId: string, limit = 50, offset = 0): { data: JobExecutionLog[]; total: number } {
const total = UserDatabaseService.get(
'SELECT COUNT(*) as count FROM job_execution_logs WHERE job_id = ?',
@@ -100,9 +91,6 @@ export class JobExecutionService {
};
}
/**
* Get all recent logs
*/
getAllLogs(limit = 100, offset = 0): { data: JobExecutionLog[]; total: number } {
const total = UserDatabaseService.get(
'SELECT COUNT(*) as count FROM job_execution_logs',
@@ -122,9 +110,6 @@ export class JobExecutionService {
};
}
/**
* Get latest execution for a job
*/
getLatestExecution(jobId: string): JobExecutionLog | null {
const log = UserDatabaseService.get(
`SELECT * FROM job_execution_logs
@@ -137,9 +122,6 @@ export class JobExecutionService {
return log ? this.mapToJobExecutionLog(log) : null;
}
/**
* Cleanup old logs (older than retention days)
*/
cleanupOldLogs(retentionDays = 30): number {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
-36
View File
@@ -28,17 +28,11 @@ class JobSchedulerService {
private currentExecutions: Map<string, number> = new Map(); // jobId -> logId
private lastRunTimes: Map<string, number> = new Map(); // jobId -> timestamp
/**
* Register a job with the scheduler
*/
registerJob(job: Job): void {
this.jobs.set(job.id, job);
logger.info(`Job registered: ${job.name} (${job.id})`);
}
/**
* Start a specific job with cron scheduling
*/
startJob(jobId: string): void {
const job = this.jobs.get(jobId);
if (!job) {
@@ -71,9 +65,6 @@ class JobSchedulerService {
logger.info(`Job started: ${job.name} (schedule: ${job.cronSchedule})`);
}
/**
* Run job with execution logging
*/
async runJobWithLogging(jobId: string, triggeredBy: string): Promise<void> {
const job = this.jobs.get(jobId);
if (!job) return;
@@ -129,9 +120,6 @@ class JobSchedulerService {
});
}
/**
* Stop a specific job
*/
stopJob(jobId: string): void {
const task = this.cronTasks.get(jobId);
if (task) {
@@ -143,9 +131,6 @@ class JobSchedulerService {
}
}
/**
* Update job configuration and restart if enabled
*/
updateJob(jobId: string, updates: Partial<Job>): void {
const job = this.jobs.get(jobId);
if (!job) {
@@ -166,9 +151,6 @@ class JobSchedulerService {
logger.info(`Job updated: ${job.name}`);
}
/**
* Update only the enabled state of a job
*/
updateJobEnabled(jobId: string, enabled: boolean): void {
const job = this.jobs.get(jobId);
if (!job) {
@@ -181,9 +163,6 @@ class JobSchedulerService {
logger.info(`Job ${job.name} enabled state updated to: ${enabled}`);
}
/**
* Get job status
*/
getJobStatus(jobId: string): { enabled: boolean; running: boolean; cronSchedule: string } | null {
const job = this.jobs.get(jobId);
if (!job) {
@@ -197,16 +176,10 @@ class JobSchedulerService {
};
}
/**
* Get all jobs
*/
getAllJobs(): Map<string, Job> {
return new Map(this.jobs);
}
/**
* Get enriched job status with execution info
*/
getJobStatusEnriched(jobId: string): JobStatusEnriched | null {
const job = this.jobs.get(jobId);
if (!job) return null;
@@ -237,9 +210,6 @@ class JobSchedulerService {
};
}
/**
* Get all jobs with enriched status
*/
getAllJobsEnriched(): JobStatusEnriched[] {
const enriched: JobStatusEnriched[] = [];
for (const [jobId] of this.jobs) {
@@ -249,9 +219,6 @@ class JobSchedulerService {
return enriched;
}
/**
* Start all enabled jobs
*/
startAllEnabledJobs(): void {
logger.info('Starting all enabled jobs...');
for (const [jobId, job] of this.jobs) {
@@ -261,9 +228,6 @@ class JobSchedulerService {
}
}
/**
* Stop all jobs
*/
stopAllJobs(): void {
logger.info('Stopping all jobs...');
for (const jobId of this.cronTasks.keys()) {
@@ -102,16 +102,10 @@ export class MetadataSyncService {
this.syncStatusService = SyncStatusService.getInstance();
}
/**
* Get the current Flashpoint edition from config (auto-detected from version.txt)
*/
private getEdition(): string {
return config.flashpointEdition;
}
/**
* Main sync function - syncs all metadata (games, tags, platforms)
*/
async syncMetadata(): Promise<SyncResult> {
// Ultimate edition does not support metadata sync
if (this.getEdition() === 'ultimate') {
@@ -220,9 +214,6 @@ export class MetadataSyncService {
}
}
/**
* Sync platforms from FPFSS API
*/
private async syncPlatforms(
source: GameMetadataSource
): Promise<{ count: number; latestDate: string | null }> {
@@ -258,9 +249,6 @@ export class MetadataSyncService {
}
}
/**
* Sync tags from FPFSS API
*/
private async syncTags(
source: GameMetadataSource
): Promise<{ count: number; latestDate: string | null }> {
@@ -296,9 +284,6 @@ export class MetadataSyncService {
}
}
/**
* Sync games from FPFSS API (with pagination)
*/
private async syncGames(
source: GameMetadataSource
): Promise<{ updated: number; deleted: number; latestDate: string | null }> {
@@ -376,9 +361,6 @@ export class MetadataSyncService {
}
}
/**
* Sync deleted games from FPFSS API
*/
private async syncDeletedGames(source: GameMetadataSource): Promise<number> {
try {
const after = source.games?.latestDeleteTime || '1970-01-01';
@@ -696,9 +678,6 @@ export class MetadataSyncService {
}
}
/**
* Update preferences.json with new timestamps after successful sync
*/
private async updatePreferencesTimestamps(
source: GameMetadataSource,
preferences: FlashpointPreferences,
@@ -43,9 +43,6 @@ export class MetadataUpdateService {
this.preferencesPath = path.join(config.flashpointPath, 'preferences.json');
}
/**
* Get the current Flashpoint edition from config (auto-detected from version.txt)
*/
getEdition(): string {
return config.flashpointEdition;
}
@@ -202,9 +199,6 @@ export class MetadataUpdateService {
}
}
/**
* Format the last update time in a human-readable format
*/
formatLastUpdateTime(timestamp: string): string {
const date = new Date(timestamp);
const now = new Date();
@@ -1,16 +1,10 @@
import { logger } from '../utils/logger';
/**
* Metric data point
*/
interface MetricPoint {
timestamp: number;
value: number;
}
/**
* Aggregated metric statistics
*/
interface MetricStats {
count: number;
sum: number;
@@ -57,9 +51,6 @@ export class PerformanceMetrics {
);
}
/**
* Record API endpoint response time
*/
static recordEndpoint(path: string, method: string, durationMs: number): void {
const normalizedPath = this.normalizePath(path);
const key = `${method} ${normalizedPath}`;
@@ -94,9 +85,6 @@ export class PerformanceMetrics {
}
}
/**
* Record cache hit
*/
static recordCacheHit(cacheName: string): void {
if (!this.cacheMetrics.has(cacheName)) {
this.cacheMetrics.set(cacheName, { hits: 0, misses: 0 });
@@ -105,9 +93,6 @@ export class PerformanceMetrics {
this.cacheMetrics.get(cacheName)!.hits++;
}
/**
* Record cache miss
*/
static recordCacheMiss(cacheName: string): void {
if (!this.cacheMetrics.has(cacheName)) {
this.cacheMetrics.set(cacheName, { hits: 0, misses: 0 });
@@ -116,9 +101,6 @@ export class PerformanceMetrics {
this.cacheMetrics.get(cacheName)!.misses++;
}
/**
* Record database query execution time
*/
static recordQuery(durationMs: number): void {
this.queryMetrics.push({
timestamp: Date.now(),
@@ -131,9 +113,6 @@ export class PerformanceMetrics {
}
}
/**
* Get statistics for a specific endpoint
*/
static getEndpointStats(path: string, method: string): MetricStats | null {
const key = `${method} ${path}`;
const metrics = this.endpointMetrics.get(key);
@@ -145,9 +124,6 @@ export class PerformanceMetrics {
return this.calculateStats(metrics);
}
/**
* Get statistics for all endpoints
*/
static getAllEndpointStats(): Record<string, MetricStats> {
const stats: Record<string, MetricStats> = {};
@@ -160,9 +136,6 @@ export class PerformanceMetrics {
return stats;
}
/**
* Get cache hit rates
*/
static getCacheStats(): Record<string, { hits: number; misses: number; hitRate: number }> {
const stats: Record<string, { hits: number; misses: number; hitRate: number }> = {};
@@ -180,9 +153,6 @@ export class PerformanceMetrics {
return stats;
}
/**
* Get query performance statistics
*/
static getQueryStats(): MetricStats | null {
if (this.queryMetrics.length === 0) {
return null;
@@ -191,9 +161,6 @@ export class PerformanceMetrics {
return this.calculateStats(this.queryMetrics);
}
/**
* Get top N slowest endpoints
*/
static getSlowestEndpoints(limit: number = 10): Array<{ endpoint: string; avgDuration: number }> {
const endpointStats = this.getAllEndpointStats();
@@ -206,9 +173,6 @@ export class PerformanceMetrics {
.slice(0, limit);
}
/**
* Calculate statistics from metric points
*/
private static calculateStats(points: MetricPoint[]): MetricStats {
const values = points.map((p) => p.value).sort((a, b) => a - b);
const count = values.length;
@@ -226,17 +190,11 @@ export class PerformanceMetrics {
};
}
/**
* Calculate percentile value
*/
private static percentile(sortedValues: number[], percentile: number): number {
const index = Math.ceil(sortedValues.length * percentile) - 1;
return sortedValues[Math.max(0, index)];
}
/**
* Clear old metrics (older than retention period)
*/
static cleanupOldMetrics(): void {
const cutoff = Date.now() - this.RETENTION_MS;
@@ -257,9 +215,6 @@ export class PerformanceMetrics {
logger.debug('[PerformanceMetrics] Cleaned up old metrics');
}
/**
* Reset all metrics
*/
static reset(): void {
this.endpointMetrics.clear();
this.cacheMetrics.clear();
@@ -267,9 +222,6 @@ export class PerformanceMetrics {
logger.info('[PerformanceMetrics] All metrics reset');
}
/**
* Get summary of all metrics
*/
static getSummary(): {
endpoints: { total: number; slowest: Array<{ endpoint: string; avgDuration: number }> };
caches: Record<string, { hits: number; misses: number; hitRate: number }>;
-27
View File
@@ -56,9 +56,6 @@ export class PermissionCache {
}
}
/**
* Get cached user permissions
*/
static getUserPermissions(userId: number): string[] | null {
const entry = this.userPermissionsCache.get(userId);
@@ -77,9 +74,6 @@ export class PermissionCache {
return entry.data;
}
/**
* Set user permissions in cache
*/
static setUserPermissions(userId: number, permissions: string[]): void {
this.userPermissionsCache.set(userId, {
data: permissions,
@@ -91,9 +85,6 @@ export class PermissionCache {
);
}
/**
* Get cached role permissions
*/
static getRolePermissions(roleId: number): string[] | null {
const entry = this.rolePermissionsCache.get(roleId);
@@ -112,9 +103,6 @@ export class PermissionCache {
return entry.data;
}
/**
* Set role permissions in cache
*/
static setRolePermissions(roleId: number, permissions: string[]): void {
this.rolePermissionsCache.set(roleId, {
data: permissions,
@@ -144,27 +132,18 @@ export class PermissionCache {
logger.info(`[PermissionCache] Invalidated cache for role ${roleId} and all user permissions`);
}
/**
* Invalidate all user permissions
*/
static invalidateAllUsers(): void {
const count = this.userPermissionsCache.size;
this.userPermissionsCache.clear();
logger.info(`[PermissionCache] Invalidated all user permissions (${count} entries)`);
}
/**
* Invalidate all role permissions
*/
static invalidateAllRoles(): void {
const count = this.rolePermissionsCache.size;
this.rolePermissionsCache.clear();
logger.info(`[PermissionCache] Invalidated all role permissions (${count} entries)`);
}
/**
* Clear all caches
*/
static clearAll(): void {
const userCount = this.userPermissionsCache.size;
const roleCount = this.rolePermissionsCache.size;
@@ -175,9 +154,6 @@ export class PermissionCache {
logger.info(`[PermissionCache] Cleared all caches (${userCount} users, ${roleCount} roles)`);
}
/**
* Cleanup expired entries
*/
static cleanup(): void {
const now = Date.now();
let userExpired = 0;
@@ -206,9 +182,6 @@ export class PermissionCache {
}
}
/**
* Get cache statistics
*/
static getStats(): {
userCacheSize: number;
roleCacheSize: number;
@@ -40,15 +40,10 @@ export interface UserStats {
}
export class PlayTrackingService {
/**
* Start a new play session
*/
async startPlaySession(userId: number, gameId: string, gameTitle: string): Promise<string> {
try {
// Generate unique session ID (UUID format)
const sessionId = randomUUID();
// Insert play session
UserDatabaseService.run(
`INSERT INTO user_game_plays (user_id, game_id, game_title, session_id, started_at)
VALUES (?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`,
@@ -199,9 +194,6 @@ export class PlayTrackingService {
logger.debug(`[PlayTracking] Skipping Flashpoint DB update for game ${gameId} (read-only)`);
}
/**
* Get user's overall stats
*/
async getUserStats(userId: number): Promise<UserStats | null> {
try {
const stats = UserDatabaseService.get(
@@ -235,9 +227,6 @@ export class PlayTrackingService {
}
}
/**
* Get user's game-specific stats
*/
async getUserGameStats(userId: number, limit = 50, offset = 0): Promise<GameStats[]> {
try {
const stats = UserDatabaseService.all(
@@ -263,9 +252,6 @@ export class PlayTrackingService {
}
}
/**
* Get user's recent play sessions
*/
async getUserPlayHistory(userId: number, limit = 50, offset = 0): Promise<PlaySession[]> {
try {
const sessions = UserDatabaseService.all(
@@ -293,9 +279,6 @@ export class PlayTrackingService {
}
}
/**
* Get top played games for a user
*/
async getTopGames(userId: number, limit = 10): Promise<GameStats[]> {
try {
const stats = UserDatabaseService.all(
@@ -321,9 +304,6 @@ export class PlayTrackingService {
}
}
/**
* Get play activity over time (daily aggregation)
*/
async getPlayActivityOverTime(
userId: number,
days = 30
@@ -358,9 +338,6 @@ export class PlayTrackingService {
}
}
/**
* Get games distribution by playtime
*/
async getGamesDistribution(
userId: number,
limit = 10
@@ -58,12 +58,10 @@ export class PreferencesService {
try {
const preferencesPath = path.join(config.flashpointPath, 'preferences.json');
// Check if file exists
if (!fs.existsSync(preferencesPath)) {
throw new Error(`Preferences file not found at: ${preferencesPath}`);
}
// Read and parse preferences
const content = await fs.promises.readFile(preferencesPath, 'utf-8');
const parsed = JSON.parse(content);
@@ -88,9 +86,6 @@ export class PreferencesService {
}
}
/**
* Validate that preferences contain required fields.
*/
private static validatePreferences(prefs: Record<string, unknown>): void {
if (!prefs || typeof prefs !== 'object') {
throw new Error('Preferences must be an object');
@@ -118,17 +113,11 @@ export class PreferencesService {
}
}
/**
* Get game data sources from preferences.
*/
static async getGameDataSources(): Promise<GameDataSource[]> {
const prefs = await this.getPreferences();
return prefs.gameDataSources || [];
}
/**
* Get the absolute path to the data packs folder.
*/
static async getDataPacksPath(): Promise<string> {
const prefs = await this.getPreferences();
const dataPacksPath = prefs.dataPacksFolderPath || 'Data/Games';
@@ -137,9 +126,6 @@ export class PreferencesService {
return path.resolve(config.flashpointPath, dataPacksPath);
}
/**
* Force reload preferences from disk.
*/
static async reload(): Promise<void> {
this.preferences = null;
this.lastLoadTime = 0;
-21
View File
@@ -68,9 +68,6 @@ export class RoleService {
}));
}
/**
* Get role by ID
*/
async getRoleById(id: number): Promise<Role | null> {
const role = UserDatabaseService.get<{
id: number;
@@ -98,9 +95,6 @@ export class RoleService {
};
}
/**
* Get all permissions
*/
async getPermissions(): Promise<Permission[]> {
return UserDatabaseService.all(
`SELECT id, name, description, resource, action FROM permissions ORDER BY resource, action`,
@@ -108,9 +102,6 @@ export class RoleService {
);
}
/**
* Get permissions for a role
*/
private getRolePermissions(roleId: number): Permission[] {
return UserDatabaseService.all(
`SELECT p.id, p.name, p.description, p.resource, p.action
@@ -122,9 +113,6 @@ export class RoleService {
);
}
/**
* Create new role
*/
async createRole(
name: string,
description?: string,
@@ -154,9 +142,6 @@ export class RoleService {
return (await this.getRoleById(roleId))!;
}
/**
* Update role
*/
async updateRole(
id: number,
name?: string,
@@ -216,9 +201,6 @@ export class RoleService {
return (await this.getRoleById(id))!;
}
/**
* Update role permissions
*/
async updateRolePermissions(roleId: number, permissionIds: number[]): Promise<void> {
const role = await this.getRoleById(roleId);
if (!role) {
@@ -254,9 +236,6 @@ export class RoleService {
// but that would defeat the purpose of caching. The TTL handles eventual consistency.
}
/**
* Delete role
*/
async deleteRole(id: number): Promise<void> {
const role = await this.getRoleById(id);
if (!role) {
-19
View File
@@ -10,15 +10,11 @@ interface GitHubAsset {
browser_download_url: string;
}
/**
* Service for managing Ruffle emulator versions
*/
export class RuffleService {
private readonly frontendPublicPath: string;
private readonly githubApiUrl = 'https://api.github.com/repos/ruffle-rs/ruffle/releases';
constructor() {
// Path to frontend's public/ruffle directory
this.frontendPublicPath = path.resolve(__dirname, '../../../frontend/public/ruffle');
}
@@ -47,9 +43,6 @@ export class RuffleService {
return version;
}
/**
* Get the currently installed Ruffle version from local files
*/
getCurrentVersion(): string | null {
try {
const packageJsonPath = path.join(this.frontendPublicPath, 'package.json');
@@ -64,9 +57,6 @@ export class RuffleService {
}
}
/**
* Get latest nightly release from GitHub
*/
async getLatestVersion(): Promise<{
version: string;
downloadUrl: string;
@@ -107,9 +97,6 @@ export class RuffleService {
}
}
/**
* Check if a Ruffle update is available
*/
async checkForUpdate(): Promise<{
currentVersion: string | null;
latestVersion: string;
@@ -135,9 +122,6 @@ export class RuffleService {
};
}
/**
* Download and install Ruffle from GitHub release
*/
async updateRuffle(): Promise<{
success: boolean;
version: string;
@@ -233,9 +217,6 @@ export class RuffleService {
}
}
/**
* Verify Ruffle files exist in the public directory
*/
verifyInstallation(): boolean {
try {
const ruffleJsPath = path.join(this.frontendPublicPath, 'ruffle.js');
-21
View File
@@ -42,23 +42,14 @@ export class SyncStatusService {
return SyncStatusService.instance;
}
/**
* Get current sync status
*/
getStatus(): SyncStatus {
return { ...this.status };
}
/**
* Check if a sync is currently running
*/
isRunning(): boolean {
return this.status.isRunning;
}
/**
* Start a new sync operation
*/
startSync(): void {
if (this.status.isRunning) {
throw new Error('Sync already in progress');
@@ -75,9 +66,6 @@ export class SyncStatusService {
logger.info('[SyncStatus] Sync started');
}
/**
* Update sync progress
*/
updateProgress(stage: string, progress: number, message: string): void {
this.status.stage = stage;
this.status.progress = Math.min(100, Math.max(0, progress));
@@ -86,9 +74,6 @@ export class SyncStatusService {
logger.debug(`[SyncStatus] Progress: ${this.status.progress}% - ${stage} - ${message}`);
}
/**
* Complete sync successfully
*/
completeSync(result: SyncStatus['result']): void {
this.status.isRunning = false;
this.status.stage = 'completed';
@@ -100,9 +85,6 @@ export class SyncStatusService {
logger.info('[SyncStatus] Sync completed successfully', result);
}
/**
* Mark sync as failed
*/
failSync(error: string): void {
this.status.isRunning = false;
this.status.stage = 'failed';
@@ -113,9 +95,6 @@ export class SyncStatusService {
logger.error('[SyncStatus] Sync failed:', error);
}
/**
* Reset sync status
*/
reset(): void {
this.status = {
isRunning: false,
@@ -1,14 +1,7 @@
import { UserDatabaseService } from './UserDatabaseService';
import { SettingValue, SettingRow, CategorySettings, SettingDataType } from '../types/settings';
/**
* System Settings Service
* Handles key-value storage for flexible system-wide settings
*/
export class SystemSettingsService {
/**
* Get a single setting by key
*/
get(key: string): SettingValue | null {
const row = UserDatabaseService.get(
'SELECT value, data_type, default_value FROM system_settings WHERE key = ?',
@@ -23,9 +16,6 @@ export class SystemSettingsService {
return this.parseValue(valueToUse, row.data_type);
}
/**
* Get all settings in a category
*/
getCategory(category: string): CategorySettings {
const rows = UserDatabaseService.all(
'SELECT key, value, data_type, default_value FROM system_settings WHERE category = ?',
@@ -48,9 +38,6 @@ export class SystemSettingsService {
return settings;
}
/**
* Get all settings grouped by category
*/
getAll(): Record<string, CategorySettings> {
const rows = UserDatabaseService.all(
'SELECT key, value, data_type, default_value, category FROM system_settings',
@@ -76,9 +63,6 @@ export class SystemSettingsService {
return allSettings;
}
/**
* Set a single setting by key
*/
set(key: string, value: SettingValue, updatedBy?: number): void {
// Validate before setting
this.validate(key, value);
@@ -93,9 +77,6 @@ export class SystemSettingsService {
);
}
/**
* Update multiple settings in a category
*/
updateCategory(category: string, settings: CategorySettings, updatedBy?: number): void {
const db = UserDatabaseService.getDatabase();
db.transaction(() => {
@@ -135,17 +116,11 @@ export class SystemSettingsService {
return publicSettings;
}
/**
* Check if a setting exists
*/
exists(key: string): boolean {
const row = UserDatabaseService.get('SELECT 1 FROM system_settings WHERE key = ?', [key]);
return !!row;
}
/**
* Reset a setting to its default value
*/
resetToDefault(key: string, updatedBy?: number): void {
UserDatabaseService.run(
`UPDATE system_settings
@@ -155,9 +130,6 @@ export class SystemSettingsService {
);
}
/**
* Get setting metadata
*/
getMetadata(key: string): {
key: string;
dataType: string;
@@ -185,9 +157,6 @@ export class SystemSettingsService {
};
}
/**
* Validate a value against its JSON schema
*/
validate(key: string, value: SettingValue): void {
const row = UserDatabaseService.get(
'SELECT validation_schema, data_type FROM system_settings WHERE key = ?',
@@ -208,9 +177,6 @@ export class SystemSettingsService {
}
}
/**
* Validate value against JSON schema
*/
private validateAgainstSchema(
value: SettingValue,
schema: Record<string, unknown>,
@@ -259,9 +225,6 @@ export class SystemSettingsService {
}
}
/**
* Get JavaScript type name for validation
*/
private getType(value: SettingValue): string {
if (typeof value === 'boolean') return 'boolean';
if (typeof value === 'string') return 'string';
@@ -274,9 +237,6 @@ export class SystemSettingsService {
return typeof value;
}
/**
* Parse value based on data type
*/
private parseValue(value: string | null, dataType: SettingDataType): SettingValue | null {
if (value === null || value === undefined) {
return null;
@@ -301,9 +261,6 @@ export class SystemSettingsService {
}
}
/**
* Stringify value for storage
*/
private stringifyValue(value: SettingValue): string {
if (typeof value === 'boolean') {
return value ? '1' : '0';
@@ -314,16 +271,10 @@ export class SystemSettingsService {
return String(value);
}
/**
* Convert snake_case to camelCase
*/
private snakeToCamel(str: string): string {
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
}
/**
* Convert camelCase to snake_case
*/
private camelToSnake(str: string): string {
return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
}
@@ -6,16 +6,9 @@ import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
/**
* User Database Service - Singleton for user.db
* Mirrors the pattern of DatabaseService.ts for Flashpoint database
*/
export class UserDatabaseService {
private static db: BetterSqlite3.Database | null = null;
/**
* Initialize user database connection and schema
*/
static async initialize(): Promise<void> {
try {
// Ensure directory exists
@@ -213,9 +206,6 @@ export class UserDatabaseService {
}
}
/**
* Get the database instance
*/
static getDatabase(): BetterSqlite3.Database {
if (!this.db) {
throw new Error('[UserDB] Database not initialized. Call initialize() first.');
@@ -306,16 +296,10 @@ export class UserDatabaseService {
);
}
/**
* Check if database is connected
*/
static isConnected(): boolean {
return this.db !== null && this.db.open;
}
/**
* Close database connection
*/
static close(): void {
if (this.db) {
this.db.close();
@@ -79,9 +79,6 @@ export class UserPlaylistService {
this.gameService = new GameService();
}
/**
* Get all playlists for a user
*/
getUserPlaylists(userId: number): UserPlaylist[] {
const db = this.userDb.getDatabase();
@@ -95,9 +92,6 @@ export class UserPlaylistService {
return stmt.all(userId) as UserPlaylist[];
}
/**
* Get a specific playlist by ID
*/
getPlaylistById(playlistId: number, userId: number): UserPlaylist | null {
const db = this.userDb.getDatabase();
@@ -110,9 +104,6 @@ export class UserPlaylistService {
return (stmt.get(playlistId, userId) as UserPlaylist) || null;
}
/**
* Get games in a playlist
*/
getPlaylistGames(playlistId: number, userId: number): PlaylistGame[] {
const db = this.userDb.getDatabase();
@@ -132,9 +123,6 @@ export class UserPlaylistService {
return stmt.all(playlistId) as PlaylistGame[];
}
/**
* Get games in a playlist with full game data
*/
async getPlaylistGamesWithData(playlistId: number, userId: number): Promise<Game[]> {
const db = this.userDb.getDatabase();
@@ -164,9 +152,6 @@ export class UserPlaylistService {
return games;
}
/**
* Create a new playlist
*/
createPlaylist(userId: number, data: CreatePlaylistData): UserPlaylist {
const db = this.userDb.getDatabase();
@@ -182,9 +167,6 @@ export class UserPlaylistService {
return this.getPlaylistById(result.lastInsertRowid as number, userId)!;
}
/**
* Update a playlist
*/
updatePlaylist(
playlistId: number,
userId: number,
@@ -234,9 +216,6 @@ export class UserPlaylistService {
return this.getPlaylistById(playlistId, userId);
}
/**
* Delete a playlist
*/
deletePlaylist(playlistId: number, userId: number): boolean {
const db = this.userDb.getDatabase();
@@ -258,9 +237,6 @@ export class UserPlaylistService {
return true;
}
/**
* Add games to a playlist
*/
addGamesToPlaylist(playlistId: number, userId: number, gameIds: string[]): boolean {
// Validate batch size
if (gameIds.length > UserPlaylistService.MAX_BATCH_SIZE) {
@@ -315,9 +291,6 @@ export class UserPlaylistService {
return true;
}
/**
* Remove games from playlist
*/
removeGamesFromPlaylist(playlistId: number, userId: number, gameIds: string[]): boolean {
// Validate batch size
if (gameIds.length > UserPlaylistService.MAX_BATCH_SIZE) {
@@ -362,9 +335,6 @@ export class UserPlaylistService {
return true;
}
/**
* Reorder games in playlist
*/
reorderGames(playlistId: number, userId: number, gameIdOrder: string[]): boolean {
// Validate batch size
if (gameIdOrder.length > UserPlaylistService.MAX_BATCH_SIZE) {
@@ -401,9 +371,6 @@ export class UserPlaylistService {
return true;
}
/**
* Copy Flashpoint playlist to user playlist
*/
async copyFlashpointPlaylist(
userId: number,
flashpointPlaylistId: string,
@@ -440,9 +407,6 @@ export class UserPlaylistService {
}
}
/**
* Get playlist statistics for a user
*/
getUserPlaylistStats(userId: number): { totalPlaylists: number; totalGames: number } {
const db = this.userDb.getDatabase();
-34
View File
@@ -8,9 +8,6 @@ import { PaginatedResponse, createPaginatedResponse, calculateOffset } from '../
import { logger } from '../utils/logger';
export class UserService {
/**
* Get all users with pagination
*/
async getUsers(page: number = 1, limit: number = 50): Promise<PaginatedResponse<User>> {
const offset = calculateOffset(page, limit);
@@ -37,9 +34,6 @@ export class UserService {
return createPaginatedResponse(users as User[], total, page, limit);
}
/**
* Get user by ID
*/
async getUserById(id: number): Promise<User | null> {
const user = UserDatabaseService.get(
`SELECT u.id, u.username, u.email, u.role_id as roleId, r.name as roleName,
@@ -54,9 +48,6 @@ export class UserService {
return user || null;
}
/**
* Create new user
*/
async createUser(data: CreateUserData): Promise<User> {
// Hash password before transaction
const passwordHash = await hashPassword(data.password);
@@ -76,7 +67,6 @@ export class UserService {
throw new AppError(409, 'Email already exists');
}
// Create user
const result = db
.prepare(
'INSERT INTO users (username, email, password_hash, role_id, is_active) VALUES (?, ?, ?, ?, ?)'
@@ -90,9 +80,6 @@ export class UserService {
return (await this.getUserById(userId))!;
}
/**
* Update user
*/
async updateUser(id: number, data: UpdateUserData): Promise<User> {
const user = await this.getUserById(id);
if (!user) {
@@ -156,9 +143,6 @@ export class UserService {
return (await this.getUserById(id))!;
}
/**
* Delete user
*/
async deleteUser(id: number): Promise<void> {
const user = await this.getUserById(id);
if (!user) {
@@ -194,9 +178,6 @@ export class UserService {
PermissionCache.invalidateUser(id);
}
/**
* Change user password
*/
async changePassword(
id: number,
currentPassword: string,
@@ -233,9 +214,6 @@ export class UserService {
logger.info(`[UserService] All refresh tokens revoked after password change for user ${id}`);
}
/**
* Get user setting by key
*/
async getUserSetting(userId: number, key: string): Promise<string | null> {
const result = UserDatabaseService.get(
`SELECT setting_value FROM user_settings
@@ -245,9 +223,6 @@ export class UserService {
return result?.setting_value ?? null;
}
/**
* Get all settings for a user
*/
async getUserSettings(userId: number): Promise<Record<string, string>> {
const results = UserDatabaseService.all(
`SELECT setting_key, setting_value FROM user_settings WHERE user_id = ?`,
@@ -263,9 +238,6 @@ export class UserService {
);
}
/**
* Set user setting (upsert)
*/
async setUserSetting(userId: number, key: string, value: string): Promise<void> {
const user = await this.getUserById(userId);
if (!user) {
@@ -283,9 +255,6 @@ export class UserService {
);
}
/**
* Update theme settings in user_settings table (atomic transaction)
*/
async updateThemeSettings(userId: number, mode: string, primaryColor: string): Promise<void> {
const user = await this.getUserById(userId);
if (!user) {
@@ -299,9 +268,6 @@ export class UserService {
]);
}
/**
* Get theme settings from user_settings table
*/
async getThemeSettings(userId: number): Promise<{ mode: string; primaryColor: string }> {
const mode = await this.getUserSetting(userId, 'theme_mode');
const primaryColor = await this.getUserSetting(userId, 'primary_color');
-51
View File
@@ -1,12 +1,3 @@
/**
* Game Search Endpoint Benchmark
*
* Measures performance of game search queries with various filter combinations
* to identify slow query patterns and optimization opportunities.
*
* Usage: npm run benchmark:games
*/
import { GameService, GameSearchQuery } from '../services/GameService';
import { DatabaseService } from '../services/DatabaseService';
import { UserDatabaseService } from '../services/UserDatabaseService';
@@ -20,16 +11,12 @@ interface BenchmarkResult {
totalResults: number;
}
/**
* Execute a benchmark test
*/
async function runBenchmark(
name: string,
query: Partial<GameSearchQuery>
): Promise<BenchmarkResult> {
const gameService = new GameService();
// Default query parameters
const fullQuery: GameSearchQuery = {
sortBy: 'title',
sortOrder: 'asc',
@@ -66,9 +53,6 @@ async function runBenchmark(
}
}
/**
* Run all benchmark tests
*/
async function runAllBenchmarks(): Promise<void> {
console.log('\n╔════════════════════════════════════════════════════════════╗');
console.log('║ GAME SEARCH ENDPOINT BENCHMARK ║');
@@ -76,33 +60,17 @@ async function runAllBenchmarks(): Promise<void> {
const benchmarks: BenchmarkResult[] = [];
// Benchmark 1: No filters (baseline)
benchmarks.push(await runBenchmark('Baseline (no filters)', {}));
// Benchmark 2: Search term only
benchmarks.push(await runBenchmark('Search: "sonic"', { search: 'sonic' }));
// Benchmark 3: Platform filter
benchmarks.push(await runBenchmark('Platform: Flash', { platforms: ['Flash'] }));
// Benchmark 4: Multiple platforms
benchmarks.push(
await runBenchmark('Platforms: Flash + HTML5', { platforms: ['Flash', 'HTML5'] })
);
// Benchmark 5: Library filter
benchmarks.push(await runBenchmark('Library: arcade', { library: 'arcade' }));
// Benchmark 6: Year range
benchmarks.push(await runBenchmark('Years: 2000-2010', { yearFrom: 2000, yearTo: 2010 }));
// Benchmark 7: Tag filter (single tag)
benchmarks.push(await runBenchmark('Tag: Puzzle', { tags: ['Puzzle'] }));
// Benchmark 8: Multiple tags
benchmarks.push(await runBenchmark('Tags: Puzzle + Action', { tags: ['Puzzle', 'Action'] }));
// Benchmark 9: Complex filter combination
benchmarks.push(
await runBenchmark('Complex: Platform + Library + Years', {
platforms: ['Flash'],
@@ -112,7 +80,6 @@ async function runAllBenchmarks(): Promise<void> {
})
);
// Benchmark 10: Search + filters
benchmarks.push(
await runBenchmark('Search + Filters', {
search: 'mario',
@@ -121,26 +88,17 @@ async function runAllBenchmarks(): Promise<void> {
})
);
// Benchmark 11: Sort by releaseDate
benchmarks.push(
await runBenchmark('Sort: releaseDate DESC', { sortBy: 'releaseDate', sortOrder: 'desc' })
);
// Benchmark 12: Sort by developer
benchmarks.push(await runBenchmark('Sort: developer ASC', { sortBy: 'developer' }));
// Benchmark 13: Large page size
benchmarks.push(await runBenchmark('Large page: limit=100', { limit: 100 }));
// Benchmark 14: Deep pagination
benchmarks.push(await runBenchmark('Deep pagination: page 10', { page: 10, limit: 50 }));
// Benchmark 15: Show broken + extreme
benchmarks.push(
await runBenchmark('Show broken + extreme', { showBroken: true, showExtreme: true })
);
// Print results
console.log('RESULTS:\n');
console.log('┌────┬──────────────────────────────────┬──────────┬────────┬──────────┐');
console.log('│ # │ Benchmark │ Time (ms)│ Results│ Total │');
@@ -153,7 +111,6 @@ async function runAllBenchmarks(): Promise<void> {
const count = String(result.resultCount).padStart(6);
const total = String(result.totalResults).padStart(8);
// Highlight slow queries (>100ms)
if (result.executionTime >= 100) {
console.log(`│ ${num} │ ${name} │ \x1b[33m${time}\x1b[0m │ ${count} │ ${total} │`);
} else {
@@ -163,7 +120,6 @@ async function runAllBenchmarks(): Promise<void> {
console.log('└────┴──────────────────────────────────┴──────────┴────────┴──────────┘\n');
// Summary statistics
const times = benchmarks.map((b) => b.executionTime);
const avgTime = Math.round(times.reduce((a, b) => a + b, 0) / times.length);
const minTime = Math.min(...times);
@@ -186,20 +142,14 @@ async function runAllBenchmarks(): Promise<void> {
console.log('\n');
}
/**
* Main entry point
*/
async function main(): Promise<void> {
try {
// Initialize databases
console.log('Initializing databases...');
await DatabaseService.initialize();
await UserDatabaseService.initialize();
// Run benchmarks
await runAllBenchmarks();
// Close databases
DatabaseService.close();
UserDatabaseService.close();
@@ -210,7 +160,6 @@ async function main(): Promise<void> {
}
}
// Run if executed directly
if (require.main === module) {
main();
}
+2 -10
View File
@@ -5,17 +5,13 @@ const REFRESH_COOKIE_NAME = 'fp_refresh';
const ACCESS_COOKIE_NAME = 'fp_access';
const isProduction = config.nodeEnv === 'production';
// ===================================
// Refresh Token Cookie (30 days, /api/auth path)
// ===================================
function getRefreshCookieOptions(): CookieOptions {
return {
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
path: '/api/auth',
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days in ms
maxAge: 30 * 24 * 60 * 60 * 1000,
};
}
@@ -36,17 +32,13 @@ export function getRefreshTokenFromCookie(cookies: Record<string, string>): stri
return cookies?.[REFRESH_COOKIE_NAME];
}
// ===================================
// Access Token Cookie (1 hour, /api path)
// ===================================
function getAccessCookieOptions(): CookieOptions {
return {
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
path: '/api',
maxAge: 60 * 60 * 1000, // 1 hour in ms
maxAge: 60 * 60 * 1000,
};
}
+2 -38
View File
@@ -2,40 +2,22 @@ import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import { config } from '../config';
/**
* JWT Payload interface
*/
export interface JWTPayload {
userId: number;
username: string;
role: string;
}
/**
* Generate JWT access token (short-lived)
* @param payload - User data to encode in the token
* @returns Signed JWT token
*/
export function generateAccessToken(payload: JWTPayload): string {
return jwt.sign(payload, config.jwtSecret, {
expiresIn: config.jwtExpiresIn as string | number,
} as jwt.SignOptions);
}
/**
* Generate refresh token (long-lived, random string)
* @returns Random hex string for refresh token
*/
export function generateRefreshToken(): string {
return crypto.randomBytes(64).toString('hex');
}
/**
* Verify and decode JWT access token
* @param token - JWT token to verify
* @returns Decoded payload
* @throws Error if token is invalid or expired
*/
export function verifyToken(token: string): JWTPayload {
try {
const payload = jwt.verify(token, config.jwtSecret, {
@@ -58,35 +40,17 @@ export function verifyToken(token: string): JWTPayload {
}
}
/**
* Shared Access Payload interface for temporary playlist access tokens
*/
export interface SharedAccessPayload {
type: 'shared_access';
shareToken: string;
playlistId: number;
}
/**
* Generate shared access token (60 minutes, non-refreshable)
* Used for temporary access to shared playlists and their games
* @param payload - Share token and playlist ID
* @returns Signed JWT token
*/
/** Generate shared access token (60 minutes, non-refreshable). */
export function generateSharedAccessToken(payload: Omit<SharedAccessPayload, 'type'>): string {
return jwt.sign(
{ ...payload, type: 'shared_access' },
config.jwtSecret,
{ expiresIn: '60m' } // 60 minutes, non-refreshable
);
return jwt.sign({ ...payload, type: 'shared_access' }, config.jwtSecret, { expiresIn: '60m' });
}
/**
* Verify and decode shared access token
* @param token - JWT token to verify
* @returns Decoded shared access payload
* @throws Error if token is invalid, expired, or wrong type
*/
export function verifySharedAccessToken(token: string): SharedAccessPayload {
const payload = jwt.verify(token, config.jwtSecret, {
algorithms: ['HS256'],
+2 -30
View File
@@ -14,10 +14,6 @@ try {
// OpenTelemetry not available - trace context will not be included
}
/**
* Get current trace context from OpenTelemetry
* Returns trace_id and span_id if available
*/
function getTraceContext(): { trace_id?: string; span_id?: string } {
if (!trace || !context) {
return {};
@@ -55,38 +51,29 @@ function getSeverityNumber(level: string): number {
return severityMap[level] || 9;
}
/**
* Custom format that adds OTEL-compatible fields
*/
const otelFormat = winston.format((info) => {
// Add trace context if available
const traceContext = getTraceContext();
if (traceContext.trace_id) {
info.trace_id = traceContext.trace_id;
info.span_id = traceContext.span_id;
}
// Add OTEL severity fields
info.severity = info.level.toUpperCase();
info.severityNumber = getSeverityNumber(info.level);
return info;
});
// Build transports array
const transports: winston.transport[] = [
// Console transport (always enabled)
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.printf(({ level, message, timestamp, ...meta }) => {
let msg = `${timestamp} [${level}]: ${message}`;
// Include trace_id in console output if present
const traceId = meta.trace_id as string | undefined;
if (traceId) {
msg = `${timestamp} [${level}] [trace:${traceId.substring(0, 8)}]: ${message}`;
}
// Filter out internal fields for console display
const displayMeta = { ...meta };
delete displayMeta.service;
delete displayMeta.trace_id;
@@ -102,14 +89,11 @@ const transports: winston.transport[] = [
}),
];
// Track file transport for diagnostics
let fileTransport: winston.transports.FileTransportInstance | null = null;
let fileLoggingError: Error | null = null;
// Add file transport if LOG_FILE is configured
if (config.logFile) {
try {
// Ensure log directory exists
const logDir = path.dirname(config.logFile);
console.log(`[Logger] Attempting to setup file logging...`);
@@ -121,7 +105,6 @@ if (config.logFile) {
fs.mkdirSync(logDir, { recursive: true });
}
// Check if directory is writable
try {
const testFile = path.join(logDir, '.write-test');
fs.writeFileSync(testFile, 'test');
@@ -133,11 +116,10 @@ if (config.logFile) {
);
}
// Create file transport with OTEL-compatible JSON format
fileTransport = new winston.transports.File({
filename: config.logFile,
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DDTHH:mm:ss.SSSZ' }), // ISO 8601
winston.format.timestamp({ format: 'YYYY-MM-DDTHH:mm:ss.SSSZ' }),
winston.format.errors({ stack: true }),
otelFormat(),
winston.format.json()
@@ -147,7 +129,6 @@ if (config.logFile) {
tailable: true,
});
// Add error handler to catch write failures
fileTransport.on('error', (error) => {
console.error(`[Logger] File transport error: ${error.message}`);
fileLoggingError = error;
@@ -177,9 +158,6 @@ export const logger = winston.createLogger({
transports,
});
/**
* Get logging status for diagnostics
*/
export function getLoggingStatus(): {
consoleEnabled: boolean;
fileEnabled: boolean;
@@ -198,20 +176,14 @@ export function getLoggingStatus(): {
};
}
/**
* Verify file logging is working by writing a test entry
*/
export function verifyFileLogging(): boolean {
if (!config.logFile || !fileTransport) {
return false;
}
try {
// Write a verification entry
logger.info('Log file verification - logging system initialized');
// Give winston a moment to flush, then check if file exists
// Note: This is a sync check, actual write is async
// Sync check only - actual write is async, so this is best-effort
return fs.existsSync(config.logFile);
} catch (error) {
console.error(`[Logger] Verification failed: ${error}`);
-15
View File
@@ -1,6 +1,3 @@
/**
* Standard pagination response interface
*/
export interface PaginatedResponse<T> {
data: T[];
pagination: {
@@ -11,9 +8,6 @@ export interface PaginatedResponse<T> {
};
}
/**
* Create a standardized paginated response
*/
export function createPaginatedResponse<T>(
data: T[],
total: number,
@@ -33,16 +27,10 @@ export function createPaginatedResponse<T>(
};
}
/**
* Calculate offset for database queries
*/
export function calculateOffset(page: number, limit: number): number {
return (page - 1) * limit;
}
/**
* Validate and normalize pagination parameters
*/
export function normalizePagination(
page?: number | string,
limit?: number | string,
@@ -52,10 +40,7 @@ export function normalizePagination(
let normalizedPage = typeof page === 'string' ? parseInt(page) : page;
let normalizedLimit = typeof limit === 'string' ? parseInt(limit) : limit;
// Ensure page is at least 1
normalizedPage = Math.max(1, normalizedPage || 1);
// Ensure limit is within bounds
normalizedLimit = Math.min(maxLimit, Math.max(1, normalizedLimit || defaultLimit));
return {
-11
View File
@@ -1,21 +1,10 @@
import bcrypt from 'bcrypt';
import { config } from '../config';
/**
* Hash a password using bcrypt
* @param password - Plain text password
* @returns Hashed password
*/
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, config.bcryptSaltRounds);
}
/**
* Verify a password against a hash
* @param password - Plain text password
* @param hash - Hashed password to compare against
* @returns True if password matches hash
*/
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
-29
View File
@@ -1,9 +1,3 @@
/**
* Pagination Query Parser Utility
*
* Provides consistent pagination query parameter parsing across all routes
*/
interface PaginationDefaults {
limit?: number;
maxLimit?: number;
@@ -15,38 +9,15 @@ interface PaginationResult {
offset: number;
}
/**
* Parse pagination query parameters with defaults and limits
*
* @param query - Express query object
* @param defaults - Default values for limit, maxLimit, and offset
* @returns Parsed and validated pagination parameters
*
* @example
* ```typescript
* const { limit, offset } = parsePaginationQuery(req.query);
* ```
*
* @example
* ```typescript
* const { limit, offset } = parsePaginationQuery(req.query, {
* limit: 25,
* maxLimit: 50,
* offset: 0
* });
* ```
*/
export function parsePaginationQuery(
query: Record<string, unknown>,
defaults: PaginationDefaults = {}
): PaginationResult {
const { limit: defaultLimit = 50, maxLimit = 100, offset: defaultOffset = 0 } = defaults;
// Parse limit with default and max cap
const parsedLimit = parseInt(query.limit as string);
const limit = isNaN(parsedLimit) ? defaultLimit : Math.min(Math.max(1, parsedLimit), maxLimit);
// Parse offset with default
const parsedOffset = parseInt(query.offset as string);
const offset = isNaN(parsedOffset) ? defaultOffset : Math.max(0, parsedOffset);
+1 -37
View File
@@ -1,13 +1,5 @@
import { logger } from './logger';
/**
* Query Performance Logger
*
* Wraps database query execution with performance monitoring.
* Logs queries that exceed the threshold with execution time and details.
*/
// Performance threshold in milliseconds
const SLOW_QUERY_THRESHOLD_MS = 100;
interface QueryMetrics {
@@ -17,14 +9,10 @@ interface QueryMetrics {
timestamp: string;
}
/**
* Sanitize query parameters for logging
* Redacts sensitive values like passwords and tokens
*/
/** Redacts sensitive values (passwords, tokens) from query params before logging. */
function sanitizeParams(params: unknown[] = []): unknown[] {
return params.map((param) => {
if (typeof param === 'string') {
// Redact if it looks like a password or token
if (param.length > 20 && /^[a-f0-9]{32,}$/i.test(param)) {
return '[REDACTED_TOKEN]';
}
@@ -36,9 +24,6 @@ function sanitizeParams(params: unknown[] = []): unknown[] {
});
}
/**
* Truncate SQL query for logging (keep first 200 chars)
*/
function truncateSQL(sql: string): string {
const maxLength = 200;
if (sql.length <= maxLength) {
@@ -47,9 +32,6 @@ function truncateSQL(sql: string): string {
return sql.substring(0, maxLength) + '...';
}
/**
* Log slow query performance metrics
*/
function logSlowQuery(metrics: QueryMetrics): void {
const { sql, params, executionTime, timestamp } = metrics;
@@ -62,14 +44,6 @@ function logSlowQuery(metrics: QueryMetrics): void {
});
}
/**
* Wrap a database query execution function with performance monitoring
*
* @param queryFn - The function that executes the database query
* @param sql - The SQL query string
* @param params - Query parameters
* @returns The result of the query function
*/
export function measureQueryPerformance<T>(
queryFn: () => T,
sql: string,
@@ -81,7 +55,6 @@ export function measureQueryPerformance<T>(
const result = queryFn();
const executionTime = Math.round(performance.now() - start);
// Log if query exceeded threshold
if (executionTime >= SLOW_QUERY_THRESHOLD_MS) {
logSlowQuery({
sql,
@@ -95,7 +68,6 @@ export function measureQueryPerformance<T>(
} catch (error) {
const executionTime = Math.round(performance.now() - start);
// Log error with execution time
logger.error('[Query Performance] Query failed', {
executionTime: `${executionTime}ms`,
sql: truncateSQL(sql),
@@ -107,16 +79,10 @@ export function measureQueryPerformance<T>(
}
}
/**
* Get current performance threshold in milliseconds
*/
export function getSlowQueryThreshold(): number {
return SLOW_QUERY_THRESHOLD_MS;
}
/**
* Async version of measureQueryPerformance for async operations
*/
export async function measureQueryPerformanceAsync<T>(
queryFn: () => Promise<T>,
sql: string,
@@ -128,7 +94,6 @@ export async function measureQueryPerformanceAsync<T>(
const result = await queryFn();
const executionTime = Math.round(performance.now() - start);
// Log if query exceeded threshold
if (executionTime >= SLOW_QUERY_THRESHOLD_MS) {
logSlowQuery({
sql,
@@ -142,7 +107,6 @@ export async function measureQueryPerformanceAsync<T>(
} catch (error) {
const executionTime = Math.round(performance.now() - start);
// Log error with execution time
logger.error('[Query Performance] Query failed', {
executionTime: `${executionTime}ms`,
sql: truncateSQL(sql),
@@ -29,11 +29,9 @@ interface ActivityLog {
createdAt: string;
}
// Constants
const PAGINATION_LIMIT = 20;
const DEBOUNCE_DELAY = 500;
// Columns definition - moved outside component to prevent recreation on every render
const ACTIVITY_TABLE_COLUMNS: ColumnDef<ActivityLog>[] = [
{
accessorKey: 'createdAt',
@@ -129,24 +127,19 @@ function ActivityTableComponent() {
const [showFilters, setShowFilters] = useState(false);
const limit = PAGINATION_LIMIT;
// Sorting state
const [sortBy, setSortBy] = useState<
'createdAt' | 'username' | 'action' | 'resource' | 'ipAddress'
>('createdAt');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
// Transition for deferred state updates
const [, startTransition] = useTransition();
// Consolidated filter state with date range validation
const { filters: filterState, updateFilter, clearFilters } = useActivityFilters();
// Debounced values for API calls
const debouncedUsername = useDebounce(filterState.username, DEBOUNCE_DELAY);
const debouncedAction = useDebounce(filterState.action, DEBOUNCE_DELAY);
const debouncedResource = useDebounce(filterState.resource, DEBOUNCE_DELAY);
// Build filters object - convert Date objects to ISO strings for API
// Only include dates if the range is valid
const hasValidDateRange =
!filterState.dateRangeError || (!filterState.startDate && !filterState.endDate);
@@ -178,22 +171,18 @@ function ActivityTableComponent() {
const { data, isLoading, isError, error } = useActivities(page, limit, filters);
// Convert sorting state to TanStack Table format
const sortingState: SortingState = useMemo(
() => [{ id: sortBy, desc: sortOrder === 'desc' }],
[sortBy, sortOrder]
);
// Handle sorting changes from DataTable
const handleSortingChange = useCallback(
(updaterOrValue: SortingState | ((old: SortingState) => SortingState)) => {
const newSorting =
typeof updaterOrValue === 'function' ? updaterOrValue(sortingState) : updaterOrValue;
// Wrap state updates in transition to defer prop changes
startTransition(() => {
if (newSorting.length === 0) {
// Reset to default when cleared
setSortBy('createdAt');
setSortOrder('desc');
} else {
@@ -201,7 +190,7 @@ function ActivityTableComponent() {
setSortBy(id as typeof sortBy);
setSortOrder(desc ? 'desc' : 'asc');
}
setPage(1); // Reset to first page on sort change
setPage(1);
});
},
[sortingState]
@@ -212,7 +201,6 @@ function ActivityTableComponent() {
setPage(1);
}, [clearFilters]);
// Check if any filters are active
const hasActiveFilters =
filterState.username ||
filterState.action ||
@@ -220,7 +208,6 @@ function ActivityTableComponent() {
filterState.startDate ||
filterState.endDate;
// Count active filters
const activeFilterCount = [
filterState.username,
filterState.action,
@@ -17,7 +17,6 @@ function ActivityDashboardComponent() {
const [autoRefresh, setAutoRefresh] = useState(false);
const [timeRange, setTimeRange] = useState<TimeRange>('24h');
// Persist collapsed state
useEffect(() => {
localStorage.setItem('activityDashboardCollapsed', String(isCollapsed));
}, [isCollapsed]);
@@ -34,7 +33,6 @@ function ActivityDashboardComponent() {
return (
<div className="space-y-6">
{/* Header */}
<div className="bg-card rounded-lg p-4 border border-border shadow-md">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
@@ -102,10 +100,8 @@ function ActivityDashboardComponent() {
</div>
</div>
{/* Dashboard Content */}
{!isCollapsed ? (
<div className="space-y-6">
{/* Loading State */}
{isLoading ? (
<div className="bg-card rounded-lg p-6 border border-border shadow-md">
<div className="animate-pulse space-y-4">
@@ -119,22 +115,15 @@ function ActivityDashboardComponent() {
</div>
) : null}
{/* Stats Cards */}
{stats ? <ActivityStats stats={stats.data} timeRange={timeRange} /> : null}
{/* Activity Trend Chart */}
<ActivityTrendChart autoRefresh={autoRefresh} />
{/* Charts Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Top Actions Bar Chart */}
<TopActionsChart autoRefresh={autoRefresh} />
{/* Resource Distribution Pie Chart */}
<ResourceDistributionChart autoRefresh={autoRefresh} />
</div>
{/* User Leaderboard */}
<UserLeaderboard autoRefresh={autoRefresh} />
</div>
) : null}

Some files were not shown because too many files have changed in this diff Show More