From a361e38562c03d9877537de26d98c2b29163ab81 Mon Sep 17 00:00:00 2001 From: librelad Date: Sat, 29 Aug 2026 05:04:43 +0100 Subject: [PATCH] Wizard: New install or Restore from backup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wizard's first question is now "is this a new server, or a replacement for one?", which §2 of the roadmap described and nothing implemented. Start asks, and the answer selects one of two disjoint step sets: new Start > Experience > Identity > Domains > Storage > Backups > Import > Recommended > (Metrics) restore Start > Source > Contents > Rebuild Disjoint deliberately. A restore is never asked for an install name, domains or an app list — the backup answers all three, and asking invites someone to type an answer that is about to be written over. The test asserts non-overlap in both directions, not just that the restore steps appear. Source collects the repository the way the Backup page does, minus everything that only means something for a place you write TO: no retention, no schedule, no enable toggle. The password leaves through the one-shot secret: channel and is cleared from the DOM, and the test asserts the value never appears in the payload — that payload reaches a task command line, and tasks are recorded world-readable. Contents is the reconciliation, rendered: apps with sizes, and each domain with a verdict, checked through the same /api/setup/dns-check the Domains step uses rather than adding a second way to ask. Plus the offer to leave the strays out until DNS is repointed. Rebuild runs `restore rebuild`: settings first (they carry every other repository's credentials), then domains, then apps with no explicit list so bulk discovers and re-preflights them itself. Inserting Start shifted every step index by one. validateStep was a chain of idx === 1 … idx === 6, carrying a comment that already explained which earlier insertions had moved them — it is keyed on the step name now. lp-storage-step-test had the same pin and did not survive: it called validateStep(3) for Storage, which had become Domains, and reported that nothing blocked. That reads exactly like validation being broken. Tests look their step up by name now too. Also: locationRemove's fix means a failed connect can finally clean up after itself, so a wrong password no longer leaves a dead destination behind on every retry. Co-Authored-By: Claude Opus 5 --- .../backend/routes/setup-routes.js | 85 ++++ .../frontend/core/setup/js/setup-wizard.js | 467 +++++++++++++++++- docs/roadmap/first-run-restore.md | 48 ++ .../commands/restore/cli_restore_commands.sh | 13 +- scripts/dev/lp-restore-wizard-test | 158 ++++++ scripts/dev/lp-storage-step-test | 10 +- scripts/restore/restore_first_run.sh | 51 ++ scripts/restore/restore_inspect.sh | 29 ++ .../source/files/arrays/function_manifest.sh | 6 + 9 files changed, 847 insertions(+), 20 deletions(-) create mode 100755 scripts/dev/lp-restore-wizard-test 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 {

-
+