mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
legacy importer in admin page aswell as setup.
This commit is contained in:
+40
-40
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
@@ -1,2 +1,2 @@
|
||||
import{e as F,r as s,j as e,S as P,C as c,L as $}from"./index-DNdP4vNH.js";import{e as q,f as D,i as I}from"./api-C0PIt_OP.js";function R(){const l=F(),[a,m]=s.useState(null),[n,b]=s.useState(""),[d,C]=s.useState(""),[p,N]=s.useState(""),[r,S]=s.useState(""),[f,v]=s.useState(""),[o,w]=s.useState(null),[x,h]=s.useState(!1),[g,i]=s.useState("");s.useEffect(()=>{q(l).then(t=>m(t.required)).catch(t=>i(t.message))},[l]);async function j(t){h(!0),i("");try{await t(),m(!1),i("Setup completed. You can now open the administration application and log in.")}catch(u){const E=Array.isArray(u.validationErrors)?` ${u.validationErrors.map(y=>`${y.path}: ${y.message}`).join("; ")}`:"";i(`${u.message}${E}`)}finally{h(!1)}}function k(t){if(t.preventDefault(),r!==f){i("Passwords do not match.");return}j(()=>D(l,{setupCode:n,username:d,discordId:p,password:r}))}function A(t){t.preventDefault(),o&&j(async()=>I(l,{setupCode:n,fileName:o.name,yaml:await o.text()}))}return e.jsxs("div",{className:"min-h-screen bg-neutral-950 p-1 text-slate-100",children:[e.jsx(P,{}),e.jsxs("main",{className:"mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-0.5",children:[e.jsxs(c,{title:"MultiRover setup",meta:a===null?"checking":a?"required":"complete",bodyClassName:"space-y-0.5 p-1 text-sm",children:[a?e.jsx("p",{children:"Enter the one-time code from setup-code.txt in the server data folder, then create the first lockdown administrator or import an existing configuration."}):null,a===!1?e.jsx($,{className:"button-dark inline-block",to:"/admin",children:"Open administration"}):null,g?e.jsx("p",{className:"surface p-1 text-sm text-slate-200",children:g}):null]}),a?e.jsxs(e.Fragment,{children:[e.jsxs(c,{title:"Setup authorization",bodyClassName:"p-1",children:[e.jsx("label",{className:"block text-xs font-semibold text-slate-200",children:"One-time setup code"}),e.jsx("input",{className:"field-input mt-0.5 w-full font-mono",value:n,onChange:t=>b(t.target.value)})]}),e.jsx(c,{title:"Create first administrator",bodyClassName:"p-1",children:e.jsxs("form",{className:"grid gap-0.5 md:grid-cols-2",onSubmit:k,children:[e.jsx("input",{className:"field-input",placeholder:"Username",value:d,onChange:t=>C(t.target.value)}),e.jsx("input",{className:"field-input",placeholder:"Discord id (optional)",value:p,onChange:t=>N(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Password",value:r,onChange:t=>S(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Confirm password",value:f,onChange:t=>v(t.target.value)}),e.jsx("button",{className:"button-dark md:col-span-2",type:"submit",disabled:x||!n||!d||!r,children:"Create lockdown administrator"})]})}),e.jsxs(c,{title:"Import configuration file",bodyClassName:"space-y-0.5 p-1 text-sm",children:[e.jsx("p",{className:"text-xs text-slate-400",children:"Choose an existing YAML configuration explicitly. The server validates and imports it once, and its secrets are never displayed back in the browser."}),e.jsxs("form",{className:"flex flex-col gap-0.5 md:flex-row",onSubmit:A,children:[e.jsx("input",{className:"field-input flex-1",type:"file",accept:".yaml,.yml,text/yaml",onChange:t=>w(t.target.files?.[0]||null)}),e.jsx("button",{className:"button-dark",type:"submit",disabled:x||!n||!o,children:"Import selected YAML"})]})]})]}):null]})]})}export{R as default};
|
||||
//# sourceMappingURL=SetupApp-DoWmWQMy.js.map
|
||||
import{e as F,r as s,j as e,S as P,C as c,L as $}from"./index-Dt89YsCl.js";import{e as q,f as D,h as I}from"./api-DxMdZ4GH.js";function R(){const l=F(),[a,m]=s.useState(null),[n,b]=s.useState(""),[d,C]=s.useState(""),[p,N]=s.useState(""),[r,S]=s.useState(""),[f,v]=s.useState(""),[o,w]=s.useState(null),[x,h]=s.useState(!1),[g,i]=s.useState("");s.useEffect(()=>{q(l).then(t=>m(t.required)).catch(t=>i(t.message))},[l]);async function j(t){h(!0),i("");try{await t(),m(!1),i("Setup completed. You can now open the administration application and log in.")}catch(u){const E=Array.isArray(u.validationErrors)?` ${u.validationErrors.map(y=>`${y.path}: ${y.message}`).join("; ")}`:"";i(`${u.message}${E}`)}finally{h(!1)}}function k(t){if(t.preventDefault(),r!==f){i("Passwords do not match.");return}j(()=>D(l,{setupCode:n,username:d,discordId:p,password:r}))}function A(t){t.preventDefault(),o&&j(async()=>I(l,{setupCode:n,fileName:o.name,yaml:await o.text()}))}return e.jsxs("div",{className:"min-h-screen bg-neutral-950 p-1 text-slate-100",children:[e.jsx(P,{}),e.jsxs("main",{className:"mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-0.5",children:[e.jsxs(c,{title:"MultiRover setup",meta:a===null?"checking":a?"required":"complete",bodyClassName:"space-y-0.5 p-1 text-sm",children:[a?e.jsx("p",{children:"Enter the one-time code from setup-code.txt in the server data folder, then create the first lockdown administrator or import an existing configuration."}):null,a===!1?e.jsx($,{className:"button-dark inline-block",to:"/admin",children:"Open administration"}):null,g?e.jsx("p",{className:"surface p-1 text-sm text-slate-200",children:g}):null]}),a?e.jsxs(e.Fragment,{children:[e.jsxs(c,{title:"Setup authorization",bodyClassName:"p-1",children:[e.jsx("label",{className:"block text-xs font-semibold text-slate-200",children:"One-time setup code"}),e.jsx("input",{className:"field-input mt-0.5 w-full font-mono",value:n,onChange:t=>b(t.target.value)})]}),e.jsx(c,{title:"Create first administrator",bodyClassName:"p-1",children:e.jsxs("form",{className:"grid gap-0.5 md:grid-cols-2",onSubmit:k,children:[e.jsx("input",{className:"field-input",placeholder:"Username",value:d,onChange:t=>C(t.target.value)}),e.jsx("input",{className:"field-input",placeholder:"Discord id (optional)",value:p,onChange:t=>N(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Password",value:r,onChange:t=>S(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Confirm password",value:f,onChange:t=>v(t.target.value)}),e.jsx("button",{className:"button-dark md:col-span-2",type:"submit",disabled:x||!n||!d||!r,children:"Create lockdown administrator"})]})}),e.jsxs(c,{title:"Import configuration file",bodyClassName:"space-y-0.5 p-1 text-sm",children:[e.jsx("p",{className:"text-xs text-slate-400",children:"Choose an existing YAML configuration explicitly. The server validates and imports it once, and its secrets are never displayed back in the browser."}),e.jsxs("form",{className:"flex flex-col gap-0.5 md:flex-row",onSubmit:A,children:[e.jsx("input",{className:"field-input flex-1",type:"file",accept:".yaml,.yml,text/yaml",onChange:t=>w(t.target.files?.[0]||null)}),e.jsx("button",{className:"button-dark",type:"submit",disabled:x||!n||!o,children:"Import selected YAML"})]})]})]}):null]})]})}export{R as default};
|
||||
//# sourceMappingURL=SetupApp-DfXTyMt5.js.map
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,2 +0,0 @@
|
||||
function n(t,i,a={}){return new Promise((e,s)=>{t.emit(i,a,(r={})=>{if(r?.error){const o=new Error(r.error);o.code=r.code||null,o.validationErrors=r.validationErrors||[],o.currentRevision=r.currentRevision||null,s(o);return}e(r)})})}const d=t=>n(t,"adminConfig:get"),m=(t,i)=>n(t,"adminConfig:confirmPassword",{password:i}),u=(t,i)=>n(t,"adminConfig:updateConfiguration",i),c=(t,i)=>n(t,"adminConfig:restoreRevision",i),f=(t,i)=>n(t,"adminConfig:createAdministrator",i),g=(t,i)=>n(t,"adminConfig:updateAdministrator",i),C=(t,i)=>n(t,"adminConfig:deleteAdministrator",{id:i}),A=t=>n(t,"setup:status"),l=(t,i)=>n(t,"setup:createAdministrator",i),p=(t,i)=>n(t,"setup:importConfigurationFile",i);export{u as a,m as b,f as c,C as d,A as e,l as f,d as g,p as i,c as r,g as u};
|
||||
//# sourceMappingURL=api-C0PIt_OP.js.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"api-C0PIt_OP.js","sources":["../../../webui/src/admin/api.js"],"sourcesContent":["// Admin Socket API\n// Purpose: Gives the setup and administration applications one promise-based boundary around acknowledged socket events.\n// Scope: Preserves server error codes and validation details so shared UI infrastructure can respond consistently.\nexport function emitAdminRequest(socket, eventName, payload = {}) {\n return new Promise((resolve, reject) => {\n socket.emit(eventName, payload, (response = {}) => {\n if (response?.error) {\n const error = new Error(response.error);\n error.code = response.code || null;\n error.validationErrors = response.validationErrors || [];\n error.currentRevision = response.currentRevision || null;\n reject(error);\n return;\n }\n resolve(response);\n });\n });\n}\n\nexport const getAdminSnapshot = (socket) => emitAdminRequest(socket, 'adminConfig:get');\nexport const confirmAdminPassword = (socket, password) => emitAdminRequest(socket, 'adminConfig:confirmPassword', { password });\nexport const updateConfiguration = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateConfiguration', payload);\nexport const restoreConfigurationRevision = (socket, payload) => emitAdminRequest(socket, 'adminConfig:restoreRevision', payload);\nexport const createAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:createAdministrator', payload);\nexport const updateAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateAdministrator', payload);\nexport const deleteAdministrator = (socket, id) => emitAdminRequest(socket, 'adminConfig:deleteAdministrator', { id });\n\nexport const getSetupStatus = (socket) => emitAdminRequest(socket, 'setup:status');\nexport const createFirstAdministrator = (socket, payload) => emitAdminRequest(socket, 'setup:createAdministrator', payload);\nexport const importConfigurationFile = (socket, payload) => emitAdminRequest(socket, 'setup:importConfigurationFile', payload);\n"],"names":["emitAdminRequest","socket","eventName","payload","resolve","reject","response","error","getAdminSnapshot","confirmAdminPassword","password","updateConfiguration","restoreConfigurationRevision","createAdministrator","updateAdministrator","deleteAdministrator","id","getSetupStatus","createFirstAdministrator","importConfigurationFile"],"mappings":"AAGO,SAASA,EAAiBC,EAAQC,EAAWC,EAAU,CAAA,EAAI,CAChE,OAAO,IAAI,QAAQ,CAACC,EAASC,IAAW,CACtCJ,EAAO,KAAKC,EAAWC,EAAS,CAACG,EAAW,CAAA,IAAO,CACjD,GAAIA,GAAU,MAAO,CACnB,MAAMC,EAAQ,IAAI,MAAMD,EAAS,KAAK,EACtCC,EAAM,KAAOD,EAAS,MAAQ,KAC9BC,EAAM,iBAAmBD,EAAS,kBAAoB,CAAA,EACtDC,EAAM,gBAAkBD,EAAS,iBAAmB,KACpDD,EAAOE,CAAK,EACZ,MACF,CACAH,EAAQE,CAAQ,CAClB,CAAC,CACH,CAAC,CACH,CAEY,MAACE,EAAoBP,GAAWD,EAAiBC,EAAQ,iBAAiB,EACzEQ,EAAuB,CAACR,EAAQS,IAAaV,EAAiBC,EAAQ,8BAA+B,CAAE,SAAAS,CAAQ,CAAE,EACjHC,EAAsB,CAACV,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GS,EAA+B,CAACX,EAAQE,IAAYH,EAAiBC,EAAQ,8BAA+BE,CAAO,EACnHU,EAAsB,CAACZ,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GW,EAAsB,CAACb,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GY,EAAsB,CAACd,EAAQe,IAAOhB,EAAiBC,EAAQ,kCAAmC,CAAE,GAAAe,CAAE,CAAE,EAExGC,EAAkBhB,GAAWD,EAAiBC,EAAQ,cAAc,EACpEiB,EAA2B,CAACjB,EAAQE,IAAYH,EAAiBC,EAAQ,4BAA6BE,CAAO,EAC7GgB,EAA0B,CAAClB,EAAQE,IAAYH,EAAiBC,EAAQ,gCAAiCE,CAAO"}
|
||||
@@ -0,0 +1,2 @@
|
||||
function n(i,t,a={}){return new Promise((e,s)=>{i.emit(t,a,(o={})=>{if(o?.error){const r=new Error(o.error);r.code=o.code||null,r.validationErrors=o.validationErrors||[],r.currentRevision=o.currentRevision||null,s(r);return}e(o)})})}const d=i=>n(i,"adminConfig:get"),m=(i,t)=>n(i,"adminConfig:confirmPassword",{password:t}),u=(i,t)=>n(i,"adminConfig:updateConfiguration",t),c=(i,t)=>n(i,"adminConfig:importConfigurationFile",t),f=(i,t)=>n(i,"adminConfig:restoreRevision",t),g=(i,t)=>n(i,"adminConfig:createAdministrator",t),C=(i,t)=>n(i,"adminConfig:updateAdministrator",t),l=(i,t)=>n(i,"adminConfig:deleteAdministrator",{id:t}),A=i=>n(i,"setup:status"),p=(i,t)=>n(i,"setup:createAdministrator",t),v=(i,t)=>n(i,"setup:importConfigurationFile",t);export{u as a,m as b,g as c,l as d,A as e,p as f,d as g,v as h,c as i,f as r,C as u};
|
||||
//# sourceMappingURL=api-DxMdZ4GH.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"api-DxMdZ4GH.js","sources":["../../../webui/src/admin/api.js"],"sourcesContent":["// Admin Socket API\n// Purpose: Gives the setup and administration applications one promise-based boundary around acknowledged socket events.\n// Scope: Preserves server error codes and validation details so shared UI infrastructure can respond consistently.\nexport function emitAdminRequest(socket, eventName, payload = {}) {\n return new Promise((resolve, reject) => {\n socket.emit(eventName, payload, (response = {}) => {\n if (response?.error) {\n const error = new Error(response.error);\n error.code = response.code || null;\n error.validationErrors = response.validationErrors || [];\n error.currentRevision = response.currentRevision || null;\n reject(error);\n return;\n }\n resolve(response);\n });\n });\n}\n\nexport const getAdminSnapshot = (socket) => emitAdminRequest(socket, 'adminConfig:get');\nexport const confirmAdminPassword = (socket, password) => emitAdminRequest(socket, 'adminConfig:confirmPassword', { password });\nexport const updateConfiguration = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateConfiguration', payload);\nexport const importAdminConfigurationFile = (socket, payload) => emitAdminRequest(socket, 'adminConfig:importConfigurationFile', payload);\nexport const restoreConfigurationRevision = (socket, payload) => emitAdminRequest(socket, 'adminConfig:restoreRevision', payload);\nexport const createAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:createAdministrator', payload);\nexport const updateAdministrator = (socket, payload) => emitAdminRequest(socket, 'adminConfig:updateAdministrator', payload);\nexport const deleteAdministrator = (socket, id) => emitAdminRequest(socket, 'adminConfig:deleteAdministrator', { id });\n\nexport const getSetupStatus = (socket) => emitAdminRequest(socket, 'setup:status');\nexport const createFirstAdministrator = (socket, payload) => emitAdminRequest(socket, 'setup:createAdministrator', payload);\nexport const importConfigurationFile = (socket, payload) => emitAdminRequest(socket, 'setup:importConfigurationFile', payload);\n"],"names":["emitAdminRequest","socket","eventName","payload","resolve","reject","response","error","getAdminSnapshot","confirmAdminPassword","password","updateConfiguration","importAdminConfigurationFile","restoreConfigurationRevision","createAdministrator","updateAdministrator","deleteAdministrator","id","getSetupStatus","createFirstAdministrator","importConfigurationFile"],"mappings":"AAGO,SAASA,EAAiBC,EAAQC,EAAWC,EAAU,CAAA,EAAI,CAChE,OAAO,IAAI,QAAQ,CAACC,EAASC,IAAW,CACtCJ,EAAO,KAAKC,EAAWC,EAAS,CAACG,EAAW,CAAA,IAAO,CACjD,GAAIA,GAAU,MAAO,CACnB,MAAMC,EAAQ,IAAI,MAAMD,EAAS,KAAK,EACtCC,EAAM,KAAOD,EAAS,MAAQ,KAC9BC,EAAM,iBAAmBD,EAAS,kBAAoB,CAAA,EACtDC,EAAM,gBAAkBD,EAAS,iBAAmB,KACpDD,EAAOE,CAAK,EACZ,MACF,CACAH,EAAQE,CAAQ,CAClB,CAAC,CACH,CAAC,CACH,CAEY,MAACE,EAAoBP,GAAWD,EAAiBC,EAAQ,iBAAiB,EACzEQ,EAAuB,CAACR,EAAQS,IAAaV,EAAiBC,EAAQ,8BAA+B,CAAE,SAAAS,CAAQ,CAAE,EACjHC,EAAsB,CAACV,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GS,EAA+B,CAACX,EAAQE,IAAYH,EAAiBC,EAAQ,sCAAuCE,CAAO,EAC3HU,EAA+B,CAACZ,EAAQE,IAAYH,EAAiBC,EAAQ,8BAA+BE,CAAO,EACnHW,EAAsB,CAACb,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9GY,EAAsB,CAACd,EAAQE,IAAYH,EAAiBC,EAAQ,kCAAmCE,CAAO,EAC9Ga,EAAsB,CAACf,EAAQgB,IAAOjB,EAAiBC,EAAQ,kCAAmC,CAAE,GAAAgB,CAAE,CAAE,EAExGC,EAAkBjB,GAAWD,EAAiBC,EAAQ,cAAc,EACpEkB,EAA2B,CAAClB,EAAQE,IAAYH,EAAiBC,EAAQ,4BAA6BE,CAAO,EAC7GiB,EAA0B,CAACnB,EAAQE,IAAYH,EAAiBC,EAAQ,gCAAiCE,CAAO"}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -12,8 +12,8 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<!-- site-metadata:inject -->
|
||||
<!-- analytics:inject -->
|
||||
<script type="module" crossorigin src="/assets/index-DNdP4vNH.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BhA42K5i.css">
|
||||
<script type="module" crossorigin src="/assets/index-Dt89YsCl.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D0j33plX.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -11,7 +11,11 @@ const { defaultConfig, normalizeConfig, assertValidConfig } = require('./validat
|
||||
const { definitions, rootSchema, secretPaths, featureDefinitions } = require('./definition');
|
||||
const { getFeatureFlags } = require('./index');
|
||||
const { createConfigurationDatabase } = require('./database');
|
||||
const { parseConfigurationFile, importConfigurationFile } = require('./configurationFileImporter');
|
||||
const {
|
||||
parseConfigurationFile,
|
||||
buildSecretOperationsForImport,
|
||||
importConfigurationFile,
|
||||
} = require('./configurationFileImporter');
|
||||
|
||||
const temporaryRoots = [];
|
||||
|
||||
@@ -355,6 +359,58 @@ bandwidthSavings:
|
||||
});
|
||||
});
|
||||
|
||||
test('an administrative YAML replacement ignores accounts and only changes secrets present in the file', () => {
|
||||
const database = createTestDatabase();
|
||||
const initial = database.getClientConfiguration();
|
||||
const seededRevision = database.updateConfiguration({
|
||||
value: initial.config,
|
||||
expectedRevision: initial.revision,
|
||||
actor: 'secret-seed',
|
||||
secretOperations: {
|
||||
'homeAssistant.token': { action: 'replace', value: 'preserve-this-token' },
|
||||
'ptzCamera.password': { action: 'replace', value: 'clear-this-password' },
|
||||
'discord.token': { action: 'replace', value: 'replace-this-token' },
|
||||
},
|
||||
});
|
||||
const yamlText = `
|
||||
admins:
|
||||
- this obsolete account entry is deliberately malformed
|
||||
timezone: America/Chicago
|
||||
ptzCamera:
|
||||
password:
|
||||
discord:
|
||||
token: new-discord-token
|
||||
`;
|
||||
|
||||
/*
|
||||
An initialized installation treats the YAML as configuration data only.
|
||||
Even malformed account data is ignored, while presence-aware secret
|
||||
operations preserve an omitted credential, clear an explicit empty value,
|
||||
and replace an explicit non-empty value.
|
||||
*/
|
||||
const parsed = parseConfigurationFile(yamlText, { includeAdministrators: false });
|
||||
assert.deepEqual(parsed.administrators, []);
|
||||
assert.equal(parsed.uploadedAdministratorCount, 1);
|
||||
assert.deepEqual(parsed.providedSecretPaths, ['ptzCamera.password', 'discord.token']);
|
||||
const revision = database.updateConfiguration({
|
||||
value: parsed.config,
|
||||
expectedRevision: seededRevision,
|
||||
secretOperations: buildSecretOperationsForImport(parsed),
|
||||
actor: 'admin-import-test',
|
||||
source: 'admin-yaml:production.yaml',
|
||||
});
|
||||
|
||||
const active = database.getActiveConfigurationRecord();
|
||||
assert.equal(active.revision, revision);
|
||||
assert.equal(active.source, 'admin-yaml:production.yaml');
|
||||
assert.equal(active.config.homeAssistant.token, 'preserve-this-token');
|
||||
assert.equal(active.config.ptzCamera.password, '');
|
||||
assert.equal(active.config.discord.token, 'new-discord-token');
|
||||
assert.equal(active.config.timezone, 'America/Chicago');
|
||||
assert.equal(database.listAdministrators().length, 0);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test('committed revisions replace the live snapshot and isolate service reload failures', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'multirover-live-configuration-'));
|
||||
temporaryRoots.push(root);
|
||||
|
||||
@@ -1,8 +1,44 @@
|
||||
// Configuration File Importer
|
||||
// Purpose: Validates one YAML file deliberately uploaded during first-run setup and stores it in the configuration database.
|
||||
// Scope: This is an explicit setup action only; startup and installation never search for or consume configuration files.
|
||||
// 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, normalizeConfig, assertValidConfig } = require('./validation');
|
||||
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) {
|
||||
/*
|
||||
@@ -46,28 +82,65 @@ function normalizeUploadedAdministrator(entry, index) {
|
||||
};
|
||||
}
|
||||
|
||||
function parseConfigurationFile(text) {
|
||||
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 administrators = Array.isArray(parsed.admins)
|
||||
? parsed.admins.map(normalizeUploadedAdministrator)
|
||||
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);
|
||||
if (!administrators.some((admin) => admin.role === 'lockdown')) {
|
||||
throw new Error('The configuration file must contain at least one lockdown administrator.');
|
||||
}
|
||||
return { config, administrators };
|
||||
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' }) {
|
||||
@@ -85,6 +158,8 @@ function importConfigurationFile({ text, database, actor = 'setup-file-upload',
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_CONFIGURATION_FILE_BYTES,
|
||||
parseConfigurationFile,
|
||||
buildSecretOperationsForImport,
|
||||
importConfigurationFile,
|
||||
};
|
||||
|
||||
@@ -161,7 +161,13 @@ function createConfigurationDatabase({ databasePath = DEFAULT_DATABASE_PATH } =
|
||||
return { ...record, ...redacted };
|
||||
}
|
||||
|
||||
function updateConfiguration({ value, expectedRevision, secretOperations = {}, actor }) {
|
||||
function updateConfiguration({
|
||||
value,
|
||||
expectedRevision,
|
||||
secretOperations = {},
|
||||
actor,
|
||||
source = 'admin-ui',
|
||||
}) {
|
||||
const active = getActiveConfigurationRecord();
|
||||
const candidate = clone(value);
|
||||
|
||||
@@ -187,7 +193,12 @@ function createConfigurationDatabase({ databasePath = DEFAULT_DATABASE_PATH } =
|
||||
return commitRevisionTransaction(candidate, {
|
||||
expectedRevision,
|
||||
actor,
|
||||
source: 'admin-ui',
|
||||
/*
|
||||
Administrative imports use this same safe update path but identify the
|
||||
selected filename in revision and audit history. The source remains
|
||||
server-controlled metadata and never contains configuration values.
|
||||
*/
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,11 @@ const {
|
||||
applyCommittedConfiguration,
|
||||
rootSchema,
|
||||
} = require('../../configuration');
|
||||
const {
|
||||
MAX_CONFIGURATION_FILE_BYTES,
|
||||
parseConfigurationFile,
|
||||
buildSecretOperationsForImport,
|
||||
} = require('../../configuration/configurationFileImporter');
|
||||
const { getRole } = require('../roleService');
|
||||
|
||||
const PASSWORD_CONFIRMATION_WINDOW_MS = 5 * 60 * 1000;
|
||||
@@ -34,6 +39,18 @@ function actorFor(socket) {
|
||||
return socket?.data?.user?.username || socket.id;
|
||||
}
|
||||
|
||||
function safeUploadedFileName(value) {
|
||||
/*
|
||||
The filename is audit metadata only and is never opened on the server.
|
||||
Removing control characters keeps logs and history readable while
|
||||
retaining the operator-visible name that identifies the imported file.
|
||||
*/
|
||||
return String(value || 'uploaded-config.yaml')
|
||||
.replace(/[\u0000-\u001f\u007f]/g, '')
|
||||
.trim()
|
||||
.slice(0, 255) || 'uploaded-config.yaml';
|
||||
}
|
||||
|
||||
function errorPayload(error) {
|
||||
return {
|
||||
error: error.message,
|
||||
@@ -102,6 +119,37 @@ io.on('connection', (socket) => {
|
||||
return { revision, application, snapshot: buildAdminSnapshot() };
|
||||
});
|
||||
|
||||
ackHandler(socket, 'adminConfig:importConfigurationFile', requireRecentPassword, async (payload) => {
|
||||
const yamlText = String(payload.yaml || '');
|
||||
if (!yamlText || Buffer.byteLength(yamlText, 'utf8') > MAX_CONFIGURATION_FILE_BYTES) {
|
||||
throw new Error('The YAML configuration file must be present and no larger than 1 MiB.');
|
||||
}
|
||||
|
||||
/*
|
||||
Parsing deliberately excludes administrators on an initialized server.
|
||||
Configuration is still filtered to today's schema and strictly
|
||||
validated, then committed through the ordinary optimistic update path so
|
||||
missing secrets survive and explicitly supplied secrets replace or clear
|
||||
their current values.
|
||||
*/
|
||||
const parsed = parseConfigurationFile(yamlText, { includeAdministrators: false });
|
||||
const fileName = safeUploadedFileName(payload.fileName);
|
||||
const revision = database.updateConfiguration({
|
||||
value: parsed.config,
|
||||
expectedRevision: payload.expectedRevision,
|
||||
secretOperations: buildSecretOperationsForImport(parsed),
|
||||
actor: actorFor(socket),
|
||||
source: `admin-yaml:${fileName}`,
|
||||
});
|
||||
const application = await applyCommittedConfiguration();
|
||||
return {
|
||||
revision,
|
||||
application,
|
||||
ignoredAdministratorCount: parsed.uploadedAdministratorCount,
|
||||
snapshot: buildAdminSnapshot(),
|
||||
};
|
||||
});
|
||||
|
||||
ackHandler(socket, 'adminConfig:restoreRevision', requireRecentPassword, async (payload) => {
|
||||
const revision = database.restoreConfigurationRevision({
|
||||
revision: payload.revision,
|
||||
|
||||
@@ -6,10 +6,12 @@ const bcrypt = require('bcrypt');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('setupService');
|
||||
const { getConfigurationDatabase, applyCommittedConfiguration } = require('../../configuration');
|
||||
const { importConfigurationFile } = require('../../configuration/configurationFileImporter');
|
||||
const {
|
||||
MAX_CONFIGURATION_FILE_BYTES,
|
||||
importConfigurationFile,
|
||||
} = require('../../configuration/configurationFileImporter');
|
||||
const { createSetupCodeFile } = require('./setupCodeFile');
|
||||
|
||||
const MAX_CONFIGURATION_FILE_BYTES = 1024 * 1024;
|
||||
const database = getConfigurationDatabase();
|
||||
const setupCodeFile = createSetupCodeFile();
|
||||
let setupNoticeLogged = false;
|
||||
|
||||
Reference in New Issue
Block a user