176 lines
6.6 KiB
TypeScript
176 lines
6.6 KiB
TypeScript
import express from 'express';
|
|
import type { NextFunction, Request, Response } from 'express';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { existsSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import type { ApiErrorBody } from '@reforger-panel/shared';
|
|
import type { Env } from './env.js';
|
|
import type { Db } from './db/client.js';
|
|
import { ApiError } from './lib/errors.js';
|
|
import type { Logger } from './lib/logger.js';
|
|
import { createAuthRouter } from './modules/auth/auth-routes.js';
|
|
import { csrfProtection, sessionResolver } from './modules/auth/auth-middleware.js';
|
|
import type { SessionService } from './modules/auth/session-service.js';
|
|
import type { ConfigEditorService } from './modules/config/config-editor-service.js';
|
|
import type { ConfigSyncService } from './modules/config/config-sync.js';
|
|
import type { ServerModsService } from './modules/config/mods-service.js';
|
|
import type { PerformanceSettingsService } from './modules/config/performance-service.js';
|
|
import type { ServerMetricsService } from './modules/servers/metrics-service.js';
|
|
import type { ResourceHistoryService } from './modules/servers/resource-history.js';
|
|
import type { MissionsService } from './modules/reforger-logs/missions-catalog.js';
|
|
import type { ConsoleHub } from './modules/pterodactyl/console-hub.js';
|
|
import type { GameServerProvider } from './modules/pterodactyl/types.js';
|
|
import type { LogPathResolver } from './modules/reforger-logs/ingestion/log-path-resolver.js';
|
|
import type { IngestionScheduler } from './modules/reforger-logs/ingestion/scheduler.js';
|
|
import { createServerRouter } from './modules/servers/server-routes.js';
|
|
import type { ServerService } from './modules/servers/server-service.js';
|
|
import { createInviteRouter } from './modules/invites/invite-routes.js';
|
|
import { createUserRouter } from './modules/users/user-routes.js';
|
|
import { createWorkshopRouter } from './modules/workshop/workshop-routes.js';
|
|
import type { WorkshopCache } from './modules/workshop/workshop-cache.js';
|
|
|
|
export type AppDeps = {
|
|
env: Env;
|
|
logger: Logger;
|
|
db: Db;
|
|
sessions: SessionService;
|
|
servers: ServerService;
|
|
provider: GameServerProvider;
|
|
metrics: ServerMetricsService;
|
|
consoleHub: ConsoleHub | null;
|
|
workshop: WorkshopCache;
|
|
scheduler: IngestionScheduler | null;
|
|
resolveLogPath: LogPathResolver | null;
|
|
configSync: ConfigSyncService | null;
|
|
configEditor: ConfigEditorService | null;
|
|
mods: ServerModsService | null;
|
|
performance: PerformanceSettingsService | null;
|
|
resourceHistory: ResourceHistoryService | null;
|
|
missions: MissionsService;
|
|
};
|
|
|
|
export function createApp(deps: AppDeps) {
|
|
const { env, logger } = deps;
|
|
const app = express();
|
|
|
|
app.disable('x-powered-by');
|
|
app.set('trust proxy', 1);
|
|
app.use(express.json({ limit: '64kb' }));
|
|
|
|
// Request id + minimal structured request logging.
|
|
app.use((req, res, next) => {
|
|
const requestId = randomUUID();
|
|
res.locals.requestId = requestId;
|
|
res.setHeader('X-Request-Id', requestId);
|
|
res.on('finish', () => {
|
|
logger.debug(
|
|
{ requestId, method: req.method, path: req.path, status: res.statusCode },
|
|
'request',
|
|
);
|
|
});
|
|
next();
|
|
});
|
|
|
|
// Safe CORS default: only the configured web origin, with credentials.
|
|
const allowedOrigin = env.WEB_ORIGIN.replace(/\/$/, '');
|
|
app.use((req, res, next) => {
|
|
const origin = req.headers.origin;
|
|
if (origin && origin.replace(/\/$/, '') === allowedOrigin) {
|
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
res.setHeader('Vary', 'Origin');
|
|
}
|
|
if (req.method === 'OPTIONS') {
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,X-CSRF-Protection');
|
|
res.status(204).end();
|
|
return;
|
|
}
|
|
next();
|
|
});
|
|
|
|
app.use(sessionResolver(deps.sessions));
|
|
|
|
// CSRF protection for all state-changing requests. The OAuth callback is a
|
|
// GET and is protected by the signed state parameter instead.
|
|
const csrf = csrfProtection([allowedOrigin, `http://localhost:${env.PORT}`]);
|
|
app.use('/api', (req, res, next) => {
|
|
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
next();
|
|
return;
|
|
}
|
|
csrf(req, res, next);
|
|
});
|
|
|
|
app.get('/api/health', (_req, res) => {
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
app.use('/api/auth', createAuthRouter(env, deps.sessions));
|
|
app.use('/api/users', createUserRouter(deps.db));
|
|
app.use('/api/invites', createInviteRouter(deps.db));
|
|
app.use(
|
|
'/api/servers',
|
|
createServerRouter({
|
|
service: deps.servers,
|
|
provider: deps.provider,
|
|
metrics: deps.metrics,
|
|
consoleHub: deps.consoleHub,
|
|
scheduler: deps.scheduler,
|
|
resolveLogPath: deps.resolveLogPath,
|
|
configSync: deps.configSync,
|
|
configEditor: deps.configEditor,
|
|
mods: deps.mods,
|
|
performance: deps.performance,
|
|
resourceHistory: deps.resourceHistory,
|
|
missions: deps.missions,
|
|
workshop: deps.workshop,
|
|
staleAfterSeconds: env.REFORGER_LOG_STALE_AFTER_SECONDS,
|
|
mockMode: env.USE_MOCK_PTERODACTYL,
|
|
}),
|
|
);
|
|
app.use('/api/workshop', createWorkshopRouter(deps.workshop));
|
|
|
|
app.use('/api', (_req, _res, next) => {
|
|
next(ApiError.notFound('Unknown API route.'));
|
|
});
|
|
|
|
// Production: serve the built SPA from the same process (no separate web
|
|
// server needed). In development Vite serves the frontend instead.
|
|
const webDist = env.WEB_DIST_PATH || path.resolve(process.cwd(), '../web/dist');
|
|
const webIndex = path.join(webDist, 'index.html');
|
|
if (env.NODE_ENV === 'production' && existsSync(webIndex)) {
|
|
app.use(express.static(webDist, { index: false, maxAge: '1h' }));
|
|
app.use((req, res, next) => {
|
|
if (req.method !== 'GET' || req.path.startsWith('/api')) {
|
|
next();
|
|
return;
|
|
}
|
|
res.sendFile(webIndex);
|
|
});
|
|
logger.info({ webDist }, 'serving web app from API process');
|
|
}
|
|
|
|
// Structured error responses; internals are logged, never sent to clients.
|
|
app.use((error: unknown, req: Request, res: Response, _next: NextFunction) => {
|
|
const requestId = String(res.locals.requestId ?? '');
|
|
if (error instanceof ApiError) {
|
|
const body: ApiErrorBody = {
|
|
error: { code: error.code, message: error.message, requestId },
|
|
};
|
|
res.status(error.status).json(body);
|
|
return;
|
|
}
|
|
logger.error(
|
|
{ requestId, path: req.path, err: error instanceof Error ? error.message : String(error) },
|
|
'unhandled error',
|
|
);
|
|
const body: ApiErrorBody = {
|
|
error: { code: 'INTERNAL_ERROR', message: 'Something went wrong.', requestId },
|
|
};
|
|
res.status(500).json(body);
|
|
});
|
|
|
|
return app;
|
|
}
|