mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
166 lines
6.2 KiB
JavaScript
166 lines
6.2 KiB
JavaScript
// Configuration File Importer
|
|
// Purpose: Validates a deliberately uploaded legacy YAML file for first-run setup or an explicit administrative replacement.
|
|
// Scope: Startup and installation never search for or consume configuration files; every import begins with a browser-selected file.
|
|
const yaml = require('js-yaml');
|
|
const {
|
|
rootSchema,
|
|
secretPaths,
|
|
normalizeConfig,
|
|
assertValidConfig,
|
|
} = require('./validation');
|
|
|
|
const MAX_CONFIGURATION_FILE_BYTES = 1024 * 1024;
|
|
|
|
function getAtPath(object, dottedPath) {
|
|
return String(dottedPath || '').split('.').filter(Boolean)
|
|
.reduce((value, key) => value?.[key], object);
|
|
}
|
|
|
|
function setAtPath(object, dottedPath, value) {
|
|
const parts = String(dottedPath || '').split('.').filter(Boolean);
|
|
let cursor = object;
|
|
parts.slice(0, -1).forEach((key) => {
|
|
cursor = cursor[key];
|
|
});
|
|
cursor[parts.at(-1)] = value;
|
|
}
|
|
|
|
function hasAtPath(object, dottedPath) {
|
|
/*
|
|
Presence, rather than truthiness, distinguishes an omitted legacy secret
|
|
from an explicitly empty one. An omitted credential must preserve the
|
|
running installation's value, while an empty YAML value deliberately
|
|
clears it through the same operation used by the schema form.
|
|
*/
|
|
let cursor = object;
|
|
for (const key of String(dottedPath || '').split('.').filter(Boolean)) {
|
|
if (cursor === null || typeof cursor !== 'object' || !Object.hasOwn(cursor, key)) return false;
|
|
cursor = cursor[key];
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function keepCurrentSchemaFields(value, schema) {
|
|
/*
|
|
An uploaded file is only a convenient seed for the current configuration;
|
|
it is not a second schema or a historical migration framework. Legacy YAML
|
|
was permissive, so real installations naturally contain keys left behind
|
|
by removed features. At object boundaries, copy only properties that exist
|
|
in today's schema and recursively apply the same rule to nested objects and
|
|
array items. Known fields retain their original values and are validated
|
|
normally afterward, so this cannot hide a malformed current setting.
|
|
*/
|
|
if (schema?.type === 'object') {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
|
|
return Object.fromEntries(Object.entries(schema.properties || {})
|
|
.filter(([key]) => Object.hasOwn(value, key))
|
|
.map(([key, childSchema]) => [key, keepCurrentSchemaFields(value[key], childSchema)]));
|
|
}
|
|
|
|
if (schema?.type === 'array') {
|
|
if (!Array.isArray(value)) return value;
|
|
return value.map((item) => keepCurrentSchemaFields(item, schema.items));
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
function normalizeUploadedAdministrator(entry, index) {
|
|
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
throw new Error(`Administrator ${index + 1} must be an object.`);
|
|
}
|
|
const username = String(entry.username || '').trim();
|
|
const passwordHash = String(entry.password_hash || '').trim();
|
|
if (!username || !passwordHash) {
|
|
throw new Error(`Administrator ${index + 1} requires username and password_hash.`);
|
|
}
|
|
return {
|
|
username,
|
|
passwordHash,
|
|
discordId: String(entry.discord_id || '').trim(),
|
|
role: entry.lockdown ? 'lockdown' : 'admin',
|
|
};
|
|
}
|
|
|
|
function parseConfigurationFile(text, { includeAdministrators = true } = {}) {
|
|
const parsed = yaml.load(String(text || ''));
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
throw new Error('The configuration file must contain a YAML object.');
|
|
}
|
|
|
|
const uploadedAdministrators = Array.isArray(parsed.admins) ? parsed.admins : [];
|
|
/*
|
|
First-run setup is the sole workflow allowed to create accounts from the
|
|
legacy file. An initialized server ignores the entire admins collection,
|
|
including obsolete or malformed entries, so importing configuration can
|
|
never rename accounts, replace password hashes, or remove the last
|
|
lockdown administrator.
|
|
*/
|
|
const administrators = includeAdministrators
|
|
? uploadedAdministrators.map(normalizeUploadedAdministrator)
|
|
: [];
|
|
const configInput = Object.fromEntries(
|
|
Object.entries(parsed).filter(([key]) => key !== 'admins'),
|
|
);
|
|
secretPaths.forEach((secretPath) => {
|
|
/*
|
|
YAML commonly represents `token:` as null even though the application
|
|
models an unconfigured credential as an empty string. Translate null only
|
|
for known secret fields so a plainly empty legacy credential has the same
|
|
clear meaning as the admin form; null in any ordinary current field still
|
|
fails its schema normally.
|
|
*/
|
|
if (hasAtPath(configInput, secretPath) && getAtPath(configInput, secretPath) === null) {
|
|
setAtPath(configInput, secretPath, '');
|
|
}
|
|
});
|
|
const config = normalizeConfig(keepCurrentSchemaFields(configInput, rootSchema));
|
|
const providedSecretPaths = secretPaths.filter((secretPath) => hasAtPath(configInput, secretPath));
|
|
|
|
// Filtering applies only to nonexistent keys. Values retained for current
|
|
// schema fields still have to satisfy every type, range, and format rule
|
|
// before the importer can atomically initialize the database.
|
|
assertValidConfig(config);
|
|
return {
|
|
config,
|
|
administrators,
|
|
uploadedAdministratorCount: uploadedAdministrators.length,
|
|
providedSecretPaths,
|
|
};
|
|
}
|
|
|
|
function buildSecretOperationsForImport({ config, providedSecretPaths = [] }) {
|
|
return Object.fromEntries(providedSecretPaths.map((secretPath) => {
|
|
const value = getAtPath(config, secretPath);
|
|
/*
|
|
Current secret schemas are strings. Keeping this conversion beside the
|
|
importer produces the database's narrow replace/clear contract and
|
|
avoids granting the import route a way around ordinary secret handling.
|
|
*/
|
|
return value === ''
|
|
? [secretPath, { action: 'clear' }]
|
|
: [secretPath, { action: 'replace', value }];
|
|
}));
|
|
}
|
|
|
|
function importConfigurationFile({ text, database, actor = 'setup-file-upload', source = 'uploaded-config.yaml' }) {
|
|
const result = parseConfigurationFile(text);
|
|
const revision = database.importConfigurationFile({
|
|
config: result.config,
|
|
administrators: result.administrators,
|
|
actor,
|
|
source,
|
|
});
|
|
return {
|
|
revision,
|
|
administratorCount: result.administrators.length,
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
MAX_CONFIGURATION_FILE_BYTES,
|
|
parseConfigurationFile,
|
|
buildSecretOperationsForImport,
|
|
importConfigurationFile,
|
|
};
|