Files
ptero-reforger-panel/apps/api/src/app.ts
T
2026-09-05 13:10:36 -07:00

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;
}