her sey
This commit is contained in:
@@ -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
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user