diff --git a/core/components/AdminVault/index.js b/core/components/AdminVault/index.js index ce48ebaaf..6ccd22368 100644 --- a/core/components/AdminVault/index.js +++ b/core/components/AdminVault/index.js @@ -46,6 +46,7 @@ export default class AdminVault { 'control.server': 'Start/Stop Server + Scheduler', 'commands.resources': 'Start/Stop Resources', 'server.cfg.editor': 'Read/Write server.cfg', + 'version_control': 'Version Control: Manage', 'txadmin.log.view': 'View System Logs', //FIXME: rename to system.log.view 'menu.vehicle': 'Spawn / Fix Vehicles', diff --git a/core/components/ConfigVault.js b/core/components/ConfigVault.js index 67ddb7bdb..3a779e6ef 100644 --- a/core/components/ConfigVault.js +++ b/core/components/ConfigVault.js @@ -53,6 +53,9 @@ export default class ConfigVault { let cfgData = this.getConfigFromFile(); this.configFile = this.setupConfigStructure(cfgData); this.config = this.setupConfigDefaults(this.configFile); + if (globals.versionControl) { + globals.versionControl.configUpdated(); + } this.setupFolderStructure(); } catch (error) { console.error(error); @@ -100,6 +103,7 @@ export default class ConfigVault { logger: null, monitor: null, playerDatabase: null, + versionControl: null, webServer: null, discordBot: null, fxRunner: null, @@ -110,6 +114,7 @@ export default class ConfigVault { // this entire config vault is stupid. // use convict, lodash defaults or something like that cfg.playerDatabase = cfg.playerDatabase ?? cfg.playerController ?? {}; + cfg.versionControl = cfg.versionControl ?? {}; try { out.global = { @@ -123,6 +128,11 @@ export default class ConfigVault { hideDefaultWarning: toDefault(cfg.global.hideDefaultWarning, false), hideDefaultScheduledRestartWarning: toDefault(cfg.global.hideDefaultScheduledRestartWarning, false), }; + out.versionControl = { + githubAuthKey: toDefault(cfg.versionControl.githubAuthKey, null), + githubOwner: toDefault(cfg.versionControl.githubAuthKey, null), + githubParentRepo: toDefault(cfg.versionControl.githubParentRepo, null) + }; out.logger = toDefault(cfg.logger, {}); //not in template out.monitor = { restarterSchedule: toDefault(cfg.monitor.restarterSchedule, []), @@ -221,6 +231,10 @@ export default class ConfigVault { cfg.logger.admin = toDefault(cfg.logger.admin, {}); cfg.logger.console = toDefault(cfg.logger.console, {}); + //Version Control + cfg.versionControl.githubAuthKey = toDefault(cfg.versionControl.githubAuthKey, null); + cfg.versionControl.githubOwner = toDefault(cfg.versionControl.githubOwner, null); + //Monitor cfg.monitor.restarterSchedule = cfg.monitor.restarterSchedule || []; cfg.monitor.cooldown = parseInt(cfg.monitor.cooldown) || 60; //not in template - 45 > 60 > 90 -> 60 after fixing the "extra time" logic @@ -346,5 +360,9 @@ export default class ConfigVault { fs.writeFileSync(this.configFilePath, JSON.stringify(toSave, null, 2), 'utf8'); this.configFile = toSave; this.config = this.setupConfigDefaults(this.configFile); + + if (scope == 'versionControl' && globals.versionControl) { + globals.versionControl.configUpdated(); + } } }; diff --git a/core/components/FxRunner/index.js b/core/components/FxRunner/index.js index 9d9371195..82c600a01 100644 --- a/core/components/FxRunner/index.js +++ b/core/components/FxRunner/index.js @@ -150,7 +150,7 @@ export default class FXRunner { async spawnServer(announce) { //If the server is already alive if (this.fxChild !== null) { - const msg = `The server is already started.`; + const msg = 'The server is already started.'; console.error(msg); return msg; } @@ -166,13 +166,13 @@ export default class FXRunner { || typeof this.spawnVariables.command == 'undefined' || typeof this.spawnVariables.args == 'undefined' ) { - const msg = `this.spawnVariables is not set.`; + const msg = 'this.spawnVariables is not set.'; console.error(msg); return msg; } //If there is any FXServer configuration missing if (this.config.serverDataPath === null || this.config.cfgPath === null) { - const msg = `Cannot start the server with missing configuration (serverDataPath || cfgPath).`; + const msg = 'Cannot start the server with missing configuration (serverDataPath || cfgPath).'; console.error(msg); return msg; } @@ -218,8 +218,8 @@ export default class FXRunner { type: 'success', description: { key: 'server_actions.spawning_discord', - data: { servername: globals.txAdmin.globalConfig.serverName } - } + data: { servername: globals.txAdmin.globalConfig.serverName }, + }, }); } @@ -335,7 +335,7 @@ export default class FXRunner { //Start server again :) return this.spawnServer(); } catch (error) { - const errMsg = `Couldn't restart the server.`; + const errMsg = 'Couldn\'t restart the server.'; console.error(errMsg); console.verbose.dir(error); return errMsg; @@ -377,8 +377,8 @@ export default class FXRunner { type: messageColor, description: { key: `server_actions.${messageType}_discord`, - data: tOptions - } + data: tOptions, + }, }); //Awaiting restart delay @@ -394,6 +394,7 @@ export default class FXRunner { globals.resourcesManager.handleServerStop(); globals.playerlistManager.handleServerStop(this.currentMutex); globals.statsManager.svRuntime.logServerClose(reasonString); + globals.versionControl.svRuntime.handleServerStop(reasonString); return null; } catch (error) { const msg = "Couldn't kill the server. Perhaps What Is Dead May Never Die."; diff --git a/core/components/HealthMonitor/index.js b/core/components/HealthMonitor/index.js index 2201ae176..4e5e900ae 100644 --- a/core/components/HealthMonitor/index.js +++ b/core/components/HealthMonitor/index.js @@ -209,6 +209,7 @@ export default class HealthMonitor { this.hasServerStartedYet = true; globals.statsManager.txRuntime.registerFxserverBoot(processUptime); globals.statsManager.svRuntime.logServerBoot(processUptime); + globals.versionControl.svRuntime.handleServerBoot(processUptime); } return; } diff --git a/core/components/Logger/index.js b/core/components/Logger/index.js index bb4158251..501aabf52 100644 --- a/core/components/Logger/index.js +++ b/core/components/Logger/index.js @@ -1,10 +1,7 @@ -const modulename = 'Logger'; import AdminLogger from './handlers/admin'; import FXServerLogger from './handlers/fxserver'; import ServerLogger from './handlers/server'; -import { getLogSizes } from './loggerUtils.js' -import consoleFactory from '@extras/console'; -const console = consoleFactory(modulename); +import { getLogSizes } from './loggerUtils.js'; // NOTE: to turn this into an universal class outside txAdmin() instance // when a txAdmin profile starts, it does universal.logger.start(profilename) diff --git a/core/components/Scheduler.js b/core/components/Scheduler.js index ef55545fd..ba1c7ef27 100644 --- a/core/components/Scheduler.js +++ b/core/components/Scheduler.js @@ -38,7 +38,7 @@ export default class Scheduler { this.calculatedNextRestartMinuteFloorTs = false; this.checkSchedule(); - //Cron Function + //Cron Function setInterval(() => { this.checkSchedule(); globals.webServer?.webSocket.pushRefresh('status'); @@ -101,7 +101,7 @@ export default class Scheduler { //Dispatch `txAdmin:events:skippedNextScheduledRestart` globals.fxRunner.sendEvent('skippedNextScheduledRestart', { secondsRemaining: Math.floor((prevMinuteFloorTs - Date.now()) / 1000), - temporary + temporary, }); } else { this.nextSkip = false; @@ -193,7 +193,7 @@ export default class Scheduler { //Checking if skipped if (this.nextSkip === this.calculatedNextRestartMinuteFloorTs) { - console.verbose.log(`Skipping next scheduled restart`); + console.verbose.log('Skipping next scheduled restart'); return; } @@ -212,7 +212,6 @@ export default class Scheduler { //reset next scheduled this.nextTempSchedule = false; - } else if (scheduleWarnings.includes(nextDistMins)) { const tOptions = { smart_count: nextDistMins, @@ -224,14 +223,14 @@ export default class Scheduler { type: 'warning', description: { key: 'restarter.schedule_warn_discord', - data: tOptions - } + data: tOptions, + }, }); - //Dispatch `txAdmin:events:scheduledRestart` + //Dispatch `txAdmin:events:scheduledRestart` globals.fxRunner.sendEvent('scheduledRestart', { secondsRemaining: nextDistMins * 60, - translatedMessage: globals.translator.t('restarter.schedule_warn', tOptions) + translatedMessage: globals.translator.t('restarter.schedule_warn', tOptions), }); } } diff --git a/core/components/VersionControl/index.ts b/core/components/VersionControl/index.ts new file mode 100644 index 000000000..6641e2c37 --- /dev/null +++ b/core/components/VersionControl/index.ts @@ -0,0 +1,157 @@ +const modulename = "VersionControl"; +import consoleFactory from "@extras/console"; +import type TxAdmin from "@core/txAdmin.js"; +import SvRuntimeVersionControl from "./svRuntime"; +import { Octokit } from "octokit"; +import fetch from "node-fetch"; + +const console = consoleFactory(modulename); + +/** + * Module responsible for handling version control + */ +export default class VersionControl { + readonly #txAdmin: TxAdmin; + public readonly svRuntime: SvRuntimeVersionControl; + public octokit: Octokit | null; + private githubAuthKey: string | null; + private githubOwner: string | null; + private githubParentRepo: string | null; + + private async updateGithubAuthKey(newValue: string | null) { + this.githubAuthKey = newValue; + await this.updateOctokitValue(); + } + + public async createGithubRepo( + repoName: string, + ownerName: string, + isOrganization: boolean + ): Promise<[boolean, any?]> { + console.log({ + repoName, + ownerName, + isOrganization + }); + if (this.octokit) { + if (isOrganization === true) { + const resp = this.octokit.request("POST /orgs/{org}/repos", { + org: ownerName, + name: repoName, + description: "Server repository created by txAdmin", + private: true, + has_issues: true, + has_projects: true, + has_wiki: true, + is_template: false + }); + console.log("resp", JSON.stringify(resp)); + + if (resp.status === 200) { + return [true, resp.data]; + } + } else { + const resp = this.octokit.request("POST /user/repos", { + name: repoName, + description: "Server repository created by txAdmin", + private: true, + has_issues: true, + has_projects: true, + has_wiki: true, + is_template: false + }); + + if (resp.status === 200) { + return [true, resp.data]; + } + } + } + + return [false]; + } + + public async isAuthKeyValid(key: string) { + try { + const val = await new Octokit({ + auth: key, + request: { + fetch: fetch + } + }).rest.users.getAuthenticated(); + + return val.status === 200; + } catch (err) { + console.verbose.warn(err); + return false; + } + } + + public async getUsername() { + if (this.octokit) { + const { data } = await this.octokit.rest.users.getAuthenticated(); + + if (data) { + return data.login; + } + } + + return null; + } + + private async updateOctokitValue() { + if (this.githubAuthKey !== null) { + if ((await this.isAuthKeyValid(this.githubAuthKey)) === true) { + this.octokit = new Octokit({ + auth: this.githubAuthKey, + request: { + fetch: fetch + } + }); + console.verbose.log("Valid github auth key"); + } else { + console.info("Invalid github auth key"); + } + } else { + this.octokit = null; + } + } + + public configUpdated() { + const cfg = this.#txAdmin.configVault.getScoped("versionControl"); + + this.updateGithubAuthKey(cfg.githubAuthKey); + this.githubOwner = cfg.githubOwner; + this.githubParentRepo = cfg.githubParentRepo; + } + + public async getOwners() { + const resp: string[] = []; + + if (this.octokit !== null) { + const orgsResp = await this.octokit.request("GET /user/orgs"); + + const { data: userResp } = + await this.octokit.rest.users.getAuthenticated(); + + if (userResp) { + resp.push(userResp.login); + } + + if (orgsResp && orgsResp.status === 200) { + orgsResp.data.forEach((v: { login: string }) => { + resp.push(v.login); + }); + } + } + + return resp; + } + + constructor(txAdmin: TxAdmin) { + this.#txAdmin = txAdmin; + this.githubAuthKey = null; + this.githubOwner = null; + this.configUpdated(); + this.svRuntime = new SvRuntimeVersionControl(txAdmin); + } +} diff --git a/core/components/VersionControl/svRuntime/config.ts b/core/components/VersionControl/svRuntime/config.ts new file mode 100644 index 000000000..74b31efea --- /dev/null +++ b/core/components/VersionControl/svRuntime/config.ts @@ -0,0 +1,6 @@ +import { VcFileType } from "./perfSchemas"; + +export const defaultData: VcFileType = { + resources: [], + repository: null +}; diff --git a/core/components/VersionControl/svRuntime/index.ts b/core/components/VersionControl/svRuntime/index.ts new file mode 100644 index 000000000..9a408702b --- /dev/null +++ b/core/components/VersionControl/svRuntime/index.ts @@ -0,0 +1,85 @@ +import TxAdmin from "@core/txAdmin"; +import consoleFactory from "@extras/console"; +import fsp from "node:fs/promises"; +import { VcFileSchema, VcFileType } from "./perfSchemas"; +import { ZodError } from "zod"; +import { throttle } from "throttle-debounce"; +import { defaultData } from "./config"; +import { existsSync } from "node:fs"; + +const modulename = "VersionControl"; +const DATA_FILE_NAME = "version_control.json"; +const console = consoleFactory(modulename); + +export default class SvRuntimeVersionControl { + readonly #txAdmin: TxAdmin; + private readonly dataFilePath: string; + private versionControlData: VcFileType | null = null; + private queueSaveVersionControlData = throttle( + 15_000, + this.saveVersionControlData.bind(this), + { noLeading: true } + ); + + constructor(txAdmin: TxAdmin) { + this.#txAdmin = txAdmin; + this.dataFilePath = `${txAdmin.info.serverProfilePath}/data/${DATA_FILE_NAME}`; + this.loadVersionControlData(); + } + + private async loadVersionControlData() { + if (existsSync(this.dataFilePath)) { + try { + const rawFileData = await fsp.readFile(this.dataFilePath, "utf8"); + const fileData = JSON.parse(rawFileData); + if (fileData?.version !== DATA_FILE_NAME) + throw new Error("invalid version"); + const versionControlData = VcFileSchema.parse(fileData); + this.versionControlData = versionControlData; + } catch (error) { + if ((error as any)?.code === "ENOENT") { + console.verbose.debug( + `${DATA_FILE_NAME} not found, starting with empty stats.` + ); + this.versionControlData = defaultData; + } else if (error instanceof ZodError) { + console.warn(`Failed to load ${DATA_FILE_NAME} due to invalid data.`); + } else { + console.warn( + `Failed to load ${DATA_FILE_NAME} with message: ${ + (error as Error).message + }` + ); + } + console.warn("Since this is not a critical file, it will be reset."); + } + } else { + console.verbose.debug( + `${DATA_FILE_NAME} not found, starting with empty stats.` + ); + this.versionControlData = defaultData; + } + } + public handleServerBoot(duration: number) { + this.queueSaveVersionControlData(); + } + + public handleServerStop(reason: string) { + this.queueSaveVersionControlData(); + } + + private async saveVersionControlData() { + try { + await fsp.writeFile( + this.dataFilePath, + JSON.stringify(this.versionControlData) + ); + } catch (error) { + console.warn( + `Failed to save ${DATA_FILE_NAME} with message: ${ + (error as Error).message + }` + ); + } + } +} diff --git a/core/components/VersionControl/svRuntime/perfSchemas.ts b/core/components/VersionControl/svRuntime/perfSchemas.ts new file mode 100644 index 000000000..f83bbda40 --- /dev/null +++ b/core/components/VersionControl/svRuntime/perfSchemas.ts @@ -0,0 +1,22 @@ +import * as z from "zod"; + +export const VcFileResourceSchema = z.object({ + submoduleName: z.string(), + repository: z.string(), + path: z.string(), + commit: z.string(), + isUpToDate: z.boolean().or( + z.object({ + prId: z.number(), + latestCommit: z.string() + }) + ) +}); + +export const VcFileSchema = z.object({ + resources: z.array(VcFileResourceSchema), + repository: z.null().or(z.string()) +}); + +export type VcFileType = z.infer; +export type VcFileResourceType = z.infer; diff --git a/core/components/WebServer/router.ts b/core/components/WebServer/router.ts index 6c20aef7c..9b4decd8e 100644 --- a/core/components/WebServer/router.ts +++ b/core/components/WebServer/router.ts @@ -107,6 +107,10 @@ export default (config: WebServerConfigType) => { router.get('/history/action', apiAuthMw, webRoutes.history_actionModal); router.post('/history/:action', apiAuthMw, webRoutes.history_actions); + // Version Control routes + router.get('/versionControl/resources', apiAuthMw, webRoutes.versionControl_resources); + router.get("/versionControl/owners", apiAuthMw, webRoutes.versionControl_owners) + //Player routes router.get('/player', apiAuthMw, webRoutes.player_modal); router.get('/player/stats', apiAuthMw, webRoutes.player_stats); diff --git a/core/extras/deployer.js b/core/extras/deployer.js index 25945330c..3dd920b6f 100644 --- a/core/extras/deployer.js +++ b/core/extras/deployer.js @@ -1,6 +1,6 @@ const modulename = 'Deployer'; import path from 'node:path'; -import { cloneDeep } from 'lodash-es'; +import { cloneDeep } from 'lodash-es'; import dateFormat from 'dateformat'; import fse from 'fs-extra'; import open from 'open'; @@ -9,6 +9,8 @@ import getOsDistro from '@core/extras/getOsDistro.js'; import { txEnv } from '@core/globalData'; import recipeEngine from './recipeEngine.js'; import consoleFactory from '@extras/console'; +import { githubRepoSourceRegex } from './helpers.js'; +import { spawnSync } from 'node:child_process'; const console = consoleFactory(modulename); @@ -161,8 +163,11 @@ export class Deployer { this.originalRecipe = originalRecipe; this.deploymentID = deploymentID; this.progress = 0; + this.validGithubData = false; + this.isOwnerOrganization = false; this.serverName = customMetaData.serverName || globals.txAdmin.globalConfig.serverName || ''; this.logLines = []; + this.userVars = null; //Load recipe const impRecipe = (originalRecipe !== false) @@ -225,16 +230,19 @@ export class Deployer { /** * Starts the deployment process - * @param {string} userInputs */ - start(userInputs) { - if (this.step !== 'input') throw new Error('expected input step'); - Object.assign(this.recipe.variables, userInputs); + async start() { + if (this.step !== 'versionControl') throw new Error('expected versionControl step'); this.logLines = []; this.customLog(`Starting deployment of ${this.recipe.name}.`); this.deployFailed = false; this.progress = 0; this.step = 'run'; + this.validGithubData = typeof globals.deployer.recipe.variables.githubAutoFork === 'boolean' && typeof globals.deployer.recipe.variables.githubAuthKey === 'string' && globals.deployer.recipe.variables.githubAuthKey.trim().length > 0 && typeof globals.deployer.recipe.variables.githubOwner === 'string' && globals.deployer.recipe.variables.githubOwner.trim().length > 0 && typeof globals.deployer.recipe.variables.githubParentRepo === 'string' && globals.deployer.recipe.variables.githubParentRepo.trim().length > 0; + if (this.validGithubData == true) { + const localUsername = await globals.versionControl.getUsername(); + this.isOwnerOrganization = this.recipe.variables.githubOwner !== localUsername; + } this.runTasks(); } @@ -249,11 +257,49 @@ export class Deployer { } catch (error) { } } + getSubmoduleFileData() { + let resp = ''; + + if (this.recipe.variables.githubAutoFork === true) { + for (let i = 0; i < this.recipe.tasks.length; i++) { + const v = this.recipe.tasks[i]; + + // todo: perhaps this should be verified the same way as in `./recipeEngine` + // todo: the last part here needs to be checked in a better way. we want all git submodules to be created in the root repo. therefore we need checks to see if there's technically a submodule containing another submodule + if (v.action === 'download_github' && typeof v.src === 'string' && typeof v.dest === 'string' && v.src !== 'https://github.com/citizenfx/cfx-server-data' && v.dest !== './resources') { + const srcMatch = v.src.match(githubRepoSourceRegex); + if (!srcMatch || !srcMatch[3] || !srcMatch[4]) throw new Error('invalid repository'); + const repoOwner = srcMatch[3]; + const repoName = srcMatch[4]; + + if (i !== 0) { + resp += '\n\n'; + } + + resp += `[submodule "${repoName}"]\npath = "${v.dest}"\nurl = "https://github.com/${this.recipe.variables.githubAutoFork === true ? this.recipe.variables.githubOwner : repoOwner}/${repoName}"`; + } + } + } + + return resp; + } + /** * (Private) Run the tasks in a sequential way. */ async runTasks() { if (this.step !== 'run') throw new Error('expected run step'); + + if (this.validGithubData === true) { + const repoCreationResp = await globals.versionControl.createGithubRepo(this.recipe.variables.githubParentRepo, this.recipe.variables.githubOwner, this.isOwnerOrganization); + + if (repoCreationResp) { + this.customLog('Created github repo'); + } else { + this.customLog('Couldnt create github repo'); + } + } + const contextVariables = cloneDeep(this.recipe.variables); contextVariables.deploymentID = this.deploymentID; contextVariables.serverName = this.serverName; @@ -263,7 +309,7 @@ export class Deployer { //Run all the tasks for (let index = 0; index < this.recipe.tasks.length; index++) { - this.progress = Math.round((index / this.recipe.tasks.length) * 100); + this.progress = Math.round((index / this.recipe.tasks.length) * (this.validGithubData === true ? 90 : 100)); const task = this.recipe.tasks[index]; const taskID = `[task${index + 1}:${task.action}]`; this.customLog(`Running ${taskID}...`); @@ -290,7 +336,7 @@ export class Deployer { + JSON.stringify([ txEnv.txAdminVersion, await getOsDistro(), - contextVariables.$step + contextVariables.$step, ]); } this.customLogError(msg); @@ -298,10 +344,6 @@ export class Deployer { } } - //Set progress - this.progress = 100; - this.customLog('All tasks completed.'); - //Check deploy folder validity (resources + server.cfg) try { if (!fse.existsSync(path.join(this.deployPath, 'resources'))) { @@ -327,6 +369,40 @@ export class Deployer { return await this.markFailedDeploy(); } + this.customLog('Starting github version control setup'); + if (this.validGithubData) { + fse.writeFileSync(path.join(this.deployPath, '.gitmodules'), this.getSubmoduleFileData()); + this.customLog('Wrote git submodules file!'); + fse.writeFileSync(path.join(this.deployPath, './resources/.gitignore'), 'node_modules\ncache\npackage-lock.json'); + fse.writeFileSync(path.join(this.deployPath, '.gitignore'), 'cache\n.replxx_history\n./*.bkp'); + + // todo: we need to check earlier if the `git` cli exists or not + // todo: if commit signing is turned on, this wont work + const cmdValue = [ + 'git init', + 'git add .', + 'git commit -am "feat: initial commit"', + 'git branch -M main', + `git remote add origin https://github.com/${this.recipe.variables.githubOwner}/${this.recipe.variables.githubParentRepo}.git`, + 'git push -u origin main', + ].join(' && '); + const gitResp = spawnSync(cmdValue, [], { + cwd: this.deployPath, + shell: true, + detached: true, + }); + + if (gitResp.status === 0) { + this.customLog('Pushed initial commit to github'); + } else { + this.customLog('Failed to push initial commit to github'); + } + } + + //Set progress + this.progress = 100; + this.customLog('All tasks completed.'); + //Else: success :) this.customLog('Deploy finished and folder validated. All done!'); this.step = 'configure'; @@ -336,4 +412,4 @@ export class Deployer { } catch (error) { } } } -} +} \ No newline at end of file diff --git a/core/extras/helpers.ts b/core/extras/helpers.ts index 4fa002d71..80e02714b 100644 --- a/core/extras/helpers.ts +++ b/core/extras/helpers.ts @@ -6,274 +6,302 @@ import consts from "../../shared/consts"; */ let __ascii: string; export const txAdminASCII = () => { - //NOTE: precalculating the ascii art for efficiency - // import figlet from 'figlet'; - // let ascii = figlet.textSync('txAdmin'); - // let b64 = Buffer.from(ascii).toString('base64'); - // console.log(b64); - if (!__ascii) { - const preCalculated = `ICBfICAgICAgICAgICAgXyAgICAgICBfICAgICAgICAgICBfICAgICAgIAogfCB8X19fICBfX + //NOTE: precalculating the ascii art for efficiency + // import figlet from 'figlet'; + // let ascii = figlet.textSync('txAdmin'); + // let b64 = Buffer.from(ascii).toString('base64'); + // console.log(b64); + if (!__ascii) { + const preCalculated = `ICBfICAgICAgICAgICAgXyAgICAgICBfICAgICAgICAgICBfICAgICAgIAogfCB8X19fICBfX yAgIC8gXCAgIF9ffCB8XyBfXyBfX18gKF8pXyBfXyAgCiB8IF9fXCBcLyAvICAvIF8gXCAvIF9gIHwgJ18gYCBfIFx8IHwg J18gXCAKIHwgfF8gPiAgPCAgLyBfX18gXCAoX3wgfCB8IHwgfCB8IHwgfCB8IHwgfAogIFxfXy9fL1xfXC9fLyAgIFxfXF9 fLF98X3wgfF98IHxffF98X3wgfF98CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA=`; - __ascii = Buffer.from(preCalculated, 'base64').toString('ascii'); - } - return __ascii; + __ascii = Buffer.from(preCalculated, "base64").toString("ascii"); + } + return __ascii; }; +export const githubRepoSourceRegex = + /^((https?:\/\/github\.com\/)?|@)?([\w.\-_]+)\/([\w.\-_]+).*$/; /** * Extracts hours and minutes from an string containing times */ export const parseSchedule = (scheduleTimes: string[]) => { - const valid = []; - const invalid = []; - for (const timeInput of scheduleTimes) { - if (typeof timeInput !== 'string') continue; - const timeTrim = timeInput.trim(); - if (!timeTrim.length) continue; - - const hmRegex = /^([01]?[0-9]|2[0-4]):([0-5][0-9])$/gm; - const m = hmRegex.exec(timeTrim); - if (m === null) { - invalid.push(timeTrim); - } else { - if (m[1] === '24') m[1] = '00'; //Americans, amirite?!?! - valid.push({ - string: m[1].padStart(2, '0') + ':' + m[2].padStart(2, '0'), - hours: parseInt(m[1]), - minutes: parseInt(m[2]), - }); - } + const valid = []; + const invalid = []; + for (const timeInput of scheduleTimes) { + if (typeof timeInput !== "string") continue; + const timeTrim = timeInput.trim(); + if (!timeTrim.length) continue; + + const hmRegex = /^([01]?[0-9]|2[0-4]):([0-5][0-9])$/gm; + const m = hmRegex.exec(timeTrim); + if (m === null) { + invalid.push(timeTrim); + } else { + if (m[1] === "24") m[1] = "00"; //Americans, amirite?!?! + valid.push({ + string: m[1].padStart(2, "0") + ":" + m[2].padStart(2, "0"), + hours: parseInt(m[1]), + minutes: parseInt(m[2]) + }); } - return { valid, invalid }; + } + return { valid, invalid }; }; - /** * Redacts sv_licenseKey, steam_webApiKey, sv_tebexSecret, and rcon_password from a string */ export const redactApiKeys = (src: string) => { - if (typeof src !== 'string' || !src.length) return src; - return src - .replace(/licenseKey\s+["']?cfxk_\w{1,60}_(\w+)["']?.?$/gim, 'licenseKey [REDACTED cfxk...$1]') - .replace(/steam_webApiKey\s+["']?\w{32}["']?.?$/gim, 'steam_webApiKey [REDACTED]') - .replace(/sv_tebexSecret\s+["']?\w{40}["']?.?$/gim, 'sv_tebexSecret [REDACTED]') - .replace(/rcon_password\s+["']?[^"']+["']?.?$/gim, 'rcon_password [REDACTED]') - .replace(/mysql_connection_string\s+["']?[^"']+["']?.?$/gim, 'mysql_connection_string [REDACTED]') - .replace(/discord\.com\/api\/webhooks\/\d{17,20}\/\w{10,}.?$/gim, 'discord.com/api/webhooks/[REDACTED]/[REDACTED]'); + if (typeof src !== "string" || !src.length) return src; + return src + .replace( + /licenseKey\s+["']?cfxk_\w{1,60}_(\w+)["']?.?$/gim, + "licenseKey [REDACTED cfxk...$1]" + ) + .replace( + /steam_webApiKey\s+["']?\w{32}["']?.?$/gim, + "steam_webApiKey [REDACTED]" + ) + .replace( + /sv_tebexSecret\s+["']?\w{40}["']?.?$/gim, + "sv_tebexSecret [REDACTED]" + ) + .replace( + /rcon_password\s+["']?[^"']+["']?.?$/gim, + "rcon_password [REDACTED]" + ) + .replace( + /mysql_connection_string\s+["']?[^"']+["']?.?$/gim, + "mysql_connection_string [REDACTED]" + ) + .replace( + /discord\.com\/api\/webhooks\/\d{17,20}\/\w{10,}.?$/gim, + "discord.com/api/webhooks/[REDACTED]/[REDACTED]" + ); }; - /** * Returns the unix timestamp in seconds. */ export const now = () => Math.round(Date.now() / 1000); - /** * Returns false if any argument is undefined */ -export const anyUndefined = (...args: any) => [...args].some((x) => (typeof x === 'undefined')); - +export const anyUndefined = (...args: any) => + [...args].some((x) => typeof x === "undefined"); /** * Calculates expiration and duration from a ban duration string like "1 day" */ export const calcExpirationFromDuration = (inputDuration: string) => { - let expiration; - let duration; - if (inputDuration === 'permanent') { - expiration = false as const; - } else { - const [multiplierInput, unit] = inputDuration.split(/\s+/); - const multiplier = parseInt(multiplierInput); - if (isNaN(multiplier) || multiplier < 1) { - throw new Error(`The duration multiplier must be a number above 1.`); - } + let expiration; + let duration; + if (inputDuration === "permanent") { + expiration = false as const; + } else { + const [multiplierInput, unit] = inputDuration.split(/\s+/); + const multiplier = parseInt(multiplierInput); + if (isNaN(multiplier) || multiplier < 1) { + throw new Error(`The duration multiplier must be a number above 1.`); + } - if (unit.startsWith('hour')) { - duration = multiplier * 3600; - } else if (unit.startsWith('day')) { - duration = multiplier * 86400; - } else if (unit.startsWith('week')) { - duration = multiplier * 604800; - } else if (unit.startsWith('month')) { - duration = multiplier * 2592000; //30 days - } else { - throw new Error(`Invalid ban duration. Supported units: hours, days, weeks, months`); - } - expiration = now() + duration; + if (unit.startsWith("hour")) { + duration = multiplier * 3600; + } else if (unit.startsWith("day")) { + duration = multiplier * 86400; + } else if (unit.startsWith("week")) { + duration = multiplier * 604800; + } else if (unit.startsWith("month")) { + duration = multiplier * 2592000; //30 days + } else { + throw new Error( + `Invalid ban duration. Supported units: hours, days, weeks, months` + ); } + expiration = now() + duration; + } - return { expiration, duration }; + return { expiration, duration }; }; - /** * Validates a single identifier and return its parts lowercased */ export const parsePlayerId = (idString: string) => { - if (typeof idString !== 'string') { - return { isIdValid: false, idType: null, idValue: null, idlowerCased: null }; - } - - const idlowerCased = idString.toLocaleLowerCase(); - const [idType, idValue] = idlowerCased.split(':', 2); - const validator = consts.validIdentifiers[idType as keyof typeof consts.validIdentifiers]; - if (validator && validator.test(idlowerCased)) { - return { isIdValid: true, idType, idValue, idlowerCased }; - } else { - return { isIdValid: false, idType, idValue, idlowerCased }; - } -} - + if (typeof idString !== "string") { + return { + isIdValid: false, + idType: null, + idValue: null, + idlowerCased: null + }; + } + + const idlowerCased = idString.toLocaleLowerCase(); + const [idType, idValue] = idlowerCased.split(":", 2); + const validator = + consts.validIdentifiers[idType as keyof typeof consts.validIdentifiers]; + if (validator && validator.test(idlowerCased)) { + return { isIdValid: true, idType, idValue, idlowerCased }; + } else { + return { isIdValid: false, idType, idValue, idlowerCased }; + } +}; /** * Get valid, invalid and license identifier from array of ids */ export const parsePlayerIds = (ids: string[]) => { - let invalidIdsArray: string[] = []; - let validIdsArray: string[] = []; - const validIdsObject: PlayerIdsObjectType = { - discord: null, - fivem: null, - license: null, - license2: null, - live: null, - steam: null, - xbl: null, - } - - for (const idString of ids) { - if (typeof idString !== 'string') continue; - const { isIdValid, idType, idValue } = parsePlayerId(idString); - if (isIdValid) { - validIdsArray.push(idString); - validIdsObject[idType as keyof PlayerIdsObjectType] = idValue; - } else { - invalidIdsArray.push(idString); - } + let invalidIdsArray: string[] = []; + let validIdsArray: string[] = []; + const validIdsObject: PlayerIdsObjectType = { + discord: null, + fivem: null, + license: null, + license2: null, + live: null, + steam: null, + xbl: null + }; + + for (const idString of ids) { + if (typeof idString !== "string") continue; + const { isIdValid, idType, idValue } = parsePlayerId(idString); + if (isIdValid) { + validIdsArray.push(idString); + validIdsObject[idType as keyof PlayerIdsObjectType] = idValue; + } else { + invalidIdsArray.push(idString); } + } - return { invalidIdsArray, validIdsArray, validIdsObject }; -} - + return { invalidIdsArray, validIdsArray, validIdsObject }; +}; /** * Get valid and invalid player HWIDs */ export const filterPlayerHwids = (hwids: string[]) => { - let invalidHwidsArray: string[] = []; - let validHwidsArray: string[] = []; + let invalidHwidsArray: string[] = []; + let validHwidsArray: string[] = []; - for (const hwidString of hwids) { - if (typeof hwidString !== 'string') continue; - if (consts.regexValidHwidToken.test(hwidString)) { - validHwidsArray.push(hwidString); - } else { - invalidHwidsArray.push(hwidString); - } + for (const hwidString of hwids) { + if (typeof hwidString !== "string") continue; + if (consts.regexValidHwidToken.test(hwidString)) { + validHwidsArray.push(hwidString); + } else { + invalidHwidsArray.push(hwidString); } + } - return { invalidHwidsArray, validHwidsArray }; -} - + return { invalidHwidsArray, validHwidsArray }; +}; /** * Attempts to parse a user-provided string into an array of valid identifiers. * This function is lenient and will attempt to parse any string into an array of valid identifiers. * For non-prefixed ids, it will attempt to parse it as discord, fivem, steam, or license. * Returns an array of valid ids/hwids, and array of invalid identifiers. - * + * * Stricter version of this function is parsePlayerIds */ export const parseLaxIdsArrayInput = (fullInput: string) => { - const validIds: string[] = []; - const validHwids: string[] = []; - const invalids: string[] = []; - - if (typeof fullInput !== 'string') { - return { validIds, validHwids, invalids }; - } - const inputs = fullInput.toLowerCase().split(/[,;\s]+/g).filter(Boolean); + const validIds: string[] = []; + const validHwids: string[] = []; + const invalids: string[] = []; - for (const input of inputs) { - if (input.includes(':')) { - if (consts.regexValidHwidToken.test(input)) { - validHwids.push(input); - } else if (Object.values(consts.validIdentifiers).some((regex) => regex.test(input))) { - validIds.push(input); - } else { - const [type, value] = input.split(':', 1); - if (consts.validIdentifierParts[type as keyof typeof consts.validIdentifierParts]?.test(value)) { - validIds.push(input); - } else { - invalids.push(input); - } - } - } else if (consts.validIdentifierParts.discord.test(input)) { - validIds.push(`discord:${input}`); - } else if (consts.validIdentifierParts.fivem.test(input)) { - validIds.push(`fivem:${input}`); - } else if (consts.validIdentifierParts.license.test(input)) { - validIds.push(`license:${input}`); - } else if (consts.validIdentifierParts.steam.test(input)) { - validIds.push(`steam:${input}`); + if (typeof fullInput !== "string") { + return { validIds, validHwids, invalids }; + } + const inputs = fullInput + .toLowerCase() + .split(/[,;\s]+/g) + .filter(Boolean); + + for (const input of inputs) { + if (input.includes(":")) { + if (consts.regexValidHwidToken.test(input)) { + validHwids.push(input); + } else if ( + Object.values(consts.validIdentifiers).some((regex) => + regex.test(input) + ) + ) { + validIds.push(input); + } else { + const [type, value] = input.split(":", 1); + if ( + consts.validIdentifierParts[ + type as keyof typeof consts.validIdentifierParts + ]?.test(value) + ) { + validIds.push(input); } else { - invalids.push(input); + invalids.push(input); } + } + } else if (consts.validIdentifierParts.discord.test(input)) { + validIds.push(`discord:${input}`); + } else if (consts.validIdentifierParts.fivem.test(input)) { + validIds.push(`fivem:${input}`); + } else if (consts.validIdentifierParts.license.test(input)) { + validIds.push(`license:${input}`); + } else if (consts.validIdentifierParts.steam.test(input)) { + validIds.push(`steam:${input}`); + } else { + invalids.push(input); } + } - return { validIds, validHwids, invalids }; -} - - + return { validIds, validHwids, invalids }; +}; /** * Extracts the fivem:xxxxxx identifier from the nameid field from the userInfo oauth response. * Example: https://forum.cfx.re/internal/user/271816 -> fivem:271816 */ export const getIdFromOauthNameid = (nameid: string) => { - try { - const res = /\/user\/(\d{1,8})/.exec(nameid); - //@ts-expect-error - return `fivem:${res[1]}`; - } catch (error) { - return false; - } -} - + try { + const res = /\/user\/(\d{1,8})/.exec(nameid); + //@ts-expect-error + return `fivem:${res[1]}`; + } catch (error) { + return false; + } +}; /** * Parses a number or string to a float with a limited precision. */ export const parseLimitedFloat = (src: number | string, precision = 6) => { - const srcAsNum = typeof src === 'string' ? parseFloat(src) : src; - return parseFloat(srcAsNum.toFixed(precision)); -} - + const srcAsNum = typeof src === "string" ? parseFloat(src) : src; + return parseFloat(srcAsNum.toFixed(precision)); +}; /** * Parses a fxserver version convar into a number. */ export const parseFxserverVersion = (version: any) => { - if (typeof version !== 'string') throw new Error(`expected string`); - - let platform: string | null = null; - if (version.includes('win32')) { - platform = 'windows'; - } else if (version.includes('linux')) { - platform = 'linux'; - } - - let build: number | null = null; - try { - const res = /v1\.0\.0\.(\d{4,5})\s*/.exec(version); - // @ts-ignore: let it throw - const num = parseInt(res[1]); - if (!isNaN(num)) build = num; - } catch (error) { } - - return { platform, build }; + if (typeof version !== "string") throw new Error(`expected string`); + + let platform: string | null = null; + if (version.includes("win32")) { + platform = "windows"; + } else if (version.includes("linux")) { + platform = "linux"; + } + + let build: number | null = null; + try { + const res = /v1\.0\.0\.(\d{4,5})\s*/.exec(version); + // @ts-ignore: let it throw + const num = parseInt(res[1]); + if (!isNaN(num)) build = num; + } catch (error) {} + + return { platform, build }; }; diff --git a/core/extras/recipeEngine.js b/core/extras/recipeEngine.js index cb2ed6ed2..f5aa1f691 100644 --- a/core/extras/recipeEngine.js +++ b/core/extras/recipeEngine.js @@ -5,10 +5,11 @@ import fsp from 'node:fs/promises'; import path from 'node:path'; import stream from 'node:stream'; import StreamZip from 'node-stream-zip'; -import { cloneDeep, escapeRegExp } from 'lodash-es'; +import { cloneDeep, escapeRegExp } from 'lodash-es'; import mysql from 'mysql2/promise'; import got from '@core/extras/got.js'; import consoleFactory from '@extras/console'; +import { githubRepoSourceRegex } from './helpers'; const console = consoleFactory(modulename); @@ -84,7 +85,6 @@ const taskDownloadFile = async (options, basePath, deployerCtx) => { * Downloads a github repository with an optional reference (branch, tag, commit hash) or subpath. * If the directory structure does not exist, it is created. */ -const githubRepoSourceRegex = /^((https?:\/\/github\.com\/)?|@)?([\w.\-_]+)\/([\w.\-_]+).*$/; const validatorDownloadGithub = (options) => { return ( typeof options.src == 'string' @@ -112,8 +112,8 @@ const taskDownloadGithub = async (options, basePath, deployerCtx) => { const data = await got.get( `https://api.github.com/repos/${repoOwner}/${repoName}`, { - timeout: { request: 15e3 } - } + timeout: { request: 15e3 }, + }, ).json(); if (typeof data !== 'object' || !data.default_branch) { throw new Error('reference not set, and wasn ot able to detect using github\'s api'); diff --git a/core/extras/setupProfile.js b/core/extras/setupProfile.js index 70f960156..84d5de960 100644 --- a/core/extras/setupProfile.js +++ b/core/extras/setupProfile.js @@ -19,6 +19,11 @@ const defaultConfig = { monitor: { restarterSchedule: [], }, + versionControl: { + githubAuthKey: null, + githubOwner: null, + githubParentRepo: null, + }, webServer: {}, discordBot: { enabled: false, @@ -62,9 +67,9 @@ export default (osType, fxServerPath, fxServerVersion, serverProfile, profilePat if (osType == 'windows') { try { const batLines = [ - `@echo off`, + '@echo off', `"${fxServerPath}/FXServer.exe" +set serverProfile "${serverProfile}"`, - `pause` + 'pause', ]; const batFolder = path.resolve(fxServerPath, '..'); const batPath = path.join(batFolder, `start_${fxServerVersion}_${serverProfile}.bat`); diff --git a/core/package.json b/core/package.json index ed71267e2..10c5da506 100644 --- a/core/package.json +++ b/core/package.json @@ -46,8 +46,10 @@ "mysql2": "^3.9.4", "nanoid": "^4.0.2", "nanoid-dictionary": "^4.3.0", + "node-fetch": "^3.3.2", "node-polyglot": "^2.5.0", "node-stream-zip": "^1.15.0", + "octokit": "^4.0.2", "open": "7.1.0", "openid-client": "^5.6.5", "pidtree": "^0.6.0", diff --git a/core/txAdmin.ts b/core/txAdmin.ts index 4aa5adf80..56962a107 100644 --- a/core/txAdmin.ts +++ b/core/txAdmin.ts @@ -16,6 +16,7 @@ import Logger from '@core/components/Logger'; import HealthMonitor from '@core/components/HealthMonitor'; import Scheduler from '@core/components/Scheduler'; import StatsManager from '@core/components/StatsManager'; +import VersionControl from '@core/components/VersionControl'; import Translator from '@core/components/Translator'; import WebServer from '@core/components/WebServer'; import ResourcesManager from '@core/components/ResourcesManager'; @@ -46,6 +47,7 @@ const globalsInternal: Record = { healthMonitor: null, scheduler: null, statsManager: null, + versionControl: null, translator: null, webServer: null, resourcesManager: null, @@ -78,6 +80,7 @@ export default class TxAdmin { healthMonitor; scheduler; statsManager; + versionControl; webServer; resourcesManager; playerlistManager; @@ -177,6 +180,9 @@ export default class TxAdmin { this.statsManager = new StatsManager(this); globalsInternal.statsManager = this.statsManager; + this.versionControl = new VersionControl(this); + globalsInternal.versionControl = this.versionControl; + this.webServer = new WebServer(this, profileConfig.webServer); globalsInternal.webServer = this.webServer; diff --git a/core/webroutes/deployer/actions.js b/core/webroutes/deployer/actions.js index 53e6aaca3..81f3d751d 100644 --- a/core/webroutes/deployer/actions.js +++ b/core/webroutes/deployer/actions.js @@ -12,7 +12,6 @@ const console = consoleFactory(modulename); //Helper functions const isUndefined = (x) => { return (typeof x === 'undefined'); }; - /** * Handle all the server control actions * @param {object} ctx @@ -39,6 +38,10 @@ export default async function DeployerActions(ctx) { return await handleConfirmRecipe(ctx); } else if (action == 'setVariables') { return await handleSetVariables(ctx); + } else if (action == 'runRecipe') { + return await handleRunRecipe(ctx); + } else if (action == 'setVersionControlVariables') { + return await handleSetVersionControlVariables(ctx); } else if (action == 'commit') { return await handleSaveConfig(ctx); } else if (action == 'cancel') { @@ -74,6 +77,93 @@ async function handleConfirmRecipe(ctx) { return ctx.send({ success: true }); } +//================================================================ +/** + * Handle submition of the version control variables + * @param {object} ctx + */ +async function handleRunRecipe(ctx) { + globals.deployer.recipe.variables['githubAutoFork'] = ctx.request.body.githubAutoFork === 'true'; + globals.deployer.recipe.variables['githubParentRepo'] = typeof ctx.request.body.githubParentRepo === 'string' ? ctx.request.body.githubParentRepo : null; + globals.deployer.recipe.variables['githubOwner'] = typeof ctx.request.body.githubOwner === 'string' ? ctx.request.body.githubOwner : null; + + //Start deployer + try { + ctx.admin.logAction('Running recipe.'); + await globals.deployer.start(); + } catch (error) { + return ctx.send({ type: 'danger', message: error.message }); + } + + return ctx.send({ success: true }); +} + +//================================================================ +/** + * Handle submition of the version control variables + * @param {object} ctx + */ +async function handleSetVersionControlVariables(ctx) { + if (ctx.request.body.key !== 'githubAutoFork' && ctx.request.body.key !== 'githubAuthKey' && ctx.request.body.key !== 'githubOwner' && ctx.request.body.key !== 'githubParentRepo') { + return ctx.utils.error(400, 'Invalid Request - invalid parameters'); + } + + // User doesnt want version control + if (ctx.request.body.value === null) { + return ctx.send({ success: true }); + } + + if (typeof ctx.request.body.value !== 'string') { + return ctx.utils.error(400, 'Invalid Request - invalid parameters'); + } + + // User doesnt want version control + if (ctx.request.body.value.trim().length <= 0) { + return ctx.send({ success: true }); + } + + //Validating auth key + if ( + ctx.request.body.key === 'githubAuthKey' + && !consts.regexGithubAuthKey.test(ctx.request.body.value) + ) { + return ctx.send({ type: 'danger', message: 'The Github Auth Key does not appear to be valid.' }); + } + + if (ctx.request.body.key === 'githubAuthKey' && (await globals.versionControl.isAuthKeyValid(ctx.request.body.value)) === false) { + return ctx.send({ type: 'danger', message: 'The Github Auth Key does not appear to be valid.' }); + } + + //Validating github auto fork + if (ctx.request.body.key === 'githubAutoFork') { + if (ctx.request.body.value !== 'true' && ctx.request.body.value !== 'false') { + return ctx.utils.error(400, 'Invalid Request - invalid parameters'); + } else { + ctx.request.body.value = ctx.request.body.value === 'true'; + } + } + + const newGlobalConfig = globals.configVault.getScopedStructure('versionControl'); + newGlobalConfig[ctx.request.body.key] = ctx.request.body.value; + + try { + globals.configVault.saveProfile('versionControl', newGlobalConfig); + } catch (error) { + console.warn(`[${ctx.admin.name}] Error changing version control settings via deployer.`); + console.verbose.dir(error); + return ctx.send({ + type: 'danger', + markdown: true, + message: `**Error saving the configuration file:** ${error.message}`, + }); + } + globals.txAdmin.refreshConfig(); + ctx.admin.logAction('Changing version control settings via deployer.'); + + globals.deployer.recipe.variables[ctx.request.body.key] = ctx.request.body.value; + + return ctx.send({ success: true }); +} //================================================================ /** @@ -123,7 +213,7 @@ async function handleSetVariables(ctx) { } outMessage = `${error?.message}
\n${specificError}`; } else if (error.message?.includes('auth_gssapi_client')) { - outMessage = `Your database does not accept the required authentication method. Please update your MySQL/MariaDB server and try again.`; + outMessage = 'Your database does not accept the required authentication method. Please update your MySQL/MariaDB server and try again.'; } return ctx.send({ type: 'danger', message: `Database connection failed: ${outMessage}` }); @@ -167,13 +257,8 @@ async function handleSetVariables(ctx) { ? addPrincipalLines.join('\n') : '# Deployer Note: this admin master has no identifiers to be automatically added.\n# add_principal identifier.discord:111111111111111111 group.admin #example'; - //Start deployer - try { - ctx.admin.logAction('Running recipe.'); - globals.deployer.start(userVars); - } catch (error) { - return ctx.send({ type: 'danger', message: error.message }); - } + Object.assign(globals.deployer.recipe.variables, userVars); + globals.deployer.step = 'versionControl'; return ctx.send({ success: true }); } @@ -228,7 +313,7 @@ async function handleSaveConfig(ctx) { return ctx.send({ type: 'danger', markdown: true, - message: `**Error saving the configuration file:** ${error.message}` + message: `**Error saving the configuration file:** ${error.message}`, }); } diff --git a/core/webroutes/deployer/status.js b/core/webroutes/deployer/status.js index 218084265..3b6b18483 100644 --- a/core/webroutes/deployer/status.js +++ b/core/webroutes/deployer/status.js @@ -1,8 +1,3 @@ -const modulename = 'WebServer:DeployerStatus'; -import consoleFactory from '@extras/console'; -const console = consoleFactory(modulename); - - /** * Returns the output page containing the live console * @param {object} ctx diff --git a/core/webroutes/deployer/stepper.js b/core/webroutes/deployer/stepper.js index fbd3fbe1a..975fd1974 100644 --- a/core/webroutes/deployer/stepper.js +++ b/core/webroutes/deployer/stepper.js @@ -29,6 +29,11 @@ export default async function DeployerStepper(ctx) { serverProfile: globals.info.serverProfile, deploymentID: globals.deployer.deploymentID, requireDBConfig: false, + githubOwners: [], + githubAuthKey: '', + githubOwner: 'none', + githubParentRepo: '', + githubAutoFork: false, defaultLicenseKey: '', recipe: undefined, defaults: {}, @@ -42,6 +47,12 @@ export default async function DeployerStepper(ctx) { description: globals.deployer.recipe.description, raw: globals.deployer.recipe.raw, }; + } else if (globals.deployer.step === 'versionControl') { + renderData.githubOwners = await globals.versionControl.getOwners(); + renderData.githubAuthKey = globals.deployer.recipe.variables.githubAuthKey ?? ''; + renderData.githubOwner = globals.deployer.recipe.variables.githubOwner ?? 'none'; + renderData.githubParentRepo = globals.deployer.recipe.variables.githubParentRepo ?? ''; + renderData.githubAutoFork = typeof globals.deployer.recipe.variables.githubAutoFork === 'boolean' ? globals.deployer.recipe.variables.githubAutoFork : false; } else if (globals.deployer.step === 'input') { renderData.defaultLicenseKey = process.env.TXADMIN_DEFAULT_LICENSE || ''; renderData.requireDBConfig = globals.deployer.recipe.requireDBConfig; diff --git a/core/webroutes/index.js b/core/webroutes/index.js index 3d5e3162c..bd525ea07 100644 --- a/core/webroutes/index.js +++ b/core/webroutes/index.js @@ -48,6 +48,9 @@ export { default as fxserver_controls } from './fxserver/controls'; export { default as fxserver_downloadLog } from './fxserver/downloadLog'; export { default as fxserver_schedule } from './fxserver/schedule'; +export { default as versionControl_resources } from './versionControl/resources'; +export { default as versionControl_owners } from './versionControl/owners'; + export { default as history_stats } from './history/stats'; export { default as history_search } from './history/search'; export { default as history_actionModal } from './history/actionModal'; diff --git a/core/webroutes/settings/save.ts b/core/webroutes/settings/save.ts index 72704c2f1..4f9f0bcb4 100644 --- a/core/webroutes/settings/save.ts +++ b/core/webroutes/settings/save.ts @@ -19,7 +19,7 @@ const isUndefined = (x: unknown) => (typeof x === 'undefined'); */ export default async function SettingsSave(ctx: AuthedCtx) { //Sanity check - if (isUndefined(ctx.params.scope)) { + if (!('params' in ctx) || ctx.params === null || typeof ctx.params !== 'object' || Array.isArray(ctx.params) === true || !('scope' in ctx.params) || typeof ctx.params.scope !== 'string') { return ctx.utils.error(400, 'Invalid Request'); } let scope = ctx.params.scope; @@ -43,6 +43,8 @@ export default async function SettingsSave(ctx: AuthedCtx) { return await handleMonitor(ctx); } else if (scope == 'discord') { return await handleDiscord(ctx); + } else if (scope == 'versionControl'){ + return await handleVersionControl(ctx); } else if (scope == 'menu') { return await handleMenu(ctx); } else { @@ -140,7 +142,7 @@ async function handleFXServer(ctx: AuthedCtx) { const resPath = path.join(cfg.serverDataPath, 'resources'); const resStat = await fsp.stat(resPath); if (!resStat.isDirectory()) { - throw new Error("Couldn't locate or read a resources folder inside of the base path."); + throw new Error('Couldn\'t locate or read a resources folder inside of the base path.'); } } catch (error) { const msg = cfg.serverDataPath.includes('resources') @@ -239,7 +241,7 @@ async function handlePlayerDatabase(ctx: AuthedCtx) { if (invalidRoleInputs.length) { return ctx.send({ type: 'danger', - message: `The whitelist role(s) "${invalidRoleInputs.join(', ')}" do not appear to be valid` + message: `The whitelist role(s) '${invalidRoleInputs.join(', ')}' do not appear to be valid` }); } @@ -350,6 +352,41 @@ async function handleMonitor(ctx: AuthedCtx) { }); } +//================================================================ +/** + * Handle version control + */ +async function handleVersionControl(ctx: AuthedCtx) { + if((typeof ctx.request.body.githubAuthKey === 'string' || ctx.request.body.githubAuthKey === null) && (typeof ctx.request.body.githubParentRepo === 'string' || ctx.request.body.githubParentRepo === null) && (typeof ctx.request.body.githubOwner === 'string' || ctx.request.body.githubOwner === null)) { + //Preparing & saving config + const newConfig = ctx.txAdmin.configVault.getScopedStructure('versionControl'); + newConfig.githubAuthKey = ctx.request.body.githubAuthKey; + newConfig.githubOwner = ctx.request.body.githubOwner; + newConfig.githubParentRepo = ctx.request.body.githubParentRepo; + try { + ctx.txAdmin.configVault.saveProfile('versionControl', newConfig); + } catch (error) { + console.warn(`[${ctx.admin.name}] Error changing Version Control settings.`); + console.verbose.dir(error); + return ctx.send({ + type: 'danger', + markdown: true, + message: `**Error saving the configuration file:** ${(error as Error).message}` + }); + } + + //Sending output + ctx.txAdmin.refreshConfig(); + ctx.admin.logAction('Changing version control settings.'); + return ctx.send({ + type: 'success', + markdown: true, + message: `**Version control configuration saved!**` + }); + } else { + return ctx.utils.error(400, 'Invalid Request - invalid parameters') + } +} //================================================================ /** diff --git a/core/webroutes/setup/post.js b/core/webroutes/setup/post.js index b5630758c..ef8a55ed3 100644 --- a/core/webroutes/setup/post.js +++ b/core/webroutes/setup/post.js @@ -111,7 +111,7 @@ async function handleValidateRecipeURL(ctx) { try { const recipeText = await got.get({ url: recipeURL, - timeout: { request: 4500 } + timeout: { request: 4500 }, }).text(); if (typeof recipeText !== 'string') throw new Error('This URL did not return a string.'); const recipe = parseValidateRecipe(recipeText); @@ -282,7 +282,7 @@ async function handleSaveLocal(ctx) { return ctx.send({ type: 'danger', markdown: true, - message: `**Error saving the configuration file:** ${error.message}` + message: `**Error saving the configuration file:** ${error.message}`, }); } @@ -331,7 +331,7 @@ async function handleSaveDeployerImport(ctx) { try { recipeText = await got.get({ url: recipeURL, - timeout: { request: 4500 } + timeout: { request: 4500 }, }).text(); if (typeof recipeText !== 'string') throw new Error('This URL did not return a string.'); } catch (error) { @@ -349,7 +349,7 @@ async function handleSaveDeployerImport(ctx) { return ctx.send({ type: 'danger', markdown: true, - message: `**Error saving the configuration file:** ${error.message}` + message: `**Error saving the configuration file:** ${error.message}`, }); } globals.txAdmin.refreshConfig(); @@ -395,7 +395,7 @@ async function handleSaveDeployerCustom(ctx) { return ctx.send({ type: 'danger', markdown: true, - message: `**Error saving the configuration file:** ${error.message}` + message: `**Error saving the configuration file:** ${error.message}`, }); } globals.txAdmin.refreshConfig(); diff --git a/core/webroutes/versionControl/actions.ts b/core/webroutes/versionControl/actions.ts new file mode 100644 index 000000000..e69de29bb diff --git a/core/webroutes/versionControl/owners.ts b/core/webroutes/versionControl/owners.ts new file mode 100644 index 000000000..7f54ee72e --- /dev/null +++ b/core/webroutes/versionControl/owners.ts @@ -0,0 +1,6 @@ +import { AuthedCtx } from "@core/components/WebServer/ctxTypes"; + +export default async function VersionControlOwners(ctx: AuthedCtx) { + /// @ts-ignore + return ctx.send(await globals.versionControl.getOwners()); +} diff --git a/core/webroutes/versionControl/resources.ts b/core/webroutes/versionControl/resources.ts new file mode 100644 index 000000000..2ed6e393f --- /dev/null +++ b/core/webroutes/versionControl/resources.ts @@ -0,0 +1,42 @@ +import { AuthedCtx } from "@core/components/WebServer/ctxTypes"; + +export default async function VersionControlResources(ctx: AuthedCtx) { + return ctx.send({ + resources: [ + { + submoduleName: "core", + path: "fx-data/resources/[wsrp]/core", + repository: "westroleplay/core", + commit: "5440e03", + isUpToDate: true + }, + { + submoduleName: "default", + path: "fx-data/resources/[wsrp]/default", + repository: "westroleplay/default", + commit: "a320e03", + isUpToDate: true + }, + { + submoduleName: "chat", + path: "fx-data/resources/[wsrp]/chat", + repository: "westroleplay/chat", + commit: "3025dba", + isUpToDate: { + prId: 7, + latestCommit: "d36428f" + } + }, + { + submoduleName: "oxmysql", + path: "fx-data/resources/[standalone]/oxmysql", + repository: "overextended/oxmysql", + commit: "5440e03", + isUpToDate: { + prId: 8, + latestCommit: "19d55a2" + } + } + ] + }); +} diff --git a/docs/permissions.md b/docs/permissions.md index d66805035..8e9986657 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -9,6 +9,7 @@ The permissions are saved in the `txData/admins.json` file and can be edited thr - `settings.view`: Settings: View (no tokens); - `settings.write`: Settings: Change; - `console.view`: Console: View; +- `version_control`: Version Control: Manage; - `console.write`: Console: Write; - `control.server`: Start/Stop/Restart Server; - `commands.resources`: Start/Stop Resources; diff --git a/nui/src/state/permissions.state.ts b/nui/src/state/permissions.state.ts index 2c12c83cf..0334ef611 100644 --- a/nui/src/state/permissions.state.ts +++ b/nui/src/state/permissions.state.ts @@ -15,6 +15,7 @@ export type ResolvablePermission = | "players.warn" | "players.whitelist" | "console.view" + | "version_control" | "console.write" | "control.server" | "server.cfg.editor" diff --git a/package-lock.json b/package-lock.json index 8b269595c..fbd6d14f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,8 +68,10 @@ "mysql2": "^3.9.4", "nanoid": "^4.0.2", "nanoid-dictionary": "^4.3.0", + "node-fetch": "^3.3.2", "node-polyglot": "^2.5.0", "node-stream-zip": "^1.15.0", + "octokit": "^4.0.2", "open": "7.1.0", "openid-client": "^5.6.5", "pidtree": "^0.6.0", @@ -3215,6 +3217,378 @@ "node": "^16.13.0 || >=18.0.0" } }, + "node_modules/@octokit/app": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/@octokit/app/-/app-15.1.0.tgz", + "integrity": "sha512-TkBr7QgOmE6ORxvIAhDbZsqPkF7RSqTY4pLTtUQCvr6dTXqvi2fFo46q3h1lxlk/sGMQjqyZ0kEahkD/NyzOHg==", + "dependencies": { + "@octokit/auth-app": "^7.0.0", + "@octokit/auth-unauthenticated": "^6.0.0", + "@octokit/core": "^6.1.2", + "@octokit/oauth-app": "^7.0.0", + "@octokit/plugin-paginate-rest": "^11.0.0", + "@octokit/types": "^13.0.0", + "@octokit/webhooks": "^13.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/auth-app": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-app/-/auth-app-7.1.0.tgz", + "integrity": "sha512-cazGaJPSgeZ8NkVYeM/C5l/6IQ5vZnsI8p1aMucadCkt/bndI+q+VqwrlnWbASRmenjOkf1t1RpCKrif53U8gw==", + "dependencies": { + "@octokit/auth-oauth-app": "^8.1.0", + "@octokit/auth-oauth-user": "^5.1.0", + "@octokit/request": "^9.1.1", + "@octokit/request-error": "^6.1.1", + "@octokit/types": "^13.4.1", + "lru-cache": "^10.0.0", + "universal-github-app-jwt": "^2.2.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/auth-app/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + }, + "node_modules/@octokit/auth-oauth-app": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-app/-/auth-oauth-app-8.1.1.tgz", + "integrity": "sha512-5UtmxXAvU2wfcHIPPDWzVSAWXVJzG3NWsxb7zCFplCWEmMCArSZV0UQu5jw5goLQXbFyOr5onzEH37UJB3zQQg==", + "dependencies": { + "@octokit/auth-oauth-device": "^7.0.0", + "@octokit/auth-oauth-user": "^5.0.1", + "@octokit/request": "^9.0.0", + "@octokit/types": "^13.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/auth-oauth-device": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-device/-/auth-oauth-device-7.1.1.tgz", + "integrity": "sha512-HWl8lYueHonuyjrKKIup/1tiy0xcmQCdq5ikvMO1YwkNNkxb6DXfrPjrMYItNLyCP/o2H87WuijuE+SlBTT8eg==", + "dependencies": { + "@octokit/oauth-methods": "^5.0.0", + "@octokit/request": "^9.0.0", + "@octokit/types": "^13.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/auth-oauth-user": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-user/-/auth-oauth-user-5.1.1.tgz", + "integrity": "sha512-rRkMz0ErOppdvEfnemHJXgZ9vTPhBuC6yASeFaB7I2yLMd7QpjfrL1mnvRPlyKo+M6eeLxrKanXJ9Qte29SRsw==", + "dependencies": { + "@octokit/auth-oauth-device": "^7.0.1", + "@octokit/oauth-methods": "^5.0.0", + "@octokit/request": "^9.0.1", + "@octokit/types": "^13.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/auth-token": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-5.1.1.tgz", + "integrity": "sha512-rh3G3wDO8J9wSjfI436JUKzHIxq8NaiL0tVeB2aXmG6p/9859aUOAjA9pmSPNGGZxfwmaJ9ozOJImuNVJdpvbA==", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/auth-unauthenticated": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-unauthenticated/-/auth-unauthenticated-6.1.0.tgz", + "integrity": "sha512-zPSmfrUAcspZH/lOFQnVnvjQZsIvmfApQH6GzJrkIunDooU1Su2qt2FfMTSVPRp7WLTQyC20Kd55lF+mIYaohQ==", + "dependencies": { + "@octokit/request-error": "^6.0.1", + "@octokit/types": "^13.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/core": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.2.tgz", + "integrity": "sha512-hEb7Ma4cGJGEUNOAVmyfdB/3WirWMg5hDuNFVejGEDFqupeOysLc2sG6HJxY2etBp5YQu5Wtxwi020jS9xlUwg==", + "dependencies": { + "@octokit/auth-token": "^5.0.0", + "@octokit/graphql": "^8.0.0", + "@octokit/request": "^9.0.0", + "@octokit/request-error": "^6.0.1", + "@octokit/types": "^13.0.0", + "before-after-hook": "^3.0.2", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/endpoint": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.1.tgz", + "integrity": "sha512-JYjh5rMOwXMJyUpj028cu0Gbp7qe/ihxfJMLc8VZBMMqSwLgOxDI1911gV4Enl1QSavAQNJcwmwBF9M0VvLh6Q==", + "dependencies": { + "@octokit/types": "^13.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/graphql": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-8.1.1.tgz", + "integrity": "sha512-ukiRmuHTi6ebQx/HFRCXKbDlOh/7xEV6QUXaE7MJEKGNAncGI/STSbOkl12qVXZrfZdpXctx5O9X1AIaebiDBg==", + "dependencies": { + "@octokit/request": "^9.0.0", + "@octokit/types": "^13.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/oauth-app": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@octokit/oauth-app/-/oauth-app-7.1.3.tgz", + "integrity": "sha512-EHXbOpBkSGVVGF1W+NLMmsnSsJRkcrnVmDKt0TQYRBb6xWfWzoi9sBD4DIqZ8jGhOWO/V8t4fqFyJ4vDQDn9bg==", + "dependencies": { + "@octokit/auth-oauth-app": "^8.0.0", + "@octokit/auth-oauth-user": "^5.0.1", + "@octokit/auth-unauthenticated": "^6.0.0-beta.1", + "@octokit/core": "^6.0.0", + "@octokit/oauth-authorization-url": "^7.0.0", + "@octokit/oauth-methods": "^5.0.0", + "@types/aws-lambda": "^8.10.83", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/oauth-authorization-url": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@octokit/oauth-authorization-url/-/oauth-authorization-url-7.1.1.tgz", + "integrity": "sha512-ooXV8GBSabSWyhLUowlMIVd9l1s2nsOGQdlP2SQ4LnkEsGXzeCvbSbCPdZThXhEFzleGPwbapT0Sb+YhXRyjCA==", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/oauth-methods": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@octokit/oauth-methods/-/oauth-methods-5.1.2.tgz", + "integrity": "sha512-C5lglRD+sBlbrhCUTxgJAFjWgJlmTx5bQ7Ch0+2uqRjYv7Cfb5xpX4WuSC9UgQna3sqRGBL9EImX9PvTpMaQ7g==", + "dependencies": { + "@octokit/oauth-authorization-url": "^7.0.0", + "@octokit/request": "^9.1.0", + "@octokit/request-error": "^6.1.0", + "@octokit/types": "^13.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "22.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-22.2.0.tgz", + "integrity": "sha512-QBhVjcUa9W7Wwhm6DBFu6ZZ+1/t/oYxqc2tp81Pi41YNuJinbFRx8B133qVOrAaBbF7D/m0Et6f9/pZt9Rc+tg==" + }, + "node_modules/@octokit/openapi-webhooks-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-webhooks-types/-/openapi-webhooks-types-8.3.0.tgz", + "integrity": "sha512-vKLsoR4xQxg4Z+6rU/F65ItTUz/EXbD+j/d4mlq2GW8TsA4Tc8Kdma2JTAAJ5hrKWUQzkR/Esn2fjsqiVRYaQg==" + }, + "node_modules/@octokit/plugin-paginate-graphql": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-graphql/-/plugin-paginate-graphql-5.2.2.tgz", + "integrity": "sha512-7znSVvlNAOJisCqAnjN1FtEziweOHSjPGAuc5W58NeGNAr/ZB57yCsjQbXDlWsVryA7hHQaEQPcBbJYFawlkyg==", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.3.3.tgz", + "integrity": "sha512-o4WRoOJZlKqEEgj+i9CpcmnByvtzoUYC6I8PD2SA95M+BJ2x8h7oLcVOg9qcowWXBOdcTRsMZiwvM3EyLm9AfA==", + "dependencies": { + "@octokit/types": "^13.5.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "13.2.4", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-13.2.4.tgz", + "integrity": "sha512-gusyAVgTrPiuXOdfqOySMDztQHv6928PQ3E4dqVGEtOvRXAKRbJR4b1zQyniIT9waqaWk/UDaoJ2dyPr7Bk7Iw==", + "dependencies": { + "@octokit/types": "^13.5.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-retry": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-7.1.1.tgz", + "integrity": "sha512-G9Ue+x2odcb8E1XIPhaFBnTTIrrUDfXN05iFXiqhR+SeeeDMMILcAnysOsxUpEWcQp2e5Ft397FCXTcPkiPkLw==", + "dependencies": { + "@octokit/request-error": "^6.0.0", + "@octokit/types": "^13.0.0", + "bottleneck": "^2.15.3" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-throttling": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-9.3.0.tgz", + "integrity": "sha512-B5YTToSRTzNSeEyssnrT7WwGhpIdbpV9NKIs3KyTWHX6PhpYn7gqF/+lL3BvsASBM3Sg5BAUYk7KZx5p/Ec77w==", + "dependencies": { + "@octokit/types": "^13.0.0", + "bottleneck": "^2.15.3" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "^6.0.0" + } + }, + "node_modules/@octokit/request": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-9.1.1.tgz", + "integrity": "sha512-pyAguc0p+f+GbQho0uNetNQMmLG1e80WjkIaqqgUkihqUp0boRU6nKItXO4VWnr+nbZiLGEyy4TeKRwqaLvYgw==", + "dependencies": { + "@octokit/endpoint": "^10.0.0", + "@octokit/request-error": "^6.0.1", + "@octokit/types": "^13.1.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/request-error": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.4.tgz", + "integrity": "sha512-VpAhIUxwhWZQImo/dWAN/NpPqqojR6PSLgLYAituLM6U+ddx9hCioFGwBr5Mi+oi5CLeJkcAs3gJ0PYYzU6wUg==", + "dependencies": { + "@octokit/types": "^13.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/types": { + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.5.0.tgz", + "integrity": "sha512-HdqWTf5Z3qwDVlzCrP8UJquMwunpDiMPt5er+QjGzL4hqr/vBVY/MauQgS1xWxCDT1oMx1EULyqxncdCY/NVSQ==", + "dependencies": { + "@octokit/openapi-types": "^22.2.0" + } + }, + "node_modules/@octokit/webhooks": { + "version": "13.2.8", + "resolved": "https://registry.npmjs.org/@octokit/webhooks/-/webhooks-13.2.8.tgz", + "integrity": "sha512-nQyf5UDEgIeZB65rXSvK9ep9PkYthbnmXu/VK9AJcMMOvH1UwzcvYNd9DN++HIr8HXwbqB1FAEv5CnNARQEsaQ==", + "dependencies": { + "@octokit/openapi-webhooks-types": "8.3.0", + "@octokit/request-error": "^6.0.1", + "@octokit/webhooks-methods": "^5.0.0", + "aggregate-error": "^5.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/webhooks-methods": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@octokit/webhooks-methods/-/webhooks-methods-5.1.0.tgz", + "integrity": "sha512-yFZa3UH11VIxYnnoOYCVoJ3q4ChuSOk2IVBBQ0O3xtKX4x9bmKb/1t+Mxixv2iUhzMdOl1qeWJqEhouXXzB3rQ==", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/webhooks/node_modules/aggregate-error": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", + "integrity": "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==", + "dependencies": { + "clean-stack": "^5.2.0", + "indent-string": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@octokit/webhooks/node_modules/clean-stack": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.2.0.tgz", + "integrity": "sha512-TyUIUJgdFnCISzG5zu3291TAsE77ddchd0bepon1VVQrKLGKFED4iXFEDQ24mIPdPBbyE16PK3F8MYE1CmcBEQ==", + "dependencies": { + "escape-string-regexp": "5.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@octokit/webhooks/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@octokit/webhooks/node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -4786,6 +5160,11 @@ "@types/node": "*" } }, + "node_modules/@types/aws-lambda": { + "version": "8.10.141", + "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.141.tgz", + "integrity": "sha512-SMWlRBukG9KV8ZNjwemp2AzDibp/czIAeKKTw09nCPbWxVskIxactCJCGOp4y6I1hCMY7T7UGfySvBLXNeUbEw==" + }, "node_modules/@types/body-parser": { "version": "1.19.4", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.4.tgz", @@ -6276,6 +6655,11 @@ "node": "^4.5.0 || >= 5.9" } }, + "node_modules/before-after-hook": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-3.0.2.tgz", + "integrity": "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==" + }, "node_modules/bin-links": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/bin-links/-/bin-links-4.0.3.tgz", @@ -6310,6 +6694,11 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bottleneck": { + "version": "2.19.5", + "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", + "integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==" + }, "node_modules/boxen": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.1.1.tgz", @@ -7607,6 +7996,14 @@ "node": ">=8" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "engines": { + "node": ">= 12" + } + }, "node_modules/dateformat": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-5.0.3.tgz", @@ -9019,6 +9416,28 @@ "reusify": "^1.0.4" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -9197,6 +9616,17 @@ "node": ">= 14.17" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -12425,6 +12855,41 @@ "node": ">= 0.6" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/node-gyp": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-10.1.0.tgz", @@ -12829,6 +13294,26 @@ "integrity": "sha512-lgHwxlxV1qIg1Eap7LgIeoBWIMFibOjbrYPIPJZcI1mmGAI2m3lNYpK12Y+GBdPQ0U1hRwSord7GIaawz962qQ==", "license": "MIT" }, + "node_modules/octokit": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/octokit/-/octokit-4.0.2.tgz", + "integrity": "sha512-wbqF4uc1YbcldtiBFfkSnquHtECEIpYD78YUXI6ri1Im5OO2NLo6ZVpRdbJpdnpZ05zMrVPssNiEo6JQtea+Qg==", + "dependencies": { + "@octokit/app": "^15.0.0", + "@octokit/core": "^6.0.0", + "@octokit/oauth-app": "^7.0.0", + "@octokit/plugin-paginate-graphql": "^5.0.0", + "@octokit/plugin-paginate-rest": "^11.0.0", + "@octokit/plugin-rest-endpoint-methods": "^13.0.0", + "@octokit/plugin-retry": "^7.0.0", + "@octokit/plugin-throttling": "^9.0.0", + "@octokit/request-error": "^6.0.0", + "@octokit/types": "^13.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/oidc-token-hash": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", @@ -16042,6 +16527,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/universal-github-app-jwt": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/universal-github-app-jwt/-/universal-github-app-jwt-2.2.0.tgz", + "integrity": "sha512-G5o6f95b5BggDGuUfKDApKaCgNYy2x7OdHY0zSMF081O0EJobw+1130VONhrA7ezGSV2FNOGyM+KQpQZAr9bIQ==" + }, + "node_modules/universal-user-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.2.tgz", + "integrity": "sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q==" + }, "node_modules/universalify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", @@ -16945,6 +17440,14 @@ "defaults": "^1.0.3" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "engines": { + "node": ">= 8" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/panel/src/layout/MainRouter.tsx b/panel/src/layout/MainRouter.tsx index 365010c67..ef8a6a66d 100644 --- a/panel/src/layout/MainRouter.tsx +++ b/panel/src/layout/MainRouter.tsx @@ -9,6 +9,7 @@ import Iframe from "@/pages/Iframe"; import NotFound from "@/pages/NotFound"; import TestingPage from "@/pages/TestingPage/TestingPage"; import LiveConsolePage from "@/pages/LiveConsole/LiveConsolePage"; +import VersionControlPage from "@/pages/VersionControl/VersionControlPage"; import PlayersPage from "@/pages/Players/PlayersPage"; import HistoryPage from "@/pages/History/HistoryPage"; import BanTemplatesPage from "@/pages/BanTemplates/BanTemplatesPage"; @@ -103,6 +104,11 @@ const allRoutes: RouteType[] = [ title: 'CFG Editor', children: