diff --git a/.env.example b/.env.example index 0ab4fd5..4952092 100644 --- a/.env.example +++ b/.env.example @@ -38,3 +38,10 @@ WEB_PORT=80 # --- Daemon --- DAEMON_CONFIG=/etc/gamepanel/config.yml DAEMON_GRPC_PORT=50051 + +# --- CDN (Plugin Artifacts) --- +CDN_BASE_URL=https://cdn.hibna.com.tr +CDN_API_KEY= +CDN_PLUGIN_BUCKET=gamepanel-plugin-artifacts +CDN_PLUGIN_ARTIFACT_TTL_SECONDS=900 +CDN_WEBHOOK_SECRET= diff --git a/apps/api/package.json b/apps/api/package.json index cdfaa60..fee3bd8 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -10,15 +10,17 @@ "lint": "eslint src/" }, "dependencies": { - "@grpc/grpc-js": "^1.14.0", - "@grpc/proto-loader": "^0.8.0", "@fastify/cookie": "^11.0.0", "@fastify/cors": "^10.0.0", "@fastify/helmet": "^13.0.2", "@fastify/jwt": "^9.0.0", + "@fastify/multipart": "^9.4.0", "@fastify/rate-limit": "^10.3.0", "@fastify/websocket": "^11.0.0", + "@grpc/grpc-js": "^1.14.0", + "@grpc/proto-loader": "^0.8.0", "@sinclair/typebox": "^0.34.0", + "@source/cdn": "1.4.0", "@source/database": "workspace:*", "@source/proto": "workspace:*", "@source/shared": "workspace:*", @@ -26,14 +28,16 @@ "drizzle-orm": "^0.38.0", "fastify": "^5.2.0", "fastify-plugin": "^5.0.0", + "pino-pretty": "^13.0.0", + "socket.io": "^4.8.0", "tar-stream": "^3.1.7", "unzipper": "^0.12.3", - "pino-pretty": "^13.0.0", - "socket.io": "^4.8.0" + "yazl": "^3.3.1" }, "devDependencies": { "@types/tar-stream": "^3.1.4", "@types/unzipper": "^0.10.11", + "@types/yazl": "^3.3.0", "dotenv-cli": "^8.0.0", "tsx": "^4.19.0" } diff --git a/apps/api/src/lib/cdn.ts b/apps/api/src/lib/cdn.ts new file mode 100644 index 0000000..5c2f220 --- /dev/null +++ b/apps/api/src/lib/cdn.ts @@ -0,0 +1,201 @@ +import { CdnClient, CdnError, type FileInfo } from '@source/cdn'; +import { AppError } from './errors.js'; + +const DEFAULT_PLUGIN_BUCKET = 'gamepanel-plugin-artifacts'; +const DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS = 900; +const ARTIFACT_POINTER_PREFIX = 'cdn://file/'; + +let cachedClient: CdnClient | null = null; +let cachedFingerprint: string | null = null; + +function envValue(name: string): string | null { + const value = process.env[name]; + if (!value) return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function getCdnConfig(): { baseUrl: string; apiKey: string } | null { + const baseUrl = envValue('CDN_BASE_URL'); + const apiKey = envValue('CDN_API_KEY'); + if (!baseUrl || !apiKey) return null; + return { baseUrl, apiKey }; +} + +function getArtifactAccessTtlSeconds(): number { + const raw = Number(process.env.CDN_PLUGIN_ARTIFACT_TTL_SECONDS ?? DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS); + if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS; + return Math.floor(raw); +} + +function getOrCreateClient(): CdnClient | null { + const config = getCdnConfig(); + if (!config) return null; + + const fingerprint = `${config.baseUrl}::${config.apiKey}`; + if (cachedClient && cachedFingerprint === fingerprint) return cachedClient; + + cachedClient = new CdnClient({ + baseUrl: config.baseUrl, + apiKey: config.apiKey, + timeoutMs: 45_000, + retry: { + retries: 2, + retryDelayMs: 250, + maxRetryDelayMs: 2_000, + }, + }); + cachedFingerprint = fingerprint; + return cachedClient; +} + +function requireClient(): CdnClient { + const client = getOrCreateClient(); + if (!client) { + throw new AppError( + 500, + 'CDN configuration is missing. Set CDN_BASE_URL and CDN_API_KEY.', + 'CDN_NOT_CONFIGURED', + ); + } + return client; +} + +function toCdnAppError(error: unknown, fallbackMessage: string, fallbackCode: string): AppError { + if (error instanceof AppError) return error; + if (error instanceof CdnError) { + return new AppError(502, `CDN error: ${error.message}`, fallbackCode); + } + return new AppError(502, fallbackMessage, fallbackCode); +} + +export function getPluginBucketName(): string { + return envValue('CDN_PLUGIN_BUCKET') ?? DEFAULT_PLUGIN_BUCKET; +} + +export async function ensurePrivatePluginBucket(): Promise { + const client = requireClient(); + const bucketName = getPluginBucketName(); + + try { + const bucket = await client.getBucket(bucketName); + if (bucket.isPublic) { + await client.updateBucket(bucketName, { isPublic: false }); + } + return bucketName; + } catch (error) { + if (error instanceof CdnError && error.statusCode === 404) { + try { + await client.createBucket(bucketName, { + description: 'GamePanel plugin artifacts', + isPublic: false, + }); + return bucketName; + } catch (createError) { + throw toCdnAppError( + createError, + 'Failed to create CDN plugin bucket', + 'CDN_BUCKET_CREATE_FAILED', + ); + } + } + + throw toCdnAppError( + error, + 'Failed to fetch CDN plugin bucket', + 'CDN_BUCKET_READ_FAILED', + ); + } +} + +export function buildCdnArtifactPointer(fileId: string): string { + return `${ARTIFACT_POINTER_PREFIX}${fileId}`; +} + +export function parseCdnArtifactPointer(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + + if (trimmed.startsWith(ARTIFACT_POINTER_PREFIX)) { + const id = trimmed.slice(ARTIFACT_POINTER_PREFIX.length).trim(); + return id.length > 0 ? id : null; + } + + try { + const parsed = new URL(trimmed); + if (parsed.protocol === 'cdn:' && parsed.hostname === 'file') { + const candidate = parsed.pathname.replace(/^\/+/, '').trim(); + return candidate.length > 0 ? candidate : null; + } + } catch { + return null; + } + + return null; +} + +export async function uploadPluginArtifact( + content: Uint8Array, + filename: string, + metadata: Record = {}, +): Promise<{ bucket: string; file: FileInfo; artifactPointer: string }> { + const client = requireClient(); + const bucket = await ensurePrivatePluginBucket(); + + try { + const file = await client.upload(content, { + bucket, + filename, + metadata, + }); + + return { + bucket, + file, + artifactPointer: buildCdnArtifactPointer(file.id), + }; + } catch (error) { + throw toCdnAppError(error, 'Failed to upload artifact to CDN', 'CDN_UPLOAD_FAILED'); + } +} + +export async function resolveArtifactDownloadUrl(artifactUrl: string): Promise { + const fileId = parseCdnArtifactPointer(artifactUrl); + if (!fileId) return artifactUrl; + + const client = requireClient(); + const config = getCdnConfig(); + const ttl = getArtifactAccessTtlSeconds(); + + try { + const access = await client.getFileAccessUrl(fileId, ttl); + if (!access.url || typeof access.url !== 'string') { + throw new AppError(502, 'CDN access URL is empty', 'CDN_ACCESS_URL_EMPTY'); + } + + const resolvedUrl = access.url.trim(); + if (!resolvedUrl) { + throw new AppError(502, 'CDN access URL is empty', 'CDN_ACCESS_URL_EMPTY'); + } + + if (/^https?:\/\//i.test(resolvedUrl)) { + return resolvedUrl; + } + + if (!config) { + throw new AppError( + 500, + 'CDN configuration is missing. Set CDN_BASE_URL and CDN_API_KEY.', + 'CDN_NOT_CONFIGURED', + ); + } + + return new URL(resolvedUrl, config.baseUrl).toString(); + } catch (error) { + throw toCdnAppError( + error, + 'Failed to get temporary CDN access URL', + 'CDN_ACCESS_URL_FAILED', + ); + } +} diff --git a/apps/api/src/lib/cs2-server-config.ts b/apps/api/src/lib/cs2-server-config.ts new file mode 100644 index 0000000..841fcd0 --- /dev/null +++ b/apps/api/src/lib/cs2-server-config.ts @@ -0,0 +1,178 @@ +import { + daemonReadFile, + daemonWriteFile, + type DaemonNodeConnection, +} from './daemon.js'; + +export const CS2_SERVER_CFG_PATH = 'game/csgo/cfg/server.cfg'; +export const CS2_PERSISTED_SERVER_CFG_PATH = 'game/csgo/cfg/.sourcegamepanel-server.cfg'; +export const CS2_PERSISTED_SERVER_CFG_FILE = '.sourcegamepanel-server.cfg'; +const LEGACY_IMAGE_CS2_SERVER_CFG = `// Server Defaults + +hostname "GamePanel CS2 Server" // Set server hostname +sv_cheats 0 // Enable or disable cheats +sv_hibernate_when_empty 0 // Disable server hibernation + +// Passwords + +rcon_password "" // Set rcon password +sv_password "" // Set server password + +// CSTV + +sv_hibernate_postgame_delay 30 // Delay server hibernation after all clients disconnect + +tv_allow_camera_man 1 // Auto director allows spectators to become camera man +tv_allow_static_shots 1 // Auto director uses fixed level cameras for shots +tv_autorecord 0 // Automatically records all games as CSTV demos: 0=off, 1=on. +tv_chatgroupsize 0 // Set the default chat group size +tv_chattimelimit 8 // Limits spectators to chat only every n seconds +tv_debug 0 // CSTV debug info. +tv_delay 0 // CSTV broadcast delay in seconds +tv_delaymapchange 1 // Delays map change until broadcast is complete +tv_deltacache 2 // Enable delta entity bit stream cache +tv_dispatchmode 1 // Dispatch clients to relay proxies: 0=never, 1=if appropriate, 2=always +tv_enable 0 // Activates CSTV on server: 0=off, 1=on. +tv_maxclients 10 // Maximum client number on CSTV server. +tv_maxrate 0 // Max CSTV spectator bandwidth rate allowed, 0 == unlimited +tv_name "GamePanel CS2 Server CSTV" // CSTV host name +tv_overridemaster 0 // Overrides the CSTV master root address. +tv_port 27020 // Host SourceTV port +tv_password "changeme" // CSTV password for clients +tv_relaypassword "changeme" // CSTV password for relay proxies +tv_relayvoice 1 // Relay voice data: 0=off, 1=on +tv_timeout 60 // CSTV connection timeout in seconds. +tv_title "GamePanel CS2 Server CSTV" // Set title for CSTV spectator UI +tv_transmitall 1 // Transmit all entities (not only director view) + +// Logs + +log on // Turns logging 'on' or 'off', defaults to 'on' +mp_logmoney 0 // Turns money logging on/off: 0=off, 1=on +mp_logdetail 0 // Combat damage logging: 0=disabled, 1=enemy, 2=friendly, 3=all +mp_logdetail_items 0 // Turns item logging on/off: 0=off, 1=on +`; +export const DEFAULT_CS2_SERVER_CFG = `// ============================================ +// CS2 Server Config +// ============================================ + +// ---- Sunucu Bilgileri ---- +hostname "SourceGamePanel CS2 Server" +sv_password "" +rcon_password "changeme" +sv_cheats 0 + +// ---- Topluluk Sunucu Gorunurlugu ---- +sv_region 3 +sv_tags "competitive,community" +sv_lan 0 +sv_steamgroup "" +sv_steamgroup_exclusive 0 + +// ---- Performans ---- +sv_maxrate 0 +sv_minrate 64000 +sv_max_queries_sec 5 +sv_max_queries_window 30 +sv_parallel_sendsnapshot 1 +net_maxroutable 1200 + +// ---- Baglanti ---- +sv_maxclients 16 +sv_timeout 60 + +// ---- GOTV (Tamamen Kapali) ---- +tv_enable 0 +tv_autorecord 0 +tv_delay 0 +tv_maxclients 0 +tv_port 0 + +// ---- Loglama ---- +log on +mp_logmoney 0 +mp_logdetail 0 +mp_logdetail_items 0 +sv_logfile 1 + +// ---- Genel Oyun Ayarlari ---- +mp_autokick 0 +sv_allow_votes 0 +sv_alltalk 0 +sv_deadtalk 1 +sv_voiceenable 1 +`; + +function normalizePath(path: string): string { + const normalized = path + .trim() + .replace(/\\/g, '/') + .replace(/^\/+/, '') + .replace(/\/{2,}/g, '/'); + return normalized; +} + +function isMissingFileError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return ( + message.includes('No such file or directory') || + message.includes('Server responded with NOT_FOUND') || + message.includes('status code 404') + ); +} + +function normalizeComparableContent(content: string): string { + return content.replace(/\r\n/g, '\n').trim(); +} + +export function isManagedCs2ServerConfigPath(gameSlug: string, path: string): boolean { + return ( + gameSlug.trim().toLowerCase() === 'cs2' && + normalizePath(path) === CS2_SERVER_CFG_PATH + ); +} + +export async function readManagedCs2ServerConfig( + node: DaemonNodeConnection, + serverUuid: string, +): Promise { + try { + const persisted = await daemonReadFile(node, serverUuid, CS2_PERSISTED_SERVER_CFG_PATH); + return persisted.data.toString('utf8'); + } catch (error) { + if (!isMissingFileError(error)) throw error; + } + + try { + const current = await daemonReadFile(node, serverUuid, CS2_SERVER_CFG_PATH); + const content = current.data.toString('utf8'); + const nextContent = + normalizeComparableContent(content) === normalizeComparableContent(LEGACY_IMAGE_CS2_SERVER_CFG) + ? DEFAULT_CS2_SERVER_CFG + : content; + await daemonWriteFile(node, serverUuid, CS2_PERSISTED_SERVER_CFG_PATH, nextContent); + return nextContent; + } catch (error) { + if (!isMissingFileError(error)) throw error; + } + + await daemonWriteFile(node, serverUuid, CS2_PERSISTED_SERVER_CFG_PATH, DEFAULT_CS2_SERVER_CFG); + return DEFAULT_CS2_SERVER_CFG; +} + +export async function writeManagedCs2ServerConfig( + node: DaemonNodeConnection, + serverUuid: string, + content: string | Buffer, +): Promise { + await daemonWriteFile(node, serverUuid, CS2_PERSISTED_SERVER_CFG_PATH, content); + await daemonWriteFile(node, serverUuid, CS2_SERVER_CFG_PATH, content); +} + +export async function reapplyManagedCs2ServerConfig( + node: DaemonNodeConnection, + serverUuid: string, +): Promise { + const content = await readManagedCs2ServerConfig(node, serverUuid); + await daemonWriteFile(node, serverUuid, CS2_SERVER_CFG_PATH, content); +} diff --git a/apps/api/src/lib/daemon.ts b/apps/api/src/lib/daemon.ts index 3350b06..c3c2f4b 100644 --- a/apps/api/src/lib/daemon.ts +++ b/apps/api/src/lib/daemon.ts @@ -27,11 +27,31 @@ export interface DaemonCreateServerRequest { install_plugin_urls: string[]; } +export interface DaemonUpdateServerRequest { + uuid: string; + docker_image: string; + memory_limit: number; + disk_limit: number; + cpu_limit: number; + startup_command: string; + environment: Record; + ports: DaemonPortMapping[]; +} + interface DaemonServerResponse { uuid: string; status: string; } +interface DaemonManagedDatabaseCredentialsRaw { + database_name: string; + username: string; + password: string; + host: string; + port: number; + phpmyadmin_url: string; +} + interface DaemonNodeStatusRaw { version: string; is_healthy: boolean; @@ -124,6 +144,15 @@ export interface DaemonBackupResponse { success: boolean; } +export interface DaemonManagedDatabaseCredentials { + databaseName: string; + username: string; + password: string; + host: string; + port: number; + phpMyAdminUrl: string | null; +} + export interface DaemonNodeStatus { version: string; isHealthy: boolean; @@ -156,11 +185,31 @@ interface DaemonServiceClient extends grpc.Client { metadata: grpc.Metadata, callback: UnaryCallback, ): void; + updateServer( + request: DaemonUpdateServerRequest, + metadata: grpc.Metadata, + callback: UnaryCallback, + ): void; deleteServer( request: { uuid: string }, metadata: grpc.Metadata, callback: UnaryCallback, ): void; + createDatabase( + request: { server_uuid: string; name: string; password?: string }, + metadata: grpc.Metadata, + callback: UnaryCallback, + ): void; + updateDatabasePassword( + request: { username: string; password: string }, + metadata: grpc.Metadata, + callback: UnaryCallback, + ): void; + deleteDatabase( + request: { database_name: string; username: string }, + metadata: grpc.Metadata, + callback: UnaryCallback, + ): void; setPowerState( request: { uuid: string; action: number }, metadata: grpc.Metadata, @@ -388,6 +437,12 @@ function toBuffer(data: Uint8Array | Buffer): Buffer { const DEFAULT_CONNECT_TIMEOUT_MS = 8_000; const DEFAULT_RPC_TIMEOUT_MS = 20_000; +const POWER_RPC_TIMEOUT_MS = 45_000; + +interface DaemonRequestTimeoutOptions { + connectTimeoutMs?: number; + rpcTimeoutMs?: number; +} export async function daemonGetNodeStatus( node: DaemonNodeConnection, @@ -464,6 +519,104 @@ export async function daemonDeleteServer( } } +export async function daemonUpdateServer( + node: DaemonNodeConnection, + request: DaemonUpdateServerRequest, +): Promise { + const client = createClient(node); + try { + await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); + return await callUnary( + (callback) => client.updateServer(request, getMetadata(node.daemonToken), callback), + DEFAULT_RPC_TIMEOUT_MS, + ); + } finally { + client.close(); + } +} + +export async function daemonCreateDatabase( + node: DaemonNodeConnection, + request: { serverUuid: string; name: string; password?: string }, +): Promise { + const client = createClient(node); + try { + await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); + const response = await callUnary( + (callback) => + client.createDatabase( + { + server_uuid: request.serverUuid, + name: request.name, + password: request.password ?? '', + }, + getMetadata(node.daemonToken), + callback, + ), + DEFAULT_RPC_TIMEOUT_MS, + ); + + return { + databaseName: response.database_name, + username: response.username, + password: response.password, + host: response.host, + port: Number(response.port), + phpMyAdminUrl: response.phpmyadmin_url.trim() ? response.phpmyadmin_url : null, + }; + } finally { + client.close(); + } +} + +export async function daemonUpdateDatabasePassword( + node: DaemonNodeConnection, + request: { username: string; password: string }, +): Promise { + const client = createClient(node); + try { + await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); + await callUnary( + (callback) => + client.updateDatabasePassword( + { + username: request.username, + password: request.password, + }, + getMetadata(node.daemonToken), + callback, + ), + DEFAULT_RPC_TIMEOUT_MS, + ); + } finally { + client.close(); + } +} + +export async function daemonDeleteDatabase( + node: DaemonNodeConnection, + request: { databaseName: string; username: string }, +): Promise { + const client = createClient(node); + try { + await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); + await callUnary( + (callback) => + client.deleteDatabase( + { + database_name: request.databaseName, + username: request.username, + }, + getMetadata(node.daemonToken), + callback, + ), + DEFAULT_RPC_TIMEOUT_MS, + ); + } finally { + client.close(); + } +} + export async function daemonSetPowerState( node: DaemonNodeConnection, serverUuid: string, @@ -474,7 +627,7 @@ export async function daemonSetPowerState( await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); await callUnary( (callback) => client.setPowerState({ uuid: serverUuid, action: POWER_ACTIONS[action] }, getMetadata(node.daemonToken), callback), - DEFAULT_RPC_TIMEOUT_MS, + POWER_RPC_TIMEOUT_MS, ); } finally { client.close(); @@ -484,13 +637,14 @@ export async function daemonSetPowerState( export async function daemonGetServerStatus( node: DaemonNodeConnection, serverUuid: string, + timeouts: DaemonRequestTimeoutOptions = {}, ): Promise { const client = createClient(node); try { - await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS); + await waitForReady(client, timeouts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS); return await callUnary( (callback) => client.getServerStatus({ uuid: serverUuid }, getMetadata(node.daemonToken), callback), - DEFAULT_RPC_TIMEOUT_MS, + timeouts.rpcTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS, ); } finally { client.close(); diff --git a/apps/api/src/lib/server-automation.ts b/apps/api/src/lib/server-automation.ts index eb813c7..0bd8cfa 100644 --- a/apps/api/src/lib/server-automation.ts +++ b/apps/api/src/lib/server-automation.ts @@ -10,6 +10,7 @@ import type { ServerAutomationGitHubReleaseExtractAction, ServerAutomationHttpDirectoryExtractAction, ServerAutomationInsertBeforeLineAction, + ServerAutomationWriteFileAction, } from '@source/shared'; import { daemonReadFile, @@ -17,6 +18,11 @@ import { daemonWriteFile, type DaemonNodeConnection, } from './daemon.js'; +import { + CS2_PERSISTED_SERVER_CFG_PATH, + CS2_SERVER_CFG_PATH, + DEFAULT_CS2_SERVER_CFG, +} from './cs2-server-config.js'; const DEFAULT_RELEASE_MAX_BYTES = 256 * 1024 * 1024; const DEFAULT_DOWNLOAD_TIMEOUT_MS = 120_000; @@ -26,7 +32,6 @@ 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', @@ -37,8 +42,33 @@ const DEFAULT_CS2_GAMEINFO_INSERT_ACTION: ServerAutomationInsertBeforeLineAction skipIfExists: true, }; +const DEFAULT_CS2_SERVER_CONFIG_ACTION: ServerAutomationWriteFileAction = { + id: 'write-cs2-default-server-config', + type: 'write_file', + path: `/${CS2_SERVER_CFG_PATH}`, + data: DEFAULT_CS2_SERVER_CFG, +}; + +const DEFAULT_CS2_SERVER_CONFIG_SHADOW_ACTION: ServerAutomationWriteFileAction = { + id: 'write-cs2-persisted-server-config', + type: 'write_file', + path: `/${CS2_PERSISTED_SERVER_CFG_PATH}`, + data: DEFAULT_CS2_SERVER_CFG, +}; + const DEFAULT_GAME_AUTOMATION_RULES: Record = { cs2: [ + { + id: 'cs2-write-default-server-config', + event: 'server.install.completed', + enabled: true, + runOncePerServer: true, + continueOnError: false, + actions: [ + { ...DEFAULT_CS2_SERVER_CONFIG_ACTION }, + { ...DEFAULT_CS2_SERVER_CONFIG_SHADOW_ACTION }, + ], + }, { id: 'cs2-install-latest-metamod', event: 'server.install.completed', @@ -147,6 +177,16 @@ function normalizeWorkflow( ): GameAutomationRule { if (gameSlug.toLowerCase() !== 'cs2') return workflow; + if (workflow.id === 'cs2-write-default-server-config') { + return { + ...workflow, + actions: [ + { ...DEFAULT_CS2_SERVER_CONFIG_ACTION }, + { ...DEFAULT_CS2_SERVER_CONFIG_SHADOW_ACTION }, + ], + }; + } + if (workflow.id === 'cs2-install-latest-counterstrikesharp-runtime') { const normalizedActions = workflow.actions.map((action) => { if (action.type !== 'github_release_extract') return action; diff --git a/apps/api/src/plugins/db.ts b/apps/api/src/plugins/db.ts index 3a00c61..c5c4ec8 100644 --- a/apps/api/src/plugins/db.ts +++ b/apps/api/src/plugins/db.ts @@ -1,6 +1,7 @@ import fp from 'fastify-plugin'; import type { FastifyInstance } from 'fastify'; import { createDb, type Database } from '@source/database'; +import { sql } from 'drizzle-orm'; declare module 'fastify' { interface FastifyInstance { @@ -17,5 +18,26 @@ export default fp(async (app: FastifyInstance) => { const db = createDb(databaseUrl); app.decorate('db', db); + await db.execute(sql.raw(` + CREATE TABLE IF NOT EXISTS server_databases ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + server_id uuid NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + name varchar(255) NOT NULL, + database_name varchar(255) NOT NULL UNIQUE, + username varchar(64) NOT NULL UNIQUE, + password text NOT NULL, + host varchar(255) NOT NULL, + port integer NOT NULL, + phpmyadmin_url text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() + ) + `)); + await db.execute( + sql.raw( + 'CREATE INDEX IF NOT EXISTS server_databases_server_id_idx ON server_databases(server_id)', + ), + ); + app.log.info('Database connected'); }); diff --git a/apps/api/src/plugins/socket.ts b/apps/api/src/plugins/socket.ts index cd14cb9..a69951c 100644 --- a/apps/api/src/plugins/socket.ts +++ b/apps/api/src/plugins/socket.ts @@ -20,6 +20,20 @@ declare module 'fastify' { } type ConsolePermission = 'console.read' | 'console.write'; +type ConsoleCommandAck = { + requestId: string | null; + ok: boolean; + error?: string; +}; + +interface SharedConsoleStream { + handle: DaemonConsoleStreamHandle; + subscribers: number; +} + +function roomForServer(serverId: string): string { + return `server:console:${serverId}`; +} export default fp(async (app: FastifyInstance) => { const io = new SocketIOServer(app.server, { @@ -32,7 +46,16 @@ export default fp(async (app: FastifyInstance) => { app.decorate('io', io); - const activeStreams = new Map(); + const serverStreams = new Map(); + const socketSubscriptions = new Map(); + + const clearServerSubscriptions = (serverId: string) => { + for (const [socketId, subscribedServerId] of socketSubscriptions.entries()) { + if (subscribedServerId === serverId) { + socketSubscriptions.delete(socketId); + } + } + }; io.use((socket, next) => { const token = typeof socket.handshake.auth?.token === 'string' @@ -61,10 +84,20 @@ export default fp(async (app: FastifyInstance) => { io.on('connection', (socket) => { const cleanupSocketStream = () => { - const current = activeStreams.get(socket.id); - if (!current) return; - current.close(); - activeStreams.delete(socket.id); + const subscribedServerId = socketSubscriptions.get(socket.id); + if (!subscribedServerId) return; + + socketSubscriptions.delete(socket.id); + socket.leave(roomForServer(subscribedServerId)); + + const shared = serverStreams.get(subscribedServerId); + if (!shared) return; + + shared.subscribers = Math.max(0, shared.subscribers - 1); + if (shared.subscribers === 0) { + shared.handle.close(); + serverStreams.delete(subscribedServerId); + } }; socket.on('server:console:join', async (payload: unknown) => { @@ -94,34 +127,63 @@ export default fp(async (app: FastifyInstance) => { return; } + const previousSubscription = socketSubscriptions.get(socket.id); + if (previousSubscription === serverId) { + return; + } cleanupSocketStream(); + socket.join(roomForServer(serverId)); - try { - const streamHandle = await daemonOpenConsoleStream(server.node, server.serverUuid); - streamHandle.stream.on('data', (output) => { - socket.emit('server:console:output', { line: output.line }); - }); - streamHandle.stream.on('end', () => { - activeStreams.delete(socket.id); - socket.emit('server:console:output', { line: '[console] Stream ended' }); - }); - streamHandle.stream.on('error', (error) => { - activeStreams.delete(socket.id); + let shared = serverStreams.get(serverId); + if (!shared) { + try { + const streamHandle = await daemonOpenConsoleStream(server.node, server.serverUuid); + const room = roomForServer(serverId); + + streamHandle.stream.on('data', (output) => { + io.to(room).emit('server:console:output', { line: output.line }); + }); + + streamHandle.stream.on('end', () => { + const current = serverStreams.get(serverId); + if (current?.handle !== streamHandle) return; + serverStreams.delete(serverId); + clearServerSubscriptions(serverId); + io.to(room).emit('server:console:output', { line: '[console] Stream ended' }); + io.in(room).socketsLeave(room); + }); + + streamHandle.stream.on('error', (error) => { + const current = serverStreams.get(serverId); + if (current?.handle !== streamHandle) return; + serverStreams.delete(serverId); + clearServerSubscriptions(serverId); + app.log.warn( + { error, serverId, serverUuid: server.serverUuid }, + 'Console stream failed', + ); + io.to(room).emit('server:console:output', { line: '[error] Console stream failed' }); + io.in(room).socketsLeave(room); + }); + + shared = { + handle: streamHandle, + subscribers: 0, + }; + serverStreams.set(serverId, shared); + } catch (error) { app.log.warn( { error, serverId, serverUuid: server.serverUuid, socketId: socket.id }, - 'Console stream failed', + 'Failed to open console stream', ); - socket.emit('server:console:output', { line: '[error] Console stream failed' }); - }); - - activeStreams.set(socket.id, streamHandle); - } catch (error) { - app.log.warn( - { error, serverId, serverUuid: server.serverUuid, socketId: socket.id }, - 'Failed to open console stream', - ); - socket.emit('server:console:output', { line: '[error] Failed to open console stream' }); + socket.leave(roomForServer(serverId)); + socket.emit('server:console:output', { line: '[error] Failed to open console stream' }); + return; + } } + + shared.subscribers += 1; + socketSubscriptions.set(socket.id, serverId); }); socket.on('server:console:leave', () => { @@ -133,43 +195,67 @@ export default fp(async (app: FastifyInstance) => { serverId?: unknown; orgId?: unknown; command?: unknown; + requestId?: unknown; }; const serverId = typeof body.serverId === 'string' ? body.serverId : ''; const orgId = typeof body.orgId === 'string' ? body.orgId : ''; const command = typeof body.command === 'string' ? body.command.trim() : ''; + const requestId = typeof body.requestId === 'string' && body.requestId.trim() + ? body.requestId.trim() + : null; if (!serverId || !orgId || !command) { socket.emit('server:console:output', { line: '[error] Invalid command payload' }); + const ack: ConsoleCommandAck = { + requestId, + ok: false, + error: 'Invalid command payload', + }; + socket.emit('server:console:command:ack', ack); return; } const user = (socket.data as { user?: AccessTokenPayload }).user; if (!user) { socket.emit('server:console:output', { line: '[error] Unauthorized' }); + const ack: ConsoleCommandAck = { requestId, ok: false, error: 'Unauthorized' }; + socket.emit('server:console:command:ack', ack); return; } const server = await getServerContext(app, serverId, orgId); if (!server) { socket.emit('server:console:output', { line: '[error] Server not found' }); + const ack: ConsoleCommandAck = { requestId, ok: false, error: 'Server not found' }; + socket.emit('server:console:command:ack', ack); return; } const allowed = await hasConsolePermission(app, user, orgId, 'console.write'); if (!allowed) { socket.emit('server:console:output', { line: '[error] Missing permission: console.write' }); + const ack: ConsoleCommandAck = { + requestId, + ok: false, + error: 'Missing permission: console.write', + }; + socket.emit('server:console:command:ack', ack); return; } try { await daemonSendCommand(server.node, server.serverUuid, command); + const ack: ConsoleCommandAck = { requestId, ok: true }; + socket.emit('server:console:command:ack', ack); } catch (error) { app.log.warn( { error, serverId, serverUuid: server.serverUuid, socketId: socket.id }, 'Failed to send console command', ); socket.emit('server:console:output', { line: '[error] Failed to send command' }); + const ack: ConsoleCommandAck = { requestId, ok: false, error: 'Failed to send command' }; + socket.emit('server:console:command:ack', ack); } }); @@ -179,10 +265,11 @@ export default fp(async (app: FastifyInstance) => { }); app.addHook('onClose', async () => { - for (const handle of activeStreams.values()) { - handle.close(); + for (const stream of serverStreams.values()) { + stream.handle.close(); } - activeStreams.clear(); + serverStreams.clear(); + socketSubscriptions.clear(); await new Promise((resolve) => { io.close(() => resolve()); diff --git a/apps/api/src/routes/admin/index.ts b/apps/api/src/routes/admin/index.ts index 591961a..eb1b6f7 100644 --- a/apps/api/src/routes/admin/index.ts +++ b/apps/api/src/routes/admin/index.ts @@ -1,12 +1,197 @@ import type { FastifyInstance } from 'fastify'; -import { eq, desc, count } from 'drizzle-orm'; -import { users, games, nodes, auditLogs } from '@source/database'; +import multipart from '@fastify/multipart'; +import { eq, desc, count, and } from 'drizzle-orm'; +import { Type } from '@sinclair/typebox'; +import { users, games, nodes, auditLogs, plugins, pluginReleases } from '@source/database'; import { AppError } from '../../lib/errors.js'; import { requireSuperAdmin } from '../../lib/permissions.js'; import { paginate, paginatedResponse, PaginationQuerySchema } from '../../lib/pagination.js'; -import { CreateGameSchema, UpdateGameSchema, GameIdParamSchema } from './schemas.js'; +import { uploadPluginArtifact } from '../../lib/cdn.js'; +import * as yazl from 'yazl'; +import { + CreateGameSchema, + UpdateGameSchema, + GameIdParamSchema, + PluginIdParamSchema, + PluginReleaseIdParamSchema, + CreateGlobalPluginSchema, + UpdateGlobalPluginSchema, + ImportPluginsSchema, + CreatePluginReleaseSchema, + UpdatePluginReleaseSchema, +} from './schemas.js'; + +type ReleaseChannel = 'stable' | 'beta' | 'alpha'; + +interface UploadArtifactFile { + relativePath: string; + data: Buffer; +} + +interface UploadJsonFile { + filename: string; + data: Buffer; +} + +function toSlug(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, '') + .slice(0, 200); +} + +function sanitizeRelativeSegments(path: string): string[] { + const segments = path.replace(/\\/g, '/').split('/').filter(Boolean); + const normalized: string[] = []; + + for (const segment of segments) { + if (segment === '.' || segment === '') continue; + if (segment === '..') { + throw AppError.badRequest('Invalid artifact path segment'); + } + normalized.push(segment); + } + + return normalized; +} + +function normalizeRelativePath(path: string, fallbackName: string): string { + const segments = sanitizeRelativeSegments(path); + if (segments.length === 0) { + return sanitizeRelativeSegments(fallbackName).join('/'); + } + return segments.join('/'); +} + +function parseJsonArrayField(rawValue: unknown, fieldName: string): unknown[] { + if (rawValue === undefined || rawValue === null || rawValue === '') return []; + if (typeof rawValue !== 'string') { + throw AppError.badRequest(`${fieldName} must be a JSON string`); + } + + let parsed: unknown; + try { + parsed = JSON.parse(rawValue); + } catch { + throw AppError.badRequest(`${fieldName} is not valid JSON`); + } + + if (!Array.isArray(parsed)) { + throw AppError.badRequest(`${fieldName} must be a JSON array`); + } + + return parsed; +} + +function parseJsonArrayUploadFile( + file: UploadJsonFile | null, + fieldName: string, +): unknown[] { + if (!file) return []; + + let rawValue = file.data.toString('utf8'); + if (rawValue.charCodeAt(0) === 0xfeff) { + rawValue = rawValue.slice(1); + } + + return parseJsonArrayField(rawValue, fieldName); +} + +function parseJsonArrayInput( + rawValue: unknown, + file: UploadJsonFile | null, + fieldName: string, +): unknown[] { + if (file) return parseJsonArrayUploadFile(file, fieldName); + return parseJsonArrayField(rawValue, fieldName); +} + +function parseOptionalBoolean(rawValue: unknown): boolean | undefined { + if (rawValue === undefined || rawValue === null || rawValue === '') return undefined; + if (typeof rawValue === 'boolean') return rawValue; + if (typeof rawValue !== 'string') return undefined; + + const normalized = rawValue.trim().toLowerCase(); + if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') return true; + if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') return false; + return undefined; +} + +function parseReleaseChannel(rawValue: unknown): ReleaseChannel { + if (rawValue === 'alpha' || rawValue === 'beta' || rawValue === 'stable') return rawValue; + if (typeof rawValue === 'string') { + const normalized = rawValue.trim().toLowerCase(); + if (normalized === 'alpha' || normalized === 'beta' || normalized === 'stable') return normalized; + } + return 'stable'; +} + +async function zipArtifacts(files: UploadArtifactFile[]): Promise { + return await new Promise((resolve, reject) => { + const archive = new yazl.ZipFile(); + const chunks: Buffer[] = []; + + archive.outputStream.on('data', (chunk: Buffer) => { + chunks.push(chunk); + }); + archive.outputStream.on('error', reject); + archive.outputStream.on('end', () => { + resolve(Buffer.concat(chunks)); + }); + + for (const file of files) { + archive.addBuffer(file.data, file.relativePath.replace(/^\/+/g, '')); + } + + archive.end(); + }); +} + +async function resolveImportGame( + app: FastifyInstance, + { + gameId, + gameSlug, + }: { + gameId?: string; + gameSlug?: string; + }, +) { + if (gameId) { + const game = await app.db.query.games.findFirst({ + where: eq(games.id, gameId), + }); + if (!game) { + throw AppError.notFound(`Game not found: ${gameId}`); + } + return game; + } + + const normalizedSlug = gameSlug?.trim().toLowerCase(); + if (normalizedSlug) { + const game = await app.db.query.games.findFirst({ + where: eq(games.slug, normalizedSlug), + }); + if (!game) { + throw AppError.notFound(`Game not found: ${normalizedSlug}`); + } + return game; + } + + throw AppError.badRequest('gameId or gameSlug is required for each import item'); +} export default async function adminRoutes(app: FastifyInstance) { + await app.register(multipart, { + limits: { + files: 200, + parts: 600, + fileSize: 512 * 1024 * 1024, + }, + }); + // All admin routes require auth + super admin app.addHook('onRequest', app.authenticate); app.addHook('onRequest', async (request) => { @@ -100,6 +285,617 @@ export default async function adminRoutes(app: FastifyInstance) { // === Nodes (global view) === + // === Global Plugins === + + app.get( + '/plugins', + { + schema: { + querystring: Type.Object({ + gameId: Type.Optional(Type.String({ format: 'uuid' })), + }), + }, + }, + async (request) => { + const { gameId } = request.query as { gameId?: string }; + + const rows = await app.db + .select({ + id: plugins.id, + gameId: plugins.gameId, + name: plugins.name, + slug: plugins.slug, + description: plugins.description, + source: plugins.source, + isGlobal: plugins.isGlobal, + updatedAt: plugins.updatedAt, + gameName: games.name, + gameSlug: games.slug, + }) + .from(plugins) + .innerJoin(games, eq(plugins.gameId, games.id)) + .where(gameId ? eq(plugins.gameId, gameId) : undefined) + .orderBy(plugins.name); + + return { data: rows }; + }, + ); + + app.post('/plugins', { schema: CreateGlobalPluginSchema }, async (request, reply) => { + const body = request.body as { + gameId: string; + name: string; + slug?: string; + description?: string; + source?: 'manual' | 'spiget'; + }; + + const game = await app.db.query.games.findFirst({ + where: eq(games.id, body.gameId), + }); + if (!game) throw AppError.notFound('Game not found'); + + const slug = toSlug(body.slug ?? body.name); + if (!slug) throw AppError.badRequest('Plugin slug is invalid'); + + const existing = await app.db.query.plugins.findFirst({ + where: and(eq(plugins.gameId, body.gameId), eq(plugins.slug, slug)), + }); + if (existing) throw AppError.conflict('Plugin slug already exists for this game'); + + const [created] = await app.db + .insert(plugins) + .values({ + gameId: body.gameId, + name: body.name, + slug, + description: body.description ?? null, + source: body.source ?? 'manual', + isGlobal: true, + }) + .returning(); + + return reply.code(201).send(created); + }); + + app.post('/plugins/import', { schema: ImportPluginsSchema }, async (request) => { + const body = request.body as { + defaultGameId?: string; + defaultGameSlug?: string; + stopOnError?: boolean; + items: Array<{ + gameId?: string; + gameSlug?: string; + plugin: { + name: string; + slug?: string; + description?: string; + source?: 'manual' | 'spiget'; + isGlobal?: boolean; + }; + release?: { + version: string; + channel?: 'stable' | 'beta' | 'alpha'; + artifactType?: 'file' | 'zip'; + artifactUrl: string; + destination?: string; + fileName?: string; + changelog?: string; + installSchema?: unknown[]; + configTemplates?: unknown[]; + isPublished?: boolean; + }; + }>; + }; + + const results: Array<{ + index: number; + success: boolean; + gameId?: string; + gameSlug?: string; + pluginId?: string; + pluginSlug?: string; + pluginAction?: 'created' | 'updated'; + releaseId?: string; + releaseVersion?: string; + releaseAction?: 'created' | 'updated' | 'skipped'; + error?: string; + }> = []; + + for (const [index, item] of body.items.entries()) { + try { + const game = await resolveImportGame(app, { + gameId: item.gameId ?? body.defaultGameId, + gameSlug: item.gameSlug ?? body.defaultGameSlug, + }); + + const pluginPayload = item.plugin; + const pluginSlug = toSlug(pluginPayload.slug ?? pluginPayload.name); + if (!pluginSlug) { + throw AppError.badRequest('Plugin slug is invalid'); + } + + const existingPlugin = await app.db.query.plugins.findFirst({ + where: and(eq(plugins.gameId, game.id), eq(plugins.slug, pluginSlug)), + }); + + let pluginRecord: typeof plugins.$inferSelect; + let pluginAction: 'created' | 'updated'; + + if (existingPlugin) { + const [updatedPlugin] = await app.db + .update(plugins) + .set({ + name: pluginPayload.name, + slug: pluginSlug, + description: + pluginPayload.description !== undefined + ? pluginPayload.description + : existingPlugin.description, + source: pluginPayload.source ?? existingPlugin.source, + isGlobal: pluginPayload.isGlobal ?? existingPlugin.isGlobal, + updatedAt: new Date(), + }) + .where(eq(plugins.id, existingPlugin.id)) + .returning(); + + if (!updatedPlugin) { + throw AppError.notFound('Plugin not found'); + } + + pluginRecord = updatedPlugin; + pluginAction = 'updated'; + } else { + const [createdPlugin] = await app.db + .insert(plugins) + .values({ + gameId: game.id, + name: pluginPayload.name, + slug: pluginSlug, + description: pluginPayload.description ?? null, + source: pluginPayload.source ?? 'manual', + isGlobal: pluginPayload.isGlobal ?? true, + }) + .returning(); + + if (!createdPlugin) { + throw new AppError(500, 'Failed to create plugin'); + } + + pluginRecord = createdPlugin; + pluginAction = 'created'; + } + + let releaseAction: 'created' | 'updated' | 'skipped' = 'skipped'; + let releaseRecord: typeof pluginReleases.$inferSelect | null = null; + + if (item.release) { + const releasePayload = item.release; + const existingRelease = await app.db.query.pluginReleases.findFirst({ + where: and( + eq(pluginReleases.pluginId, pluginRecord.id), + eq(pluginReleases.version, releasePayload.version), + ), + }); + + if (existingRelease) { + const [updatedRelease] = await app.db + .update(pluginReleases) + .set({ + channel: releasePayload.channel ?? existingRelease.channel, + artifactType: releasePayload.artifactType ?? existingRelease.artifactType, + artifactUrl: releasePayload.artifactUrl, + destination: + releasePayload.destination !== undefined + ? releasePayload.destination + : existingRelease.destination, + fileName: + releasePayload.fileName !== undefined + ? releasePayload.fileName + : existingRelease.fileName, + changelog: + releasePayload.changelog !== undefined + ? releasePayload.changelog + : existingRelease.changelog, + installSchema: releasePayload.installSchema ?? existingRelease.installSchema, + configTemplates: releasePayload.configTemplates ?? existingRelease.configTemplates, + isPublished: releasePayload.isPublished ?? existingRelease.isPublished, + updatedAt: new Date(), + }) + .where(eq(pluginReleases.id, existingRelease.id)) + .returning(); + + if (!updatedRelease) { + throw AppError.notFound('Plugin release not found'); + } + + releaseRecord = updatedRelease; + releaseAction = 'updated'; + } else { + const [createdRelease] = await app.db + .insert(pluginReleases) + .values({ + pluginId: pluginRecord.id, + version: releasePayload.version, + channel: releasePayload.channel ?? 'stable', + artifactType: releasePayload.artifactType ?? 'file', + artifactUrl: releasePayload.artifactUrl, + destination: releasePayload.destination ?? null, + fileName: releasePayload.fileName ?? null, + changelog: releasePayload.changelog ?? null, + installSchema: releasePayload.installSchema ?? [], + configTemplates: releasePayload.configTemplates ?? [], + isPublished: releasePayload.isPublished ?? true, + createdByUserId: request.user.sub, + }) + .returning(); + + if (!createdRelease) { + throw new AppError(500, 'Failed to create plugin release'); + } + + releaseRecord = createdRelease; + releaseAction = 'created'; + } + } + + results.push({ + index, + success: true, + gameId: game.id, + gameSlug: game.slug, + pluginId: pluginRecord.id, + pluginSlug: pluginRecord.slug, + pluginAction, + releaseId: releaseRecord?.id, + releaseVersion: releaseRecord?.version, + releaseAction, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (body.stopOnError) { + throw AppError.badRequest(`Import failed at item ${index}: ${message}`); + } + + results.push({ + index, + success: false, + error: message, + }); + } + } + + const succeeded = results.filter((result) => result.success).length; + const failed = results.length - succeeded; + + return { + results, + summary: { + total: results.length, + succeeded, + failed, + }, + }; + }); + + app.patch('/plugins/:pluginId', { schema: { ...PluginIdParamSchema, ...UpdateGlobalPluginSchema } }, async (request) => { + const { pluginId } = request.params as { pluginId: string }; + const body = request.body as { + name?: string; + slug?: string; + description?: string; + source?: 'manual' | 'spiget'; + isGlobal?: boolean; + }; + + const existing = await app.db.query.plugins.findFirst({ + where: eq(plugins.id, pluginId), + }); + if (!existing) throw AppError.notFound('Plugin not found'); + + const nextSlug = body.slug !== undefined + ? toSlug(body.slug) + : (body.name !== undefined ? toSlug(body.name) : existing.slug); + if (!nextSlug) throw AppError.badRequest('Plugin slug is invalid'); + + const duplicate = await app.db.query.plugins.findFirst({ + where: and(eq(plugins.gameId, existing.gameId), eq(plugins.slug, nextSlug)), + }); + if (duplicate && duplicate.id !== existing.id) { + throw AppError.conflict('Plugin slug already exists for this game'); + } + + const [updated] = await app.db + .update(plugins) + .set({ + name: body.name ?? existing.name, + slug: nextSlug, + description: body.description ?? existing.description, + source: body.source ?? existing.source, + isGlobal: body.isGlobal ?? existing.isGlobal, + updatedAt: new Date(), + }) + .where(eq(plugins.id, existing.id)) + .returning(); + + if (!updated) throw AppError.notFound('Plugin not found'); + return updated; + }); + + app.get('/plugins/:pluginId/releases', { schema: PluginIdParamSchema }, async (request) => { + const { pluginId } = request.params as { pluginId: string }; + + const plugin = await app.db.query.plugins.findFirst({ + where: eq(plugins.id, pluginId), + }); + if (!plugin) throw AppError.notFound('Plugin not found'); + + const releases = await app.db + .select() + .from(pluginReleases) + .where(eq(pluginReleases.pluginId, pluginId)) + .orderBy(desc(pluginReleases.createdAt)); + + return { plugin, releases }; + }); + + app.post('/plugins/:pluginId/releases/upload', { schema: PluginIdParamSchema }, async (request, reply) => { + const { pluginId } = request.params as { pluginId: string }; + + const plugin = await app.db.query.plugins.findFirst({ + where: eq(plugins.id, pluginId), + }); + if (!plugin) throw AppError.notFound('Plugin not found'); + + if (!request.isMultipart()) { + throw AppError.badRequest('Content-Type must be multipart/form-data'); + } + + const fields: Record = {}; + const files: UploadArtifactFile[] = []; + let installSchemaFile: UploadJsonFile | null = null; + let configTemplatesFile: UploadJsonFile | null = null; + const relativePathQueue: string[] = []; + + for await (const part of request.parts()) { + if (part.type === 'file') { + if (part.fieldname === 'installSchemaFile') { + const data = await part.toBuffer(); + if (data.length > 0) { + installSchemaFile = { + filename: part.filename || 'install-schema.json', + data, + }; + } + continue; + } + + if (part.fieldname === 'configTemplatesFile') { + const data = await part.toBuffer(); + if (data.length > 0) { + configTemplatesFile = { + filename: part.filename || 'config-templates.json', + data, + }; + } + continue; + } + + const fallbackName = `artifact-${files.length + 1}.bin`; + const queuedPath = relativePathQueue.shift(); + const relativePath = normalizeRelativePath( + queuedPath ?? part.filename ?? '', + fallbackName, + ); + const data = await part.toBuffer(); + if (data.length === 0) continue; + files.push({ relativePath, data }); + } else { + if (part.fieldname === 'relativePath') { + const raw = typeof part.value === 'string' ? part.value : ''; + relativePathQueue.push(raw); + continue; + } + + fields[part.fieldname] = part.value; + } + } + + if (files.length === 0) { + throw AppError.badRequest('At least one file is required'); + } + + const version = typeof fields.version === 'string' ? fields.version.trim() : ''; + if (!version) { + throw AppError.badRequest('version is required'); + } + + const channel = parseReleaseChannel(fields.channel); + const destination = typeof fields.destination === 'string' && fields.destination.trim().length > 0 + ? fields.destination.trim() + : null; + const changelog = typeof fields.changelog === 'string' && fields.changelog.trim().length > 0 + ? fields.changelog + : null; + const isPublished = parseOptionalBoolean(fields.isPublished) ?? true; + const installSchema = parseJsonArrayInput(fields.installSchema, installSchemaFile, 'installSchema'); + const configTemplates = parseJsonArrayInput( + fields.configTemplates, + configTemplatesFile, + 'configTemplates', + ); + + const rawFileName = typeof fields.fileName === 'string' ? fields.fileName.trim() : ''; + const hasNestedPaths = files.some((entry) => entry.relativePath.includes('/')); + const shouldZip = files.length > 1 || hasNestedPaths; + + let artifactType: 'file' | 'zip'; + let artifactContent: Buffer; + let uploadFileName: string; + let releaseFileName: string | null; + + if (shouldZip) { + artifactType = 'zip'; + artifactContent = await zipArtifacts(files); + + const suggestedName = rawFileName || `${toSlug(plugin.slug || plugin.name)}-${version}.zip`; + uploadFileName = suggestedName.toLowerCase().endsWith('.zip') + ? suggestedName + : `${suggestedName}.zip`; + releaseFileName = null; + } else { + artifactType = 'file'; + const [singleFile] = files; + if (!singleFile) { + throw AppError.badRequest('No artifact file received'); + } + + artifactContent = singleFile.data; + const originalName = singleFile.relativePath.split('/').pop() ?? 'artifact.bin'; + uploadFileName = rawFileName || originalName; + releaseFileName = uploadFileName; + } + + const uploaded = await uploadPluginArtifact(artifactContent, uploadFileName, { + pluginId: plugin.id, + pluginSlug: plugin.slug, + releaseVersion: version, + uploadedBy: request.user.sub, + uploadMode: shouldZip ? 'archive' : 'single', + sourceFileCount: files.length, + }); + + const [created] = await app.db + .insert(pluginReleases) + .values({ + pluginId: plugin.id, + version, + channel, + artifactType, + artifactUrl: uploaded.artifactPointer, + destination, + fileName: releaseFileName, + changelog, + installSchema, + configTemplates, + isPublished, + createdByUserId: request.user.sub, + }) + .returning(); + + return reply.code(201).send({ + release: created, + artifact: { + bucket: uploaded.bucket, + fileId: uploaded.file.id, + storedName: uploaded.file.storedName, + originalName: uploaded.file.originalName, + pointer: uploaded.artifactPointer, + }, + }); + }); + + app.post('/plugins/:pluginId/releases', { schema: { ...PluginIdParamSchema, ...CreatePluginReleaseSchema } }, async (request, reply) => { + const { pluginId } = request.params as { pluginId: string }; + const body = request.body as { + version: string; + channel?: 'stable' | 'beta' | 'alpha'; + artifactType?: 'file' | 'zip'; + artifactUrl: string; + destination?: string; + fileName?: string; + changelog?: string; + installSchema?: unknown[]; + configTemplates?: unknown[]; + isPublished?: boolean; + cloneFromReleaseId?: string; + }; + + const plugin = await app.db.query.plugins.findFirst({ + where: eq(plugins.id, pluginId), + }); + if (!plugin) throw AppError.notFound('Plugin not found'); + + let baseRelease: typeof pluginReleases.$inferSelect | null = null; + if (body.cloneFromReleaseId) { + baseRelease = await app.db.query.pluginReleases.findFirst({ + where: and( + eq(pluginReleases.id, body.cloneFromReleaseId), + eq(pluginReleases.pluginId, pluginId), + ), + }) ?? null; + if (!baseRelease) { + throw AppError.notFound('Clone source release not found'); + } + } + + const [created] = await app.db + .insert(pluginReleases) + .values({ + pluginId, + version: body.version, + channel: body.channel ?? baseRelease?.channel ?? 'stable', + artifactType: body.artifactType ?? baseRelease?.artifactType ?? 'file', + artifactUrl: body.artifactUrl, + destination: body.destination ?? baseRelease?.destination ?? null, + fileName: body.fileName ?? baseRelease?.fileName ?? null, + changelog: body.changelog ?? baseRelease?.changelog ?? null, + installSchema: body.installSchema ?? baseRelease?.installSchema ?? [], + configTemplates: body.configTemplates ?? baseRelease?.configTemplates ?? [], + isPublished: body.isPublished ?? baseRelease?.isPublished ?? true, + createdByUserId: request.user.sub, + }) + .returning(); + + return reply.code(201).send(created); + }); + + app.patch( + '/plugins/:pluginId/releases/:releaseId', + { schema: { ...PluginReleaseIdParamSchema, ...UpdatePluginReleaseSchema } }, + async (request) => { + const { pluginId, releaseId } = request.params as { pluginId: string; releaseId: string }; + const body = request.body as { + version?: string; + channel?: 'stable' | 'beta' | 'alpha'; + artifactType?: 'file' | 'zip'; + artifactUrl?: string; + destination?: string; + fileName?: string; + changelog?: string; + installSchema?: unknown[]; + configTemplates?: unknown[]; + isPublished?: boolean; + }; + + const release = await app.db.query.pluginReleases.findFirst({ + where: and(eq(pluginReleases.id, releaseId), eq(pluginReleases.pluginId, pluginId)), + }); + if (!release) throw AppError.notFound('Plugin release not found'); + + const [updated] = await app.db + .update(pluginReleases) + .set({ + version: body.version ?? release.version, + channel: body.channel ?? release.channel, + artifactType: body.artifactType ?? release.artifactType, + artifactUrl: body.artifactUrl ?? release.artifactUrl, + destination: body.destination ?? release.destination, + fileName: body.fileName ?? release.fileName, + changelog: body.changelog ?? release.changelog, + installSchema: body.installSchema ?? release.installSchema, + configTemplates: body.configTemplates ?? release.configTemplates, + isPublished: body.isPublished ?? release.isPublished, + updatedAt: new Date(), + }) + .where(eq(pluginReleases.id, release.id)) + .returning(); + + if (!updated) throw AppError.notFound('Plugin release not found'); + return updated; + }, + ); + // GET /api/admin/nodes app.get('/nodes', async () => { const nodeList = await app.db diff --git a/apps/api/src/routes/admin/schemas.ts b/apps/api/src/routes/admin/schemas.ts index 37f067c..320fabb 100644 --- a/apps/api/src/routes/admin/schemas.ts +++ b/apps/api/src/routes/admin/schemas.ts @@ -32,3 +32,132 @@ export const GameIdParamSchema = { gameId: Type.String({ format: 'uuid' }), }), }; + +export const PluginIdParamSchema = { + params: Type.Object({ + pluginId: Type.String({ format: 'uuid' }), + }), +}; + +export const PluginReleaseIdParamSchema = { + params: Type.Object({ + pluginId: Type.String({ format: 'uuid' }), + releaseId: Type.String({ format: 'uuid' }), + }), +}; + +export const CreateGlobalPluginSchema = { + body: Type.Object({ + gameId: Type.String({ format: 'uuid' }), + name: Type.String({ minLength: 1, maxLength: 255 }), + slug: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })), + description: Type.Optional(Type.String()), + source: Type.Optional(Type.Union([Type.Literal('manual'), Type.Literal('spiget')])), + }), +}; + +export const UpdateGlobalPluginSchema = { + body: Type.Object({ + name: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })), + slug: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })), + description: Type.Optional(Type.String()), + source: Type.Optional(Type.Union([Type.Literal('manual'), Type.Literal('spiget')])), + isGlobal: Type.Optional(Type.Boolean()), + }), +}; + +const ImportPluginPayloadSchema = Type.Object({ + name: Type.String({ minLength: 1, maxLength: 255 }), + slug: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })), + description: Type.Optional(Type.String()), + source: Type.Optional(Type.Union([Type.Literal('manual'), Type.Literal('spiget')])), + isGlobal: Type.Optional(Type.Boolean()), +}); + +export const ReleaseInstallFieldSchema = Type.Object({ + key: Type.String({ minLength: 1, maxLength: 120 }), + label: Type.String({ minLength: 1, maxLength: 255 }), + type: Type.Union([ + Type.Literal('text'), + Type.Literal('number'), + Type.Literal('boolean'), + Type.Literal('select'), + ]), + description: Type.Optional(Type.String({ maxLength: 1000 })), + required: Type.Optional(Type.Boolean()), + defaultValue: Type.Optional(Type.Any()), + options: Type.Optional(Type.Array(Type.Object({ + label: Type.String({ minLength: 1, maxLength: 255 }), + value: Type.String({ minLength: 1, maxLength: 255 }), + }))), + min: Type.Optional(Type.Number()), + max: Type.Optional(Type.Number()), + pattern: Type.Optional(Type.String({ maxLength: 500 })), + secret: Type.Optional(Type.Boolean()), +}); + +export const ReleaseTemplateSchema = Type.Object({ + path: Type.String({ minLength: 1 }), + content: Type.String(), +}); + +const ImportPluginReleasePayloadSchema = Type.Object({ + version: Type.String({ minLength: 1, maxLength: 100 }), + channel: Type.Optional(Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')])), + artifactType: Type.Optional(Type.Union([Type.Literal('file'), Type.Literal('zip')])), + artifactUrl: Type.String({ format: 'uri' }), + destination: Type.Optional(Type.String({ minLength: 1 })), + fileName: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })), + changelog: Type.Optional(Type.String()), + installSchema: Type.Optional(Type.Array(ReleaseInstallFieldSchema)), + configTemplates: Type.Optional(Type.Array(ReleaseTemplateSchema)), + isPublished: Type.Optional(Type.Boolean()), +}); + +export const ImportPluginsSchema = { + body: Type.Object({ + defaultGameId: Type.Optional(Type.String({ format: 'uuid' })), + defaultGameSlug: Type.Optional(Type.String({ minLength: 1, maxLength: 100 })), + stopOnError: Type.Optional(Type.Boolean()), + items: Type.Array( + Type.Object({ + gameId: Type.Optional(Type.String({ format: 'uuid' })), + gameSlug: Type.Optional(Type.String({ minLength: 1, maxLength: 100 })), + plugin: ImportPluginPayloadSchema, + release: Type.Optional(ImportPluginReleasePayloadSchema), + }), + { minItems: 1, maxItems: 500 }, + ), + }), +}; + +export const CreatePluginReleaseSchema = { + body: Type.Object({ + version: Type.String({ minLength: 1, maxLength: 100 }), + channel: Type.Optional(Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')])), + artifactType: Type.Optional(Type.Union([Type.Literal('file'), Type.Literal('zip')])), + artifactUrl: Type.String({ format: 'uri' }), + destination: Type.Optional(Type.String({ minLength: 1 })), + fileName: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })), + changelog: Type.Optional(Type.String()), + installSchema: Type.Optional(Type.Array(ReleaseInstallFieldSchema)), + configTemplates: Type.Optional(Type.Array(ReleaseTemplateSchema)), + isPublished: Type.Optional(Type.Boolean()), + cloneFromReleaseId: Type.Optional(Type.String({ format: 'uuid' })), + }), +}; + +export const UpdatePluginReleaseSchema = { + body: Type.Object({ + version: Type.Optional(Type.String({ minLength: 1, maxLength: 100 })), + channel: Type.Optional(Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')])), + artifactType: Type.Optional(Type.Union([Type.Literal('file'), Type.Literal('zip')])), + artifactUrl: Type.Optional(Type.String({ format: 'uri' })), + destination: Type.Optional(Type.String({ minLength: 1 })), + fileName: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })), + changelog: Type.Optional(Type.String()), + installSchema: Type.Optional(Type.Array(ReleaseInstallFieldSchema)), + configTemplates: Type.Optional(Type.Array(ReleaseTemplateSchema)), + isPublished: Type.Optional(Type.Boolean()), + }), +}; diff --git a/apps/api/src/routes/internal/index.ts b/apps/api/src/routes/internal/index.ts index 07a4cee..32585c4 100644 --- a/apps/api/src/routes/internal/index.ts +++ b/apps/api/src/routes/internal/index.ts @@ -12,6 +12,19 @@ function extractBearerToken(authHeader?: string): string | null { return token; } +function extractCdnWebhookSecret(request: FastifyRequest): string | null { + const byHeader = request.headers['x-cdn-webhook-secret'] ?? request.headers['x-webhook-secret']; + if (typeof byHeader === 'string' && byHeader.trim().length > 0) { + return byHeader.trim(); + } + + const authHeader = typeof request.headers.authorization === 'string' + ? request.headers.authorization + : undefined; + + return extractBearerToken(authHeader); +} + async function requireDaemonToken( app: FastifyInstance, request: FastifyRequest, @@ -39,6 +52,36 @@ async function requireDaemonToken( } export default async function internalRoutes(app: FastifyInstance) { + app.post( + '/cdn/webhook/plugins', + { + schema: { + body: Type.Optional(Type.Unknown()), + }, + }, + async (request, reply) => { + const configuredSecret = process.env.CDN_WEBHOOK_SECRET?.trim(); + if (configuredSecret) { + const providedSecret = extractCdnWebhookSecret(request); + if (!providedSecret || providedSecret !== configuredSecret) { + throw AppError.unauthorized('Invalid CDN webhook secret', 'CDN_WEBHOOK_AUTH_INVALID'); + } + } + + const body = request.body as Record | undefined; + const eventType = typeof body?.eventType === 'string' + ? body.eventType + : (typeof body?.type === 'string' ? body.type : 'unknown'); + + request.log.info( + { eventType, payload: body }, + 'Received CDN plugin webhook event', + ); + + return reply.code(202).send({ accepted: true }); + }, + ); + app.get('/schedules/due', async (request) => { const node = await requireDaemonToken(app, request); const now = new Date(); diff --git a/apps/api/src/routes/servers/config.ts b/apps/api/src/routes/servers/config.ts index 84e3f23..3f366b9 100644 --- a/apps/api/src/routes/servers/config.ts +++ b/apps/api/src/routes/servers/config.ts @@ -7,6 +7,11 @@ import { AppError } from '../../lib/errors.js'; import { requirePermission } from '../../lib/permissions.js'; import { parseConfig, serializeConfig } from '../../lib/config-parsers.js'; import { daemonReadFile, daemonWriteFile, type DaemonNodeConnection } from '../../lib/daemon.js'; +import { + isManagedCs2ServerConfigPath, + readManagedCs2ServerConfig, + writeManagedCs2ServerConfig, +} from '../../lib/cs2-server-config.js'; const ParamSchema = { params: Type.Object({ @@ -61,12 +66,16 @@ export default async function configRoutes(app: FastifyInstance) { }; await requirePermission(request, orgId, 'config.read'); - const { server, node, configFile } = await getServerConfig(app, orgId, serverId, configIndex); + const { game, server, node, configFile } = await getServerConfig(app, orgId, serverId, configIndex); let raw = ''; try { - const file = await daemonReadFile(node, server.uuid, configFile.path); - raw = file.data.toString('utf8'); + if (isManagedCs2ServerConfigPath(game.slug, configFile.path)) { + raw = await readManagedCs2ServerConfig(node, server.uuid); + } else { + const file = await daemonReadFile(node, server.uuid, configFile.path); + raw = file.data.toString('utf8'); + } } catch (error) { if (!isMissingConfigFileError(error)) { app.log.error({ error, serverId, path: configFile.path }, 'Failed to read config file from daemon'); @@ -109,13 +118,19 @@ export default async function configRoutes(app: FastifyInstance) { const { entries } = request.body as { entries: { key: string; value: string }[] }; await requirePermission(request, orgId, 'config.write'); - const { server, node, configFile } = await getServerConfig(app, orgId, serverId, configIndex); + const { game, server, node, configFile } = await getServerConfig(app, orgId, serverId, configIndex); + + const isManagedCs2Config = isManagedCs2ServerConfigPath(game.slug, configFile.path); let originalContent: string | undefined; let originalEntries: { key: string; value: string }[] = []; try { - const current = await daemonReadFile(node, server.uuid, configFile.path); - originalContent = current.data.toString('utf8'); + if (isManagedCs2Config) { + originalContent = await readManagedCs2ServerConfig(node, server.uuid); + } else { + const current = await daemonReadFile(node, server.uuid, configFile.path); + originalContent = current.data.toString('utf8'); + } originalEntries = parseConfig(originalContent, configFile.parser as ConfigParser); } catch (error) { if (!isMissingConfigFileError(error)) { @@ -146,7 +161,11 @@ export default async function configRoutes(app: FastifyInstance) { originalContent, ); - await daemonWriteFile(node, server.uuid, configFile.path, content); + if (isManagedCs2Config) { + await writeManagedCs2ServerConfig(node, server.uuid, content); + } else { + await daemonWriteFile(node, server.uuid, configFile.path, content); + } return { success: true, path: configFile.path, content }; }, ); diff --git a/apps/api/src/routes/servers/databases.ts b/apps/api/src/routes/servers/databases.ts new file mode 100644 index 0000000..b2aa052 --- /dev/null +++ b/apps/api/src/routes/servers/databases.ts @@ -0,0 +1,343 @@ +import type { FastifyInstance } from 'fastify'; +import { Type } from '@sinclair/typebox'; +import { and, eq } from 'drizzle-orm'; +import { nodes, serverDatabases, servers } from '@source/database'; +import { AppError } from '../../lib/errors.js'; +import { requirePermission } from '../../lib/permissions.js'; +import { createAuditLog } from '../../lib/audit.js'; +import { + daemonCreateDatabase, + daemonDeleteDatabase, + daemonUpdateDatabasePassword, + type DaemonNodeConnection, +} from '../../lib/daemon.js'; + +const ServerDatabaseParamSchema = { + params: Type.Object({ + orgId: Type.String({ format: 'uuid' }), + serverId: Type.String({ format: 'uuid' }), + databaseId: Type.String({ format: 'uuid' }), + }), +}; + +const ServerScopeSchema = { + params: Type.Object({ + orgId: Type.String({ format: 'uuid' }), + serverId: Type.String({ format: 'uuid' }), + }), +}; + +const CreateServerDatabaseSchema = { + body: Type.Object({ + name: Type.String({ minLength: 1, maxLength: 255 }), + password: Type.Optional(Type.String({ minLength: 8, maxLength: 255 })), + }), +}; + +const UpdateServerDatabaseSchema = { + body: Type.Object({ + name: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })), + password: Type.Optional(Type.String({ minLength: 8, maxLength: 255 })), + }), +}; + +async function getServerContext(app: FastifyInstance, orgId: string, serverId: string) { + const [server] = await app.db + .select({ + id: servers.id, + name: servers.name, + uuid: servers.uuid, + nodeFqdn: nodes.fqdn, + nodeGrpcPort: nodes.grpcPort, + nodeDaemonToken: nodes.daemonToken, + }) + .from(servers) + .innerJoin(nodes, eq(servers.nodeId, nodes.id)) + .where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId))); + + if (!server) { + throw AppError.notFound('Server not found'); + } + + return server; +} + +function buildNodeConnection(server: { + nodeDaemonToken: string; + nodeFqdn: string; + nodeGrpcPort: number; +}): DaemonNodeConnection { + return { + fqdn: server.nodeFqdn, + grpcPort: server.nodeGrpcPort, + daemonToken: server.nodeDaemonToken, + }; +} + +function daemonErrorMessage(error: unknown, fallback: string): string { + if (error instanceof Error && error.message.trim()) { + return error.message; + } + + return fallback; +} + +export default async function databaseRoutes(app: FastifyInstance) { + app.addHook('onRequest', app.authenticate); + + app.get('/', { schema: ServerScopeSchema }, async (request) => { + const { orgId, serverId } = request.params as { orgId: string; serverId: string }; + await requirePermission(request, orgId, 'server.read'); + await getServerContext(app, orgId, serverId); + + const databases = await app.db + .select() + .from(serverDatabases) + .where(eq(serverDatabases.serverId, serverId)) + .orderBy(serverDatabases.createdAt); + + return { data: databases }; + }); + + app.post('/', { schema: { ...ServerScopeSchema, ...CreateServerDatabaseSchema } }, async (request, reply) => { + const { orgId, serverId } = request.params as { orgId: string; serverId: string }; + await requirePermission(request, orgId, 'server.update'); + + const body = request.body as { name: string; password?: string }; + const name = body.name.trim(); + if (!name) { + throw AppError.badRequest('Database name is required'); + } + + const server = await getServerContext(app, orgId, serverId); + + let managedDatabase; + try { + managedDatabase = await daemonCreateDatabase(buildNodeConnection(server), { + name, + password: body.password, + serverUuid: server.uuid, + }); + } catch (error) { + request.log.error( + { error, orgId, serverId, serverUuid: server.uuid }, + 'Failed to provision node-local MySQL database', + ); + throw new AppError( + 502, + daemonErrorMessage(error, 'Failed to provision node-local MySQL database'), + 'MANAGED_MYSQL_CREATE_FAILED', + ); + } + + try { + const [created] = await app.db + .insert(serverDatabases) + .values({ + serverId, + name, + databaseName: managedDatabase.databaseName, + username: managedDatabase.username, + password: managedDatabase.password, + host: managedDatabase.host, + port: managedDatabase.port, + phpMyAdminUrl: managedDatabase.phpMyAdminUrl, + }) + .returning(); + + await createAuditLog(app.db, request, { + organizationId: orgId, + serverId, + action: 'server.database.create', + metadata: { + name: created!.name, + databaseName: created!.databaseName, + username: created!.username, + }, + }); + + return reply.code(201).send(created); + } catch (error) { + try { + await daemonDeleteDatabase(buildNodeConnection(server), { + databaseName: managedDatabase.databaseName, + username: managedDatabase.username, + }); + } catch (cleanupError) { + request.log.error( + { cleanupError, orgId, serverId, databaseName: managedDatabase.databaseName }, + 'Failed to roll back node-local MySQL database after panel insert failure', + ); + } + + request.log.error( + { error, orgId, serverId, databaseName: managedDatabase.databaseName }, + 'Failed to persist managed MySQL database metadata', + ); + throw new AppError(500, 'Failed to save database metadata', 'SERVER_DATABASE_SAVE_FAILED'); + } + }); + + app.patch('/:databaseId', { schema: { ...ServerDatabaseParamSchema, ...UpdateServerDatabaseSchema } }, async (request) => { + const { orgId, serverId, databaseId } = request.params as { + databaseId: string; + orgId: string; + serverId: string; + }; + await requirePermission(request, orgId, 'server.update'); + + const body = request.body as { name?: string; password?: string }; + + const [current] = await app.db + .select({ + id: serverDatabases.id, + name: serverDatabases.name, + databaseName: serverDatabases.databaseName, + username: serverDatabases.username, + password: serverDatabases.password, + host: serverDatabases.host, + port: serverDatabases.port, + phpMyAdminUrl: serverDatabases.phpMyAdminUrl, + createdAt: serverDatabases.createdAt, + updatedAt: serverDatabases.updatedAt, + nodeFqdn: nodes.fqdn, + nodeGrpcPort: nodes.grpcPort, + nodeDaemonToken: nodes.daemonToken, + }) + .from(serverDatabases) + .innerJoin(servers, eq(serverDatabases.serverId, servers.id)) + .innerJoin(nodes, eq(servers.nodeId, nodes.id)) + .where( + and( + eq(serverDatabases.id, databaseId), + eq(serverDatabases.serverId, serverId), + eq(servers.organizationId, orgId), + ), + ); + + if (!current) { + throw AppError.notFound('Database not found'); + } + + const nextName = body.name === undefined ? undefined : body.name.trim(); + if (body.name !== undefined && !nextName) { + throw AppError.badRequest('Database name is required'); + } + + const nextPassword = body.password?.trim(); + if (!nextName && !nextPassword) { + return current; + } + + if (nextPassword) { + try { + await daemonUpdateDatabasePassword(buildNodeConnection(current), { + password: nextPassword, + username: current.username, + }); + } catch (error) { + request.log.error( + { error, orgId, serverId, databaseId, username: current.username }, + 'Failed to rotate node-local MySQL password', + ); + throw new AppError( + 502, + daemonErrorMessage(error, 'Failed to rotate database password'), + 'MANAGED_MYSQL_PASSWORD_UPDATE_FAILED', + ); + } + } + + const patch: Record = { + updatedAt: new Date(), + }; + if (nextName) patch.name = nextName; + if (nextPassword) patch.password = nextPassword; + + const [updated] = await app.db + .update(serverDatabases) + .set(patch) + .where(eq(serverDatabases.id, databaseId)) + .returning(); + + await createAuditLog(app.db, request, { + organizationId: orgId, + serverId, + action: 'server.database.update', + metadata: { + databaseId, + updatedName: nextName ?? undefined, + passwordRotated: Boolean(nextPassword), + }, + }); + + return updated; + }); + + app.delete('/:databaseId', { schema: ServerDatabaseParamSchema }, async (request, reply) => { + const { orgId, serverId, databaseId } = request.params as { + databaseId: string; + orgId: string; + serverId: string; + }; + await requirePermission(request, orgId, 'server.update'); + + const [current] = await app.db + .select({ + id: serverDatabases.id, + name: serverDatabases.name, + databaseName: serverDatabases.databaseName, + username: serverDatabases.username, + nodeFqdn: nodes.fqdn, + nodeGrpcPort: nodes.grpcPort, + nodeDaemonToken: nodes.daemonToken, + }) + .from(serverDatabases) + .innerJoin(servers, eq(serverDatabases.serverId, servers.id)) + .innerJoin(nodes, eq(servers.nodeId, nodes.id)) + .where( + and( + eq(serverDatabases.id, databaseId), + eq(serverDatabases.serverId, serverId), + eq(servers.organizationId, orgId), + ), + ); + + if (!current) { + throw AppError.notFound('Database not found'); + } + + try { + await daemonDeleteDatabase(buildNodeConnection(current), { + databaseName: current.databaseName, + username: current.username, + }); + } catch (error) { + request.log.error( + { error, orgId, serverId, databaseId, databaseName: current.databaseName }, + 'Failed to delete node-local MySQL database', + ); + throw new AppError( + 502, + daemonErrorMessage(error, 'Failed to delete node-local MySQL database'), + 'MANAGED_MYSQL_DELETE_FAILED', + ); + } + + await app.db.delete(serverDatabases).where(eq(serverDatabases.id, databaseId)); + + await createAuditLog(app.db, request, { + organizationId: orgId, + serverId, + action: 'server.database.delete', + metadata: { + databaseId, + name: current.name, + databaseName: current.databaseName, + username: current.username, + }, + }); + + return reply.code(204).send(); + }); +} diff --git a/apps/api/src/routes/servers/files.ts b/apps/api/src/routes/servers/files.ts index 77d4212..3506557 100644 --- a/apps/api/src/routes/servers/files.ts +++ b/apps/api/src/routes/servers/files.ts @@ -11,6 +11,13 @@ import { daemonWriteFile, type DaemonNodeConnection, } from '../../lib/daemon.js'; +import { + CS2_PERSISTED_SERVER_CFG_PATH, + CS2_PERSISTED_SERVER_CFG_FILE, + isManagedCs2ServerConfigPath, + readManagedCs2ServerConfig, + writeManagedCs2ServerConfig, +} from '../../lib/cs2-server-config.js'; const FileParamSchema = { params: Type.Object({ @@ -21,6 +28,7 @@ const FileParamSchema = { function shouldHideFileForGame(gameSlug: string, fileName: string, isDirectory: boolean): boolean { if (gameSlug !== 'cs2') return false; + if (fileName.trim() === CS2_PERSISTED_SERVER_CFG_FILE) return true; if (isDirectory) return false; const normalizedName = fileName.trim().toLowerCase(); @@ -96,16 +104,28 @@ export default async function fileRoutes(app: FastifyInstance) { await requirePermission(request, orgId, 'files.read'); const serverContext = await getServerContext(app, orgId, serverId); - const content = await daemonReadFile(serverContext.node, serverContext.serverUuid, path); const requestedEncoding = encoding === 'base64' ? 'base64' : 'utf8'; + let payload: Buffer; + let mimeType = 'text/plain'; + + if (isManagedCs2ServerConfigPath(serverContext.gameSlug, path)) { + payload = Buffer.from( + await readManagedCs2ServerConfig(serverContext.node, serverContext.serverUuid), + 'utf8', + ); + } else { + const content = await daemonReadFile(serverContext.node, serverContext.serverUuid, path); + payload = content.data; + mimeType = content.mimeType; + } return { data: requestedEncoding === 'base64' - ? content.data.toString('base64') - : content.data.toString('utf8'), + ? payload.toString('base64') + : payload.toString('utf8'), encoding: requestedEncoding, - mimeType: content.mimeType, + mimeType, }; }, ); @@ -136,7 +156,11 @@ export default async function fileRoutes(app: FastifyInstance) { const payload = encoding === 'base64' ? decodeBase64Payload(data) : data; - await daemonWriteFile(serverContext.node, serverContext.serverUuid, path, payload); + if (isManagedCs2ServerConfigPath(serverContext.gameSlug, path)) { + await writeManagedCs2ServerConfig(serverContext.node, serverContext.serverUuid, payload); + } else { + await daemonWriteFile(serverContext.node, serverContext.serverUuid, path, payload); + } return { success: true, path }; }, ); @@ -158,7 +182,18 @@ export default async function fileRoutes(app: FastifyInstance) { await requirePermission(request, orgId, 'files.delete'); const serverContext = await getServerContext(app, orgId, serverId); - await daemonDeleteFiles(serverContext.node, serverContext.serverUuid, paths); + const resolvedPaths = paths.flatMap((path) => + isManagedCs2ServerConfigPath(serverContext.gameSlug, path) + ? [ + path, + path.trim().startsWith('/') + ? `/${CS2_PERSISTED_SERVER_CFG_PATH}` + : CS2_PERSISTED_SERVER_CFG_PATH, + ] + : [path], + ); + + await daemonDeleteFiles(serverContext.node, serverContext.serverUuid, resolvedPaths); return { success: true, paths }; }, ); diff --git a/apps/api/src/routes/servers/index.ts b/apps/api/src/routes/servers/index.ts index a56a923..95b8d40 100644 --- a/apps/api/src/routes/servers/index.ts +++ b/apps/api/src/routes/servers/index.ts @@ -3,7 +3,7 @@ import { Type } from '@sinclair/typebox'; import { eq, and, count } from 'drizzle-orm'; import { randomUUID } from 'crypto'; import { setTimeout as sleep } from 'timers/promises'; -import { servers, allocations, nodes, games } from '@source/database'; +import { servers, allocations, nodes, games, serverDatabases } from '@source/database'; import type { GameAutomationRule, PowerAction, ServerAutomationEvent } from '@source/shared'; import { AppError } from '../../lib/errors.js'; import { requirePermission } from '../../lib/permissions.js'; @@ -15,9 +15,11 @@ import { daemonDeleteServer, daemonGetServerStatus, daemonSetPowerState, + daemonUpdateServer, type DaemonNodeConnection, type DaemonPortMapping, } from '../../lib/daemon.js'; +import { reapplyManagedCs2ServerConfig } from '../../lib/cs2-server-config.js'; import { ServerParamSchema, CreateServerSchema, @@ -30,8 +32,10 @@ import pluginRoutes from './plugins.js'; import playerRoutes from './players.js'; import scheduleRoutes from './schedules.js'; import backupRoutes from './backups.js'; +import databaseRoutes from './databases.js'; type MutableServerStatus = 'installing' | 'running' | 'stopped' | 'error'; +type RuntimeServerStatus = MutableServerStatus | 'starting' | 'stopping' | 'suspended'; function mapDaemonStatus(rawStatus: string): MutableServerStatus | null { switch (rawStatus.toLowerCase()) { @@ -52,6 +56,27 @@ function mapDaemonStatus(rawStatus: string): MutableServerStatus | null { } } +function normalizeRuntimeServerStatus(rawStatus: string): RuntimeServerStatus | null { + switch (rawStatus.toLowerCase()) { + case 'installing': + return 'installing'; + case 'running': + return 'running'; + case 'stopped': + return 'stopped'; + case 'starting': + return 'starting'; + case 'stopping': + return 'stopping'; + case 'error': + return 'error'; + case 'suspended': + return 'suspended'; + default: + return null; + } +} + function buildDaemonEnvironment( gameEnvVarsRaw: unknown, overrides: Record | undefined, @@ -63,6 +88,7 @@ function buildDaemonEnvironment( for (const item of gameEnvVarsRaw) { if (!item || typeof item !== 'object') continue; const record = item as Record; + if (typeof record.composeInto === 'string' && record.composeInto.trim()) continue; const key = typeof record.key === 'string' ? record.key.trim() : ''; if (!key) continue; @@ -99,6 +125,34 @@ function buildDaemonPorts(gameSlug: string, allocationPort: number, containerPor return [{ host_port: allocationPort, container_port: containerPort, protocol: 'tcp' }]; } +function normalizeEnvironmentOverrides(raw: unknown): Record { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}; + + const normalized: Record = {}; + for (const [key, value] of Object.entries(raw as Record)) { + const normalizedKey = key.trim(); + if (!normalizedKey) continue; + normalized[normalizedKey] = String(value ?? ''); + } + + return normalized; +} + +function sameEnvironmentOverrides( + left: Record, + right: Record, +): boolean { + const leftEntries = Object.entries(left); + const rightEntries = Object.entries(right); + if (leftEntries.length !== rightEntries.length) return false; + + for (const [key, value] of leftEntries) { + if (right[key] !== value) return false; + } + + return true; +} + async function syncServerInstallStatus( app: FastifyInstance, node: DaemonNodeConnection, @@ -162,6 +216,32 @@ async function syncServerInstallStatus( ); } +async function sustainCs2ServerConfigAfterPowerStart( + app: FastifyInstance, + node: DaemonNodeConnection, + serverId: string, + serverUuid: string, + gameSlug: string, +): Promise { + if (gameSlug.trim().toLowerCase() !== 'cs2') return; + + const attempts = 6; + const intervalMs = 10_000; + + for (let attempt = 1; attempt <= attempts; attempt += 1) { + await sleep(intervalMs); + + try { + await reapplyManagedCs2ServerConfig(node, serverUuid); + } catch (error) { + app.log.warn( + { error, serverId, serverUuid, attempt }, + 'Failed to reapply managed CS2 server.cfg after power start', + ); + } + } +} + export default async function serverRoutes(app: FastifyInstance) { app.addHook('onRequest', app.authenticate); @@ -172,6 +252,7 @@ export default async function serverRoutes(app: FastifyInstance) { await app.register(playerRoutes, { prefix: '/:serverId/players' }); await app.register(scheduleRoutes, { prefix: '/:serverId/schedules' }); await app.register(backupRoutes, { prefix: '/:serverId/backups' }); + await app.register(databaseRoutes, { prefix: '/:serverId/databases' }); // GET /api/organizations/:orgId/servers app.get('/', { schema: { querystring: PaginationQuerySchema } }, async (request) => { @@ -463,6 +544,8 @@ export default async function serverRoutes(app: FastifyInstance) { nodeId: nodes.id, nodeName: nodes.name, nodeFqdn: nodes.fqdn, + nodeGrpcPort: nodes.grpcPort, + nodeDaemonToken: nodes.daemonToken, gameId: games.id, gameName: games.name, gameSlug: games.slug, @@ -474,7 +557,55 @@ export default async function serverRoutes(app: FastifyInstance) { if (!server) throw AppError.notFound('Server not found'); - return server; + let liveStatus: RuntimeServerStatus = server.status; + + if (server.status !== 'suspended') { + try { + const daemonStatus = await daemonGetServerStatus( + { + fqdn: server.nodeFqdn, + grpcPort: server.nodeGrpcPort, + daemonToken: server.nodeDaemonToken, + }, + server.uuid, + { + connectTimeoutMs: 1_500, + rpcTimeoutMs: 2_500, + }, + ); + const normalized = normalizeRuntimeServerStatus(daemonStatus.state); + if (normalized) { + liveStatus = normalized; + + if (normalized !== 'starting' && normalized !== 'stopping') { + const persistedStatus = mapDaemonStatus(normalized); + if (persistedStatus && persistedStatus !== server.status) { + await app.db + .update(servers) + .set({ + status: persistedStatus, + installedAt: persistedStatus === 'running' || persistedStatus === 'stopped' + ? (server.installedAt ?? new Date()) + : server.installedAt, + updatedAt: new Date(), + }) + .where(eq(servers.id, server.id)); + } + } + } + } catch (error) { + app.log.warn( + { error, serverId: server.id, serverUuid: server.uuid }, + 'Failed to fetch live daemon status for server detail', + ); + } + } + + const { nodeGrpcPort: _nodeGrpcPort, nodeDaemonToken: _nodeDaemonToken, ...response } = server; + return { + ...response, + status: liveStatus, + }; }); // PATCH /api/organizations/:orgId/servers/:serverId @@ -482,21 +613,121 @@ export default async function serverRoutes(app: FastifyInstance) { const { orgId, serverId } = request.params as { orgId: string; serverId: string }; await requirePermission(request, orgId, 'server.update'); - const body = request.body as Record; + const body = request.body as { + name?: string; + description?: string; + memoryLimit?: number; + diskLimit?: number; + cpuLimit?: number; + environment?: Record; + startupOverride?: string; + }; + + const [current] = await app.db + .select({ + id: servers.id, + uuid: servers.uuid, + organizationId: servers.organizationId, + name: servers.name, + description: servers.description, + status: servers.status, + memoryLimit: servers.memoryLimit, + diskLimit: servers.diskLimit, + cpuLimit: servers.cpuLimit, + port: servers.port, + environment: servers.environment, + startupOverride: servers.startupOverride, + nodeFqdn: nodes.fqdn, + nodeGrpcPort: nodes.grpcPort, + nodeDaemonToken: nodes.daemonToken, + gameDockerImage: games.dockerImage, + gameDefaultPort: games.defaultPort, + gameSlug: games.slug, + gameStartupCommand: games.startupCommand, + gameEnvironmentVars: games.environmentVars, + }) + .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 (!current) throw AppError.notFound('Server not found'); + + const nextMemoryLimit = body.memoryLimit ?? current.memoryLimit; + const nextDiskLimit = body.diskLimit ?? current.diskLimit; + const nextCpuLimit = body.cpuLimit ?? current.cpuLimit; + const currentEnvironment = normalizeEnvironmentOverrides(current.environment); + const nextEnvironment = body.environment !== undefined + ? normalizeEnvironmentOverrides(body.environment) + : currentEnvironment; + const nextStartupOverride = body.startupOverride !== undefined + ? (body.startupOverride.trim() || null) + : (current.startupOverride ?? null); + + const needsRuntimeRecreate = + nextMemoryLimit !== current.memoryLimit || + nextDiskLimit !== current.diskLimit || + nextCpuLimit !== current.cpuLimit || + !sameEnvironmentOverrides(nextEnvironment, currentEnvironment) || + nextStartupOverride !== (current.startupOverride ?? null); + + let nextStatus: MutableServerStatus | null = null; + if (needsRuntimeRecreate) { + try { + const response = await daemonUpdateServer( + { + fqdn: current.nodeFqdn, + grpcPort: current.nodeGrpcPort, + daemonToken: current.nodeDaemonToken, + }, + { + uuid: current.uuid, + docker_image: current.gameDockerImage, + memory_limit: nextMemoryLimit, + disk_limit: nextDiskLimit, + cpu_limit: nextCpuLimit, + startup_command: nextStartupOverride ?? current.gameStartupCommand, + environment: buildDaemonEnvironment( + current.gameEnvironmentVars, + nextEnvironment, + nextMemoryLimit, + ), + ports: buildDaemonPorts(current.gameSlug, current.port, current.gameDefaultPort), + }, + ); + nextStatus = mapDaemonStatus(response.status); + } catch (error) { + app.log.error( + { error, serverId: current.id, serverUuid: current.uuid }, + 'Failed to update server on daemon', + ); + throw new AppError(502, 'Failed to apply runtime changes on daemon', 'DAEMON_UPDATE_FAILED'); + } + } + + const patch: Record = { + updatedAt: new Date(), + }; + if (body.name !== undefined) patch.name = body.name; + if (body.description !== undefined) patch.description = body.description; + if (body.memoryLimit !== undefined) patch.memoryLimit = nextMemoryLimit; + if (body.diskLimit !== undefined) patch.diskLimit = nextDiskLimit; + if (body.cpuLimit !== undefined) patch.cpuLimit = nextCpuLimit; + if (body.environment !== undefined) patch.environment = nextEnvironment; + if (body.startupOverride !== undefined) patch.startupOverride = nextStartupOverride; + if (nextStatus) patch.status = nextStatus; const [updated] = await app.db .update(servers) - .set({ ...body, updatedAt: new Date() }) + .set(patch) .where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId))) .returning(); - if (!updated) throw AppError.notFound('Server not found'); - await createAuditLog(app.db, request, { organizationId: orgId, serverId, action: 'server.update', - metadata: body, + metadata: patch, }); return updated; @@ -507,6 +738,15 @@ export default async function serverRoutes(app: FastifyInstance) { const { orgId, serverId } = request.params as { orgId: string; serverId: string }; await requirePermission(request, orgId, 'server.delete'); + const [databaseUsage] = await app.db + .select({ count: count() }) + .from(serverDatabases) + .where(eq(serverDatabases.serverId, serverId)); + + if ((databaseUsage?.count ?? 0) > 0) { + throw AppError.conflict('Delete server databases before deleting the server'); + } + const [server] = await app.db .select({ id: servers.id, @@ -633,6 +873,18 @@ export default async function serverRoutes(app: FastifyInstance) { .where(eq(servers.id, serverId)); if (serverWithGame) { + void sustainCs2ServerConfigAfterPowerStart( + app, + { + fqdn: server.nodeFqdn, + grpcPort: server.nodeGrpcPort, + daemonToken: server.nodeDaemonToken, + }, + serverId, + server.uuid, + serverWithGame.gameSlug, + ); + void runServerAutomationEvent(app, { serverId, serverUuid: server.uuid, diff --git a/apps/api/src/routes/servers/plugins.ts b/apps/api/src/routes/servers/plugins.ts index 5735212..1d1b468 100644 --- a/apps/api/src/routes/servers/plugins.ts +++ b/apps/api/src/routes/servers/plugins.ts @@ -1,12 +1,21 @@ import type { FastifyInstance } from 'fastify'; -import { eq, and } from 'drizzle-orm'; +import { eq, and, desc, inArray } from 'drizzle-orm'; import { Type } from '@sinclair/typebox'; -import { servers, plugins, serverPlugins, games, nodes } from '@source/database'; +import { + servers, + plugins, + serverPlugins, + games, + nodes, + pluginReleases, + serverPluginFiles, +} from '@source/database'; import { AppError } from '../../lib/errors.js'; import { requirePermission } from '../../lib/permissions.js'; import { createAuditLog } from '../../lib/audit.js'; import { daemonDeleteFiles, + daemonReadFile, daemonWriteFile, type DaemonNodeConnection, } from '../../lib/daemon.js'; @@ -15,6 +24,8 @@ import { getSpigetResource, getSpigetDownloadUrl, } from '../../lib/spiget.js'; +import { resolveArtifactDownloadUrl } from '../../lib/cdn.js'; +import * as unzipper from 'unzipper'; const PLUGIN_DOWNLOAD_TIMEOUT_MS = 45_000; const PLUGIN_DOWNLOAD_MAX_BYTES = 128 * 1024 * 1024; @@ -26,6 +37,9 @@ const ParamSchema = { }), }; +type ReleaseChannel = 'stable' | 'beta' | 'alpha'; +type ArtifactType = 'file' | 'zip'; + interface ServerPluginContext { serverId: string; serverUuid: string; @@ -41,6 +55,45 @@ interface PluginArtifactInput { downloadUrl: string | null; } +interface ReleaseInstallFieldOption { + label: string; + value: string; +} + +interface ReleaseInstallField { + key: string; + label: string; + type: 'text' | 'number' | 'boolean' | 'select'; + description?: string; + required?: boolean; + defaultValue?: unknown; + options?: ReleaseInstallFieldOption[]; + min?: number; + max?: number; + pattern?: string; + secret?: boolean; +} + +interface ReleaseConfigTemplate { + path: string; + content: string; +} + +interface ReleaseInstallResult { + artifactPaths: string[]; + configPaths: string[]; +} + +const CHANNEL_WEIGHT: Record = { + stable: 0, + beta: 1, + alpha: 2, +}; + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + function toSlug(value: string): string { return value .trim() @@ -79,6 +132,229 @@ function pluginFilePath(gameSlug: string, plugin: PluginArtifactInput): string | return `${directory}/${safeSlug}-${plugin.id.slice(0, 8)}${extension}`; } +function sanitizeRelativeSegments(path: string): string[] { + const segments = path.replace(/\\/g, '/').split('/').filter(Boolean); + const normalized: string[] = []; + + for (const segment of segments) { + if (segment === '.' || segment === '') continue; + if (segment === '..') { + throw AppError.badRequest('Invalid path segment detected'); + } + normalized.push(segment); + } + + return normalized; +} + +function normalizeAbsolutePath(path: string): string { + const segments = sanitizeRelativeSegments(path); + return `/${segments.join('/')}`; +} + +function joinAbsolutePath(base: string, relative: string): string { + const baseSegments = sanitizeRelativeSegments(base); + const relSegments = sanitizeRelativeSegments(relative); + return `/${[...baseSegments, ...relSegments].join('/')}`; +} + +function resolveTemplatePath(path: string): string { + if (path.startsWith('/')) return normalizeAbsolutePath(path); + return joinAbsolutePath('/', path); +} + +function channelAllowed(limit: ReleaseChannel, candidate: ReleaseChannel): boolean { + return CHANNEL_WEIGHT[candidate] <= CHANNEL_WEIGHT[limit]; +} + +function resolveChannel(value: unknown, fallback: ReleaseChannel = 'stable'): ReleaseChannel { + if (value === 'stable' || value === 'beta' || value === 'alpha') return value; + return fallback; +} + +function parseInstallSchema(raw: unknown): ReleaseInstallField[] { + if (!Array.isArray(raw)) return []; + + const parsed: ReleaseInstallField[] = []; + for (const entry of raw) { + if (!isObject(entry)) continue; + const key = typeof entry.key === 'string' ? entry.key.trim() : ''; + const label = typeof entry.label === 'string' ? entry.label.trim() : ''; + const type = entry.type; + + if (!key || !label) continue; + if (type !== 'text' && type !== 'number' && type !== 'boolean' && type !== 'select') continue; + + const options = Array.isArray(entry.options) + ? entry.options + .map((option) => { + if (!isObject(option)) return null; + const optionLabel = typeof option.label === 'string' ? option.label.trim() : ''; + const optionValue = typeof option.value === 'string' ? option.value.trim() : ''; + if (!optionLabel || !optionValue) return null; + return { label: optionLabel, value: optionValue }; + }) + .filter((option): option is ReleaseInstallFieldOption => option !== null) + : undefined; + + parsed.push({ + key, + label, + type, + description: typeof entry.description === 'string' ? entry.description : undefined, + required: entry.required === true, + defaultValue: entry.defaultValue, + options, + min: typeof entry.min === 'number' ? entry.min : undefined, + max: typeof entry.max === 'number' ? entry.max : undefined, + pattern: typeof entry.pattern === 'string' ? entry.pattern : undefined, + secret: entry.secret === true, + }); + } + + return parsed; +} + +function parseConfigTemplates(raw: unknown): ReleaseConfigTemplate[] { + if (!Array.isArray(raw)) return []; + + const templates: ReleaseConfigTemplate[] = []; + for (const entry of raw) { + if (!isObject(entry)) continue; + + const path = typeof entry.path === 'string' ? entry.path.trim() : ''; + const content = typeof entry.content === 'string' ? entry.content : ''; + + if (!path) continue; + + templates.push({ + path, + content, + }); + } + + return templates; +} + +function interpolateTemplate(content: string, values: Record): string { + return content.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (_match, key: string) => { + if (!(key in values)) return ''; + const value = values[key]; + if (value === null || value === undefined) return ''; + if (typeof value === 'object') return JSON.stringify(value); + return String(value); + }); +} + +function parseBooleanLike(input: unknown): boolean | null { + if (typeof input === 'boolean') return input; + if (typeof input === 'number') { + if (input === 1) return true; + if (input === 0) return false; + return null; + } + if (typeof input === 'string') { + const normalized = input.trim().toLowerCase(); + if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') { + return true; + } + if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') { + return false; + } + } + return null; +} + +function validateInstallOptions( + fields: ReleaseInstallField[], + rawOptions: unknown, +): Record { + const options = isObject(rawOptions) ? rawOptions : {}; + const normalized: Record = {}; + + for (const field of fields) { + const provided = options[field.key]; + const value = provided === undefined ? field.defaultValue : provided; + + if ((value === undefined || value === null || value === '') && field.required) { + throw AppError.badRequest(`Missing required install option: ${field.key}`); + } + + if (value === undefined || value === null || value === '') { + normalized[field.key] = value; + continue; + } + + if (field.type === 'text') { + const textValue = String(value); + if (field.pattern) { + try { + const regex = new RegExp(field.pattern); + if (!regex.test(textValue)) { + throw AppError.badRequest(`Invalid value for ${field.key}`); + } + } catch { + throw AppError.badRequest(`Invalid pattern on install field: ${field.key}`); + } + } + normalized[field.key] = textValue; + continue; + } + + if (field.type === 'number') { + const numericValue = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(numericValue)) { + throw AppError.badRequest(`Install option must be numeric: ${field.key}`); + } + if (field.min !== undefined && numericValue < field.min) { + throw AppError.badRequest(`Install option is below minimum: ${field.key}`); + } + if (field.max !== undefined && numericValue > field.max) { + throw AppError.badRequest(`Install option is above maximum: ${field.key}`); + } + normalized[field.key] = numericValue; + continue; + } + + if (field.type === 'boolean') { + const boolValue = parseBooleanLike(value); + if (boolValue === null) { + throw AppError.badRequest(`Install option must be boolean: ${field.key}`); + } + normalized[field.key] = boolValue; + continue; + } + + if (field.type === 'select') { + const selected = String(value); + const allowed = (field.options ?? []).map((option) => option.value); + if (allowed.length > 0 && !allowed.includes(selected)) { + throw AppError.badRequest(`Install option has invalid selection: ${field.key}`); + } + normalized[field.key] = selected; + } + } + + return normalized; +} + +function chooseBestRelease(releases: T[], autoChannel: ReleaseChannel): T | null { + for (const release of releases) { + if (!release.isPublished) continue; + const releaseChannel = resolveChannel(release.channel); + if (!channelAllowed(autoChannel, releaseChannel)) continue; + return release; + } + return null; +} + +function uniqPaths(paths: string[]): string[] { + return Array.from(new Set(paths)); +} + async function getServerPluginContext( app: FastifyInstance, orgId: string, @@ -132,12 +408,53 @@ async function getPluginForGame( return plugin; } +async function getPluginReleaseForPlugin( + app: FastifyInstance, + pluginId: string, + releaseId: string, +) { + const release = await app.db.query.pluginReleases.findFirst({ + where: and(eq(pluginReleases.id, releaseId), eq(pluginReleases.pluginId, pluginId)), + }); + if (!release) { + throw AppError.notFound('Plugin release not found'); + } + return release; +} + +async function listPublishedPluginReleases( + app: FastifyInstance, + pluginId: string, +) { + return app.db + .select() + .from(pluginReleases) + .where(and(eq(pluginReleases.pluginId, pluginId), eq(pluginReleases.isPublished, true))) + .orderBy(desc(pluginReleases.createdAt)); +} + async function downloadPluginArtifact(downloadUrl: string): Promise { + const normalizedUrl = (() => { + const trimmed = downloadUrl.trim(); + if (/^https?:\/\//i.test(trimmed)) return trimmed; + if (trimmed.startsWith('/')) { + const base = process.env.CDN_BASE_URL?.trim(); + if (base) { + try { + return new URL(trimmed, base).toString(); + } catch { + return trimmed; + } + } + } + return trimmed; + })(); + const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), PLUGIN_DOWNLOAD_TIMEOUT_MS); try { - const res = await fetch(downloadUrl, { + const res = await fetch(normalizedUrl, { headers: { 'User-Agent': 'GamePanel/1.0' }, redirect: 'follow', signal: controller.signal, @@ -177,6 +494,331 @@ async function downloadPluginArtifact(downloadUrl: string): Promise { } } +async function extractZipArtifact(buffer: Buffer, destination: string): Promise> { + const archive = await unzipper.Open.buffer(buffer); + const files: Array<{ path: string; data: Buffer }> = []; + + for (const entry of archive.files) { + if (entry.type !== 'File') continue; + + const relativeSegments = sanitizeRelativeSegments(entry.path); + if (relativeSegments.length === 0) continue; + + const outputPath = joinAbsolutePath(destination, relativeSegments.join('/')); + const data = await entry.buffer(); + files.push({ path: outputPath, data }); + } + + return files; +} + +function resolveReleaseFilePath( + gameSlug: string, + plugin: { id: string; slug: string }, + release: { + artifactUrl: string; + destination: string | null; + fileName: string | null; + }, +): string { + const destination = release.destination + ? normalizeAbsolutePath(release.destination) + : pluginInstallDirectory(gameSlug); + + if (release.fileName && release.fileName.trim().length > 0) { + if (release.fileName.includes('/')) { + return joinAbsolutePath(destination, release.fileName); + } + return `${destination.replace(/\/+$/g, '')}/${release.fileName.trim()}`; + } + + const extension = pluginFileExtension(release.artifactUrl); + const safeSlug = toSlug(plugin.slug) || 'plugin'; + return `${destination.replace(/\/+$/g, '')}/${safeSlug}-${plugin.id.slice(0, 8)}${extension}`; +} + +async function insertServerPluginFileRows( + app: FastifyInstance, + serverPluginId: string, + paths: string[], + kind: 'artifact' | 'config', +): Promise { + if (paths.length === 0) return; + + await app.db + .insert(serverPluginFiles) + .values( + uniqPaths(paths).map((path) => ({ + serverPluginId, + path, + kind, + })), + ); +} + +async function installReleaseArtifacts( + app: FastifyInstance, + context: ServerPluginContext, + plugin: { id: string; slug: string }, + release: { + artifactType: ArtifactType; + artifactUrl: string; + destination: string | null; + fileName: string | null; + }, +): Promise { + const resolvedArtifactUrl = await resolveArtifactDownloadUrl(release.artifactUrl); + const artifact = await downloadPluginArtifact(resolvedArtifactUrl); + + if (release.artifactType === 'zip') { + const destination = release.destination + ? normalizeAbsolutePath(release.destination) + : pluginInstallDirectory(context.gameSlug); + + const files = await extractZipArtifact(artifact, destination); + for (const file of files) { + await daemonWriteFile(context.node, context.serverUuid, file.path, file.data); + } + + return files.map((file) => file.path); + } + + const installPath = resolveReleaseFilePath(context.gameSlug, plugin, release); + await daemonWriteFile(context.node, context.serverUuid, installPath, artifact); + return [installPath]; +} + +async function applyReleaseConfigTemplates( + context: ServerPluginContext, + templates: ReleaseConfigTemplate[], + values: Record, +): Promise { + const writtenPaths: string[] = []; + + for (const template of templates) { + const path = resolveTemplatePath(template.path); + const rendered = interpolateTemplate(template.content, values); + await daemonWriteFile(context.node, context.serverUuid, path, rendered); + writtenPaths.push(path); + } + + return writtenPaths; +} + +async function configurePluginReleaseForServer( + context: ServerPluginContext, + plugin: typeof plugins.$inferSelect, + release: typeof pluginReleases.$inferSelect, + rawOptions: unknown, +): Promise<{ configPaths: string[]; installOptions: Record }> { + const installSchema = parseInstallSchema(release.installSchema); + const installOptions = validateInstallOptions(installSchema, rawOptions); + const configTemplates = parseConfigTemplates(release.configTemplates); + const templateValues: Record = { + ...installOptions, + plugin_name: plugin.name, + plugin_slug: plugin.slug, + plugin_version: release.version, + }; + + const configPaths = await applyReleaseConfigTemplates(context, configTemplates, templateValues); + + return { + configPaths, + installOptions, + }; +} + +async function removeInstalledPluginFiles( + app: FastifyInstance, + context: ServerPluginContext, + installId: string, + fallbackPaths: string[] = [], + preservePaths: string[] = [], +): Promise { + const preserveSet = new Set(preservePaths); + + const tracked = await app.db + .select({ path: serverPluginFiles.path }) + .from(serverPluginFiles) + .where(eq(serverPluginFiles.serverPluginId, installId)); + + const candidates = tracked.length > 0 + ? tracked.map((row) => row.path) + : fallbackPaths; + + const pathsToDelete = uniqPaths(candidates).filter((path) => !preserveSet.has(path)); + + if (pathsToDelete.length > 0) { + try { + await daemonDeleteFiles(context.node, context.serverUuid, pathsToDelete); + } catch (error) { + app.log.warn( + { error, serverId: context.serverId, pluginInstallId: installId, pathsToDelete }, + 'Failed to delete plugin files from server filesystem', + ); + } + } + + await app.db.delete(serverPluginFiles).where(eq(serverPluginFiles.serverPluginId, installId)); +} + +async function syncInstalledPluginConfigFiles( + app: FastifyInstance, + context: ServerPluginContext, + installId: string, + configPaths: string[], +): Promise { + const tracked = await app.db + .select({ path: serverPluginFiles.path }) + .from(serverPluginFiles) + .where( + and( + eq(serverPluginFiles.serverPluginId, installId), + eq(serverPluginFiles.kind, 'config'), + ), + ); + + const nextPaths = uniqPaths(configPaths); + const nextPathSet = new Set(nextPaths); + const stalePaths = tracked + .map((row) => row.path) + .filter((path) => !nextPathSet.has(path)); + + if (stalePaths.length > 0) { + try { + await daemonDeleteFiles(context.node, context.serverUuid, stalePaths); + } catch (error) { + app.log.warn( + { + error, + serverId: context.serverId, + pluginInstallId: installId, + stalePaths, + }, + 'Failed to delete stale plugin config files from server filesystem', + ); + } + } + + await app.db + .delete(serverPluginFiles) + .where( + and( + eq(serverPluginFiles.serverPluginId, installId), + eq(serverPluginFiles.kind, 'config'), + ), + ); + + await insertServerPluginFileRows(app, installId, nextPaths, 'config'); +} + +async function renameServerFile( + context: ServerPluginContext, + fromPath: string, + toPath: string, +): Promise { + const content = await daemonReadFile(context.node, context.serverUuid, fromPath); + await daemonWriteFile(context.node, context.serverUuid, toPath, content.data); + await daemonDeleteFiles(context.node, context.serverUuid, [fromPath]); +} + +function canToggleByRename(path: string): boolean { + const lowered = path.toLowerCase(); + return ( + lowered.endsWith('.jar') || + lowered.endsWith('.dll') || + lowered.endsWith('.smx') || + lowered.endsWith('.cs') || + lowered.endsWith('.jar.disabled') || + lowered.endsWith('.dll.disabled') || + lowered.endsWith('.smx.disabled') || + lowered.endsWith('.cs.disabled') + ); +} + +function nextToggledPath(path: string, enable: boolean): string { + if (enable) { + if (path.endsWith('.disabled')) { + return path.slice(0, -'.disabled'.length); + } + return path; + } + + if (path.endsWith('.disabled')) return path; + return `${path}.disabled`; +} + +async function toggleInstalledPluginFiles( + app: FastifyInstance, + context: ServerPluginContext, + installId: string, + enable: boolean, +): Promise { + const tracked = await app.db + .select({ + id: serverPluginFiles.id, + path: serverPluginFiles.path, + kind: serverPluginFiles.kind, + }) + .from(serverPluginFiles) + .where(eq(serverPluginFiles.serverPluginId, installId)); + + for (const file of tracked) { + if (file.kind !== 'artifact') continue; + if (!canToggleByRename(file.path)) continue; + + const nextPath = nextToggledPath(file.path, enable); + if (nextPath === file.path) continue; + + try { + await renameServerFile(context, file.path, nextPath); + await app.db + .update(serverPluginFiles) + .set({ path: nextPath }) + .where(eq(serverPluginFiles.id, file.id)); + } catch (error) { + app.log.warn( + { + error, + serverId: context.serverId, + pluginInstallId: installId, + fromPath: file.path, + toPath: nextPath, + }, + 'Failed to toggle plugin file by rename', + ); + } + } +} + +async function installPluginReleaseForServer( + app: FastifyInstance, + context: ServerPluginContext, + plugin: typeof plugins.$inferSelect, + release: typeof pluginReleases.$inferSelect, + rawOptions: unknown, +): Promise }> { + const artifactPaths = await installReleaseArtifacts(app, context, plugin, { + artifactType: release.artifactType, + artifactUrl: release.artifactUrl, + destination: release.destination, + fileName: release.fileName, + }); + const { configPaths, installOptions } = await configurePluginReleaseForServer( + context, + plugin, + release, + rawOptions, + ); + + return { + artifactPaths, + configPaths, + installOptions, + }; +} + async function installPluginForServer( app: FastifyInstance, context: ServerPluginContext, @@ -212,6 +854,7 @@ async function installPluginForServer( pluginId: plugin.id, installedVersion: installedVersion ?? plugin.version ?? null, isActive: true, + status: 'installed', }) .returning(); @@ -219,6 +862,8 @@ async function installPluginForServer( throw new AppError(500, 'Failed to save plugin installation', 'PLUGIN_INSTALL_FAILED'); } + await insertServerPluginFileRows(app, installed.id, [installPath], 'artifact'); + return { installed, installPath }; } @@ -229,14 +874,20 @@ export default async function pluginRoutes(app: FastifyInstance) { app.get('/', { schema: ParamSchema }, async (request) => { const { orgId, serverId } = request.params as { orgId: string; serverId: string }; await requirePermission(request, orgId, 'plugin.read'); - await getServerPluginContext(app, orgId, serverId); + const context = await getServerPluginContext(app, orgId, serverId); const installed = await app.db .select({ id: serverPlugins.id, pluginId: serverPlugins.pluginId, + releaseId: serverPlugins.releaseId, installedVersion: serverPlugins.installedVersion, isActive: serverPlugins.isActive, + installOptions: serverPlugins.installOptions, + autoUpdateChannel: serverPlugins.autoUpdateChannel, + isPinned: serverPlugins.isPinned, + status: serverPlugins.status, + lastError: serverPlugins.lastError, installedAt: serverPlugins.installedAt, name: plugins.name, slug: plugins.slug, @@ -248,7 +899,61 @@ export default async function pluginRoutes(app: FastifyInstance) { .innerJoin(plugins, eq(serverPlugins.pluginId, plugins.id)) .where(eq(serverPlugins.serverId, serverId)); - return { plugins: installed }; + const pluginIds = uniqPaths(installed.map((row) => row.pluginId)); + const releases = pluginIds.length > 0 + ? await app.db + .select({ + id: pluginReleases.id, + pluginId: pluginReleases.pluginId, + version: pluginReleases.version, + channel: pluginReleases.channel, + installSchema: pluginReleases.installSchema, + isPublished: pluginReleases.isPublished, + createdAt: pluginReleases.createdAt, + }) + .from(pluginReleases) + .where(inArray(pluginReleases.pluginId, pluginIds)) + .orderBy(desc(pluginReleases.createdAt)) + : []; + + const releasesByPlugin = new Map(); + for (const release of releases) { + const list = releasesByPlugin.get(release.pluginId) ?? []; + list.push(release); + releasesByPlugin.set(release.pluginId, list); + } + + return { + plugins: installed.map((row) => { + const releaseList = releasesByPlugin.get(row.pluginId) ?? []; + const currentRelease = row.releaseId + ? releaseList.find((release) => release.id === row.releaseId) ?? null + : null; + const currentChannel = resolveChannel(row.autoUpdateChannel); + const latestAllowed = chooseBestRelease(releaseList, currentChannel); + const updateAvailable = Boolean( + !row.isPinned && + latestAllowed && + row.releaseId && + latestAllowed.id !== row.releaseId, + ); + + return { + ...row, + updateAvailable, + latestReleaseId: latestAllowed?.id ?? null, + latestVersion: latestAllowed?.version ?? null, + latestChannel: latestAllowed?.channel ?? null, + currentRelease: currentRelease + ? { + id: currentRelease.id, + version: currentRelease.version, + installSchema: parseInstallSchema(currentRelease.installSchema), + } + : null, + }; + }), + }; }); // GET /plugins/marketplace — list game-specific marketplace plugins @@ -278,23 +983,55 @@ export default async function pluginRoutes(app: FastifyInstance) { externalId: plugins.externalId, downloadUrl: plugins.downloadUrl, version: plugins.version, + isGlobal: plugins.isGlobal, updatedAt: plugins.updatedAt, }) .from(plugins) - .where(eq(plugins.gameId, context.gameId)) + .where(and(eq(plugins.gameId, context.gameId), eq(plugins.isGlobal, true))) .orderBy(plugins.name); const installedRows = await app.db .select({ installId: serverPlugins.id, pluginId: serverPlugins.pluginId, + releaseId: serverPlugins.releaseId, installedVersion: serverPlugins.installedVersion, isActive: serverPlugins.isActive, + isPinned: serverPlugins.isPinned, + autoUpdateChannel: serverPlugins.autoUpdateChannel, installedAt: serverPlugins.installedAt, }) .from(serverPlugins) .where(eq(serverPlugins.serverId, context.serverId)); + const pluginIds = catalog.map((plugin) => plugin.id); + const releaseRows = pluginIds.length > 0 + ? await app.db + .select({ + id: pluginReleases.id, + pluginId: pluginReleases.pluginId, + version: pluginReleases.version, + channel: pluginReleases.channel, + artifactType: pluginReleases.artifactType, + artifactUrl: pluginReleases.artifactUrl, + destination: pluginReleases.destination, + fileName: pluginReleases.fileName, + installSchema: pluginReleases.installSchema, + isPublished: pluginReleases.isPublished, + createdAt: pluginReleases.createdAt, + }) + .from(pluginReleases) + .where(and(inArray(pluginReleases.pluginId, pluginIds), eq(pluginReleases.isPublished, true))) + .orderBy(desc(pluginReleases.createdAt)) + : []; + + const releaseByPlugin = new Map(); + for (const row of releaseRows) { + const list = releaseByPlugin.get(row.pluginId) ?? []; + list.push(row); + releaseByPlugin.set(row.pluginId, list); + } + const installedByPluginId = new Map( installedRows.map((row) => [row.pluginId, row]), ); @@ -302,10 +1039,10 @@ export default async function pluginRoutes(app: FastifyInstance) { const needle = q?.trim().toLowerCase(); const filtered = needle ? catalog.filter((plugin) => { - const name = plugin.name.toLowerCase(); - const description = (plugin.description ?? '').toLowerCase(); - return name.includes(needle) || description.includes(needle); - }) + const name = plugin.name.toLowerCase(); + const description = (plugin.description ?? '').toLowerCase(); + return name.includes(needle) || description.includes(needle); + }) : catalog; return { @@ -315,14 +1052,46 @@ export default async function pluginRoutes(app: FastifyInstance) { name: context.gameName, }, plugins: filtered.map((plugin) => { + const releases = releaseByPlugin.get(plugin.id) ?? []; + const latestStable = chooseBestRelease(releases, 'stable'); + const latestAny = chooseBestRelease(releases, 'alpha'); + const latestRelease = latestStable ?? latestAny; + const latestInstallSchema = latestRelease + ? parseInstallSchema(latestRelease.installSchema) + : []; + const installed = installedByPluginId.get(plugin.id); + const autoChannel = resolveChannel(installed?.autoUpdateChannel); + const latestAllowed = chooseBestRelease(releases, autoChannel); + const updateAvailable = Boolean( + installed && + !installed.isPinned && + installed.releaseId && + latestAllowed && + latestAllowed.id !== installed.releaseId, + ); + return { ...plugin, isInstalled: Boolean(installed), installId: installed?.installId ?? null, installedVersion: installed?.installedVersion ?? null, isActive: installed?.isActive ?? false, + isPinned: installed?.isPinned ?? false, + autoUpdateChannel: installed?.autoUpdateChannel ?? 'stable', installedAt: installed?.installedAt ?? null, + releaseId: installed?.releaseId ?? null, + latestRelease: latestRelease + ? { + id: latestRelease.id, + version: latestRelease.version, + channel: latestRelease.channel, + artifactType: latestRelease.artifactType, + artifactUrl: latestRelease.artifactUrl, + installSchema: latestInstallSchema, + } + : null, + updateAvailable, }; }), }; @@ -381,6 +1150,7 @@ export default async function pluginRoutes(app: FastifyInstance) { source: 'manual', downloadUrl, version: version ?? null, + isGlobal: true, }) .returning(); @@ -505,6 +1275,7 @@ export default async function pluginRoutes(app: FastifyInstance) { throw AppError.conflict('Plugin is installed on at least one server'); } + await app.db.delete(pluginReleases).where(eq(pluginReleases.pluginId, plugin.id)); await app.db.delete(plugins).where(eq(plugins.id, plugin.id)); await createAuditLog(app.db, request, { @@ -555,7 +1326,7 @@ export default async function pluginRoutes(app: FastifyInstance) { }, ); - // POST /plugins/install/:pluginId — install from game marketplace + // POST /plugins/install/:pluginId — install from game marketplace or a release app.post( '/install/:pluginId', { @@ -565,6 +1336,16 @@ export default async function pluginRoutes(app: FastifyInstance) { serverId: Type.String({ format: 'uuid' }), pluginId: Type.String({ format: 'uuid' }), }), + body: Type.Optional(Type.Object({ + releaseId: Type.Optional(Type.String({ format: 'uuid' })), + options: Type.Optional(Type.Record(Type.String(), Type.Any())), + pinVersion: Type.Optional(Type.Boolean()), + autoUpdateChannel: Type.Optional(Type.Union([ + Type.Literal('stable'), + Type.Literal('beta'), + Type.Literal('alpha'), + ])), + })), }, }, async (request) => { @@ -573,27 +1354,101 @@ export default async function pluginRoutes(app: FastifyInstance) { serverId: string; pluginId: string; }; + const body = (request.body ?? {}) as { + releaseId?: string; + options?: Record; + pinVersion?: boolean; + autoUpdateChannel?: ReleaseChannel; + }; + await requirePermission(request, orgId, 'plugin.manage'); const context = await getServerPluginContext(app, orgId, serverId); const plugin = await getPluginForGame(app, pluginId, context.gameId); - const { installed, installPath } = await installPluginForServer( + const existing = await app.db.query.serverPlugins.findFirst({ + where: and(eq(serverPlugins.serverId, context.serverId), eq(serverPlugins.pluginId, plugin.id)), + }); + if (existing) { + throw AppError.conflict('Plugin is already installed'); + } + + let selectedRelease: typeof pluginReleases.$inferSelect | null = null; + if (body.releaseId) { + selectedRelease = await getPluginReleaseForPlugin(app, plugin.id, body.releaseId); + } else { + const releases = await listPublishedPluginReleases(app, plugin.id); + selectedRelease = chooseBestRelease(releases, body.autoUpdateChannel ?? 'stable'); + } + + if (!selectedRelease && plugin.downloadUrl) { + const { installed, installPath } = await installPluginForServer( + app, + context, + { + id: plugin.id, + slug: plugin.slug, + downloadUrl: plugin.downloadUrl, + version: plugin.version, + }, + plugin.version, + ); + + await createAuditLog(app.db, request, { + organizationId: orgId, + serverId, + action: 'plugin.install', + metadata: { pluginId: plugin.id, name: plugin.name, source: 'marketplace', installPath }, + }); + + return installed; + } + + if (!selectedRelease) { + throw AppError.badRequest('No published release is available for this plugin'); + } + + const installResult = await installPluginReleaseForServer( app, context, - { - id: plugin.id, - slug: plugin.slug, - downloadUrl: plugin.downloadUrl, - version: plugin.version, - }, - plugin.version, + plugin, + selectedRelease, + body.options, ); + const [installed] = await app.db + .insert(serverPlugins) + .values({ + serverId: context.serverId, + pluginId: plugin.id, + releaseId: selectedRelease.id, + installedVersion: selectedRelease.version, + isActive: true, + installOptions: installResult.installOptions, + autoUpdateChannel: body.autoUpdateChannel ?? selectedRelease.channel, + isPinned: body.pinVersion ?? false, + status: 'installed', + lastError: null, + updatedAt: new Date(), + }) + .returning(); + + if (!installed) { + throw new AppError(500, 'Failed to save plugin installation', 'PLUGIN_INSTALL_FAILED'); + } + + await insertServerPluginFileRows(app, installed.id, installResult.artifactPaths, 'artifact'); + await insertServerPluginFileRows(app, installed.id, installResult.configPaths, 'config'); + await createAuditLog(app.db, request, { organizationId: orgId, serverId, action: 'plugin.install', - metadata: { pluginId: plugin.id, name: plugin.name, source: 'marketplace', installPath }, + metadata: { + pluginId: plugin.id, + releaseId: selectedRelease.id, + version: selectedRelease.version, + installPaths: uniqPaths([...installResult.artifactPaths, ...installResult.configPaths]), + }, }); return installed; @@ -608,12 +1463,13 @@ export default async function pluginRoutes(app: FastifyInstance) { ...ParamSchema, body: Type.Object({ resourceId: Type.Number(), + options: Type.Optional(Type.Record(Type.String(), Type.Any())), }), }, }, async (request) => { const { orgId, serverId } = request.params as { orgId: string; serverId: string }; - const { resourceId } = request.body as { resourceId: number }; + const { resourceId } = request.body as { resourceId: number; options?: Record }; await requirePermission(request, orgId, 'plugin.manage'); const context = await getServerPluginContext(app, orgId, serverId); @@ -644,23 +1500,71 @@ export default async function pluginRoutes(app: FastifyInstance) { externalId: String(resourceId), downloadUrl: getSpigetDownloadUrl(resourceId), version: null, + isGlobal: true, }) .returning(); plugin = created!; } - const { installed, installPath } = await installPluginForServer( + const releaseVersion = resource.version ? String(resource.version.id) : `spiget-${Date.now()}`; + let release = await app.db.query.pluginReleases.findFirst({ + where: and( + eq(pluginReleases.pluginId, plugin.id), + eq(pluginReleases.version, releaseVersion), + ), + }); + + if (!release) { + const [createdRelease] = await app.db + .insert(pluginReleases) + .values({ + pluginId: plugin.id, + version: releaseVersion, + channel: 'stable', + artifactType: 'file', + artifactUrl: getSpigetDownloadUrl(resourceId), + isPublished: true, + createdByUserId: request.user.sub, + }) + .returning(); + release = createdRelease!; + } + + const existing = await app.db.query.serverPlugins.findFirst({ + where: and(eq(serverPlugins.serverId, context.serverId), eq(serverPlugins.pluginId, plugin.id)), + }); + if (existing) { + throw AppError.conflict('Plugin is already installed'); + } + + const installResult = await installPluginReleaseForServer( app, context, - { - id: plugin.id, - slug: plugin.slug, - downloadUrl: plugin.downloadUrl, - version: plugin.version, - }, - resource.version ? String(resource.version.id) : plugin.version, + plugin, + release, + {}, ); + const [installed] = await app.db + .insert(serverPlugins) + .values({ + serverId: context.serverId, + pluginId: plugin.id, + releaseId: release.id, + installedVersion: release.version, + isActive: true, + installOptions: {}, + autoUpdateChannel: 'stable', + isPinned: false, + status: 'installed', + lastError: null, + updatedAt: new Date(), + }) + .returning(); + + await insertServerPluginFileRows(app, installed!.id, installResult.artifactPaths, 'artifact'); + await insertServerPluginFileRows(app, installed!.id, installResult.configPaths, 'config'); + await createAuditLog(app.db, request, { organizationId: orgId, serverId, @@ -670,7 +1574,8 @@ export default async function pluginRoutes(app: FastifyInstance) { name: resource.name, source: 'spiget', resourceId, - installPath, + releaseId: release.id, + installPaths: uniqPaths([...installResult.artifactPaths, ...installResult.configPaths]), }, }); @@ -686,47 +1591,83 @@ export default async function pluginRoutes(app: FastifyInstance) { ...ParamSchema, body: Type.Object({ name: Type.String({ minLength: 1 }), - fileName: Type.String({ minLength: 1 }), + filePath: Type.String({ minLength: 1 }), version: Type.Optional(Type.String()), + pluginId: Type.Optional(Type.String({ format: 'uuid' })), }), }, }, async (request) => { const { orgId, serverId } = request.params as { orgId: string; serverId: string }; - const { name, fileName, version } = request.body as { + const { name, filePath, version, pluginId } = request.body as { name: string; - fileName: string; + filePath: string; version?: string; + pluginId?: string; }; await requirePermission(request, orgId, 'plugin.manage'); const context = await getServerPluginContext(app, orgId, serverId); - const [plugin] = await app.db - .insert(plugins) - .values({ - gameId: context.gameId, - name, - slug: toSlug(name), - source: 'manual', - version: version ?? null, - }) - .returning(); + const normalizedPath = filePath.startsWith('/') + ? normalizeAbsolutePath(filePath) + : joinAbsolutePath(pluginInstallDirectory(context.gameSlug), filePath); + + let plugin = pluginId + ? await getPluginForGame(app, pluginId, context.gameId) + : null; + + if (!plugin) { + const slug = toSlug(name); + const existing = await app.db.query.plugins.findFirst({ + where: and(eq(plugins.gameId, context.gameId), eq(plugins.slug, slug)), + }); + + if (existing) { + plugin = existing; + } else { + const [created] = await app.db + .insert(plugins) + .values({ + gameId: context.gameId, + name, + slug, + source: 'manual', + isGlobal: false, + }) + .returning(); + plugin = created!; + } + } + + const existingInstall = await app.db.query.serverPlugins.findFirst({ + where: and(eq(serverPlugins.serverId, context.serverId), eq(serverPlugins.pluginId, plugin.id)), + }); + if (existingInstall) { + throw AppError.conflict('Plugin is already installed'); + } const [installed] = await app.db .insert(serverPlugins) .values({ serverId, - pluginId: plugin!.id, + pluginId: plugin.id, installedVersion: version ?? null, isActive: true, + installOptions: {}, + autoUpdateChannel: 'stable', + isPinned: true, + status: 'installed', + updatedAt: new Date(), }) .returning(); + await insertServerPluginFileRows(app, installed!.id, [normalizedPath], 'artifact'); + await createAuditLog(app.db, request, { organizationId: orgId, serverId, action: 'plugin.install', - metadata: { pluginId: plugin?.id, name, source: 'manual', fileName }, + metadata: { pluginId: plugin.id, name: plugin.name, source: 'manual', filePath: normalizedPath }, }); return installed; @@ -760,9 +1701,13 @@ export default async function pluginRoutes(app: FastifyInstance) { pluginId: serverPlugins.pluginId, pluginSlug: plugins.slug, pluginDownloadUrl: plugins.downloadUrl, + releaseArtifactUrl: pluginReleases.artifactUrl, + releaseDestination: pluginReleases.destination, + releaseFileName: pluginReleases.fileName, }) .from(serverPlugins) .innerJoin(plugins, eq(serverPlugins.pluginId, plugins.id)) + .leftJoin(pluginReleases, eq(serverPlugins.releaseId, pluginReleases.id)) .where(and( eq(serverPlugins.id, pluginInstallId), eq(serverPlugins.serverId, context.serverId), @@ -772,22 +1717,27 @@ export default async function pluginRoutes(app: FastifyInstance) { throw AppError.notFound('Plugin installation not found'); } - const uninstallPath = pluginFilePath(context.gameSlug, { - id: installed.pluginId, - slug: installed.pluginSlug, - downloadUrl: installed.pluginDownloadUrl, - }); + const fallbackPath = installed.releaseArtifactUrl + ? resolveReleaseFilePath(context.gameSlug, { + id: installed.pluginId, + slug: installed.pluginSlug, + }, { + artifactUrl: installed.releaseArtifactUrl, + destination: installed.releaseDestination, + fileName: installed.releaseFileName, + }) + : pluginFilePath(context.gameSlug, { + id: installed.pluginId, + slug: installed.pluginSlug, + downloadUrl: installed.pluginDownloadUrl, + }); - if (uninstallPath) { - try { - await daemonDeleteFiles(context.node, context.serverUuid, [uninstallPath]); - } catch (error) { - request.log.warn( - { error, serverId, pluginInstallId, uninstallPath }, - 'Failed to delete plugin artifact from server filesystem', - ); - } - } + await removeInstalledPluginFiles( + app, + context, + installed.installId, + fallbackPath ? [fallbackPath] : [], + ); await app.db.delete(serverPlugins).where(eq(serverPlugins.id, pluginInstallId)); @@ -831,13 +1781,222 @@ export default async function pluginRoutes(app: FastifyInstance) { }); if (!installed) throw AppError.notFound('Plugin installation not found'); + const nextActive = !installed.isActive; + await toggleInstalledPluginFiles(app, context, pluginInstallId, nextActive); + const [updated] = await app.db .update(serverPlugins) - .set({ isActive: !installed.isActive }) + .set({ + isActive: nextActive, + updatedAt: new Date(), + }) .where(eq(serverPlugins.id, pluginInstallId)) .returning(); return updated; }, ); + + // POST /plugins/:pluginInstallId/update — update plugin or reapply current config + app.post( + '/:pluginInstallId/update', + { + schema: { + params: Type.Object({ + orgId: Type.String({ format: 'uuid' }), + serverId: Type.String({ format: 'uuid' }), + pluginInstallId: Type.String({ format: 'uuid' }), + }), + body: Type.Optional(Type.Object({ + releaseId: Type.Optional(Type.String({ format: 'uuid' })), + options: Type.Optional(Type.Record(Type.String(), Type.Any())), + pinVersion: Type.Optional(Type.Boolean()), + autoUpdateChannel: Type.Optional(Type.Union([ + Type.Literal('stable'), + Type.Literal('beta'), + Type.Literal('alpha'), + ])), + })), + }, + }, + async (request) => { + const { orgId, serverId, pluginInstallId } = request.params as { + orgId: string; + serverId: string; + pluginInstallId: string; + }; + const body = (request.body ?? {}) as { + releaseId?: string; + options?: Record; + pinVersion?: boolean; + autoUpdateChannel?: ReleaseChannel; + }; + + await requirePermission(request, orgId, 'plugin.manage'); + const context = await getServerPluginContext(app, orgId, serverId); + + const [installed] = await app.db + .select({ + installId: serverPlugins.id, + pluginId: serverPlugins.pluginId, + releaseId: serverPlugins.releaseId, + installOptions: serverPlugins.installOptions, + isPinned: serverPlugins.isPinned, + autoUpdateChannel: serverPlugins.autoUpdateChannel, + }) + .from(serverPlugins) + .where(and(eq(serverPlugins.id, pluginInstallId), eq(serverPlugins.serverId, context.serverId))); + + if (!installed) { + throw AppError.notFound('Plugin installation not found'); + } + + const plugin = await getPluginForGame(app, installed.pluginId, context.gameId); + + if (installed.isPinned && !body.releaseId) { + throw AppError.badRequest('Plugin is pinned. Choose a target release explicitly.'); + } + + const releases = await listPublishedPluginReleases(app, plugin.id); + + let targetRelease: typeof pluginReleases.$inferSelect | null = null; + if (body.releaseId) { + targetRelease = await getPluginReleaseForPlugin(app, plugin.id, body.releaseId); + } else { + targetRelease = chooseBestRelease( + releases, + body.autoUpdateChannel ?? resolveChannel(installed.autoUpdateChannel), + ); + } + + if (!targetRelease) { + throw AppError.badRequest('No eligible release found for update'); + } + + const releaseChanged = targetRelease.id !== installed.releaseId; + const hasOptionChanges = body.options !== undefined; + const nextPinned = body.pinVersion ?? installed.isPinned; + const nextAutoUpdateChannel = body.autoUpdateChannel ?? installed.autoUpdateChannel; + const hasMetadataChanges = + nextPinned !== installed.isPinned || + nextAutoUpdateChannel !== installed.autoUpdateChannel; + + if (!releaseChanged && !hasOptionChanges && !hasMetadataChanges) { + throw AppError.conflict('Plugin is already on the selected release'); + } + + const mergedOptions = { + ...(isObject(installed.installOptions) ? installed.installOptions : {}), + ...(body.options ?? {}), + }; + + if (!releaseChanged && !hasOptionChanges) { + const [updated] = await app.db + .update(serverPlugins) + .set({ + isPinned: nextPinned, + autoUpdateChannel: nextAutoUpdateChannel, + updatedAt: new Date(), + }) + .where(eq(serverPlugins.id, installed.installId)) + .returning(); + + await createAuditLog(app.db, request, { + organizationId: orgId, + serverId, + action: 'plugin.update', + metadata: { + pluginInstallId, + pluginId: plugin.id, + releaseId: targetRelease.id, + version: targetRelease.version, + reconfigured: false, + }, + }); + + return updated; + } + + await app.db + .update(serverPlugins) + .set({ status: 'updating', lastError: null, updatedAt: new Date() }) + .where(eq(serverPlugins.id, installed.installId)); + + try { + let nextInstallOptions = mergedOptions; + + if (releaseChanged) { + const installResult = await installPluginReleaseForServer( + app, + context, + plugin, + targetRelease, + mergedOptions, + ); + + const newPaths = uniqPaths([...installResult.artifactPaths, ...installResult.configPaths]); + await removeInstalledPluginFiles(app, context, installed.installId, [], newPaths); + + await insertServerPluginFileRows(app, installed.installId, installResult.artifactPaths, 'artifact'); + await insertServerPluginFileRows(app, installed.installId, installResult.configPaths, 'config'); + nextInstallOptions = installResult.installOptions; + } else { + const configureResult = await configurePluginReleaseForServer( + context, + plugin, + targetRelease, + mergedOptions, + ); + + await syncInstalledPluginConfigFiles( + app, + context, + installed.installId, + configureResult.configPaths, + ); + nextInstallOptions = configureResult.installOptions; + } + + const [updated] = await app.db + .update(serverPlugins) + .set({ + releaseId: targetRelease.id, + installedVersion: targetRelease.version, + installOptions: nextInstallOptions, + isPinned: nextPinned, + autoUpdateChannel: nextAutoUpdateChannel, + status: 'installed', + lastError: null, + updatedAt: new Date(), + }) + .where(eq(serverPlugins.id, installed.installId)) + .returning(); + + await createAuditLog(app.db, request, { + organizationId: orgId, + serverId, + action: 'plugin.update', + metadata: { + pluginInstallId, + pluginId: plugin.id, + releaseId: targetRelease.id, + version: targetRelease.version, + reconfigured: !releaseChanged, + }, + }); + + return updated; + } catch (error) { + await app.db + .update(serverPlugins) + .set({ + status: 'failed', + lastError: error instanceof Error ? error.message : String(error), + updatedAt: new Date(), + }) + .where(eq(serverPlugins.id, installed.installId)); + throw error; + } + }, + ); } diff --git a/apps/daemon/Dockerfile b/apps/daemon/Dockerfile index f794b7e..b47e591 100644 --- a/apps/daemon/Dockerfile +++ b/apps/daemon/Dockerfile @@ -14,6 +14,7 @@ FROM debian:bookworm-slim AS production RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ libssl3 \ + mariadb-client \ && rm -rf /var/lib/apt/lists/* WORKDIR /app diff --git a/apps/daemon/src/command/mod.rs b/apps/daemon/src/command/mod.rs new file mode 100644 index 0000000..123fa8b --- /dev/null +++ b/apps/daemon/src/command/mod.rs @@ -0,0 +1,152 @@ +use std::collections::HashMap; +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, +}; + +use anyhow::{anyhow, Result}; +use tokio::sync::{mpsc, oneshot, RwLock}; +use tracing::{debug, warn}; + +use crate::server::ServerManager; + +const DEFAULT_QUEUE_CAPACITY: usize = 256; + +#[derive(Debug)] +struct CommandJob { + command: String, + response_tx: oneshot::Sender>, +} + +#[derive(Clone)] +struct WorkerHandle { + id: u64, + sender: mpsc::Sender, +} + +pub struct CommandDispatcher { + server_manager: Arc, + workers: Arc>>, + next_worker_id: Arc, + queue_capacity: usize, +} + +impl CommandDispatcher { + pub fn new(server_manager: Arc) -> Self { + Self { + server_manager, + workers: Arc::new(RwLock::new(HashMap::new())), + next_worker_id: Arc::new(AtomicU64::new(1)), + queue_capacity: DEFAULT_QUEUE_CAPACITY, + } + } + + pub async fn send_command(&self, server_uuid: &str, command: &str) -> Result<()> { + let cmd = command.trim(); + if cmd.is_empty() { + return Err(anyhow!("Command cannot be empty")); + } + + // Retry once if the current worker channel is unexpectedly closed. + for _ in 0..2 { + let worker = self.get_or_create_worker(server_uuid).await; + let (response_tx, response_rx) = oneshot::channel(); + let job = CommandJob { + command: cmd.to_string(), + response_tx, + }; + + match worker.sender.send(job).await { + Ok(_) => { + return response_rx + .await + .unwrap_or_else(|_| Err(anyhow!("Command worker dropped response channel"))); + } + Err(send_err) => { + warn!( + server_uuid = %server_uuid, + worker_id = worker.id, + error = %send_err, + "Command worker queue send failed, rotating worker", + ); + self.remove_worker_if_matches(server_uuid, worker.id).await; + } + } + } + + Err(anyhow!("Failed to dispatch command after retry")) + } + + async fn get_or_create_worker(&self, server_uuid: &str) -> WorkerHandle { + if let Some(existing) = self.workers.read().await.get(server_uuid).cloned() { + return existing; + } + + let worker_id = self.next_worker_id.fetch_add(1, Ordering::Relaxed); + let (sender, receiver) = mpsc::channel::(self.queue_capacity); + let handle = WorkerHandle { + id: worker_id, + sender: sender.clone(), + }; + + { + let mut workers = self.workers.write().await; + if let Some(existing) = workers.get(server_uuid).cloned() { + return existing; + } + workers.insert(server_uuid.to_string(), handle.clone()); + } + + self.spawn_worker(server_uuid.to_string(), worker_id, receiver); + handle + } + + fn spawn_worker( + &self, + server_uuid: String, + worker_id: u64, + mut receiver: mpsc::Receiver, + ) { + let server_manager = self.server_manager.clone(); + let workers = self.workers.clone(); + + tokio::spawn(async move { + debug!(server_uuid = %server_uuid, worker_id, "Command worker started"); + + while let Some(job) = receiver.recv().await { + let result = execute_command(server_manager.clone(), &server_uuid, &job.command).await; + let _ = job.response_tx.send(result); + } + + let mut map = workers.write().await; + if let Some(current) = map.get(&server_uuid) { + if current.id == worker_id { + map.remove(&server_uuid); + } + } + debug!(server_uuid = %server_uuid, worker_id, "Command worker stopped"); + }); + } + + async fn remove_worker_if_matches(&self, server_uuid: &str, worker_id: u64) { + let mut workers = self.workers.write().await; + if let Some(current) = workers.get(server_uuid) { + if current.id == worker_id { + workers.remove(server_uuid); + } + } + } +} + +async fn execute_command( + server_manager: Arc, + server_uuid: &str, + command: &str, +) -> Result<()> { + server_manager + .docker() + .send_command(server_uuid, command) + .await?; + + Ok(()) +} diff --git a/apps/daemon/src/config.rs b/apps/daemon/src/config.rs index f50fbb6..2e06ee5 100644 --- a/apps/daemon/src/config.rs +++ b/apps/daemon/src/config.rs @@ -14,6 +14,8 @@ pub struct DaemonConfig { pub data_path: PathBuf, #[serde(default = "default_backup_path")] pub backup_path: PathBuf, + #[serde(default)] + pub managed_mysql: Option, } #[derive(Debug, Deserialize)] @@ -36,6 +38,19 @@ impl Default for DockerConfig { } } +#[derive(Debug, Deserialize, Clone)] +pub struct ManagedMysqlConfig { + pub url: String, + #[serde(default)] + pub connection_host: Option, + #[serde(default)] + pub connection_port: Option, + #[serde(default)] + pub phpmyadmin_url: Option, + #[serde(default)] + pub bin: Option, +} + fn default_grpc_port() -> u16 { 50051 } diff --git a/apps/daemon/src/docker/container.rs b/apps/daemon/src/docker/container.rs index f290675..e5c6539 100644 --- a/apps/daemon/src/docker/container.rs +++ b/apps/daemon/src/docker/container.rs @@ -2,14 +2,14 @@ use std::collections::HashMap; use std::sync::Arc; use anyhow::Result; use bollard::container::{ - Config, CreateContainerOptions, LogsOptions, RemoveContainerOptions, StartContainerOptions, + AttachContainerOptions, Config, CreateContainerOptions, LogsOptions, RemoveContainerOptions, StartContainerOptions, StopContainerOptions, StatsOptions, Stats, }; use bollard::image::CreateImageOptions; use bollard::models::{HostConfig, PortBinding}; use futures::StreamExt; use tokio::time::{sleep, Duration}; -use tracing::info; +use tracing::{debug, info}; use crate::docker::DockerManager; use crate::server::ServerSpec; @@ -33,6 +33,65 @@ fn container_data_path_for_image(image: &str) -> &'static str { } impl DockerManager { + async fn attach_command_stream( + &self, + container_name: &str, + ) -> Result> { + let bollard::container::AttachContainerResults { mut output, input } = self + .client() + .attach_container( + container_name, + Some(AttachContainerOptions:: { + stdin: Some(true), + stream: Some(true), + ..Default::default() + }), + ) + .await?; + + let name = container_name.to_string(); + let drain_task = tokio::spawn(async move { + while let Some(chunk) = output.next().await { + if let Err(error) = chunk { + debug!(container = %name, error = %error, "Container stdin attach stream closed"); + break; + } + } + debug!(container = %name, "Container stdin attach stream ended"); + }); + + Ok(Arc::new(crate::docker::manager::CommandStreamHandle::new(input, drain_task))) + } + + async fn get_or_attach_command_stream( + &self, + server_uuid: &str, + ) -> Result> { + let name = container_name(server_uuid); + + if let Some(existing) = self.command_streams().read().await.get(&name).cloned() { + return Ok(existing); + } + + let created = self.attach_command_stream(&name).await?; + + let mut streams = self.command_streams().write().await; + if let Some(existing) = streams.get(&name).cloned() { + created.abort(); + return Ok(existing); + } + + streams.insert(name, created.clone()); + Ok(created) + } + + async fn clear_command_stream(&self, server_uuid: &str) { + let name = container_name(server_uuid); + if let Some(stream) = self.command_streams().write().await.remove(&name) { + stream.abort(); + } + } + async fn run_exec(&self, container_name: &str, cmd: Vec) -> Result { let exec = self .client() @@ -206,6 +265,7 @@ impl DockerManager { /// Stop a container gracefully. pub async fn stop_container(&self, server_uuid: &str, timeout_secs: i64) -> Result<()> { let name = container_name(server_uuid); + self.clear_command_stream(server_uuid).await; self.client() .stop_container( &name, @@ -221,6 +281,7 @@ impl DockerManager { /// Kill a container immediately. pub async fn kill_container(&self, server_uuid: &str) -> Result<()> { let name = container_name(server_uuid); + self.clear_command_stream(server_uuid).await; self.client() .kill_container::(&name, None) .await?; @@ -231,6 +292,7 @@ impl DockerManager { /// Remove a container and its volumes. pub async fn remove_container(&self, server_uuid: &str) -> Result<()> { let name = container_name(server_uuid); + self.clear_command_stream(server_uuid).await; self.client() .remove_container( &name, @@ -338,24 +400,22 @@ impl DockerManager { }) } - /// Send a command to a container via exec (attach to stdin). + /// Send a command to a container via a persistent Docker attach stdin stream. pub async fn send_command(&self, server_uuid: &str, command: &str) -> Result<()> { - let name = container_name(server_uuid); + let trimmed = command.trim_end_matches(|ch| ch == '\r' || ch == '\n'); + let payload = format!("{trimmed}\n"); - // Preferred path for Minecraft-like images where rcon-cli is available. - if self - .run_exec(&name, vec!["rcon-cli".to_string(), command.to_string()]) - .await - .is_ok() - { - return Ok(()); + for _ in 0..2 { + let stream = self.get_or_attach_command_stream(server_uuid).await?; + match stream.write_all(payload.as_bytes()).await { + Ok(_) => return Ok(()), + Err(error) => { + debug!(server_uuid = %server_uuid, error = %error, "Failed to write to container stdin, resetting attach stream"); + self.clear_command_stream(server_uuid).await; + } + } } - // Generic fallback: write directly to PID 1 stdin. - let escaped = command.replace('\'', "'\"'\"'"); - let shell_cmd = format!("printf '%s\\n' '{}' > /proc/1/fd/0", escaped); - self.run_exec(&name, vec!["sh".to_string(), "-c".to_string(), shell_cmd]) - .await - .map(|_| ()) + Err(anyhow::anyhow!("failed to write command to container stdin")) } } diff --git a/apps/daemon/src/docker/manager.rs b/apps/daemon/src/docker/manager.rs index b94839c..08c0b8d 100644 --- a/apps/daemon/src/docker/manager.rs +++ b/apps/daemon/src/docker/manager.rs @@ -1,15 +1,50 @@ +use std::collections::HashMap; +use std::pin::Pin; +use std::sync::Arc; + use anyhow::Result; use bollard::Docker; use bollard::network::CreateNetworkOptions; +use tokio::io::{AsyncWrite, AsyncWriteExt}; +use tokio::sync::{Mutex, RwLock}; +use tokio::task::JoinHandle; use tracing::info; use crate::config::DockerConfig; +type AttachedInput = Pin>; + +pub(crate) struct CommandStreamHandle { + input: Mutex, + drain_task: JoinHandle<()>, +} + +impl CommandStreamHandle { + pub(crate) fn new(input: AttachedInput, drain_task: JoinHandle<()>) -> Self { + Self { + input: Mutex::new(input), + drain_task, + } + } + + pub(crate) async fn write_all(&self, bytes: &[u8]) -> Result<()> { + let mut input = self.input.lock().await; + input.write_all(bytes).await?; + input.flush().await?; + Ok(()) + } + + pub(crate) fn abort(&self) { + self.drain_task.abort(); + } +} + /// Manages the Docker client and network setup. #[derive(Clone)] pub struct DockerManager { client: Docker, network_name: String, + command_streams: Arc>>>, } impl DockerManager { @@ -30,6 +65,7 @@ impl DockerManager { let manager = Self { client, network_name: config.network.clone(), + command_streams: Arc::new(RwLock::new(HashMap::new())), }; manager.ensure_network(&config.network_subnet).await?; @@ -45,6 +81,10 @@ impl DockerManager { &self.network_name } + pub(crate) fn command_streams(&self) -> &Arc>>> { + &self.command_streams + } + async fn ensure_network(&self, subnet: &str) -> Result<()> { let networks = self.client.list_networks::(None).await?; let exists = networks diff --git a/apps/daemon/src/grpc/service.rs b/apps/daemon/src/grpc/service.rs index c10696b..909fc51 100644 --- a/apps/daemon/src/grpc/service.rs +++ b/apps/daemon/src/grpc/service.rs @@ -13,9 +13,11 @@ use tokio_stream::wrappers::ReceiverStream; use tonic::{Request, Response, Status}; use tracing::{info, error, warn}; +use crate::command::CommandDispatcher; use crate::server::{ServerManager, PortMap}; use crate::filesystem::FileSystem; use crate::backup::BackupManager; +use crate::managed_mysql::ManagedMysqlManager; // Import generated protobuf types pub mod pb { @@ -27,7 +29,9 @@ use pb::*; pub struct DaemonServiceImpl { server_manager: Arc, + command_dispatcher: Arc, backup_manager: BackupManager, + managed_mysql: Arc, daemon_token: String, start_time: Instant, } @@ -35,9 +39,11 @@ pub struct DaemonServiceImpl { impl DaemonServiceImpl { pub fn new( server_manager: Arc, + command_dispatcher: Arc, daemon_token: String, backup_root: PathBuf, api_url: String, + managed_mysql: Arc, ) -> Self { let backup_manager = BackupManager::new( server_manager.clone(), @@ -48,7 +54,9 @@ impl DaemonServiceImpl { Self { server_manager, + command_dispatcher, backup_manager, + managed_mysql, daemon_token, start_time: Instant::now(), } @@ -106,6 +114,21 @@ impl DaemonServiceImpl { Self::env_value(env, &["CS2_RCONPW", "CS2_RCON_PASSWORD", "SRCDS_RCONPW", "RCON_PASSWORD"]) .unwrap_or_else(|| "changeme".to_string()) } + + fn map_ports(ports: &[PortMapping]) -> Vec { + ports + .iter() + .map(|p| PortMap { + host_port: p.host_port as u16, + container_port: p.container_port as u16, + protocol: if p.protocol.is_empty() { + "tcp".to_string() + } else { + p.protocol.clone() + }, + }) + .collect() + } } type GrpcStream = Pin> + Send>>; @@ -168,20 +191,6 @@ impl DaemonService for DaemonServiceImpl { self.check_auth(&request)?; let req = request.into_inner(); - let ports: Vec = req - .ports - .iter() - .map(|p| PortMap { - host_port: p.host_port as u16, - container_port: p.container_port as u16, - protocol: if p.protocol.is_empty() { - "tcp".to_string() - } else { - p.protocol.clone() - }, - }) - .collect(); - self.server_manager .create_server( req.uuid.clone(), @@ -191,7 +200,7 @@ impl DaemonService for DaemonServiceImpl { req.cpu_limit, req.startup_command, req.environment, - ports, + Self::map_ports(&req.ports), ) .await .map_err(|e| Status::from(e))?; @@ -202,6 +211,33 @@ impl DaemonService for DaemonServiceImpl { })) } + async fn update_server( + &self, + request: Request, + ) -> Result, Status> { + self.check_auth(&request)?; + let req = request.into_inner(); + + let state = self.server_manager + .update_server( + req.uuid.clone(), + req.docker_image, + req.memory_limit, + req.disk_limit, + req.cpu_limit, + req.startup_command, + req.environment, + Self::map_ports(&req.ports), + ) + .await + .map_err(Status::from)?; + + Ok(Response::new(ServerResponse { + uuid: req.uuid, + status: state.to_string(), + })) + } + async fn delete_server( &self, request: Request, @@ -232,6 +268,85 @@ impl DaemonService for DaemonServiceImpl { Ok(Response::new(Empty {})) } + async fn create_database( + &self, + request: Request, + ) -> Result, Status> { + self.check_auth(&request)?; + let req = request.into_inner(); + + if req.server_uuid.trim().is_empty() { + return Err(Status::invalid_argument("Server UUID is required")); + } + if req.name.trim().is_empty() { + return Err(Status::invalid_argument("Database name is required")); + } + + let password = req.password.trim(); + let database = self + .managed_mysql + .create_database( + req.server_uuid.trim(), + req.name.trim(), + if password.is_empty() { None } else { Some(password) }, + ) + .await + .map_err(Status::from)?; + + Ok(Response::new(ManagedDatabaseCredentials { + database_name: database.database_name, + username: database.username, + password: database.password, + host: database.host, + port: i32::from(database.port), + phpmyadmin_url: database.phpmyadmin_url.unwrap_or_default(), + })) + } + + async fn update_database_password( + &self, + request: Request, + ) -> Result, Status> { + self.check_auth(&request)?; + let req = request.into_inner(); + + if req.username.trim().is_empty() { + return Err(Status::invalid_argument("Database username is required")); + } + if req.password.trim().is_empty() { + return Err(Status::invalid_argument("Database password is required")); + } + + self.managed_mysql + .update_password(req.username.trim(), req.password.trim()) + .await + .map_err(Status::from)?; + + Ok(Response::new(Empty {})) + } + + async fn delete_database( + &self, + request: Request, + ) -> Result, Status> { + self.check_auth(&request)?; + let req = request.into_inner(); + + if req.database_name.trim().is_empty() { + return Err(Status::invalid_argument("Database name is required")); + } + if req.username.trim().is_empty() { + return Err(Status::invalid_argument("Database username is required")); + } + + self.managed_mysql + .delete_database(req.database_name.trim(), req.username.trim()) + .await + .map_err(Status::from)?; + + Ok(Response::new(Empty {})) + } + // === Power === async fn set_power_state( @@ -331,31 +446,7 @@ impl DaemonService for DaemonServiceImpl { self.check_auth(&request)?; let req = request.into_inner(); - if let Some((image, env)) = self.get_server_runtime(&req.uuid).await { - let image = image.to_lowercase(); - if image.contains("cs2") || image.contains("csgo") { - let host = Self::env_value(&env, &["RCON_HOST"]) - .unwrap_or_else(|| "127.0.0.1".to_string()); - let port = Self::env_u16(&env, &["RCON_PORT", "CS2_PORT"]).unwrap_or(27015); - let password = Self::cs2_rcon_password(&env); - let address = format!("{}:{}", host, port); - - match crate::game::rcon::RconClient::connect(&address, &password).await { - Ok(mut client) => match client.command(&req.command).await { - Ok(_) => return Ok(Response::new(Empty {})), - Err(e) => { - warn!(uuid = %req.uuid, command = %req.command, error = %e, "CS2 RCON command failed"); - } - }, - Err(e) => { - warn!(uuid = %req.uuid, command = %req.command, error = %e, "CS2 RCON connect failed"); - } - } - } - } - - self.server_manager - .docker() + self.command_dispatcher .send_command(&req.uuid, &req.command) .await .map_err(|e| Status::internal(e.to_string()))?; diff --git a/apps/daemon/src/main.rs b/apps/daemon/src/main.rs index 83258cc..b573658 100644 --- a/apps/daemon/src/main.rs +++ b/apps/daemon/src/main.rs @@ -6,19 +6,23 @@ use tracing_subscriber::EnvFilter; mod auth; mod backup; +mod command; mod config; mod docker; mod error; mod filesystem; mod game; mod grpc; +mod managed_mysql; mod scheduler; mod server; use crate::docker::DockerManager; use crate::grpc::DaemonServiceImpl; use crate::grpc::service::pb::daemon_service_server::DaemonServiceServer; +use crate::managed_mysql::ManagedMysqlManager; use crate::server::ServerManager; +use crate::command::CommandDispatcher; const MAX_GRPC_MESSAGE_SIZE_BYTES: usize = 32 * 1024 * 1024; @@ -45,12 +49,21 @@ async fn main() -> Result<()> { let server_manager = Arc::new(ServerManager::new(docker, &config)); info!("Server manager initialized"); + // Initialize shared command dispatcher (single command pipeline for all games/sources) + let command_dispatcher = Arc::new(CommandDispatcher::new(server_manager.clone())); + info!("Command dispatcher initialized"); + + let managed_mysql = Arc::new(ManagedMysqlManager::new(config.managed_mysql.clone())?); + info!(enabled = managed_mysql.is_enabled(), "Managed MySQL initialized"); + // Create gRPC service let daemon_service = DaemonServiceImpl::new( server_manager.clone(), + command_dispatcher.clone(), config.node_token.clone(), config.backup_path.clone(), config.api_url.clone(), + managed_mysql.clone(), ); // Start gRPC server @@ -68,6 +81,7 @@ async fn main() -> Result<()> { // Scheduler task let sched = Arc::new(scheduler::Scheduler::new( server_manager.clone(), + command_dispatcher.clone(), config.api_url.clone(), config.node_token.clone(), )); diff --git a/apps/daemon/src/managed_mysql.rs b/apps/daemon/src/managed_mysql.rs new file mode 100644 index 0000000..859cee8 --- /dev/null +++ b/apps/daemon/src/managed_mysql.rs @@ -0,0 +1,369 @@ +use std::io::ErrorKind; + +use reqwest::Url; +use thiserror::Error; +use tokio::process::Command; +use tonic::Status; +use uuid::Uuid; + +use crate::config::ManagedMysqlConfig; + +#[derive(Debug, Clone)] +struct ManagedMysqlRuntimeConfig { + admin_database: String, + admin_host: String, + admin_password: String, + admin_port: u16, + admin_username: String, + client_bin: Option, + connection_host: String, + connection_port: u16, + phpmyadmin_url: Option, +} + +#[derive(Debug, Clone)] +pub struct ManagedMysqlDatabase { + pub database_name: String, + pub username: String, + pub password: String, + pub host: String, + pub port: u16, + pub phpmyadmin_url: Option, +} + +#[derive(Debug, Error)] +pub enum ManagedMysqlError { + #[error("Managed MySQL is not configured on this node")] + NotConfigured, + + #[error("Managed MySQL configuration is invalid: {0}")] + InvalidConfig(String), + + #[error("Managed MySQL client binary is not installed on this node")] + ClientMissing, + + #[error("Managed MySQL command failed: {0}")] + CommandFailed(String), + + #[error("Managed MySQL I/O error: {0}")] + Io(#[from] std::io::Error), +} + +impl From for Status { + fn from(error: ManagedMysqlError) -> Self { + match error { + ManagedMysqlError::NotConfigured | ManagedMysqlError::ClientMissing => { + Status::failed_precondition(error.to_string()) + } + ManagedMysqlError::InvalidConfig(_) => Status::internal(error.to_string()), + ManagedMysqlError::CommandFailed(_) => Status::internal(error.to_string()), + ManagedMysqlError::Io(_) => Status::internal(error.to_string()), + } + } +} + +#[derive(Debug, Clone)] +pub struct ManagedMysqlManager { + config: Option, +} + +impl ManagedMysqlManager { + pub fn new(config: Option) -> Result { + let runtime = match config { + Some(config) => Some(resolve_runtime_config(config)?), + None => None, + }; + + Ok(Self { config: runtime }) + } + + pub fn is_enabled(&self) -> bool { + self.config.is_some() + } + + pub async fn create_database( + &self, + server_uuid: &str, + label: &str, + password: Option<&str>, + ) -> Result { + let config = self.config.as_ref().ok_or(ManagedMysqlError::NotConfigured)?; + let label = label.trim(); + if label.is_empty() { + return Err(ManagedMysqlError::CommandFailed( + "Database name is required".to_string(), + )); + } + + let database_name = build_database_name(server_uuid, label); + let username = build_username(server_uuid); + let password = build_password(password); + + self.run_sql( + config, + &format!( + "CREATE DATABASE {} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", + escape_identifier(&database_name) + ), + ) + .await?; + + if let Err(error) = self + .run_sql( + config, + &format!( + "CREATE USER {}@'%' IDENTIFIED BY {};GRANT ALL PRIVILEGES ON {}.* TO {}@'%'", + escape_string(&username), + escape_string(&password), + escape_identifier(&database_name), + escape_string(&username), + ), + ) + .await + { + let _ = self + .run_sql( + config, + &format!("DROP DATABASE IF EXISTS {}", escape_identifier(&database_name)), + ) + .await; + return Err(error); + } + + Ok(ManagedMysqlDatabase { + database_name: database_name.clone(), + username, + password, + host: config.connection_host.clone(), + port: config.connection_port, + phpmyadmin_url: build_phpmyadmin_url(config.phpmyadmin_url.as_deref(), &database_name), + }) + } + + pub async fn update_password( + &self, + username: &str, + password: &str, + ) -> Result<(), ManagedMysqlError> { + let config = self.config.as_ref().ok_or(ManagedMysqlError::NotConfigured)?; + let password = password.trim(); + if password.is_empty() { + return Err(ManagedMysqlError::CommandFailed( + "Database password is required".to_string(), + )); + } + + self.run_sql( + config, + &format!( + "ALTER USER {}@'%' IDENTIFIED BY {}", + escape_string(username), + escape_string(password), + ), + ) + .await + } + + pub async fn delete_database( + &self, + database_name: &str, + username: &str, + ) -> Result<(), ManagedMysqlError> { + let config = self.config.as_ref().ok_or(ManagedMysqlError::NotConfigured)?; + + self.run_sql( + config, + &format!( + "DROP DATABASE IF EXISTS {};DROP USER IF EXISTS {}@'%'", + escape_identifier(database_name), + escape_string(username), + ), + ) + .await + } + + async fn run_sql( + &self, + config: &ManagedMysqlRuntimeConfig, + sql: &str, + ) -> Result<(), ManagedMysqlError> { + let binaries = match config.client_bin.as_deref() { + Some(bin) if !bin.trim().is_empty() => vec![bin.to_string()], + _ => vec!["mariadb".to_string(), "mysql".to_string()], + }; + + let mut missing_binary = false; + + for binary in binaries { + let output = Command::new(&binary) + .args([ + "--protocol=TCP", + "--batch", + "--skip-column-names", + "-h", + &config.admin_host, + "-P", + &config.admin_port.to_string(), + "-u", + &config.admin_username, + &config.admin_database, + "-e", + sql, + ]) + .env("MYSQL_PWD", &config.admin_password) + .output() + .await; + + match output { + Ok(output) if output.status.success() => return Ok(()), + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let message = if !stderr.is_empty() { + stderr + } else if !stdout.is_empty() { + stdout + } else { + format!("{} exited with status {}", binary, output.status) + }; + return Err(ManagedMysqlError::CommandFailed(message)); + } + Err(error) if error.kind() == ErrorKind::NotFound => { + missing_binary = true; + continue; + } + Err(error) => return Err(ManagedMysqlError::Io(error)), + } + } + + if missing_binary { + return Err(ManagedMysqlError::ClientMissing); + } + + Err(ManagedMysqlError::ClientMissing) + } +} + +fn resolve_runtime_config( + config: ManagedMysqlConfig, +) -> Result { + let parsed = Url::parse(&config.url) + .map_err(|error| ManagedMysqlError::InvalidConfig(error.to_string()))?; + + if parsed.scheme() != "mysql" && parsed.scheme() != "mariadb" { + return Err(ManagedMysqlError::InvalidConfig( + "url must use mysql:// or mariadb://".to_string(), + )); + } + + let admin_host = parsed.host_str().unwrap_or_default().trim().to_string(); + let admin_username = parsed.username().trim().to_string(); + + if admin_host.is_empty() || admin_username.is_empty() { + return Err(ManagedMysqlError::InvalidConfig( + "url must include host and username".to_string(), + )); + } + + let admin_database = { + let trimmed = parsed.path().trim_start_matches('/').trim(); + if trimmed.is_empty() { + "mysql".to_string() + } else { + trimmed.to_string() + } + }; + + Ok(ManagedMysqlRuntimeConfig { + admin_database, + admin_host: admin_host.clone(), + admin_password: parsed.password().unwrap_or_default().to_string(), + admin_port: parsed.port().unwrap_or(3306), + admin_username, + client_bin: config.bin, + connection_host: config.connection_host.unwrap_or(admin_host), + connection_port: config.connection_port.unwrap_or(parsed.port().unwrap_or(3306)), + phpmyadmin_url: config.phpmyadmin_url, + }) +} + +fn normalize_token(value: &str, fallback: &str, max_len: usize) -> String { + let mut normalized = String::with_capacity(value.len()); + + for ch in value.chars() { + if ch.is_ascii_alphanumeric() { + normalized.push(ch.to_ascii_lowercase()); + } else if !normalized.ends_with('_') { + normalized.push('_'); + } + } + + let trimmed = normalized.trim_matches('_'); + if trimmed.is_empty() { + return fallback.to_string(); + } + + trimmed + .chars() + .take(max_len) + .collect::() + .trim_end_matches('_') + .to_string() +} + +fn build_database_name(server_uuid: &str, label: &str) -> String { + let server_token = normalize_token(&server_uuid.replace('-', ""), "server", 12); + let label_token = normalize_token(label, "db", 16); + let suffix = Uuid::new_v4().simple().to_string(); + format!("srv_{}_{}_{}", server_token, label_token, &suffix[..8]) + .chars() + .take(64) + .collect::() + .trim_end_matches('_') + .to_string() +} + +fn build_username(server_uuid: &str) -> String { + let server_token = normalize_token(&server_uuid.replace('-', ""), "server", 8); + let suffix = Uuid::new_v4().simple().to_string(); + format!("u_{}_{}", server_token, &suffix[..8]) + .chars() + .take(32) + .collect::() + .trim_end_matches('_') + .to_string() +} + +fn build_password(password: Option<&str>) -> String { + match password { + Some(password) if !password.trim().is_empty() => password.trim().to_string(), + _ => { + let first = Uuid::new_v4().simple().to_string(); + let second = Uuid::new_v4().simple().to_string(); + format!("{}{}", first, second) + } + } +} + +fn escape_identifier(value: &str) -> String { + format!("`{}`", value.replace('`', "``")) +} + +fn escape_string(value: &str) -> String { + format!("'{}'", value.replace('\\', "\\\\").replace('\'', "''")) +} + +fn build_phpmyadmin_url(base_url: Option<&str>, database_name: &str) -> Option { + let base_url = base_url?.trim(); + if base_url.is_empty() { + return None; + } + + match Url::parse(base_url) { + Ok(mut url) => { + url.query_pairs_mut().append_pair("db", database_name); + Some(url.to_string()) + } + Err(_) => Some(base_url.to_string()), + } +} diff --git a/apps/daemon/src/scheduler/mod.rs b/apps/daemon/src/scheduler/mod.rs index 9aa4eb3..e89da0e 100644 --- a/apps/daemon/src/scheduler/mod.rs +++ b/apps/daemon/src/scheduler/mod.rs @@ -4,6 +4,7 @@ use tokio::time::{interval, Duration}; use tracing::{info, error, warn}; use serde::Deserialize; +use crate::command::CommandDispatcher; use crate::server::ServerManager; /// A scheduled task received from the panel API. @@ -21,6 +22,7 @@ pub struct ScheduledTask { /// Scheduler that polls the panel API for due tasks and executes them. pub struct Scheduler { server_manager: Arc, + command_dispatcher: Arc, api_url: String, node_token: String, poll_interval_secs: u64, @@ -29,11 +31,13 @@ pub struct Scheduler { impl Scheduler { pub fn new( server_manager: Arc, + command_dispatcher: Arc, api_url: String, node_token: String, ) -> Self { Self { server_manager, + command_dispatcher, api_url, node_token, poll_interval_secs: 15, @@ -117,9 +121,7 @@ impl Scheduler { match task.action.as_str() { "command" => { - // Send command to server's stdin via Docker exec - let docker = self.server_manager.docker(); - docker + self.command_dispatcher .send_command(&task.server_uuid, &task.payload) .await?; } diff --git a/apps/daemon/src/server/manager.rs b/apps/daemon/src/server/manager.rs index fcc32a1..73335f4 100644 --- a/apps/daemon/src/server/manager.rs +++ b/apps/daemon/src/server/manager.rs @@ -20,6 +20,27 @@ pub struct ServerManager { } impl ServerManager { + async fn ensure_server_data_dir(&self, data_path: &PathBuf) -> Result<(), DaemonError> { + tokio::fs::create_dir_all(data_path) + .await + .map_err(DaemonError::Io)?; + #[cfg(unix)] + { + // Containers may run with non-root users (e.g. steam uid 1000). + // Keep server directory writable to avoid install/start failures. + let permissions = std::fs::Permissions::from_mode(0o777); + tokio::fs::set_permissions(data_path, permissions) + .await + .map_err(DaemonError::Io)?; + } + + Ok(()) + } + + fn is_running_state(state: &str) -> bool { + matches!(state, "running" | "restarting") + } + pub fn new(docker: Arc, config: &DaemonConfig) -> Self { Self { servers: Arc::new(RwLock::new(HashMap::new())), @@ -61,20 +82,7 @@ impl ServerManager { } let data_path = self.data_root.join(&uuid); - - // Create data directory - tokio::fs::create_dir_all(&data_path) - .await - .map_err(DaemonError::Io)?; - #[cfg(unix)] - { - // Containers may run with non-root users (e.g. steam uid 1000). - // Keep server directory writable to avoid install/start failures. - let permissions = std::fs::Permissions::from_mode(0o777); - tokio::fs::set_permissions(&data_path, permissions) - .await - .map_err(DaemonError::Io)?; - } + self.ensure_server_data_dir(&data_path).await?; let spec = ServerSpec { uuid: uuid.clone(), @@ -109,6 +117,117 @@ impl ServerManager { Ok(()) } + /// Recreate a server container with updated runtime configuration while preserving data files. + pub async fn update_server( + &self, + uuid: String, + docker_image: String, + memory_limit: i64, + disk_limit: i64, + cpu_limit: i32, + startup_command: String, + environment: HashMap, + ports: Vec, + ) -> Result { + let existing = { + let servers = self.servers.read().await; + servers.get(&uuid).cloned() + }; + + if matches!(existing.as_ref().map(|spec| &spec.state), Some(ServerState::Installing)) { + return Err(DaemonError::InvalidStateTransition { + current: "installing".to_string(), + requested: "update".to_string(), + }); + } + + let runtime_state = self + .docker + .container_state(&uuid) + .await + .map_err(|e| DaemonError::Internal(format!("Failed to inspect container: {}", e)))?; + + if existing.is_none() && runtime_state.is_none() { + return Err(DaemonError::ServerNotFound(uuid)); + } + + let should_restart = runtime_state + .as_deref() + .map(Self::is_running_state) + .unwrap_or_else(|| { + existing + .as_ref() + .map(|spec| matches!(spec.state, ServerState::Running | ServerState::Starting)) + .unwrap_or(false) + }); + + let data_path = existing + .as_ref() + .map(|spec| spec.data_path.clone()) + .unwrap_or_else(|| self.data_root.join(&uuid)); + self.ensure_server_data_dir(&data_path).await?; + + let mut desired_spec = ServerSpec { + uuid: uuid.clone(), + docker_image, + memory_limit, + disk_limit, + cpu_limit, + startup_command, + environment, + ports, + data_path, + state: ServerState::Stopped, + container_id: None, + }; + + if runtime_state + .as_deref() + .map(Self::is_running_state) + .unwrap_or(false) + { + if let Err(stop_error) = self.docker.stop_container(&uuid, 30).await { + warn!(uuid = %uuid, error = %stop_error, "Graceful stop failed during server update, forcing kill"); + self.docker.kill_container(&uuid).await.map_err(|e| { + DaemonError::Internal(format!("Failed to stop running container during update: {}", e)) + })?; + } + } + + if runtime_state.is_some() { + self.docker.remove_container(&uuid).await.map_err(|e| { + DaemonError::Internal(format!("Failed to remove existing container during update: {}", e)) + })?; + } + + match self.docker.create_container(&desired_spec).await { + Ok(container_id) => { + desired_spec.container_id = Some(container_id); + } + Err(error) => { + desired_spec.state = ServerState::Error; + let mut servers = self.servers.write().await; + servers.insert(uuid.clone(), desired_spec); + return Err(DaemonError::Internal(format!( + "Failed to recreate container during update: {}", + error + ))); + } + } + + { + let mut servers = self.servers.write().await; + servers.insert(uuid.clone(), desired_spec); + } + + if should_restart { + self.start_server(&uuid).await?; + return Ok(ServerState::Running); + } + + Ok(ServerState::Stopped) + } + /// Install a server: pull image, create container. async fn install_server( docker: Arc, diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 00138fb..ac9cfd1 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -31,11 +31,13 @@ import { SchedulesPage } from '@/pages/server/schedules'; import { ConfigPage } from '@/pages/server/config'; import { PluginsPage } from '@/pages/server/plugins'; import { PlayersPage } from '@/pages/server/players'; +import { DatabasesPage } from '@/pages/server/databases'; import { ServerSettingsPage } from '@/pages/server/settings'; // Admin pages import { AdminUsersPage } from '@/pages/admin/users'; import { AdminGamesPage } from '@/pages/admin/games'; +import { AdminPluginsPage } from '@/pages/admin/plugins'; import { AdminNodesPage } from '@/pages/admin/nodes'; import { AdminAuditLogsPage } from '@/pages/admin/audit-logs'; import { AccountSecurityPage } from '@/pages/account/security'; @@ -106,6 +108,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> @@ -116,6 +119,7 @@ export function App() { {/* Admin */} } /> } /> + } /> } /> } /> diff --git a/apps/web/src/components/layout/server-layout.tsx b/apps/web/src/components/layout/server-layout.tsx index 21f8bfe..282812e 100644 --- a/apps/web/src/components/layout/server-layout.tsx +++ b/apps/web/src/components/layout/server-layout.tsx @@ -1,6 +1,6 @@ import { Outlet, useParams, Link, useLocation } from 'react-router'; import { useQuery } from '@tanstack/react-query'; -import { Terminal, FolderOpen, Settings, Calendar, HardDrive, Users, Puzzle, Settings2 } from 'lucide-react'; +import { Terminal, FolderOpen, Settings, Calendar, HardDrive, Users, Puzzle, Settings2, Database as DatabaseIcon } from 'lucide-react'; import { cn } from '@source/ui'; import { api } from '@/lib/api'; import { Badge } from '@/components/ui/badge'; @@ -26,6 +26,7 @@ const tabs = [ { label: 'Console', path: 'console', icon: Terminal }, { label: 'Files', path: 'files', icon: FolderOpen }, { label: 'Config', path: 'config', icon: Settings2 }, + { label: 'Databases', path: 'databases', icon: DatabaseIcon }, { label: 'Plugins', path: 'plugins', icon: Puzzle }, { label: 'Backups', path: 'backups', icon: HardDrive }, { label: 'Schedules', path: 'schedules', icon: Calendar }, @@ -40,6 +41,7 @@ export function ServerLayout() { const { data: server } = useQuery({ queryKey: ['server', orgId, serverId], queryFn: () => api.get(`/organizations/${orgId}/servers/${serverId}`), + refetchInterval: 3_000, }); const currentTab = location.pathname.split('/').pop(); diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx index 642deae..b87a957 100644 --- a/apps/web/src/components/layout/sidebar.tsx +++ b/apps/web/src/components/layout/sidebar.tsx @@ -7,6 +7,7 @@ import { Users, Shield, Gamepad2, + Puzzle, ScrollText, ChevronLeft, } from 'lucide-react'; @@ -40,6 +41,7 @@ export function Sidebar() { ? [ { label: 'Users', href: '/admin/users', icon: Users }, { label: 'Games', href: '/admin/games', icon: Gamepad2 }, + { label: 'Plugins', href: '/admin/plugins', icon: Puzzle }, { label: 'Nodes', href: '/admin/nodes', icon: Network }, { label: 'Audit Logs', href: '/admin/audit-logs', icon: ScrollText }, ] diff --git a/apps/web/src/components/server/power-controls.tsx b/apps/web/src/components/server/power-controls.tsx index e4da16b..b337da9 100644 --- a/apps/web/src/components/server/power-controls.tsx +++ b/apps/web/src/components/server/power-controls.tsx @@ -19,14 +19,38 @@ interface PowerControlsProps { status: string; } +type PowerAction = 'start' | 'stop' | 'restart' | 'kill'; + +interface CachedServerDetail { + status: string; + [key: string]: unknown; +} + export function PowerControls({ serverId, orgId, status }: PowerControlsProps) { const queryClient = useQueryClient(); + const serverQueryKey = ['server', orgId, serverId] as const; const powerMutation = useMutation({ - mutationFn: (action: string) => + mutationFn: (action: PowerAction) => api.post(`/organizations/${orgId}/servers/${serverId}/power`, { action }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['server', orgId, serverId] }); + onMutate: (action) => { + const nextStatusByAction: Record = { + start: 'starting', + stop: 'stopping', + restart: 'stopping', + kill: 'stopped', + }; + + queryClient.setQueryData(serverQueryKey, (current) => { + if (!current) return current; + return { + ...current, + status: nextStatusByAction[action], + }; + }); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: serverQueryKey }); queryClient.invalidateQueries({ queryKey: ['servers', orgId] }); }, }); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 5b9dde0..3811049 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -49,7 +49,23 @@ * { @apply border-border; } + + html { + min-height: 100%; + } + body { @apply bg-background text-foreground; + min-height: 100vh; + background-image: + radial-gradient(circle at 0% 0%, hsl(var(--primary) / 0.18), transparent 34%), + radial-gradient(circle at 88% 10%, hsl(var(--ring) / 0.12), transparent 28%), + linear-gradient(180deg, hsl(var(--background)) 0%, hsl(var(--muted) / 0.72) 100%); + background-attachment: fixed; + background-repeat: no-repeat; + } + + #root { + min-height: 100vh; } } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 8f2780d..17032e0 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -9,6 +9,21 @@ interface RequestOptions extends RequestInit { params?: Record; } +function toRequestBody(body: unknown): BodyInit | undefined { + if (body === undefined || body === null) return undefined; + + if ( + body instanceof FormData || + body instanceof Blob || + body instanceof URLSearchParams || + body instanceof ArrayBuffer + ) { + return body; + } + + return JSON.stringify(body); +} + class ApiError extends Error { constructor( public status: number, @@ -102,19 +117,19 @@ export const api = { post: (path: string, body?: unknown) => request(path, { method: 'POST', - body: body ? JSON.stringify(body) : undefined, + body: toRequestBody(body), }), put: (path: string, body?: unknown) => request(path, { method: 'PUT', - body: body ? JSON.stringify(body) : undefined, + body: toRequestBody(body), }), patch: (path: string, body?: unknown) => request(path, { method: 'PATCH', - body: body ? JSON.stringify(body) : undefined, + body: toRequestBody(body), }), delete: (path: string) => diff --git a/apps/web/src/pages/admin/plugins.tsx b/apps/web/src/pages/admin/plugins.tsx new file mode 100644 index 0000000..598e577 --- /dev/null +++ b/apps/web/src/pages/admin/plugins.tsx @@ -0,0 +1,820 @@ +import { useMemo, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Plus, UploadCloud, Puzzle, Rocket, Copy } from 'lucide-react'; +import { toast } from 'sonner'; +import { api, ApiError } from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; + +interface Game { + id: string; + slug: string; + name: string; +} + +interface GamesResponse { + data: Game[]; +} + +interface GlobalPlugin { + id: string; + gameId: string; + gameName: string; + gameSlug: string; + name: string; + slug: string; + description: string | null; + source: 'manual' | 'spiget'; + isGlobal: boolean; +} + +interface GlobalPluginsResponse { + data: GlobalPlugin[]; +} + +interface PluginRelease { + id: string; + pluginId: string; + version: string; + channel: 'stable' | 'beta' | 'alpha'; + artifactType: 'file' | 'zip'; + artifactUrl: string; + destination: string | null; + fileName: string | null; + changelog: string | null; + installSchema: unknown[]; + configTemplates: unknown[]; + isPublished: boolean; + createdAt: string; + updatedAt: string; +} + +interface PluginReleaseResponse { + plugin: GlobalPlugin; + releases: PluginRelease[]; +} + +type ReleaseInputMode = 'url' | 'upload'; + +function extractApiMessage(error: unknown, fallback: string): string { + if (error instanceof ApiError && error.data && typeof error.data === 'object') { + const maybeMessage = (error.data as { message?: unknown }).message; + if (typeof maybeMessage === 'string' && maybeMessage.trim()) { + return maybeMessage; + } + } + return fallback; +} + +function prettyJson(input: unknown): string { + try { + return JSON.stringify(input, null, 2); + } catch { + return '[]'; + } +} + +function parseJsonArray(raw: string): unknown[] { + if (raw.trim() === '') return []; + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) { + throw new Error('JSON value must be an array'); + } + return parsed; +} + +async function parseJsonArrayFile(file: File, label: string): Promise { + let raw = await file.text(); + if (raw.charCodeAt(0) === 0xfeff) { + raw = raw.slice(1); + } + + try { + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) { + throw new Error('JSON value must be an array'); + } + return parsed; + } catch (error) { + const message = error instanceof Error ? error.message : 'Invalid JSON'; + throw new Error(`${label}: ${message}`); + } +} + +export function AdminPluginsPage() { + const queryClient = useQueryClient(); + + const [selectedGameId, setSelectedGameId] = useState(''); + const [selectedPluginId, setSelectedPluginId] = useState(null); + + const [createPluginOpen, setCreatePluginOpen] = useState(false); + const [createPluginName, setCreatePluginName] = useState(''); + const [createPluginSlug, setCreatePluginSlug] = useState(''); + const [createPluginDescription, setCreatePluginDescription] = useState(''); + + const [createReleaseOpen, setCreateReleaseOpen] = useState(false); + const [releaseInputMode, setReleaseInputMode] = useState('upload'); + const [releaseVersion, setReleaseVersion] = useState(''); + const [releaseChannel, setReleaseChannel] = useState<'stable' | 'beta' | 'alpha'>('stable'); + const [releaseArtifactType, setReleaseArtifactType] = useState<'file' | 'zip'>('file'); + const [releaseArtifactUrl, setReleaseArtifactUrl] = useState(''); + const [releaseDestination, setReleaseDestination] = useState(''); + const [releaseFileName, setReleaseFileName] = useState(''); + const [releaseChangelog, setReleaseChangelog] = useState(''); + const [releaseInstallSchemaJson, setReleaseInstallSchemaJson] = useState('[]'); + const [releaseTemplatesJson, setReleaseTemplatesJson] = useState('[]'); + const [releaseInstallSchemaFile, setReleaseInstallSchemaFile] = useState(null); + const [releaseTemplatesFile, setReleaseTemplatesFile] = useState(null); + const [releaseInstallSchemaFileInputKey, setReleaseInstallSchemaFileInputKey] = useState(0); + const [releaseTemplatesFileInputKey, setReleaseTemplatesFileInputKey] = useState(0); + const [releaseArtifactFiles, setReleaseArtifactFiles] = useState([]); + + const { data: gamesData } = useQuery({ + queryKey: ['admin-games'], + queryFn: () => api.get('/admin/games'), + }); + + const games = gamesData?.data ?? []; + + const { data: pluginsData } = useQuery({ + queryKey: ['admin-plugins', selectedGameId], + queryFn: () => + api.get( + '/admin/plugins', + selectedGameId ? { gameId: selectedGameId } : undefined, + ), + }); + + const plugins = pluginsData?.data ?? []; + + const selectedPlugin = useMemo( + () => plugins.find((plugin) => plugin.id === selectedPluginId) ?? null, + [plugins, selectedPluginId], + ); + + const { data: releaseData } = useQuery({ + queryKey: ['admin-plugin-releases', selectedPluginId], + enabled: Boolean(selectedPluginId), + queryFn: () => api.get(`/admin/plugins/${selectedPluginId}/releases`), + }); + + const releases = releaseData?.releases ?? []; + + const resetReleaseForm = () => { + setCreateReleaseOpen(false); + setReleaseInputMode('upload'); + setReleaseVersion(''); + setReleaseChannel('stable'); + setReleaseArtifactType('file'); + setReleaseArtifactUrl(''); + setReleaseDestination(''); + setReleaseFileName(''); + setReleaseChangelog(''); + setReleaseInstallSchemaJson('[]'); + setReleaseTemplatesJson('[]'); + setReleaseInstallSchemaFile(null); + setReleaseTemplatesFile(null); + setReleaseInstallSchemaFileInputKey((prev) => prev + 1); + setReleaseTemplatesFileInputKey((prev) => prev + 1); + setReleaseArtifactFiles([]); + }; + + const appendReleaseFiles = (incoming: FileList | null) => { + if (!incoming || incoming.length === 0) return; + + setReleaseArtifactFiles((prev) => { + const map = new Map(); + + for (const item of prev) { + const relative = (item as File & { webkitRelativePath?: string }).webkitRelativePath || item.name; + map.set(`${relative}::${item.size}::${item.lastModified}`, item); + } + + for (const item of Array.from(incoming)) { + const relative = (item as File & { webkitRelativePath?: string }).webkitRelativePath || item.name; + map.set(`${relative}::${item.size}::${item.lastModified}`, item); + } + + return Array.from(map.values()); + }); + }; + + const createPluginMutation = useMutation({ + mutationFn: (body: { + gameId: string; + name: string; + slug?: string; + description?: string; + }) => api.post('/admin/plugins', body), + onSuccess: () => { + toast.success('Global plugin created'); + setCreatePluginOpen(false); + setCreatePluginName(''); + setCreatePluginSlug(''); + setCreatePluginDescription(''); + queryClient.invalidateQueries({ queryKey: ['admin-plugins'] }); + }, + onError: (error) => { + toast.error(extractApiMessage(error, 'Failed to create plugin')); + }, + }); + + const createReleaseMutation = useMutation({ + mutationFn: (body: { + version: string; + channel: 'stable' | 'beta' | 'alpha'; + artifactType: 'file' | 'zip'; + artifactUrl: string; + destination?: string; + fileName?: string; + changelog?: string; + installSchema?: unknown[]; + configTemplates?: unknown[]; + }) => { + if (!selectedPluginId) { + throw new Error('No plugin selected'); + } + return api.post(`/admin/plugins/${selectedPluginId}/releases`, body); + }, + onSuccess: () => { + toast.success('Release published'); + resetReleaseForm(); + queryClient.invalidateQueries({ queryKey: ['admin-plugin-releases', selectedPluginId] }); + queryClient.invalidateQueries({ queryKey: ['admin-plugins'] }); + }, + onError: (error) => { + toast.error(extractApiMessage(error, 'Failed to publish release')); + }, + }); + + const createUploadReleaseMutation = useMutation({ + mutationFn: (formData: FormData) => { + if (!selectedPluginId) { + throw new Error('No plugin selected'); + } + return api.post(`/admin/plugins/${selectedPluginId}/releases/upload`, formData); + }, + onSuccess: () => { + toast.success('Release uploaded and published'); + resetReleaseForm(); + queryClient.invalidateQueries({ queryKey: ['admin-plugin-releases', selectedPluginId] }); + queryClient.invalidateQueries({ queryKey: ['admin-plugins'] }); + }, + onError: (error) => { + toast.error(extractApiMessage(error, 'Failed to upload release')); + }, + }); + + const togglePublishedMutation = useMutation({ + mutationFn: ({ releaseId, isPublished }: { releaseId: string; isPublished: boolean }) => { + if (!selectedPluginId) { + throw new Error('No plugin selected'); + } + return api.patch(`/admin/plugins/${selectedPluginId}/releases/${releaseId}`, { isPublished }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-plugin-releases', selectedPluginId] }); + }, + onError: (error) => { + toast.error(extractApiMessage(error, 'Failed to update release')); + }, + }); + + const openReleaseDialogFrom = (release?: PluginRelease) => { + setReleaseVersion(''); + setReleaseChannel('stable'); + setReleaseArtifactType('file'); + setReleaseArtifactUrl(''); + setReleaseDestination(''); + setReleaseFileName(''); + setReleaseChangelog(''); + setReleaseInstallSchemaJson('[]'); + setReleaseTemplatesJson('[]'); + setReleaseInstallSchemaFile(null); + setReleaseTemplatesFile(null); + setReleaseInstallSchemaFileInputKey((prev) => prev + 1); + setReleaseTemplatesFileInputKey((prev) => prev + 1); + setReleaseArtifactFiles([]); + setReleaseInputMode(release ? 'url' : 'upload'); + + if (release) { + setReleaseChannel(release.channel); + setReleaseArtifactType(release.artifactType); + setReleaseArtifactUrl(release.artifactUrl); + setReleaseDestination(release.destination ?? ''); + setReleaseFileName(release.fileName ?? ''); + setReleaseChangelog(release.changelog ?? ''); + setReleaseInstallSchemaJson(prettyJson(release.installSchema)); + setReleaseTemplatesJson(prettyJson(release.configTemplates)); + } + setCreateReleaseOpen(true); + }; + + return ( +
+
+
+

