chore: comments cleanup
This commit is contained in:
+8
-56
@@ -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,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';
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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, '&')
|
||||
@@ -22,10 +22,6 @@ function escapeHtml(str: string): string {
|
||||
.replace(/`/g, '`');
|
||||
}
|
||||
|
||||
/**
|
||||
* Download progress tracking for in-progress downloads
|
||||
* Used to show loading page while game is being downloaded
|
||||
*/
|
||||
interface DownloadProgress {
|
||||
startTime: number;
|
||||
progress: number;
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,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';
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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', '*');
|
||||
|
||||
@@ -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 +
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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>;
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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();
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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 ')
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 });
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = ?')
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,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'],
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,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
Reference in New Issue
Block a user