diff --git a/containers/libreportal/backend/routes/setup-routes.js b/containers/libreportal/backend/routes/setup-routes.js index 03c0e5f..b7c24cd 100644 --- a/containers/libreportal/backend/routes/setup-routes.js +++ b/containers/libreportal/backend/routes/setup-routes.js @@ -237,6 +237,91 @@ router.post('/import-check', requireAuth, async (req, res) => { } }); +// Read a backup repository: connect, list what is in it, and report. Nothing +// on this machine is written β€” the host creates a location to read through and +// removes it again if the read fails. +// +// The password never appears here. It arrives as a secret: the browser +// already handed to /secret, and is redeemed once, host-side, at the moment of +// the write. This payload reaches a task command line and tasks are recorded +// world-readable. +router.post('/restore/read', requireAuth, async (req, res) => { + const loc = (req.body && req.body.location) || null; + if (!loc || typeof loc !== 'object') { + return res.status(400).json({ error: 'A backup location is required' }); + } + + const TYPES = ['local', 'sftp', 'rest', 's3', 'b2']; + if (!TYPES.includes(String(loc.type || ''))) { + return res.status(400).json({ error: 'Unsupported backup type' }); + } + if (loc.type === 'local' && !String(loc.path || '').startsWith('/')) { + return res.status(400).json({ error: 'A full path to the backup folder is required' }); + } + // Only a reference may travel; a raw password in this field would end up in + // the task file, which is exactly what the secret channel exists to prevent. + if (loc.password_ref && !/^secret:[0-9a-f]{32}$/.test(String(loc.password_ref))) { + return res.status(400).json({ error: 'Invalid password reference' }); + } + for (const k of Object.keys(loc)) { + if (typeof loc[k] === 'string' && loc[k].length > 1024) { + return res.status(413).json({ error: `${k} is too long` }); + } + } + + // base64 so the payload survives the command line intact β€” it carries paths + // and URLs, which are user input. + const b64 = Buffer.from(JSON.stringify({ + location: loc, + host: typeof req.body.host === 'string' ? req.body.host : '' + }), 'utf8').toString('base64'); + + // A nonce echoed back in the published document, so the browser can tell its + // own answer from one left by an earlier attempt. Without it a second read + // shows the first read's repository β€” wrong in a way that looks plausible. + const nonce = require('crypto').randomBytes(8).toString('hex'); + + try { + const id = await enqueueTask({ + command: `libreportal restore connect ${b64} --publish ${nonce}`, + type: 'restore', + app: 'libreportal', + setupRole: 'config' + }); + res.json({ ok: true, taskId: id, nonce }); + } catch (e) { + res.status(500).json({ error: e.message || String(e) }); + } +}); + +// Run the rebuild: adopt settings, reconcile domains, restore apps. The +// location index comes from the read that preceded this, so the repository the +// user actually looked at is the one restored from. +router.post('/restore/apply', requireAuth, async (req, res) => { + const idx = String((req.body && req.body.location_idx) || ''); + if (!/^[0-9]+$/.test(idx)) { + return res.status(400).json({ error: 'A backup location is required' }); + } + // Host names come from the repository, but they still reach a command line. + const host = String((req.body && req.body.host) || ''); + if (host && !/^[A-Za-z0-9._-]{1,64}$/.test(host)) { + return res.status(400).json({ error: 'Invalid host name' }); + } + const drop = (req.body && req.body.drop_domains) ? 'yes' : 'no'; + + try { + const id = await enqueueTask({ + command: `libreportal restore rebuild ${idx} ${host || "''"} ${drop}`, + type: 'restore', + app: 'libreportal', + setupRole: 'config' + }); + res.json({ ok: true, taskId: id }); + } catch (e) { + res.status(500).json({ error: e.message || String(e) }); + } +}); + router.post('/save', requireAuth, async (req, res) => { const payload = req.body || {}; diff --git a/containers/libreportal/frontend/core/setup/js/setup-wizard.js b/containers/libreportal/frontend/core/setup/js/setup-wizard.js index 7225e46..709b4a8 100755 --- a/containers/libreportal/frontend/core/setup/js/setup-wizard.js +++ b/containers/libreportal/frontend/core/setup/js/setup-wizard.js @@ -23,8 +23,20 @@ class SetupWizard { // Storage sits BEFORE Recommended on purpose: a location has to exist // before an app can be placed on it, and the Recommended step can then // offer the big apps a home other than the system disk. - this.stepNames = ['Experience', 'Identity', 'Domains', 'Storage', 'Backups', 'Import', 'Recommended', 'Metrics']; - this.stepIcons = ['🌱', 'πŸͺ', 'πŸ›°οΈ', 'πŸ’Ύ', 'πŸ›Ÿ', 'πŸ“¦', 'πŸ›‘οΈ', 'πŸ“Š']; + // 'Start' asks new-install-or-restore, and the answer selects one of two + // disjoint step sets: everything after it is gated on the mode. A restore + // must not be asked for an install name, domains or an app list β€” the + // backup already answers all three, and asking would invite someone to + // contradict what is about to be restored over their answer. + this.stepNames = ['Start', 'Experience', 'Identity', 'Domains', 'Storage', 'Backups', 'Import', 'Recommended', 'Metrics', + 'Source', 'Contents', 'Rebuild']; + this.stepIcons = ['\u{1F9ED}', '\u{1F331}', '\u{1FA90}', '\u{1F6F0}\u{FE0F}', '\u{1F4BE}', '\u{1F6DF}', '\u{1F4E6}', '\u{1F6E1}\u{FE0F}', '\u{1F4CA}', + '\u{1F5C4}\u{FE0F}', '\u{1F50D}', '\u{267B}\u{FE0F}']; + // 'new' | 'restore'. Chosen on Start; everything downstream reads it. + this.installMode = 'new'; + // What `restore read` found: host, hosts, apps, domains, location_idx. + this.restoreInfo = null; + this.restoreDomains = []; // Storage is skipped entirely when this box has nowhere else to put things // β€” one disk means one answer, and a step with nothing in it is noise. // Set by loadStorage() once the candidate scan comes back. @@ -68,6 +80,14 @@ class SetupWizard { // only when a usable second filesystem was actually found. _stepVisible(idx) { const name = this.stepNames[idx]; + // Start is the branch point and always shows. After it the two sets are + // disjoint: a restore is not asked for an install name, domains or an app + // list, because the backup answers all three and asking would invite the + // user to contradict what is about to be written over their answer. + const RESTORE_ONLY = ['Source', 'Contents', 'Rebuild']; + if (name === 'Start') return true; + if (RESTORE_ONLY.includes(name)) return this.installMode === 'restore'; + if (this.installMode === 'restore') return false; if (name === 'Metrics') return this.installLevel === 'advanced'; // Storage always shows now. Even with one disk it answers "where does my // data actually go?", which is worth a step in a self-hosting product β€” @@ -179,7 +199,36 @@ class SetupWizard { doesn't get a wall of operator detail and an experienced user sees everything by default. Either choice is reversible from the Advanced toggle in any page that exposes it. --> +
+
+ +

+ Rebuilding a machine? Point us at your backup and we will bring + it back β€” settings, backup repositories and apps. +

+
+ + +
+
+
+ +

@@ -218,7 +267,7 @@ class SetupWizard {

-
+