Files
ptero-reforger-panel/apps/api/src/modules/config/config-editor-service.ts
T
2026-09-05 13:10:36 -07:00

189 lines
6.1 KiB
TypeScript

import type {
ConfigPatchOp,
ConfigPatchResult,
ConfigRawResponse,
ConfigTreeResponse,
StartupMirror,
} from '@reforger-panel/shared';
import { ApiError } from '../../lib/errors.js';
import type { Logger } from '../../lib/logger.js';
import type { GameServerProvider } from '../pterodactyl/types.js';
import type { ServerRecord } from '../servers/server-service.js';
import type { ConfigFileGateway } from './config-file-gateway.js';
import { applyConfigOps, flattenConfig, verifyConfigOps } from './config-tree.js';
import type { ConfigSyncService } from './config-sync.js';
import { detectStartupMirrors, mirrorsForPath } from './startup-mirrors.js';
function providerId(server: ServerRecord): string {
return server.pterodactylServerId ?? server.slug;
}
/**
* The general-purpose config.json editor: every key the file actually
* contains, addressed by path, patched one field at a time.
*
* The old editor could only reach eleven hardcoded keys and submitted all of
* them on every save, so a stale form quietly reverted whatever anyone else
* had changed. Here only the paths the user touched are sent, and the write is
* refused outright if the file moved since it was loaded.
*/
export class ConfigEditorService {
constructor(
private readonly gateway: ConfigFileGateway,
private readonly provider: GameServerProvider,
private readonly configSync: ConfigSyncService,
private readonly logger: Logger,
) {}
private async loadMirrors(
server: ServerRecord,
root: Record<string, unknown>,
): Promise<StartupMirror[]> {
const variables = await this.provider
.listStartupVariables(providerId(server))
.then((list) =>
list.map((variable) => ({
name: variable.name,
description: variable.description,
envVariable: variable.envVariable,
value: variable.serverValue,
defaultValue: variable.defaultValue,
isEditable: variable.isEditable,
})),
)
.catch(() => []);
return detectStartupMirrors(root, variables);
}
async getTree(server: ServerRecord): Promise<ConfigTreeResponse> {
const document = await this.gateway.download(providerId(server));
return {
entries: flattenConfig(document.root),
mirrors: await this.loadMirrors(server, document.root),
revision: document.revision,
fetchedAt: new Date().toISOString(),
};
}
async getRaw(server: ServerRecord): Promise<ConfigRawResponse> {
const document = await this.gateway.download(providerId(server));
return {
content: document.raw,
revision: document.revision,
fetchedAt: new Date().toISOString(),
};
}
async putRaw(
server: ServerRecord,
content: string,
expectedRevision?: string,
): Promise<ConfigRawResponse> {
const document = await this.gateway.replace(providerId(server), content, expectedRevision);
await this.afterWrite(server);
return {
content: document.raw,
revision: document.revision,
fetchedAt: new Date().toISOString(),
};
}
/**
* Applies field-level edits. When `writeStartupVars` is set, any changed path
* that the egg also templates from a startup variable is written to both
* places, so the change survives the next restart.
*/
async patch(
server: ServerRecord,
ops: readonly ConfigPatchOp[],
options: { expectedRevision?: string; writeStartupVars?: boolean } = {},
): Promise<ConfigPatchResult> {
if (ops.length === 0) {
const current = await this.gateway.download(providerId(server));
return {
changedPaths: [],
startupVarsWritten: [],
revision: current.revision,
fetchedAt: new Date().toISOString(),
requiresRestart: true,
};
}
let changedPaths: string[] = [];
const document = await this.gateway.mutate(providerId(server), {
expectedRevision: options.expectedRevision,
apply: (root) => {
changedPaths = applyConfigOps(root, ops);
},
verify: (readBack) => {
const failed = verifyConfigOps(readBack, ops);
if (failed) {
throw ApiError.upstream(
`Config write verification failed for "${failed}" — the file on the server does not match. Check config.json.bak.`,
);
}
},
});
const startupVarsWritten = options.writeStartupVars
? await this.mirrorChangedPaths(server, document.root, ops, changedPaths)
: [];
await this.afterWrite(server);
this.logger.info(
{ serverId: server.id, changedPaths, startupVarsWritten },
'config.json updated',
);
return {
changedPaths,
startupVarsWritten,
revision: document.revision,
fetchedAt: new Date().toISOString(),
requiresRestart: true,
};
}
private async mirrorChangedPaths(
server: ServerRecord,
root: Record<string, unknown>,
ops: readonly ConfigPatchOp[],
changedPaths: readonly string[],
): Promise<string[]> {
const mirrors = await this.loadMirrors(server, root);
const written: string[] = [];
for (const path of changedPaths) {
const op = ops.find((candidate) => candidate.path === path);
if (!op || op.value === null) continue;
for (const mirror of mirrorsForPath(path, mirrors)) {
try {
await this.provider.updateStartupVariable(
providerId(server),
mirror.envVariable,
String(op.value),
);
written.push(mirror.envVariable);
} catch (error) {
// Some eggs mark variables read-only; that is not fatal for the
// config write that already succeeded.
this.logger.warn(
{ serverId: server.id, envVariable: mirror.envVariable, err: String(error) },
'startup variable mirror write failed',
);
}
}
}
return written;
}
private async afterWrite(server: ServerRecord): Promise<void> {
await this.configSync.sync(server).catch((error) => {
this.logger.warn(
{ serverId: server.id, err: String(error) },
'post-write config sync failed',
);
});
}
}