This commit is contained in:
2026-07-21 22:06:10 +00:00
parent afc64b83c1
commit 5215560ede
25 changed files with 4364 additions and 333 deletions
+2 -2
View File
@@ -187,7 +187,7 @@ function parseKeyValue(content: string): ConfigEntry[] {
const entries: ConfigEntry[] = [];
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('//')) continue;
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('#')) continue;
// Match: key "value" or key value
const match = trimmed.match(/^(\S+)\s+"([^"]*)"/) || trimmed.match(/^(\S+)\s+(.*)/);
@@ -210,7 +210,7 @@ function serializeKeyValue(entries: ConfigEntry[], originalContent?: string): st
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('//')) {
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('#')) {
result.push(line);
continue;
}
+61 -26
View File
@@ -200,6 +200,11 @@ interface DaemonServiceClient extends grpc.Client {
metadata: grpc.Metadata,
callback: UnaryCallback<DaemonManagedDatabaseCredentialsRaw>,
): void;
importDatabaseSql(
request: { database_name: string; sql: string },
metadata: grpc.Metadata,
callback: UnaryCallback<EmptyResponse>,
): void;
updateDatabasePassword(
request: { username: string; password: string },
metadata: grpc.Metadata,
@@ -319,9 +324,7 @@ function buildGrpcTarget(fqdn: string, grpcPort: number): string {
const withoutPath = host.replace(/\/.*$/, '');
if (/^\[.+\](?::\d+)?$/.test(withoutPath)) {
const innerHost = withoutPath
.replace(/^\[/, '')
.replace(/\](?::\d+)?$/, '');
const innerHost = withoutPath.replace(/^\[/, '').replace(/\](?::\d+)?$/, '');
return `[${innerHost}]:${grpcPort}`;
}
if (/^[^:]+:\d+$/.test(withoutPath)) {
@@ -340,14 +343,10 @@ function getMetadata(daemonToken: string): grpc.Metadata {
function createClient(node: DaemonNodeConnection): DaemonServiceClient {
const target = buildGrpcTarget(node.fqdn, node.grpcPort);
return new DaemonService(
target,
grpc.credentials.createInsecure(),
{
'grpc.max_send_message_length': MAX_GRPC_MESSAGE_BYTES,
'grpc.max_receive_message_length': MAX_GRPC_MESSAGE_BYTES,
},
) as unknown as DaemonServiceClient;
return new DaemonService(target, grpc.credentials.createInsecure(), {
'grpc.max_send_message_length': MAX_GRPC_MESSAGE_BYTES,
'grpc.max_receive_message_length': MAX_GRPC_MESSAGE_BYTES,
}) as unknown as DaemonServiceClient;
}
function waitForReady(client: grpc.Client, timeoutMs: number): Promise<void> {
@@ -444,9 +443,7 @@ interface DaemonRequestTimeoutOptions {
rpcTimeoutMs?: number;
}
export async function daemonGetNodeStatus(
node: DaemonNodeConnection,
): Promise<DaemonNodeStatus> {
export async function daemonGetNodeStatus(node: DaemonNodeConnection): Promise<DaemonNodeStatus> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
@@ -466,9 +463,7 @@ export async function daemonGetNodeStatus(
}
}
export async function daemonGetNodeStats(
node: DaemonNodeConnection,
): Promise<DaemonNodeStats> {
export async function daemonGetNodeStats(node: DaemonNodeConnection): Promise<DaemonNodeStats> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
@@ -511,7 +506,8 @@ export async function daemonDeleteServer(
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) => client.deleteServer({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
(callback) =>
client.deleteServer({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
@@ -593,6 +589,30 @@ export async function daemonUpdateDatabasePassword(
}
}
export async function daemonImportDatabaseSql(
node: DaemonNodeConnection,
request: { databaseName: string; sql: string },
): Promise<void> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) =>
client.importDatabaseSql(
{
database_name: request.databaseName,
sql: request.sql,
},
getMetadata(node.daemonToken),
callback,
),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function daemonDeleteDatabase(
node: DaemonNodeConnection,
request: { databaseName: string; username: string },
@@ -626,7 +646,12 @@ export async function daemonSetPowerState(
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) => client.setPowerState({ uuid: serverUuid, action: POWER_ACTIONS[action] }, getMetadata(node.daemonToken), callback),
(callback) =>
client.setPowerState(
{ uuid: serverUuid, action: POWER_ACTIONS[action] },
getMetadata(node.daemonToken),
callback,
),
POWER_RPC_TIMEOUT_MS,
);
} finally {
@@ -643,7 +668,8 @@ export async function daemonGetServerStatus(
try {
await waitForReady(client, timeouts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);
return await callUnary<DaemonStatusResponse>(
(callback) => client.getServerStatus({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
(callback) =>
client.getServerStatus({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
timeouts.rpcTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
@@ -688,7 +714,8 @@ export async function daemonSendCommand(
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) => client.sendCommand({ uuid: serverUuid, command }, getMetadata(node.daemonToken), callback),
(callback) =>
client.sendCommand({ uuid: serverUuid, command }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
@@ -705,7 +732,8 @@ export async function daemonListFiles(
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
const response = await callUnary<DaemonFileListResponseRaw>(
(callback) => client.listFiles({ uuid: serverUuid, path }, getMetadata(node.daemonToken), callback),
(callback) =>
client.listFiles({ uuid: serverUuid, path }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
@@ -731,7 +759,8 @@ export async function daemonReadFile(
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
const response = await callUnary<DaemonFileContentRaw>(
(callback) => client.readFile({ uuid: serverUuid, path }, getMetadata(node.daemonToken), callback),
(callback) =>
client.readFile({ uuid: serverUuid, path }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
@@ -756,7 +785,11 @@ export async function daemonWriteFile(
await callUnary<EmptyResponse>(
(callback) =>
client.writeFile(
{ uuid: serverUuid, path, data: typeof data === 'string' ? Buffer.from(data, 'utf8') : data },
{
uuid: serverUuid,
path,
data: typeof data === 'string' ? Buffer.from(data, 'utf8') : data,
},
getMetadata(node.daemonToken),
callback,
),
@@ -776,7 +809,8 @@ export async function daemonDeleteFiles(
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) => client.deleteFiles({ uuid: serverUuid, paths }, getMetadata(node.daemonToken), callback),
(callback) =>
client.deleteFiles({ uuid: serverUuid, paths }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
@@ -870,7 +904,8 @@ export async function daemonGetActivePlayers(
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
const response = await callUnary<DaemonPlayerListRaw>(
(callback) => client.getActivePlayers({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
(callback) =>
client.getActivePlayers({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
+740
View File
@@ -0,0 +1,740 @@
import { randomBytes } from 'node:crypto';
import { gunzipSync } from 'node:zlib';
import type { FastifyInstance } from 'fastify';
import { and, asc, eq } from 'drizzle-orm';
import * as tar from 'tar-stream';
import type { Headers } from 'tar-stream';
import * as unzipper from 'unzipper';
import { serverDatabases, servers } from '@source/database';
import {
daemonCreateDatabase,
daemonDeleteDatabase,
daemonDeleteFiles,
daemonImportDatabaseSql,
daemonReadFile,
daemonWriteFile,
type DaemonNodeConnection,
} from './daemon.js';
const GITHUB_ARCHIVE_MAX_BYTES = 256 * 1024 * 1024;
const URL_ARCHIVE_MAX_BYTES = 256 * 1024 * 1024;
const QBCORE_DATABASE_NAME = 'qbcore';
const FIVE_M_QBCORE_MARKER_PATH = '/.gamepanel/fivem-qbcore.json';
const FIVE_M_INTERNAL_PORT = 30120;
const QBCORE_SQL_URL =
'https://raw.githubusercontent.com/qbcore-framework/txAdminRecipe/main/qbcore.sql';
const OXMYSQL_ZIP_URL =
'https://github.com/overextended/oxmysql/releases/download/v2.12.0/oxmysql.zip';
const MENUV_ZIP_URL = 'https://github.com/ThymonA/menuv/releases/download/v1.4.1/menuv_v1.4.1.zip';
interface ExtractedFile {
path: string;
data: Buffer;
}
interface ManagedServerDatabaseRecord {
id: string;
name: string;
databaseName: string;
username: string;
password: string;
host: string;
port: number;
phpMyAdminUrl: string | null;
}
interface FivemProvisionContext {
node: DaemonNodeConnection;
serverDescription?: string | null;
serverId: string;
serverName: string;
serverUuid: string;
}
interface GitHubArchiveResource {
destination: string;
owner: string;
ref: string;
repo: string;
subpath?: string;
}
interface RemoteArchiveResource {
collapseTopLevelDirectory?: boolean;
destination: string;
url: string;
}
const FIVEM_GITHUB_RESOURCES: GitHubArchiveResource[] = [
{
owner: 'citizenfx',
repo: 'cfx-server-data',
ref: 'master',
destination: '/resources/[cfx-default]',
subpath: 'resources',
},
{
owner: 'qbcore-framework',
repo: 'bob74_ipl',
ref: 'master',
destination: '/resources/[standalone]/bob74_ipl',
},
{
owner: 'qbcore-framework',
repo: 'safecracker',
ref: 'main',
destination: '/resources/[standalone]/safecracker',
},
{
owner: 'citizenfx',
repo: 'screenshot-basic',
ref: 'master',
destination: '/resources/[standalone]/screenshot-basic',
},
{
owner: 'qbcore-framework',
repo: 'progressbar',
ref: 'main',
destination: '/resources/[standalone]/progressbar',
},
{
owner: 'qbcore-framework',
repo: 'interact-sound',
ref: 'master',
destination: '/resources/[standalone]/interact-sound',
},
{
owner: 'qbcore-framework',
repo: 'connectqueue',
ref: 'master',
destination: '/resources/[standalone]/connectqueue',
},
{
owner: 'qbcore-framework',
repo: 'PolyZone',
ref: 'master',
destination: '/resources/[standalone]/PolyZone',
},
{
owner: 'AvarianKnight',
repo: 'pma-voice',
ref: 'main',
destination: '/resources/[voice]/pma-voice',
},
{
owner: 'qbcore-framework',
repo: 'qb-radio',
ref: 'main',
destination: '/resources/[voice]/qb-radio',
},
{
owner: 'qbcore-framework',
repo: 'hospital_map',
ref: 'main',
destination: '/resources/[defaultmaps]/hospital_map',
},
{
owner: 'qbcore-framework',
repo: 'dealer_map',
ref: 'main',
destination: '/resources/[defaultmaps]/dealer_map',
},
{
owner: 'qbcore-framework',
repo: 'prison_map',
ref: 'main',
destination: '/resources/[defaultmaps]/prison_map',
},
...[
'qb-core',
'qb-scoreboard',
'qb-adminmenu',
'qb-multicharacter',
'qb-target',
'qb-vehiclesales',
'qb-vehicleshop',
'qb-houserobbery',
'qb-prison',
'qb-hud',
'qb-management',
'qb-weed',
'qb-lapraces',
'qb-inventory',
'qb-houses',
'qb-garages',
'qb-ambulancejob',
'qb-radialmenu',
'qb-crypto',
'qb-weathersync',
'qb-policejob',
'qb-apartments',
'qb-vehiclekeys',
'qb-mechanicjob',
'qb-phone',
'qb-vineyard',
'qb-weapons',
'qb-scrapyard',
'qb-towjob',
'qb-streetraces',
'qb-storerobbery',
'qb-spawn',
'qb-smallresources',
'qb-recyclejob',
'qb-crafting',
'qb-diving',
'qb-cityhall',
'qb-truckrobbery',
'qb-pawnshop',
'qb-minigames',
'qb-taxijob',
'qb-busjob',
'qb-newsjob',
'qb-fuel',
'qb-jewelery',
'qb-bankrobbery',
'qb-banking',
'qb-clothing',
'qb-hotdogjob',
'qb-doorlock',
'qb-garbagejob',
'qb-drugs',
'qb-shops',
'qb-interior',
'qb-menu',
'qb-input',
'qb-loading',
].map((repo) => ({
owner: 'qbcore-framework',
repo,
ref: 'main',
destination: `/resources/[qb]/${repo}`,
})),
];
const FIVEM_REMOTE_ARCHIVES: RemoteArchiveResource[] = [
{
url: OXMYSQL_ZIP_URL,
destination: '/resources/[standalone]/oxmysql',
collapseTopLevelDirectory: true,
},
{
url: MENUV_ZIP_URL,
destination: '/resources/[standalone]/menuv',
collapseTopLevelDirectory: true,
},
];
function normalizePathSegments(path: string): string[] {
return path
.replace(/\\/g, '/')
.split('/')
.filter((segment) => segment && segment !== '.' && segment !== '..');
}
function normalizeArchivePath(path: string): string | null {
const segments = normalizePathSegments(path);
if (segments.length === 0) return null;
return segments.join('/');
}
function joinServerPath(base: string, relative: string): string {
const baseSegments = normalizePathSegments(base);
const relativeSegments = normalizePathSegments(relative);
return `/${[...baseSegments, ...relativeSegments].join('/')}`.replace(/\/{2,}/g, '/');
}
function stripSharedTopLevelDirectory(files: ExtractedFile[]): ExtractedFile[] {
if (files.length === 0) return files;
const firstSegments = new Set<string>();
for (const file of files) {
const [first] = normalizePathSegments(file.path);
if (!first) return files;
firstSegments.add(first);
if (firstSegments.size > 1) {
return files;
}
}
return files
.map((file) => {
const segments = normalizePathSegments(file.path).slice(1);
if (segments.length === 0) return null;
return {
path: segments.join('/'),
data: file.data,
};
})
.filter((file): file is ExtractedFile => file !== null);
}
function filterFilesBySubpath(files: ExtractedFile[], subpath: string): ExtractedFile[] {
const prefix = normalizePathSegments(subpath).join('/');
if (!prefix) return files;
const normalizedPrefix = `${prefix}/`;
return files
.map((file) => {
if (file.path === prefix) return null;
if (!file.path.startsWith(normalizedPrefix)) return null;
return {
path: file.path.slice(normalizedPrefix.length),
data: file.data,
};
})
.filter((file): file is ExtractedFile => file !== null && file.path.length > 0);
}
async function downloadBinary(
url: string,
maxBytes: number,
headers: Record<string, string> = {},
): Promise<Buffer> {
const response = await fetch(url, {
headers: {
'User-Agent': 'SourceGamePanel/1.0',
...headers,
},
redirect: 'follow',
});
if (!response.ok) {
throw new Error(`Download failed (${response.status}): ${url}`);
}
const contentLength = Number(response.headers.get('content-length') ?? '0');
if (contentLength > maxBytes) {
throw new Error(`Download exceeds size limit (${contentLength} > ${maxBytes})`);
}
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length === 0) {
throw new Error(`Downloaded archive is empty: ${url}`);
}
if (buffer.length > maxBytes) {
throw new Error(`Download exceeds size limit (${buffer.length} > ${maxBytes})`);
}
return buffer;
}
async function downloadText(url: string, headers: Record<string, string> = {}): Promise<string> {
const response = await fetch(url, {
headers: {
'User-Agent': 'SourceGamePanel/1.0',
...headers,
},
redirect: 'follow',
});
if (!response.ok) {
throw new Error(`Text download failed (${response.status}): ${url}`);
}
const text = await response.text();
if (!text.trim()) {
throw new Error(`Downloaded text is empty: ${url}`);
}
return text;
}
async function extractZipFiles(buffer: Buffer): Promise<ExtractedFile[]> {
const archive = await unzipper.Open.buffer(buffer);
const files: ExtractedFile[] = [];
for (const entry of archive.files) {
if (entry.type !== 'File') continue;
const normalized = normalizeArchivePath(entry.path);
if (!normalized) continue;
files.push({
path: normalized,
data: await entry.buffer(),
});
}
return files;
}
function extractTarFiles(buffer: Buffer): Promise<ExtractedFile[]> {
return new Promise((resolve, reject) => {
const extract = tar.extract();
const files: ExtractedFile[] = [];
extract.on('entry', (header: Headers, stream, next) => {
const type = header.type ?? 'file';
const normalized = normalizeArchivePath(header.name);
const isFileType = type === 'file' || type === 'contiguous-file';
if (!isFileType || !normalized) {
stream.resume();
stream.on('end', next);
stream.on('error', reject);
return;
}
const chunks: Buffer[] = [];
stream.on('data', (chunk: Buffer) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
stream.on('end', () => {
files.push({ path: normalized, data: Buffer.concat(chunks) });
next();
});
stream.on('error', reject);
});
extract.on('finish', () => resolve(files));
extract.on('error', reject);
extract.end(buffer);
});
}
async function extractArchive(buffer: Buffer, url: string): Promise<ExtractedFile[]> {
const normalizedUrl = url.toLowerCase();
if (normalizedUrl.endsWith('.zip')) {
return extractZipFiles(buffer);
}
if (normalizedUrl.endsWith('.tar.gz') || normalizedUrl.endsWith('.tgz')) {
return extractTarFiles(gunzipSync(buffer));
}
if (normalizedUrl.endsWith('.tar')) {
return extractTarFiles(buffer);
}
throw new Error(`Unsupported archive type: ${url}`);
}
async function writeFilesToServer(
node: DaemonNodeConnection,
serverUuid: string,
destination: string,
files: ExtractedFile[],
): Promise<void> {
for (const file of files) {
await daemonWriteFile(node, serverUuid, joinServerPath(destination, file.path), file.data);
}
}
function escapeCfgValue(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
function buildMysqlConnectionString(database: ManagedServerDatabaseRecord): string {
return `mysql://${encodeURIComponent(database.username)}:${encodeURIComponent(database.password)}@${database.host}:${database.port}/${encodeURIComponent(database.databaseName)}?charset=utf8mb4`;
}
function renderFivemServerConfig(
serverName: string,
description: string | null | undefined,
database: ManagedServerDatabaseRecord,
): string {
const safeServerName = escapeCfgValue(serverName.trim() || 'QBCore Server');
const safeProjectDescription = escapeCfgValue(
description?.trim() || 'QBCore server provisioned by Source GamePanel.',
);
const rconPassword = randomBytes(16).toString('hex');
const mysqlConnectionString = escapeCfgValue(buildMysqlConnectionString(database));
return `# Generated by Source GamePanel
# QBCore resources and base dependencies are installed automatically.
endpoint_add_tcp "0.0.0.0:${FIVE_M_INTERNAL_PORT}"
endpoint_add_udp "0.0.0.0:${FIVE_M_INTERNAL_PORT}"
sv_maxclients "32"
sv_hostname "${safeServerName}"
sets sv_projectName "[QBCore] ${safeServerName}"
sets sv_projectDesc "${safeProjectDescription}"
sets locale "en-US"
sets tags "qbcore, qb-core, roleplay, source-gamepanel"
set steam_webApiKey "none"
set resources_useSystemChat "true"
set mysql_connection_string "${mysqlConnectionString}"
setr qb_locale "en"
setr UseTarget "false"
setr voice_useNativeAudio "true"
setr voice_useSendingRangeOnly "true"
setr voice_defaultCycle "GRAVE"
setr voice_defaultVolume "0.3"
setr voice_enableRadioAnim "1"
setr voice_syncData "1"
sv_scriptHookAllowed "0"
sv_endpointprivacy "true"
rcon_password "${rconPassword}"
ensure mapmanager
ensure chat
ensure spawnmanager
ensure sessionmanager
ensure basic-gamemode
ensure hardcap
ensure baseevents
ensure qb-core
ensure [qb]
ensure [standalone]
ensure [voice]
ensure [defaultmaps]
add_ace group.admin command allow
add_ace group.admin command.quit deny
add_ace resource.qb-core command allow
add_ace qbcore.god command allow
add_principal qbcore.god group.admin
add_principal qbcore.god qbcore.admin
add_principal qbcore.admin qbcore.mod
`;
}
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')
);
}
export function isFivemQbCoreGame(gameSlug: string): boolean {
return gameSlug.trim().toLowerCase() === 'fivem';
}
export async function ensureFivemQbCoreDatabase(
app: FastifyInstance,
context: Pick<FivemProvisionContext, 'node' | 'serverId' | 'serverUuid'>,
): Promise<ManagedServerDatabaseRecord> {
const existing = 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,
})
.from(serverDatabases)
.where(eq(serverDatabases.serverId, context.serverId))
.orderBy(asc(serverDatabases.createdAt));
const preferred =
existing.find((database) => database.name.trim().toLowerCase() === QBCORE_DATABASE_NAME) ??
existing[0];
if (preferred) {
return preferred;
}
const managedDatabase = await daemonCreateDatabase(context.node, {
serverUuid: context.serverUuid,
name: QBCORE_DATABASE_NAME,
});
try {
const [created] = await app.db
.insert(serverDatabases)
.values({
serverId: context.serverId,
name: QBCORE_DATABASE_NAME,
databaseName: managedDatabase.databaseName,
username: managedDatabase.username,
password: managedDatabase.password,
host: managedDatabase.host,
port: managedDatabase.port,
phpMyAdminUrl: managedDatabase.phpMyAdminUrl,
})
.returning({
id: serverDatabases.id,
name: serverDatabases.name,
databaseName: serverDatabases.databaseName,
username: serverDatabases.username,
password: serverDatabases.password,
host: serverDatabases.host,
port: serverDatabases.port,
phpMyAdminUrl: serverDatabases.phpMyAdminUrl,
});
if (!created) {
throw new Error('Failed to persist managed database metadata');
}
return created;
} catch (error) {
try {
await daemonDeleteDatabase(context.node, {
databaseName: managedDatabase.databaseName,
username: managedDatabase.username,
});
} catch (cleanupError) {
app.log.error(
{
cleanupError,
databaseName: managedDatabase.databaseName,
serverId: context.serverId,
serverUuid: context.serverUuid,
},
'Failed to roll back managed MySQL database after metadata save failure',
);
}
throw error;
}
}
export async function deleteFivemQbCoreDatabase(
app: FastifyInstance,
context: Pick<FivemProvisionContext, 'node' | 'serverId'>,
): Promise<void> {
const [database] = await app.db
.select({
id: serverDatabases.id,
databaseName: serverDatabases.databaseName,
username: serverDatabases.username,
})
.from(serverDatabases)
.where(
and(
eq(serverDatabases.serverId, context.serverId),
eq(serverDatabases.name, QBCORE_DATABASE_NAME),
),
);
if (!database) return;
await daemonDeleteDatabase(context.node, {
databaseName: database.databaseName,
username: database.username,
});
await app.db.delete(serverDatabases).where(eq(serverDatabases.id, database.id));
}
async function installGitHubResource(
app: FastifyInstance,
context: FivemProvisionContext,
resource: GitHubArchiveResource,
): Promise<void> {
const archiveUrl = `https://codeload.github.com/${resource.owner}/${resource.repo}/tar.gz/refs/heads/${encodeURIComponent(resource.ref)}`;
const archive = await downloadBinary(archiveUrl, GITHUB_ARCHIVE_MAX_BYTES);
let files = await extractTarFiles(gunzipSync(archive));
files = stripSharedTopLevelDirectory(files);
if (resource.subpath) {
files = filterFilesBySubpath(files, resource.subpath);
}
if (files.length === 0) {
throw new Error(
`GitHub archive had no files: ${resource.owner}/${resource.repo}@${resource.ref}`,
);
}
await writeFilesToServer(context.node, context.serverUuid, resource.destination, files);
app.log.info(
{
destination: resource.destination,
filesWritten: files.length,
repo: `${resource.owner}/${resource.repo}`,
serverId: context.serverId,
serverUuid: context.serverUuid,
},
'Installed FiveM GitHub resource',
);
}
async function installRemoteArchive(
app: FastifyInstance,
context: FivemProvisionContext,
resource: RemoteArchiveResource,
): Promise<void> {
const archive = await downloadBinary(resource.url, URL_ARCHIVE_MAX_BYTES);
let files = await extractArchive(archive, resource.url);
if (resource.collapseTopLevelDirectory) {
files = stripSharedTopLevelDirectory(files);
}
if (files.length === 0) {
throw new Error(`Remote archive had no files: ${resource.url}`);
}
await writeFilesToServer(context.node, context.serverUuid, resource.destination, files);
app.log.info(
{
destination: resource.destination,
filesWritten: files.length,
serverId: context.serverId,
serverUuid: context.serverUuid,
url: resource.url,
},
'Installed FiveM remote archive',
);
}
export async function provisionFivemQbCoreServer(
app: FastifyInstance,
context: FivemProvisionContext,
): Promise<void> {
try {
await daemonReadFile(context.node, context.serverUuid, FIVE_M_QBCORE_MARKER_PATH);
return;
} catch (error) {
if (!isMissingFileError(error)) {
throw error;
}
}
const database = await ensureFivemQbCoreDatabase(app, context);
const qbCoreSql = await downloadText(QBCORE_SQL_URL);
await daemonImportDatabaseSql(context.node, {
databaseName: database.databaseName,
sql: qbCoreSql,
});
for (const resource of FIVEM_GITHUB_RESOURCES) {
await installGitHubResource(app, context, resource);
}
for (const resource of FIVEM_REMOTE_ARCHIVES) {
await installRemoteArchive(app, context, resource);
}
try {
await daemonDeleteFiles(context.node, context.serverUuid, [
'/resources/[cfx-default]/[gameplay]/chat',
]);
} catch (error) {
if (!isMissingFileError(error)) {
throw error;
}
}
await daemonWriteFile(
context.node,
context.serverUuid,
'/server.cfg',
renderFivemServerConfig(context.serverName, context.serverDescription, database),
);
await daemonWriteFile(
context.node,
context.serverUuid,
FIVE_M_QBCORE_MARKER_PATH,
JSON.stringify(
{
installedAt: new Date().toISOString(),
manifestVersion: 1,
resourceCount: FIVEM_GITHUB_RESOURCES.length + FIVEM_REMOTE_ARCHIVES.length,
},
null,
2,
),
);
await app.db
.update(servers)
.set({ updatedAt: new Date() })
.where(eq(servers.id, context.serverId));
}