initial commit
This commit is contained in:
106 files changed
+24584
No files matched your search
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/db/schema.ts',
|
||||
out: './drizzle',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL ?? 'postgresql://reforger:reforger@127.0.0.1:5433/reforger_panel',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
CREATE TABLE "config_revisions" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"server_id" text NOT NULL,
|
||||
"created_by_user_id" text,
|
||||
"version" integer NOT NULL,
|
||||
"summary" text NOT NULL,
|
||||
"config" jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "log_cursors" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"server_id" text NOT NULL,
|
||||
"log_path" text NOT NULL,
|
||||
"file_fingerprint" text,
|
||||
"last_byte_offset" bigint DEFAULT 0 NOT NULL,
|
||||
"last_line_hash" text,
|
||||
"partial_trailing_line" text,
|
||||
"last_event_timestamp" timestamp with time zone,
|
||||
"last_successful_sync_at" timestamp with time zone,
|
||||
"last_error_at" timestamp with time zone,
|
||||
"last_error_message" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "mod_pack_revisions" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"mod_pack_id" text NOT NULL,
|
||||
"version" integer NOT NULL,
|
||||
"notes" text,
|
||||
"mods" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"created_by_user_id" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "mod_packs" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"server_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"status" text DEFAULT 'draft' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "player_sessions" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"server_id" text NOT NULL,
|
||||
"player_id" text NOT NULL,
|
||||
"connected_at" timestamp with time zone NOT NULL,
|
||||
"disconnected_at" timestamp with time zone,
|
||||
"duration_seconds" integer,
|
||||
"disconnect_reason" text,
|
||||
"source_log_path" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "players" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"server_id" text NOT NULL,
|
||||
"external_player_id" text,
|
||||
"display_name" text NOT NULL,
|
||||
"first_seen_at" timestamp with time zone NOT NULL,
|
||||
"last_seen_at" timestamp with time zone NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "server_activity" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"server_id" text NOT NULL,
|
||||
"actor_user_id" text,
|
||||
"action" text NOT NULL,
|
||||
"summary" text NOT NULL,
|
||||
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "server_events" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"server_id" text NOT NULL,
|
||||
"event_type" text NOT NULL,
|
||||
"occurred_at" timestamp with time zone NOT NULL,
|
||||
"player_id" text,
|
||||
"player_session_id" text,
|
||||
"summary" text NOT NULL,
|
||||
"payload" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"source_log_path" text NOT NULL,
|
||||
"source_line_hash" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "servers" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"provider_type" text DEFAULT 'pterodactyl' NOT NULL,
|
||||
"pterodactyl_server_id" text,
|
||||
"status" text DEFAULT 'unknown' NOT NULL,
|
||||
"max_players" integer,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sessions" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"discord_id" text NOT NULL,
|
||||
"username" text NOT NULL,
|
||||
"display_name" text,
|
||||
"avatar_url" text,
|
||||
"role" text DEFAULT 'viewer' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "config_revisions" ADD CONSTRAINT "config_revisions_server_id_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "config_revisions" ADD CONSTRAINT "config_revisions_created_by_user_id_users_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "log_cursors" ADD CONSTRAINT "log_cursors_server_id_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "mod_pack_revisions" ADD CONSTRAINT "mod_pack_revisions_mod_pack_id_mod_packs_id_fk" FOREIGN KEY ("mod_pack_id") REFERENCES "public"."mod_packs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "mod_pack_revisions" ADD CONSTRAINT "mod_pack_revisions_created_by_user_id_users_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "mod_packs" ADD CONSTRAINT "mod_packs_server_id_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "player_sessions" ADD CONSTRAINT "player_sessions_server_id_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "player_sessions" ADD CONSTRAINT "player_sessions_player_id_players_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "players" ADD CONSTRAINT "players_server_id_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "server_activity" ADD CONSTRAINT "server_activity_server_id_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "server_activity" ADD CONSTRAINT "server_activity_actor_user_id_users_id_fk" FOREIGN KEY ("actor_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "server_events" ADD CONSTRAINT "server_events_server_id_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "server_events" ADD CONSTRAINT "server_events_player_id_players_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "server_events" ADD CONSTRAINT "server_events_player_session_id_player_sessions_id_fk" FOREIGN KEY ("player_session_id") REFERENCES "public"."player_sessions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "config_revisions_server_version_unique" ON "config_revisions" USING btree ("server_id","version");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "log_cursors_server_path_unique" ON "log_cursors" USING btree ("server_id","log_path");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "mod_pack_revisions_pack_version_unique" ON "mod_pack_revisions" USING btree ("mod_pack_id","version");--> statement-breakpoint
|
||||
CREATE INDEX "player_sessions_server_open_idx" ON "player_sessions" USING btree ("server_id","disconnected_at");--> statement-breakpoint
|
||||
CREATE INDEX "player_sessions_player_idx" ON "player_sessions" USING btree ("player_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "players_server_external_id_unique" ON "players" USING btree ("server_id","external_player_id");--> statement-breakpoint
|
||||
CREATE INDEX "players_server_name_idx" ON "players" USING btree ("server_id","display_name");--> statement-breakpoint
|
||||
CREATE INDEX "server_activity_server_created_idx" ON "server_activity" USING btree ("server_id","created_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "server_events_dedupe_unique" ON "server_events" USING btree ("server_id","source_log_path","source_line_hash");--> statement-breakpoint
|
||||
CREATE INDEX "server_events_server_occurred_idx" ON "server_events" USING btree ("server_id","occurred_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "servers_slug_unique" ON "servers" USING btree ("slug");--> statement-breakpoint
|
||||
CREATE INDEX "sessions_user_id_idx" ON "sessions" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "users_discord_id_unique" ON "users" USING btree ("discord_id");
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE "invites" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"code" text NOT NULL,
|
||||
"role" text DEFAULT 'viewer' NOT NULL,
|
||||
"created_by_user_id" text,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"used_by_user_id" text,
|
||||
"used_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "invites" ADD CONSTRAINT "invites_created_by_user_id_users_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "invites" ADD CONSTRAINT "invites_used_by_user_id_users_id_fk" FOREIGN KEY ("used_by_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "invites_code_unique" ON "invites" USING btree ("code");
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1783229645037,
|
||||
"tag": "0000_init",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1783279251170,
|
||||
"tag": "0001_invites",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@reforger-panel/api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch --env-file-if-exists=../../.env src/index.ts",
|
||||
"build": "tsup",
|
||||
"start": "node --env-file-if-exists=../../.env dist/index.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "tsx --env-file-if-exists=../../.env src/db/migrate.ts",
|
||||
"db:seed": "tsx --env-file-if-exists=../../.env src/db/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@reforger-panel/shared": "*",
|
||||
"cookie": "^1.0.2",
|
||||
"drizzle-orm": "^0.44.0",
|
||||
"express": "^5.1.0",
|
||||
"pg": "^8.16.0",
|
||||
"pino": "^9.7.0",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/pg": "^8.15.0",
|
||||
"@types/supertest": "^6.0.0",
|
||||
"drizzle-kit": "^0.31.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"supertest": "^7.1.0",
|
||||
"tsup": "^8.5.0",
|
||||
"tsx": "^4.20.0",
|
||||
"vitest": "^3.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
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 { 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 { ResourceHistoryService } from './modules/servers/resource-history.js';
|
||||
import type { MissionCatalog } from './modules/reforger-logs/missions-catalog.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 { WorkshopClient } from './modules/workshop/workshop-client.js';
|
||||
|
||||
export type AppDeps = {
|
||||
env: Env;
|
||||
logger: Logger;
|
||||
db: Db;
|
||||
sessions: SessionService;
|
||||
servers: ServerService;
|
||||
provider: GameServerProvider;
|
||||
workshop: WorkshopClient;
|
||||
scheduler: IngestionScheduler | null;
|
||||
resolveLogPath: LogPathResolver | null;
|
||||
configSync: ConfigSyncService | null;
|
||||
mods: ServerModsService | null;
|
||||
performance: PerformanceSettingsService | null;
|
||||
resourceHistory: ResourceHistoryService | null;
|
||||
missions: MissionCatalog | null;
|
||||
};
|
||||
|
||||
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,
|
||||
scheduler: deps.scheduler,
|
||||
resolveLogPath: deps.resolveLogPath,
|
||||
configSync: deps.configSync,
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import pg from 'pg';
|
||||
import * as schema from './schema.js';
|
||||
|
||||
export function createDb(databaseUrl: string) {
|
||||
const pool = new pg.Pool({ connectionString: databaseUrl, max: 10 });
|
||||
const db = drizzle(pool, { schema });
|
||||
return { db, pool };
|
||||
}
|
||||
|
||||
export type Db = ReturnType<typeof createDb>['db'];
|
||||
export { schema };
|
||||
@@ -0,0 +1,17 @@
|
||||
import { migrate } from 'drizzle-orm/node-postgres/migrator';
|
||||
import path from 'node:path';
|
||||
import { createDb } from './client.js';
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) {
|
||||
console.error('DATABASE_URL is not set');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Run from apps/api (the npm scripts do this); migrations live in ./drizzle.
|
||||
const migrationsFolder = path.resolve(process.cwd(), 'drizzle');
|
||||
|
||||
const { db, pool } = createDb(databaseUrl);
|
||||
await migrate(db, { migrationsFolder });
|
||||
await pool.end();
|
||||
console.log('Migrations applied.');
|
||||
@@ -0,0 +1,247 @@
|
||||
import {
|
||||
bigint,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
const id = () =>
|
||||
text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => randomUUID());
|
||||
|
||||
const createdAt = () => timestamp('created_at', { withTimezone: true }).notNull().defaultNow();
|
||||
const updatedAt = () =>
|
||||
timestamp('updated_at', { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdateFn(() => new Date());
|
||||
|
||||
export const users = pgTable(
|
||||
'users',
|
||||
{
|
||||
id: id(),
|
||||
discordId: text('discord_id').notNull(),
|
||||
username: text('username').notNull(),
|
||||
displayName: text('display_name'),
|
||||
avatarUrl: text('avatar_url'),
|
||||
role: text('role', { enum: ['owner', 'server_admin', 'mission_lead', 'viewer'] })
|
||||
.notNull()
|
||||
.default('viewer'),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [uniqueIndex('users_discord_id_unique').on(t.discordId)],
|
||||
);
|
||||
|
||||
export const sessions = pgTable(
|
||||
'sessions',
|
||||
{
|
||||
// sha256 hash of the cookie token; the raw token is never stored.
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [index('sessions_user_id_idx').on(t.userId)],
|
||||
);
|
||||
|
||||
export const invites = pgTable(
|
||||
'invites',
|
||||
{
|
||||
id: id(),
|
||||
code: text('code').notNull(),
|
||||
role: text('role', { enum: ['owner', 'server_admin', 'mission_lead', 'viewer'] })
|
||||
.notNull()
|
||||
.default('viewer'),
|
||||
createdByUserId: text('created_by_user_id').references(() => users.id, {
|
||||
onDelete: 'set null',
|
||||
}),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
usedByUserId: text('used_by_user_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
usedAt: timestamp('used_at', { withTimezone: true }),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [uniqueIndex('invites_code_unique').on(t.code)],
|
||||
);
|
||||
|
||||
export const servers = pgTable(
|
||||
'servers',
|
||||
{
|
||||
id: id(),
|
||||
slug: text('slug').notNull(),
|
||||
name: text('name').notNull(),
|
||||
providerType: text('provider_type').notNull().default('pterodactyl'),
|
||||
pterodactylServerId: text('pterodactyl_server_id'),
|
||||
status: text('status').notNull().default('unknown'),
|
||||
maxPlayers: integer('max_players'),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [uniqueIndex('servers_slug_unique').on(t.slug)],
|
||||
);
|
||||
|
||||
export const serverActivity = pgTable(
|
||||
'server_activity',
|
||||
{
|
||||
id: id(),
|
||||
serverId: text('server_id')
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: 'cascade' }),
|
||||
actorUserId: text('actor_user_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
action: text('action').notNull(),
|
||||
summary: text('summary').notNull(),
|
||||
metadata: jsonb('metadata').notNull().default({}),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [index('server_activity_server_created_idx').on(t.serverId, t.createdAt)],
|
||||
);
|
||||
|
||||
export const modPacks = pgTable('mod_packs', {
|
||||
id: id(),
|
||||
serverId: text('server_id')
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
status: text('status').notNull().default('draft'),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
});
|
||||
|
||||
export const modPackRevisions = pgTable(
|
||||
'mod_pack_revisions',
|
||||
{
|
||||
id: id(),
|
||||
modPackId: text('mod_pack_id')
|
||||
.notNull()
|
||||
.references(() => modPacks.id, { onDelete: 'cascade' }),
|
||||
version: integer('version').notNull(),
|
||||
notes: text('notes'),
|
||||
mods: jsonb('mods').notNull().default([]),
|
||||
createdByUserId: text('created_by_user_id').references(() => users.id, {
|
||||
onDelete: 'set null',
|
||||
}),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [uniqueIndex('mod_pack_revisions_pack_version_unique').on(t.modPackId, t.version)],
|
||||
);
|
||||
|
||||
export const configRevisions = pgTable(
|
||||
'config_revisions',
|
||||
{
|
||||
id: id(),
|
||||
serverId: text('server_id')
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: 'cascade' }),
|
||||
createdByUserId: text('created_by_user_id').references(() => users.id, {
|
||||
onDelete: 'set null',
|
||||
}),
|
||||
version: integer('version').notNull(),
|
||||
summary: text('summary').notNull(),
|
||||
config: jsonb('config').notNull(),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [uniqueIndex('config_revisions_server_version_unique').on(t.serverId, t.version)],
|
||||
);
|
||||
|
||||
export const logCursors = pgTable(
|
||||
'log_cursors',
|
||||
{
|
||||
id: id(),
|
||||
serverId: text('server_id')
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: 'cascade' }),
|
||||
logPath: text('log_path').notNull(),
|
||||
fileFingerprint: text('file_fingerprint'),
|
||||
lastByteOffset: bigint('last_byte_offset', { mode: 'number' }).notNull().default(0),
|
||||
lastLineHash: text('last_line_hash'),
|
||||
partialTrailingLine: text('partial_trailing_line'),
|
||||
lastEventTimestamp: timestamp('last_event_timestamp', { withTimezone: true }),
|
||||
lastSuccessfulSyncAt: timestamp('last_successful_sync_at', { withTimezone: true }),
|
||||
lastErrorAt: timestamp('last_error_at', { withTimezone: true }),
|
||||
lastErrorMessage: text('last_error_message'),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [uniqueIndex('log_cursors_server_path_unique').on(t.serverId, t.logPath)],
|
||||
);
|
||||
|
||||
export const players = pgTable(
|
||||
'players',
|
||||
{
|
||||
id: id(),
|
||||
serverId: text('server_id')
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: 'cascade' }),
|
||||
// Stable identity from logs (e.g. a GUID) when available. Display names are
|
||||
// NOT unique identities; see README for the fallback limitations.
|
||||
externalPlayerId: text('external_player_id'),
|
||||
displayName: text('display_name').notNull(),
|
||||
firstSeenAt: timestamp('first_seen_at', { withTimezone: true }).notNull(),
|
||||
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull(),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('players_server_external_id_unique').on(t.serverId, t.externalPlayerId),
|
||||
index('players_server_name_idx').on(t.serverId, t.displayName),
|
||||
],
|
||||
);
|
||||
|
||||
export const playerSessions = pgTable(
|
||||
'player_sessions',
|
||||
{
|
||||
id: id(),
|
||||
serverId: text('server_id')
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: 'cascade' }),
|
||||
playerId: text('player_id')
|
||||
.notNull()
|
||||
.references(() => players.id, { onDelete: 'cascade' }),
|
||||
connectedAt: timestamp('connected_at', { withTimezone: true }).notNull(),
|
||||
disconnectedAt: timestamp('disconnected_at', { withTimezone: true }),
|
||||
durationSeconds: integer('duration_seconds'),
|
||||
disconnectReason: text('disconnect_reason'),
|
||||
sourceLogPath: text('source_log_path').notNull(),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [
|
||||
index('player_sessions_server_open_idx').on(t.serverId, t.disconnectedAt),
|
||||
index('player_sessions_player_idx').on(t.playerId),
|
||||
],
|
||||
);
|
||||
|
||||
export const serverEvents = pgTable(
|
||||
'server_events',
|
||||
{
|
||||
id: id(),
|
||||
serverId: text('server_id')
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: 'cascade' }),
|
||||
eventType: text('event_type').notNull(),
|
||||
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(),
|
||||
playerId: text('player_id').references(() => players.id, { onDelete: 'set null' }),
|
||||
playerSessionId: text('player_session_id').references(() => playerSessions.id, {
|
||||
onDelete: 'set null',
|
||||
}),
|
||||
summary: text('summary').notNull(),
|
||||
payload: jsonb('payload').notNull().default({}),
|
||||
sourceLogPath: text('source_log_path').notNull(),
|
||||
sourceLineHash: text('source_line_hash').notNull(),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [
|
||||
// Event deduplication key: one event per (server, log file, line fingerprint).
|
||||
uniqueIndex('server_events_dedupe_unique').on(t.serverId, t.sourceLogPath, t.sourceLineHash),
|
||||
index('server_events_server_occurred_idx').on(t.serverId, t.occurredAt),
|
||||
],
|
||||
);
|
||||
@@ -0,0 +1,47 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { createDb, schema } from './client.js';
|
||||
|
||||
// Seeds only the server row. Everything else (configuration, players,
|
||||
// sessions, events, activity) comes from the real server: config revisions are
|
||||
// imported from config.json and player data from log ingestion. No fake data.
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) {
|
||||
console.error('DATABASE_URL is not set');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { db, pool } = createDb(databaseUrl);
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(schema.servers)
|
||||
.where(eq(schema.servers.slug, 'training-server'));
|
||||
|
||||
const pterodactylServerId = process.env.PTERODACTYL_SERVER_ID || null;
|
||||
|
||||
if (existing[0]) {
|
||||
if (pterodactylServerId && existing[0].pterodactylServerId !== pterodactylServerId) {
|
||||
await db
|
||||
.update(schema.servers)
|
||||
.set({ pterodactylServerId })
|
||||
.where(eq(schema.servers.id, existing[0].id));
|
||||
console.log(`Updated pterodactylServerId to ${pterodactylServerId}.`);
|
||||
} else {
|
||||
console.log('Server "training-server" already exists; nothing to do.');
|
||||
}
|
||||
} else {
|
||||
await db.insert(schema.servers).values({
|
||||
slug: 'training-server',
|
||||
// Placeholder until the first config.json import overwrites it.
|
||||
name: 'Reforger Server',
|
||||
providerType: 'pterodactyl',
|
||||
pterodactylServerId,
|
||||
status: 'unknown',
|
||||
maxPlayers: null,
|
||||
});
|
||||
console.log('Seeded server row (name/maxPlayers will be imported from config.json).');
|
||||
}
|
||||
|
||||
await pool.end();
|
||||
console.log('Seed complete.');
|
||||
@@ -0,0 +1,105 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const booleanString = z
|
||||
.enum(['true', 'false'])
|
||||
.default('false')
|
||||
.transform((v) => v === 'true');
|
||||
|
||||
const envSchema = z
|
||||
.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
PORT: z.coerce.number().int().min(1).max(65535).default(3001),
|
||||
WEB_ORIGIN: z.string().url().default('http://localhost:5173'),
|
||||
|
||||
DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'),
|
||||
/** Directory of the built web app; when it exists the API serves it. */
|
||||
WEB_DIST_PATH: z.string().default(''),
|
||||
SESSION_SECRET: z.string().min(32, 'SESSION_SECRET must be at least 32 characters'),
|
||||
|
||||
DISCORD_CLIENT_ID: z.string().default(''),
|
||||
DISCORD_CLIENT_SECRET: z.string().default(''),
|
||||
DISCORD_REDIRECT_URI: z.string().default('http://localhost:3001/api/auth/discord/callback'),
|
||||
OWNER_DISCORD_ID: z.string().default(''),
|
||||
DEV_AUTH_BYPASS: booleanString,
|
||||
|
||||
REFORGER_WORKSHOP_API_BASE_URL: z.string().url().default('https://api.reforgermods.net'),
|
||||
|
||||
PTERODACTYL_BASE_URL: z.string().default(''),
|
||||
PTERODACTYL_CLIENT_API_KEY: z.string().default(''),
|
||||
PTERODACTYL_SERVER_ID: z.string().default(''),
|
||||
USE_MOCK_PTERODACTYL: booleanString,
|
||||
|
||||
REFORGER_CONFIG_PATH: z.string().default('/config.json'),
|
||||
REFORGER_CONFIG_SYNC_INTERVAL_SECONDS: z.coerce.number().int().min(60).max(86400).default(300),
|
||||
|
||||
REFORGER_ADMIN_LOG_PATH: z.string().default(''),
|
||||
REFORGER_LOG_DIRECTORY: z.string().default(''),
|
||||
REFORGER_LOG_FILE_PATTERN: z.string().default(''),
|
||||
REFORGER_LOG_POLL_INTERVAL_SECONDS: z.coerce.number().int().min(5).max(3600).default(20),
|
||||
REFORGER_LOG_MAX_DOWNLOAD_BYTES: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(64 * 1024)
|
||||
.max(64 * 1024 * 1024)
|
||||
.default(2 * 1024 * 1024),
|
||||
REFORGER_LOG_STALE_AFTER_SECONDS: z.coerce.number().int().min(30).default(90),
|
||||
})
|
||||
.superRefine((env, ctx) => {
|
||||
if (env.NODE_ENV === 'production' && env.DEV_AUTH_BYPASS) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'DEV_AUTH_BYPASS must not be enabled in production',
|
||||
path: ['DEV_AUTH_BYPASS'],
|
||||
});
|
||||
}
|
||||
if (!env.USE_MOCK_PTERODACTYL) {
|
||||
for (const key of [
|
||||
'PTERODACTYL_BASE_URL',
|
||||
'PTERODACTYL_CLIENT_API_KEY',
|
||||
'PTERODACTYL_SERVER_ID',
|
||||
] as const) {
|
||||
if (!env[key]) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `${key} is required when USE_MOCK_PTERODACTYL is false`,
|
||||
path: [key],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (env.NODE_ENV === 'production' && (!env.DISCORD_CLIENT_ID || !env.DISCORD_CLIENT_SECRET)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Discord OAuth credentials are required in production',
|
||||
path: ['DISCORD_CLIENT_ID'],
|
||||
});
|
||||
}
|
||||
if (env.NODE_ENV === 'production' && !env.OWNER_DISCORD_ID) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'OWNER_DISCORD_ID is required in production so the owner account is recoverable',
|
||||
path: ['OWNER_DISCORD_ID'],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env {
|
||||
const parsed = envSchema.safeParse(source);
|
||||
if (!parsed.success) {
|
||||
const details = parsed.error.issues
|
||||
.map((issue) => ` - ${issue.path.join('.') || '(root)'}: ${issue.message}`)
|
||||
.join('\n');
|
||||
throw new Error(`Invalid environment configuration:\n${details}`);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
/** True when the panel has enough configuration to talk to a game server backend. */
|
||||
export function isPterodactylConfigured(env: Env): boolean {
|
||||
return (
|
||||
env.USE_MOCK_PTERODACTYL ||
|
||||
Boolean(env.PTERODACTYL_BASE_URL && env.PTERODACTYL_CLIENT_API_KEY && env.PTERODACTYL_SERVER_ID)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { createApp } from './app.js';
|
||||
import { createDb } from './db/client.js';
|
||||
import { isPterodactylConfigured, loadEnv } from './env.js';
|
||||
import { createLogger } from './lib/logger.js';
|
||||
import { SessionService } from './modules/auth/session-service.js';
|
||||
import { ConfigFileGateway } from './modules/config/config-file-gateway.js';
|
||||
import { ConfigSyncService } from './modules/config/config-sync.js';
|
||||
import { ServerModsService } from './modules/config/mods-service.js';
|
||||
import { PerformanceSettingsService } from './modules/config/performance-service.js';
|
||||
import { ResourceHistoryService } from './modules/servers/resource-history.js';
|
||||
import { MissionCatalog } from './modules/reforger-logs/missions-catalog.js';
|
||||
import { MockGameServerProvider } from './modules/pterodactyl/mock-provider.js';
|
||||
import { PterodactylProvider } from './modules/pterodactyl/pterodactyl-provider.js';
|
||||
import type { GameServerProvider } from './modules/pterodactyl/types.js';
|
||||
import { DrizzleIngestionStore } from './modules/reforger-logs/ingestion/drizzle-store.js';
|
||||
import { LogIngestionService } from './modules/reforger-logs/ingestion/ingestion-service.js';
|
||||
import { createLogPathResolver } from './modules/reforger-logs/ingestion/log-path-resolver.js';
|
||||
import { PterodactylLogSource } from './modules/reforger-logs/ingestion/pterodactyl-log-source.js';
|
||||
import { IngestionScheduler } from './modules/reforger-logs/ingestion/scheduler.js';
|
||||
import { ServerService } from './modules/servers/server-service.js';
|
||||
import { WorkshopClient } from './modules/workshop/workshop-client.js';
|
||||
|
||||
const logger = createLogger();
|
||||
|
||||
const env = loadEnv();
|
||||
const { db, pool } = createDb(env.DATABASE_URL);
|
||||
|
||||
const mockLogPath = env.REFORGER_ADMIN_LOG_PATH || '/profile/logs/console.log';
|
||||
const provider: GameServerProvider = env.USE_MOCK_PTERODACTYL
|
||||
? new MockGameServerProvider({ logPath: mockLogPath })
|
||||
: new PterodactylProvider({
|
||||
baseUrl: env.PTERODACTYL_BASE_URL,
|
||||
apiKey: env.PTERODACTYL_CLIENT_API_KEY,
|
||||
});
|
||||
|
||||
const sessions = new SessionService(db, env.OWNER_DISCORD_ID);
|
||||
const servers = new ServerService(db);
|
||||
const workshop = new WorkshopClient({ baseUrl: env.REFORGER_WORKSHOP_API_BASE_URL });
|
||||
const configSync = isPterodactylConfigured(env)
|
||||
? new ConfigSyncService(provider, servers, logger, env.REFORGER_CONFIG_PATH)
|
||||
: null;
|
||||
const gateway = new ConfigFileGateway(provider, env.REFORGER_CONFIG_PATH);
|
||||
const mods = configSync ? new ServerModsService(gateway, configSync, logger) : null;
|
||||
const performance = configSync ? new PerformanceSettingsService(gateway, configSync, logger) : null;
|
||||
const resourceHistory = new ResourceHistoryService(provider, logger);
|
||||
|
||||
// Log ingestion runs when a backend is configured and we know where logs live.
|
||||
const logsConfigured =
|
||||
isPterodactylConfigured(env) &&
|
||||
Boolean(env.REFORGER_ADMIN_LOG_PATH || env.REFORGER_LOG_DIRECTORY || env.USE_MOCK_PTERODACTYL);
|
||||
|
||||
const primaryServer = (await servers.listServers())[0] ?? null;
|
||||
const providerServerId = primaryServer
|
||||
? (primaryServer.pterodactylServerId ?? primaryServer.slug)
|
||||
: '';
|
||||
|
||||
const resolveLogPath =
|
||||
logsConfigured && primaryServer
|
||||
? createLogPathResolver({
|
||||
provider,
|
||||
providerServerId,
|
||||
explicitPath: env.USE_MOCK_PTERODACTYL ? mockLogPath : env.REFORGER_ADMIN_LOG_PATH,
|
||||
directory: env.REFORGER_LOG_DIRECTORY,
|
||||
fileName: env.REFORGER_LOG_FILE_PATTERN,
|
||||
})
|
||||
: null;
|
||||
|
||||
const missions =
|
||||
resolveLogPath && primaryServer
|
||||
? new MissionCatalog(provider, resolveLogPath, providerServerId)
|
||||
: null;
|
||||
|
||||
let scheduler: IngestionScheduler | null = null;
|
||||
if (resolveLogPath) {
|
||||
const ingestion = new LogIngestionService(
|
||||
new PterodactylLogSource(provider),
|
||||
new DrizzleIngestionStore(db),
|
||||
logger,
|
||||
{ maxDownloadBytes: env.REFORGER_LOG_MAX_DOWNLOAD_BYTES },
|
||||
);
|
||||
scheduler = new IngestionScheduler(
|
||||
ingestion,
|
||||
logger,
|
||||
env.REFORGER_LOG_POLL_INTERVAL_SECONDS * 1000,
|
||||
);
|
||||
} else {
|
||||
logger.info('log ingestion disabled (backend or log location not configured)');
|
||||
}
|
||||
|
||||
const app = createApp({
|
||||
env,
|
||||
logger,
|
||||
db,
|
||||
sessions,
|
||||
servers,
|
||||
provider,
|
||||
workshop,
|
||||
scheduler,
|
||||
resolveLogPath,
|
||||
configSync,
|
||||
mods,
|
||||
performance,
|
||||
resourceHistory,
|
||||
missions,
|
||||
});
|
||||
|
||||
const httpServer = app.listen(env.PORT, () => {
|
||||
logger.info(
|
||||
{ port: env.PORT, mockPterodactyl: env.USE_MOCK_PTERODACTYL },
|
||||
'reforger-panel API listening',
|
||||
);
|
||||
});
|
||||
|
||||
if (scheduler && resolveLogPath && primaryServer) {
|
||||
scheduler.start([
|
||||
{
|
||||
serverId: primaryServer.id,
|
||||
providerServerId,
|
||||
resolveLogPath,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
if (primaryServer && isPterodactylConfigured(env)) {
|
||||
resourceHistory.start([{ serverId: primaryServer.id, providerServerId }]);
|
||||
}
|
||||
|
||||
// Import the real config.json at startup and on an interval so the panel
|
||||
// always reflects what the server actually runs.
|
||||
let configSyncTimer: ReturnType<typeof setInterval> | null = null;
|
||||
if (configSync) {
|
||||
void configSync.syncAllQuietly();
|
||||
configSyncTimer = setInterval(
|
||||
() => void configSync.syncAllQuietly(),
|
||||
env.REFORGER_CONFIG_SYNC_INTERVAL_SECONDS * 1000,
|
||||
);
|
||||
configSyncTimer.unref();
|
||||
}
|
||||
|
||||
// Hourly cleanup of expired sessions.
|
||||
const sessionCleanup = setInterval(
|
||||
() => void sessions.deleteExpiredSessions().catch(() => undefined),
|
||||
60 * 60 * 1000,
|
||||
);
|
||||
sessionCleanup.unref();
|
||||
|
||||
let shuttingDown = false;
|
||||
async function shutdown(signal: string) {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
logger.info({ signal }, 'shutting down');
|
||||
httpServer.close();
|
||||
clearInterval(sessionCleanup);
|
||||
if (configSyncTimer) clearInterval(configSyncTimer);
|
||||
resourceHistory.stop();
|
||||
if (scheduler) await scheduler.stop();
|
||||
if (provider instanceof MockGameServerProvider) provider.dispose();
|
||||
await pool.end();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => void shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
||||
@@ -0,0 +1,31 @@
|
||||
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
/** 256-bit URL-safe random token. */
|
||||
export function generateToken(): string {
|
||||
return randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
/** Sessions are stored by token hash so a DB leak does not leak usable cookies. */
|
||||
export function hashSessionToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
export function sha256Hex(input: string): string {
|
||||
return createHash('sha256').update(input).digest('hex');
|
||||
}
|
||||
|
||||
export function signValue(value: string, secret: string): string {
|
||||
const signature = createHmac('sha256', secret).update(value).digest('base64url');
|
||||
return `${value}.${signature}`;
|
||||
}
|
||||
|
||||
export function verifySignedValue(signed: string, secret: string): string | null {
|
||||
const separator = signed.lastIndexOf('.');
|
||||
if (separator <= 0) return null;
|
||||
const value = signed.slice(0, separator);
|
||||
const expected = signValue(value, secret);
|
||||
const a = Buffer.from(signed);
|
||||
const b = Buffer.from(expected);
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ApiErrorCode } from '@reforger-panel/shared';
|
||||
|
||||
const STATUS_BY_CODE: Record<ApiErrorCode, number> = {
|
||||
UNAUTHENTICATED: 401,
|
||||
FORBIDDEN: 403,
|
||||
NOT_FOUND: 404,
|
||||
VALIDATION_ERROR: 400,
|
||||
RATE_LIMITED: 429,
|
||||
CONFLICT: 409,
|
||||
UPSTREAM_UNAVAILABLE: 502,
|
||||
NOT_CONFIGURED: 503,
|
||||
INTERNAL_ERROR: 500,
|
||||
};
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly code: ApiErrorCode;
|
||||
readonly status: number;
|
||||
|
||||
constructor(code: ApiErrorCode, message: string) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.code = code;
|
||||
this.status = STATUS_BY_CODE[code];
|
||||
}
|
||||
|
||||
static unauthenticated(message = 'You must be signed in.') {
|
||||
return new ApiError('UNAUTHENTICATED', message);
|
||||
}
|
||||
static forbidden(message = 'You do not have permission to perform this action.') {
|
||||
return new ApiError('FORBIDDEN', message);
|
||||
}
|
||||
static notFound(message = 'Not found.') {
|
||||
return new ApiError('NOT_FOUND', message);
|
||||
}
|
||||
static validation(message: string) {
|
||||
return new ApiError('VALIDATION_ERROR', message);
|
||||
}
|
||||
static rateLimited(message = 'Too many requests. Try again shortly.') {
|
||||
return new ApiError('RATE_LIMITED', message);
|
||||
}
|
||||
static upstream(message = 'An upstream service is unavailable.') {
|
||||
return new ApiError('UPSTREAM_UNAVAILABLE', message);
|
||||
}
|
||||
static notConfigured(message = 'This feature is not configured.') {
|
||||
return new ApiError('NOT_CONFIGURED', message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { pino } from 'pino';
|
||||
|
||||
const REDACT_PATHS = [
|
||||
'req.headers.authorization',
|
||||
'req.headers.cookie',
|
||||
'res.headers["set-cookie"]',
|
||||
'*.apiKey',
|
||||
'*.clientSecret',
|
||||
'*.sessionToken',
|
||||
'*.password',
|
||||
'apiKey',
|
||||
'clientSecret',
|
||||
'sessionToken',
|
||||
];
|
||||
|
||||
export function createLogger(level?: string) {
|
||||
return pino({
|
||||
level: level ?? (process.env.NODE_ENV === 'test' ? 'silent' : 'info'),
|
||||
redact: { paths: REDACT_PATHS, censor: '[redacted]' },
|
||||
transport:
|
||||
process.env.NODE_ENV === 'development'
|
||||
? { target: 'pino-pretty', options: { colorize: true, translateTime: 'HH:MM:ss' } }
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export type Logger = ReturnType<typeof createLogger>;
|
||||
|
||||
const SECRET_HINTS = [/api[_-]?key/i, /secret/i, /token/i, /password/i, /authorization/i];
|
||||
|
||||
/**
|
||||
* Strip anything that looks like a secret, an internal URL, or a stack trace
|
||||
* from an error before it is persisted or shown to a user.
|
||||
*/
|
||||
export function sanitizeErrorMessage(error: unknown, maxLength = 300): string {
|
||||
let message = error instanceof Error ? error.message : String(error);
|
||||
message = message.split('\n')[0] ?? '';
|
||||
// Drop credentials embedded in URLs and query strings.
|
||||
message = message.replace(/\/\/[^/\s:]+:[^@/\s]+@/g, '//[redacted]@');
|
||||
message = message.replace(/([?&](?:key|token|secret|password)=)[^&\s]+/gi, '$1[redacted]');
|
||||
for (const hint of SECRET_HINTS) {
|
||||
if (hint.test(message)) {
|
||||
// A secret-ish word appears; keep only a generic description.
|
||||
return 'Upstream request failed (details withheld — see server logs)';
|
||||
}
|
||||
}
|
||||
return message.slice(0, maxLength);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { ApiError } from './errors.js';
|
||||
|
||||
type Bucket = { count: number; resetAt: number };
|
||||
|
||||
/**
|
||||
* Small in-memory fixed-window rate limiter. Sufficient for a single-process
|
||||
* private panel; swap for a shared store if the API is ever scaled out.
|
||||
*/
|
||||
export function rateLimit(options: { windowMs: number; max: number; keyPrefix: string }) {
|
||||
const buckets = new Map<string, Bucket>();
|
||||
|
||||
return (req: Request, _res: Response, next: NextFunction) => {
|
||||
const now = Date.now();
|
||||
const key = `${options.keyPrefix}:${req.ip ?? 'unknown'}`;
|
||||
let bucket = buckets.get(key);
|
||||
if (!bucket || bucket.resetAt <= now) {
|
||||
bucket = { count: 0, resetAt: now + options.windowMs };
|
||||
buckets.set(key, bucket);
|
||||
}
|
||||
bucket.count += 1;
|
||||
if (buckets.size > 10_000) {
|
||||
for (const [k, b] of buckets) {
|
||||
if (b.resetAt <= now) buckets.delete(k);
|
||||
}
|
||||
}
|
||||
if (bucket.count > options.max) {
|
||||
next(ApiError.rateLimited());
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { parse as parseCookies } from 'cookie';
|
||||
import type { Capability } from '@reforger-panel/shared';
|
||||
import { roleHasCapability } from '@reforger-panel/shared';
|
||||
import { ApiError } from '../../lib/errors.js';
|
||||
import type { SessionUser } from './session-service.js';
|
||||
|
||||
export const SESSION_COOKIE = 'rp_session';
|
||||
|
||||
declare module 'express-serve-static-core' {
|
||||
interface Request {
|
||||
user?: SessionUser;
|
||||
sessionToken?: string;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SessionLookup {
|
||||
getUserBySessionToken(token: string): Promise<SessionUser | null>;
|
||||
}
|
||||
|
||||
export function readSessionToken(req: Request): string | null {
|
||||
const header = req.headers.cookie;
|
||||
if (!header) return null;
|
||||
const cookies = parseCookies(header);
|
||||
return cookies[SESSION_COOKIE] ?? null;
|
||||
}
|
||||
|
||||
/** Resolves the session cookie into req.user (if valid); never rejects on its own. */
|
||||
export function sessionResolver(sessions: SessionLookup) {
|
||||
return async (req: Request, _res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const token = readSessionToken(req);
|
||||
if (token) {
|
||||
const user = await sessions.getUserBySessionToken(token);
|
||||
if (user) {
|
||||
req.user = user;
|
||||
req.sessionToken = token;
|
||||
}
|
||||
}
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function requireAuth(req: Request, _res: Response, next: NextFunction) {
|
||||
if (!req.user) {
|
||||
next(ApiError.unauthenticated());
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/** Backend-enforced capability check. Frontend role checks are UI convenience only. */
|
||||
export function requireCapability(capability: Capability, message?: string) {
|
||||
return (req: Request, _res: Response, next: NextFunction) => {
|
||||
if (!req.user) {
|
||||
next(ApiError.unauthenticated());
|
||||
return;
|
||||
}
|
||||
if (!roleHasCapability(req.user.role, capability)) {
|
||||
next(ApiError.forbidden(message));
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* CSRF protection for state-changing endpoints: the SPA sends a custom header
|
||||
* (which browsers only allow same-origin / via CORS we control), and when the
|
||||
* browser supplies an Origin header it must match an allowed origin.
|
||||
*/
|
||||
export function csrfProtection(allowedOrigins: string[]) {
|
||||
const allowed = new Set(allowedOrigins.map((o) => o.replace(/\/$/, '')));
|
||||
return (req: Request, _res: Response, next: NextFunction) => {
|
||||
const origin = req.headers.origin;
|
||||
if (origin && !allowed.has(origin.replace(/\/$/, ''))) {
|
||||
next(ApiError.forbidden('Cross-origin request rejected.'));
|
||||
return;
|
||||
}
|
||||
if (req.headers['x-csrf-protection'] !== '1') {
|
||||
next(ApiError.forbidden('Missing CSRF protection header.'));
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { Router } from 'express';
|
||||
import { serialize as serializeCookie } from 'cookie';
|
||||
import type { CurrentUser } from '@reforger-panel/shared';
|
||||
import { ROLE_CAPABILITIES } from '@reforger-panel/shared';
|
||||
import type { Env } from '../../env.js';
|
||||
import { ApiError } from '../../lib/errors.js';
|
||||
import { generateToken, signValue, verifySignedValue } from '../../lib/crypto.js';
|
||||
import { rateLimit } from '../../lib/rate-limit.js';
|
||||
import { buildAuthorizeUrl, exchangeCodeForProfile } from './discord.js';
|
||||
import type { SessionService } from './session-service.js';
|
||||
import { SESSION_COOKIE, requireAuth } from './auth-middleware.js';
|
||||
|
||||
const STATE_COOKIE = 'rp_oauth_state';
|
||||
|
||||
function sessionCookie(token: string, expiresAt: Date, secure: boolean): string {
|
||||
return serializeCookie(SESSION_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
expires: expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
function clearedSessionCookie(secure: boolean): string {
|
||||
return serializeCookie(SESSION_COOKIE, '', {
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function createAuthRouter(env: Env, sessions: SessionService): Router {
|
||||
const router = Router();
|
||||
const secure = env.NODE_ENV === 'production';
|
||||
const oauthConfig = {
|
||||
clientId: env.DISCORD_CLIENT_ID,
|
||||
clientSecret: env.DISCORD_CLIENT_SECRET,
|
||||
redirectUri: env.DISCORD_REDIRECT_URI,
|
||||
};
|
||||
const authRateLimit = rateLimit({ windowMs: 60_000, max: 10, keyPrefix: 'auth' });
|
||||
|
||||
// Which login methods the frontend should offer.
|
||||
router.get('/options', (_req, res) => {
|
||||
res.json({
|
||||
discord: Boolean(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET),
|
||||
devLogin: env.DEV_AUTH_BYPASS && env.NODE_ENV !== 'production',
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/discord', authRateLimit, (req, res, next) => {
|
||||
if (!env.DISCORD_CLIENT_ID || !env.DISCORD_CLIENT_SECRET) {
|
||||
next(
|
||||
ApiError.notConfigured(
|
||||
'Discord OAuth is not configured. Set DISCORD_CLIENT_ID and DISCORD_CLIENT_SECRET.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const state = generateToken();
|
||||
res.setHeader(
|
||||
'Set-Cookie',
|
||||
serializeCookie(STATE_COOKIE, signValue(state, env.SESSION_SECRET), {
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite: 'lax',
|
||||
path: '/api/auth',
|
||||
maxAge: 10 * 60,
|
||||
}),
|
||||
);
|
||||
res.redirect(buildAuthorizeUrl(oauthConfig, state));
|
||||
});
|
||||
|
||||
router.get('/discord/callback', authRateLimit, async (req, res, next) => {
|
||||
try {
|
||||
const code = typeof req.query.code === 'string' ? req.query.code : null;
|
||||
const state = typeof req.query.state === 'string' ? req.query.state : null;
|
||||
const stateCookieRaw = req.headers.cookie
|
||||
?.split(';')
|
||||
.map((c) => c.trim())
|
||||
.find((c) => c.startsWith(`${STATE_COOKIE}=`))
|
||||
?.slice(STATE_COOKIE.length + 1);
|
||||
|
||||
if (!code || !state || !stateCookieRaw) {
|
||||
throw ApiError.validation('Missing OAuth code or state.');
|
||||
}
|
||||
const expectedState = verifySignedValue(
|
||||
decodeURIComponent(stateCookieRaw),
|
||||
env.SESSION_SECRET,
|
||||
);
|
||||
if (!expectedState || expectedState !== state) {
|
||||
throw ApiError.forbidden('OAuth state mismatch. Please try signing in again.');
|
||||
}
|
||||
|
||||
const profile = await exchangeCodeForProfile(oauthConfig, code);
|
||||
const user = await sessions.upsertUserFromDiscord(profile);
|
||||
const session = await sessions.createSession(user.id);
|
||||
|
||||
res.setHeader('Set-Cookie', [
|
||||
sessionCookie(session.token, session.expiresAt, secure),
|
||||
serializeCookie(STATE_COOKIE, '', { path: '/api/auth', maxAge: 0 }),
|
||||
]);
|
||||
res.redirect(env.WEB_ORIGIN);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Local development helper: sign in without Discord. Refuses to exist in
|
||||
// production (env validation also rejects the flag there).
|
||||
if (env.DEV_AUTH_BYPASS && env.NODE_ENV !== 'production') {
|
||||
router.post('/dev-login', authRateLimit, async (_req, res, next) => {
|
||||
try {
|
||||
const user = await sessions.upsertUserFromDiscord({
|
||||
discordId: env.OWNER_DISCORD_ID || '000000000000000000',
|
||||
username: 'dev-owner',
|
||||
displayName: 'Dev Owner',
|
||||
avatarUrl: null,
|
||||
});
|
||||
// The dev user is always the owner locally.
|
||||
if (user.role !== 'owner') {
|
||||
await sessions.setRole(user.id, 'owner');
|
||||
}
|
||||
const session = await sessions.createSession(user.id);
|
||||
res.setHeader('Set-Cookie', sessionCookie(session.token, session.expiresAt, secure));
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
router.get('/me', requireAuth, (req, res) => {
|
||||
const user = req.user!;
|
||||
const body: CurrentUser = {
|
||||
id: user.id,
|
||||
discordId: user.discordId,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
role: user.role,
|
||||
capabilities: [...ROLE_CAPABILITIES[user.role]],
|
||||
};
|
||||
res.json(body);
|
||||
});
|
||||
|
||||
router.post('/logout', async (req, res, next) => {
|
||||
try {
|
||||
if (req.sessionToken) {
|
||||
await sessions.revokeSession(req.sessionToken);
|
||||
}
|
||||
res.setHeader('Set-Cookie', clearedSessionCookie(secure));
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { z } from 'zod';
|
||||
import { ApiError } from '../../lib/errors.js';
|
||||
|
||||
const DISCORD_API = 'https://discord.com/api/v10';
|
||||
const DISCORD_OAUTH_AUTHORIZE = 'https://discord.com/oauth2/authorize';
|
||||
|
||||
const tokenResponseSchema = z.object({
|
||||
access_token: z.string(),
|
||||
token_type: z.string(),
|
||||
});
|
||||
|
||||
const discordUserSchema = z.object({
|
||||
id: z.string(),
|
||||
username: z.string(),
|
||||
global_name: z.string().nullable().optional(),
|
||||
avatar: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export type DiscordProfile = {
|
||||
discordId: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
|
||||
export type DiscordOAuthConfig = {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
redirectUri: string;
|
||||
};
|
||||
|
||||
export function buildAuthorizeUrl(config: DiscordOAuthConfig, state: string): string {
|
||||
const url = new URL(DISCORD_OAUTH_AUTHORIZE);
|
||||
url.searchParams.set('client_id', config.clientId);
|
||||
url.searchParams.set('redirect_uri', config.redirectUri);
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('scope', 'identify');
|
||||
url.searchParams.set('state', state);
|
||||
url.searchParams.set('prompt', 'none');
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export async function exchangeCodeForProfile(
|
||||
config: DiscordOAuthConfig,
|
||||
code: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<DiscordProfile> {
|
||||
const tokenResponse = await fetchImpl(`${DISCORD_API}/oauth2/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: config.redirectUri,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!tokenResponse.ok) {
|
||||
throw ApiError.upstream('Discord token exchange failed.');
|
||||
}
|
||||
const token = tokenResponseSchema.parse(await tokenResponse.json());
|
||||
|
||||
const userResponse = await fetchImpl(`${DISCORD_API}/users/@me`, {
|
||||
headers: { Authorization: `${token.token_type} ${token.access_token}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!userResponse.ok) {
|
||||
throw ApiError.upstream('Failed to fetch Discord profile.');
|
||||
}
|
||||
const user = discordUserSchema.parse(await userResponse.json());
|
||||
|
||||
return {
|
||||
discordId: user.id,
|
||||
username: user.username,
|
||||
displayName: user.global_name ?? null,
|
||||
avatarUrl: user.avatar
|
||||
? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=128`
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { eq, lt } from 'drizzle-orm';
|
||||
import type { Role } from '@reforger-panel/shared';
|
||||
import type { Db } from '../../db/client.js';
|
||||
import { schema } from '../../db/client.js';
|
||||
import { generateToken, hashSessionToken } from '../../lib/crypto.js';
|
||||
import type { DiscordProfile } from './discord.js';
|
||||
|
||||
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days, revocable server-side
|
||||
|
||||
export type SessionUser = {
|
||||
id: string;
|
||||
discordId: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
role: Role;
|
||||
};
|
||||
|
||||
/**
|
||||
* Role assignment at login: the configured owner Discord ID always gets (and
|
||||
* keeps) `owner`; existing users keep their locally-assigned role; everyone
|
||||
* new starts as `viewer`.
|
||||
*/
|
||||
export function resolveRoleForLogin(
|
||||
existingRole: Role | null,
|
||||
discordId: string,
|
||||
ownerDiscordId: string,
|
||||
): Role {
|
||||
if (ownerDiscordId !== '' && discordId === ownerDiscordId) return 'owner';
|
||||
return existingRole ?? 'viewer';
|
||||
}
|
||||
|
||||
export class SessionService {
|
||||
constructor(
|
||||
private readonly db: Db,
|
||||
private readonly ownerDiscordId: string,
|
||||
) {}
|
||||
|
||||
/** Create or update the local user record for a Discord login. */
|
||||
async upsertUserFromDiscord(profile: DiscordProfile): Promise<SessionUser> {
|
||||
const existing = await this.db
|
||||
.select()
|
||||
.from(schema.users)
|
||||
.where(eq(schema.users.discordId, profile.discordId));
|
||||
|
||||
if (existing[0]) {
|
||||
const nextRole = resolveRoleForLogin(
|
||||
existing[0].role,
|
||||
profile.discordId,
|
||||
this.ownerDiscordId,
|
||||
);
|
||||
const [updated] = await this.db
|
||||
.update(schema.users)
|
||||
.set({
|
||||
username: profile.username,
|
||||
displayName: profile.displayName,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
role: nextRole,
|
||||
})
|
||||
.where(eq(schema.users.id, existing[0].id))
|
||||
.returning();
|
||||
return updated!;
|
||||
}
|
||||
|
||||
const [created] = await this.db
|
||||
.insert(schema.users)
|
||||
.values({
|
||||
discordId: profile.discordId,
|
||||
username: profile.username,
|
||||
displayName: profile.displayName,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
role: resolveRoleForLogin(null, profile.discordId, this.ownerDiscordId),
|
||||
})
|
||||
.returning();
|
||||
return created!;
|
||||
}
|
||||
|
||||
async setRole(userId: string, role: Role): Promise<SessionUser | null> {
|
||||
const [updated] = await this.db
|
||||
.update(schema.users)
|
||||
.set({ role })
|
||||
.where(eq(schema.users.id, userId))
|
||||
.returning();
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
/** Returns the raw token for the cookie; only its hash is persisted. */
|
||||
async createSession(userId: string): Promise<{ token: string; expiresAt: Date }> {
|
||||
const token = generateToken();
|
||||
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
|
||||
await this.db.insert(schema.sessions).values({
|
||||
id: hashSessionToken(token),
|
||||
userId,
|
||||
expiresAt,
|
||||
});
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
async getUserBySessionToken(token: string): Promise<SessionUser | null> {
|
||||
const rows = await this.db
|
||||
.select({ session: schema.sessions, user: schema.users })
|
||||
.from(schema.sessions)
|
||||
.innerJoin(schema.users, eq(schema.users.id, schema.sessions.userId))
|
||||
.where(eq(schema.sessions.id, hashSessionToken(token)));
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
if (row.session.expiresAt.getTime() <= Date.now()) {
|
||||
await this.db.delete(schema.sessions).where(eq(schema.sessions.id, row.session.id));
|
||||
return null;
|
||||
}
|
||||
return row.user;
|
||||
}
|
||||
|
||||
async revokeSession(token: string): Promise<void> {
|
||||
await this.db.delete(schema.sessions).where(eq(schema.sessions.id, hashSessionToken(token)));
|
||||
}
|
||||
|
||||
async deleteExpiredSessions(): Promise<void> {
|
||||
await this.db.delete(schema.sessions).where(lt(schema.sessions.expiresAt, new Date()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ApiError } from '../../lib/errors.js';
|
||||
import type { GameServerProvider } from '../pterodactyl/types.js';
|
||||
|
||||
const CONFIG_MAX_BYTES = 256 * 1024;
|
||||
|
||||
export function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared read-modify-write access to the server's config.json: size-guarded
|
||||
* download + parse, and a write path that backs the previous content up to
|
||||
* `<config>.bak` and verifies the upload by downloading it again. Callers
|
||||
* mutate only their own keys on the parsed document so everything else in the
|
||||
* file passes through untouched.
|
||||
*/
|
||||
export class ConfigFileGateway {
|
||||
constructor(
|
||||
private readonly provider: GameServerProvider,
|
||||
readonly configPath: string,
|
||||
) {}
|
||||
|
||||
async download(
|
||||
providerServerId: string,
|
||||
): Promise<{ raw: string; root: Record<string, unknown> }> {
|
||||
const file = await this.provider.downloadTextFile(
|
||||
providerServerId,
|
||||
this.configPath,
|
||||
CONFIG_MAX_BYTES,
|
||||
);
|
||||
if (file.truncated) {
|
||||
throw ApiError.upstream('Server config.json is unexpectedly large; refusing to modify it.');
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(file.content.replace(/^\uFEFF/, ''));
|
||||
} catch {
|
||||
throw ApiError.upstream('Server config.json is not valid JSON.');
|
||||
}
|
||||
const root = asRecord(parsed);
|
||||
if (!root || !asRecord(root.game)) {
|
||||
throw ApiError.upstream('Server config.json has no "game" section; refusing to modify it.');
|
||||
}
|
||||
return { raw: file.content, root };
|
||||
}
|
||||
|
||||
/**
|
||||
* Backs up `previousRaw`, writes the mutated document, downloads it again
|
||||
* and hands the verified parsed result to `verify` (throw there to fail).
|
||||
*/
|
||||
async write(
|
||||
providerServerId: string,
|
||||
root: Record<string, unknown>,
|
||||
previousRaw: string,
|
||||
verify: (readBack: Record<string, unknown>) => void,
|
||||
): Promise<Record<string, unknown>> {
|
||||
await this.provider.writeTextFile(providerServerId, `${this.configPath}.bak`, previousRaw);
|
||||
const serialized = `${JSON.stringify(root, null, 4)}\n`;
|
||||
await this.provider.writeTextFile(providerServerId, this.configPath, serialized);
|
||||
const readBack = await this.download(providerServerId);
|
||||
verify(readBack.root);
|
||||
return readBack.root;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ReforgerServerConfig } from '@reforger-panel/shared';
|
||||
import { sanitizeErrorMessage, type Logger } from '../../lib/logger.js';
|
||||
import type { GameServerProvider } from '../pterodactyl/types.js';
|
||||
import type { ServerRecord, ServerService } from '../servers/server-service.js';
|
||||
import { parseReforgerConfigJson } from './reforger-config-file.js';
|
||||
|
||||
const CONFIG_MAX_BYTES = 256 * 1024;
|
||||
|
||||
export type ConfigSyncResult = {
|
||||
serverName: string;
|
||||
maxPlayers: number;
|
||||
config: ReforgerServerConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads the server's real config.json (via the provider, read-only) and keeps
|
||||
* the server row's name/maxPlayers in line with what the server actually
|
||||
* runs. Configuration is always served live; no revision history is kept.
|
||||
*/
|
||||
export class ConfigSyncService {
|
||||
constructor(
|
||||
private readonly provider: GameServerProvider,
|
||||
private readonly servers: ServerService,
|
||||
private readonly logger: Logger,
|
||||
private readonly configPath: string,
|
||||
) {}
|
||||
|
||||
async getLiveConfig(server: ServerRecord): Promise<ReforgerServerConfig> {
|
||||
const providerServerId = server.pterodactylServerId ?? server.slug;
|
||||
const file = await this.provider.downloadTextFile(
|
||||
providerServerId,
|
||||
this.configPath,
|
||||
CONFIG_MAX_BYTES,
|
||||
);
|
||||
return parseReforgerConfigJson(file.content);
|
||||
}
|
||||
|
||||
async sync(server: ServerRecord): Promise<ConfigSyncResult> {
|
||||
const config = await this.getLiveConfig(server);
|
||||
const maxPlayers = config.maxPlayers > 0 ? config.maxPlayers : null;
|
||||
if (server.name !== config.serverName || server.maxPlayers !== maxPlayers) {
|
||||
await this.servers.updateServerInfo(server.id, {
|
||||
name: config.serverName,
|
||||
maxPlayers,
|
||||
});
|
||||
this.logger.info(
|
||||
{ serverId: server.id, serverName: config.serverName, maxPlayers },
|
||||
'server info updated from config.json',
|
||||
);
|
||||
}
|
||||
return { serverName: config.serverName, maxPlayers: config.maxPlayers, config };
|
||||
}
|
||||
|
||||
/** Sync all servers, logging failures instead of throwing (for the poll loop). */
|
||||
async syncAllQuietly(): Promise<void> {
|
||||
const servers = await this.servers.listServers();
|
||||
for (const server of servers) {
|
||||
try {
|
||||
await this.sync(server);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{ serverId: server.id, error: sanitizeErrorMessage(error) },
|
||||
'config sync failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type {
|
||||
ReforgerConfigMod,
|
||||
ServerModsResponse,
|
||||
UpdateModsResult,
|
||||
} from '@reforger-panel/shared';
|
||||
import { ApiError } from '../../lib/errors.js';
|
||||
import type { Logger } from '../../lib/logger.js';
|
||||
import type { ServerRecord } from '../servers/server-service.js';
|
||||
import { asRecord, type ConfigFileGateway } from './config-file-gateway.js';
|
||||
import type { ConfigSyncService } from './config-sync.js';
|
||||
|
||||
function readMods(root: Record<string, unknown>): ReforgerConfigMod[] {
|
||||
const game = asRecord(root.game);
|
||||
if (!game || !Array.isArray(game.mods)) return [];
|
||||
return game.mods
|
||||
.map((entry): ReforgerConfigMod | null => {
|
||||
const mod = asRecord(entry);
|
||||
const modId = typeof mod?.modId === 'string' ? mod.modId : '';
|
||||
if (!modId) return null;
|
||||
const name = typeof mod?.name === 'string' && mod.name ? mod.name : undefined;
|
||||
const version = typeof mod?.version === 'string' && mod.version ? mod.version : undefined;
|
||||
return {
|
||||
modId,
|
||||
...(name ? { name } : {}),
|
||||
...(version ? { version } : {}),
|
||||
};
|
||||
})
|
||||
.filter((mod): mod is ReforgerConfigMod => mod !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the `game.mods` array of the server's real config.json through the
|
||||
* shared ConfigFileGateway (backup + read-back verification; all other config
|
||||
* fields pass through untouched). Changes apply on the next server restart.
|
||||
*/
|
||||
export class ServerModsService {
|
||||
constructor(
|
||||
private readonly gateway: ConfigFileGateway,
|
||||
private readonly configSync: ConfigSyncService,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
private providerId(server: ServerRecord): string {
|
||||
return server.pterodactylServerId ?? server.slug;
|
||||
}
|
||||
|
||||
async getMods(server: ServerRecord): Promise<ServerModsResponse> {
|
||||
const { root } = await this.gateway.download(this.providerId(server));
|
||||
return { mods: readMods(root), fetchedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
async setMods(server: ServerRecord, mods: ReforgerConfigMod[]): Promise<UpdateModsResult> {
|
||||
const providerId = this.providerId(server);
|
||||
const { raw, root } = await this.gateway.download(providerId);
|
||||
const previous = readMods(root);
|
||||
|
||||
const previousIds = new Set(previous.map((mod) => mod.modId.toUpperCase()));
|
||||
const nextIds = new Set(mods.map((mod) => mod.modId.toUpperCase()));
|
||||
const added = [...nextIds].filter((id) => !previousIds.has(id)).length;
|
||||
const removed = [...previousIds].filter((id) => !nextIds.has(id)).length;
|
||||
|
||||
const game = asRecord(root.game)!;
|
||||
game.mods = mods.map((mod) => ({
|
||||
modId: mod.modId.toUpperCase(),
|
||||
...(mod.name ? { name: mod.name } : {}),
|
||||
...(mod.version ? { version: mod.version } : {}),
|
||||
}));
|
||||
|
||||
const verified = await this.gateway.write(providerId, root, raw, (readBack) => {
|
||||
const verifyIds = readMods(readBack)
|
||||
.map((mod) => mod.modId.toUpperCase())
|
||||
.sort();
|
||||
if (JSON.stringify(verifyIds) !== JSON.stringify([...nextIds].sort())) {
|
||||
throw ApiError.upstream(
|
||||
'Config write verification failed — the file on the server does not match. Check config.json.bak.',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await this.configSync.sync(server).catch((error) => {
|
||||
this.logger.warn(
|
||||
{ serverId: server.id, err: String(error) },
|
||||
'post-write config sync failed',
|
||||
);
|
||||
});
|
||||
|
||||
this.logger.info({ serverId: server.id, added, removed }, 'server mods updated');
|
||||
return {
|
||||
mods: readMods(verified),
|
||||
fetchedAt: new Date().toISOString(),
|
||||
added,
|
||||
removed,
|
||||
requiresRestart: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import type {
|
||||
PerformanceSettings,
|
||||
PerformanceSettingsPatch,
|
||||
PerformanceSettingsResponse,
|
||||
} from '@reforger-panel/shared';
|
||||
import type { Logger } from '../../lib/logger.js';
|
||||
import type { ServerRecord } from '../servers/server-service.js';
|
||||
import { asRecord, type ConfigFileGateway } from './config-file-gateway.js';
|
||||
import type { ConfigSyncService } from './config-sync.js';
|
||||
|
||||
/** Where each performance field lives inside config.json. */
|
||||
const FIELD_LOCATIONS: Record<
|
||||
keyof PerformanceSettings,
|
||||
['game' | 'gameProperties' | 'operating', string]
|
||||
> = {
|
||||
scenarioId: ['game', 'scenarioId'],
|
||||
maxPlayers: ['game', 'maxPlayers'],
|
||||
serverMaxViewDistance: ['gameProperties', 'serverMaxViewDistance'],
|
||||
networkViewDistance: ['gameProperties', 'networkViewDistance'],
|
||||
serverMinGrassDistance: ['gameProperties', 'serverMinGrassDistance'],
|
||||
disableThirdPerson: ['gameProperties', 'disableThirdPerson'],
|
||||
fastValidation: ['gameProperties', 'fastValidation'],
|
||||
battlEye: ['gameProperties', 'battlEye'],
|
||||
aiLimit: ['operating', 'aiLimit'],
|
||||
playerSaveTime: ['operating', 'playerSaveTime'],
|
||||
slotReservationTimeout: ['operating', 'slotReservationTimeout'],
|
||||
lobbyPlayerSynchronise: ['operating', 'lobbyPlayerSynchronise'],
|
||||
};
|
||||
|
||||
function sectionFor(
|
||||
root: Record<string, unknown>,
|
||||
section: 'game' | 'gameProperties' | 'operating',
|
||||
createMissing: boolean,
|
||||
): Record<string, unknown> | null {
|
||||
const game = asRecord(root.game)!;
|
||||
if (section === 'game') return game;
|
||||
if (section === 'gameProperties') {
|
||||
let props = asRecord(game.gameProperties);
|
||||
if (!props && createMissing) {
|
||||
props = {};
|
||||
game.gameProperties = props;
|
||||
}
|
||||
return props;
|
||||
}
|
||||
let operating = asRecord(root.operating);
|
||||
if (!operating && createMissing) {
|
||||
operating = {};
|
||||
root.operating = operating;
|
||||
}
|
||||
return operating;
|
||||
}
|
||||
|
||||
export function readPerformanceSettings(root: Record<string, unknown>): PerformanceSettings {
|
||||
const result = {} as Record<keyof PerformanceSettings, number | boolean | string | null>;
|
||||
for (const [field, [section, key]] of Object.entries(FIELD_LOCATIONS) as [
|
||||
keyof PerformanceSettings,
|
||||
['game' | 'gameProperties' | 'operating', string],
|
||||
][]) {
|
||||
const container = sectionFor(root, section, false);
|
||||
const value = container?.[key];
|
||||
const validType =
|
||||
field === 'scenarioId'
|
||||
? typeof value === 'string'
|
||||
: typeof value === 'number' || typeof value === 'boolean';
|
||||
result[field] = validType ? (value as number | boolean | string) : null;
|
||||
}
|
||||
return result as PerformanceSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits the performance-related keys of the live config.json. A `null` value
|
||||
* removes the key from the file entirely so the game's own default applies —
|
||||
* network/identity fields (bind address, ports, passwords, rcon…) are never
|
||||
* touched by this service.
|
||||
*/
|
||||
export class PerformanceSettingsService {
|
||||
constructor(
|
||||
private readonly gateway: ConfigFileGateway,
|
||||
private readonly configSync: ConfigSyncService,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
private providerId(server: ServerRecord): string {
|
||||
return server.pterodactylServerId ?? server.slug;
|
||||
}
|
||||
|
||||
async get(server: ServerRecord): Promise<PerformanceSettingsResponse> {
|
||||
const { root } = await this.gateway.download(this.providerId(server));
|
||||
return { settings: readPerformanceSettings(root), fetchedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
async update(
|
||||
server: ServerRecord,
|
||||
patch: PerformanceSettingsPatch,
|
||||
): Promise<PerformanceSettingsResponse & { changedFields: string[]; requiresRestart: true }> {
|
||||
const providerId = this.providerId(server);
|
||||
const { raw, root } = await this.gateway.download(providerId);
|
||||
const before = readPerformanceSettings(root);
|
||||
|
||||
const changedFields: string[] = [];
|
||||
for (const [field, [section, key]] of Object.entries(FIELD_LOCATIONS) as [
|
||||
keyof PerformanceSettings,
|
||||
['game' | 'gameProperties' | 'operating', string],
|
||||
][]) {
|
||||
if (!(field in patch)) continue; // untouched fields stay as-is
|
||||
const next = patch[field] as number | boolean | string | null;
|
||||
if (before[field] === next) continue;
|
||||
changedFields.push(field);
|
||||
if (next === null) {
|
||||
const container = sectionFor(root, section, false);
|
||||
if (container) delete container[key];
|
||||
} else {
|
||||
const container = sectionFor(root, section, true)!;
|
||||
container[key] = next;
|
||||
}
|
||||
}
|
||||
|
||||
if (changedFields.length > 0) {
|
||||
await this.gateway.write(providerId, root, raw, (readBack) => {
|
||||
const after = readPerformanceSettings(readBack);
|
||||
for (const field of changedFields) {
|
||||
if (
|
||||
after[field as keyof PerformanceSettings] !== patch[field as keyof PerformanceSettings]
|
||||
) {
|
||||
throw new Error('Config write verification failed. Check config.json.bak.');
|
||||
}
|
||||
}
|
||||
});
|
||||
await this.configSync.sync(server).catch((error) => {
|
||||
this.logger.warn(
|
||||
{ serverId: server.id, err: String(error) },
|
||||
'post-write config sync failed',
|
||||
);
|
||||
});
|
||||
this.logger.info({ serverId: server.id, changedFields }, 'performance settings updated');
|
||||
}
|
||||
|
||||
return {
|
||||
settings: { ...before, ...patch } as PerformanceSettings,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
changedFields,
|
||||
requiresRestart: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseReforgerConfigJson } from './reforger-config-file.js';
|
||||
import { ApiError } from '../../lib/errors.js';
|
||||
|
||||
// Shape from the Reforger dedicated-server docs / typical Pterodactyl egg output.
|
||||
const REAL_SHAPE = {
|
||||
bindAddress: '0.0.0.0',
|
||||
bindPort: 2001,
|
||||
publicAddress: '',
|
||||
publicPort: 2001,
|
||||
a2s: { address: '0.0.0.0', port: 17777 },
|
||||
rcon: { address: '127.0.0.1', port: 19999, password: 'hunter2', permission: 'admin' },
|
||||
game: {
|
||||
name: 'DazzledCorp Training Grounds',
|
||||
password: '',
|
||||
passwordAdmin: 'secret',
|
||||
admins: ['76561198000000000'],
|
||||
scenarioId: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf',
|
||||
maxPlayers: 16,
|
||||
visible: true,
|
||||
crossPlatform: true,
|
||||
supportedPlatforms: ['PLATFORM_PC', 'PLATFORM_XBL'],
|
||||
gameProperties: {
|
||||
serverMaxViewDistance: 2500,
|
||||
serverMinGrassDistance: 50,
|
||||
networkViewDistance: 1000,
|
||||
disableThirdPerson: true,
|
||||
fastValidation: true,
|
||||
battlEye: true,
|
||||
VONDisableUI: false,
|
||||
},
|
||||
mods: [
|
||||
{ modId: '591AF5BDA9F7CE8B', name: 'Some Mod', version: '1.0.2' },
|
||||
{ modId: '5AAF0CCE3F001FB5' },
|
||||
],
|
||||
},
|
||||
operating: { lobbyPlayerSynchronise: true, aiLimit: -1, playerSaveTime: 120 },
|
||||
};
|
||||
|
||||
describe('parseReforgerConfigJson', () => {
|
||||
it('maps a real-shaped config.json into the panel model', () => {
|
||||
const config = parseReforgerConfigJson(JSON.stringify(REAL_SHAPE));
|
||||
expect(config).toEqual({
|
||||
serverName: 'DazzledCorp Training Grounds',
|
||||
maxPlayers: 16,
|
||||
scenarioId: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf',
|
||||
aiLimit: -1,
|
||||
serverMaxViewDistance: 2500,
|
||||
networkViewDistance: 1000,
|
||||
crossPlatform: true,
|
||||
disableThirdPerson: true,
|
||||
mods: [
|
||||
{ modId: '591AF5BDA9F7CE8B', name: 'Some Mod', version: '1.0.2' },
|
||||
{ modId: '5AAF0CCE3F001FB5' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('never includes credentials from the config file in the mapped model', () => {
|
||||
const json = JSON.stringify(parseReforgerConfigJson(JSON.stringify(REAL_SHAPE)));
|
||||
expect(json).not.toContain('hunter2');
|
||||
expect(json).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('tolerates missing sections with neutral defaults', () => {
|
||||
const config = parseReforgerConfigJson('{"game":{"name":"Bare"}}');
|
||||
expect(config.serverName).toBe('Bare');
|
||||
expect(config.maxPlayers).toBe(0);
|
||||
expect(config.aiLimit).toBe(-1);
|
||||
expect(config.mods).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects invalid JSON with a sanitized upstream error', () => {
|
||||
expect(() => parseReforgerConfigJson('not json {')).toThrow(ApiError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { ReforgerServerConfig } from '@reforger-panel/shared';
|
||||
import { ApiError } from '../../lib/errors.js';
|
||||
|
||||
/**
|
||||
* Maps a real Reforger server `config.json` (the file the dedicated server
|
||||
* runs with, documented at
|
||||
* https://community.bistudio.com/wiki/Arma_Reforger:Server_Config) into the
|
||||
* panel's internal config model. Mapping is defensive: missing or oddly-typed
|
||||
* fields fall back to neutral defaults instead of failing the sync.
|
||||
*/
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function str(value: unknown, fallback = ''): string {
|
||||
return typeof value === 'string' ? value : fallback;
|
||||
}
|
||||
|
||||
function num(value: unknown, fallback: number): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function bool(value: unknown, fallback: boolean): boolean {
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
export function mapReforgerConfig(raw: unknown): ReforgerServerConfig {
|
||||
const root = record(raw);
|
||||
const game = record(root.game);
|
||||
const gameProperties = record(game.gameProperties);
|
||||
const operating = record(root.operating);
|
||||
|
||||
const mods = Array.isArray(game.mods)
|
||||
? game.mods
|
||||
.map((entry) => {
|
||||
const mod = record(entry);
|
||||
const modId = str(mod.modId);
|
||||
if (!modId) return null;
|
||||
return {
|
||||
modId,
|
||||
name: str(mod.name) || undefined,
|
||||
version: str(mod.version) || undefined,
|
||||
};
|
||||
})
|
||||
.filter((mod): mod is NonNullable<typeof mod> => mod !== null)
|
||||
: [];
|
||||
|
||||
return {
|
||||
serverName: str(game.name, 'Unnamed server'),
|
||||
maxPlayers: num(game.maxPlayers, 0),
|
||||
scenarioId: str(game.scenarioId),
|
||||
// -1 means "no limit" in Reforger's operating.aiLimit.
|
||||
aiLimit: num(operating.aiLimit, -1),
|
||||
serverMaxViewDistance: num(gameProperties.serverMaxViewDistance, 0),
|
||||
networkViewDistance: num(gameProperties.networkViewDistance, 0),
|
||||
crossPlatform: bool(game.crossPlatform, false),
|
||||
disableThirdPerson: bool(gameProperties.disableThirdPerson, false),
|
||||
mods,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseReforgerConfigJson(content: string): ReforgerServerConfig {
|
||||
const text = content.replace(/^\uFEFF/, '').trim();
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(text);
|
||||
} catch {
|
||||
throw ApiError.upstream('Server config.json is not valid JSON.');
|
||||
}
|
||||
return mapReforgerConfig(raw);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { and, desc, eq, gt, isNull } from 'drizzle-orm';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import type { InviteSummary, Role } from '@reforger-panel/shared';
|
||||
import { ROLES } from '@reforger-panel/shared';
|
||||
import type { Db } from '../../db/client.js';
|
||||
import { schema } from '../../db/client.js';
|
||||
import { ApiError } from '../../lib/errors.js';
|
||||
import { rateLimit } from '../../lib/rate-limit.js';
|
||||
import { requireAuth, requireCapability } from '../auth/auth-middleware.js';
|
||||
|
||||
const createBodySchema = z.object({
|
||||
// Owner invites are deliberately not creatable; there is one owner.
|
||||
role: z.enum(['server_admin', 'mission_lead', 'viewer']),
|
||||
expiresInHours: z.number().int().min(1).max(8760).nullable().default(168),
|
||||
});
|
||||
|
||||
const NEVER_EXPIRES_HOURS = 24 * 365 * 100;
|
||||
|
||||
const redeemBodySchema = z.object({
|
||||
code: z.string().trim().min(4).max(64),
|
||||
});
|
||||
|
||||
function inviteCode(): string {
|
||||
// Readable, unambiguous, ~50 bits.
|
||||
return randomBytes(10).toString('base64url').replace(/[-_]/g, 'x').slice(0, 12).toUpperCase();
|
||||
}
|
||||
|
||||
export function createInviteRouter(db: Db): Router {
|
||||
const router = Router();
|
||||
const redeemRateLimit = rateLimit({ windowMs: 60_000, max: 10, keyPrefix: 'invite-redeem' });
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
/**
|
||||
* Redeem an invite: upgrades the calling user to the invite's role and
|
||||
* consumes the code. Available to any signed-in user (rate limited).
|
||||
*/
|
||||
router.post('/redeem', redeemRateLimit, async (req, res, next) => {
|
||||
try {
|
||||
const body = redeemBodySchema.safeParse(req.body);
|
||||
if (!body.success) throw ApiError.validation('Invalid invite code.');
|
||||
const user = req.user!;
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(schema.invites)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.invites.code, body.data.code.toUpperCase()),
|
||||
isNull(schema.invites.usedAt),
|
||||
gt(schema.invites.expiresAt, new Date()),
|
||||
),
|
||||
);
|
||||
const invite = rows[0];
|
||||
if (!invite) {
|
||||
throw ApiError.notFound('This invite code is invalid, used, or expired.');
|
||||
}
|
||||
if (user.role === 'owner') {
|
||||
// Owners never downgrade themselves by redeeming a code.
|
||||
res.json({ ok: true, role: user.role, changed: false });
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(schema.invites)
|
||||
.set({ usedByUserId: user.id, usedAt: new Date() })
|
||||
.where(eq(schema.invites.id, invite.id));
|
||||
await db
|
||||
.update(schema.users)
|
||||
.set({ role: invite.role as Role })
|
||||
.where(eq(schema.users.id, user.id));
|
||||
res.json({ ok: true, role: invite.role, changed: invite.role !== user.role });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.use(requireCapability('users.manage', 'Only the owner can manage invites.'));
|
||||
|
||||
router.get('/', async (_req, res, next) => {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({ invite: schema.invites, createdBy: schema.users })
|
||||
.from(schema.invites)
|
||||
.leftJoin(schema.users, eq(schema.users.id, schema.invites.createdByUserId))
|
||||
.orderBy(desc(schema.invites.createdAt))
|
||||
.limit(50);
|
||||
|
||||
const usedByIds = rows
|
||||
.map((r) => r.invite.usedByUserId)
|
||||
.filter((id): id is string => id !== null);
|
||||
const usedByUsers = usedByIds.length > 0 ? await db.select().from(schema.users) : [];
|
||||
const usedByName = new Map(usedByUsers.map((u) => [u.id, u.displayName ?? u.username]));
|
||||
|
||||
const invites: InviteSummary[] = rows.map(({ invite, createdBy }) => ({
|
||||
id: invite.id,
|
||||
code: invite.code,
|
||||
role: invite.role,
|
||||
createdBy: createdBy ? (createdBy.displayName ?? createdBy.username) : null,
|
||||
expiresAt: invite.expiresAt?.toISOString() ?? null,
|
||||
usedBy: invite.usedByUserId ? (usedByName.get(invite.usedByUserId) ?? 'unknown') : null,
|
||||
usedAt: invite.usedAt?.toISOString() ?? null,
|
||||
createdAt: invite.createdAt.toISOString(),
|
||||
}));
|
||||
res.json({ invites });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const body = createBodySchema.safeParse(req.body);
|
||||
if (!body.success) throw ApiError.validation('Invalid invite request.');
|
||||
if (!ROLES.includes(body.data.role)) throw ApiError.validation('Invalid role.');
|
||||
const [invite] = await db
|
||||
.insert(schema.invites)
|
||||
.values({
|
||||
code: inviteCode(),
|
||||
role: body.data.role,
|
||||
createdByUserId: req.user!.id,
|
||||
expiresAt: new Date(
|
||||
Date.now() + (body.data.expiresInHours ?? NEVER_EXPIRES_HOURS) * 60 * 60 * 1000,
|
||||
),
|
||||
})
|
||||
.returning();
|
||||
res.json({
|
||||
id: invite!.id,
|
||||
code: invite!.code,
|
||||
role: invite!.role,
|
||||
expiresAt: invite!.expiresAt?.toISOString() ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const id = z.string().uuid().safeParse(req.params.id);
|
||||
if (!id.success) throw ApiError.validation('Invalid invite id.');
|
||||
await db.delete(schema.invites).where(eq(schema.invites.id, id.data));
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import type {
|
||||
RestartScheduleInput,
|
||||
ServerScheduleSummary,
|
||||
ServerStatus,
|
||||
} from '@reforger-panel/shared';
|
||||
import { ApiError } from '../../lib/errors.js';
|
||||
import type {
|
||||
DownloadableFile,
|
||||
GameServerProvider,
|
||||
ProviderServerResources,
|
||||
ServerFileEntry,
|
||||
} from './types.js';
|
||||
|
||||
const START_DELAY_MS = 4_000;
|
||||
const STOP_DELAY_MS = 2_500;
|
||||
|
||||
function pad(n: number, width = 2): string {
|
||||
return String(n).padStart(width, '0');
|
||||
}
|
||||
|
||||
function timeOfDay(date: Date): string {
|
||||
return `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}.${pad(
|
||||
date.getUTCMilliseconds(),
|
||||
3,
|
||||
)}`;
|
||||
}
|
||||
|
||||
function dateStamp(date: Date): string {
|
||||
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a plausible Reforger console.log covering the last ~50 minutes:
|
||||
* server start, four connects, one disconnect. Line shapes mirror the real
|
||||
* Enfusion/BattlEye output the parser targets (see parser/patterns.ts).
|
||||
*/
|
||||
export function buildMockConsoleLog(now: Date = new Date()): string {
|
||||
const at = (minutesAgo: number, driftSeconds = 0) =>
|
||||
new Date(now.getTime() - minutesAgo * 60_000 + driftSeconds * 1000);
|
||||
|
||||
const started = at(50);
|
||||
const lines = [
|
||||
`------------------------------------------------------------------------------------------------`,
|
||||
`Log started ${dateStamp(started)} ${timeOfDay(started).slice(0, 8)}`,
|
||||
`${timeOfDay(started)} ENGINE : Enfusion engine build: 1.3.0.42 (mock)`,
|
||||
`${timeOfDay(at(50, 4))} DEFAULT : Loading world.`,
|
||||
`${timeOfDay(at(49))} DEFAULT : Game successfully created.`,
|
||||
`${timeOfDay(at(48))} NETWORK : Server is ready to accept connections`,
|
||||
`${timeOfDay(at(44))} DEFAULT : BattlEye Server: 'Player #1 Braeden (10.66.4.21:50241) connected'`,
|
||||
`${timeOfDay(at(44, 2))} DEFAULT : BattlEye Server: 'Player #1 Braeden - GUID: 9f2ab04c11d9e0aa'`,
|
||||
`${timeOfDay(at(38))} DEFAULT : BattlEye Server: 'Player #2 Sable (10.66.4.30:61022) connected'`,
|
||||
`${timeOfDay(at(38, 1))} DEFAULT : BattlEye Server: 'Player #2 Sable - GUID: 41c7de9a5b02f311'`,
|
||||
`${timeOfDay(at(31))} DEFAULT : BattlEye Server: 'Player #3 Kestrel (10.66.4.87:49155) connected'`,
|
||||
`${timeOfDay(at(27))} SCRIPT : SCR_BaseGameMode: match state changed`,
|
||||
`${timeOfDay(at(22))} DEFAULT : BattlEye Server: 'Player #4 Moss (10.66.4.44:51811) connected'`,
|
||||
`${timeOfDay(at(22, 1))} DEFAULT : BattlEye Server: 'Player #4 Moss - GUID: c31009e2ab77d514'`,
|
||||
`${timeOfDay(at(9))} DEFAULT : BattlEye Server: 'Player #3 Kestrel disconnected'`,
|
||||
`${timeOfDay(at(2))} NETWORK : ### Connection stats`,
|
||||
'',
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* In-process stand-in for Pterodactyl so the whole panel runs without
|
||||
* credentials. Power actions transition through starting/stopping states, and
|
||||
* the mock file system serves a generated console.log fixture.
|
||||
*/
|
||||
export class MockGameServerProvider implements GameServerProvider {
|
||||
private status: ServerStatus = 'online';
|
||||
private startedAt = Date.now() - 50 * 60_000;
|
||||
private transitionTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private readonly logContent: string;
|
||||
private readonly logPath: string;
|
||||
|
||||
private readonly configPath: string;
|
||||
private configContent: string;
|
||||
private nextScheduleId = 2;
|
||||
private schedules: ServerScheduleSummary[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Daily restart',
|
||||
isActive: true,
|
||||
onlyWhenOnline: true,
|
||||
minute: '0',
|
||||
hour: '9',
|
||||
dayOfMonth: '*',
|
||||
month: '*',
|
||||
dayOfWeek: '*',
|
||||
nextRunAt: null,
|
||||
lastRunAt: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
tasks: [
|
||||
{
|
||||
id: '1',
|
||||
action: 'power',
|
||||
payload: 'restart',
|
||||
timeOffsetSeconds: 0,
|
||||
continueOnFailure: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
/** Files written via writeTextFile (e.g. config.json backups). */
|
||||
readonly writtenFiles = new Map<string, string>();
|
||||
|
||||
constructor(options: { logPath?: string; configPath?: string; now?: Date } = {}) {
|
||||
this.logPath = options.logPath ?? '/profile/logs/console.log';
|
||||
this.logContent = buildMockConsoleLog(options.now ?? new Date());
|
||||
this.configPath = options.configPath ?? '/config.json';
|
||||
// Shape mirrors a real Reforger dedicated-server config.json.
|
||||
this.configContent = JSON.stringify(
|
||||
{
|
||||
bindAddress: '0.0.0.0',
|
||||
bindPort: 2001,
|
||||
game: {
|
||||
name: 'Mock Reforger Server',
|
||||
scenarioId: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf',
|
||||
maxPlayers: 16,
|
||||
crossPlatform: true,
|
||||
gameProperties: {
|
||||
serverMaxViewDistance: 2500,
|
||||
networkViewDistance: 1500,
|
||||
disableThirdPerson: false,
|
||||
},
|
||||
mods: [{ modId: '591AF5BDA9F7CE8B', name: 'Mock Sample Mod', version: '1.0.2' }],
|
||||
},
|
||||
operating: { aiLimit: 40 },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.transitionTimer) clearTimeout(this.transitionTimer);
|
||||
}
|
||||
|
||||
private transition(to: ServerStatus, after: number, thenTo: ServerStatus) {
|
||||
this.status = to;
|
||||
if (this.transitionTimer) clearTimeout(this.transitionTimer);
|
||||
this.transitionTimer = setTimeout(() => {
|
||||
this.status = thenTo;
|
||||
if (thenTo === 'online') this.startedAt = Date.now();
|
||||
this.transitionTimer = null;
|
||||
}, after);
|
||||
this.transitionTimer.unref?.();
|
||||
}
|
||||
|
||||
async getServerStatus(): Promise<ServerStatus> {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
async getServerResources(): Promise<ProviderServerResources> {
|
||||
const online = this.status === 'online';
|
||||
const wobble = (base: number, spread: number) => base + (Math.random() - 0.5) * spread;
|
||||
return {
|
||||
status: this.status,
|
||||
cpuPercent: online ? Math.max(2, wobble(38, 14)) : 0,
|
||||
cpuLimitPercent: 400,
|
||||
memoryBytes: online ? Math.round(wobble(5.1, 0.6) * 1024 ** 3) : 0,
|
||||
memoryLimitBytes: 8 * 1024 ** 3,
|
||||
diskBytes: Math.round(22.4 * 1024 ** 3),
|
||||
diskLimitBytes: 40 * 1024 ** 3,
|
||||
networkRxBytes: online ? Math.round(wobble(9.2, 1.5) * 1024 ** 2) : 0,
|
||||
networkTxBytes: online ? Math.round(wobble(26.8, 4) * 1024 ** 2) : 0,
|
||||
uptimeMs: online ? Date.now() - this.startedAt : 0,
|
||||
};
|
||||
}
|
||||
|
||||
async startServer(): Promise<void> {
|
||||
if (this.status === 'online') return;
|
||||
this.transition('starting', START_DELAY_MS, 'online');
|
||||
}
|
||||
|
||||
async stopServer(): Promise<void> {
|
||||
if (this.status === 'offline') return;
|
||||
this.transition('stopping', STOP_DELAY_MS, 'offline');
|
||||
}
|
||||
|
||||
async restartServer(): Promise<void> {
|
||||
this.transition('stopping', STOP_DELAY_MS, 'starting');
|
||||
setTimeout(() => {
|
||||
if (this.status === 'starting') {
|
||||
this.status = 'online';
|
||||
this.startedAt = Date.now();
|
||||
}
|
||||
}, STOP_DELAY_MS + START_DELAY_MS).unref?.();
|
||||
}
|
||||
|
||||
async listFiles(_serverId: string, directory: string): Promise<ServerFileEntry[]> {
|
||||
const dir = directory.replace(/\/$/, '') || '/';
|
||||
const logDir = this.logPath.slice(0, this.logPath.lastIndexOf('/')) || '/';
|
||||
if (dir !== logDir) return [];
|
||||
return [
|
||||
{
|
||||
name: this.logPath.slice(this.logPath.lastIndexOf('/') + 1),
|
||||
isFile: true,
|
||||
sizeBytes: Buffer.byteLength(this.logContent),
|
||||
modifiedAt: new Date(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async getFileDownloadUrl(): Promise<string> {
|
||||
throw ApiError.notConfigured('Direct downloads are not available in mock mode.');
|
||||
}
|
||||
|
||||
async writeTextFile(_serverId: string, path: string, content: string): Promise<void> {
|
||||
this.writtenFiles.set(path, content);
|
||||
if (path === this.configPath) {
|
||||
this.configContent = content;
|
||||
}
|
||||
}
|
||||
|
||||
private startupVariables = [
|
||||
{
|
||||
name: 'Server Password',
|
||||
description: 'Password required to join the server.',
|
||||
envVariable: 'SERVER_PASSWORD',
|
||||
serverValue: '',
|
||||
defaultValue: '',
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
name: 'Admin Password',
|
||||
description: 'Password for in-game admin access.',
|
||||
envVariable: 'ADMIN_PASSWORD',
|
||||
serverValue: 'mock-admin-pass',
|
||||
defaultValue: '',
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
name: 'App ID',
|
||||
description: 'Steam application id (managed by the egg).',
|
||||
envVariable: 'SRCDS_APPID',
|
||||
serverValue: '1874900',
|
||||
defaultValue: '1874900',
|
||||
isEditable: false,
|
||||
},
|
||||
];
|
||||
|
||||
async listStartupVariables() {
|
||||
return this.startupVariables.map((v) => ({ ...v }));
|
||||
}
|
||||
|
||||
async updateStartupVariable(_serverId: string, envVariable: string, value: string) {
|
||||
const variable = this.startupVariables.find((v) => v.envVariable === envVariable);
|
||||
if (!variable || !variable.isEditable) {
|
||||
throw ApiError.validation('This startup variable cannot be edited.');
|
||||
}
|
||||
variable.serverValue = value;
|
||||
}
|
||||
|
||||
async listSchedules(): Promise<ServerScheduleSummary[]> {
|
||||
return this.schedules.map((schedule) => ({
|
||||
...schedule,
|
||||
tasks: schedule.tasks.map((task) => ({ ...task })),
|
||||
}));
|
||||
}
|
||||
|
||||
async createRestartSchedule(
|
||||
_serverId: string,
|
||||
input: RestartScheduleInput,
|
||||
): Promise<ServerScheduleSummary> {
|
||||
const now = new Date().toISOString();
|
||||
const schedule: ServerScheduleSummary = {
|
||||
id: String(this.nextScheduleId++),
|
||||
name: input.name,
|
||||
isActive: input.isActive,
|
||||
onlyWhenOnline: input.onlyWhenOnline,
|
||||
minute: String(input.minute),
|
||||
hour: String(input.hour),
|
||||
dayOfMonth: '*',
|
||||
month: '*',
|
||||
dayOfWeek: input.dayOfWeek,
|
||||
nextRunAt: null,
|
||||
lastRunAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
tasks: [
|
||||
{
|
||||
id: String(this.nextScheduleId++),
|
||||
action: 'power',
|
||||
payload: 'restart',
|
||||
timeOffsetSeconds: 0,
|
||||
continueOnFailure: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
this.schedules.unshift(schedule);
|
||||
return { ...schedule, tasks: schedule.tasks.map((task) => ({ ...task })) };
|
||||
}
|
||||
|
||||
async updateRestartSchedule(
|
||||
_serverId: string,
|
||||
scheduleId: string,
|
||||
input: RestartScheduleInput,
|
||||
): Promise<ServerScheduleSummary> {
|
||||
const schedule = this.schedules.find((s) => s.id === scheduleId);
|
||||
if (!schedule) throw ApiError.notFound('Schedule not found.');
|
||||
schedule.name = input.name;
|
||||
schedule.isActive = input.isActive;
|
||||
schedule.onlyWhenOnline = input.onlyWhenOnline;
|
||||
schedule.minute = String(input.minute);
|
||||
schedule.hour = String(input.hour);
|
||||
schedule.dayOfWeek = input.dayOfWeek;
|
||||
schedule.updatedAt = new Date().toISOString();
|
||||
return { ...schedule, tasks: schedule.tasks.map((task) => ({ ...task })) };
|
||||
}
|
||||
|
||||
async deleteSchedule(_serverId: string, scheduleId: string): Promise<void> {
|
||||
this.schedules = this.schedules.filter((schedule) => schedule.id !== scheduleId);
|
||||
}
|
||||
|
||||
async downloadTextFile(
|
||||
_serverId: string,
|
||||
path: string,
|
||||
maxBytes = 2 * 1024 * 1024,
|
||||
): Promise<DownloadableFile> {
|
||||
const content =
|
||||
path === this.logPath
|
||||
? this.logContent
|
||||
: path === this.configPath
|
||||
? this.configContent
|
||||
: null;
|
||||
if (content === null) {
|
||||
throw ApiError.notFound(`Mock file not found: ${path}`);
|
||||
}
|
||||
const buffer = Buffer.from(content, 'utf8');
|
||||
const trimmed =
|
||||
buffer.byteLength > maxBytes ? buffer.subarray(buffer.byteLength - maxBytes) : buffer;
|
||||
return {
|
||||
path,
|
||||
content: trimmed.toString('utf8'),
|
||||
totalSizeBytes: buffer.byteLength,
|
||||
contentStartOffset: buffer.byteLength - trimmed.byteLength,
|
||||
truncated: trimmed.byteLength < buffer.byteLength,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
import type {
|
||||
RestartScheduleInput,
|
||||
ServerScheduleSummary,
|
||||
ServerScheduleTask,
|
||||
ServerStatus,
|
||||
} from '@reforger-panel/shared';
|
||||
import { ApiError } from '../../lib/errors.js';
|
||||
import type {
|
||||
DownloadableFile,
|
||||
GameServerProvider,
|
||||
ProviderServerResources,
|
||||
ServerFileEntry,
|
||||
} from './types.js';
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||
const DOWNLOAD_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_MAX_DOWNLOAD_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
type PterodactylOptions = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type PterodactylScheduleResponse = {
|
||||
data?: {
|
||||
attributes?: PterodactylScheduleAttributes;
|
||||
};
|
||||
};
|
||||
|
||||
type PterodactylScheduleAttributes = {
|
||||
id?: number | string;
|
||||
name?: string;
|
||||
cron?: {
|
||||
minute?: string;
|
||||
hour?: string;
|
||||
day_of_month?: string;
|
||||
month?: string;
|
||||
day_of_week?: string;
|
||||
};
|
||||
is_active?: boolean;
|
||||
only_when_online?: boolean;
|
||||
last_run_at?: string | null;
|
||||
next_run_at?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
relationships?: {
|
||||
tasks?: {
|
||||
data?: {
|
||||
attributes?: {
|
||||
id?: number | string;
|
||||
action?: string;
|
||||
payload?: string;
|
||||
time_offset?: number;
|
||||
continue_on_failure?: boolean;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function mapState(state: string): ServerStatus {
|
||||
switch (state) {
|
||||
case 'running':
|
||||
return 'online';
|
||||
case 'offline':
|
||||
return 'offline';
|
||||
case 'starting':
|
||||
return 'starting';
|
||||
case 'stopping':
|
||||
return 'stopping';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pterodactyl Client API provider. Uses only client-scoped endpoints (status,
|
||||
* resources, power, read-only file access). Errors are sanitized: they carry
|
||||
* the endpoint category and HTTP status, never the API key or full URL.
|
||||
*/
|
||||
export class PterodactylProvider implements GameServerProvider {
|
||||
private readonly baseUrl: string;
|
||||
private readonly apiKey: string;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private readonly timeoutMs: number;
|
||||
private limitsCache = new Map<
|
||||
string,
|
||||
{
|
||||
cpuLimitPercent: number | null;
|
||||
memoryLimitBytes: number | null;
|
||||
diskLimitBytes: number | null;
|
||||
fetchedAt: number;
|
||||
}
|
||||
>();
|
||||
|
||||
constructor(options: PterodactylOptions) {
|
||||
this.baseUrl = options.baseUrl.replace(/\/$/, '');
|
||||
this.apiKey = options.apiKey;
|
||||
this.fetchImpl = options.fetchImpl ?? fetch;
|
||||
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
private async request<T = unknown>(
|
||||
label: string,
|
||||
path: string,
|
||||
init: { method?: string; body?: unknown; timeoutMs?: number; raw?: boolean } = {},
|
||||
): Promise<T> {
|
||||
const url = `${this.baseUrl}/api/client${path}`;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.fetchImpl(url, {
|
||||
method: init.method ?? 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
Accept: 'application/json',
|
||||
...(init.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
|
||||
signal: AbortSignal.timeout(init.timeoutMs ?? this.timeoutMs),
|
||||
});
|
||||
} catch (error) {
|
||||
const reason =
|
||||
error instanceof Error && error.name === 'TimeoutError' ? 'timed out' : 'failed';
|
||||
throw ApiError.upstream(`Pterodactyl request (${label}) ${reason}.`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw ApiError.upstream(`Pterodactyl request (${label}) returned HTTP ${response.status}.`);
|
||||
}
|
||||
if (init.raw) {
|
||||
return (await response.text()) as T;
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
const text = await response.text();
|
||||
if (!text) return undefined as T;
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
throw ApiError.upstream(`Pterodactyl request (${label}) returned invalid JSON.`);
|
||||
}
|
||||
}
|
||||
|
||||
private async getLimits(serverId: string) {
|
||||
const cached = this.limitsCache.get(serverId);
|
||||
if (cached && Date.now() - cached.fetchedAt < 5 * 60_000) return cached;
|
||||
const data = await this.request<{
|
||||
attributes?: { limits?: { cpu?: number; memory?: number; disk?: number } };
|
||||
}>('server details', `/servers/${encodeURIComponent(serverId)}`);
|
||||
const limits = data.attributes?.limits;
|
||||
const entry = {
|
||||
cpuLimitPercent: limits?.cpu && limits.cpu > 0 ? limits.cpu : null,
|
||||
memoryLimitBytes: limits?.memory ? limits.memory * 1024 * 1024 : null,
|
||||
diskLimitBytes: limits?.disk ? limits.disk * 1024 * 1024 : null,
|
||||
fetchedAt: Date.now(),
|
||||
};
|
||||
this.limitsCache.set(serverId, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
async getServerStatus(serverId: string): Promise<ServerStatus> {
|
||||
const resources = await this.getServerResources(serverId);
|
||||
return resources.status;
|
||||
}
|
||||
|
||||
async getServerResources(serverId: string): Promise<ProviderServerResources> {
|
||||
const data = await this.request<{
|
||||
attributes?: {
|
||||
current_state?: string;
|
||||
resources?: {
|
||||
memory_bytes?: number;
|
||||
cpu_absolute?: number;
|
||||
disk_bytes?: number;
|
||||
network_rx_bytes?: number;
|
||||
network_tx_bytes?: number;
|
||||
uptime?: number;
|
||||
};
|
||||
};
|
||||
}>('resources', `/servers/${encodeURIComponent(serverId)}/resources`);
|
||||
|
||||
const attrs = data.attributes ?? {};
|
||||
const res = attrs.resources ?? {};
|
||||
const limits = await this.getLimits(serverId).catch(() => ({
|
||||
cpuLimitPercent: null,
|
||||
memoryLimitBytes: null,
|
||||
diskLimitBytes: null,
|
||||
}));
|
||||
|
||||
return {
|
||||
status: mapState(attrs.current_state ?? 'unknown'),
|
||||
cpuPercent: res.cpu_absolute ?? 0,
|
||||
cpuLimitPercent: limits.cpuLimitPercent,
|
||||
memoryBytes: res.memory_bytes ?? 0,
|
||||
memoryLimitBytes: limits.memoryLimitBytes,
|
||||
diskBytes: res.disk_bytes ?? 0,
|
||||
diskLimitBytes: limits.diskLimitBytes,
|
||||
networkRxBytes: res.network_rx_bytes ?? 0,
|
||||
networkTxBytes: res.network_tx_bytes ?? 0,
|
||||
uptimeMs: res.uptime ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
private async sendPowerSignal(serverId: string, signal: 'start' | 'stop' | 'restart') {
|
||||
await this.request(`power ${signal}`, `/servers/${encodeURIComponent(serverId)}/power`, {
|
||||
method: 'POST',
|
||||
body: { signal },
|
||||
});
|
||||
}
|
||||
|
||||
async startServer(serverId: string): Promise<void> {
|
||||
await this.sendPowerSignal(serverId, 'start');
|
||||
}
|
||||
|
||||
async stopServer(serverId: string): Promise<void> {
|
||||
await this.sendPowerSignal(serverId, 'stop');
|
||||
}
|
||||
|
||||
async restartServer(serverId: string): Promise<void> {
|
||||
await this.sendPowerSignal(serverId, 'restart');
|
||||
}
|
||||
|
||||
async listFiles(serverId: string, directory: string): Promise<ServerFileEntry[]> {
|
||||
const data = await this.request<{
|
||||
data?: {
|
||||
attributes?: {
|
||||
name?: string;
|
||||
is_file?: boolean;
|
||||
size?: number;
|
||||
modified_at?: string;
|
||||
};
|
||||
}[];
|
||||
}>(
|
||||
'file list',
|
||||
`/servers/${encodeURIComponent(serverId)}/files/list?directory=${encodeURIComponent(directory)}`,
|
||||
);
|
||||
return (data.data ?? []).map((entry) => ({
|
||||
name: entry.attributes?.name ?? '',
|
||||
isFile: entry.attributes?.is_file ?? false,
|
||||
sizeBytes: entry.attributes?.size ?? 0,
|
||||
modifiedAt: entry.attributes?.modified_at ? new Date(entry.attributes.modified_at) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
async getFileDownloadUrl(serverId: string, path: string): Promise<string> {
|
||||
const data = await this.request<{ attributes?: { url?: string } }>(
|
||||
'file download url',
|
||||
`/servers/${encodeURIComponent(serverId)}/files/download?file=${encodeURIComponent(path)}`,
|
||||
);
|
||||
const url = data.attributes?.url;
|
||||
if (!url) {
|
||||
throw ApiError.upstream('Pterodactyl did not return a download URL.');
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a text file via the signed one-time download URL (streams and
|
||||
* caps size, unlike files/contents which buffers whole files). When the file
|
||||
* exceeds maxBytes the TAIL is kept — this method exists for log retrieval.
|
||||
*/
|
||||
async downloadTextFile(
|
||||
serverId: string,
|
||||
path: string,
|
||||
maxBytes: number = DEFAULT_MAX_DOWNLOAD_BYTES,
|
||||
): Promise<DownloadableFile> {
|
||||
const stat = await this.statFile(serverId, path);
|
||||
const url = await this.getFileDownloadUrl(serverId, path);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.fetchImpl(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
|
||||
} catch (error) {
|
||||
const reason =
|
||||
error instanceof Error && error.name === 'TimeoutError' ? 'timed out' : 'failed';
|
||||
throw ApiError.upstream(`Pterodactyl log download ${reason}.`);
|
||||
}
|
||||
if (!response.ok || !response.body) {
|
||||
throw ApiError.upstream(`Pterodactyl log download returned HTTP ${response.status}.`);
|
||||
}
|
||||
|
||||
// Stream and keep a rolling tail of at most maxBytes.
|
||||
const chunks: Uint8Array[] = [];
|
||||
let buffered = 0;
|
||||
let discarded = 0;
|
||||
const reader = response.body.getReader();
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
buffered += value.byteLength;
|
||||
while (buffered - (chunks[0]?.byteLength ?? 0) >= maxBytes && chunks.length > 1) {
|
||||
const dropped = chunks.shift()!;
|
||||
buffered -= dropped.byteLength;
|
||||
discarded += dropped.byteLength;
|
||||
}
|
||||
}
|
||||
let combined = Buffer.concat(chunks);
|
||||
if (combined.byteLength > maxBytes) {
|
||||
const trim = combined.byteLength - maxBytes;
|
||||
combined = combined.subarray(trim);
|
||||
discarded += trim;
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
content: combined.toString('utf8'),
|
||||
totalSizeBytes: stat?.sizeBytes ?? discarded + combined.byteLength,
|
||||
contentStartOffset: discarded,
|
||||
truncated: discarded > 0,
|
||||
};
|
||||
}
|
||||
|
||||
async writeTextFile(serverId: string, path: string, content: string): Promise<void> {
|
||||
const url = `${this.baseUrl}/api/client/servers/${encodeURIComponent(serverId)}/files/write?file=${encodeURIComponent(path)}`;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.fetchImpl(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'text/plain',
|
||||
},
|
||||
body: content,
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
});
|
||||
} catch (error) {
|
||||
const reason =
|
||||
error instanceof Error && error.name === 'TimeoutError' ? 'timed out' : 'failed';
|
||||
throw ApiError.upstream(`Pterodactyl request (file write) ${reason}.`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw ApiError.upstream(`Pterodactyl request (file write) returned HTTP ${response.status}.`);
|
||||
}
|
||||
}
|
||||
|
||||
async listStartupVariables(serverId: string) {
|
||||
const data = await this.request<{
|
||||
data?: {
|
||||
attributes?: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
env_variable?: string;
|
||||
server_value?: string | null;
|
||||
default_value?: string | null;
|
||||
is_editable?: boolean;
|
||||
};
|
||||
}[];
|
||||
}>('startup variables', `/servers/${encodeURIComponent(serverId)}/startup`);
|
||||
return (data.data ?? []).map((entry) => ({
|
||||
name: entry.attributes?.name ?? '',
|
||||
description: entry.attributes?.description ?? '',
|
||||
envVariable: entry.attributes?.env_variable ?? '',
|
||||
serverValue: entry.attributes?.server_value ?? '',
|
||||
defaultValue: entry.attributes?.default_value ?? '',
|
||||
isEditable: entry.attributes?.is_editable ?? false,
|
||||
}));
|
||||
}
|
||||
|
||||
async updateStartupVariable(serverId: string, envVariable: string, value: string): Promise<void> {
|
||||
await this.request(
|
||||
'startup variable update',
|
||||
`/servers/${encodeURIComponent(serverId)}/startup/variable`,
|
||||
{ method: 'PUT', body: { key: envVariable, value } },
|
||||
);
|
||||
}
|
||||
|
||||
private mapSchedule(attributes: PterodactylScheduleAttributes): ServerScheduleSummary {
|
||||
const cron = attributes.cron ?? {};
|
||||
const tasks: ServerScheduleTask[] = (attributes.relationships?.tasks?.data ?? []).map(
|
||||
(task) => ({
|
||||
id: String(task.attributes?.id ?? ''),
|
||||
action: task.attributes?.action ?? '',
|
||||
payload: task.attributes?.payload ?? '',
|
||||
timeOffsetSeconds: task.attributes?.time_offset ?? 0,
|
||||
continueOnFailure: task.attributes?.continue_on_failure ?? false,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
id: String(attributes.id ?? ''),
|
||||
name: attributes.name ?? 'Untitled schedule',
|
||||
isActive: attributes.is_active ?? false,
|
||||
onlyWhenOnline: attributes.only_when_online ?? false,
|
||||
minute: cron.minute ?? '*',
|
||||
hour: cron.hour ?? '*',
|
||||
dayOfMonth: cron.day_of_month ?? '*',
|
||||
month: cron.month ?? '*',
|
||||
dayOfWeek: cron.day_of_week ?? '*',
|
||||
nextRunAt: attributes.next_run_at ?? null,
|
||||
lastRunAt: attributes.last_run_at ?? null,
|
||||
createdAt: attributes.created_at ?? null,
|
||||
updatedAt: attributes.updated_at ?? null,
|
||||
tasks,
|
||||
};
|
||||
}
|
||||
|
||||
private scheduleBody(input: RestartScheduleInput) {
|
||||
return {
|
||||
name: input.name,
|
||||
is_active: input.isActive,
|
||||
minute: String(input.minute),
|
||||
hour: String(input.hour),
|
||||
day_of_month: '*',
|
||||
month: '*',
|
||||
day_of_week: input.dayOfWeek,
|
||||
only_when_online: input.onlyWhenOnline,
|
||||
};
|
||||
}
|
||||
|
||||
async listSchedules(serverId: string): Promise<ServerScheduleSummary[]> {
|
||||
const data = await this.request<{
|
||||
data?: { attributes?: PterodactylScheduleAttributes }[];
|
||||
}>('schedules', `/servers/${encodeURIComponent(serverId)}/schedules?include=tasks`);
|
||||
return (data.data ?? []).map((entry) => this.mapSchedule(entry.attributes ?? {}));
|
||||
}
|
||||
|
||||
async createRestartSchedule(
|
||||
serverId: string,
|
||||
input: RestartScheduleInput,
|
||||
): Promise<ServerScheduleSummary> {
|
||||
const created = await this.request<PterodactylScheduleResponse>(
|
||||
'schedule create',
|
||||
`/servers/${encodeURIComponent(serverId)}/schedules`,
|
||||
{ method: 'POST', body: this.scheduleBody(input) },
|
||||
);
|
||||
const schedule = this.mapSchedule(created.data?.attributes ?? {});
|
||||
if (!schedule.id) {
|
||||
throw ApiError.upstream('Pterodactyl did not return the created schedule id.');
|
||||
}
|
||||
await this.request(
|
||||
'schedule task create',
|
||||
`/servers/${encodeURIComponent(serverId)}/schedules/${encodeURIComponent(schedule.id)}/tasks`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: {
|
||||
action: 'power',
|
||||
payload: 'restart',
|
||||
time_offset: 0,
|
||||
continue_on_failure: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
const [withTasks] = (await this.listSchedules(serverId)).filter((s) => s.id === schedule.id);
|
||||
return withTasks ?? schedule;
|
||||
}
|
||||
|
||||
async updateRestartSchedule(
|
||||
serverId: string,
|
||||
scheduleId: string,
|
||||
input: RestartScheduleInput,
|
||||
): Promise<ServerScheduleSummary> {
|
||||
const updated = await this.request<PterodactylScheduleResponse>(
|
||||
'schedule update',
|
||||
`/servers/${encodeURIComponent(serverId)}/schedules/${encodeURIComponent(scheduleId)}`,
|
||||
{ method: 'PATCH', body: this.scheduleBody(input) },
|
||||
);
|
||||
return this.mapSchedule(updated.data?.attributes ?? {});
|
||||
}
|
||||
|
||||
async deleteSchedule(serverId: string, scheduleId: string): Promise<void> {
|
||||
await this.request(
|
||||
'schedule delete',
|
||||
`/servers/${encodeURIComponent(serverId)}/schedules/${encodeURIComponent(scheduleId)}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
}
|
||||
|
||||
private async statFile(
|
||||
serverId: string,
|
||||
path: string,
|
||||
): Promise<{ sizeBytes: number; modifiedAt: Date | null } | null> {
|
||||
const directory = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) || '/' : '/';
|
||||
const fileName = path.slice(path.lastIndexOf('/') + 1);
|
||||
try {
|
||||
const entries = await this.listFiles(serverId, directory);
|
||||
const match = entries.find((entry) => entry.isFile && entry.name === fileName);
|
||||
return match ? { sizeBytes: match.sizeBytes, modifiedAt: match.modifiedAt } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type {
|
||||
RestartScheduleInput,
|
||||
ServerScheduleSummary,
|
||||
ServerStatus,
|
||||
} from '@reforger-panel/shared';
|
||||
|
||||
export type ProviderServerResources = {
|
||||
status: ServerStatus;
|
||||
cpuPercent: number;
|
||||
cpuLimitPercent: number | null;
|
||||
memoryBytes: number;
|
||||
memoryLimitBytes: number | null;
|
||||
diskBytes: number;
|
||||
diskLimitBytes: number | null;
|
||||
networkRxBytes: number;
|
||||
networkTxBytes: number;
|
||||
uptimeMs: number;
|
||||
};
|
||||
|
||||
export type ServerFileEntry = {
|
||||
name: string;
|
||||
isFile: boolean;
|
||||
sizeBytes: number;
|
||||
modifiedAt: Date | null;
|
||||
};
|
||||
|
||||
export type DownloadableFile = {
|
||||
path: string;
|
||||
content: string;
|
||||
/** Size of the file on the remote, if known (may exceed content length when capped). */
|
||||
totalSizeBytes: number | null;
|
||||
/** Byte offset of content[0] within the remote file. Non-zero when the head was trimmed. */
|
||||
contentStartOffset: number;
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Abstraction over the game-server backend (Pterodactyl Client API in
|
||||
* production, an in-process mock for local development). Deliberately narrow:
|
||||
* no arbitrary writes, no console execution.
|
||||
*/
|
||||
export interface GameServerProvider {
|
||||
getServerStatus(serverId: string): Promise<ServerStatus>;
|
||||
getServerResources(serverId: string): Promise<ProviderServerResources>;
|
||||
|
||||
startServer(serverId: string): Promise<void>;
|
||||
stopServer(serverId: string): Promise<void>;
|
||||
restartServer(serverId: string): Promise<void>;
|
||||
|
||||
listFiles(serverId: string, directory: string): Promise<ServerFileEntry[]>;
|
||||
getFileDownloadUrl(serverId: string, path: string): Promise<string>;
|
||||
downloadTextFile(serverId: string, path: string, maxBytes?: number): Promise<DownloadableFile>;
|
||||
|
||||
/**
|
||||
* Writes a text file. NOT exposed as a generic panel endpoint: the only
|
||||
* callers write server-generated content to paths from server configuration
|
||||
* (config.json updates and their backups), never user-supplied paths.
|
||||
*/
|
||||
writeTextFile(serverId: string, path: string, content: string): Promise<void>;
|
||||
|
||||
/** Egg startup variables (Pterodactyl "Startup" tab). May contain secrets. */
|
||||
listStartupVariables(serverId: string): Promise<StartupVariableEntry[]>;
|
||||
updateStartupVariable(serverId: string, envVariable: string, value: string): Promise<void>;
|
||||
|
||||
/** Native Pterodactyl schedules, scoped here to restart schedule management. */
|
||||
listSchedules(serverId: string): Promise<ServerScheduleSummary[]>;
|
||||
createRestartSchedule(
|
||||
serverId: string,
|
||||
input: RestartScheduleInput,
|
||||
): Promise<ServerScheduleSummary>;
|
||||
updateRestartSchedule(
|
||||
serverId: string,
|
||||
scheduleId: string,
|
||||
input: RestartScheduleInput,
|
||||
): Promise<ServerScheduleSummary>;
|
||||
deleteSchedule(serverId: string, scheduleId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export type StartupVariableEntry = {
|
||||
name: string;
|
||||
description: string;
|
||||
envVariable: string;
|
||||
serverValue: string;
|
||||
defaultValue: string;
|
||||
isEditable: boolean;
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { BOUNDED_TAIL_BYTES, hashLine, planIngestion, type CursorState } from './cursor-service.js';
|
||||
|
||||
function snapshot(content: string, opts: { startOffset?: number; totalSize?: number } = {}) {
|
||||
const buffer = Buffer.from(content, 'utf8');
|
||||
return {
|
||||
buffer,
|
||||
contentStartOffset: opts.startOffset ?? 0,
|
||||
totalSizeBytes: opts.totalSize ?? (opts.startOffset ?? 0) + buffer.byteLength,
|
||||
};
|
||||
}
|
||||
|
||||
function cursorFor(consumed: string, extra: Partial<CursorState> = {}): CursorState {
|
||||
const lines = consumed.endsWith('\n') ? consumed.slice(0, -1).split('\n') : consumed.split('\n');
|
||||
return {
|
||||
fileFingerprint: null,
|
||||
lastByteOffset: Buffer.byteLength(consumed, 'utf8'),
|
||||
lastLineHash: hashLine(lines.at(-1) ?? ''),
|
||||
partialTrailingLine: null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
describe('planIngestion', () => {
|
||||
it('first sync processes the whole (small) file', () => {
|
||||
const plan = planIngestion(null, snapshot('line1\nline2\n'));
|
||||
expect(plan.reason).toBe('first_sync');
|
||||
expect(plan.cursorReset).toBe(false);
|
||||
expect(plan.chunk).toBe('line1\nline2\n');
|
||||
expect(plan.nextByteOffset).toBe(12);
|
||||
});
|
||||
|
||||
it('first sync bounds a huge file to a tail starting at a line boundary', () => {
|
||||
const bigLine = 'x'.repeat(1000) + '\n';
|
||||
const content = bigLine.repeat(600); // ~600 KB > BOUNDED_TAIL_BYTES
|
||||
const plan = planIngestion(null, snapshot(content));
|
||||
expect(Buffer.byteLength(plan.chunk)).toBeLessThanOrEqual(BOUNDED_TAIL_BYTES);
|
||||
expect(plan.chunk.startsWith('x')).toBe(true);
|
||||
expect(plan.chunk.endsWith('\n')).toBe(true);
|
||||
expect(plan.nextByteOffset).toBe(Buffer.byteLength(content));
|
||||
});
|
||||
|
||||
it('normal append processes only new content', () => {
|
||||
const consumed = 'line1\nline2\n';
|
||||
const appended = 'line3\nline4\n';
|
||||
const plan = planIngestion(cursorFor(consumed), snapshot(consumed + appended));
|
||||
expect(plan.reason).toBe('append');
|
||||
expect(plan.chunk).toBe(appended);
|
||||
expect(plan.cursorReset).toBe(false);
|
||||
});
|
||||
|
||||
it('prepends a stored partial trailing line to new content', () => {
|
||||
const consumed = 'line1\npart';
|
||||
const cursor = cursorFor(consumed, {
|
||||
partialTrailingLine: 'part',
|
||||
lastLineHash: hashLine('line1'),
|
||||
});
|
||||
const plan = planIngestion(cursor, snapshot('line1\npartial-done\nline3\n'));
|
||||
expect(plan.reason).toBe('append');
|
||||
expect(plan.chunk).toBe('partial-done\nline3\n');
|
||||
});
|
||||
|
||||
it('reports no new data when the file has not grown', () => {
|
||||
const consumed = 'line1\nline2\n';
|
||||
const plan = planIngestion(cursorFor(consumed), snapshot(consumed));
|
||||
expect(plan.reason).toBe('no_new_data');
|
||||
expect(plan.chunk).toBe('');
|
||||
expect(plan.cursorReset).toBe(false);
|
||||
});
|
||||
|
||||
it('resets on rotation (file shrank)', () => {
|
||||
const cursor = cursorFor('a'.repeat(5000) + '\n');
|
||||
const plan = planIngestion(cursor, snapshot('fresh1\nfresh2\n'));
|
||||
expect(plan.reason).toBe('rotation');
|
||||
expect(plan.cursorReset).toBe(true);
|
||||
expect(plan.chunk).toBe('fresh1\nfresh2\n');
|
||||
});
|
||||
|
||||
it('resets when the fingerprint (first line) changed despite a larger file', () => {
|
||||
const oldContent = 'old-header\nold-line\n';
|
||||
const cursor = cursorFor(oldContent, { fileFingerprint: hashLine('old-header') });
|
||||
const newContent = 'new-header-longer\nnew-line-1\nnew-line-2\n';
|
||||
const plan = planIngestion(cursor, snapshot(newContent));
|
||||
expect(plan.reason).toBe('rotation');
|
||||
expect(plan.cursorReset).toBe(true);
|
||||
});
|
||||
|
||||
it('resets on continuity mismatch (replaced file, same-or-larger size, no visible head)', () => {
|
||||
const consumed = 'line1\nline2\n';
|
||||
const cursor = cursorFor(consumed);
|
||||
// Same length as consumed but different content before the cut.
|
||||
const replaced = 'lineX\nlineZ\nline3\n';
|
||||
const plan = planIngestion(cursor, snapshot(replaced, { startOffset: 0, totalSize: 100 }));
|
||||
// fingerprint check triggers first only if cursor had one; here continuity check fires
|
||||
expect(['continuity_mismatch', 'rotation']).toContain(plan.reason);
|
||||
expect(plan.cursorReset).toBe(true);
|
||||
});
|
||||
|
||||
it('processes a bounded tail when the download window skipped past the cursor', () => {
|
||||
const cursor = cursorFor('early\n'); // offset 6
|
||||
const plan = planIngestion(
|
||||
cursor,
|
||||
snapshot('tail-line-1\ntail-line-2\n', { startOffset: 10_000, totalSize: 10_024 }),
|
||||
);
|
||||
expect(plan.reason).toBe('gap');
|
||||
expect(plan.cursorReset).toBe(true);
|
||||
// Head-cut downloads drop the first partial line.
|
||||
expect(plan.chunk).toBe('tail-line-2\n');
|
||||
});
|
||||
|
||||
it('advances the cursor across consecutive appends', () => {
|
||||
let content = 'l1\n';
|
||||
let cursor: CursorState | null = null;
|
||||
const offsets: number[] = [];
|
||||
for (const next of ['l2\n', 'l3\n', 'l4\n']) {
|
||||
const plan = planIngestion(cursor, snapshot(content));
|
||||
offsets.push(plan.nextByteOffset);
|
||||
const lines = content.slice(0, plan.nextByteOffset);
|
||||
cursor = cursorFor(lines, { fileFingerprint: null });
|
||||
content += next;
|
||||
}
|
||||
expect(offsets).toEqual([3, 6, 9]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { sha256Hex } from '../../../lib/crypto.js';
|
||||
|
||||
/** How much history to import when seeing a file for the first time (or after rotation). */
|
||||
export const BOUNDED_TAIL_BYTES = 512 * 1024;
|
||||
|
||||
export type CursorState = {
|
||||
fileFingerprint: string | null;
|
||||
lastByteOffset: number;
|
||||
lastLineHash: string | null;
|
||||
partialTrailingLine: string | null;
|
||||
};
|
||||
|
||||
export type FileSnapshot = {
|
||||
/** Raw downloaded bytes (possibly only the tail of the remote file). */
|
||||
buffer: Buffer;
|
||||
/** Byte offset of buffer[0] within the remote file. */
|
||||
contentStartOffset: number;
|
||||
/** Total remote file size if known. */
|
||||
totalSizeBytes: number | null;
|
||||
};
|
||||
|
||||
export type IngestionPlan = {
|
||||
/** Text to parse this sync, starting at a line boundary. */
|
||||
chunk: string;
|
||||
/** Cursor byte offset to record after a successful parse. */
|
||||
nextByteOffset: number;
|
||||
/** True when the cursor was reset (first sync, rotation, truncation, or mismatch). */
|
||||
cursorReset: boolean;
|
||||
reason: 'first_sync' | 'append' | 'no_new_data' | 'rotation' | 'continuity_mismatch' | 'gap';
|
||||
};
|
||||
|
||||
export function hashLine(line: string): string {
|
||||
return sha256Hex(line);
|
||||
}
|
||||
|
||||
/** Extract the final complete line of a buffer region (for continuity checks). */
|
||||
function lastCompleteLineBefore(buffer: Buffer, end: number): string | null {
|
||||
if (end <= 0) return null;
|
||||
const region = buffer.subarray(0, end);
|
||||
const text = region.toString('utf8');
|
||||
const withoutTrailing = text.endsWith('\n') ? text.slice(0, -1) : text;
|
||||
const lastNewline = withoutTrailing.lastIndexOf('\n');
|
||||
const line = lastNewline >= 0 ? withoutTrailing.slice(lastNewline + 1) : withoutTrailing;
|
||||
return line.replace(/\r$/, '');
|
||||
}
|
||||
|
||||
/** Skip a leading partial line after an arbitrary byte cut. */
|
||||
function alignToNextLine(buffer: Buffer): Buffer {
|
||||
const newlineIndex = buffer.indexOf(0x0a);
|
||||
if (newlineIndex === -1) return Buffer.alloc(0);
|
||||
return buffer.subarray(newlineIndex + 1);
|
||||
}
|
||||
|
||||
function boundedTail(snapshot: FileSnapshot, reason: IngestionPlan['reason']): IngestionPlan {
|
||||
let region = snapshot.buffer;
|
||||
let cutInsideLine = snapshot.contentStartOffset > 0;
|
||||
if (region.byteLength > BOUNDED_TAIL_BYTES) {
|
||||
region = region.subarray(region.byteLength - BOUNDED_TAIL_BYTES);
|
||||
cutInsideLine = true;
|
||||
}
|
||||
if (cutInsideLine) {
|
||||
region = alignToNextLine(region);
|
||||
}
|
||||
return {
|
||||
chunk: region.toString('utf8'),
|
||||
nextByteOffset: snapshot.contentStartOffset + snapshot.buffer.byteLength,
|
||||
cursorReset: reason !== 'first_sync',
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide what portion of the downloaded file to parse, handling first sync,
|
||||
* normal append, rotation/truncation/replacement, and download gaps.
|
||||
*
|
||||
* The fingerprint is the hash of the file's first line when the download
|
||||
* includes the start of the file; it changes when the file is replaced even
|
||||
* if the new file is larger than the old offset.
|
||||
*/
|
||||
export function planIngestion(cursor: CursorState | null, snapshot: FileSnapshot): IngestionPlan {
|
||||
const fileEnd = snapshot.contentStartOffset + snapshot.buffer.byteLength;
|
||||
|
||||
if (!cursor) {
|
||||
return boundedTail(snapshot, 'first_sync');
|
||||
}
|
||||
|
||||
const totalSize = snapshot.totalSizeBytes ?? fileEnd;
|
||||
|
||||
// Rotation / truncation: the file shrank below what we already consumed.
|
||||
if (totalSize < cursor.lastByteOffset) {
|
||||
return boundedTail(snapshot, 'rotation');
|
||||
}
|
||||
|
||||
// Replacement detection via fingerprint (only when we can see the file head).
|
||||
const fingerprint = computeFingerprint(snapshot);
|
||||
if (fingerprint && cursor.fileFingerprint && fingerprint !== cursor.fileFingerprint) {
|
||||
return boundedTail(snapshot, 'rotation');
|
||||
}
|
||||
|
||||
// The download window no longer reaches back to our cursor (file grew more
|
||||
// than maxBytes between syncs). Process what we have; some lines were lost.
|
||||
if (cursor.lastByteOffset < snapshot.contentStartOffset) {
|
||||
return boundedTail(snapshot, 'gap');
|
||||
}
|
||||
|
||||
const cutIndex = cursor.lastByteOffset - snapshot.contentStartOffset;
|
||||
if (cutIndex >= snapshot.buffer.byteLength) {
|
||||
return {
|
||||
chunk: '',
|
||||
nextByteOffset: cursor.lastByteOffset,
|
||||
cursorReset: false,
|
||||
reason: 'no_new_data',
|
||||
};
|
||||
}
|
||||
|
||||
// Continuity check: the content just before the cut must be what we last
|
||||
// saw; otherwise the file was replaced by a same-size-or-larger one.
|
||||
if (cursor.partialTrailingLine !== null && cursor.partialTrailingLine !== '') {
|
||||
const fragment = lastCompleteLineBefore(snapshot.buffer, cutIndex);
|
||||
if (fragment !== null && cutIndex > 0 && !cursor.partialTrailingLine.endsWith(fragment)) {
|
||||
return boundedTail(snapshot, 'continuity_mismatch');
|
||||
}
|
||||
} else if (cursor.lastLineHash) {
|
||||
const previousLine = lastCompleteLineBefore(snapshot.buffer, cutIndex);
|
||||
if (previousLine !== null && hashLine(previousLine) !== cursor.lastLineHash) {
|
||||
return boundedTail(snapshot, 'continuity_mismatch');
|
||||
}
|
||||
}
|
||||
|
||||
const newRegion = snapshot.buffer.subarray(cutIndex);
|
||||
const chunk = (cursor.partialTrailingLine ?? '') + newRegion.toString('utf8');
|
||||
return {
|
||||
chunk,
|
||||
nextByteOffset: fileEnd,
|
||||
cursorReset: false,
|
||||
reason: 'append',
|
||||
};
|
||||
}
|
||||
|
||||
export function computeFingerprint(snapshot: FileSnapshot): string | null {
|
||||
if (snapshot.contentStartOffset !== 0) return null;
|
||||
const firstNewline = snapshot.buffer.indexOf(0x0a);
|
||||
if (firstNewline === -1) return null;
|
||||
return sha256Hex(snapshot.buffer.subarray(0, firstNewline).toString('utf8'));
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { and, eq, isNull } from 'drizzle-orm';
|
||||
import type { Db } from '../../../db/client.js';
|
||||
import { schema } from '../../../db/client.js';
|
||||
import type {
|
||||
CursorRecord,
|
||||
IngestionStore,
|
||||
NewServerEvent,
|
||||
OpenSessionRecord,
|
||||
PlayerRecord,
|
||||
} from './types.js';
|
||||
|
||||
export class DrizzleIngestionStore implements IngestionStore {
|
||||
constructor(private readonly db: Db) {}
|
||||
|
||||
async getCursor(serverId: string, logPath: string): Promise<CursorRecord | null> {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(schema.logCursors)
|
||||
.where(and(eq(schema.logCursors.serverId, serverId), eq(schema.logCursors.logPath, logPath)));
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
return {
|
||||
serverId: row.serverId,
|
||||
logPath: row.logPath,
|
||||
fileFingerprint: row.fileFingerprint,
|
||||
lastByteOffset: row.lastByteOffset,
|
||||
lastLineHash: row.lastLineHash,
|
||||
partialTrailingLine: row.partialTrailingLine,
|
||||
lastEventTimestamp: row.lastEventTimestamp,
|
||||
lastSuccessfulSyncAt: row.lastSuccessfulSyncAt,
|
||||
lastErrorAt: row.lastErrorAt,
|
||||
lastErrorMessage: row.lastErrorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
async saveCursor(cursor: CursorRecord): Promise<void> {
|
||||
await this.db
|
||||
.insert(schema.logCursors)
|
||||
.values(cursor)
|
||||
.onConflictDoUpdate({
|
||||
target: [schema.logCursors.serverId, schema.logCursors.logPath],
|
||||
set: {
|
||||
fileFingerprint: cursor.fileFingerprint,
|
||||
lastByteOffset: cursor.lastByteOffset,
|
||||
lastLineHash: cursor.lastLineHash,
|
||||
partialTrailingLine: cursor.partialTrailingLine,
|
||||
lastEventTimestamp: cursor.lastEventTimestamp,
|
||||
lastSuccessfulSyncAt: cursor.lastSuccessfulSyncAt,
|
||||
lastErrorAt: cursor.lastErrorAt,
|
||||
lastErrorMessage: cursor.lastErrorMessage,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async insertEventIfNew(
|
||||
event: NewServerEvent,
|
||||
): Promise<{ created: boolean; eventId: string | null }> {
|
||||
const rows = await this.db
|
||||
.insert(schema.serverEvents)
|
||||
.values({
|
||||
serverId: event.serverId,
|
||||
eventType: event.eventType,
|
||||
occurredAt: event.occurredAt,
|
||||
playerId: event.playerId ?? null,
|
||||
playerSessionId: event.playerSessionId ?? null,
|
||||
summary: event.summary,
|
||||
payload: event.payload,
|
||||
sourceLogPath: event.sourceLogPath,
|
||||
sourceLineHash: event.sourceLineHash,
|
||||
})
|
||||
.onConflictDoNothing({
|
||||
target: [
|
||||
schema.serverEvents.serverId,
|
||||
schema.serverEvents.sourceLogPath,
|
||||
schema.serverEvents.sourceLineHash,
|
||||
],
|
||||
})
|
||||
.returning({ id: schema.serverEvents.id });
|
||||
return { created: rows.length > 0, eventId: rows[0]?.id ?? null };
|
||||
}
|
||||
|
||||
async findPlayerByExternalId(
|
||||
serverId: string,
|
||||
externalPlayerId: string,
|
||||
): Promise<PlayerRecord | null> {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(schema.players)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.players.serverId, serverId),
|
||||
eq(schema.players.externalPlayerId, externalPlayerId),
|
||||
),
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findPlayerByName(serverId: string, displayName: string): Promise<PlayerRecord | null> {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(schema.players)
|
||||
.where(
|
||||
and(eq(schema.players.serverId, serverId), eq(schema.players.displayName, displayName)),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async createPlayer(input: {
|
||||
serverId: string;
|
||||
displayName: string;
|
||||
externalPlayerId: string | null;
|
||||
seenAt: Date;
|
||||
}): Promise<PlayerRecord> {
|
||||
const [row] = await this.db
|
||||
.insert(schema.players)
|
||||
.values({
|
||||
serverId: input.serverId,
|
||||
displayName: input.displayName,
|
||||
externalPlayerId: input.externalPlayerId,
|
||||
firstSeenAt: input.seenAt,
|
||||
lastSeenAt: input.seenAt,
|
||||
})
|
||||
.returning();
|
||||
return row!;
|
||||
}
|
||||
|
||||
async updatePlayer(
|
||||
playerId: string,
|
||||
patch: { externalPlayerId?: string; displayName?: string; lastSeenAt?: Date },
|
||||
): Promise<void> {
|
||||
await this.db.update(schema.players).set(patch).where(eq(schema.players.id, playerId));
|
||||
}
|
||||
|
||||
async getOpenSession(serverId: string, playerId: string): Promise<OpenSessionRecord | null> {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(schema.playerSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.playerSessions.serverId, serverId),
|
||||
eq(schema.playerSessions.playerId, playerId),
|
||||
isNull(schema.playerSessions.disconnectedAt),
|
||||
),
|
||||
);
|
||||
const row = rows[0];
|
||||
return row ? { id: row.id, playerId: row.playerId, connectedAt: row.connectedAt } : null;
|
||||
}
|
||||
|
||||
async openSession(input: {
|
||||
serverId: string;
|
||||
playerId: string;
|
||||
connectedAt: Date;
|
||||
sourceLogPath: string;
|
||||
}): Promise<OpenSessionRecord> {
|
||||
const [row] = await this.db.insert(schema.playerSessions).values(input).returning();
|
||||
return { id: row!.id, playerId: row!.playerId, connectedAt: row!.connectedAt };
|
||||
}
|
||||
|
||||
async closeSession(
|
||||
sessionId: string,
|
||||
input: { disconnectedAt: Date; durationSeconds: number; disconnectReason: string | null },
|
||||
): Promise<void> {
|
||||
await this.db
|
||||
.update(schema.playerSessions)
|
||||
.set(input)
|
||||
.where(eq(schema.playerSessions.id, sessionId));
|
||||
}
|
||||
|
||||
async closeAllOpenSessions(
|
||||
serverId: string,
|
||||
disconnectedAt: Date,
|
||||
reason: string,
|
||||
): Promise<{ closed: number }> {
|
||||
const open = await this.db
|
||||
.select()
|
||||
.from(schema.playerSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.playerSessions.serverId, serverId),
|
||||
isNull(schema.playerSessions.disconnectedAt),
|
||||
),
|
||||
);
|
||||
for (const session of open) {
|
||||
await this.closeSession(session.id, {
|
||||
disconnectedAt,
|
||||
durationSeconds: Math.max(
|
||||
0,
|
||||
Math.round((disconnectedAt.getTime() - session.connectedAt.getTime()) / 1000),
|
||||
),
|
||||
disconnectReason: reason,
|
||||
});
|
||||
}
|
||||
return { closed: open.length };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import type { LogSyncResult } from '@reforger-panel/shared';
|
||||
import { sanitizeErrorMessage, type Logger } from '../../../lib/logger.js';
|
||||
import { parseLogChunk } from '../parser/parser.js';
|
||||
import type { ParsedLogEvent } from '../parser/types.js';
|
||||
import { computeFingerprint, hashLine, planIngestion } from './cursor-service.js';
|
||||
import { dateFromLogPath } from './log-path-resolver.js';
|
||||
import type { CursorRecord, IngestionStore, LogSource, PlayerRecord } from './types.js';
|
||||
|
||||
export type IngestionOptions = {
|
||||
maxDownloadBytes: number;
|
||||
};
|
||||
|
||||
export type SyncStats = LogSyncResult & {
|
||||
ignoredLines: number;
|
||||
invalidTimestamps: number;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Turns raw Reforger log content into player/session/event records.
|
||||
* Orchestrates: fetch (LogSource) → plan (cursor-service) → parse (parser) →
|
||||
* persist (IngestionStore). Holds no state between runs beyond the cursor.
|
||||
*/
|
||||
export class LogIngestionService {
|
||||
constructor(
|
||||
private readonly source: LogSource,
|
||||
private readonly store: IngestionStore,
|
||||
private readonly logger: Logger,
|
||||
private readonly options: IngestionOptions,
|
||||
) {}
|
||||
|
||||
async sync(serverId: string, providerServerId: string, logPath: string): Promise<SyncStats> {
|
||||
const startedAt = new Date();
|
||||
try {
|
||||
const stats = await this.runSync(serverId, providerServerId, logPath, startedAt);
|
||||
this.logger.debug({ ...stats }, 'log sync completed');
|
||||
return stats;
|
||||
} catch (error) {
|
||||
const message = sanitizeErrorMessage(error);
|
||||
await this.recordFailure(serverId, logPath, message).catch(() => undefined);
|
||||
this.logger.warn({ serverId, logPath, error: message }, 'log sync failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async runSync(
|
||||
serverId: string,
|
||||
providerServerId: string,
|
||||
logPath: string,
|
||||
startedAt: Date,
|
||||
): Promise<SyncStats> {
|
||||
const file = await this.source.fetchLog(
|
||||
providerServerId,
|
||||
logPath,
|
||||
this.options.maxDownloadBytes,
|
||||
);
|
||||
const buffer = Buffer.from(file.content, 'utf8');
|
||||
const snapshot = {
|
||||
buffer,
|
||||
contentStartOffset: file.contentStartOffset,
|
||||
totalSizeBytes: file.totalSizeBytes,
|
||||
};
|
||||
|
||||
const cursor = await this.store.getCursor(serverId, logPath);
|
||||
const plan = planIngestion(cursor, snapshot);
|
||||
|
||||
// Continuation chunks have no "Log started" header, so carry the calendar
|
||||
// date forward from the last ingested event — or, failing that, from the
|
||||
// dated per-boot folder name in the log path. A header in the chunk
|
||||
// (fresh file after rotation) still overrides this.
|
||||
const previousTimestamp =
|
||||
(!plan.cursorReset ? (cursor?.lastEventTimestamp ?? null) : null) ?? dateFromLogPath(logPath);
|
||||
const parsed = parseLogChunk(plan.chunk, {
|
||||
fallbackDate: new Date(),
|
||||
context: previousTimestamp
|
||||
? {
|
||||
baseDate: new Date(
|
||||
Date.UTC(
|
||||
previousTimestamp.getUTCFullYear(),
|
||||
previousTimestamp.getUTCMonth(),
|
||||
previousTimestamp.getUTCDate(),
|
||||
),
|
||||
),
|
||||
lastTimestamp: previousTimestamp,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
let createdEvents = 0;
|
||||
let updatedSessions = 0;
|
||||
for (const event of parsed.events) {
|
||||
const result = await this.applyEvent(serverId, logPath, event);
|
||||
createdEvents += result.createdEvents;
|
||||
updatedSessions += result.updatedSessions;
|
||||
}
|
||||
|
||||
const lastEvent = parsed.events.at(-1);
|
||||
const fingerprint =
|
||||
computeFingerprint(snapshot) ?? (plan.cursorReset ? null : (cursor?.fileFingerprint ?? null));
|
||||
const nextCursor: CursorRecord = {
|
||||
serverId,
|
||||
logPath,
|
||||
fileFingerprint: fingerprint,
|
||||
lastByteOffset: plan.nextByteOffset,
|
||||
lastLineHash: parsed.lastCompleteLine
|
||||
? hashLine(parsed.lastCompleteLine)
|
||||
: plan.cursorReset || plan.reason === 'first_sync'
|
||||
? null
|
||||
: (cursor?.lastLineHash ?? null),
|
||||
partialTrailingLine:
|
||||
parsed.partialTrailingLine ??
|
||||
(plan.reason === 'no_new_data' ? (cursor?.partialTrailingLine ?? null) : null),
|
||||
lastEventTimestamp: lastEvent?.occurredAt ?? cursor?.lastEventTimestamp ?? null,
|
||||
lastSuccessfulSyncAt: new Date(),
|
||||
lastErrorAt: null,
|
||||
lastErrorMessage: null,
|
||||
};
|
||||
await this.store.saveCursor(nextCursor);
|
||||
|
||||
return {
|
||||
serverId,
|
||||
logPath,
|
||||
fetchedBytes: buffer.byteLength,
|
||||
processedLines: parsed.completeLineCount,
|
||||
createdEvents,
|
||||
updatedSessions,
|
||||
cursorReset: plan.cursorReset,
|
||||
startedAt: startedAt.toISOString(),
|
||||
finishedAt: new Date().toISOString(),
|
||||
ignoredLines: parsed.ignoredLineCount,
|
||||
invalidTimestamps: parsed.invalidTimestampCount,
|
||||
reason: plan.reason,
|
||||
};
|
||||
}
|
||||
|
||||
private async recordFailure(serverId: string, logPath: string, message: string): Promise<void> {
|
||||
const cursor = await this.store.getCursor(serverId, logPath);
|
||||
await this.store.saveCursor({
|
||||
serverId,
|
||||
logPath,
|
||||
fileFingerprint: cursor?.fileFingerprint ?? null,
|
||||
lastByteOffset: cursor?.lastByteOffset ?? 0,
|
||||
lastLineHash: cursor?.lastLineHash ?? null,
|
||||
partialTrailingLine: cursor?.partialTrailingLine ?? null,
|
||||
lastEventTimestamp: cursor?.lastEventTimestamp ?? null,
|
||||
lastSuccessfulSyncAt: cursor?.lastSuccessfulSyncAt ?? null,
|
||||
lastErrorAt: new Date(),
|
||||
lastErrorMessage: message,
|
||||
});
|
||||
}
|
||||
|
||||
private async resolvePlayer(
|
||||
serverId: string,
|
||||
event: Extract<
|
||||
ParsedLogEvent,
|
||||
{ type: 'player_connected' | 'player_disconnected' | 'player_identity' }
|
||||
>,
|
||||
): Promise<PlayerRecord> {
|
||||
// Prefer the stable log-provided identity; fall back to display name.
|
||||
// Names are NOT globally unique — see README for the limitations.
|
||||
if (event.type === 'player_identity' || event.externalPlayerId) {
|
||||
const externalId = event.externalPlayerId!;
|
||||
const byExternal = await this.store.findPlayerByExternalId(serverId, externalId);
|
||||
if (byExternal) {
|
||||
if (byExternal.displayName !== event.playerName) {
|
||||
await this.store.updatePlayer(byExternal.id, {
|
||||
displayName: event.playerName,
|
||||
lastSeenAt: event.occurredAt,
|
||||
});
|
||||
}
|
||||
return byExternal;
|
||||
}
|
||||
const byName = await this.store.findPlayerByName(serverId, event.playerName);
|
||||
if (byName && byName.externalPlayerId === null) {
|
||||
await this.store.updatePlayer(byName.id, {
|
||||
externalPlayerId: externalId,
|
||||
lastSeenAt: event.occurredAt,
|
||||
});
|
||||
return { ...byName, externalPlayerId: externalId };
|
||||
}
|
||||
if (byName) {
|
||||
// The player already carries a different identity (e.g. engine
|
||||
// identityId vs BattlEye GUID — the logs emit both). Keep the first
|
||||
// one rather than splitting the player into duplicates.
|
||||
await this.store.updatePlayer(byName.id, { lastSeenAt: event.occurredAt });
|
||||
return byName;
|
||||
}
|
||||
return this.store.createPlayer({
|
||||
serverId,
|
||||
displayName: event.playerName,
|
||||
externalPlayerId: externalId,
|
||||
seenAt: event.occurredAt,
|
||||
});
|
||||
}
|
||||
|
||||
const byName = await this.store.findPlayerByName(serverId, event.playerName);
|
||||
if (byName) {
|
||||
await this.store.updatePlayer(byName.id, { lastSeenAt: event.occurredAt });
|
||||
return byName;
|
||||
}
|
||||
return this.store.createPlayer({
|
||||
serverId,
|
||||
displayName: event.playerName,
|
||||
externalPlayerId: null,
|
||||
seenAt: event.occurredAt,
|
||||
});
|
||||
}
|
||||
|
||||
private async resolvePlayerByName(
|
||||
serverId: string,
|
||||
playerName: string,
|
||||
occurredAt: Date,
|
||||
): Promise<PlayerRecord> {
|
||||
const byName = await this.store.findPlayerByName(serverId, playerName);
|
||||
if (byName) {
|
||||
await this.store.updatePlayer(byName.id, { lastSeenAt: occurredAt });
|
||||
return byName;
|
||||
}
|
||||
return this.store.createPlayer({
|
||||
serverId,
|
||||
displayName: playerName,
|
||||
externalPlayerId: null,
|
||||
seenAt: occurredAt,
|
||||
});
|
||||
}
|
||||
|
||||
private async applyEvent(
|
||||
serverId: string,
|
||||
logPath: string,
|
||||
event: ParsedLogEvent,
|
||||
): Promise<{ createdEvents: number; updatedSessions: number }> {
|
||||
const lineHash = hashLine(event.rawLine);
|
||||
|
||||
switch (event.type) {
|
||||
case 'player_connected': {
|
||||
const player = await this.resolvePlayer(serverId, event);
|
||||
const inserted = await this.store.insertEventIfNew({
|
||||
serverId,
|
||||
eventType: 'player_connected',
|
||||
occurredAt: event.occurredAt,
|
||||
playerId: player.id,
|
||||
summary: `${event.playerName} connected`,
|
||||
payload: { playerName: event.playerName, playerNumber: event.playerNumber ?? null },
|
||||
sourceLogPath: logPath,
|
||||
sourceLineHash: lineHash,
|
||||
});
|
||||
if (!inserted.created) return { createdEvents: 0, updatedSessions: 0 };
|
||||
|
||||
// A connect while a session is open means we missed the disconnect.
|
||||
const existing = await this.store.getOpenSession(serverId, player.id);
|
||||
let updatedSessions = 0;
|
||||
if (existing) {
|
||||
await this.store.closeSession(existing.id, {
|
||||
disconnectedAt: event.occurredAt,
|
||||
durationSeconds: Math.max(
|
||||
0,
|
||||
Math.round((event.occurredAt.getTime() - existing.connectedAt.getTime()) / 1000),
|
||||
),
|
||||
disconnectReason: 'missed_disconnect',
|
||||
});
|
||||
updatedSessions += 1;
|
||||
}
|
||||
await this.store.openSession({
|
||||
serverId,
|
||||
playerId: player.id,
|
||||
connectedAt: event.occurredAt,
|
||||
sourceLogPath: logPath,
|
||||
});
|
||||
return { createdEvents: 1, updatedSessions: updatedSessions + 1 };
|
||||
}
|
||||
|
||||
case 'player_identity': {
|
||||
// Identity lines only enrich the player record; they are not events.
|
||||
await this.resolvePlayer(serverId, event);
|
||||
return { createdEvents: 0, updatedSessions: 0 };
|
||||
}
|
||||
|
||||
case 'player_disconnected': {
|
||||
const player = await this.resolvePlayer(serverId, event);
|
||||
const inserted = await this.store.insertEventIfNew({
|
||||
serverId,
|
||||
eventType: 'player_disconnected',
|
||||
occurredAt: event.occurredAt,
|
||||
playerId: player.id,
|
||||
summary: event.reason
|
||||
? `${event.playerName} disconnected (${event.reason})`
|
||||
: `${event.playerName} disconnected`,
|
||||
payload: { playerName: event.playerName, reason: event.reason ?? null },
|
||||
sourceLogPath: logPath,
|
||||
sourceLineHash: lineHash,
|
||||
});
|
||||
if (!inserted.created) return { createdEvents: 0, updatedSessions: 0 };
|
||||
|
||||
const open = await this.store.getOpenSession(serverId, player.id);
|
||||
if (!open) return { createdEvents: 1, updatedSessions: 0 };
|
||||
await this.store.closeSession(open.id, {
|
||||
disconnectedAt: event.occurredAt,
|
||||
durationSeconds: Math.max(
|
||||
0,
|
||||
Math.round((event.occurredAt.getTime() - open.connectedAt.getTime()) / 1000),
|
||||
),
|
||||
disconnectReason: event.reason ?? null,
|
||||
});
|
||||
return { createdEvents: 1, updatedSessions: 1 };
|
||||
}
|
||||
|
||||
case 'player_killed': {
|
||||
const killer = await this.resolvePlayerByName(serverId, event.killerName, event.occurredAt);
|
||||
const victim = await this.resolvePlayerByName(serverId, event.victimName, event.occurredAt);
|
||||
const inserted = await this.store.insertEventIfNew({
|
||||
serverId,
|
||||
eventType: 'player_killed',
|
||||
occurredAt: event.occurredAt,
|
||||
playerId: victim.id,
|
||||
summary: `${event.killerName} killed ${event.victimName}`,
|
||||
payload: {
|
||||
killerPlayerId: killer.id,
|
||||
killerName: event.killerName,
|
||||
victimPlayerId: victim.id,
|
||||
victimName: event.victimName,
|
||||
friendly: event.friendly,
|
||||
killerTeam: null,
|
||||
victimTeam: null,
|
||||
killerPosition: null,
|
||||
victimPosition: null,
|
||||
distanceMeters: null,
|
||||
weapon: null,
|
||||
},
|
||||
sourceLogPath: logPath,
|
||||
sourceLineHash: lineHash,
|
||||
});
|
||||
return { createdEvents: inserted.created ? 1 : 0, updatedSessions: 0 };
|
||||
}
|
||||
|
||||
case 'server_started': {
|
||||
const inserted = await this.store.insertEventIfNew({
|
||||
serverId,
|
||||
eventType: 'server_started',
|
||||
occurredAt: event.occurredAt,
|
||||
summary: 'Server started',
|
||||
payload: {},
|
||||
sourceLogPath: logPath,
|
||||
sourceLineHash: lineHash,
|
||||
});
|
||||
if (!inserted.created) return { createdEvents: 0, updatedSessions: 0 };
|
||||
|
||||
// Sessions can't survive a server start; anything still open was
|
||||
// orphaned by a crash/restart we didn't see a disconnect for.
|
||||
const { closed } = await this.store.closeAllOpenSessions(
|
||||
serverId,
|
||||
event.occurredAt,
|
||||
'server_restart',
|
||||
);
|
||||
let createdEvents = 1;
|
||||
if (closed > 0) {
|
||||
const restartInserted = await this.store.insertEventIfNew({
|
||||
serverId,
|
||||
eventType: 'server_restart_detected',
|
||||
occurredAt: event.occurredAt,
|
||||
summary: `Server restart detected (${closed} session${closed === 1 ? '' : 's'} closed)`,
|
||||
payload: { closedSessions: closed },
|
||||
sourceLogPath: logPath,
|
||||
sourceLineHash: `${lineHash}:restart`,
|
||||
});
|
||||
if (restartInserted.created) createdEvents += 1;
|
||||
}
|
||||
return { createdEvents, updatedSessions: closed };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createLogPathResolver, dateFromLogPath } from './log-path-resolver.js';
|
||||
import type { GameServerProvider, ServerFileEntry } from '../../pterodactyl/types.js';
|
||||
|
||||
function providerWithListing(entries: ServerFileEntry[]): GameServerProvider {
|
||||
return {
|
||||
listFiles: async () => entries,
|
||||
} as unknown as GameServerProvider;
|
||||
}
|
||||
|
||||
function dir(name: string, modifiedAt: Date | null): ServerFileEntry {
|
||||
return { name, isFile: false, sizeBytes: 0, modifiedAt };
|
||||
}
|
||||
|
||||
describe('dateFromLogPath', () => {
|
||||
it('extracts the session start time from dated folder names', () => {
|
||||
expect(
|
||||
dateFromLogPath('/profile/logs/logs_2026-07-04_12-54-04/console.log')?.toISOString(),
|
||||
).toBe('2026-07-04T12:54:04.000Z');
|
||||
});
|
||||
|
||||
it('returns null for paths without a dated folder', () => {
|
||||
expect(dateFromLogPath('/profile/logs/console.log')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createLogPathResolver', () => {
|
||||
it('uses the explicit path when configured, without listing files', async () => {
|
||||
const resolve = createLogPathResolver({
|
||||
provider: providerWithListing([]),
|
||||
providerServerId: 'x',
|
||||
explicitPath: '/profile/logs/pinned.log',
|
||||
directory: '/profile/logs',
|
||||
fileName: 'console.log',
|
||||
});
|
||||
expect(await resolve()).toBe('/profile/logs/pinned.log');
|
||||
});
|
||||
|
||||
it('picks the newest dated logs_* folder', async () => {
|
||||
const resolve = createLogPathResolver({
|
||||
provider: providerWithListing([
|
||||
dir('logs_2026-07-04_12-54-04', new Date('2026-07-04T12:54:04Z')),
|
||||
dir('logs_2026-07-05_08-10-00', new Date('2026-07-05T08:10:00Z')),
|
||||
dir('backups', new Date('2026-07-05T09:00:00Z')),
|
||||
]),
|
||||
providerServerId: 'x',
|
||||
explicitPath: '',
|
||||
directory: '/profile/logs',
|
||||
fileName: 'console.log',
|
||||
});
|
||||
expect(await resolve()).toBe('/profile/logs/logs_2026-07-05_08-10-00/console.log');
|
||||
});
|
||||
|
||||
it('falls back to name ordering when modified times are missing', async () => {
|
||||
const resolve = createLogPathResolver({
|
||||
provider: providerWithListing([
|
||||
dir('logs_2026-07-03_23-00-00', null),
|
||||
dir('logs_2026-07-05_01-00-00', null),
|
||||
]),
|
||||
providerServerId: 'x',
|
||||
explicitPath: '',
|
||||
directory: '/profile/logs',
|
||||
fileName: 'console.log',
|
||||
});
|
||||
expect(await resolve()).toBe('/profile/logs/logs_2026-07-05_01-00-00/console.log');
|
||||
});
|
||||
|
||||
it('prefers a stable file directly in the directory', async () => {
|
||||
const resolve = createLogPathResolver({
|
||||
provider: providerWithListing([
|
||||
{ name: 'console.log', isFile: true, sizeBytes: 10, modifiedAt: new Date() },
|
||||
dir('logs_2026-07-05_01-00-00', new Date()),
|
||||
]),
|
||||
providerServerId: 'x',
|
||||
explicitPath: '',
|
||||
directory: '/profile/logs/',
|
||||
fileName: 'console.log',
|
||||
});
|
||||
expect(await resolve()).toBe('/profile/logs/console.log');
|
||||
});
|
||||
|
||||
it('returns null when nothing matches', async () => {
|
||||
const resolve = createLogPathResolver({
|
||||
provider: providerWithListing([dir('backups', new Date())]),
|
||||
providerServerId: 'x',
|
||||
explicitPath: '',
|
||||
directory: '/profile/logs',
|
||||
fileName: 'console.log',
|
||||
});
|
||||
expect(await resolve()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { GameServerProvider } from '../../pterodactyl/types.js';
|
||||
|
||||
export type LogPathResolver = () => Promise<string | null>;
|
||||
|
||||
/** Directories the Reforger server creates per boot, e.g. logs_2026-07-04_12-54-04. */
|
||||
const DATED_LOG_DIR_PATTERN = /^logs[_-]/i;
|
||||
|
||||
const LOG_PATH_DATE_PATTERN = /logs[_-](\d{4})-(\d{2})-(\d{2})[_-](\d{2})-(\d{2})-(\d{2})/i;
|
||||
|
||||
/**
|
||||
* Reforger's per-boot folder names encode the session start time; use it to
|
||||
* anchor line timestamps when the parsed chunk has no "Log started" header
|
||||
* and no prior cursor context.
|
||||
*/
|
||||
export function dateFromLogPath(logPath: string): Date | null {
|
||||
const match = LOG_PATH_DATE_PATTERN.exec(logPath);
|
||||
if (!match) return null;
|
||||
const [, year, month, day, hours, minutes, seconds] = match;
|
||||
const date = new Date(
|
||||
Date.UTC(
|
||||
Number(year),
|
||||
Number(month) - 1,
|
||||
Number(day),
|
||||
Number(hours),
|
||||
Number(minutes),
|
||||
Number(seconds),
|
||||
),
|
||||
);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the current Reforger log file path.
|
||||
*
|
||||
* - `REFORGER_ADMIN_LOG_PATH` (explicit file) wins when set.
|
||||
* - Otherwise `REFORGER_LOG_DIRECTORY` is listed on every sync and the newest
|
||||
* dated `logs_*` subdirectory is used, so per-boot log folders are picked up
|
||||
* automatically after restarts. `REFORGER_LOG_FILE_PATTERN` is the file name
|
||||
* inside that directory (default `console.log`).
|
||||
*/
|
||||
export function createLogPathResolver(options: {
|
||||
provider: GameServerProvider;
|
||||
providerServerId: string;
|
||||
explicitPath: string;
|
||||
directory: string;
|
||||
fileName: string;
|
||||
}): LogPathResolver {
|
||||
const fileName = options.fileName || 'console.log';
|
||||
return async () => {
|
||||
if (options.explicitPath) return options.explicitPath;
|
||||
if (!options.directory) return null;
|
||||
const directory = options.directory.replace(/\/$/, '');
|
||||
|
||||
const entries = await options.provider.listFiles(options.providerServerId, directory);
|
||||
|
||||
// A stable file directly in the directory takes priority.
|
||||
if (entries.some((entry) => entry.isFile && entry.name === fileName)) {
|
||||
return `${directory}/${fileName}`;
|
||||
}
|
||||
|
||||
const datedDirs = entries.filter(
|
||||
(entry) => !entry.isFile && DATED_LOG_DIR_PATTERN.test(entry.name),
|
||||
);
|
||||
if (datedDirs.length === 0) return null;
|
||||
datedDirs.sort((a, b) => {
|
||||
const byTime = (b.modifiedAt?.getTime() ?? 0) - (a.modifiedAt?.getTime() ?? 0);
|
||||
// Names embed sortable timestamps (logs_YYYY-MM-DD_HH-MM-SS); use them
|
||||
// as a tiebreaker when mtimes are missing or equal.
|
||||
return byTime !== 0 ? byTime : b.name.localeCompare(a.name);
|
||||
});
|
||||
return `${directory}/${datedDirs[0]!.name}/${fileName}`;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { GameServerProvider } from '../../pterodactyl/types.js';
|
||||
import type { LogSource } from './types.js';
|
||||
|
||||
/**
|
||||
* LogSource backed by the game server provider (Pterodactyl Client API or the
|
||||
* mock). Retrieval is size-capped tail download; if the panel ever needs
|
||||
* range/tail requests, only this adapter changes.
|
||||
*/
|
||||
export class PterodactylLogSource implements LogSource {
|
||||
constructor(private readonly provider: GameServerProvider) {}
|
||||
|
||||
fetchLog(serverId: string, logPath: string, maxBytes: number) {
|
||||
return this.provider.downloadTextFile(serverId, logPath, maxBytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { ApiError } from '../../../lib/errors.js';
|
||||
import type { Logger } from '../../../lib/logger.js';
|
||||
import type { LogIngestionService, SyncStats } from './ingestion-service.js';
|
||||
import type { LogPathResolver } from './log-path-resolver.js';
|
||||
|
||||
export type ScheduledServer = {
|
||||
serverId: string;
|
||||
providerServerId: string;
|
||||
/** Resolved on every sync so per-boot dated log folders are followed. */
|
||||
resolveLogPath: LogPathResolver;
|
||||
};
|
||||
|
||||
const MAX_BACKOFF_MULTIPLIER = 8;
|
||||
|
||||
/**
|
||||
* Background polling loop. One timer per server, a per-server lock so syncs
|
||||
* never overlap, exponential backoff after consecutive failures (to avoid
|
||||
* hammering a broken Pterodactyl), and graceful shutdown that waits for
|
||||
* in-flight syncs.
|
||||
*/
|
||||
export class IngestionScheduler {
|
||||
private timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
private inFlight = new Map<string, Promise<void>>();
|
||||
private failureCounts = new Map<string, number>();
|
||||
private stopped = false;
|
||||
private lastResults = new Map<string, SyncStats>();
|
||||
|
||||
constructor(
|
||||
private readonly service: LogIngestionService,
|
||||
private readonly logger: Logger,
|
||||
private readonly intervalMs: number,
|
||||
) {}
|
||||
|
||||
start(servers: ScheduledServer[]): void {
|
||||
for (const server of servers) {
|
||||
this.schedule(server, 1_000 + Math.floor(Math.random() * 2_000));
|
||||
}
|
||||
this.logger.info(
|
||||
{ servers: servers.length, intervalSeconds: this.intervalMs / 1000 },
|
||||
'log ingestion scheduler started',
|
||||
);
|
||||
}
|
||||
|
||||
/** Manually trigger a sync; shares the per-server lock with the poller. */
|
||||
async syncNow(server: ScheduledServer): Promise<SyncStats> {
|
||||
const existing = this.inFlight.get(server.serverId);
|
||||
if (existing) {
|
||||
await existing.catch(() => undefined);
|
||||
}
|
||||
let stats!: SyncStats;
|
||||
const run = (async () => {
|
||||
const logPath = await server.resolveLogPath();
|
||||
if (!logPath) {
|
||||
throw ApiError.notConfigured(
|
||||
'Could not locate the current Reforger log file. Check REFORGER_LOG_DIRECTORY / REFORGER_ADMIN_LOG_PATH.',
|
||||
);
|
||||
}
|
||||
stats = await this.service.sync(server.serverId, server.providerServerId, logPath);
|
||||
})();
|
||||
this.inFlight.set(server.serverId, run.catch(() => undefined) as Promise<void>);
|
||||
try {
|
||||
await run;
|
||||
} finally {
|
||||
this.inFlight.delete(server.serverId);
|
||||
}
|
||||
this.lastResults.set(server.serverId, stats);
|
||||
return stats;
|
||||
}
|
||||
|
||||
getLastResult(serverId: string): SyncStats | null {
|
||||
return this.lastResults.get(serverId) ?? null;
|
||||
}
|
||||
|
||||
private schedule(server: ScheduledServer, delayMs: number): void {
|
||||
if (this.stopped) return;
|
||||
const timer = setTimeout(() => void this.tick(server), delayMs);
|
||||
timer.unref?.();
|
||||
this.timers.set(server.serverId, timer);
|
||||
}
|
||||
|
||||
private async tick(server: ScheduledServer): Promise<void> {
|
||||
if (this.stopped) return;
|
||||
if (this.inFlight.has(server.serverId)) {
|
||||
this.schedule(server, this.intervalMs);
|
||||
return;
|
||||
}
|
||||
const run = server
|
||||
.resolveLogPath()
|
||||
.then((logPath) => {
|
||||
if (!logPath) {
|
||||
throw new Error('no log path resolved');
|
||||
}
|
||||
return this.service.sync(server.serverId, server.providerServerId, logPath);
|
||||
})
|
||||
.then((stats) => {
|
||||
this.lastResults.set(server.serverId, stats);
|
||||
this.failureCounts.set(server.serverId, 0);
|
||||
})
|
||||
.catch(() => {
|
||||
const failures = (this.failureCounts.get(server.serverId) ?? 0) + 1;
|
||||
this.failureCounts.set(server.serverId, failures);
|
||||
});
|
||||
this.inFlight.set(server.serverId, run);
|
||||
await run;
|
||||
this.inFlight.delete(server.serverId);
|
||||
|
||||
const failures = this.failureCounts.get(server.serverId) ?? 0;
|
||||
const multiplier = Math.min(2 ** failures, MAX_BACKOFF_MULTIPLIER);
|
||||
this.schedule(server, this.intervalMs * multiplier);
|
||||
}
|
||||
|
||||
/** Stop scheduling and wait for any in-flight sync to finish. */
|
||||
async stop(): Promise<void> {
|
||||
this.stopped = true;
|
||||
for (const timer of this.timers.values()) clearTimeout(timer);
|
||||
this.timers.clear();
|
||||
await Promise.allSettled(this.inFlight.values());
|
||||
this.logger.info('log ingestion scheduler stopped');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { ServerEventType } from '@reforger-panel/shared';
|
||||
import type { DownloadableFile } from '../../pterodactyl/types.js';
|
||||
|
||||
export type CursorRecord = {
|
||||
serverId: string;
|
||||
logPath: string;
|
||||
fileFingerprint: string | null;
|
||||
lastByteOffset: number;
|
||||
lastLineHash: string | null;
|
||||
partialTrailingLine: string | null;
|
||||
lastEventTimestamp: Date | null;
|
||||
lastSuccessfulSyncAt: Date | null;
|
||||
lastErrorAt: Date | null;
|
||||
lastErrorMessage: string | null;
|
||||
};
|
||||
|
||||
export type PlayerRecord = {
|
||||
id: string;
|
||||
serverId: string;
|
||||
externalPlayerId: string | null;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
export type OpenSessionRecord = {
|
||||
id: string;
|
||||
playerId: string;
|
||||
connectedAt: Date;
|
||||
};
|
||||
|
||||
export type NewServerEvent = {
|
||||
serverId: string;
|
||||
eventType: ServerEventType;
|
||||
occurredAt: Date;
|
||||
playerId?: string | null;
|
||||
playerSessionId?: string | null;
|
||||
summary: string;
|
||||
payload: Record<string, unknown>;
|
||||
sourceLogPath: string;
|
||||
sourceLineHash: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Persistence boundary for log ingestion. Production uses Drizzle/Postgres;
|
||||
* tests use an in-memory implementation.
|
||||
*/
|
||||
export interface IngestionStore {
|
||||
getCursor(serverId: string, logPath: string): Promise<CursorRecord | null>;
|
||||
saveCursor(cursor: CursorRecord): Promise<void>;
|
||||
|
||||
/** Returns created=false when the dedupe key already exists. */
|
||||
insertEventIfNew(event: NewServerEvent): Promise<{ created: boolean; eventId: string | null }>;
|
||||
|
||||
findPlayerByExternalId(serverId: string, externalPlayerId: string): Promise<PlayerRecord | null>;
|
||||
findPlayerByName(serverId: string, displayName: string): Promise<PlayerRecord | null>;
|
||||
createPlayer(input: {
|
||||
serverId: string;
|
||||
displayName: string;
|
||||
externalPlayerId: string | null;
|
||||
seenAt: Date;
|
||||
}): Promise<PlayerRecord>;
|
||||
updatePlayer(
|
||||
playerId: string,
|
||||
patch: { externalPlayerId?: string; displayName?: string; lastSeenAt?: Date },
|
||||
): Promise<void>;
|
||||
|
||||
getOpenSession(serverId: string, playerId: string): Promise<OpenSessionRecord | null>;
|
||||
openSession(input: {
|
||||
serverId: string;
|
||||
playerId: string;
|
||||
connectedAt: Date;
|
||||
sourceLogPath: string;
|
||||
}): Promise<OpenSessionRecord>;
|
||||
closeSession(
|
||||
sessionId: string,
|
||||
input: { disconnectedAt: Date; durationSeconds: number; disconnectReason: string | null },
|
||||
): Promise<void>;
|
||||
/** Close every open session on the server (used when a fresh server start is seen). */
|
||||
closeAllOpenSessions(
|
||||
serverId: string,
|
||||
disconnectedAt: Date,
|
||||
reason: string,
|
||||
): Promise<{ closed: number }>;
|
||||
}
|
||||
|
||||
/** Source of log file bytes; production wraps the Pterodactyl provider. */
|
||||
export interface LogSource {
|
||||
fetchLog(serverId: string, logPath: string, maxBytes: number): Promise<DownloadableFile>;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mergeMissions, parseMissionList, scenariosFromWorkshopMod } from './missions-catalog.js';
|
||||
|
||||
// Verbatim shape from a real console.log (server runs with -listScenarios).
|
||||
const LOG = [
|
||||
'12:54:28.215 SCRIPT : --------------------------------------------------',
|
||||
'12:54:28.215 SCRIPT : Official scenarios (3 entries)',
|
||||
'12:54:28.216 SCRIPT : --------------------------------------------------',
|
||||
'12:54:28.216 SCRIPT : {ECC61978EDCC2B5A}Missions/23_Campaign.conf (Conflict - Everon)',
|
||||
'12:54:28.216 SCRIPT : {002AF7323E0129AF}Missions/Tutorial.conf (Training)',
|
||||
'12:54:28.217 SCRIPT : {59AD59368755F41A}Missions/21_GM_Eden.conf (Game Master - Everon)',
|
||||
'12:54:29.000 SCRIPT : Workshop scenarios (1 entries)',
|
||||
'12:54:29.001 SCRIPT : {ABCDEF0123456789}Missions/CustomOps.conf (Custom Ops)',
|
||||
'12:54:30.000 DEFAULT : something unrelated',
|
||||
].join('\n');
|
||||
|
||||
describe('parseMissionList', () => {
|
||||
it('parses scenario ids, display names, and section sources', () => {
|
||||
const missions = parseMissionList(LOG);
|
||||
expect(missions).toHaveLength(4);
|
||||
expect(missions[0]).toEqual({
|
||||
scenarioId: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf',
|
||||
name: 'Conflict - Everon',
|
||||
source: 'official',
|
||||
});
|
||||
expect(missions[3]).toEqual({
|
||||
scenarioId: '{ABCDEF0123456789}Missions/CustomOps.conf',
|
||||
name: 'Custom Ops',
|
||||
source: 'workshop',
|
||||
});
|
||||
});
|
||||
|
||||
it('deduplicates repeated listings (multiple boots in one file)', () => {
|
||||
const missions = parseMissionList(`${LOG}\n${LOG}`);
|
||||
expect(missions).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('returns an empty list when no listing is present', () => {
|
||||
expect(parseMissionList('12:00:00.000 DEFAULT : nothing here')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('workshop scenario helpers', () => {
|
||||
it('converts mod scenarios into mission entries', () => {
|
||||
const missions = scenariosFromWorkshopMod({
|
||||
id: 'ABC',
|
||||
name: 'Scenario Pack',
|
||||
author: 'Author',
|
||||
imageUrl: null,
|
||||
size: null,
|
||||
rating: null,
|
||||
workshopUrl: null,
|
||||
version: null,
|
||||
gameVersion: null,
|
||||
subscribers: null,
|
||||
downloads: null,
|
||||
createdAtText: null,
|
||||
lastModifiedText: null,
|
||||
summary: null,
|
||||
description: null,
|
||||
license: null,
|
||||
tags: [],
|
||||
dependencies: [],
|
||||
scenarios: [
|
||||
{
|
||||
name: 'Raid Night',
|
||||
description: null,
|
||||
scenarioId: '{1111111111111111}Missions/RaidNight.conf',
|
||||
gamemode: 'Coop',
|
||||
playerCount: 32,
|
||||
imageUrl: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(missions).toEqual([
|
||||
{
|
||||
scenarioId: '{1111111111111111}Missions/RaidNight.conf',
|
||||
name: 'Raid Night',
|
||||
source: 'mod: Scenario Pack',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('deduplicates mission groups while preserving first source', () => {
|
||||
const merged = mergeMissions(
|
||||
[{ scenarioId: 'same', name: 'From Log', source: 'workshop' }],
|
||||
[{ scenarioId: 'same', name: 'From Mod', source: 'mod: Pack' }],
|
||||
[{ scenarioId: 'other', name: 'Other', source: 'mod: Pack' }],
|
||||
);
|
||||
expect(merged).toEqual([
|
||||
{ scenarioId: 'same', name: 'From Log', source: 'workshop' },
|
||||
{ scenarioId: 'other', name: 'Other', source: 'mod: Pack' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { MissionInfo, MissionsResponse, WorkshopModDetail } from '@reforger-panel/shared';
|
||||
import type { GameServerProvider } from '../pterodactyl/types.js';
|
||||
import type { LogPathResolver } from './ingestion/log-path-resolver.js';
|
||||
|
||||
const CATALOG_TTL_MS = 10 * 60 * 1000;
|
||||
const CATALOG_MAX_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Scenario listing printed at boot when the server runs with -listScenarios
|
||||
* (verified against real logs):
|
||||
* 12:54:28.215 SCRIPT : Official scenarios (31 entries)
|
||||
* 12:54:28.216 SCRIPT : {ECC61978EDCC2B5A}Missions/23_Campaign.conf (Conflict - Everon)
|
||||
*/
|
||||
const SECTION_PATTERN = /SCRIPT\s*:\s*(.+ scenarios) \(\d+ entr/i;
|
||||
const MISSION_PATTERN = /SCRIPT\s*:\s*(\{[0-9A-Fa-f]{16}\}\S+\.conf)(?:\s+\((.+)\))?\s*$/;
|
||||
|
||||
export function parseMissionList(logContent: string): MissionInfo[] {
|
||||
const missions: MissionInfo[] = [];
|
||||
const seen = new Set<string>();
|
||||
let currentSource = 'official';
|
||||
for (const line of logContent.split('\n')) {
|
||||
const section = SECTION_PATTERN.exec(line);
|
||||
if (section) {
|
||||
currentSource = section[1]!.toLowerCase().replace(/ scenarios$/, '');
|
||||
continue;
|
||||
}
|
||||
const mission = MISSION_PATTERN.exec(line);
|
||||
if (mission && !seen.has(mission[1]!)) {
|
||||
seen.add(mission[1]!);
|
||||
missions.push({
|
||||
scenarioId: mission[1]!,
|
||||
name: mission[2] ?? mission[1]!.slice(mission[1]!.lastIndexOf('/') + 1),
|
||||
source: currentSource,
|
||||
});
|
||||
}
|
||||
}
|
||||
return missions;
|
||||
}
|
||||
|
||||
export function scenariosFromWorkshopMod(mod: WorkshopModDetail): MissionInfo[] {
|
||||
return mod.scenarios.map((scenario) => ({
|
||||
scenarioId: scenario.scenarioId,
|
||||
name: scenario.name,
|
||||
source: `mod: ${mod.name}`,
|
||||
}));
|
||||
}
|
||||
|
||||
export function mergeMissions(...groups: MissionInfo[][]): MissionInfo[] {
|
||||
const merged: MissionInfo[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const group of groups) {
|
||||
for (const mission of group) {
|
||||
if (seen.has(mission.scenarioId)) continue;
|
||||
seen.add(mission.scenarioId);
|
||||
merged.push(mission);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the available-missions dropdown data from the server's current
|
||||
* console.log. Cached briefly; a fresh boot log always carries the listing
|
||||
* near the top, so the head of the file is enough.
|
||||
*/
|
||||
export class MissionCatalog {
|
||||
private cache: { missions: MissionInfo[]; fetchedAt: string; expiresAt: number } | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly provider: GameServerProvider,
|
||||
private readonly resolveLogPath: LogPathResolver,
|
||||
private readonly providerServerId: string,
|
||||
) {}
|
||||
|
||||
async list(force = false): Promise<MissionsResponse> {
|
||||
if (!force && this.cache && this.cache.expiresAt > Date.now()) {
|
||||
return { missions: this.cache.missions, fetchedAt: this.cache.fetchedAt };
|
||||
}
|
||||
const logPath = await this.resolveLogPath();
|
||||
if (!logPath) return { missions: [], fetchedAt: null };
|
||||
const file = await this.provider.downloadTextFile(
|
||||
this.providerServerId,
|
||||
logPath,
|
||||
CATALOG_MAX_BYTES,
|
||||
);
|
||||
const missions = parseMissionList(file.content);
|
||||
if (missions.length > 0) {
|
||||
this.cache = {
|
||||
missions,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
expiresAt: Date.now() + CATALOG_TTL_MS,
|
||||
};
|
||||
return { missions, fetchedAt: this.cache.fetchedAt };
|
||||
}
|
||||
// Long-running servers may have rotated past the listing; keep the last
|
||||
// known catalog rather than returning nothing.
|
||||
if (this.cache) {
|
||||
return { missions: this.cache.missions, fetchedAt: this.cache.fetchedAt };
|
||||
}
|
||||
return { missions: [], fetchedAt: null };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseLogChunk } from './parser.js';
|
||||
|
||||
const HEADER = 'Log started 2026-07-04 10:00:00';
|
||||
|
||||
function line(time: string, category: string, message: string): string {
|
||||
return `${time} ${category.padEnd(12)} : ${message}`;
|
||||
}
|
||||
|
||||
const CONNECT_LINE = line(
|
||||
'10:05:01.123',
|
||||
'DEFAULT',
|
||||
"BattlEye Server: 'Player #1 Braeden (10.0.0.2:50241) connected'",
|
||||
);
|
||||
const GUID_LINE = line(
|
||||
'10:05:02.500',
|
||||
'DEFAULT',
|
||||
"BattlEye Server: 'Player #1 Braeden - GUID: 9f2ab04c11d9e0aa'",
|
||||
);
|
||||
const DISCONNECT_LINE = line(
|
||||
'10:45:09.001',
|
||||
'DEFAULT',
|
||||
"BattlEye Server: 'Player #1 Braeden disconnected'",
|
||||
);
|
||||
|
||||
describe('parseLogChunk', () => {
|
||||
it('parses a player connect event with timestamp from the header date', () => {
|
||||
const result = parseLogChunk(`${HEADER}\n${CONNECT_LINE}\n`);
|
||||
expect(result.events).toHaveLength(1);
|
||||
const event = result.events[0]!;
|
||||
expect(event.type).toBe('player_connected');
|
||||
if (event.type === 'player_connected') {
|
||||
expect(event.playerName).toBe('Braeden');
|
||||
expect(event.playerNumber).toBe(1);
|
||||
expect(event.occurredAt.toISOString()).toBe('2026-07-04T10:05:01.123Z');
|
||||
}
|
||||
});
|
||||
|
||||
it('parses disconnect events and identity (GUID) lines', () => {
|
||||
const result = parseLogChunk(`${HEADER}\n${CONNECT_LINE}\n${GUID_LINE}\n${DISCONNECT_LINE}\n`);
|
||||
expect(result.events.map((e) => e.type)).toEqual([
|
||||
'player_connected',
|
||||
'player_identity',
|
||||
'player_disconnected',
|
||||
]);
|
||||
const identity = result.events[1]!;
|
||||
if (identity.type === 'player_identity') {
|
||||
expect(identity.externalPlayerId).toBe('9f2ab04c11d9e0aa');
|
||||
}
|
||||
});
|
||||
|
||||
it('parses player names containing spaces and parentheses-free IPs', () => {
|
||||
const weird = line(
|
||||
'10:06:00.000',
|
||||
'DEFAULT',
|
||||
"BattlEye Server: 'Player #7 Sgt. Moss Jr (192.168.1.44:61022) connected'",
|
||||
);
|
||||
const result = parseLogChunk(`${HEADER}\n${weird}\n`);
|
||||
expect(result.events).toHaveLength(1);
|
||||
if (result.events[0]!.type === 'player_connected') {
|
||||
expect(result.events[0]!.playerName).toBe('Sgt. Moss Jr');
|
||||
}
|
||||
});
|
||||
|
||||
it('parses engine-level authenticated-player identity lines (real log format)', () => {
|
||||
const backend = line(
|
||||
'12:57:48.941',
|
||||
'BACKEND',
|
||||
'Authenticated player: rplIdentity=0x00000000 identityId=33cd5666-3466-477c-aeb8-010df1978756 name=mcdazzzled',
|
||||
);
|
||||
const result = parseLogChunk(`${HEADER}\n${backend}\n`);
|
||||
expect(result.events).toHaveLength(1);
|
||||
const event = result.events[0]!;
|
||||
expect(event.type).toBe('player_identity');
|
||||
if (event.type === 'player_identity') {
|
||||
expect(event.playerName).toBe('mcdazzzled');
|
||||
expect(event.externalPlayerId).toBe('33cd5666-3466-477c-aeb8-010df1978756');
|
||||
}
|
||||
});
|
||||
|
||||
it('detects server start lines', () => {
|
||||
const content = `${HEADER}\n${line('10:00:05.000', 'DEFAULT', 'Game successfully created.')}\n`;
|
||||
const result = parseLogChunk(content);
|
||||
expect(result.events).toHaveLength(1);
|
||||
expect(result.events[0]!.type).toBe('server_started');
|
||||
});
|
||||
|
||||
it('parses ServerAdminTools killfeed lines', () => {
|
||||
const kill = line(
|
||||
'10:12:30.000',
|
||||
'SCRIPT',
|
||||
'ServerAdminTools | Event serveradmintools_player_killed | player: Victim, instigator: Killer, friendly: false',
|
||||
);
|
||||
const result = parseLogChunk(`${HEADER}\n${kill}\n`);
|
||||
expect(result.events).toHaveLength(1);
|
||||
const event = result.events[0]!;
|
||||
expect(event.type).toBe('player_killed');
|
||||
if (event.type === 'player_killed') {
|
||||
expect(event.victimName).toBe('Victim');
|
||||
expect(event.killerName).toBe('Killer');
|
||||
expect(event.friendly).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('handles multiple simultaneous players', () => {
|
||||
const content = [
|
||||
HEADER,
|
||||
line('10:01:00.000', 'DEFAULT', "BattlEye Server: 'Player #1 Alpha (10.0.0.1:1) connected'"),
|
||||
line('10:01:01.000', 'DEFAULT', "BattlEye Server: 'Player #2 Bravo (10.0.0.2:2) connected'"),
|
||||
line(
|
||||
'10:01:02.000',
|
||||
'DEFAULT',
|
||||
"BattlEye Server: 'Player #3 Charlie (10.0.0.3:3) connected'",
|
||||
),
|
||||
line('10:30:00.000', 'DEFAULT', "BattlEye Server: 'Player #2 Bravo disconnected'"),
|
||||
'',
|
||||
].join('\n');
|
||||
const result = parseLogChunk(content);
|
||||
expect(result.events).toHaveLength(4);
|
||||
expect(result.events.filter((e) => e.type === 'player_connected')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('ignores unknown lines safely and counts them', () => {
|
||||
const content = [
|
||||
HEADER,
|
||||
line('10:02:00.000', 'SCRIPT', 'SCR_BaseGameMode: match state changed'),
|
||||
line('10:02:01.000', 'NETWORK', '### Connection stats'),
|
||||
'complete garbage that matches nothing',
|
||||
CONNECT_LINE,
|
||||
'',
|
||||
].join('\n');
|
||||
const result = parseLogChunk(content);
|
||||
expect(result.events).toHaveLength(1);
|
||||
expect(result.ignoredLineCount).toBe(3);
|
||||
});
|
||||
|
||||
it('rejects invalid timestamps without crashing', () => {
|
||||
const content = `${HEADER}\n${line('25:99:99.000', 'DEFAULT', 'Game successfully created.')}\n${CONNECT_LINE}\n`;
|
||||
const result = parseLogChunk(content);
|
||||
expect(result.invalidTimestampCount).toBe(1);
|
||||
expect(result.events).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns the partial trailing line unparsed', () => {
|
||||
const partial = "10:50:00.100 DEFAULT : BattlEye Server: 'Player #2 Sab";
|
||||
const result = parseLogChunk(`${HEADER}\n${CONNECT_LINE}\n${partial}`);
|
||||
expect(result.events).toHaveLength(1);
|
||||
expect(result.partialTrailingLine).toBe(partial);
|
||||
});
|
||||
|
||||
it('rolls the date over at midnight', () => {
|
||||
const content = [
|
||||
'Log started 2026-07-04 23:58:00',
|
||||
line('23:59:30.000', 'DEFAULT', "BattlEye Server: 'Player #1 Alpha (10.0.0.1:1) connected'"),
|
||||
line('00:01:10.000', 'DEFAULT', "BattlEye Server: 'Player #1 Alpha disconnected'"),
|
||||
'',
|
||||
].join('\n');
|
||||
const result = parseLogChunk(content);
|
||||
expect(result.events).toHaveLength(2);
|
||||
expect(result.events[0]!.occurredAt.toISOString()).toBe('2026-07-04T23:59:30.000Z');
|
||||
expect(result.events[1]!.occurredAt.toISOString()).toBe('2026-07-05T00:01:10.000Z');
|
||||
});
|
||||
|
||||
it('uses the fallback date when no header is present, without producing future timestamps', () => {
|
||||
const fallback = new Date('2026-07-05T00:10:00.000Z');
|
||||
const result = parseLogChunk(
|
||||
`${line('23:55:00.000', 'DEFAULT', 'Game successfully created.')}\n`,
|
||||
{
|
||||
fallbackDate: fallback,
|
||||
},
|
||||
);
|
||||
expect(result.events).toHaveLength(1);
|
||||
expect(result.events[0]!.occurredAt.toISOString()).toBe('2026-07-04T23:55:00.000Z');
|
||||
});
|
||||
|
||||
it('parses an empty chunk without events', () => {
|
||||
const result = parseLogChunk('');
|
||||
expect(result.events).toHaveLength(0);
|
||||
expect(result.completeLineCount).toBe(0);
|
||||
expect(result.partialTrailingLine).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import {
|
||||
AUTHENTICATED_PLAYER_PATTERN,
|
||||
BATTLEYE_WRAPPER_PATTERN,
|
||||
LINE_PREFIX_PATTERN,
|
||||
LOG_HEADER_PATTERN,
|
||||
PLAYER_CONNECTED_PATTERN,
|
||||
PLAYER_DISCONNECTED_PATTERN,
|
||||
PLAYER_GUID_PATTERN,
|
||||
SERVER_ADMIN_TOOLS_KILL_PATTERN,
|
||||
SERVER_STARTED_PATTERNS,
|
||||
} from './patterns.js';
|
||||
import type { ParseChunkResult, ParsedLogEvent, ParserContext } from './types.js';
|
||||
|
||||
const MIDNIGHT_ROLLOVER_TOLERANCE_MS = 60_000;
|
||||
|
||||
export function emptyContext(): ParserContext {
|
||||
return { baseDate: null, lastTimestamp: null };
|
||||
}
|
||||
|
||||
function startOfDayUtc(date: Date): Date {
|
||||
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine a time-of-day stamp with the tracked calendar date. Reforger's
|
||||
* console.log lines carry no date, so the date comes from the log header when
|
||||
* present, otherwise from the fallback (file time / now). Rollover past
|
||||
* midnight is detected by the clock going backwards.
|
||||
*/
|
||||
function resolveTimestamp(
|
||||
hours: number,
|
||||
minutes: number,
|
||||
seconds: number,
|
||||
millis: number,
|
||||
context: ParserContext,
|
||||
fallbackDate: Date,
|
||||
): Date | null {
|
||||
if (hours > 23 || minutes > 59 || seconds > 59) return null;
|
||||
const base = context.baseDate ?? startOfDayUtc(fallbackDate);
|
||||
if (!context.baseDate) context.baseDate = base;
|
||||
|
||||
let timestamp = new Date(
|
||||
base.getTime() + ((hours * 60 + minutes) * 60 + seconds) * 1000 + millis,
|
||||
);
|
||||
// No header date + fallback of "today" can push pre-midnight lines into the
|
||||
// future when syncing just after midnight; pull them back a day.
|
||||
if (
|
||||
!context.lastTimestamp &&
|
||||
timestamp.getTime() > fallbackDate.getTime() + MIDNIGHT_ROLLOVER_TOLERANCE_MS
|
||||
) {
|
||||
context.baseDate = new Date(base.getTime() - 24 * 60 * 60 * 1000);
|
||||
timestamp = new Date(timestamp.getTime() - 24 * 60 * 60 * 1000);
|
||||
}
|
||||
if (
|
||||
context.lastTimestamp &&
|
||||
timestamp.getTime() < context.lastTimestamp.getTime() - MIDNIGHT_ROLLOVER_TOLERANCE_MS
|
||||
) {
|
||||
context.baseDate = new Date(base.getTime() + 24 * 60 * 60 * 1000);
|
||||
timestamp = new Date(timestamp.getTime() + 24 * 60 * 60 * 1000);
|
||||
}
|
||||
context.lastTimestamp = timestamp;
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
function parseMessage(message: string, occurredAt: Date, rawLine: string): ParsedLogEvent | null {
|
||||
const battleye = BATTLEYE_WRAPPER_PATTERN.exec(message);
|
||||
const body = battleye ? battleye[1]! : message;
|
||||
|
||||
const connected = PLAYER_CONNECTED_PATTERN.exec(body);
|
||||
if (connected) {
|
||||
return {
|
||||
type: 'player_connected',
|
||||
occurredAt,
|
||||
playerNumber: Number(connected[1]),
|
||||
playerName: connected[2]!,
|
||||
rawLine,
|
||||
};
|
||||
}
|
||||
|
||||
const authenticated = AUTHENTICATED_PLAYER_PATTERN.exec(body);
|
||||
if (authenticated) {
|
||||
return {
|
||||
type: 'player_identity',
|
||||
occurredAt,
|
||||
playerName: authenticated[2]!,
|
||||
externalPlayerId: authenticated[1]!.toLowerCase(),
|
||||
rawLine,
|
||||
};
|
||||
}
|
||||
|
||||
const guid = PLAYER_GUID_PATTERN.exec(body);
|
||||
if (guid) {
|
||||
return {
|
||||
type: 'player_identity',
|
||||
occurredAt,
|
||||
playerNumber: Number(guid[1]),
|
||||
playerName: guid[2]!,
|
||||
externalPlayerId: guid[3]!.toLowerCase(),
|
||||
rawLine,
|
||||
};
|
||||
}
|
||||
|
||||
const disconnected = PLAYER_DISCONNECTED_PATTERN.exec(body);
|
||||
if (disconnected) {
|
||||
return {
|
||||
type: 'player_disconnected',
|
||||
occurredAt,
|
||||
playerNumber: Number(disconnected[1]),
|
||||
playerName: disconnected[2]!,
|
||||
reason: disconnected[3] || undefined,
|
||||
rawLine,
|
||||
};
|
||||
}
|
||||
|
||||
if (SERVER_STARTED_PATTERNS.some((pattern) => pattern.test(body))) {
|
||||
return { type: 'server_started', occurredAt, rawLine };
|
||||
}
|
||||
|
||||
const kill = SERVER_ADMIN_TOOLS_KILL_PATTERN.exec(body);
|
||||
if (kill) {
|
||||
return {
|
||||
type: 'player_killed',
|
||||
occurredAt,
|
||||
victimName: kill[1]!,
|
||||
killerName: kill[2]!,
|
||||
friendly: kill[3]!.toLowerCase() === 'true',
|
||||
rawLine,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a chunk of log content. The chunk must start at a line boundary
|
||||
* (callers prepend any stored partial trailing line). Pure and side-effect
|
||||
* free apart from the returned, updated context.
|
||||
*/
|
||||
export function parseLogChunk(
|
||||
content: string,
|
||||
options: { context?: ParserContext; fallbackDate?: Date } = {},
|
||||
): ParseChunkResult {
|
||||
const context: ParserContext = options.context ? { ...options.context } : emptyContext();
|
||||
const fallbackDate = options.fallbackDate ?? new Date();
|
||||
|
||||
const endsWithNewline = content.endsWith('\n');
|
||||
const segments = content.split('\n');
|
||||
const partialTrailingLine = endsWithNewline ? null : (segments.pop() ?? null);
|
||||
if (endsWithNewline) segments.pop(); // drop the empty segment after the final newline
|
||||
|
||||
const events: ParsedLogEvent[] = [];
|
||||
let ignoredLineCount = 0;
|
||||
let invalidTimestampCount = 0;
|
||||
let lastCompleteLine: string | null = null;
|
||||
|
||||
for (const rawSegment of segments) {
|
||||
const line = rawSegment.replace(/\r$/, '');
|
||||
lastCompleteLine = line;
|
||||
if (line.trim() === '') continue;
|
||||
|
||||
const header = LOG_HEADER_PATTERN.exec(line);
|
||||
if (header) {
|
||||
const [, year, month, day, hours, minutes, seconds] = header;
|
||||
const headerDate = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day), 0, 0, 0));
|
||||
if (!Number.isNaN(headerDate.getTime())) {
|
||||
context.baseDate = headerDate;
|
||||
context.lastTimestamp = new Date(
|
||||
Date.UTC(
|
||||
Number(year),
|
||||
Number(month) - 1,
|
||||
Number(day),
|
||||
Number(hours),
|
||||
Number(minutes),
|
||||
Number(seconds),
|
||||
),
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const prefix = LINE_PREFIX_PATTERN.exec(line);
|
||||
if (!prefix) {
|
||||
ignoredLineCount += 1;
|
||||
continue;
|
||||
}
|
||||
const [, h, m, s, ms, , message] = prefix;
|
||||
const occurredAt = resolveTimestamp(
|
||||
Number(h),
|
||||
Number(m),
|
||||
Number(s),
|
||||
Number(ms),
|
||||
context,
|
||||
fallbackDate,
|
||||
);
|
||||
if (!occurredAt) {
|
||||
invalidTimestampCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const event = parseMessage(message!, occurredAt, line);
|
||||
if (event) {
|
||||
events.push(event);
|
||||
} else {
|
||||
ignoredLineCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
events,
|
||||
completeLineCount: segments.length,
|
||||
ignoredLineCount,
|
||||
invalidTimestampCount,
|
||||
partialTrailingLine: partialTrailingLine === '' ? null : partialTrailingLine,
|
||||
lastCompleteLine,
|
||||
context,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Line patterns for Arma Reforger (Enfusion) server logs.
|
||||
*
|
||||
* These are based on observed community-documented output and the bundled
|
||||
* fixtures, NOT an official spec — Bohemia can change them between game
|
||||
* versions. All pattern knowledge lives in this file so new formats only
|
||||
* require touching the regexes below and adding a fixture. Unknown lines are
|
||||
* ignored safely and only counted in diagnostics.
|
||||
*
|
||||
* Canonical shapes targeted:
|
||||
* Log started 2026-07-04 11:22:33
|
||||
* 11:24:01.001 DEFAULT : BattlEye Server: 'Player #1 Braeden (10.0.0.2:50241) connected'
|
||||
* 11:24:03.500 DEFAULT : BattlEye Server: 'Player #1 Braeden - GUID: 9f2ab04c11d9e0aa'
|
||||
* 11:52:09.114 DEFAULT : BattlEye Server: 'Player #1 Braeden disconnected'
|
||||
* 11:22:35.123 DEFAULT : Game successfully created.
|
||||
*/
|
||||
|
||||
/** Header written at the top of console.log; provides the calendar date. */
|
||||
export const LOG_HEADER_PATTERN =
|
||||
/^Log started\s+(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/;
|
||||
|
||||
/** Enfusion line prefix: time-of-day, category, colon, message. */
|
||||
export const LINE_PREFIX_PATTERN = /^(\d{1,2}):(\d{2}):(\d{2})\.(\d{3})\s+([A-Z]+)\s*:\s*(.*)$/;
|
||||
|
||||
/** BattlEye messages are quoted inside a wrapper on the DEFAULT channel. */
|
||||
export const BATTLEYE_WRAPPER_PATTERN = /^BattlEye Server: '(.*)'$/;
|
||||
|
||||
/** Player #1 Name (1.2.3.4:56789) connected */
|
||||
export const PLAYER_CONNECTED_PATTERN =
|
||||
/^Player #(\d+) (.+) \((?:\d{1,3}\.){3}\d{1,3}:\d+\) connected$/;
|
||||
|
||||
/** Player #1 Name disconnected (optionally with a trailing reason in parentheses) */
|
||||
export const PLAYER_DISCONNECTED_PATTERN = /^Player #(\d+) (.+?) disconnected(?: \((.+)\))?$/;
|
||||
|
||||
/** Player #1 Name - GUID: abcdef0123456789 (also matches "- BE GUID:") */
|
||||
export const PLAYER_GUID_PATTERN = /^Player #(\d+) (.+) - (?:BE )?GUID: ([0-9a-fA-F]{8,64})$/;
|
||||
|
||||
/**
|
||||
* Engine-level identity on the BACKEND channel (verified against real logs):
|
||||
* Authenticated player: rplIdentity=0x00000000 identityId=<uuid> name=<name>
|
||||
* Available even when BattlEye is disabled.
|
||||
*/
|
||||
export const AUTHENTICATED_PLAYER_PATTERN =
|
||||
/^Authenticated player: .*identityId=([0-9a-fA-F-]{8,64}) name=(.+)$/;
|
||||
|
||||
/** Messages that indicate the server process finished starting a session. */
|
||||
export const SERVER_STARTED_PATTERNS: RegExp[] = [
|
||||
/^Game successfully created\.?$/,
|
||||
/^Server is ready to accept connections/,
|
||||
];
|
||||
|
||||
/**
|
||||
* ServerAdminTools killfeed line observed in reforger-stats:
|
||||
* ServerAdminTools | Event serveradmintools_player_killed | player: Victim, instigator: Killer, friendly: false
|
||||
*/
|
||||
export const SERVER_ADMIN_TOOLS_KILL_PATTERN =
|
||||
/^ServerAdminTools \| Event serveradmintools_player_killed \| player: (.+), instigator: (.+), friendly: (true|false)$/i;
|
||||
@@ -0,0 +1,63 @@
|
||||
export type ParsedLogEvent =
|
||||
| {
|
||||
type: 'player_connected';
|
||||
occurredAt: Date;
|
||||
playerName: string;
|
||||
playerNumber?: number;
|
||||
externalPlayerId?: string;
|
||||
rawLine: string;
|
||||
}
|
||||
| {
|
||||
type: 'player_disconnected';
|
||||
occurredAt: Date;
|
||||
playerName: string;
|
||||
playerNumber?: number;
|
||||
externalPlayerId?: string;
|
||||
reason?: string;
|
||||
rawLine: string;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Identity lines (e.g. BattlEye GUID) arrive separately from connects;
|
||||
* ingestion merges them into the matching player record.
|
||||
*/
|
||||
type: 'player_identity';
|
||||
occurredAt: Date;
|
||||
playerName: string;
|
||||
playerNumber?: number;
|
||||
externalPlayerId: string;
|
||||
rawLine: string;
|
||||
}
|
||||
| {
|
||||
type: 'server_started';
|
||||
occurredAt: Date;
|
||||
rawLine: string;
|
||||
}
|
||||
| {
|
||||
type: 'player_killed';
|
||||
occurredAt: Date;
|
||||
victimName: string;
|
||||
killerName: string;
|
||||
friendly: boolean;
|
||||
rawLine: string;
|
||||
};
|
||||
|
||||
export type ParserContext = {
|
||||
/** Calendar date the time-of-day stamps are relative to (from the log header). */
|
||||
baseDate: Date | null;
|
||||
/** Last timestamp emitted; used to detect midnight rollover. */
|
||||
lastTimestamp: Date | null;
|
||||
};
|
||||
|
||||
export type ParseChunkResult = {
|
||||
events: ParsedLogEvent[];
|
||||
completeLineCount: number;
|
||||
/** Lines that matched no pattern. Safe to ignore; counted for diagnostics only. */
|
||||
ignoredLineCount: number;
|
||||
invalidTimestampCount: number;
|
||||
/** Content after the final newline — not parsed, carried to the next sync. */
|
||||
partialTrailingLine: string | null;
|
||||
/** Raw text of the last complete line, for cursor continuity checks. */
|
||||
lastCompleteLine: string | null;
|
||||
context: ParserContext;
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { ResourceHistoryResponse, ResourceSample } from '@reforger-panel/shared';
|
||||
import type { Logger } from '../../lib/logger.js';
|
||||
import type { GameServerProvider } from '../pterodactyl/types.js';
|
||||
|
||||
export const SAMPLE_INTERVAL_SECONDS = 15;
|
||||
const MAX_SAMPLES = 240; // ~1 hour window
|
||||
|
||||
type RawSample = ResourceSample & { rxTotal: number; txTotal: number };
|
||||
|
||||
/**
|
||||
* In-memory rolling window of resource usage for the dashboard graphs.
|
||||
* Network rates are derived from the provider's cumulative rx/tx counters;
|
||||
* history is intentionally not persisted (it is telemetry, not records).
|
||||
*/
|
||||
export class ResourceHistoryService {
|
||||
private samples = new Map<string, RawSample[]>();
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private servers: { serverId: string; providerServerId: string }[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly provider: GameServerProvider,
|
||||
private readonly logger: Logger,
|
||||
private readonly intervalSeconds: number = SAMPLE_INTERVAL_SECONDS,
|
||||
) {}
|
||||
|
||||
start(servers: { serverId: string; providerServerId: string }[]): void {
|
||||
this.servers = servers;
|
||||
void this.sampleAll();
|
||||
this.timer = setInterval(() => void this.sampleAll(), this.intervalSeconds * 1000);
|
||||
this.timer.unref?.();
|
||||
this.logger.info({ servers: servers.length }, 'resource history sampler started');
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
private async sampleAll(): Promise<void> {
|
||||
for (const server of this.servers) {
|
||||
try {
|
||||
await this.sampleOne(server.serverId, server.providerServerId);
|
||||
} catch {
|
||||
// Provider unreachable: record an offline-ish gap sample so graphs
|
||||
// show the outage instead of freezing on the last good value.
|
||||
this.push(server.serverId, {
|
||||
t: Date.now(),
|
||||
status: 'unknown',
|
||||
cpuPercent: 0,
|
||||
cpuLimitPercent: null,
|
||||
memoryBytes: 0,
|
||||
memoryLimitBytes: null,
|
||||
networkRxRate: 0,
|
||||
networkTxRate: 0,
|
||||
rxTotal: -1,
|
||||
txTotal: -1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async sampleOne(serverId: string, providerServerId: string): Promise<void> {
|
||||
const resources = await this.provider.getServerResources(providerServerId);
|
||||
const previous = this.samples.get(serverId)?.at(-1);
|
||||
const now = Date.now();
|
||||
|
||||
let networkRxRate = 0;
|
||||
let networkTxRate = 0;
|
||||
if (previous && previous.rxTotal >= 0 && now > previous.t) {
|
||||
const dtSeconds = (now - previous.t) / 1000;
|
||||
// Counters reset on server restart; clamp negative deltas to zero.
|
||||
networkRxRate = Math.max(0, (resources.networkRxBytes - previous.rxTotal) / dtSeconds);
|
||||
networkTxRate = Math.max(0, (resources.networkTxBytes - previous.txTotal) / dtSeconds);
|
||||
}
|
||||
|
||||
this.push(serverId, {
|
||||
t: now,
|
||||
status: resources.status,
|
||||
cpuPercent: Math.round(resources.cpuPercent * 10) / 10,
|
||||
cpuLimitPercent: resources.cpuLimitPercent,
|
||||
memoryBytes: resources.memoryBytes,
|
||||
memoryLimitBytes: resources.memoryLimitBytes,
|
||||
networkRxRate: Math.round(networkRxRate),
|
||||
networkTxRate: Math.round(networkTxRate),
|
||||
rxTotal: resources.networkRxBytes,
|
||||
txTotal: resources.networkTxBytes,
|
||||
});
|
||||
}
|
||||
|
||||
private push(serverId: string, sample: RawSample): void {
|
||||
const list = this.samples.get(serverId) ?? [];
|
||||
list.push(sample);
|
||||
if (list.length > MAX_SAMPLES) list.splice(0, list.length - MAX_SAMPLES);
|
||||
this.samples.set(serverId, list);
|
||||
}
|
||||
|
||||
history(serverId: string): ResourceHistoryResponse {
|
||||
const samples = (this.samples.get(serverId) ?? []).map(
|
||||
({ rxTotal: _rx, txTotal: _tx, ...sample }) => sample,
|
||||
);
|
||||
return { samples, intervalSeconds: this.intervalSeconds };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,687 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import type {
|
||||
LogIngestionHealth,
|
||||
ServerResources,
|
||||
ServerStatus,
|
||||
ServerSummary,
|
||||
} from '@reforger-panel/shared';
|
||||
import { ApiError } from '../../lib/errors.js';
|
||||
import { rateLimit } from '../../lib/rate-limit.js';
|
||||
import { requireAuth, requireCapability } from '../auth/auth-middleware.js';
|
||||
import type { ConfigSyncService } from '../config/config-sync.js';
|
||||
import type { ServerModsService } from '../config/mods-service.js';
|
||||
import type { PerformanceSettingsService } from '../config/performance-service.js';
|
||||
import type { ResourceHistoryService } from './resource-history.js';
|
||||
import type { GameServerProvider } from '../pterodactyl/types.js';
|
||||
import type { LogPathResolver } from '../reforger-logs/ingestion/log-path-resolver.js';
|
||||
import type { IngestionScheduler, ScheduledServer } from '../reforger-logs/ingestion/scheduler.js';
|
||||
import type { MissionCatalog } from '../reforger-logs/missions-catalog.js';
|
||||
import { mergeMissions, scenariosFromWorkshopMod } from '../reforger-logs/missions-catalog.js';
|
||||
import type { ServerRecord, ServerService } from './server-service.js';
|
||||
import type { WorkshopClient } from '../workshop/workshop-client.js';
|
||||
|
||||
const slugSchema = z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/, 'Invalid server slug.');
|
||||
|
||||
export type ServerRouterDeps = {
|
||||
service: ServerService;
|
||||
provider: GameServerProvider;
|
||||
scheduler: IngestionScheduler | null;
|
||||
resolveLogPath: LogPathResolver | null;
|
||||
configSync: ConfigSyncService | null;
|
||||
mods: ServerModsService | null;
|
||||
performance: PerformanceSettingsService | null;
|
||||
resourceHistory: ResourceHistoryService | null;
|
||||
missions: MissionCatalog | null;
|
||||
workshop: WorkshopClient;
|
||||
staleAfterSeconds: number;
|
||||
mockMode: boolean;
|
||||
};
|
||||
|
||||
// Validation ranges follow the Bohemia server-config reference. Only provided
|
||||
// keys are touched; `null` removes the key (the game default applies).
|
||||
const performanceBodySchema = z
|
||||
.object({
|
||||
scenarioId: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(200)
|
||||
.regex(/^\{[0-9A-Fa-f]{16}\}\S+\.conf$/, 'Invalid scenario id.')
|
||||
.nullable(),
|
||||
maxPlayers: z.number().int().min(1).max(128).nullable(),
|
||||
serverMaxViewDistance: z.number().int().min(500).max(10000).nullable(),
|
||||
networkViewDistance: z.number().int().min(500).max(5000).nullable(),
|
||||
serverMinGrassDistance: z.number().int().min(0).max(150).nullable(),
|
||||
disableThirdPerson: z.boolean().nullable(),
|
||||
fastValidation: z.boolean().nullable(),
|
||||
battlEye: z.boolean().nullable(),
|
||||
aiLimit: z.number().int().min(-1).max(1000).nullable(),
|
||||
playerSaveTime: z.number().int().min(1).max(3600).nullable(),
|
||||
slotReservationTimeout: z.number().int().min(5).max(300).nullable(),
|
||||
lobbyPlayerSynchronise: z.boolean().nullable(),
|
||||
})
|
||||
.partial()
|
||||
.strict();
|
||||
|
||||
const startupVariableBodySchema = z.object({
|
||||
key: z.string().regex(/^[A-Z0-9_]{1,64}$/, 'Invalid variable name.'),
|
||||
value: z.string().max(500),
|
||||
});
|
||||
|
||||
const restartScheduleBodySchema = z.object({
|
||||
name: z.string().trim().min(1).max(100),
|
||||
isActive: z.boolean(),
|
||||
minute: z.number().int().min(0).max(59),
|
||||
hour: z.number().int().min(0).max(23),
|
||||
dayOfWeek: z.enum(['*', '0', '1', '2', '3', '4', '5', '6']),
|
||||
onlyWhenOnline: z.boolean(),
|
||||
});
|
||||
|
||||
const scheduleIdSchema = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/, 'Invalid schedule id.');
|
||||
|
||||
// Reforger Workshop mod IDs are 16 hex characters (see the Bohemia server
|
||||
// config reference); name/version are free-ish text with sane caps.
|
||||
const modsBodySchema = z.object({
|
||||
mods: z
|
||||
.array(
|
||||
z.object({
|
||||
modId: z.string().regex(/^[A-Fa-f0-9]{16}$/, 'Invalid mod id.'),
|
||||
name: z.string().trim().max(200).optional(),
|
||||
version: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(32)
|
||||
.regex(/^[\w.+-]*$/, 'Invalid version.')
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.max(200),
|
||||
});
|
||||
|
||||
function providerId(server: ServerRecord): string {
|
||||
return server.pterodactylServerId ?? server.slug;
|
||||
}
|
||||
|
||||
export function createServerRouter(deps: ServerRouterDeps): Router {
|
||||
const router = Router();
|
||||
const { service, provider } = deps;
|
||||
const powerRateLimit = rateLimit({ windowMs: 60_000, max: 10, keyPrefix: 'power' });
|
||||
const syncRateLimit = rateLimit({ windowMs: 60_000, max: 6, keyPrefix: 'logsync' });
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
async function loadServer(slugRaw: unknown): Promise<ServerRecord> {
|
||||
const slug = slugSchema.safeParse(slugRaw);
|
||||
if (!slug.success) throw ApiError.validation('Invalid server slug.');
|
||||
const server = await service.getServerBySlug(slug.data);
|
||||
if (!server) throw ApiError.notFound('Server not found.');
|
||||
return server;
|
||||
}
|
||||
|
||||
async function toSummary(server: ServerRecord): Promise<ServerSummary> {
|
||||
let status = server.status as ServerStatus;
|
||||
try {
|
||||
status = await provider.getServerStatus(providerId(server));
|
||||
if (status !== server.status) {
|
||||
await service.updateStatus(server.id, status);
|
||||
}
|
||||
} catch {
|
||||
// Provider unreachable: fall back to the last stored status.
|
||||
}
|
||||
return {
|
||||
id: server.id,
|
||||
slug: server.slug,
|
||||
name: server.name,
|
||||
providerType: server.providerType,
|
||||
status,
|
||||
maxPlayers: server.maxPlayers,
|
||||
onlinePlayerCount: await service.countOnlinePlayers(server.id),
|
||||
createdAt: server.createdAt.toISOString(),
|
||||
updatedAt: server.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/', async (_req, res, next) => {
|
||||
try {
|
||||
const servers = await service.listServers();
|
||||
res.json({ servers: await Promise.all(servers.map((s) => toSummary(s))) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:slug', async (req, res, next) => {
|
||||
try {
|
||||
const server = await loadServer(req.params.slug);
|
||||
res.json(await toSummary(server));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:slug/resources', async (req, res, next) => {
|
||||
try {
|
||||
const server = await loadServer(req.params.slug);
|
||||
const resources = await provider.getServerResources(providerId(server));
|
||||
const body: ServerResources = { ...resources, fetchedAt: new Date().toISOString() };
|
||||
res.json(body);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:slug/resources/history', async (req, res, next) => {
|
||||
try {
|
||||
const server = await loadServer(req.params.slug);
|
||||
if (!deps.resourceHistory) {
|
||||
throw ApiError.notConfigured('Resource history requires a configured game server backend.');
|
||||
}
|
||||
res.json(deps.resourceHistory.history(server.id));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:slug/config/performance', async (req, res, next) => {
|
||||
try {
|
||||
const server = await loadServer(req.params.slug);
|
||||
if (!deps.performance) {
|
||||
throw ApiError.notConfigured('Config editing requires a configured game server backend.');
|
||||
}
|
||||
res.json(await deps.performance.get(server));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put(
|
||||
'/:slug/config/performance',
|
||||
syncRateLimit,
|
||||
requireCapability('config.edit', 'You do not have permission to edit the configuration.'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const server = await loadServer(req.params.slug);
|
||||
if (!deps.performance) {
|
||||
throw ApiError.notConfigured('Config editing requires a configured game server backend.');
|
||||
}
|
||||
const body = performanceBodySchema.safeParse(req.body);
|
||||
if (!body.success) {
|
||||
const issue = body.error.issues[0];
|
||||
throw ApiError.validation(
|
||||
issue ? `${issue.path.join('.')}: ${issue.message}` : 'Invalid settings.',
|
||||
);
|
||||
}
|
||||
const result = await deps.performance.update(server, body.data);
|
||||
// Many Reforger eggs template config.json from startup variables at
|
||||
// boot; mirror the mission there too so switching sticks either way.
|
||||
if (result.changedFields.includes('scenarioId') && body.data.scenarioId) {
|
||||
await provider
|
||||
.updateStartupVariable(providerId(server), 'SCENARIO_ID', body.data.scenarioId)
|
||||
.catch(() => undefined); // variable may not exist on this egg
|
||||
}
|
||||
if (result.changedFields.length > 0) {
|
||||
const user = req.user!;
|
||||
await service.recordActivity({
|
||||
serverId: server.id,
|
||||
actorUserId: user.id,
|
||||
action: 'config.performance.updated',
|
||||
summary: `Performance settings updated by ${user.displayName ?? user.username}: ${result.changedFields.join(', ')} (applies on restart)`,
|
||||
metadata: { changedFields: result.changedFields },
|
||||
});
|
||||
}
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.get('/:slug/players', async (req, res, next) => {
|
||||
try {
|
||||
const server = await loadServer(req.params.slug);
|
||||
res.json(await service.getOnlinePlayers(server, deps.staleAfterSeconds));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:slug/players/known', async (req, res, next) => {
|
||||
try {
|
||||
const server = await loadServer(req.params.slug);
|
||||
res.json({ players: await service.getKnownPlayers(server.id) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:slug/activity', async (req, res, next) => {
|
||||
try {
|
||||
const server = await loadServer(req.params.slug);
|
||||
const limit = z.coerce.number().int().min(1).max(200).default(50).parse(req.query.limit);
|
||||
res.json({ activity: await service.getActivity(server.id, limit) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:slug/killfeed', async (req, res, next) => {
|
||||
try {
|
||||
const server = await loadServer(req.params.slug);
|
||||
const limit = z.coerce.number().int().min(1).max(500).default(100).parse(req.query.limit);
|
||||
res.json({ events: await service.getKillfeed(server.id, limit) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:slug/configuration', async (req, res, next) => {
|
||||
try {
|
||||
const server = await loadServer(req.params.slug);
|
||||
if (!deps.configSync) {
|
||||
throw ApiError.notConfigured('Configuration requires a configured game server backend.');
|
||||
}
|
||||
const config = await deps.configSync.getLiveConfig(server);
|
||||
res.json({ config, fetchedAt: new Date().toISOString() });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:slug/missions', async (req, res, next) => {
|
||||
try {
|
||||
const server = await loadServer(req.params.slug);
|
||||
const logMissions = deps.missions ? (await deps.missions.list()).missions : [];
|
||||
const modMissions = [];
|
||||
if (deps.mods) {
|
||||
const installed = await deps.mods.getMods(server);
|
||||
const details = await Promise.allSettled(
|
||||
installed.mods.map((mod) => deps.workshop.getMod(mod.modId)),
|
||||
);
|
||||
for (const result of details) {
|
||||
if (result.status === 'fulfilled') {
|
||||
modMissions.push(...scenariosFromWorkshopMod(result.value));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!deps.missions && !deps.mods) {
|
||||
throw ApiError.notConfigured('Missions require logs or config/mod access.');
|
||||
}
|
||||
res.json({
|
||||
missions: mergeMissions(logMissions, modMissions),
|
||||
fetchedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get(
|
||||
'/:slug/logs/raw',
|
||||
requireCapability('ops.health.view', 'Raw logs are restricted.'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const server = await loadServer(req.params.slug);
|
||||
if (!deps.resolveLogPath) {
|
||||
throw ApiError.notConfigured('Log access requires a configured game server backend.');
|
||||
}
|
||||
const lineCount = z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(10)
|
||||
.max(1000)
|
||||
.default(300)
|
||||
.parse(req.query.lines);
|
||||
const logPath = await deps.resolveLogPath();
|
||||
if (!logPath) throw ApiError.notConfigured('Could not locate the current log file.');
|
||||
const file = await provider.downloadTextFile(providerId(server), logPath, 512 * 1024);
|
||||
const allLines = file.content.split('\n');
|
||||
res.json({
|
||||
path: logPath,
|
||||
lines: allLines.slice(-lineCount),
|
||||
truncated: file.truncated || allLines.length > lineCount,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:slug/startup',
|
||||
requireCapability('config.edit', 'Startup variables are restricted.'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const server = await loadServer(req.params.slug);
|
||||
const variables = await provider.listStartupVariables(providerId(server));
|
||||
res.json({
|
||||
variables: variables.map((v) => ({
|
||||
name: v.name,
|
||||
description: v.description,
|
||||
envVariable: v.envVariable,
|
||||
value: v.serverValue,
|
||||
defaultValue: v.defaultValue,
|
||||
isEditable: v.isEditable,
|
||||
})),
|
||||
fetchedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/:slug/startup/variable',
|
||||
syncRateLimit,
|
||||
requireCapability('config.edit', 'Startup variables are restricted.'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const server = await loadServer(req.params.slug);
|
||||
const body = startupVariableBodySchema.safeParse(req.body);
|
||||
if (!body.success) throw ApiError.validation('Invalid startup variable update.');
|
||||
await provider.updateStartupVariable(providerId(server), body.data.key, body.data.value);
|
||||
const user = req.user!;
|
||||
// Never put the value in the activity feed — these can be passwords.
|
||||
await service.recordActivity({
|
||||
serverId: server.id,
|
||||
actorUserId: user.id,
|
||||
action: 'startup.variable.updated',
|
||||
summary: `Startup variable ${body.data.key} updated by ${user.displayName ?? user.username} (applies on restart)`,
|
||||
metadata: { key: body.data.key },
|
||||
});
|
||||
res.json({ ok: true, requiresRestart: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:slug/schedules',
|
||||
requireCapability('config.edit', 'Schedule management is restricted.'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const server = await loadServer(req.params.slug);
|
||||
res.json({
|
||||
schedules: await provider.listSchedules(providerId(server)),
|
||||
fetchedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:slug/schedules/restarts',
|
||||
syncRateLimit,
|
||||
requireCapability('config.edit', 'Schedule management is restricted.'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const server = await loadServer(req.params.slug);
|
||||
const body = restartScheduleBodySchema.safeParse(req.body);
|
||||
if (!body.success) throw ApiError.validation('Invalid restart schedule.');
|
||||
const schedule = await provider.createRestartSchedule(providerId(server), body.data);
|
||||
const user = req.user!;
|
||||
await service.recordActivity({
|
||||
serverId: server.id,
|
||||
actorUserId: user.id,
|
||||
action: 'schedule.restart.created',
|
||||
summary: `Restart schedule "${schedule.name}" created by ${user.displayName ?? user.username}`,
|
||||
metadata: { scheduleId: schedule.id },
|
||||
});
|
||||
res.json({ schedule });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/:slug/schedules/:scheduleId/restart',
|
||||
syncRateLimit,
|
||||
requireCapability('config.edit', 'Schedule management is restricted.'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const server = await loadServer(req.params.slug);
|
||||
const scheduleId = scheduleIdSchema.safeParse(req.params.scheduleId);
|
||||
if (!scheduleId.success) throw ApiError.validation('Invalid schedule id.');
|
||||
const body = restartScheduleBodySchema.safeParse(req.body);
|
||||
if (!body.success) throw ApiError.validation('Invalid restart schedule.');
|
||||
const schedule = await provider.updateRestartSchedule(
|
||||
providerId(server),
|
||||
scheduleId.data,
|
||||
body.data,
|
||||
);
|
||||
const user = req.user!;
|
||||
await service.recordActivity({
|
||||
serverId: server.id,
|
||||
actorUserId: user.id,
|
||||
action: 'schedule.restart.updated',
|
||||
summary: `Restart schedule "${schedule.name}" updated by ${user.displayName ?? user.username}`,
|
||||
metadata: { scheduleId: schedule.id },
|
||||
});
|
||||
res.json({ schedule });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:slug/schedules/:scheduleId',
|
||||
syncRateLimit,
|
||||
requireCapability('config.edit', 'Schedule management is restricted.'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const server = await loadServer(req.params.slug);
|
||||
const scheduleId = scheduleIdSchema.safeParse(req.params.scheduleId);
|
||||
if (!scheduleId.success) throw ApiError.validation('Invalid schedule id.');
|
||||
await provider.deleteSchedule(providerId(server), scheduleId.data);
|
||||
const user = req.user!;
|
||||
await service.recordActivity({
|
||||
serverId: server.id,
|
||||
actorUserId: user.id,
|
||||
action: 'schedule.deleted',
|
||||
summary: `Schedule deleted by ${user.displayName ?? user.username}`,
|
||||
metadata: { scheduleId: scheduleId.data },
|
||||
});
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.get('/:slug/mods', async (req, res, next) => {
|
||||
try {
|
||||
const server = await loadServer(req.params.slug);
|
||||
if (!deps.mods) {
|
||||
throw ApiError.notConfigured('Mod management requires a configured game server backend.');
|
||||
}
|
||||
res.json(await deps.mods.getMods(server));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put(
|
||||
'/:slug/mods',
|
||||
syncRateLimit,
|
||||
requireCapability('mods.manage', 'You do not have permission to manage mods.'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const server = await loadServer(req.params.slug);
|
||||
if (!deps.mods) {
|
||||
throw ApiError.notConfigured('Mod management requires a configured game server backend.');
|
||||
}
|
||||
const body = modsBodySchema.safeParse(req.body);
|
||||
if (!body.success) {
|
||||
throw ApiError.validation(body.error.issues[0]?.message ?? 'Invalid mod list.');
|
||||
}
|
||||
// Reject duplicate mod ids up front instead of silently collapsing.
|
||||
const ids = body.data.mods.map((mod) => mod.modId.toUpperCase());
|
||||
if (new Set(ids).size !== ids.length) {
|
||||
throw ApiError.validation('Duplicate mod ids in the list.');
|
||||
}
|
||||
|
||||
const result = await deps.mods.setMods(server, body.data.mods);
|
||||
const user = req.user!;
|
||||
await service.recordActivity({
|
||||
serverId: server.id,
|
||||
actorUserId: user.id,
|
||||
action: 'mods.updated',
|
||||
summary: `Mods updated by ${user.displayName ?? user.username}: ${result.added} added, ${result.removed} removed (${result.mods.length} total, applies on restart)`,
|
||||
metadata: { added: result.added, removed: result.removed, total: result.mods.length },
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.get('/:slug/mod-packs', async (req, res, next) => {
|
||||
try {
|
||||
const server = await loadServer(req.params.slug);
|
||||
res.json({ modPacks: await service.getModPacks(server.id) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
const powerActions = [
|
||||
{
|
||||
action: 'start' as const,
|
||||
capability: 'server.power.start' as const,
|
||||
message: 'You do not have permission to start this server.',
|
||||
run: (id: string) => provider.startServer(id),
|
||||
},
|
||||
{
|
||||
action: 'stop' as const,
|
||||
capability: 'server.power.stop' as const,
|
||||
message: 'You do not have permission to stop this server.',
|
||||
run: (id: string) => provider.stopServer(id),
|
||||
},
|
||||
{
|
||||
action: 'restart' as const,
|
||||
capability: 'server.power.restart' as const,
|
||||
message: 'You do not have permission to restart this server.',
|
||||
run: (id: string) => provider.restartServer(id),
|
||||
},
|
||||
];
|
||||
|
||||
for (const { action, capability, message, run } of powerActions) {
|
||||
router.post(
|
||||
`/:slug/power/${action}`,
|
||||
powerRateLimit,
|
||||
requireCapability(capability, message),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const server = await loadServer(req.params.slug);
|
||||
await run(providerId(server));
|
||||
const user = req.user!;
|
||||
await service.recordActivity({
|
||||
serverId: server.id,
|
||||
actorUserId: user.id,
|
||||
action: `server.power.${action}`,
|
||||
summary: `Server ${action} requested by ${user.displayName ?? user.username}${
|
||||
deps.mockMode ? ' (mock mode)' : ''
|
||||
}`,
|
||||
metadata: { action, mock: deps.mockMode },
|
||||
});
|
||||
res.json({ ok: true, action, simulated: deps.mockMode });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
router.post(
|
||||
'/:slug/logs/sync',
|
||||
syncRateLimit,
|
||||
requireCapability('logs.sync', 'Only the owner can trigger a manual log sync.'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const server = await loadServer(req.params.slug);
|
||||
if (!deps.scheduler || !deps.resolveLogPath) {
|
||||
throw ApiError.notConfigured(
|
||||
'Log ingestion is not configured. Set REFORGER_LOG_DIRECTORY (or REFORGER_ADMIN_LOG_PATH) and the Pterodactyl variables.',
|
||||
);
|
||||
}
|
||||
const target: ScheduledServer = {
|
||||
serverId: server.id,
|
||||
providerServerId: providerId(server),
|
||||
resolveLogPath: deps.resolveLogPath,
|
||||
};
|
||||
const result = await deps.scheduler.syncNow(target);
|
||||
const user = req.user!;
|
||||
await service.recordActivity({
|
||||
serverId: server.id,
|
||||
actorUserId: user.id,
|
||||
action: 'logs.sync.manual',
|
||||
summary: `Manual log sync by ${user.displayName ?? user.username} (${result.createdEvents} new events)`,
|
||||
metadata: { createdEvents: result.createdEvents, processedLines: result.processedLines },
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:slug/config/sync',
|
||||
syncRateLimit,
|
||||
requireCapability('ops.health.view', 'Config sync is restricted to owner and server admins.'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const server = await loadServer(req.params.slug);
|
||||
if (!deps.configSync) {
|
||||
throw ApiError.notConfigured('Config import requires a configured game server backend.');
|
||||
}
|
||||
// Config is served live; this just refreshes the stored name/capacity.
|
||||
const result = await deps.configSync.sync(server);
|
||||
res.json({ ok: true, serverName: result.serverName, maxPlayers: result.maxPlayers });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:slug/logs/health',
|
||||
requireCapability('ops.health.view', 'Operational diagnostics are restricted.'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const server = await loadServer(req.params.slug);
|
||||
const cursor = await service.getLogCursor(server.id);
|
||||
const lastResult = deps.scheduler?.getLastResult(server.id) ?? null;
|
||||
const lastSyncAt = cursor?.lastSuccessfulSyncAt ?? null;
|
||||
const body: LogIngestionHealth = {
|
||||
configured: Boolean(deps.scheduler && deps.resolveLogPath),
|
||||
running: Boolean(deps.scheduler),
|
||||
logPath: cursor?.logPath ?? null,
|
||||
lastSuccessfulSyncAt: lastSyncAt?.toISOString() ?? null,
|
||||
lastErrorAt: cursor?.lastErrorAt?.toISOString() ?? null,
|
||||
lastErrorMessage: cursor?.lastErrorMessage ?? null,
|
||||
lastSync: lastResult
|
||||
? {
|
||||
processedLines: lastResult.processedLines,
|
||||
createdEvents: lastResult.createdEvents,
|
||||
updatedSessions: lastResult.updatedSessions,
|
||||
}
|
||||
: null,
|
||||
stale: !lastSyncAt || Date.now() - lastSyncAt.getTime() > deps.staleAfterSeconds * 1000,
|
||||
};
|
||||
res.json(body);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { and, count, desc, eq, isNull, sql } from 'drizzle-orm';
|
||||
import type {
|
||||
ActivityItem,
|
||||
KillfeedEvent,
|
||||
KnownPlayer,
|
||||
ModPackSummary,
|
||||
OnlinePlayer,
|
||||
PlayersResponse,
|
||||
} from '@reforger-panel/shared';
|
||||
import type { Db } from '../../db/client.js';
|
||||
import { schema } from '../../db/client.js';
|
||||
|
||||
export type ServerRecord = typeof schema.servers.$inferSelect;
|
||||
|
||||
export class ServerService {
|
||||
constructor(private readonly db: Db) {}
|
||||
|
||||
async listServers(): Promise<ServerRecord[]> {
|
||||
return this.db.select().from(schema.servers).orderBy(schema.servers.name);
|
||||
}
|
||||
|
||||
async getServerBySlug(slug: string): Promise<ServerRecord | null> {
|
||||
const rows = await this.db.select().from(schema.servers).where(eq(schema.servers.slug, slug));
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async updateStatus(serverId: string, status: string): Promise<void> {
|
||||
await this.db.update(schema.servers).set({ status }).where(eq(schema.servers.id, serverId));
|
||||
}
|
||||
|
||||
async updateServerInfo(
|
||||
serverId: string,
|
||||
patch: { name?: string; maxPlayers?: number | null },
|
||||
): Promise<void> {
|
||||
await this.db.update(schema.servers).set(patch).where(eq(schema.servers.id, serverId));
|
||||
}
|
||||
|
||||
async countOnlinePlayers(serverId: string): Promise<number> {
|
||||
const rows = await this.db
|
||||
.select({ value: count() })
|
||||
.from(schema.playerSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.playerSessions.serverId, serverId),
|
||||
isNull(schema.playerSessions.disconnectedAt),
|
||||
),
|
||||
);
|
||||
return rows[0]?.value ?? 0;
|
||||
}
|
||||
|
||||
async getOnlinePlayers(
|
||||
server: ServerRecord,
|
||||
staleAfterSeconds: number,
|
||||
): Promise<PlayersResponse> {
|
||||
const rows = await this.db
|
||||
.select({ session: schema.playerSessions, player: schema.players })
|
||||
.from(schema.playerSessions)
|
||||
.innerJoin(schema.players, eq(schema.players.id, schema.playerSessions.playerId))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.playerSessions.serverId, server.id),
|
||||
isNull(schema.playerSessions.disconnectedAt),
|
||||
),
|
||||
)
|
||||
.orderBy(schema.playerSessions.connectedAt);
|
||||
|
||||
const now = Date.now();
|
||||
const players: OnlinePlayer[] = rows.map(({ session, player }) => ({
|
||||
playerId: player.id,
|
||||
displayName: player.displayName,
|
||||
externalPlayerId: player.externalPlayerId,
|
||||
connectedAt: session.connectedAt.toISOString(),
|
||||
sessionDurationSeconds: Math.max(0, Math.round((now - session.connectedAt.getTime()) / 1000)),
|
||||
}));
|
||||
|
||||
const cursors = await this.db
|
||||
.select()
|
||||
.from(schema.logCursors)
|
||||
.where(eq(schema.logCursors.serverId, server.id));
|
||||
const lastSyncedAt = cursors
|
||||
.map((c) => c.lastSuccessfulSyncAt)
|
||||
.filter((d): d is Date => d !== null)
|
||||
.sort((a, b) => b.getTime() - a.getTime())[0];
|
||||
|
||||
return {
|
||||
players,
|
||||
onlineCount: players.length,
|
||||
maxPlayers: server.maxPlayers,
|
||||
lastSyncedAt: lastSyncedAt?.toISOString() ?? null,
|
||||
stale: !lastSyncedAt || now - lastSyncedAt.getTime() > staleAfterSeconds * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
async getKnownPlayers(serverId: string, limit = 100): Promise<KnownPlayer[]> {
|
||||
const rows = await this.db
|
||||
.select({
|
||||
player: schema.players,
|
||||
totalSessions: count(schema.playerSessions.id),
|
||||
totalPlaytimeSeconds: sql<number>`coalesce(sum(${schema.playerSessions.durationSeconds}), 0)`,
|
||||
openSessions: sql<number>`count(*) filter (where ${schema.playerSessions.disconnectedAt} is null)`,
|
||||
})
|
||||
.from(schema.players)
|
||||
.leftJoin(schema.playerSessions, eq(schema.playerSessions.playerId, schema.players.id))
|
||||
.where(eq(schema.players.serverId, serverId))
|
||||
.groupBy(schema.players.id)
|
||||
.orderBy(desc(schema.players.lastSeenAt))
|
||||
.limit(limit);
|
||||
|
||||
return rows.map(({ player, totalSessions, totalPlaytimeSeconds, openSessions }) => ({
|
||||
id: player.id,
|
||||
displayName: player.displayName,
|
||||
externalPlayerId: player.externalPlayerId,
|
||||
firstSeenAt: player.firstSeenAt.toISOString(),
|
||||
lastSeenAt: player.lastSeenAt.toISOString(),
|
||||
totalSessions,
|
||||
totalPlaytimeSeconds: Number(totalPlaytimeSeconds),
|
||||
online: Number(openSessions) > 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Merged feed of panel actions (server_activity) and log-derived server events. */
|
||||
async getActivity(serverId: string, limit = 50): Promise<ActivityItem[]> {
|
||||
const actions = await this.db
|
||||
.select({ activity: schema.serverActivity, actor: schema.users })
|
||||
.from(schema.serverActivity)
|
||||
.leftJoin(schema.users, eq(schema.users.id, schema.serverActivity.actorUserId))
|
||||
.where(eq(schema.serverActivity.serverId, serverId))
|
||||
.orderBy(desc(schema.serverActivity.createdAt))
|
||||
.limit(limit);
|
||||
|
||||
const events = await this.db
|
||||
.select()
|
||||
.from(schema.serverEvents)
|
||||
.where(eq(schema.serverEvents.serverId, serverId))
|
||||
.orderBy(desc(schema.serverEvents.occurredAt))
|
||||
.limit(limit);
|
||||
|
||||
const items: ActivityItem[] = [
|
||||
...actions.map(({ activity, actor }) => ({
|
||||
id: `activity:${activity.id}`,
|
||||
kind: 'panel_action' as const,
|
||||
action: activity.action,
|
||||
summary: activity.summary,
|
||||
actor: actor
|
||||
? { id: actor.id, username: actor.username, displayName: actor.displayName }
|
||||
: null,
|
||||
occurredAt: activity.createdAt.toISOString(),
|
||||
})),
|
||||
...events.map((event) => ({
|
||||
id: `event:${event.id}`,
|
||||
kind: 'server_event' as const,
|
||||
action: event.eventType,
|
||||
summary: event.summary,
|
||||
actor: null,
|
||||
occurredAt: event.occurredAt.toISOString(),
|
||||
})),
|
||||
];
|
||||
items.sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
|
||||
return items.slice(0, limit);
|
||||
}
|
||||
|
||||
async getKillfeed(serverId: string, limit = 100): Promise<KillfeedEvent[]> {
|
||||
const events = await this.db
|
||||
.select()
|
||||
.from(schema.serverEvents)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.serverEvents.serverId, serverId),
|
||||
eq(schema.serverEvents.eventType, 'player_killed'),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(schema.serverEvents.occurredAt))
|
||||
.limit(limit);
|
||||
|
||||
return events.map((event) => {
|
||||
const payload = event.payload as Record<string, unknown>;
|
||||
const position = (value: unknown) => {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
return typeof record.x === 'number' && typeof record.y === 'number'
|
||||
? {
|
||||
x: record.x,
|
||||
y: record.y,
|
||||
z: typeof record.z === 'number' ? record.z : null,
|
||||
}
|
||||
: null;
|
||||
};
|
||||
return {
|
||||
id: event.id,
|
||||
occurredAt: event.occurredAt.toISOString(),
|
||||
killerName: typeof payload.killerName === 'string' ? payload.killerName : 'unknown',
|
||||
victimName: typeof payload.victimName === 'string' ? payload.victimName : 'unknown',
|
||||
friendly: payload.friendly === true,
|
||||
killerTeam: typeof payload.killerTeam === 'string' ? payload.killerTeam : null,
|
||||
victimTeam: typeof payload.victimTeam === 'string' ? payload.victimTeam : null,
|
||||
killerPosition: position(payload.killerPosition),
|
||||
victimPosition: position(payload.victimPosition),
|
||||
distanceMeters: typeof payload.distanceMeters === 'number' ? payload.distanceMeters : null,
|
||||
weapon: typeof payload.weapon === 'string' ? payload.weapon : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async recordActivity(input: {
|
||||
serverId: string;
|
||||
actorUserId: string | null;
|
||||
action: string;
|
||||
summary: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}): Promise<void> {
|
||||
await this.db.insert(schema.serverActivity).values({
|
||||
serverId: input.serverId,
|
||||
actorUserId: input.actorUserId,
|
||||
action: input.action,
|
||||
summary: input.summary,
|
||||
metadata: input.metadata ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
async getModPacks(serverId: string): Promise<ModPackSummary[]> {
|
||||
const packs = await this.db
|
||||
.select()
|
||||
.from(schema.modPacks)
|
||||
.where(eq(schema.modPacks.serverId, serverId))
|
||||
.orderBy(desc(schema.modPacks.updatedAt));
|
||||
|
||||
const result: ModPackSummary[] = [];
|
||||
for (const pack of packs) {
|
||||
const revisions = await this.db
|
||||
.select()
|
||||
.from(schema.modPackRevisions)
|
||||
.where(eq(schema.modPackRevisions.modPackId, pack.id))
|
||||
.orderBy(desc(schema.modPackRevisions.version))
|
||||
.limit(1);
|
||||
const latest = revisions[0];
|
||||
const mods = (latest?.mods ?? []) as unknown[];
|
||||
result.push({
|
||||
id: pack.id,
|
||||
name: pack.name,
|
||||
description: pack.description,
|
||||
status: pack.status,
|
||||
modCount: Array.isArray(mods) ? mods.length : 0,
|
||||
latestVersion: latest?.version ?? null,
|
||||
updatedAt: pack.updatedAt.toISOString(),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async getLogCursor(serverId: string) {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(schema.logCursors)
|
||||
.where(eq(schema.logCursors.serverId, serverId))
|
||||
.orderBy(desc(schema.logCursors.updatedAt))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { desc } from 'drizzle-orm';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { PanelUser } from '@reforger-panel/shared';
|
||||
import { ROLES } from '@reforger-panel/shared';
|
||||
import type { Db } from '../../db/client.js';
|
||||
import { schema } from '../../db/client.js';
|
||||
import { ApiError } from '../../lib/errors.js';
|
||||
import { requireCapability } from '../auth/auth-middleware.js';
|
||||
|
||||
const roleBodySchema = z.object({ role: z.enum(ROLES as [string, ...string[]]) });
|
||||
|
||||
export function createUserRouter(db: Db): Router {
|
||||
const router = Router();
|
||||
|
||||
router.use(requireCapability('users.manage', 'Only the owner can manage users.'));
|
||||
|
||||
router.get('/', async (_req, res, next) => {
|
||||
try {
|
||||
const rows = await db.select().from(schema.users).orderBy(desc(schema.users.createdAt));
|
||||
const users: PanelUser[] = rows.map((user) => ({
|
||||
id: user.id,
|
||||
discordId: user.discordId,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
updatedAt: user.updatedAt.toISOString(),
|
||||
}));
|
||||
res.json({ users });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/:id/role', async (req, res, next) => {
|
||||
try {
|
||||
const id = z.string().uuid().safeParse(req.params.id);
|
||||
if (!id.success) throw ApiError.validation('Invalid user id.');
|
||||
const body = roleBodySchema.safeParse(req.body);
|
||||
if (!body.success) throw ApiError.validation('Invalid role.');
|
||||
if (req.user!.id === id.data) {
|
||||
throw ApiError.validation('You cannot change your own role.');
|
||||
}
|
||||
const [updated] = await db
|
||||
.update(schema.users)
|
||||
.set({ role: body.data.role as (typeof ROLES)[number] })
|
||||
.where(eq(schema.users.id, id.data))
|
||||
.returning();
|
||||
if (!updated) throw ApiError.notFound('User not found.');
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { WorkshopClient, normalizeImageUrl } from './workshop-client.js';
|
||||
|
||||
const REAL_IMAGE = 'https://ar-gcp-cdn.bistudio.com/image/abcd/1234';
|
||||
|
||||
function listResponse() {
|
||||
return {
|
||||
status: 'success',
|
||||
meta: { totalPages: 1, currentPage: 1, totalMods: 2, shownMods: 2 },
|
||||
data: [
|
||||
{
|
||||
name: 'Mod A',
|
||||
author: 'Author',
|
||||
imageURL: 'https://via.placeholder.com/640x360',
|
||||
originalModURL: 'https://reforger.armaplatform.com/workshop/AAAAAAAAAAAAAAA1',
|
||||
apiModURL: 'https://api.reforgermods.net/v1/mod/AAAAAAAAAAAAAAA1',
|
||||
size: '1 MB',
|
||||
rating: '99%',
|
||||
ID: 'AAAAAAAAAAAAAAA1',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function detailResponse(id: string) {
|
||||
return {
|
||||
status: 'success',
|
||||
mod: {
|
||||
name: 'Mod A',
|
||||
author: 'Author',
|
||||
originalModURL: `https://reforger.armaplatform.com/workshop/${id}`,
|
||||
apiModURL: `https://api.reforgermods.net/v1/mod/${id}`,
|
||||
// Upstream bug: two URLs concatenated.
|
||||
imageURL: `https://reforger.armaplatform.com${REAL_IMAGE}`,
|
||||
rating: '99%',
|
||||
version: '1.2.0',
|
||||
size: '1 MB',
|
||||
id,
|
||||
tags: [],
|
||||
dependencies: [],
|
||||
scenarios: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('normalizeImageUrl', () => {
|
||||
it('drops dead placeholder URLs', () => {
|
||||
expect(normalizeImageUrl('https://via.placeholder.com/640x360')).toBeNull();
|
||||
});
|
||||
|
||||
it('repairs concatenated double URLs', () => {
|
||||
expect(normalizeImageUrl(`https://reforger.armaplatform.com${REAL_IMAGE}`)).toBe(REAL_IMAGE);
|
||||
});
|
||||
|
||||
it('passes through well-formed URLs and rejects junk', () => {
|
||||
expect(normalizeImageUrl(REAL_IMAGE)).toBe(REAL_IMAGE);
|
||||
expect(normalizeImageUrl('')).toBeNull();
|
||||
expect(normalizeImageUrl('not a url')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WorkshopClient image enrichment', () => {
|
||||
it('warms list images from the detail endpoint in the background and caches them', async () => {
|
||||
const fetchImpl = vi.fn(async (url: string | URL) => {
|
||||
const path = String(url);
|
||||
if (path.includes('/v1/mod/')) {
|
||||
const id = path.slice(path.lastIndexOf('/') + 1);
|
||||
return new Response(JSON.stringify(detailResponse(id)), { status: 200 });
|
||||
}
|
||||
return new Response(JSON.stringify(listResponse()), { status: 200 });
|
||||
});
|
||||
const client = new WorkshopClient({
|
||||
baseUrl: 'https://workshop.test',
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
const first = await client.search('', 1);
|
||||
expect(first.mods[0]!.imageUrl).toBeNull();
|
||||
await vi.waitFor(() => {
|
||||
const detailCalls = fetchImpl.mock.calls.filter((c) => String(c[0]).includes('/v1/mod/'));
|
||||
expect(detailCalls).toHaveLength(1);
|
||||
});
|
||||
const detailCalls = fetchImpl.mock.calls.filter((c) => String(c[0]).includes('/v1/mod/'));
|
||||
expect(detailCalls).toHaveLength(1);
|
||||
|
||||
// Second search hits the cache — no extra detail request.
|
||||
const second = await client.search('', 1);
|
||||
expect(second.mods[0]!.imageUrl).toBe(REAL_IMAGE);
|
||||
const detailCallsAfter = fetchImpl.mock.calls.filter((c) => String(c[0]).includes('/v1/mod/'));
|
||||
expect(detailCallsAfter).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('leaves the image empty when the detail fetch fails', async () => {
|
||||
const fetchImpl = vi.fn(async (url: string | URL) => {
|
||||
const path = String(url);
|
||||
if (path.includes('/v1/mod/')) {
|
||||
return new Response('nope', { status: 500 });
|
||||
}
|
||||
return new Response(JSON.stringify(listResponse()), { status: 200 });
|
||||
});
|
||||
const client = new WorkshopClient({
|
||||
baseUrl: 'https://workshop.test',
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
});
|
||||
const result = await client.search('', 1);
|
||||
expect(result.mods[0]!.imageUrl).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import { z } from 'zod';
|
||||
import type {
|
||||
WorkshopHealth,
|
||||
WorkshopModDetail,
|
||||
WorkshopModPreview,
|
||||
WorkshopSearchResponse,
|
||||
} from '@reforger-panel/shared';
|
||||
import { ApiError } from '../../lib/errors.js';
|
||||
import { sanitizeErrorMessage } from '../../lib/logger.js';
|
||||
|
||||
/**
|
||||
* Client for the public reforgermods.net Workshop metadata API.
|
||||
* Endpoint shapes follow https://reforgermods.net/?page=documentation/api:
|
||||
* GET /v1/health
|
||||
* GET /v1/mods/{page}?search={q}&sort={sort}
|
||||
* GET /v1/mod/{mod_id}
|
||||
* Backend-only — the browser never talks to this host directly.
|
||||
*/
|
||||
|
||||
const modPreviewSchema = z.object({
|
||||
name: z.string(),
|
||||
author: z.string().catch('Unknown'),
|
||||
imageURL: z.string().catch(''),
|
||||
originalModURL: z.string().catch(''),
|
||||
size: z.string().catch(''),
|
||||
rating: z.string().catch(''),
|
||||
ID: z.string(),
|
||||
});
|
||||
|
||||
const searchResponseSchema = z.object({
|
||||
status: z.string(),
|
||||
meta: z.object({
|
||||
totalPages: z.number().catch(1),
|
||||
currentPage: z.number().catch(1),
|
||||
totalMods: z.number().catch(0),
|
||||
}),
|
||||
data: z.array(modPreviewSchema).catch([]),
|
||||
});
|
||||
|
||||
const modDetailSchema = z.object({
|
||||
name: z.string(),
|
||||
author: z.string().catch('Unknown'),
|
||||
originalModURL: z.string().catch(''),
|
||||
imageURL: z.string().catch(''),
|
||||
rating: z.string().catch(''),
|
||||
version: z.string().nullish(),
|
||||
gameVersion: z.string().nullish(),
|
||||
size: z.string().catch(''),
|
||||
subscribers: z.number().nullish(),
|
||||
downloads: z.number().nullish(),
|
||||
created: z.string().nullish(),
|
||||
lastModified: z.string().nullish(),
|
||||
id: z.string(),
|
||||
summary: z.string().nullish(),
|
||||
description: z.string().nullish(),
|
||||
license: z.string().nullish(),
|
||||
tags: z.array(z.string()).catch([]),
|
||||
dependencies: z.array(z.object({ name: z.string(), apiModURL: z.string().catch('') })).catch([]),
|
||||
scenarios: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
description: z.string().catch(''),
|
||||
scenarioID: z.string(),
|
||||
gamemode: z.string().catch(''),
|
||||
playerCount: z.number().catch(0),
|
||||
imageURL: z.string().catch(''),
|
||||
}),
|
||||
)
|
||||
.catch([]),
|
||||
});
|
||||
|
||||
const modDetailEnvelopeSchema = z.object({ status: z.string(), mod: modDetailSchema });
|
||||
|
||||
export type WorkshopSort = 'popularity' | 'newest' | 'subscribers' | 'version_size';
|
||||
|
||||
function extractModId(apiModUrl: string): string | null {
|
||||
const match = /\/v1\/mod\/([^/?#]+)/.exec(apiModUrl);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upstream image URLs need repair: list endpoints return dead
|
||||
* via.placeholder.com stubs, and detail endpoints sometimes concatenate two
|
||||
* URLs ("https://reforger.armaplatform.comhttps://ar-gcp-cdn...").
|
||||
*/
|
||||
export function normalizeImageUrl(raw: string | null | undefined): string | null {
|
||||
if (!raw) return null;
|
||||
if (raw.includes('via.placeholder.com')) return null;
|
||||
const lastScheme = raw.lastIndexOf('https://');
|
||||
const candidate = lastScheme > 0 ? raw.slice(lastScheme) : raw;
|
||||
return candidate.startsWith('http') ? candidate : null;
|
||||
}
|
||||
|
||||
function toPreview(mod: z.infer<typeof modPreviewSchema>): WorkshopModPreview {
|
||||
return {
|
||||
id: mod.ID,
|
||||
name: mod.name,
|
||||
author: mod.author,
|
||||
imageUrl: normalizeImageUrl(mod.imageURL),
|
||||
size: mod.size || null,
|
||||
rating: mod.rating || null,
|
||||
workshopUrl: mod.originalModURL || null,
|
||||
};
|
||||
}
|
||||
|
||||
const IMAGE_CACHE_TTL_MS = 60 * 60 * 1000; // matches upstream's 1 h detail cache
|
||||
const IMAGE_FETCH_CONCURRENCY = 5;
|
||||
|
||||
export class WorkshopClient {
|
||||
private readonly baseUrl: string;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private readonly timeoutMs: number;
|
||||
/** modId → real image URL (or null when the mod has none). */
|
||||
private imageCache = new Map<string, { url: string | null; expiresAt: number }>();
|
||||
|
||||
constructor(options: { baseUrl: string; fetchImpl?: typeof fetch; timeoutMs?: number }) {
|
||||
this.baseUrl = options.baseUrl.replace(/\/$/, '');
|
||||
this.fetchImpl = options.fetchImpl ?? fetch;
|
||||
this.timeoutMs = options.timeoutMs ?? 10_000;
|
||||
}
|
||||
|
||||
private async get(path: string): Promise<unknown> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
});
|
||||
} catch (error) {
|
||||
const reason =
|
||||
error instanceof Error && error.name === 'TimeoutError' ? 'timed out' : 'failed';
|
||||
throw ApiError.upstream(`Workshop API request ${reason}.`);
|
||||
}
|
||||
if (response.status === 404) {
|
||||
throw ApiError.notFound('Workshop mod not found.');
|
||||
}
|
||||
if (response.status === 429) {
|
||||
throw ApiError.rateLimited('Workshop API rate limit reached. Try again shortly.');
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw ApiError.upstream(`Workshop API returned HTTP ${response.status}.`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async health(): Promise<WorkshopHealth> {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
await this.get('/v1/health');
|
||||
return {
|
||||
ok: true,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
checkedAt: new Date().toISOString(),
|
||||
message: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
latencyMs: null,
|
||||
checkedAt: new Date().toISOString(),
|
||||
message: sanitizeErrorMessage(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async search(query: string, page = 1, sort?: WorkshopSort): Promise<WorkshopSearchResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (query) params.set('search', query);
|
||||
if (sort) params.set('sort', sort);
|
||||
const qs = params.size > 0 ? `?${params.toString()}` : '';
|
||||
const raw = await this.get(`/v1/mods/${Math.max(1, page)}${qs}`);
|
||||
const parsed = searchResponseSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
throw ApiError.upstream('Workshop API returned an unexpected response shape.');
|
||||
}
|
||||
const mods = parsed.data.data.map(toPreview);
|
||||
this.applyCachedImages(mods);
|
||||
void this.enrichImages(mods).catch(() => undefined);
|
||||
return {
|
||||
mods,
|
||||
meta: parsed.data.meta,
|
||||
};
|
||||
}
|
||||
|
||||
private applyCachedImages(mods: WorkshopModPreview[]): void {
|
||||
const now = Date.now();
|
||||
for (const mod of mods) {
|
||||
if (mod.imageUrl) continue;
|
||||
const cached = this.imageCache.get(mod.id);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
mod.imageUrl = cached.url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List responses carry no usable images, so fill them in from the detail
|
||||
* endpoint (which does). This runs as a background cache warmer from search:
|
||||
* first-load results are fast, later visits pick up cached images.
|
||||
*/
|
||||
private async enrichImages(mods: WorkshopModPreview[]): Promise<void> {
|
||||
const now = Date.now();
|
||||
const pending: WorkshopModPreview[] = [];
|
||||
for (const mod of mods) {
|
||||
if (mod.imageUrl) continue;
|
||||
const cached = this.imageCache.get(mod.id);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
mod.imageUrl = cached.url;
|
||||
} else {
|
||||
pending.push(mod);
|
||||
}
|
||||
}
|
||||
if (pending.length === 0) return;
|
||||
|
||||
const queue = [...pending];
|
||||
const worker = async () => {
|
||||
for (;;) {
|
||||
const mod = queue.shift();
|
||||
if (!mod) return;
|
||||
try {
|
||||
const detail = await this.getMod(mod.id);
|
||||
mod.imageUrl = detail.imageUrl;
|
||||
} catch {
|
||||
mod.imageUrl = null;
|
||||
}
|
||||
this.imageCache.set(mod.id, {
|
||||
url: mod.imageUrl,
|
||||
expiresAt: Date.now() + IMAGE_CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
};
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(IMAGE_FETCH_CONCURRENCY, queue.length) }, () => worker()),
|
||||
);
|
||||
if (this.imageCache.size > 5_000) {
|
||||
for (const [key, value] of this.imageCache) {
|
||||
if (value.expiresAt <= now) this.imageCache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getMod(modId: string): Promise<WorkshopModDetail> {
|
||||
const raw = await this.get(`/v1/mod/${encodeURIComponent(modId)}`);
|
||||
const parsed = modDetailEnvelopeSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
throw ApiError.upstream('Workshop API returned an unexpected response shape.');
|
||||
}
|
||||
const mod = parsed.data.mod;
|
||||
return {
|
||||
id: mod.id,
|
||||
name: mod.name,
|
||||
author: mod.author,
|
||||
imageUrl: normalizeImageUrl(mod.imageURL),
|
||||
size: mod.size || null,
|
||||
rating: mod.rating || null,
|
||||
workshopUrl: mod.originalModURL || null,
|
||||
version: mod.version ?? null,
|
||||
gameVersion: mod.gameVersion ?? null,
|
||||
subscribers: mod.subscribers ?? null,
|
||||
downloads: mod.downloads ?? null,
|
||||
createdAtText: mod.created ?? null,
|
||||
lastModifiedText: mod.lastModified ?? null,
|
||||
summary: mod.summary ?? null,
|
||||
description: mod.description ?? null,
|
||||
license: mod.license ?? null,
|
||||
tags: mod.tags,
|
||||
dependencies: mod.dependencies.map((dep) => ({
|
||||
name: dep.name,
|
||||
id: extractModId(dep.apiModURL),
|
||||
})),
|
||||
scenarios: mod.scenarios.map((scenario) => ({
|
||||
name: scenario.name,
|
||||
description: scenario.description || null,
|
||||
scenarioId: scenario.scenarioID,
|
||||
gamemode: scenario.gamemode || null,
|
||||
playerCount: scenario.playerCount || null,
|
||||
imageUrl: normalizeImageUrl(scenario.imageURL),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { ApiError } from '../../lib/errors.js';
|
||||
import { rateLimit } from '../../lib/rate-limit.js';
|
||||
import { requireAuth } from '../auth/auth-middleware.js';
|
||||
import type { WorkshopClient } from './workshop-client.js';
|
||||
|
||||
const searchQuerySchema = z.object({
|
||||
q: z.string().trim().max(100).default(''),
|
||||
page: z.coerce.number().int().min(1).max(10_000).default(1),
|
||||
sort: z.enum(['popularity', 'newest', 'subscribers', 'version_size']).optional(),
|
||||
});
|
||||
|
||||
const modIdSchema = z.string().regex(/^[A-Za-z0-9]{1,32}$/, 'Invalid mod id.');
|
||||
|
||||
export function createWorkshopRouter(client: WorkshopClient): Router {
|
||||
const router = Router();
|
||||
// The upstream allows 60 req/min per IP; stay well under it.
|
||||
const workshopRateLimit = rateLimit({ windowMs: 60_000, max: 30, keyPrefix: 'workshop' });
|
||||
|
||||
router.use(requireAuth, workshopRateLimit);
|
||||
|
||||
router.get('/health', async (_req, res, next) => {
|
||||
try {
|
||||
res.json(await client.health());
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/search', async (req, res, next) => {
|
||||
try {
|
||||
const parsed = searchQuerySchema.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
throw ApiError.validation('Invalid search parameters.');
|
||||
}
|
||||
const { q, page, sort } = parsed.data;
|
||||
res.json(await client.search(q, page, sort));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/mods/:id', async (req, res, next) => {
|
||||
try {
|
||||
const parsed = modIdSchema.safeParse(req.params.id);
|
||||
if (!parsed.success) {
|
||||
throw ApiError.validation('Invalid mod id.');
|
||||
}
|
||||
res.json(await client.getMod(parsed.data));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Role } from '@reforger-panel/shared';
|
||||
import { createApp } from '../src/app.js';
|
||||
import { loadEnv } from '../src/env.js';
|
||||
import { createLogger } from '../src/lib/logger.js';
|
||||
import type { Db } from '../src/db/client.js';
|
||||
import {
|
||||
resolveRoleForLogin,
|
||||
type SessionService,
|
||||
type SessionUser,
|
||||
} from '../src/modules/auth/session-service.js';
|
||||
import type { ServerModsService } from '../src/modules/config/mods-service.js';
|
||||
import type { PerformanceSettingsService } from '../src/modules/config/performance-service.js';
|
||||
import type { ResourceHistoryService } from '../src/modules/servers/resource-history.js';
|
||||
import { MockGameServerProvider } from '../src/modules/pterodactyl/mock-provider.js';
|
||||
import type { IngestionScheduler } from '../src/modules/reforger-logs/ingestion/scheduler.js';
|
||||
import type { ServerRecord, ServerService } from '../src/modules/servers/server-service.js';
|
||||
import { WorkshopClient } from '../src/modules/workshop/workshop-client.js';
|
||||
|
||||
const OWNER_ID = '111111111111111111';
|
||||
|
||||
const TEST_ENV = {
|
||||
NODE_ENV: 'test',
|
||||
DATABASE_URL: 'postgresql://unused',
|
||||
SESSION_SECRET: 'a'.repeat(40),
|
||||
OWNER_DISCORD_ID: OWNER_ID,
|
||||
USE_MOCK_PTERODACTYL: 'true',
|
||||
};
|
||||
|
||||
function makeUser(role: Role): SessionUser {
|
||||
return {
|
||||
id: `user-${role}`,
|
||||
discordId: `discord-${role}`,
|
||||
username: role,
|
||||
displayName: role,
|
||||
avatarUrl: null,
|
||||
role,
|
||||
};
|
||||
}
|
||||
|
||||
const TOKENS: Record<string, SessionUser> = {
|
||||
'owner-token': makeUser('owner'),
|
||||
'admin-token': makeUser('server_admin'),
|
||||
'lead-token': makeUser('mission_lead'),
|
||||
'viewer-token': makeUser('viewer'),
|
||||
};
|
||||
|
||||
const trainingServer: ServerRecord = {
|
||||
id: 'srv-1',
|
||||
slug: 'training-server',
|
||||
name: 'Training Server',
|
||||
providerType: 'pterodactyl',
|
||||
pterodactylServerId: null,
|
||||
status: 'online',
|
||||
maxPlayers: 20,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
function buildApp() {
|
||||
const env = loadEnv(TEST_ENV as NodeJS.ProcessEnv);
|
||||
const provider = new MockGameServerProvider();
|
||||
const activity: { action: string; actorUserId: string | null }[] = [];
|
||||
|
||||
const sessions = {
|
||||
getUserBySessionToken: async (token: string) => TOKENS[token] ?? null,
|
||||
revokeSession: async () => undefined,
|
||||
} as unknown as SessionService;
|
||||
|
||||
const servers = {
|
||||
getServerBySlug: async (slug: string) => (slug === 'training-server' ? trainingServer : null),
|
||||
listServers: async () => [trainingServer],
|
||||
countOnlinePlayers: async () => 0,
|
||||
updateStatus: async () => undefined,
|
||||
recordActivity: async (input: { action: string; actorUserId: string | null }) => {
|
||||
activity.push(input);
|
||||
},
|
||||
getOnlinePlayers: async () => ({
|
||||
players: [],
|
||||
onlineCount: 0,
|
||||
maxPlayers: 20,
|
||||
lastSyncedAt: null,
|
||||
stale: true,
|
||||
}),
|
||||
getActivity: async () => [],
|
||||
getConfiguration: async () => ({ current: null, history: [] }),
|
||||
getModPacks: async () => [],
|
||||
getKnownPlayers: async () => [],
|
||||
getLogCursor: async () => null,
|
||||
} as unknown as ServerService;
|
||||
|
||||
const scheduler = {
|
||||
syncNow: async () => ({
|
||||
serverId: 'srv-1',
|
||||
logPath: '/profile/logs/console.log',
|
||||
fetchedBytes: 0,
|
||||
processedLines: 0,
|
||||
createdEvents: 0,
|
||||
updatedSessions: 0,
|
||||
cursorReset: false,
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: new Date().toISOString(),
|
||||
ignoredLines: 0,
|
||||
invalidTimestamps: 0,
|
||||
reason: 'no_new_data',
|
||||
}),
|
||||
getLastResult: () => null,
|
||||
} as unknown as IngestionScheduler;
|
||||
|
||||
const app = createApp({
|
||||
env,
|
||||
logger: createLogger('silent'),
|
||||
db: {} as Db,
|
||||
sessions,
|
||||
servers,
|
||||
provider,
|
||||
workshop: new WorkshopClient({ baseUrl: 'https://workshop.invalid' }),
|
||||
scheduler,
|
||||
resolveLogPath: async () => '/profile/logs/console.log',
|
||||
configSync: null,
|
||||
mods: {
|
||||
getMods: async () => ({ mods: [], fetchedAt: new Date().toISOString() }),
|
||||
setMods: async () => ({
|
||||
mods: [],
|
||||
fetchedAt: new Date().toISOString(),
|
||||
added: 0,
|
||||
removed: 0,
|
||||
requiresRestart: true as const,
|
||||
}),
|
||||
} as unknown as ServerModsService,
|
||||
performance: {
|
||||
get: async () => ({ settings: {}, fetchedAt: new Date().toISOString() }),
|
||||
update: async (_server: unknown, settings: unknown) => ({
|
||||
settings,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
changedFields: [],
|
||||
requiresRestart: true as const,
|
||||
}),
|
||||
} as unknown as PerformanceSettingsService,
|
||||
resourceHistory: {
|
||||
history: () => ({ samples: [], intervalSeconds: 15 }),
|
||||
} as unknown as ResourceHistoryService,
|
||||
missions: null,
|
||||
});
|
||||
return { app, provider, activity };
|
||||
}
|
||||
|
||||
function asUser(token: string) {
|
||||
return { Cookie: `rp_session=${token}`, 'X-CSRF-Protection': '1' };
|
||||
}
|
||||
|
||||
describe('authentication and roles', () => {
|
||||
it('rejects unauthenticated requests to /api/auth/me', async () => {
|
||||
const { app } = buildApp();
|
||||
const response = await request(app).get('/api/auth/me');
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error.code).toBe('UNAUTHENTICATED');
|
||||
});
|
||||
|
||||
it('returns the current user with capabilities', async () => {
|
||||
const { app } = buildApp();
|
||||
const response = await request(app).get('/api/auth/me').set(asUser('viewer-token'));
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.role).toBe('viewer');
|
||||
expect(response.body.capabilities).toEqual(['server.view']);
|
||||
});
|
||||
|
||||
it('bootstraps the owner role from OWNER_DISCORD_ID and defaults others to viewer', () => {
|
||||
expect(resolveRoleForLogin(null, OWNER_ID, OWNER_ID)).toBe('owner');
|
||||
expect(resolveRoleForLogin('viewer', OWNER_ID, OWNER_ID)).toBe('owner');
|
||||
expect(resolveRoleForLogin(null, '222', OWNER_ID)).toBe('viewer');
|
||||
expect(resolveRoleForLogin('server_admin', '222', OWNER_ID)).toBe('server_admin');
|
||||
// No owner configured: nobody is silently promoted.
|
||||
expect(resolveRoleForLogin(null, '', '')).toBe('viewer');
|
||||
});
|
||||
|
||||
it('requires authentication on server routes', async () => {
|
||||
const { app } = buildApp();
|
||||
const response = await request(app).get('/api/servers');
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('power controls by role', () => {
|
||||
const cases: { token: string; action: string; expected: number }[] = [
|
||||
{ token: 'owner-token', action: 'start', expected: 200 },
|
||||
{ token: 'owner-token', action: 'stop', expected: 200 },
|
||||
{ token: 'owner-token', action: 'restart', expected: 200 },
|
||||
{ token: 'admin-token', action: 'start', expected: 200 },
|
||||
{ token: 'admin-token', action: 'stop', expected: 200 },
|
||||
{ token: 'admin-token', action: 'restart', expected: 200 },
|
||||
{ token: 'lead-token', action: 'restart', expected: 200 },
|
||||
{ token: 'lead-token', action: 'start', expected: 403 },
|
||||
{ token: 'lead-token', action: 'stop', expected: 403 },
|
||||
{ token: 'viewer-token', action: 'start', expected: 403 },
|
||||
{ token: 'viewer-token', action: 'stop', expected: 403 },
|
||||
{ token: 'viewer-token', action: 'restart', expected: 403 },
|
||||
];
|
||||
|
||||
for (const { token, action, expected } of cases) {
|
||||
it(`${token.replace('-token', '')} ${action} → ${expected}`, async () => {
|
||||
const { app } = buildApp();
|
||||
const response = await request(app)
|
||||
.post(`/api/servers/training-server/power/${action}`)
|
||||
.set(asUser(token));
|
||||
expect(response.status).toBe(expected);
|
||||
if (expected === 403) {
|
||||
expect(response.body.error.code).toBe('FORBIDDEN');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('simulated power actions still create activity records', async () => {
|
||||
const { app, activity } = buildApp();
|
||||
await request(app)
|
||||
.post('/api/servers/training-server/power/restart')
|
||||
.set(asUser('lead-token'))
|
||||
.expect(200);
|
||||
expect(activity).toHaveLength(1);
|
||||
expect(activity[0]!.action).toBe('server.power.restart');
|
||||
});
|
||||
});
|
||||
|
||||
describe('manual log sync and diagnostics', () => {
|
||||
it('allows only the owner to trigger a manual sync', async () => {
|
||||
const { app } = buildApp();
|
||||
await request(app)
|
||||
.post('/api/servers/training-server/logs/sync')
|
||||
.set(asUser('owner-token'))
|
||||
.expect(200);
|
||||
for (const token of ['admin-token', 'lead-token', 'viewer-token']) {
|
||||
const response = await request(app)
|
||||
.post('/api/servers/training-server/logs/sync')
|
||||
.set(asUser(token));
|
||||
expect(response.status).toBe(403);
|
||||
}
|
||||
});
|
||||
|
||||
it('hides log ingestion health from mission leads and viewers', async () => {
|
||||
const { app } = buildApp();
|
||||
await request(app)
|
||||
.get('/api/servers/training-server/logs/health')
|
||||
.set(asUser('admin-token'))
|
||||
.expect(200);
|
||||
await request(app)
|
||||
.get('/api/servers/training-server/logs/health')
|
||||
.set(asUser('lead-token'))
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('restricts user management to the owner', async () => {
|
||||
const { app } = buildApp();
|
||||
const response = await request(app).get('/api/users').set(asUser('viewer-token'));
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mod management by role', () => {
|
||||
it('allows owner and server_admin to update mods', async () => {
|
||||
const { app } = buildApp();
|
||||
for (const token of ['owner-token', 'admin-token']) {
|
||||
const response = await request(app)
|
||||
.put('/api/servers/training-server/mods')
|
||||
.set(asUser(token))
|
||||
.send({ mods: [{ modId: '591AF5BDA9F7CE8B', name: 'X' }] });
|
||||
expect(response.status).toBe(200);
|
||||
}
|
||||
});
|
||||
|
||||
it('forbids mission leads and viewers from updating mods', async () => {
|
||||
const { app } = buildApp();
|
||||
for (const token of ['lead-token', 'viewer-token']) {
|
||||
const response = await request(app)
|
||||
.put('/api/servers/training-server/mods')
|
||||
.set(asUser(token))
|
||||
.send({ mods: [] });
|
||||
expect(response.status).toBe(403);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid mod ids and duplicates', async () => {
|
||||
const { app } = buildApp();
|
||||
const bad = await request(app)
|
||||
.put('/api/servers/training-server/mods')
|
||||
.set(asUser('owner-token'))
|
||||
.send({ mods: [{ modId: 'not-a-mod-id' }] });
|
||||
expect(bad.status).toBe(400);
|
||||
|
||||
const dup = await request(app)
|
||||
.put('/api/servers/training-server/mods')
|
||||
.set(asUser('owner-token'))
|
||||
.send({
|
||||
mods: [{ modId: '591AF5BDA9F7CE8B' }, { modId: '591af5bda9f7ce8b' }],
|
||||
});
|
||||
expect(dup.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('schedule management by role', () => {
|
||||
it('allows owner and server_admin to view schedules', async () => {
|
||||
const { app } = buildApp();
|
||||
await request(app)
|
||||
.get('/api/servers/training-server/schedules')
|
||||
.set(asUser('owner-token'))
|
||||
.expect(200);
|
||||
await request(app)
|
||||
.get('/api/servers/training-server/schedules')
|
||||
.set(asUser('admin-token'))
|
||||
.expect(200);
|
||||
await request(app)
|
||||
.get('/api/servers/training-server/schedules')
|
||||
.set(asUser('lead-token'))
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('creates restart schedules through the provider and records activity', async () => {
|
||||
const { app, activity } = buildApp();
|
||||
const response = await request(app)
|
||||
.post('/api/servers/training-server/schedules/restarts')
|
||||
.set(asUser('admin-token'))
|
||||
.send({
|
||||
name: 'Morning restart',
|
||||
isActive: true,
|
||||
minute: 30,
|
||||
hour: 8,
|
||||
dayOfWeek: '*',
|
||||
onlyWhenOnline: true,
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.schedule.name).toBe('Morning restart');
|
||||
expect(response.body.schedule.tasks[0].payload).toBe('restart');
|
||||
expect(activity.at(-1)?.action).toBe('schedule.restart.created');
|
||||
});
|
||||
});
|
||||
|
||||
describe('performance config by role', () => {
|
||||
const validBody = {
|
||||
maxPlayers: 32,
|
||||
serverMaxViewDistance: null,
|
||||
networkViewDistance: null,
|
||||
serverMinGrassDistance: null,
|
||||
disableThirdPerson: null,
|
||||
fastValidation: null,
|
||||
battlEye: null,
|
||||
aiLimit: null,
|
||||
playerSaveTime: null,
|
||||
slotReservationTimeout: null,
|
||||
lobbyPlayerSynchronise: null,
|
||||
};
|
||||
|
||||
it('allows owner and server_admin, forbids mission_lead and viewer', async () => {
|
||||
const { app } = buildApp();
|
||||
for (const token of ['owner-token', 'admin-token']) {
|
||||
await request(app)
|
||||
.put('/api/servers/training-server/config/performance')
|
||||
.set(asUser(token))
|
||||
.send(validBody)
|
||||
.expect(200);
|
||||
}
|
||||
for (const token of ['lead-token', 'viewer-token']) {
|
||||
await request(app)
|
||||
.put('/api/servers/training-server/config/performance')
|
||||
.set(asUser(token))
|
||||
.send(validBody)
|
||||
.expect(403);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects out-of-range values with the offending field named', async () => {
|
||||
const { app } = buildApp();
|
||||
const response = await request(app)
|
||||
.put('/api/servers/training-server/config/performance')
|
||||
.set(asUser('owner-token'))
|
||||
.send({ ...validBody, serverMaxViewDistance: 99999 });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toContain('serverMaxViewDistance');
|
||||
});
|
||||
|
||||
it('serves resource history to any authenticated user', async () => {
|
||||
const { app } = buildApp();
|
||||
const response = await request(app)
|
||||
.get('/api/servers/training-server/resources/history')
|
||||
.set(asUser('viewer-token'));
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.intervalSeconds).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invites', () => {
|
||||
it('restricts invite management to the owner', async () => {
|
||||
const { app } = buildApp();
|
||||
for (const token of ['admin-token', 'lead-token', 'viewer-token']) {
|
||||
const response = await request(app).get('/api/invites').set(asUser(token));
|
||||
expect(response.status).toBe(403);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects malformed redeem codes before touching the database', async () => {
|
||||
const { app } = buildApp();
|
||||
const response = await request(app)
|
||||
.post('/api/invites/redeem')
|
||||
.set(asUser('viewer-token'))
|
||||
.send({ code: '' });
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSRF protection', () => {
|
||||
it('rejects state-changing requests without the CSRF header', async () => {
|
||||
const { app } = buildApp();
|
||||
const response = await request(app)
|
||||
.post('/api/servers/training-server/power/restart')
|
||||
.set('Cookie', 'rp_session=owner-token');
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects cross-origin state-changing requests', async () => {
|
||||
const { app } = buildApp();
|
||||
const response = await request(app)
|
||||
.post('/api/servers/training-server/power/restart')
|
||||
.set(asUser('owner-token'))
|
||||
.set('Origin', 'https://evil.example.com');
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type {
|
||||
CursorRecord,
|
||||
IngestionStore,
|
||||
LogSource,
|
||||
NewServerEvent,
|
||||
OpenSessionRecord,
|
||||
PlayerRecord,
|
||||
} from '../../src/modules/reforger-logs/ingestion/types.js';
|
||||
import type { DownloadableFile } from '../../src/modules/pterodactyl/types.js';
|
||||
|
||||
export type StoredSession = {
|
||||
id: string;
|
||||
serverId: string;
|
||||
playerId: string;
|
||||
connectedAt: Date;
|
||||
disconnectedAt: Date | null;
|
||||
durationSeconds: number | null;
|
||||
disconnectReason: string | null;
|
||||
sourceLogPath: string;
|
||||
};
|
||||
|
||||
export type StoredEvent = NewServerEvent & { id: string };
|
||||
|
||||
export class InMemoryIngestionStore implements IngestionStore {
|
||||
cursors = new Map<string, CursorRecord>();
|
||||
events: StoredEvent[] = [];
|
||||
players: (PlayerRecord & { firstSeenAt: Date; lastSeenAt: Date })[] = [];
|
||||
sessions: StoredSession[] = [];
|
||||
|
||||
async getCursor(serverId: string, logPath: string) {
|
||||
return this.cursors.get(`${serverId}:${logPath}`) ?? null;
|
||||
}
|
||||
|
||||
async saveCursor(cursor: CursorRecord) {
|
||||
this.cursors.set(`${cursor.serverId}:${cursor.logPath}`, { ...cursor });
|
||||
}
|
||||
|
||||
async insertEventIfNew(event: NewServerEvent) {
|
||||
const duplicate = this.events.find(
|
||||
(e) =>
|
||||
e.serverId === event.serverId &&
|
||||
e.sourceLogPath === event.sourceLogPath &&
|
||||
e.sourceLineHash === event.sourceLineHash,
|
||||
);
|
||||
if (duplicate) return { created: false, eventId: null };
|
||||
const stored = { ...event, id: randomUUID() };
|
||||
this.events.push(stored);
|
||||
return { created: true, eventId: stored.id };
|
||||
}
|
||||
|
||||
async findPlayerByExternalId(serverId: string, externalPlayerId: string) {
|
||||
return (
|
||||
this.players.find(
|
||||
(p) => p.serverId === serverId && p.externalPlayerId === externalPlayerId,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async findPlayerByName(serverId: string, displayName: string) {
|
||||
return (
|
||||
this.players.find((p) => p.serverId === serverId && p.displayName === displayName) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async createPlayer(input: {
|
||||
serverId: string;
|
||||
displayName: string;
|
||||
externalPlayerId: string | null;
|
||||
seenAt: Date;
|
||||
}) {
|
||||
const player = {
|
||||
id: randomUUID(),
|
||||
serverId: input.serverId,
|
||||
displayName: input.displayName,
|
||||
externalPlayerId: input.externalPlayerId,
|
||||
firstSeenAt: input.seenAt,
|
||||
lastSeenAt: input.seenAt,
|
||||
};
|
||||
this.players.push(player);
|
||||
return player;
|
||||
}
|
||||
|
||||
async updatePlayer(
|
||||
playerId: string,
|
||||
patch: { externalPlayerId?: string; displayName?: string; lastSeenAt?: Date },
|
||||
) {
|
||||
const player = this.players.find((p) => p.id === playerId);
|
||||
if (player) Object.assign(player, patch);
|
||||
}
|
||||
|
||||
async getOpenSession(serverId: string, playerId: string): Promise<OpenSessionRecord | null> {
|
||||
const session = this.sessions.find(
|
||||
(s) => s.serverId === serverId && s.playerId === playerId && s.disconnectedAt === null,
|
||||
);
|
||||
return session
|
||||
? { id: session.id, playerId: session.playerId, connectedAt: session.connectedAt }
|
||||
: null;
|
||||
}
|
||||
|
||||
async openSession(input: {
|
||||
serverId: string;
|
||||
playerId: string;
|
||||
connectedAt: Date;
|
||||
sourceLogPath: string;
|
||||
}) {
|
||||
const session: StoredSession = {
|
||||
id: randomUUID(),
|
||||
serverId: input.serverId,
|
||||
playerId: input.playerId,
|
||||
connectedAt: input.connectedAt,
|
||||
disconnectedAt: null,
|
||||
durationSeconds: null,
|
||||
disconnectReason: null,
|
||||
sourceLogPath: input.sourceLogPath,
|
||||
};
|
||||
this.sessions.push(session);
|
||||
return { id: session.id, playerId: session.playerId, connectedAt: session.connectedAt };
|
||||
}
|
||||
|
||||
async closeSession(
|
||||
sessionId: string,
|
||||
input: { disconnectedAt: Date; durationSeconds: number; disconnectReason: string | null },
|
||||
) {
|
||||
const session = this.sessions.find((s) => s.id === sessionId);
|
||||
if (session) Object.assign(session, input);
|
||||
}
|
||||
|
||||
async closeAllOpenSessions(serverId: string, disconnectedAt: Date, reason: string) {
|
||||
const open = this.sessions.filter((s) => s.serverId === serverId && s.disconnectedAt === null);
|
||||
for (const session of open) {
|
||||
session.disconnectedAt = disconnectedAt;
|
||||
session.durationSeconds = Math.max(
|
||||
0,
|
||||
Math.round((disconnectedAt.getTime() - session.connectedAt.getTime()) / 1000),
|
||||
);
|
||||
session.disconnectReason = reason;
|
||||
}
|
||||
return { closed: open.length };
|
||||
}
|
||||
|
||||
openSessions(serverId: string) {
|
||||
return this.sessions.filter((s) => s.serverId === serverId && s.disconnectedAt === null);
|
||||
}
|
||||
}
|
||||
|
||||
/** LogSource whose content can be mutated between syncs to simulate a live file. */
|
||||
export class FakeLogSource implements LogSource {
|
||||
content = '';
|
||||
failWith: Error | null = null;
|
||||
|
||||
async fetchLog(_serverId: string, logPath: string, maxBytes: number): Promise<DownloadableFile> {
|
||||
if (this.failWith) throw this.failWith;
|
||||
const buffer = Buffer.from(this.content, 'utf8');
|
||||
const trimmed =
|
||||
buffer.byteLength > maxBytes ? buffer.subarray(buffer.byteLength - maxBytes) : buffer;
|
||||
return {
|
||||
path: logPath,
|
||||
content: trimmed.toString('utf8'),
|
||||
totalSizeBytes: buffer.byteLength,
|
||||
contentStartOffset: buffer.byteLength - trimmed.byteLength,
|
||||
truncated: trimmed.byteLength < buffer.byteLength,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { LogIngestionService } from '../src/modules/reforger-logs/ingestion/ingestion-service.js';
|
||||
import { createLogger } from '../src/lib/logger.js';
|
||||
import { FakeLogSource, InMemoryIngestionStore } from './helpers/in-memory-ingestion-store.js';
|
||||
|
||||
const SERVER_ID = 'srv-1';
|
||||
const PROVIDER_ID = 'ptero-1';
|
||||
const LOG_PATH = '/profile/logs/console.log';
|
||||
|
||||
const HEADER = 'Log started 2026-07-04 10:00:00\n';
|
||||
|
||||
function connect(time: string, num: number, name: string): string {
|
||||
return `${time} DEFAULT : BattlEye Server: 'Player #${num} ${name} (10.0.0.${num}:5000) connected'\n`;
|
||||
}
|
||||
function guid(time: string, num: number, name: string, id: string): string {
|
||||
return `${time} DEFAULT : BattlEye Server: 'Player #${num} ${name} - GUID: ${id}'\n`;
|
||||
}
|
||||
function disconnect(time: string, num: number, name: string): string {
|
||||
return `${time} DEFAULT : BattlEye Server: 'Player #${num} ${name} disconnected'\n`;
|
||||
}
|
||||
function kill(time: string, victim: string, killer: string, friendly = false): string {
|
||||
return `${time} SCRIPT : ServerAdminTools | Event serveradmintools_player_killed | player: ${victim}, instigator: ${killer}, friendly: ${friendly ? 'true' : 'false'}\n`;
|
||||
}
|
||||
|
||||
describe('LogIngestionService', () => {
|
||||
let store: InMemoryIngestionStore;
|
||||
let source: FakeLogSource;
|
||||
let service: LogIngestionService;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new InMemoryIngestionStore();
|
||||
source = new FakeLogSource();
|
||||
service = new LogIngestionService(source, store, createLogger('silent'), {
|
||||
maxDownloadBytes: 2 * 1024 * 1024,
|
||||
});
|
||||
});
|
||||
|
||||
const sync = () => service.sync(SERVER_ID, PROVIDER_ID, LOG_PATH);
|
||||
|
||||
it('creates players, events, and open sessions from connect lines', async () => {
|
||||
source.content =
|
||||
HEADER +
|
||||
connect('10:05:00.000', 1, 'Braeden') +
|
||||
guid('10:05:01.000', 1, 'Braeden', 'aabbccdd11223344');
|
||||
const result = await sync();
|
||||
|
||||
expect(result.createdEvents).toBe(1);
|
||||
expect(store.players).toHaveLength(1);
|
||||
expect(store.players[0]!.externalPlayerId).toBe('aabbccdd11223344');
|
||||
expect(store.openSessions(SERVER_ID)).toHaveLength(1);
|
||||
expect(result.cursorReset).toBe(false);
|
||||
});
|
||||
|
||||
it('closes sessions with duration on disconnect', async () => {
|
||||
source.content =
|
||||
HEADER + connect('10:00:10.000', 1, 'Braeden') + disconnect('10:42:10.000', 1, 'Braeden');
|
||||
await sync();
|
||||
|
||||
expect(store.openSessions(SERVER_ID)).toHaveLength(0);
|
||||
const session = store.sessions[0]!;
|
||||
expect(session.durationSeconds).toBe(42 * 60);
|
||||
});
|
||||
|
||||
it('tracks multiple simultaneous players', async () => {
|
||||
source.content =
|
||||
HEADER +
|
||||
connect('10:01:00.000', 1, 'Alpha') +
|
||||
connect('10:02:00.000', 2, 'Bravo') +
|
||||
connect('10:03:00.000', 3, 'Charlie') +
|
||||
disconnect('10:30:00.000', 2, 'Bravo');
|
||||
await sync();
|
||||
|
||||
expect(store.players).toHaveLength(3);
|
||||
const open = store.openSessions(SERVER_ID);
|
||||
expect(open).toHaveLength(2);
|
||||
const openNames = open.map((s) => store.players.find((p) => p.id === s.playerId)!.displayName);
|
||||
expect(openNames.sort()).toEqual(['Alpha', 'Charlie']);
|
||||
});
|
||||
|
||||
it('stores ServerAdminTools killfeed events', async () => {
|
||||
source.content = HEADER + kill('10:10:00.000', 'Victim', 'Killer');
|
||||
const result = await sync();
|
||||
|
||||
expect(result.createdEvents).toBe(1);
|
||||
expect(store.events[0]!.eventType).toBe('player_killed');
|
||||
expect(store.events[0]!.payload).toMatchObject({
|
||||
killerName: 'Killer',
|
||||
victimName: 'Victim',
|
||||
friendly: false,
|
||||
distanceMeters: null,
|
||||
weapon: null,
|
||||
});
|
||||
expect(store.players.map((player) => player.displayName).sort()).toEqual(['Killer', 'Victim']);
|
||||
});
|
||||
|
||||
it('does not duplicate events when the same content is synced twice', async () => {
|
||||
source.content = HEADER + connect('10:05:00.000', 1, 'Braeden');
|
||||
await sync();
|
||||
// Force a cursor reset by clearing the cursor: same lines get re-read.
|
||||
store.cursors.clear();
|
||||
const second = await sync();
|
||||
|
||||
expect(second.createdEvents).toBe(0);
|
||||
expect(store.events).toHaveLength(1);
|
||||
expect(store.openSessions(SERVER_ID)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('processes only appended content on subsequent syncs', async () => {
|
||||
source.content = HEADER + connect('10:05:00.000', 1, 'Braeden');
|
||||
const first = await sync();
|
||||
source.content += disconnect('10:45:00.000', 1, 'Braeden');
|
||||
const second = await sync();
|
||||
|
||||
expect(first.createdEvents).toBe(1);
|
||||
expect(second.createdEvents).toBe(1);
|
||||
expect(second.cursorReset).toBe(false);
|
||||
expect(second.processedLines).toBe(1);
|
||||
expect(store.openSessions(SERVER_ID)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('carries a partial trailing line across syncs and parses it once complete', async () => {
|
||||
const full = connect('10:05:00.000', 1, 'Braeden');
|
||||
source.content = HEADER + full.slice(0, 40); // mid-line
|
||||
const first = await sync();
|
||||
expect(first.createdEvents).toBe(0);
|
||||
|
||||
source.content = HEADER + full;
|
||||
const second = await sync();
|
||||
expect(second.createdEvents).toBe(1);
|
||||
expect(store.events[0]!.eventType).toBe('player_connected');
|
||||
});
|
||||
|
||||
it('handles log rotation: resets the cursor and ingests the new file without duplicates', async () => {
|
||||
source.content =
|
||||
HEADER + connect('10:05:00.000', 1, 'Braeden') + disconnect('11:00:00.000', 1, 'Braeden');
|
||||
await sync();
|
||||
expect(store.events).toHaveLength(2);
|
||||
|
||||
// Rotation: much smaller replacement file with a fresh header.
|
||||
source.content = 'Log started 2026-07-04 11:30:00\n' + connect('11:31:00.000', 1, 'Sable');
|
||||
const afterRotation = await sync();
|
||||
|
||||
expect(afterRotation.cursorReset).toBe(true);
|
||||
expect(afterRotation.createdEvents).toBe(1);
|
||||
expect(store.events).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('bounds the first sync of a huge file instead of importing full history', async () => {
|
||||
let old = HEADER;
|
||||
for (let i = 0; i < 30_000; i += 1) {
|
||||
old += `09:00:00.000 SCRIPT : filler line ${i} ${'x'.repeat(20)}\n`;
|
||||
}
|
||||
source.content = old + connect('10:05:00.000', 1, 'Braeden');
|
||||
const result = await sync();
|
||||
|
||||
expect(result.createdEvents).toBe(1);
|
||||
expect(result.processedLines).toBeLessThan(30_000);
|
||||
});
|
||||
|
||||
it('closes orphaned sessions when a server start is detected', async () => {
|
||||
source.content = HEADER + connect('10:05:00.000', 1, 'Braeden');
|
||||
await sync();
|
||||
expect(store.openSessions(SERVER_ID)).toHaveLength(1);
|
||||
|
||||
source.content += '11:00:00.000 DEFAULT : Game successfully created.\n';
|
||||
const result = await sync();
|
||||
|
||||
expect(store.openSessions(SERVER_ID)).toHaveLength(0);
|
||||
expect(store.sessions[0]!.disconnectReason).toBe('server_restart');
|
||||
expect(store.events.map((e) => e.eventType)).toContain('server_restart_detected');
|
||||
expect(result.updatedSessions).toBe(1);
|
||||
});
|
||||
|
||||
it('closes a stale session when the same player reconnects without a disconnect', async () => {
|
||||
source.content = HEADER + connect('10:05:00.000', 1, 'Braeden');
|
||||
await sync();
|
||||
source.content += connect('12:00:00.000', 4, 'Braeden');
|
||||
await sync();
|
||||
|
||||
const open = store.openSessions(SERVER_ID);
|
||||
expect(open).toHaveLength(1);
|
||||
expect(open[0]!.connectedAt.toISOString()).toBe('2026-07-04T12:00:00.000Z');
|
||||
const closed = store.sessions.find((s) => s.disconnectedAt !== null)!;
|
||||
expect(closed.disconnectReason).toBe('missed_disconnect');
|
||||
});
|
||||
|
||||
it('records a sanitized error on the cursor when the download fails', async () => {
|
||||
source.failWith = new Error('connect ETIMEDOUT 10.1.2.3:443 with apiKey=secret123');
|
||||
await expect(sync()).rejects.toThrow();
|
||||
|
||||
const cursor = await store.getCursor(SERVER_ID, LOG_PATH);
|
||||
expect(cursor?.lastErrorAt).toBeInstanceOf(Date);
|
||||
expect(cursor?.lastErrorMessage).not.toContain('secret123');
|
||||
expect(cursor?.lastSuccessfulSyncAt).toBeNull();
|
||||
|
||||
// Recovery: the next successful sync clears the error.
|
||||
source.failWith = null;
|
||||
source.content = HEADER + connect('10:05:00.000', 1, 'Braeden');
|
||||
await sync();
|
||||
const recovered = await store.getCursor(SERVER_ID, LOG_PATH);
|
||||
expect(recovered?.lastErrorAt).toBeNull();
|
||||
expect(recovered?.lastSuccessfulSyncAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('keeps one player when the logs emit both identityId and BattlEye GUID', async () => {
|
||||
// Real logs emit BACKEND Authenticated (uuid) then the BE GUID line.
|
||||
source.content =
|
||||
HEADER +
|
||||
'10:04:59.941 BACKEND : Authenticated player: rplIdentity=0x00000000 identityId=33cd5666-3466-477c-aeb8-010df1978756 name=Braeden\n' +
|
||||
connect('10:05:00.000', 1, 'Braeden') +
|
||||
guid('10:05:01.000', 1, 'Braeden', '8f1ec46b6979b3a3590e62aa8b757a68');
|
||||
await sync();
|
||||
|
||||
expect(store.players).toHaveLength(1);
|
||||
// First identity wins; the GUID does not split the player.
|
||||
expect(store.players[0]!.externalPlayerId).toBe('33cd5666-3466-477c-aeb8-010df1978756');
|
||||
expect(store.openSessions(SERVER_ID)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('merges identity lines that arrive in a later sync via open-session name match', async () => {
|
||||
source.content = HEADER + connect('10:05:00.000', 1, 'Braeden');
|
||||
await sync();
|
||||
source.content += guid('10:05:02.000', 1, 'Braeden', 'deadbeef00000001');
|
||||
await sync();
|
||||
|
||||
expect(store.players).toHaveLength(1);
|
||||
expect(store.players[0]!.externalPlayerId).toBe('deadbeef00000001');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { MockGameServerProvider } from '../src/modules/pterodactyl/mock-provider.js';
|
||||
import { ConfigFileGateway } from '../src/modules/config/config-file-gateway.js';
|
||||
import { ServerModsService } from '../src/modules/config/mods-service.js';
|
||||
import type { ConfigSyncService } from '../src/modules/config/config-sync.js';
|
||||
import type { ServerRecord } from '../src/modules/servers/server-service.js';
|
||||
import { createLogger } from '../src/lib/logger.js';
|
||||
import { ApiError } from '../src/lib/errors.js';
|
||||
|
||||
const server: ServerRecord = {
|
||||
id: 'srv-1',
|
||||
slug: 'training-server',
|
||||
name: 'SCAR Operations',
|
||||
providerType: 'pterodactyl',
|
||||
pterodactylServerId: 'abc123',
|
||||
status: 'online',
|
||||
maxPlayers: 16,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
describe('ServerModsService', () => {
|
||||
let provider: MockGameServerProvider;
|
||||
let service: ServerModsService;
|
||||
let configSyncCalled: number;
|
||||
|
||||
beforeEach(() => {
|
||||
provider = new MockGameServerProvider();
|
||||
configSyncCalled = 0;
|
||||
const configSync = {
|
||||
sync: async () => {
|
||||
configSyncCalled += 1;
|
||||
return { changed: true, revisionVersion: 2, serverName: 'x', maxPlayers: 16 };
|
||||
},
|
||||
} as unknown as ConfigSyncService;
|
||||
service = new ServerModsService(
|
||||
new ConfigFileGateway(provider, '/config.json'),
|
||||
configSync,
|
||||
createLogger('silent'),
|
||||
);
|
||||
});
|
||||
|
||||
it('reads the current mods from config.json', async () => {
|
||||
const result = await service.getMods(server);
|
||||
expect(result.mods).toEqual([
|
||||
{ modId: '591AF5BDA9F7CE8B', name: 'Mock Sample Mod', version: '1.0.2' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('writes the new mod list while preserving every other config field', async () => {
|
||||
const result = await service.setMods(server, [
|
||||
{ modId: '591AF5BDA9F7CE8B', name: 'Mock Sample Mod', version: '1.0.2' },
|
||||
{ modId: '5AAF0CCE3F001FB5', name: 'Server Admin Tools' },
|
||||
]);
|
||||
|
||||
expect(result.added).toBe(1);
|
||||
expect(result.removed).toBe(0);
|
||||
expect(result.requiresRestart).toBe(true);
|
||||
expect(result.mods).toHaveLength(2);
|
||||
|
||||
const written = provider.writtenFiles.get('/config.json')!;
|
||||
const parsed = JSON.parse(written);
|
||||
// game.mods replaced…
|
||||
expect(parsed.game.mods).toEqual([
|
||||
{ modId: '591AF5BDA9F7CE8B', name: 'Mock Sample Mod', version: '1.0.2' },
|
||||
{ modId: '5AAF0CCE3F001FB5', name: 'Server Admin Tools' },
|
||||
]);
|
||||
// …everything else untouched.
|
||||
expect(parsed.bindPort).toBe(2001);
|
||||
expect(parsed.game.name).toBe('Mock Reforger Server');
|
||||
expect(parsed.game.maxPlayers).toBe(16);
|
||||
expect(parsed.operating.aiLimit).toBe(40);
|
||||
});
|
||||
|
||||
it('writes a rollback backup of the previous file before modifying it', async () => {
|
||||
const before = (await provider.downloadTextFile('abc123', '/config.json')).content;
|
||||
await service.setMods(server, []);
|
||||
expect(provider.writtenFiles.get('/config.json.bak')).toBe(before);
|
||||
// Removal reflected in the live file.
|
||||
const after = JSON.parse(provider.writtenFiles.get('/config.json')!);
|
||||
expect(after.game.mods).toEqual([]);
|
||||
});
|
||||
|
||||
it('imports a config revision after a successful write', async () => {
|
||||
await service.setMods(server, []);
|
||||
expect(configSyncCalled).toBe(1);
|
||||
});
|
||||
|
||||
it('normalizes mod ids to uppercase and drops empty name/version', async () => {
|
||||
const result = await service.setMods(server, [{ modId: '69c566706abd5a3c', name: '' }]);
|
||||
expect(result.mods).toEqual([{ modId: '69C566706ABD5A3C' }]);
|
||||
});
|
||||
|
||||
it('fails the write when read-back verification does not match', async () => {
|
||||
// Simulate a server that ignores writes to config.json.
|
||||
const originalWrite = provider.writeTextFile.bind(provider);
|
||||
vi.spyOn(provider, 'writeTextFile').mockImplementation(async (sid, path, content) => {
|
||||
if (path === '/config.json') return; // swallow the write
|
||||
await originalWrite(sid, path, content);
|
||||
});
|
||||
await expect(service.setMods(server, [])).rejects.toThrow(/verification failed/);
|
||||
});
|
||||
|
||||
it('refuses to modify a config without a game section', async () => {
|
||||
await provider.writeTextFile('abc123', '/config.json', '{"something": true}');
|
||||
await expect(service.setMods(server, [])).rejects.toThrow(ApiError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { MockGameServerProvider } from '../src/modules/pterodactyl/mock-provider.js';
|
||||
import { ConfigFileGateway } from '../src/modules/config/config-file-gateway.js';
|
||||
import { PerformanceSettingsService } from '../src/modules/config/performance-service.js';
|
||||
import type { ConfigSyncService } from '../src/modules/config/config-sync.js';
|
||||
import type { ServerRecord } from '../src/modules/servers/server-service.js';
|
||||
import { createLogger } from '../src/lib/logger.js';
|
||||
|
||||
const server: ServerRecord = {
|
||||
id: 'srv-1',
|
||||
slug: 'training-server',
|
||||
name: 'SCAR Operations',
|
||||
providerType: 'pterodactyl',
|
||||
pterodactylServerId: 'abc123',
|
||||
status: 'online',
|
||||
maxPlayers: 16,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
describe('PerformanceSettingsService', () => {
|
||||
let provider: MockGameServerProvider;
|
||||
let service: PerformanceSettingsService;
|
||||
|
||||
beforeEach(() => {
|
||||
provider = new MockGameServerProvider();
|
||||
const configSync = { sync: async () => ({}) } as unknown as ConfigSyncService;
|
||||
service = new PerformanceSettingsService(
|
||||
new ConfigFileGateway(provider, '/config.json'),
|
||||
configSync,
|
||||
createLogger('silent'),
|
||||
);
|
||||
});
|
||||
|
||||
it('reads current values, reporting absent keys as null', async () => {
|
||||
const { settings } = await service.get(server);
|
||||
// Present in the mock config.json:
|
||||
expect(settings.maxPlayers).toBe(16);
|
||||
expect(settings.serverMaxViewDistance).toBe(2500);
|
||||
expect(settings.aiLimit).toBe(40);
|
||||
expect(settings.disableThirdPerson).toBe(false);
|
||||
// Absent keys:
|
||||
expect(settings.playerSaveTime).toBeNull();
|
||||
expect(settings.fastValidation).toBeNull();
|
||||
});
|
||||
|
||||
it('sets changed values and removes nulled keys, preserving everything else', async () => {
|
||||
const { settings } = await service.get(server);
|
||||
const result = await service.update(server, {
|
||||
...settings,
|
||||
maxPlayers: 32,
|
||||
playerSaveTime: 180, // new key
|
||||
aiLimit: null, // remove key → game default
|
||||
});
|
||||
|
||||
expect(result.changedFields.sort()).toEqual(['aiLimit', 'maxPlayers', 'playerSaveTime']);
|
||||
expect(result.requiresRestart).toBe(true);
|
||||
|
||||
const written = JSON.parse(provider.writtenFiles.get('/config.json')!);
|
||||
expect(written.game.maxPlayers).toBe(32);
|
||||
expect(written.operating.playerSaveTime).toBe(180);
|
||||
expect('aiLimit' in written.operating).toBe(false);
|
||||
// Untouched fields preserved:
|
||||
expect(written.bindPort).toBe(2001);
|
||||
expect(written.game.scenarioId).toContain('Missions');
|
||||
expect(written.game.mods).toHaveLength(1);
|
||||
// Backup written:
|
||||
expect(provider.writtenFiles.get('/config.json.bak')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not write the file at all when nothing changed', async () => {
|
||||
const { settings } = await service.get(server);
|
||||
const result = await service.update(server, settings);
|
||||
expect(result.changedFields).toEqual([]);
|
||||
expect(provider.writtenFiles.has('/config.json')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { MockGameServerProvider } from '../src/modules/pterodactyl/mock-provider.js';
|
||||
import { PterodactylProvider } from '../src/modules/pterodactyl/pterodactyl-provider.js';
|
||||
import { ApiError } from '../src/lib/errors.js';
|
||||
|
||||
const API_KEY = 'ptlc_super_secret_key_123';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
describe('MockGameServerProvider', () => {
|
||||
it('reports online with plausible resources by default', async () => {
|
||||
const provider = new MockGameServerProvider();
|
||||
expect(await provider.getServerStatus()).toBe('online');
|
||||
const resources = await provider.getServerResources();
|
||||
expect(resources.status).toBe('online');
|
||||
expect(resources.memoryLimitBytes).toBeGreaterThan(0);
|
||||
expect(resources.uptimeMs).toBeGreaterThan(0);
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
it('transitions through stopping on stop', async () => {
|
||||
const provider = new MockGameServerProvider();
|
||||
await provider.stopServer();
|
||||
expect(await provider.getServerStatus()).toBe('stopping');
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
it('serves a parseable console.log fixture', async () => {
|
||||
const provider = new MockGameServerProvider();
|
||||
const file = await provider.downloadTextFile('any', '/profile/logs/console.log');
|
||||
expect(file.content).toContain('connected');
|
||||
expect(file.content).toContain('Log started');
|
||||
expect(file.contentStartOffset).toBe(0);
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
it('rejects unknown paths instead of exposing a file system', async () => {
|
||||
const provider = new MockGameServerProvider();
|
||||
await expect(provider.downloadTextFile('any', '/etc/passwd')).rejects.toThrow(ApiError);
|
||||
provider.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PterodactylProvider', () => {
|
||||
it('maps resource responses from the Client API', async () => {
|
||||
const fetchImpl = vi.fn(async (url: string | URL, _init?: RequestInit) => {
|
||||
const path = String(url);
|
||||
if (path.endsWith('/resources')) {
|
||||
return jsonResponse({
|
||||
object: 'stats',
|
||||
attributes: {
|
||||
current_state: 'running',
|
||||
resources: {
|
||||
memory_bytes: 1024,
|
||||
cpu_absolute: 51.5,
|
||||
disk_bytes: 2048,
|
||||
network_rx_bytes: 10,
|
||||
network_tx_bytes: 20,
|
||||
uptime: 5000,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return jsonResponse({ attributes: { limits: { cpu: 400, memory: 8192, disk: 40960 } } });
|
||||
});
|
||||
const provider = new PterodactylProvider({
|
||||
baseUrl: 'https://panel.example.com',
|
||||
apiKey: API_KEY,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
const resources = await provider.getServerResources('abc123');
|
||||
expect(resources.status).toBe('online');
|
||||
expect(resources.cpuPercent).toBe(51.5);
|
||||
expect(resources.cpuLimitPercent).toBe(400);
|
||||
expect(resources.memoryLimitBytes).toBe(8192 * 1024 * 1024);
|
||||
|
||||
const [calledUrl, calledInit] = fetchImpl.mock.calls[0]!;
|
||||
expect(String(calledUrl)).toBe('https://panel.example.com/api/client/servers/abc123/resources');
|
||||
const headers = calledInit?.headers as Record<string, string>;
|
||||
expect(headers.Authorization).toBe(`Bearer ${API_KEY}`);
|
||||
});
|
||||
|
||||
it('sends power signals with the expected body', async () => {
|
||||
const fetchImpl = vi.fn(async (_url: string | URL, _init?: RequestInit) => {
|
||||
return new Response(null, { status: 204 });
|
||||
});
|
||||
const provider = new PterodactylProvider({
|
||||
baseUrl: 'https://panel.example.com',
|
||||
apiKey: API_KEY,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
});
|
||||
await provider.restartServer('abc123');
|
||||
const [url, init] = fetchImpl.mock.calls[0]!;
|
||||
expect(String(url)).toContain('/power');
|
||||
expect(init?.method).toBe('POST');
|
||||
expect(JSON.parse(String(init?.body))).toEqual({ signal: 'restart' });
|
||||
});
|
||||
|
||||
it('maps HTTP errors without leaking the API key or full URL', async () => {
|
||||
const fetchImpl = vi.fn(async () => new Response('nope', { status: 500 }));
|
||||
const provider = new PterodactylProvider({
|
||||
baseUrl: 'https://panel.example.com',
|
||||
apiKey: API_KEY,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
});
|
||||
const error = await provider.getServerResources('abc123').catch((e: unknown) => e as ApiError);
|
||||
expect(error).toBeInstanceOf(ApiError);
|
||||
expect((error as ApiError).code).toBe('UPSTREAM_UNAVAILABLE');
|
||||
expect((error as ApiError).message).not.toContain(API_KEY);
|
||||
expect((error as ApiError).message).not.toContain('panel.example.com');
|
||||
expect((error as ApiError).message).toContain('500');
|
||||
});
|
||||
|
||||
it('maps timeouts to a sanitized upstream error', async () => {
|
||||
const timeoutError = new Error('The operation was aborted due to timeout');
|
||||
timeoutError.name = 'TimeoutError';
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
throw timeoutError;
|
||||
});
|
||||
const provider = new PterodactylProvider({
|
||||
baseUrl: 'https://panel.example.com',
|
||||
apiKey: API_KEY,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
timeoutMs: 50,
|
||||
});
|
||||
const error = await provider.getServerStatus('abc123').catch((e: unknown) => e as ApiError);
|
||||
expect(error).toBeInstanceOf(ApiError);
|
||||
expect((error as ApiError).message).toContain('timed out');
|
||||
expect((error as ApiError).message).not.toContain(API_KEY);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src", "test", "tsup.config.ts", "drizzle.config.ts", "vitest.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts', 'src/db/migrate.ts', 'src/db/seed.ts'],
|
||||
format: ['esm'],
|
||||
target: 'node22',
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
// Bundle the source-only workspace package into the output.
|
||||
noExternal: ['@reforger-panel/shared'],
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user