On a network share the database copy and cache pre-warm run for minutes, and the listener only opened after all of it, so anyone who loaded the page during that window got a connection error. Open the port immediately after registering the static frontend, and hold every route that needs the database behind a gate that answers 503 with a machine-readable marker and the current startup phase. The SPA shell needs nothing the gate blocks, so the frontend renders a "server is starting" screen on the login page's own background, polls /health, and reloads once the server reports ready. Move the database copy out of the entrypoint: it does the same freshness check the application does, and doing it before node starts kept the port closed for the longest part of startup. Distinguish a dead server from a slow one by requiring the backend's own marker, since a proxy in front of a stopped backend still answers.
538 lines
19 KiB
TypeScript
538 lines
19 KiB
TypeScript
// IMPORTANT: OpenTelemetry must be imported FIRST for auto-instrumentation
|
|
import './telemetry';
|
|
|
|
import path from 'path';
|
|
import fs from 'fs';
|
|
import crypto from 'crypto';
|
|
import express, { Express } from 'express';
|
|
import cors from 'cors';
|
|
import helmet from 'helmet';
|
|
import compression from 'compression';
|
|
import cookieParser from 'cookie-parser';
|
|
import { config } from './config';
|
|
import { logger, getLoggingStatus, verifyFileLogging } from './utils/logger';
|
|
import { isOriginAllowed } from './utils/origins';
|
|
import { errorHandler } from './middleware/errorHandler';
|
|
import { softAuth } from './middleware/auth';
|
|
import { maintenanceMode } from './middleware/maintenanceMode';
|
|
import { requestTimeout, TimeoutConfig } from './middleware/requestTimeout';
|
|
import { performanceTracking } from './middleware/performanceTracking';
|
|
import { setupRoutes } from './routes';
|
|
import { DatabaseService } from './services/DatabaseService';
|
|
import { DbQueryPool } from './services/DbQueryPool';
|
|
import { DownloadedReconciler } from './services/DownloadedReconciler';
|
|
import { UserDatabaseService } from './services/UserDatabaseService';
|
|
import { DomainService } from './services/DomainService';
|
|
import { PlayTrackingService } from './services/PlayTrackingService';
|
|
import { AuthService } from './services/AuthService';
|
|
import { GameSearchCache } from './services/GameSearchCache';
|
|
import { GameService } from './services/GameService';
|
|
import { JobScheduler } from './services/JobScheduler';
|
|
import { JobExecutionService } from './services/JobExecutionService';
|
|
import { MetadataSyncJob } from './jobs/MetadataSyncJob';
|
|
import { RuffleUpdateJob } from './jobs/RuffleUpdateJob';
|
|
import { CachedSystemSettingsService } from './services/CachedSystemSettingsService';
|
|
import { PermissionCache } from './services/PermissionCache';
|
|
import { PerformanceMetrics } from './services/PerformanceMetrics';
|
|
import { RuffleService } from './services/RuffleService';
|
|
import { StartupState } from './services/StartupState';
|
|
import { startupGate } from './middleware/startupGate';
|
|
import { registerPreListenStack } from './middleware/frontendServing';
|
|
import { DownloadManager } from './services/DownloadManager';
|
|
import { ConfigManager } from './game/config';
|
|
import { zipManager } from './game/zip-manager';
|
|
import { gameZipServer } from './game/gamezipserver';
|
|
import gameProxyRouter from './routes/game-proxy';
|
|
import gameZipRouter from './routes/game-zip';
|
|
|
|
/**
|
|
* Compute SHA-256 CSP hashes for inline <script> blocks (those without a src
|
|
* attribute) in the given HTML — e.g. the theme bootstrap in index.html — so the
|
|
* CSP can allow exactly those scripts by hash instead of the blanket
|
|
* 'unsafe-inline'. Returns values like "'sha256-…'".
|
|
*/
|
|
function inlineScriptCspHashes(html: string): string[] {
|
|
const hashes: string[] = [];
|
|
const inlineScript = /<script(?![^>]*\ssrc=)[^>]*>([\s\S]*?)<\/script>/gi;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = inlineScript.exec(html)) !== null) {
|
|
// The HTML parser normalizes CRLF/CR to LF before hashing the script text,
|
|
// so normalize here too or the hash won't match on CRLF-checked-out builds.
|
|
const body = match[1].replace(/\r\n?/g, '\n');
|
|
const digest = crypto.createHash('sha256').update(body, 'utf8').digest('base64');
|
|
hashes.push(`'sha256-${digest}'`);
|
|
}
|
|
return hashes;
|
|
}
|
|
|
|
async function cleanupActivityLogs(): Promise<void> {
|
|
try {
|
|
const systemSettings = CachedSystemSettingsService.getInstance();
|
|
const storageSettings = systemSettings.getCategory('storage');
|
|
const retentionDays =
|
|
typeof storageSettings.logRetentionDays === 'number' ? storageSettings.logRetentionDays : 30;
|
|
|
|
const result = UserDatabaseService.run(
|
|
`DELETE FROM activity_logs
|
|
WHERE created_at < datetime('now', '-' || ? || ' days')`,
|
|
[retentionDays]
|
|
);
|
|
|
|
if (result.changes > 0) {
|
|
logger.info(
|
|
`[ActivityLogs] Cleaned up ${result.changes} logs older than ${retentionDays} days`
|
|
);
|
|
}
|
|
} catch (error) {
|
|
logger.error('[ActivityLogs] Failed to cleanup old activity logs:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function startServer() {
|
|
const app: Express = express();
|
|
|
|
// 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 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');
|
|
res.setHeader('Access-Control-Allow-Headers', '*');
|
|
// Required for iframe embedding when parent has COEP enabled
|
|
res.setHeader('Cross-Origin-Embedder-Policy', 'credentialless');
|
|
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
|
if (_req.method === 'OPTIONS') {
|
|
res.status(204).end();
|
|
return;
|
|
}
|
|
next();
|
|
};
|
|
app.use('/game-proxy', gameCors, startupGate, gameProxyRouter);
|
|
app.use('/game-zip', gameCors, startupGate, gameZipRouter);
|
|
|
|
// When the backend also serves the SPA, allow the app's own resources by
|
|
// hash/keyword rather than the blanket 'unsafe-inline'/'unsafe-eval':
|
|
// - scriptSrc: 'self' for hashed bundles, sha256 hashes for the inline theme
|
|
// bootstrap in index.html, and 'wasm-unsafe-eval' for Ruffle's WASM (no
|
|
// general eval). No 'unsafe-inline', so injected inline scripts are blocked.
|
|
// - styleSrc: keep 'unsafe-inline' — Radix/shadcn set inline style attributes
|
|
// at runtime, which hashes/nonces cannot cover (style-src-attr). Style
|
|
// injection is far lower risk than script injection.
|
|
// No upgrade-insecure-requests, so self-hosted HTTP deployments keep working.
|
|
// When running API-only, keep the strict 'none' policy.
|
|
const frontendIndexHtml = path.join(config.frontendDistPath, 'index.html');
|
|
const inlineScriptHashes =
|
|
config.serveFrontend && fs.existsSync(frontendIndexHtml)
|
|
? inlineScriptCspHashes(fs.readFileSync(frontendIndexHtml, 'utf8'))
|
|
: [];
|
|
const cspDirectives: Record<string, string[]> = config.serveFrontend
|
|
? {
|
|
defaultSrc: ["'self'"],
|
|
scriptSrc: ["'self'", "'wasm-unsafe-eval'", ...inlineScriptHashes],
|
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
imgSrc: ["'self'", 'data:', 'https:'],
|
|
fontSrc: ["'self'", 'data:'],
|
|
connectSrc: ["'self'"],
|
|
frameSrc: ["'self'"],
|
|
objectSrc: ["'none'"],
|
|
baseUri: ["'self'"],
|
|
formAction: ["'self'"],
|
|
frameAncestors: ["'none'"],
|
|
}
|
|
: {
|
|
defaultSrc: ["'none'"],
|
|
frameAncestors: ["'none'"],
|
|
};
|
|
|
|
app.use(
|
|
helmet({
|
|
contentSecurityPolicy: {
|
|
useDefaults: !config.serveFrontend,
|
|
directives: cspDirectives,
|
|
},
|
|
crossOriginEmbedderPolicy: false,
|
|
// Sent per request instead: browsers ignore COOP on non-secure origins and
|
|
// log a console warning, and a LAN deployment served over plain HTTP is a
|
|
// supported setup here.
|
|
crossOriginOpenerPolicy: false,
|
|
// Agent-cluster keying is decided once per origin, so every document on it
|
|
// must agree. Game content is served from /game-proxy and /game-zip, which
|
|
// are mounted before helmet (they must bypass frameguard and the SPA CSP to
|
|
// be framed at all) and therefore cannot carry the header. Requesting
|
|
// origin-keying only from the rest of the origin makes the browser warn.
|
|
originAgentCluster: false,
|
|
frameguard: { action: 'deny' },
|
|
})
|
|
);
|
|
|
|
app.use((req, res, next) => {
|
|
// req.secure honours X-Forwarded-Proto because `trust proxy` is enabled.
|
|
if (req.secure) {
|
|
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
|
|
}
|
|
next();
|
|
});
|
|
|
|
const domainService = DomainService.getInstance();
|
|
|
|
app.use(
|
|
cors((req, callback) => {
|
|
const origin = req.headers.origin;
|
|
|
|
let domainOrigins: ReadonlySet<string> = new Set<string>();
|
|
try {
|
|
domainOrigins = domainService.getAllowedOrigins();
|
|
} catch (error) {
|
|
logger.warn(`[CORS] Failed to load domains for origin "${origin ?? ''}":`, error);
|
|
}
|
|
|
|
const allowed = isOriginAllowed({
|
|
origin,
|
|
host: req.headers.host,
|
|
protocol: req.protocol,
|
|
configuredOrigins: config.allowedOrigins,
|
|
domainOrigins,
|
|
});
|
|
|
|
if (!allowed) {
|
|
logger.warn(
|
|
`[CORS] Rejected origin "${origin ?? ''}" (host "${req.headers.host ?? ''}"). ` +
|
|
`Allowed: ${config.allowedOrigins.join(', ') || 'none'}`
|
|
);
|
|
}
|
|
|
|
// origin: true reflects the request's own origin, which is required for
|
|
// credentialed requests — a wildcard is not permitted with cookies.
|
|
callback(null, { origin: allowed, credentials: true });
|
|
})
|
|
);
|
|
|
|
app.use(express.json({ limit: '1mb' }));
|
|
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
|
|
|
|
app.use(cookieParser());
|
|
app.use(compression());
|
|
|
|
registerPreListenStack(app);
|
|
|
|
// Listen before the slow work: on a network mount the database copy and cache
|
|
// pre-warm can run for minutes, and a closed port shows the user a connection
|
|
// error instead of a page that explains itself.
|
|
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}`);
|
|
logger.info(`🎮 Game content: /game-proxy/* and /game-zip/* (integrated)`);
|
|
logger.info(`🌍 Environment: ${config.nodeEnv}`);
|
|
});
|
|
|
|
// Prevent connection exhaustion - critical for handling game file requests
|
|
server.keepAliveTimeout = 65000; // 65s - slightly above common load balancer timeout
|
|
server.headersTimeout = 66000; // Must be > keepAliveTimeout
|
|
server.timeout = 120000; // 2 min max for any request (including game file streaming)
|
|
server.maxConnections = 500; // Defense-in-depth against connection exhaustion
|
|
|
|
app.use(requestTimeout(TimeoutConfig.DEFAULT));
|
|
|
|
app.use((req, res, next) => {
|
|
if (
|
|
req.path === '/health' ||
|
|
req.path.startsWith('/game-proxy/') ||
|
|
req.path.startsWith('/game-zip/')
|
|
) {
|
|
next();
|
|
return;
|
|
}
|
|
logger.debug(`${req.method} ${req.path}`);
|
|
next();
|
|
});
|
|
|
|
app.use(performanceTracking);
|
|
|
|
// softAuth MUST run before maintenanceMode so it can identify admins
|
|
app.use(softAuth);
|
|
app.use(maintenanceMode);
|
|
|
|
try {
|
|
StartupState.setPhase('Connecting to the Flashpoint database');
|
|
await DatabaseService.initialize();
|
|
logger.info('Database connection established');
|
|
DownloadedReconciler.reconcile().catch((error: unknown) =>
|
|
logger.error('[Startup] Downloaded reconciliation failed:', error)
|
|
);
|
|
} catch (error) {
|
|
logger.error('Failed to connect to database:', error);
|
|
process.exit(1);
|
|
}
|
|
|
|
try {
|
|
StartupState.setPhase('Preparing application data');
|
|
await UserDatabaseService.initialize();
|
|
logger.info('User database connection established');
|
|
} catch (error) {
|
|
logger.error('Failed to connect to user database:', error);
|
|
process.exit(1);
|
|
}
|
|
|
|
logger.info(`[Edition] Flashpoint edition: ${config.flashpointEdition} (auto-detected)`);
|
|
if (config.flashpointVersionString) {
|
|
logger.info(`[Edition] Flashpoint version: ${config.flashpointVersionString}`);
|
|
}
|
|
|
|
StartupState.setPhase('Warming up game search');
|
|
GameSearchCache.prewarmCache().catch((error) => {
|
|
logger.warn('Failed to pre-warm game search cache:', error);
|
|
});
|
|
|
|
GameService.prewarmFilterOptions().catch((error) => {
|
|
logger.warn('Failed to pre-warm filter options cache:', error);
|
|
});
|
|
|
|
DownloadManager.sweepOrphanedTempFiles().catch((error) => {
|
|
logger.warn('Failed to sweep orphaned download files:', error);
|
|
});
|
|
|
|
// Ruffle installs in the background: it downloads from GitHub, and awaiting it
|
|
// here kept the port closed for minutes on slow storage — indefinitely if the
|
|
// download stalled. Flash games need it, everything else does not.
|
|
void (async () => {
|
|
try {
|
|
const ruffleService = new RuffleService();
|
|
if (!ruffleService.verifyInstallation()) {
|
|
logger.info('🎮 Ruffle not found, downloading latest version...');
|
|
await ruffleService.updateRuffle();
|
|
logger.info('✅ Ruffle installation complete');
|
|
} else {
|
|
const version = ruffleService.getCurrentVersion();
|
|
logger.info(`✅ Ruffle verified (version: ${version || 'unknown'})`);
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to install Ruffle:', error);
|
|
logger.warn('⚠️ Continuing without Ruffle - Flash games will not work');
|
|
}
|
|
})();
|
|
|
|
try {
|
|
const systemSettings = CachedSystemSettingsService.getInstance();
|
|
const jobSettings = systemSettings.getCategory('jobs');
|
|
|
|
const metadataSyncJob = new MetadataSyncJob();
|
|
JobScheduler.registerJob({
|
|
id: 'metadata-sync',
|
|
name: 'Metadata Sync',
|
|
enabled:
|
|
typeof jobSettings.metadataSyncEnabled === 'boolean'
|
|
? jobSettings.metadataSyncEnabled
|
|
: false,
|
|
cronSchedule:
|
|
typeof jobSettings.metadataSyncSchedule === 'string'
|
|
? jobSettings.metadataSyncSchedule
|
|
: '0 * * * *',
|
|
run: () => metadataSyncJob.run(),
|
|
});
|
|
|
|
const ruffleUpdateJob = new RuffleUpdateJob();
|
|
JobScheduler.registerJob({
|
|
id: 'ruffle-update',
|
|
name: 'Ruffle Update',
|
|
enabled:
|
|
typeof jobSettings.ruffleUpdateEnabled === 'boolean'
|
|
? jobSettings.ruffleUpdateEnabled
|
|
: false,
|
|
cronSchedule:
|
|
typeof jobSettings.ruffleUpdateSchedule === 'string'
|
|
? jobSettings.ruffleUpdateSchedule
|
|
: '0 0 * * *',
|
|
run: () => ruffleUpdateJob.run(),
|
|
});
|
|
|
|
JobScheduler.startAllEnabledJobs();
|
|
logger.info('✅ Job scheduler initialized');
|
|
} catch (error) {
|
|
logger.error('Failed to initialize job scheduler:', error);
|
|
}
|
|
|
|
try {
|
|
StartupState.setPhase('Loading game service configuration');
|
|
await ConfigManager.loadConfig(config.flashpointPath);
|
|
logger.info('🎮 Game service configuration loaded (integrated)');
|
|
} catch (error) {
|
|
logger.warn('⚠️ Failed to load game service config (non-fatal):', error);
|
|
}
|
|
|
|
app.get('/health', async (_req, res) => {
|
|
const isHealthy = DatabaseService.isConnected();
|
|
|
|
res.status(isHealthy ? 200 : 503).json({
|
|
status: isHealthy ? 'ok' : 'degraded',
|
|
timestamp: new Date().toISOString(),
|
|
services: {
|
|
database: {
|
|
status: DatabaseService.isConnected() ? 'ok' : 'error',
|
|
},
|
|
gameService: {
|
|
status: 'ok',
|
|
note: 'integrated',
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
setupRoutes(app);
|
|
app.use(errorHandler);
|
|
|
|
// Routes are live from here on, so stop short-circuiting requests.
|
|
StartupState.markReady();
|
|
logger.info('✅ Server ready to accept requests');
|
|
|
|
PermissionCache.startCleanup();
|
|
logger.info('✅ Permission cache initialized');
|
|
|
|
const loggingStatus = getLoggingStatus();
|
|
logger.info('Logging system status', loggingStatus);
|
|
if (loggingStatus.fileEnabled) {
|
|
setTimeout(() => {
|
|
if (verifyFileLogging()) {
|
|
logger.info('File logging verified successfully');
|
|
} else {
|
|
logger.warn('File logging verification failed - check permissions');
|
|
}
|
|
}, 1000).unref();
|
|
} else if (loggingStatus.fileError) {
|
|
logger.warn(`File logging unavailable: ${loggingStatus.fileError}`);
|
|
}
|
|
|
|
const playTrackingService = new PlayTrackingService();
|
|
const playSessionCleanupInterval = setInterval(
|
|
() => {
|
|
playTrackingService.cleanupAbandonedSessions().catch((error) => {
|
|
logger.error('Failed to cleanup abandoned sessions:', error);
|
|
});
|
|
},
|
|
6 * 60 * 60 * 1000
|
|
);
|
|
playSessionCleanupInterval.unref();
|
|
|
|
playTrackingService.cleanupAbandonedSessions().catch((error) => {
|
|
logger.error('Failed to cleanup abandoned sessions:', error);
|
|
});
|
|
|
|
const authService = new AuthService();
|
|
const loginAttemptsCleanupInterval = setInterval(
|
|
() => {
|
|
authService.cleanupOldLoginAttempts().catch((error) => {
|
|
logger.error('Failed to cleanup old login attempts:', error);
|
|
});
|
|
},
|
|
6 * 60 * 60 * 1000
|
|
);
|
|
loginAttemptsCleanupInterval.unref();
|
|
|
|
authService.cleanupOldLoginAttempts().catch((error) => {
|
|
logger.error('Failed to cleanup old login attempts:', error);
|
|
});
|
|
|
|
const activityLogsCleanupInterval = setInterval(
|
|
() => {
|
|
cleanupActivityLogs().catch((error) => {
|
|
logger.error('Failed to cleanup old activity logs:', error);
|
|
});
|
|
},
|
|
24 * 60 * 60 * 1000
|
|
);
|
|
activityLogsCleanupInterval.unref();
|
|
|
|
cleanupActivityLogs().catch((error) => {
|
|
logger.error('Failed to cleanup old activity logs:', error);
|
|
});
|
|
|
|
// Cleanup old performance metrics every hour
|
|
const metricsCleanupInterval = setInterval(
|
|
() => {
|
|
PerformanceMetrics.cleanupOldMetrics();
|
|
},
|
|
60 * 60 * 1000
|
|
);
|
|
metricsCleanupInterval.unref();
|
|
|
|
const jobExecutionService = new JobExecutionService();
|
|
const jobLogCleanupInterval = setInterval(
|
|
() => {
|
|
try {
|
|
jobExecutionService.cleanupOldLogs(30);
|
|
} catch (error) {
|
|
logger.error('Failed to cleanup old job execution logs:', error);
|
|
}
|
|
},
|
|
24 * 60 * 60 * 1000
|
|
);
|
|
jobLogCleanupInterval.unref();
|
|
|
|
const shutdown = async () => {
|
|
logger.info('Shutting down gracefully...');
|
|
|
|
JobScheduler.stopAllJobs();
|
|
PermissionCache.stopCleanup();
|
|
clearInterval(playSessionCleanupInterval);
|
|
clearInterval(loginAttemptsCleanupInterval);
|
|
clearInterval(activityLogsCleanupInterval);
|
|
clearInterval(jobLogCleanupInterval);
|
|
clearInterval(metricsCleanupInterval);
|
|
|
|
try {
|
|
gameZipServer.dispose();
|
|
} catch (err) {
|
|
logger.error('Failed to dispose game zip server during shutdown:', err);
|
|
}
|
|
|
|
try {
|
|
await zipManager.unmountAll();
|
|
} catch (err) {
|
|
logger.error('Failed to unmount ZIPs during shutdown:', err);
|
|
}
|
|
|
|
try {
|
|
await DbQueryPool.shutdown();
|
|
} catch (err) {
|
|
logger.error('Failed to stop database query workers during shutdown:', err);
|
|
}
|
|
|
|
try {
|
|
DatabaseService.close();
|
|
} catch (err) {
|
|
logger.error('Failed to close database during shutdown:', err);
|
|
}
|
|
try {
|
|
UserDatabaseService.close();
|
|
} catch (err) {
|
|
logger.error('Failed to close user database during shutdown:', err);
|
|
}
|
|
|
|
server.close(() => {
|
|
logger.info('Server closed');
|
|
process.exit(0);
|
|
});
|
|
|
|
setTimeout(() => {
|
|
logger.error('Could not close connections in time, forcefully shutting down');
|
|
process.exit(1);
|
|
}, 10000).unref();
|
|
};
|
|
|
|
process.on('SIGTERM', shutdown);
|
|
process.on('SIGINT', shutdown);
|
|
}
|
|
|
|
process.on('unhandledRejection', (error) => {
|
|
logger.error('Unhandled rejection:', error);
|
|
process.exit(1);
|
|
});
|
|
|
|
process.on('uncaughtException', (error) => {
|
|
logger.error('Uncaught exception:', error);
|
|
process.exit(1);
|
|
});
|
|
|
|
startServer();
|