Global Plugins

+

+ Oyun bazında global plugin tanımla, release yayınla, install ayar şemasını yönet. +

+
+ + + + + + + Create Global Plugin + +
{ + event.preventDefault(); + if (!selectedGameId) { + toast.error('Select a game first'); + return; + } + createPluginMutation.mutate({ + gameId: selectedGameId, + name: createPluginName, + slug: createPluginSlug || undefined, + description: createPluginDescription || undefined, + }); + }} + > +
+ + game.id === selectedGameId)?.name ?? ''} + readOnly + placeholder="Select game from filter above" + /> +
+
+ + setCreatePluginName(e.target.value)} required /> +
+
+ + setCreatePluginSlug(e.target.value)} /> +
+
+ + setCreatePluginDescription(e.target.value)} /> +
+ + + +
+
+
+
+ + + +
+ + +
+
+
+ +
+ + + Plugins + + + {plugins.length === 0 && ( +

No plugins found for this filter.

+ )} + {plugins.map((plugin) => ( + + ))} +
+
+ + + + Releases +
+ + +
+
+ + {!selectedPlugin && ( +

Select a plugin to manage releases.

+ )} + {selectedPlugin && releases.length === 0 && ( +

No releases published yet.

+ )} + {releases.map((release) => ( +
+
+ v{release.version} + {release.channel} + {release.artifactType} + {!release.isPublished && Unpublished} +
+

{release.artifactUrl}

+

+ Schema: {Array.isArray(release.installSchema) ? release.installSchema.length : 0} fields • Templates:{' '} + {Array.isArray(release.configTemplates) ? release.configTemplates.length : 0} +

+
+ +
+
+ ))} +
+
+
+ + { + if (open) { + setCreateReleaseOpen(true); + return; + } + resetReleaseForm(); + }} + > + + + Publish Release{selectedPlugin ? ` - ${selectedPlugin.name}` : ''} + +
{ + event.preventDefault(); + try { + const installSchema = releaseInstallSchemaFile + ? await parseJsonArrayFile(releaseInstallSchemaFile, 'Install schema file') + : parseJsonArray(releaseInstallSchemaJson); + const configTemplates = releaseTemplatesFile + ? await parseJsonArrayFile(releaseTemplatesFile, 'Config templates file') + : parseJsonArray(releaseTemplatesJson); + + if (releaseInputMode === 'upload') { + if (releaseArtifactFiles.length === 0) { + toast.error('Select at least one file or folder'); + return; + } + + const formData = new FormData(); + formData.append('version', releaseVersion); + formData.append('channel', releaseChannel); + if (releaseDestination.trim()) formData.append('destination', releaseDestination.trim()); + if (releaseFileName.trim()) formData.append('fileName', releaseFileName.trim()); + if (releaseChangelog.trim()) formData.append('changelog', releaseChangelog); + if (releaseInstallSchemaFile) { + formData.append( + 'installSchemaFile', + releaseInstallSchemaFile, + releaseInstallSchemaFile.name, + ); + } else { + formData.append('installSchema', JSON.stringify(installSchema)); + } + if (releaseTemplatesFile) { + formData.append( + 'configTemplatesFile', + releaseTemplatesFile, + releaseTemplatesFile.name, + ); + } else { + formData.append('configTemplates', JSON.stringify(configTemplates)); + } + + for (const file of releaseArtifactFiles) { + const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath; + formData.append('relativePath', relativePath && relativePath.length > 0 ? relativePath : file.name); + formData.append('files', file, file.name); + } + + createUploadReleaseMutation.mutate(formData); + return; + } + + createReleaseMutation.mutate({ + version: releaseVersion, + channel: releaseChannel, + artifactType: releaseArtifactType, + artifactUrl: releaseArtifactUrl, + destination: releaseDestination || undefined, + fileName: releaseFileName || undefined, + changelog: releaseChangelog || undefined, + installSchema, + configTemplates, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Invalid JSON'; + toast.error(`Release JSON error: ${message}`); + } + }} + > +
+
+ + setReleaseVersion(e.target.value)} required /> +
+
+ + +
+
+ +
+ +
+ + +
+
+ + {releaseInputMode === 'url' && ( +
+
+ + +
+
+ + setReleaseArtifactUrl(e.target.value)} + required={releaseInputMode === 'url'} + /> +
+
+ )} + + {releaseInputMode === 'upload' && ( +
+

+ Tek dosya secersen tekil upload olur. Birden fazla dosya veya klasor secersen otomatik zip + yapilip CDN'e yuklenir. +

+
+
+ + appendReleaseFiles(e.target.files)} + /> +
+
+ + )} + className="block w-full text-sm" + onChange={(e) => appendReleaseFiles(e.target.files)} + /> +
+
+
+

Selected: {releaseArtifactFiles.length} file(s)

+ {releaseArtifactFiles.length > 0 && ( + + )} +
+ {releaseArtifactFiles.length > 0 && ( +
+ {releaseArtifactFiles.map((file, index) => { + const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath; + return ( +

+ {relativePath || file.name} +

+ ); + })} +
+ )} +
+ )} + +
+
+ + setReleaseDestination(e.target.value)} + placeholder="/game/csgo/addons" + /> +
+
+ + setReleaseFileName(e.target.value)} + placeholder="plugin.dll" + /> +
+
+ +
+ +