diff --git a/apps/api/src/lib/server-automation.ts b/apps/api/src/lib/server-automation.ts index d70b6e9..eb813c7 100644 --- a/apps/api/src/lib/server-automation.ts +++ b/apps/api/src/lib/server-automation.ts @@ -9,6 +9,7 @@ import type { ServerAutomationAction, ServerAutomationGitHubReleaseExtractAction, ServerAutomationHttpDirectoryExtractAction, + ServerAutomationInsertBeforeLineAction, } from '@source/shared'; import { daemonReadFile, @@ -20,6 +21,21 @@ import { const DEFAULT_RELEASE_MAX_BYTES = 256 * 1024 * 1024; const DEFAULT_DOWNLOAD_TIMEOUT_MS = 120_000; const AUTOMATION_MARKER_ROOT = '/.gamepanel/automation'; +const CS2_GAMEINFO_PATH = '/game/csgo/gameinfo.gi'; +const CS2_GAMEINFO_METAMOD_LINE = '\t\t\tGame csgo/addons/metamod'; +const CS2_GAMEINFO_INSERT_BEFORE_PATTERN = '^\\s*Game\\s+csgo\\s*$'; +const CS2_GAMEINFO_EXISTS_PATTERN = '^\\s*Game\\s+csgo/addons/metamod\\s*$'; +const CS2_GAMEINFO_INSERT_ACTION_ID = 'ensure-cs2-metamod-gameinfo-entry'; + +const DEFAULT_CS2_GAMEINFO_INSERT_ACTION: ServerAutomationInsertBeforeLineAction = { + id: CS2_GAMEINFO_INSERT_ACTION_ID, + type: 'insert_before_line', + path: CS2_GAMEINFO_PATH, + line: CS2_GAMEINFO_METAMOD_LINE, + beforePattern: CS2_GAMEINFO_INSERT_BEFORE_PATTERN, + existsPattern: CS2_GAMEINFO_EXISTS_PATTERN, + skipIfExists: true, +}; const DEFAULT_GAME_AUTOMATION_RULES: Record = { cs2: [ @@ -39,6 +55,7 @@ const DEFAULT_GAME_AUTOMATION_RULES: Record = { stripComponents: 0, maxBytes: DEFAULT_RELEASE_MAX_BYTES, }, + { ...DEFAULT_CS2_GAMEINFO_INSERT_ACTION }, ], }, { @@ -129,25 +146,43 @@ function normalizeWorkflow( workflow: GameAutomationRule, ): GameAutomationRule { if (gameSlug.toLowerCase() !== 'cs2') return workflow; - if (workflow.id !== 'cs2-install-latest-counterstrikesharp-runtime') return workflow; - const normalizedActions = workflow.actions.map((action) => { - if (action.type !== 'github_release_extract') return action; - if (action.id !== 'install-cs2-runtime') return action; + if (workflow.id === 'cs2-install-latest-counterstrikesharp-runtime') { + const normalizedActions = workflow.actions.map((action) => { + if (action.type !== 'github_release_extract') return action; + if (action.id !== 'install-cs2-runtime') return action; - const destination = (action.destination ?? '').trim(); - if (destination !== '' && destination !== '/') return action; + const destination = (action.destination ?? '').trim(); + if (destination !== '' && destination !== '/') return action; + + return { + ...action, + destination: '/game/csgo', + }; + }); return { - ...action, - destination: '/game/csgo', + ...workflow, + actions: normalizedActions, }; - }); + } - return { - ...workflow, - actions: normalizedActions, - }; + if (workflow.id === 'cs2-install-latest-metamod') { + const hasGameInfoAction = workflow.actions.some( + (action) => + action.type === 'insert_before_line' && + (action.id === CS2_GAMEINFO_INSERT_ACTION_ID || action.path === CS2_GAMEINFO_PATH), + ); + + if (hasGameInfoAction) return workflow; + + return { + ...workflow, + actions: [...workflow.actions, { ...DEFAULT_CS2_GAMEINFO_INSERT_ACTION }], + }; + } + + return workflow; } function asAutomationRules(raw: unknown, gameSlug: string): GameAutomationRule[] { @@ -609,6 +644,76 @@ async function executeHttpDirectoryExtract( ); } +async function executeInsertBeforeLine( + app: FastifyInstance, + context: ServerAutomationContext, + action: ServerAutomationInsertBeforeLineAction, +): Promise { + const file = await daemonReadFile(context.node, context.serverUuid, action.path); + const content = file.data.toString('utf8'); + const eol = content.includes('\r\n') ? '\r\n' : '\n'; + const hasTrailingEol = content.endsWith('\n'); + const lines = content.split(/\r?\n/); + + if (hasTrailingEol && lines[lines.length - 1] === '') { + lines.pop(); + } + + const skipIfExists = action.skipIfExists !== false; + if (skipIfExists) { + const existsRegex = action.existsPattern + ? new RegExp(action.existsPattern, 'i') + : null; + + const alreadyExists = lines.some((line) => + existsRegex ? existsRegex.test(line) : line === action.line, + ); + + if (alreadyExists) { + app.log.info( + { + serverId: context.serverId, + serverUuid: context.serverUuid, + event: context.event, + actionId: action.id, + path: action.path, + }, + 'Automation action skipped: line already present', + ); + return; + } + } + + let beforeRegex: RegExp; + try { + beforeRegex = new RegExp(action.beforePattern); + } catch { + throw new Error(`Invalid beforePattern regex for action ${action.id}`); + } + + const insertIndex = lines.findIndex((line) => beforeRegex.test(line)); + if (insertIndex < 0) { + throw new Error( + `Could not find insertion point in ${action.path} with pattern: ${action.beforePattern}`, + ); + } + + const updated = [...lines.slice(0, insertIndex), action.line, ...lines.slice(insertIndex)]; + const output = `${updated.join(eol)}${hasTrailingEol ? eol : ''}`; + await daemonWriteFile(context.node, context.serverUuid, action.path, output); + + app.log.info( + { + serverId: context.serverId, + serverUuid: context.serverUuid, + event: context.event, + actionId: action.id, + path: action.path, + }, + 'Automation action completed: insert_before_line', + ); +} + async function executeAction( app: FastifyInstance, context: ServerAutomationContext, @@ -625,6 +730,11 @@ async function executeAction( return; } + case 'insert_before_line': { + await executeInsertBeforeLine(app, context, action); + return; + } + case 'write_file': { const payload = action.encoding === 'base64' diff --git a/apps/api/src/routes/servers/files.ts b/apps/api/src/routes/servers/files.ts index df1aa9a..77d4212 100644 --- a/apps/api/src/routes/servers/files.ts +++ b/apps/api/src/routes/servers/files.ts @@ -1,7 +1,7 @@ import type { FastifyInstance } from 'fastify'; import { Type } from '@sinclair/typebox'; import { and, eq } from 'drizzle-orm'; -import { nodes, servers } from '@source/database'; +import { games, nodes, servers } from '@source/database'; import { AppError } from '../../lib/errors.js'; import { requirePermission } from '../../lib/permissions.js'; import { @@ -19,6 +19,17 @@ const FileParamSchema = { }), }; +function shouldHideFileForGame(gameSlug: string, fileName: string, isDirectory: boolean): boolean { + if (gameSlug !== 'cs2') return false; + if (isDirectory) return false; + + const normalizedName = fileName.trim().toLowerCase(); + if (normalizedName.endsWith('.vpk')) return true; + if (/^backup_round.*\.txt$/.test(normalizedName)) return true; + + return false; +} + function decodeBase64Payload(data: string): Buffer { const normalized = data.trim(); if (!normalized) return Buffer.alloc(0); @@ -56,7 +67,11 @@ export default async function fileRoutes(app: FastifyInstance) { path?.trim() || '/', ); - return { files }; + const filteredFiles = files.filter( + (file) => !shouldHideFileForGame(serverContext.gameSlug, file.name, file.isDirectory), + ); + + return { files: filteredFiles }; }, ); @@ -151,17 +166,20 @@ export default async function fileRoutes(app: FastifyInstance) { async function getServerContext(app: FastifyInstance, orgId: string, serverId: string): Promise<{ serverUuid: string; + gameSlug: string; node: DaemonNodeConnection; }> { const [server] = await app.db .select({ uuid: servers.uuid, + gameSlug: games.slug, nodeFqdn: nodes.fqdn, nodeGrpcPort: nodes.grpcPort, nodeDaemonToken: nodes.daemonToken, }) .from(servers) .innerJoin(nodes, eq(servers.nodeId, nodes.id)) + .innerJoin(games, eq(servers.gameId, games.id)) .where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId))); if (!server) { @@ -170,6 +188,7 @@ async function getServerContext(app: FastifyInstance, orgId: string, serverId: s return { serverUuid: server.uuid, + gameSlug: server.gameSlug, node: { fqdn: server.nodeFqdn, grpcPort: server.nodeGrpcPort, diff --git a/packages/database/drizzle/0002_cs2_add_metamod_workflow.sql b/packages/database/drizzle/0002_cs2_add_metamod_workflow.sql index 1095d04..b51bd3a 100644 --- a/packages/database/drizzle/0002_cs2_add_metamod_workflow.sql +++ b/packages/database/drizzle/0002_cs2_add_metamod_workflow.sql @@ -15,6 +15,15 @@ WITH metamod_rule AS ( "destination": "/game/csgo", "stripComponents": 0, "maxBytes": 268435456 + }, + { + "id": "ensure-cs2-metamod-gameinfo-entry", + "type": "insert_before_line", + "path": "/game/csgo/gameinfo.gi", + "line": "\\t\\t\\tGame csgo/addons/metamod", + "beforePattern": "^\\\\s*Game\\\\s+csgo\\\\s*$", + "existsPattern": "^\\\\s*Game\\\\s+csgo/addons/metamod\\\\s*$", + "skipIfExists": true } ] } diff --git a/packages/database/src/seed.ts b/packages/database/src/seed.ts index 89bf3b8..e0dae97 100644 --- a/packages/database/src/seed.ts +++ b/packages/database/src/seed.ts @@ -127,6 +127,15 @@ async function seed() { stripComponents: 0, maxBytes: 256 * 1024 * 1024, }, + { + id: 'ensure-cs2-metamod-gameinfo-entry', + type: 'insert_before_line', + path: '/game/csgo/gameinfo.gi', + line: '\t\t\tGame csgo/addons/metamod', + beforePattern: '^\\s*Game\\s+csgo\\s*$', + existsPattern: '^\\s*Game\\s+csgo/addons/metamod\\s*$', + skipIfExists: true, + }, ], }, { diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 9cda59b..81ca1c5 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -19,6 +19,7 @@ export type ServerAutomationEvent = export type ServerAutomationActionType = | 'github_release_extract' | 'http_directory_extract' + | 'insert_before_line' | 'write_file' | 'send_command'; @@ -51,6 +52,16 @@ export interface ServerAutomationHttpDirectoryExtractAction { maxBytes?: number; } +export interface ServerAutomationInsertBeforeLineAction { + id: string; + type: 'insert_before_line'; + path: string; + line: string; + beforePattern: string; + existsPattern?: string; + skipIfExists?: boolean; +} + export interface ServerAutomationSendCommandAction { id: string; type: 'send_command'; @@ -60,6 +71,7 @@ export interface ServerAutomationSendCommandAction { export type ServerAutomationAction = | ServerAutomationGitHubReleaseExtractAction | ServerAutomationHttpDirectoryExtractAction + | ServerAutomationInsertBeforeLineAction | ServerAutomationWriteFileAction | ServerAutomationSendCommandAction;