Add panel feature updates across API, daemon, and web
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
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<string, unknown> = {},
|
||||
): 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<string> {
|
||||
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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const content = await readManagedCs2ServerConfig(node, serverUuid);
|
||||
await daemonWriteFile(node, serverUuid, CS2_SERVER_CFG_PATH, content);
|
||||
}
|
||||
+157
-3
@@ -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<string, string>;
|
||||
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<DaemonServerResponse>,
|
||||
): void;
|
||||
updateServer(
|
||||
request: DaemonUpdateServerRequest,
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<DaemonServerResponse>,
|
||||
): void;
|
||||
deleteServer(
|
||||
request: { uuid: string },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<EmptyResponse>,
|
||||
): void;
|
||||
createDatabase(
|
||||
request: { server_uuid: string; name: string; password?: string },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<DaemonManagedDatabaseCredentialsRaw>,
|
||||
): void;
|
||||
updateDatabasePassword(
|
||||
request: { username: string; password: string },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<EmptyResponse>,
|
||||
): void;
|
||||
deleteDatabase(
|
||||
request: { database_name: string; username: string },
|
||||
metadata: grpc.Metadata,
|
||||
callback: UnaryCallback<EmptyResponse>,
|
||||
): 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<DaemonServerResponse> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
return await callUnary<DaemonServerResponse>(
|
||||
(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<DaemonManagedDatabaseCredentials> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
const response = await callUnary<DaemonManagedDatabaseCredentialsRaw>(
|
||||
(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<void> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await callUnary<EmptyResponse>(
|
||||
(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<void> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await callUnary<EmptyResponse>(
|
||||
(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<EmptyResponse>(
|
||||
(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<DaemonStatusResponse> {
|
||||
const client = createClient(node);
|
||||
try {
|
||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
await waitForReady(client, timeouts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);
|
||||
return await callUnary<DaemonStatusResponse>(
|
||||
(callback) => client.getServerStatus({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
|
||||
DEFAULT_RPC_TIMEOUT_MS,
|
||||
timeouts.rpcTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
|
||||
@@ -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<string, GameAutomationRule[]> = {
|
||||
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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
+117
-30
@@ -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<string, DaemonConsoleStreamHandle>();
|
||||
const serverStreams = new Map<string, SharedConsoleStream>();
|
||||
const socketSubscriptions = new Map<string, string>();
|
||||
|
||||
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<void>((resolve) => {
|
||||
io.close(() => resolve());
|
||||
|
||||
@@ -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<Buffer> {
|
||||
return await new Promise<Buffer>((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<string, unknown> = {};
|
||||
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
|
||||
|
||||
@@ -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()),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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<string, unknown> | 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();
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
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();
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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<string, string> | undefined,
|
||||
@@ -63,6 +88,7 @@ function buildDaemonEnvironment(
|
||||
for (const item of gameEnvVarsRaw) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const record = item as Record<string, unknown>;
|
||||
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<string, string> {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
|
||||
|
||||
const normalized: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||
const normalizedKey = key.trim();
|
||||
if (!normalizedKey) continue;
|
||||
normalized[normalizedKey] = String(value ?? '');
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function sameEnvironmentOverrides(
|
||||
left: Record<string, string>,
|
||||
right: Record<string, string>,
|
||||
): 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<void> {
|
||||
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<string, unknown>;
|
||||
const body = request.body as {
|
||||
name?: string;
|
||||
description?: string;
|
||||
memoryLimit?: number;
|
||||
diskLimit?: number;
|
||||
cpuLimit?: number;
|
||||
environment?: Record<string, string>;
|
||||
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<string, unknown> = {
|
||||
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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user