Wizard: New install or Restore from backup
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:<ref> 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 <noreply@anthropic.com>
This commit is contained in:
parent
5dd763713d
commit
a361e38562
@ -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:<ref> 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) => {
|
router.post('/save', requireAuth, async (req, res) => {
|
||||||
const payload = req.body || {};
|
const payload = req.body || {};
|
||||||
|
|
||||||
|
|||||||
@ -23,8 +23,20 @@ class SetupWizard {
|
|||||||
// Storage sits BEFORE Recommended on purpose: a location has to exist
|
// 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
|
// 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.
|
// offer the big apps a home other than the system disk.
|
||||||
this.stepNames = ['Experience', 'Identity', 'Domains', 'Storage', 'Backups', 'Import', 'Recommended', 'Metrics'];
|
// 'Start' asks new-install-or-restore, and the answer selects one of two
|
||||||
this.stepIcons = ['🌱', '🪐', '🛰️', '💾', '🛟', '📦', '🛡️', '📊'];
|
// 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
|
// 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.
|
// — one disk means one answer, and a step with nothing in it is noise.
|
||||||
// Set by loadStorage() once the candidate scan comes back.
|
// Set by loadStorage() once the candidate scan comes back.
|
||||||
@ -68,6 +80,14 @@ class SetupWizard {
|
|||||||
// only when a usable second filesystem was actually found.
|
// only when a usable second filesystem was actually found.
|
||||||
_stepVisible(idx) {
|
_stepVisible(idx) {
|
||||||
const name = this.stepNames[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';
|
if (name === 'Metrics') return this.installLevel === 'advanced';
|
||||||
// Storage always shows now. Even with one disk it answers "where does my
|
// 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 —
|
// 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
|
doesn't get a wall of operator detail and an experienced
|
||||||
user sees everything by default. Either choice is reversible
|
user sees everything by default. Either choice is reversible
|
||||||
from the Advanced toggle in any page that exposes it. -->
|
from the Advanced toggle in any page that exposes it. -->
|
||||||
|
<!-- Step 1: New install, or a rebuild from a backup -->
|
||||||
<section class="setup-step" data-step="0">
|
<section class="setup-step" data-step="0">
|
||||||
|
<div class="setup-field setup-level-field">
|
||||||
|
<label>What are we doing?</label>
|
||||||
|
<p class="setup-step-note setup-level-note">
|
||||||
|
Rebuilding a machine? Point us at your backup and we will bring
|
||||||
|
it back — settings, backup repositories and apps.
|
||||||
|
</p>
|
||||||
|
<div class="setup-level-cards">
|
||||||
|
<label class="setup-level-card" data-mode="new">
|
||||||
|
<input type="radio" name="sw-mode" value="new" checked>
|
||||||
|
<div class="setup-level-card-body">
|
||||||
|
<div class="setup-level-card-icon">\u{1F331}</div>
|
||||||
|
<div class="setup-level-card-title">New install</div>
|
||||||
|
<div class="setup-level-card-desc">Set this machine up from scratch.</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="setup-level-card" data-mode="restore">
|
||||||
|
<input type="radio" name="sw-mode" value="restore">
|
||||||
|
<div class="setup-level-card-body">
|
||||||
|
<div class="setup-level-card-icon">\u{267B}\u{FE0F}</div>
|
||||||
|
<div class="setup-level-card-title">Restore from backup</div>
|
||||||
|
<div class="setup-level-card-desc">Rebuild this server from an existing backup.</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="setup-step" data-step="1">
|
||||||
<div class="setup-field setup-level-field">
|
<div class="setup-field setup-level-field">
|
||||||
<label>Choose your experience</label>
|
<label>Choose your experience</label>
|
||||||
<p class="setup-step-note setup-level-note">
|
<p class="setup-step-note setup-level-note">
|
||||||
@ -218,7 +267,7 @@ class SetupWizard {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Step 2: Identity -->
|
<!-- Step 2: Identity -->
|
||||||
<section class="setup-step" data-step="1">
|
<section class="setup-step" data-step="2">
|
||||||
<div class="setup-field">
|
<div class="setup-field">
|
||||||
<label for="sw-name">
|
<label for="sw-name">
|
||||||
Install Name
|
Install Name
|
||||||
@ -262,7 +311,7 @@ class SetupWizard {
|
|||||||
<!-- Step 3: Domains (optional, multi). Each domain enables HTTPS
|
<!-- Step 3: Domains (optional, multi). Each domain enables HTTPS
|
||||||
routing for apps via Traefik. Skipping leaves the install
|
routing for apps via Traefik. Skipping leaves the install
|
||||||
local-only (apps reachable by IP and Port, no SSL). -->
|
local-only (apps reachable by IP and Port, no SSL). -->
|
||||||
<section class="setup-step" data-step="2">
|
<section class="setup-step" data-step="3">
|
||||||
<div class="setup-field">
|
<div class="setup-field">
|
||||||
<label>
|
<label>
|
||||||
Domains
|
Domains
|
||||||
@ -279,7 +328,7 @@ class SetupWizard {
|
|||||||
<!-- Step 4: Storage locations. Only rendered when the candidate
|
<!-- Step 4: Storage locations. Only rendered when the candidate
|
||||||
scan found a filesystem LibrePortal isn't already using —
|
scan found a filesystem LibrePortal isn't already using —
|
||||||
otherwise there is exactly one answer and the step is skipped. -->
|
otherwise there is exactly one answer and the step is skipped. -->
|
||||||
<section class="setup-step" data-step="3">
|
<section class="setup-step" data-step="4">
|
||||||
<div class="setup-section">
|
<div class="setup-section">
|
||||||
<div class="setup-section-title">Storage
|
<div class="setup-section-title">Storage
|
||||||
<span class="setup-tooltip" tabindex="0" data-tip="Apps normally live on the system disk. If you have another drive, you can register it here and choose per app where its data goes.">?</span>
|
<span class="setup-tooltip" tabindex="0" data-tip="Apps normally live on the system disk. If you have another drive, you can register it here and choose per app where its data goes.">?</span>
|
||||||
@ -295,7 +344,7 @@ class SetupWizard {
|
|||||||
<!-- Step 5: Backups. Always shown — the people most likely to need a
|
<!-- Step 5: Backups. Always shown — the people most likely to need a
|
||||||
restore are the ones who never got round to configuring one, so
|
restore are the ones who never got round to configuring one, so
|
||||||
this asks rather than waiting to be found in the Backup page. -->
|
this asks rather than waiting to be found in the Backup page. -->
|
||||||
<section class="setup-step" data-step="4">
|
<section class="setup-step" data-step="5">
|
||||||
<div class="setup-section">
|
<div class="setup-section">
|
||||||
<div class="setup-section-title">Backups
|
<div class="setup-section-title">Backups
|
||||||
<span class="setup-tooltip" tabindex="0" data-tip="Snapshots of your apps and settings, taken on a schedule. Encrypted, so keep the password somewhere other than this machine — without it a backup cannot be opened, not even by us.">?</span>
|
<span class="setup-tooltip" tabindex="0" data-tip="Snapshots of your apps and settings, taken on a schedule. Encrypted, so keep the password somewhere other than this machine — without it a backup cannot be opened, not even by us.">?</span>
|
||||||
@ -311,7 +360,7 @@ class SetupWizard {
|
|||||||
Path-based, not upload: the file is already on the server, and a
|
Path-based, not upload: the file is already on the server, and a
|
||||||
.lpapp is a plain tarball, so nothing secret crosses into the
|
.lpapp is a plain tarball, so nothing secret crosses into the
|
||||||
browser (unlike a backup repository — see first-run-restore.md). -->
|
browser (unlike a backup repository — see first-run-restore.md). -->
|
||||||
<section class="setup-step" data-step="5">
|
<section class="setup-step" data-step="6">
|
||||||
<div class="setup-section">
|
<div class="setup-section">
|
||||||
<div class="setup-section-title">Import
|
<div class="setup-section-title">Import
|
||||||
<span class="setup-tooltip" tabindex="0" data-tip="Bring apps over from another LibrePortal using .lpapp files made with 'libreportal app export'. Point at a file or a folder of them, somewhere on this machine.">?</span>
|
<span class="setup-tooltip" tabindex="0" data-tip="Bring apps over from another LibrePortal using .lpapp files made with 'libreportal app export'. Point at a file or a folder of them, somewhere on this machine.">?</span>
|
||||||
@ -329,7 +378,7 @@ class SetupWizard {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Step 7: Recommended apps (Traefik + Fail2ban) -->
|
<!-- Step 7: Recommended apps (Traefik + Fail2ban) -->
|
||||||
<section class="setup-step" data-step="6">
|
<section class="setup-step" data-step="7">
|
||||||
<div class="setup-section">
|
<div class="setup-section">
|
||||||
<div class="setup-section-title">Recommended Apps</div>
|
<div class="setup-section-title">Recommended Apps</div>
|
||||||
<p class="setup-section-hint">Pre-selected to give you a working install out of the box.</p>
|
<p class="setup-section-hint">Pre-selected to give you a working install out of the box.</p>
|
||||||
@ -353,7 +402,7 @@ class SetupWizard {
|
|||||||
default — they're only useful if the user wants the MONITORING
|
default — they're only useful if the user wants the MONITORING
|
||||||
toggle on apps to do anything. Advanced-only: this whole step
|
toggle on apps to do anything. Advanced-only: this whole step
|
||||||
is skipped when the user chose Beginner on step 1. -->
|
is skipped when the user chose Beginner on step 1. -->
|
||||||
<section class="setup-step" data-step="7">
|
<section class="setup-step" data-step="8">
|
||||||
<div class="setup-section">
|
<div class="setup-section">
|
||||||
<div class="setup-section-title">Metrics Apps</div>
|
<div class="setup-section-title">Metrics Apps</div>
|
||||||
<p class="setup-section-hint">Optional. Install these to enable per-app "Export metrics to Grafana" later.</p>
|
<p class="setup-section-hint">Optional. Install these to enable per-app "Export metrics to Grafana" later.</p>
|
||||||
@ -361,6 +410,53 @@ class SetupWizard {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Restore branch. Only ever shown when Start says "restore". -->
|
||||||
|
|
||||||
|
<!-- Where is your backup? -->
|
||||||
|
<section class="setup-step" data-step="9">
|
||||||
|
<div class="setup-section">
|
||||||
|
<div class="setup-section-title">Where is your backup?
|
||||||
|
<span class="setup-tooltip" tabindex="0" data-tip="A backup lives in a repository \u2014 a folder on a disk, or a remote server \u2014 not a single file. Point us at the repository itself.">?</span>
|
||||||
|
</div>
|
||||||
|
<p class="setup-section-hint">
|
||||||
|
Your backups live in a repository: a folder on a disk, or a
|
||||||
|
remote server. Not a single file.
|
||||||
|
</p>
|
||||||
|
<div id="sw-rs-fields"></div>
|
||||||
|
<div class="setup-storage-choice">
|
||||||
|
<span class="setup-storage-choice-label">Password</span>
|
||||||
|
<input type="password" id="sw-rs-pass" class="form-control" placeholder="The repository password" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<p class="setup-section-hint">
|
||||||
|
This unlocks the repository. It is handed to the host through a
|
||||||
|
one-shot reference, so it never lands in a task or a log.
|
||||||
|
</p>
|
||||||
|
<div class="setup-storage-choice">
|
||||||
|
<span class="setup-storage-choice-label"></span>
|
||||||
|
<button type="button" class="setup-domain-add" id="sw-rs-read">Read this backup</button>
|
||||||
|
</div>
|
||||||
|
<div id="sw-rs-status"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- What is in there -->
|
||||||
|
<section class="setup-step" data-step="10">
|
||||||
|
<div class="setup-section">
|
||||||
|
<div class="setup-section-title">What is in this backup</div>
|
||||||
|
<div id="sw-rs-contents">
|
||||||
|
<p class="setup-section-hint">Go back a step and read the backup first.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Confirm -->
|
||||||
|
<section class="setup-step" data-step="11">
|
||||||
|
<div class="setup-section">
|
||||||
|
<div class="setup-section-title">Rebuild this server</div>
|
||||||
|
<div id="sw-rs-summary"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -409,6 +505,22 @@ class SetupWizard {
|
|||||||
// Experience radio — updates installLevel and the visible-step count
|
// Experience radio — updates installLevel and the visible-step count
|
||||||
// immediately so the progress bar reflects the choice (4 vs 5 steps).
|
// immediately so the progress bar reflects the choice (4 vs 5 steps).
|
||||||
// Selection cards visually toggle via the wrapping <label>'s :has().
|
// Selection cards visually toggle via the wrapping <label>'s :has().
|
||||||
|
// New install or restore. Changing it swaps the whole step set, so the
|
||||||
|
// progress bar and nav have to be re-derived immediately rather than at
|
||||||
|
// the next navigation.
|
||||||
|
this.container.querySelectorAll('input[name="sw-mode"]').forEach((r) => {
|
||||||
|
r.addEventListener('change', () => {
|
||||||
|
if (!r.checked) return;
|
||||||
|
this.installMode = (r.value === 'restore') ? 'restore' : 'new';
|
||||||
|
if (this.installMode === 'restore') this.renderRestoreSource();
|
||||||
|
this.totalSteps = this._effectiveTotalSteps();
|
||||||
|
this.showStep(this.currentStep);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const rsRead = this.container.querySelector('#sw-rs-read');
|
||||||
|
if (rsRead) rsRead.addEventListener('click', () => this.readBackup());
|
||||||
|
|
||||||
this.container.querySelectorAll('input[name="sw-level"]').forEach((r) => {
|
this.container.querySelectorAll('input[name="sw-level"]').forEach((r) => {
|
||||||
r.addEventListener('change', () => {
|
r.addEventListener('change', () => {
|
||||||
if (!r.checked) return;
|
if (!r.checked) return;
|
||||||
@ -883,6 +995,311 @@ class SetupWizard {
|
|||||||
return blocked;
|
return blocked;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- restore branch -------------------------------------------------------
|
||||||
|
|
||||||
|
// The "where is your backup" fields. Same backend types the Backup page
|
||||||
|
// offers, rendered here rather than reusing the destination dialog: that
|
||||||
|
// dialog is for a place to WRITE backups and carries retention, scheduling
|
||||||
|
// and an enable toggle, none of which mean anything for a repository you are
|
||||||
|
// only going to read.
|
||||||
|
renderRestoreSource() {
|
||||||
|
const box = this.container.querySelector('#sw-rs-fields');
|
||||||
|
if (!box || box.dataset.rendered === '1') return;
|
||||||
|
box.dataset.rendered = '1';
|
||||||
|
|
||||||
|
const row = (label, id, ph, type) => `
|
||||||
|
<div class="setup-storage-choice" data-rs-group="${type}">
|
||||||
|
<span class="setup-storage-choice-label">${label}</span>
|
||||||
|
<input type="text" id="${id}" class="form-control" placeholder="${this.escapeHtml(ph)}" autocomplete="off">
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
box.innerHTML = `
|
||||||
|
<div class="setup-storage-choice">
|
||||||
|
<span class="setup-storage-choice-label">Kind</span>
|
||||||
|
<select id="sw-rs-type" class="form-control">
|
||||||
|
<option value="local">A folder on this machine or a plugged-in disk</option>
|
||||||
|
<option value="sftp">An SFTP server</option>
|
||||||
|
<option value="rest">A REST server</option>
|
||||||
|
<option value="s3">S3 or compatible</option>
|
||||||
|
<option value="b2">Backblaze B2</option>
|
||||||
|
</select>
|
||||||
|
</div>` +
|
||||||
|
row('Path', 'sw-rs-path', '/mnt/usb/libreportal-backups', 'local') +
|
||||||
|
row('SSH user', 'sw-rs-ssh-user', 'backups', 'sftp') +
|
||||||
|
row('SSH host', 'sw-rs-ssh-host', 'nas.example.com', 'sftp') +
|
||||||
|
row('Remote path', 'sw-rs-ssh-path', '/srv/libreportal', 'sftp') +
|
||||||
|
row('URL', 'sw-rs-uri', 'rest:https://backup.example.com/', 'rest') +
|
||||||
|
row('Bucket URI', 'sw-rs-s3uri', 's3:s3.amazonaws.com/my-bucket', 's3') +
|
||||||
|
row('Access key ID', 'sw-rs-s3id', 'AKIA…', 's3') +
|
||||||
|
row('Bucket URI', 'sw-rs-b2uri', 'b2:my-bucket', 'b2') +
|
||||||
|
row('Account ID', 'sw-rs-b2id', '', 'b2');
|
||||||
|
|
||||||
|
const sync = () => {
|
||||||
|
const type = (this.container.querySelector('#sw-rs-type') || {}).value || 'local';
|
||||||
|
box.querySelectorAll('[data-rs-group]').forEach((g) => {
|
||||||
|
g.style.display = g.dataset.rsGroup === type ? '' : 'none';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const sel = this.container.querySelector('#sw-rs-type');
|
||||||
|
if (sel) sel.addEventListener('change', sync);
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the location half of the payload from whichever fields are showing.
|
||||||
|
_restoreLocationPayload() {
|
||||||
|
const v = (id) => {
|
||||||
|
const el = this.container.querySelector('#' + id);
|
||||||
|
return el ? el.value.trim() : '';
|
||||||
|
};
|
||||||
|
const type = v('sw-rs-type') || 'local';
|
||||||
|
const loc = { name: 'restore-source', type };
|
||||||
|
if (type === 'local') loc.path = v('sw-rs-path');
|
||||||
|
if (type === 'sftp') {
|
||||||
|
loc.ssh_user = v('sw-rs-ssh-user');
|
||||||
|
loc.ssh_host = v('sw-rs-ssh-host');
|
||||||
|
loc.ssh_path = v('sw-rs-ssh-path');
|
||||||
|
}
|
||||||
|
if (type === 'rest') loc.uri = v('sw-rs-uri');
|
||||||
|
if (type === 's3') { loc.uri = v('sw-rs-s3uri'); loc.s3_key_id = v('sw-rs-s3id'); }
|
||||||
|
if (type === 'b2') { loc.uri = v('sw-rs-b2uri'); loc.b2_account_id = v('sw-rs-b2id'); }
|
||||||
|
return loc;
|
||||||
|
}
|
||||||
|
|
||||||
|
_restoreSourceProblem() {
|
||||||
|
const loc = this._restoreLocationPayload();
|
||||||
|
const pass = this.container.querySelector('#sw-rs-pass');
|
||||||
|
if (loc.type === 'local' && !(loc.path || '').startsWith('/')) {
|
||||||
|
return 'Give the full path to the backup folder.';
|
||||||
|
}
|
||||||
|
if (loc.type === 'sftp' && !(loc.ssh_user && loc.ssh_host && loc.ssh_path)) {
|
||||||
|
return 'SFTP needs a user, a host and a remote path.';
|
||||||
|
}
|
||||||
|
if (['rest', 's3', 'b2'].includes(loc.type) && !loc.uri) {
|
||||||
|
return 'Give the repository URL.';
|
||||||
|
}
|
||||||
|
if (!pass || !pass.value) {
|
||||||
|
return 'The repository password is needed to open the backup.';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect and read. Nothing is written to this machine by this: the host
|
||||||
|
// creates a location to read through, and removes it again if the read
|
||||||
|
// fails.
|
||||||
|
async readBackup() {
|
||||||
|
const status = this.container.querySelector('#sw-rs-status');
|
||||||
|
const btn = this.container.querySelector('#sw-rs-read');
|
||||||
|
const problem = this._restoreSourceProblem();
|
||||||
|
if (problem) {
|
||||||
|
if (status) status.innerHTML = `<p class="setup-rs-error">${this.escapeHtml(problem)}</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = 'Reading…'; }
|
||||||
|
if (status) status.innerHTML = '<p class="setup-section-hint">Opening the repository…</p>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// The password goes through the one-shot secret channel, never in the
|
||||||
|
// payload: this reaches a task command line and tasks are recorded
|
||||||
|
// world-readable.
|
||||||
|
const passEl = this.container.querySelector('#sw-rs-pass');
|
||||||
|
const ref = await this.stashSecret(passEl.value);
|
||||||
|
if (!ref) throw new Error('Could not hand the password to the host');
|
||||||
|
// Cleared either way — it has been handed over, and there is no reason
|
||||||
|
// for it to sit in the DOM afterwards.
|
||||||
|
passEl.value = '';
|
||||||
|
|
||||||
|
const loc = this._restoreLocationPayload();
|
||||||
|
loc.password_ref = ref;
|
||||||
|
|
||||||
|
const res = await fetch('/api/setup/restore/read', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ location: loc })
|
||||||
|
});
|
||||||
|
const queued = await res.json();
|
||||||
|
if (!res.ok || queued.error) throw new Error(queued.error || `HTTP ${res.status}`);
|
||||||
|
|
||||||
|
// The WebUI cannot read a task's stdout, so the host publishes the
|
||||||
|
// result and we poll for it. Matched on the nonce, not merely on the
|
||||||
|
// file existing: a stale document from an earlier attempt would
|
||||||
|
// otherwise be read as this attempt's answer.
|
||||||
|
const started = Date.now();
|
||||||
|
let data = null;
|
||||||
|
while (Date.now() - started < 60000) {
|
||||||
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
|
try {
|
||||||
|
const f = await fetch('/data/system/restore_read.json', { cache: 'no-store' });
|
||||||
|
if (f.ok) {
|
||||||
|
const d = await f.json();
|
||||||
|
if (d && d.nonce === queued.nonce) { data = d; break; }
|
||||||
|
}
|
||||||
|
} catch { /* not written yet */ }
|
||||||
|
}
|
||||||
|
if (!data) throw new Error('the backup could not be read in time');
|
||||||
|
if (data.error) throw new Error(data.error);
|
||||||
|
|
||||||
|
this.restoreInfo = data;
|
||||||
|
if (status) {
|
||||||
|
status.innerHTML = `<p class="setup-rs-ok">Found backups from
|
||||||
|
<strong>${this.escapeHtml(data.host || '')}</strong> \u2014
|
||||||
|
${(data.apps || []).length} app(s). Continue to see what will happen.</p>`;
|
||||||
|
}
|
||||||
|
await this.renderRestoreContents();
|
||||||
|
} catch (e) {
|
||||||
|
this.restoreInfo = null;
|
||||||
|
if (status) {
|
||||||
|
status.innerHTML = `<p class="setup-rs-error">${this.escapeHtml(e.message || String(e))}</p>`;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = 'Read this backup'; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What is in there, and what it will mean on this machine. The domain checks
|
||||||
|
// are the part that has no equivalent anywhere else: a restored domain still
|
||||||
|
// points wherever DNS says, which after a rebuild is usually the old server.
|
||||||
|
async renderRestoreContents() {
|
||||||
|
const box = this.container.querySelector('#sw-rs-contents');
|
||||||
|
if (!box) return;
|
||||||
|
const d = this.restoreInfo;
|
||||||
|
if (!d) {
|
||||||
|
box.innerHTML = '<p class="setup-section-hint">Go back a step and read the backup first.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apps = d.apps || [];
|
||||||
|
const domains = d.domains || [];
|
||||||
|
|
||||||
|
box.innerHTML = `
|
||||||
|
<p class="setup-section-hint">
|
||||||
|
From <strong>${this.escapeHtml(d.host || '')}</strong>${
|
||||||
|
(d.hosts || []).length > 1
|
||||||
|
? ` \u2014 this repository also holds backups from ${this.escapeHtml((d.hosts || []).filter(h => h !== d.host).join(', '))}`
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
<div class="setup-storage-divider"><span>Apps</span></div>
|
||||||
|
${apps.length
|
||||||
|
? apps.map(a => `
|
||||||
|
<div class="setup-app-card">
|
||||||
|
<span class="setup-app-name">${this.escapeHtml(a.name)}</span>
|
||||||
|
<span class="setup-app-desc">${this.escapeHtml(a.size || '')}</span>
|
||||||
|
</div>`).join('')
|
||||||
|
: '<p class="setup-section-hint">No app backups in this repository.</p>'}
|
||||||
|
<div class="setup-storage-divider"><span>Domains</span></div>
|
||||||
|
<div id="sw-rs-domains">${
|
||||||
|
domains.length
|
||||||
|
? '<p class="setup-section-hint">Checking where these point\u2026</p>'
|
||||||
|
: '<p class="setup-section-hint">This backup carries no domains.</p>'}</div>`;
|
||||||
|
|
||||||
|
if (!domains.length) return;
|
||||||
|
|
||||||
|
// Checked one at a time through the endpoint the Domains step already
|
||||||
|
// uses, rather than adding a second way to ask the same question.
|
||||||
|
const rows = [];
|
||||||
|
for (const domain of domains) {
|
||||||
|
let verdict = 'unknown', detail = 'could not check';
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/setup/dns-check?domain=${encodeURIComponent(domain)}`, {
|
||||||
|
headers: { 'Accept': 'application/json' }
|
||||||
|
});
|
||||||
|
const j = await r.json();
|
||||||
|
if (j && j.matches) { verdict = 'ok'; detail = 'points at this server'; }
|
||||||
|
else if (j && j.domain_ip) { verdict = 'elsewhere'; detail = `points at ${j.domain_ip}, not this server`; }
|
||||||
|
else { verdict = 'unresolved'; detail = 'no DNS record found'; }
|
||||||
|
} catch { /* left as unknown — see below */ }
|
||||||
|
rows.push({ domain, verdict, detail });
|
||||||
|
}
|
||||||
|
this.restoreDomains = rows;
|
||||||
|
|
||||||
|
const strays = rows.filter(r => r.verdict === 'elsewhere' || r.verdict === 'unresolved');
|
||||||
|
const el = this.container.querySelector('#sw-rs-domains');
|
||||||
|
if (!el) return;
|
||||||
|
el.innerHTML =
|
||||||
|
rows.map(r => `
|
||||||
|
<div class="setup-app-card">
|
||||||
|
<span class="setup-app-name">${this.escapeHtml(r.domain)}</span>
|
||||||
|
<span class="setup-storage-badge ${r.verdict === 'ok' ? 'setup-storage-badge-ok' : 'setup-storage-badge-warn'}">${
|
||||||
|
r.verdict === 'ok' ? 'points here' : 'not here yet'}</span>
|
||||||
|
<span class="setup-app-desc">${this.escapeHtml(r.detail)}</span>
|
||||||
|
</div>`).join('') +
|
||||||
|
(strays.length
|
||||||
|
? `<p class="setup-section-hint">
|
||||||
|
${strays.length} of ${rows.length} do not point at this server yet. That is
|
||||||
|
normal mid-rebuild \u2014 update their DNS A records and they will work.
|
||||||
|
Until then Traefik cannot get a certificate for them.
|
||||||
|
</p>
|
||||||
|
<label class="setup-storage-choice">
|
||||||
|
<input type="checkbox" id="sw-rs-drop-domains">
|
||||||
|
<span> Leave those ${strays.length} out for now (you can add them back later)</span>
|
||||||
|
</label>`
|
||||||
|
: '<p class="setup-section-hint">Every domain in this backup already points at this server.</p>');
|
||||||
|
}
|
||||||
|
|
||||||
|
renderRestoreSummary() {
|
||||||
|
const box = this.container.querySelector('#sw-rs-summary');
|
||||||
|
if (!box) return;
|
||||||
|
const d = this.restoreInfo;
|
||||||
|
if (!d) {
|
||||||
|
box.innerHTML = '<p class="setup-section-hint">Read a backup first.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const apps = d.apps || [];
|
||||||
|
box.innerHTML = `
|
||||||
|
<p class="setup-section-hint">This will, in order:</p>
|
||||||
|
<div class="setup-app-card"><span class="setup-app-name">1. Settings and credentials</span>
|
||||||
|
<span class="setup-app-desc">Including every backup repository you had, so one password brings the rest back.</span></div>
|
||||||
|
<div class="setup-app-card"><span class="setup-app-name">2. ${apps.length} app(s)</span>
|
||||||
|
<span class="setup-app-desc">${this.escapeHtml(apps.map(a => a.name).join(', '))}</span></div>
|
||||||
|
<p class="setup-section-hint">
|
||||||
|
Apps this version no longer ships, or that will not fit, are skipped
|
||||||
|
rather than restored into something that cannot start \u2014 you will see
|
||||||
|
which, as it goes.
|
||||||
|
</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hand the rebuild to the host. Everything it needs was established on the
|
||||||
|
// previous steps: which repository (by the index the read returned) and
|
||||||
|
// whether to keep the domains that do not point at this server.
|
||||||
|
async submitRestore() {
|
||||||
|
const btn = this.container.querySelector('#sw-submit');
|
||||||
|
const setLabel = (s) => {
|
||||||
|
const el = btn && btn.querySelector('.setup-launch-text');
|
||||||
|
if (el) el.textContent = s;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!this.restoreInfo || !this.restoreInfo.location_idx) {
|
||||||
|
this.showError('Read a backup first \u2014 go back to "Where is your backup?".');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
setLabel('Restoring\u2026');
|
||||||
|
|
||||||
|
const dropBox = this.container.querySelector('#sw-rs-drop-domains');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/setup/restore/apply', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
location_idx: String(this.restoreInfo.location_idx),
|
||||||
|
host: this.restoreInfo.host || '',
|
||||||
|
drop_domains: !!(dropBox && dropBox.checked)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok || data.error) throw new Error(data.error || `HTTP ${res.status}`);
|
||||||
|
|
||||||
|
// The restore runs as a task and takes a while, so hand over to the same
|
||||||
|
// progress view a normal install uses rather than inventing a second one.
|
||||||
|
if (typeof this.onComplete === 'function') this.onComplete();
|
||||||
|
} catch (e) {
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
setLabel('Restore');
|
||||||
|
this.showError(`Could not start the restore: ${e.message || e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
renderStorageSystemMsg() {
|
renderStorageSystemMsg() {
|
||||||
const msg = this.container.querySelector('#sw-storage-system-msg');
|
const msg = this.container.querySelector('#sw-storage-system-msg');
|
||||||
if (!msg) return;
|
if (!msg) return;
|
||||||
@ -1470,15 +1887,19 @@ class SetupWizard {
|
|||||||
}, 350);
|
}, 350);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keyed on the step's NAME, not its index. The indices drifted once already
|
||||||
|
// when Storage, Backups and Import were inserted, and a validator that
|
||||||
|
// silently starts checking the wrong step is invisible until someone submits
|
||||||
|
// something invalid.
|
||||||
validateStep(idx) {
|
validateStep(idx) {
|
||||||
const $ = (id) => this.container.querySelector(id);
|
const $ = (id) => this.container.querySelector(id);
|
||||||
// Step 0 (Experience): always valid — radios are pre-checked.
|
const name = this.stepNames[idx];
|
||||||
if (idx === 1) {
|
if (name === 'Identity') {
|
||||||
const name = $('#sw-name').value.trim();
|
const name = $('#sw-name').value.trim();
|
||||||
if (!/^[a-zA-Z0-9-]+$/.test(name)) return 'Install name must be letters, numbers, or hyphens only.';
|
if (!/^[a-zA-Z0-9-]+$/.test(name)) return 'Install name must be letters, numbers, or hyphens only.';
|
||||||
if (!$('#sw-timezone').value) return 'Please select a timezone.';
|
if (!$('#sw-timezone').value) return 'Please select a timezone.';
|
||||||
}
|
}
|
||||||
if (idx === 2) {
|
if (name === 'Domains') {
|
||||||
// Domains are optional, but any non-empty input must be valid.
|
// Domains are optional, but any non-empty input must be valid.
|
||||||
const inputs = Array.from(this.container.querySelectorAll('.setup-domain-input'));
|
const inputs = Array.from(this.container.querySelectorAll('.setup-domain-input'));
|
||||||
for (const input of inputs) {
|
for (const input of inputs) {
|
||||||
@ -1488,14 +1909,13 @@ class SetupWizard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 3 = Storage. Only a typed custom path can be invalid; the scanned
|
// Only an edited exact path can be invalid; the scanned options all came
|
||||||
// options all came from the backend.
|
// from the backend.
|
||||||
if (idx === 3) {
|
if (name === 'Storage') {
|
||||||
const problem = this._syncStorageNav();
|
const problem = this._syncStorageNav();
|
||||||
if (problem) return problem;
|
if (problem) return problem;
|
||||||
}
|
}
|
||||||
// 6 = Recommended (Storage 3, Backups 4 and Import 5 shifted this along).
|
if (name === 'Recommended') {
|
||||||
if (idx === 6) {
|
|
||||||
const traefikBox = this.container.querySelector('input[data-app="traefik"]');
|
const traefikBox = this.container.querySelector('input[data-app="traefik"]');
|
||||||
if (traefikBox && traefikBox.checked) {
|
if (traefikBox && traefikBox.checked) {
|
||||||
const tEmail = $('#sw-traefik-email').value.trim();
|
const tEmail = $('#sw-traefik-email').value.trim();
|
||||||
@ -1809,6 +2229,19 @@ class SetupWizard {
|
|||||||
const $ = (id) => this.container.querySelector(id);
|
const $ = (id) => this.container.querySelector(id);
|
||||||
this.clearError();
|
this.clearError();
|
||||||
|
|
||||||
|
// A restore submits something else entirely. The normal payload is built
|
||||||
|
// from steps a restore never showed — install name, domains, app list — so
|
||||||
|
// running it here would post an empty name and be rejected by the route,
|
||||||
|
// which is a confusing way to find out the branch was never wired.
|
||||||
|
if (this.installMode === 'restore') {
|
||||||
|
try {
|
||||||
|
await this.submitRestore();
|
||||||
|
} finally {
|
||||||
|
this._submitting = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const visible = this._visibleSteps();
|
const visible = this._visibleSteps();
|
||||||
for (let pos = 0; pos < visible.length; pos++) {
|
for (let pos = 0; pos < visible.length; pos++) {
|
||||||
const i = visible[pos];
|
const i = visible[pos];
|
||||||
|
|||||||
@ -465,6 +465,53 @@ is allowed to read each one. Adoption now preserves the destination's existing
|
|||||||
ownership and mode, defaults closed only for a file that did not exist, and
|
ownership and mode, defaults closed only for a file that did not exist, and
|
||||||
never re-permissions a directory it merely passes through.
|
never re-permissions a directory it merely passes through.
|
||||||
|
|
||||||
|
## 3.12 — The WebUI branch
|
||||||
|
|
||||||
|
§2 described the wizard's first question becoming "is this a new server, or a
|
||||||
|
replacement for one?", and it now is. `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 on purpose. A restore is never asked for an install name, domains or
|
||||||
|
an app list, because the backup answers all three — asking would invite someone
|
||||||
|
to type an answer that is about to be written over, which is worse than not
|
||||||
|
asking. The test asserts non-overlap in *both* directions rather than only that
|
||||||
|
the restore steps appear.
|
||||||
|
|
||||||
|
**Source** collects the repository the same 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:<ref>` channel and is cleared from the DOM; the test asserts the value
|
||||||
|
never appears in the payload, since that payload reaches a task command line
|
||||||
|
and tasks are recorded world-readable.
|
||||||
|
|
||||||
|
**Contents** is §3's reconciliation, rendered. Apps with sizes, and the domains
|
||||||
|
with a verdict each — checked through the same `/api/setup/dns-check` the
|
||||||
|
Domains step uses rather than adding a second way to ask the question — plus
|
||||||
|
the offer to leave the strays out until DNS is repointed.
|
||||||
|
|
||||||
|
**Rebuild** hands over to `restore rebuild`, which is the installer's order
|
||||||
|
with the same reasoning: 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.
|
||||||
|
|
||||||
|
### The index that moved
|
||||||
|
|
||||||
|
Inserting `Start` shifted every step index by one, and `validateStep` was a
|
||||||
|
chain of `idx === 1 … idx === 6` with a comment already explaining 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 the change: it
|
||||||
|
called `validateStep(3)` for Storage, which had become Domains, and reported
|
||||||
|
that nothing blocked. That reads exactly like validation being broken — and if
|
||||||
|
the four assertions had happened to be less specific it would have read like
|
||||||
|
everything passing instead. Tests look their step up by name too now.
|
||||||
|
|
||||||
## 4. The password problem, stated plainly
|
## 4. The password problem, stated plainly
|
||||||
|
|
||||||
**An encrypted repository cannot be opened with anything inside itself.** `CFG_BACKUP_LOC_<idx>_PASSWORD` lives in the system config — which is *inside the backup*. So on a fresh machine the user must supply the repository password by hand. There is no way around this and it is not a bug; it is what encryption means.
|
**An encrypted repository cannot be opened with anything inside itself.** `CFG_BACKUP_LOC_<idx>_PASSWORD` lives in the system config — which is *inside the backup*. So on a fresh machine the user must supply the repository password by hand. There is no way around this and it is not a bug; it is what encryption means.
|
||||||
@ -563,6 +610,7 @@ change — and the docs should say so plainly so nobody uses it as their backup.
|
|||||||
| **2** ✅ | Two installer paths: New setup / Restore from backup, through connect → discover → system config → apps. *The system-config half only staged until §3.7; it now adopts.* |
|
| **2** ✅ | Two installer paths: New setup / Restore from backup, through connect → discover → system config → apps. *The system-config half only staged until §3.7; it now adopts.* |
|
||||||
| **3** ✅ | Preflight reconciliation report in the installer (§3) |
|
| **3** ✅ | Preflight reconciliation report in the installer (§3) |
|
||||||
| **4** ✅ | `app export` / `app import` (§7). The installer's `.lpapp` option is still open — see §9.5 |
|
| **4** ✅ | `app export` / `app import` (§7). The installer's `.lpapp` option is still open — see §9.5 |
|
||||||
|
| **5** ✅ | The WebUI branch: New install / Restore from backup, through source → contents → rebuild (§3.12) |
|
||||||
|
|
||||||
## 9. Open questions
|
## 9. Open questions
|
||||||
|
|
||||||
|
|||||||
@ -41,11 +41,22 @@ cliHandleRestoreCommands()
|
|||||||
# staging dir; never overwrites live config. Optional location idx.
|
# staging dir; never overwrites live config. Optional location idx.
|
||||||
backupRestoreSystemConfig "$action"
|
backupRestoreSystemConfig "$action"
|
||||||
;;
|
;;
|
||||||
|
rebuild)
|
||||||
|
# The WebUI's restore: adopt settings, reconcile domains, restore
|
||||||
|
# apps. restore rebuild <location-idx> <host> [drop-domains]
|
||||||
|
restoreWebuiRebuild "$action" "$name" "$extra"
|
||||||
|
;;
|
||||||
connect)
|
connect)
|
||||||
# Connect a repository from a base64 JSON payload and report what
|
# Connect a repository from a base64 JSON payload and report what
|
||||||
# it holds. The WebUI's restore branch; see restore_inspect.sh.
|
# it holds. The WebUI's restore branch; see restore_inspect.sh.
|
||||||
# restore connect <base64-json>
|
# restore connect <base64-json>
|
||||||
restoreConnectInspect "$action"
|
# With --publish the result is written where the WebUI polls for
|
||||||
|
# it, tagged with the caller's nonce; otherwise it goes to stdout.
|
||||||
|
if [[ "$name" == "--publish" ]]; then
|
||||||
|
restoreConnectInspectPublish "$action" "$extra"
|
||||||
|
else
|
||||||
|
restoreConnectInspect "$action"
|
||||||
|
fi
|
||||||
;;
|
;;
|
||||||
inspect)
|
inspect)
|
||||||
# Report what a restore from this repository would bring, without
|
# Report what a restore from this repository would bring, without
|
||||||
|
|||||||
158
scripts/dev/lp-restore-wizard-test
Executable file
158
scripts/dev/lp-restore-wizard-test
Executable file
@ -0,0 +1,158 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# The wizard's New install / Restore branch, driven in a real browser.
|
||||||
|
#
|
||||||
|
# scripts/dev/lp-restore-wizard-test # needs a running WebUI
|
||||||
|
#
|
||||||
|
# The branch point is the whole design: Start asks new-or-restore, and the
|
||||||
|
# answer selects one of two DISJOINT step sets. A restore must never be asked
|
||||||
|
# for an install name, domains or an app list — the backup answers all three,
|
||||||
|
# and asking invites the user to contradict what is about to be written over
|
||||||
|
# their answer. So the test asserts the sets do not overlap, in both
|
||||||
|
# directions, rather than only that the restore steps appear.
|
||||||
|
#
|
||||||
|
# It also asserts the two things that would be invisible until someone had
|
||||||
|
# already lost by them: that the repository password leaves through the
|
||||||
|
# one-shot secret channel and does not linger in the DOM, and that submit()
|
||||||
|
# routes to the restore path — the normal payload is built from steps a restore
|
||||||
|
# never showed, so submitting it posts an empty install name and is rejected by
|
||||||
|
# the route, which is a confusing way to find out the branch was never wired.
|
||||||
|
|
||||||
|
REPO="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
SHOT="$REPO/scripts/dev/lp-shot"
|
||||||
|
fail=0
|
||||||
|
chk(){ if [[ "$2" == "$3" ]]; then echo " ok $1"; else echo " FAIL $1: got '$2' want '$3'"; fail=1; fi; }
|
||||||
|
command -v jq >/dev/null 2>&1 || { echo " SKIP jq not installed"; exit 0; }
|
||||||
|
|
||||||
|
lp_reachable() {
|
||||||
|
local u; u=$("$SHOT" --url 2>/dev/null) || return 1
|
||||||
|
[[ -n "$u" ]] || return 1
|
||||||
|
curl -fsS -o /dev/null --max-time 5 "$u" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
read -r -d '' DRIVE <<'JS'
|
||||||
|
const out = {};
|
||||||
|
const w = window.setupWizard;
|
||||||
|
if (!w) return JSON.stringify({ error: 'wizard handle missing' });
|
||||||
|
const fire = (el, ev) => el.dispatchEvent(new Event(ev, { bubbles: true }));
|
||||||
|
const $ = s => document.querySelector(s);
|
||||||
|
const visible = () => w.stepNames.filter((n, i) => w._stepVisible(i));
|
||||||
|
|
||||||
|
const newRadio = $('input[name="sw-mode"][value="new"]');
|
||||||
|
const resRadio = $('input[name="sw-mode"][value="restore"]');
|
||||||
|
if (!newRadio || !resRadio) return JSON.stringify({ error: 'Start step has no mode cards' });
|
||||||
|
|
||||||
|
out.newSteps = visible();
|
||||||
|
out.newIsDefault = w.installMode === 'new';
|
||||||
|
|
||||||
|
resRadio.checked = true; fire(resRadio, 'change');
|
||||||
|
out.restoreSteps = visible();
|
||||||
|
out.mode = w.installMode;
|
||||||
|
|
||||||
|
// Disjoint in both directions, apart from Start itself.
|
||||||
|
const NEW_ONLY = ['Experience', 'Identity', 'Domains', 'Storage', 'Backups', 'Import', 'Recommended', 'Metrics'];
|
||||||
|
const RESTORE_ONLY = ['Source', 'Contents', 'Rebuild'];
|
||||||
|
out.restoreLeaksNewStep = out.restoreSteps.some(s => NEW_ONLY.includes(s));
|
||||||
|
out.newLeaksRestoreStep = out.newSteps.some(s => RESTORE_ONLY.includes(s));
|
||||||
|
out.startAlwaysShown = out.newSteps[0] === 'Start' && out.restoreSteps[0] === 'Start';
|
||||||
|
|
||||||
|
// The source form offers every backend, and shows only the chosen one.
|
||||||
|
out.kinds = Array.from(document.querySelectorAll('#sw-rs-type option')).map(o => o.value);
|
||||||
|
const groupsFor = (t) => {
|
||||||
|
const sel = $('#sw-rs-type'); sel.value = t; fire(sel, 'change');
|
||||||
|
return Array.from(document.querySelectorAll('[data-rs-group]'))
|
||||||
|
.filter(g => g.style.display !== 'none')
|
||||||
|
.map(g => g.dataset.rsGroup)
|
||||||
|
.filter((v, i, a) => a.indexOf(v) === i);
|
||||||
|
};
|
||||||
|
out.localShowsOnlyLocal = JSON.stringify(groupsFor('local')) === JSON.stringify(['local']);
|
||||||
|
out.sftpShowsOnlySftp = JSON.stringify(groupsFor('sftp')) === JSON.stringify(['sftp']);
|
||||||
|
|
||||||
|
// Validation, before anything is sent.
|
||||||
|
$('#sw-rs-type').value = 'local'; fire($('#sw-rs-type'), 'change');
|
||||||
|
$('#sw-rs-path').value = ''; $('#sw-rs-pass').value = '';
|
||||||
|
out.emptyPathRefused = !!w._restoreSourceProblem();
|
||||||
|
$('#sw-rs-path').value = 'relative/path';
|
||||||
|
out.relativePathRefused = !!w._restoreSourceProblem();
|
||||||
|
$('#sw-rs-path').value = '/somewhere/backups';
|
||||||
|
out.missingPasswordRefused = !!w._restoreSourceProblem();
|
||||||
|
$('#sw-rs-pass').value = 'x';
|
||||||
|
out.completeAccepted = !w._restoreSourceProblem();
|
||||||
|
|
||||||
|
// A password must leave as a reference and not linger in the DOM. Stubbed:
|
||||||
|
// the real channel is covered by lp-secret-channel-test, and what matters
|
||||||
|
// here is that readBackup routes through it at all rather than putting the
|
||||||
|
// value in the payload.
|
||||||
|
let stashedValue = null, sentBody = null;
|
||||||
|
w.stashSecret = async (v) => { stashedValue = v; return 'secret:' + '0'.repeat(32); };
|
||||||
|
const realFetch = window.fetch;
|
||||||
|
window.fetch = async (url, opts) => {
|
||||||
|
if (String(url).includes('/api/setup/restore/read')) {
|
||||||
|
sentBody = JSON.parse(opts.body);
|
||||||
|
return { ok: true, json: async () => ({ ok: true, taskId: 't', nonce: 'n' }) };
|
||||||
|
}
|
||||||
|
return realFetch(url, opts);
|
||||||
|
};
|
||||||
|
$('#sw-rs-pass').value = 'hunter2-not-a-real-password';
|
||||||
|
const readPromise = w.readBackup();
|
||||||
|
// Do not wait out the poll: what is under test is what left the browser.
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
out.passwordWasStashed = stashedValue === 'hunter2-not-a-real-password';
|
||||||
|
out.passwordClearedFromDom = $('#sw-rs-pass').value === '';
|
||||||
|
out.payloadCarriesRef = !!(sentBody && sentBody.location && sentBody.location.password_ref);
|
||||||
|
out.payloadCarriesNoPassword = !!(sentBody && sentBody.location
|
||||||
|
&& !JSON.stringify(sentBody.location).includes('hunter2'));
|
||||||
|
window.fetch = realFetch;
|
||||||
|
// Deliberately NOT awaited: the stubbed response has no matching document to
|
||||||
|
// find, so readBackup polls for a full minute before giving up. What is
|
||||||
|
// under test already happened — what left the browser — and waiting for the
|
||||||
|
// timeout only makes the test take a minute longer than it needs to.
|
||||||
|
readPromise.catch(() => {});
|
||||||
|
|
||||||
|
// submit() must route to the restore path, not the install payload.
|
||||||
|
let routedTo = null;
|
||||||
|
w.submitRestore = async () => { routedTo = 'restore'; };
|
||||||
|
w._submitting = false;
|
||||||
|
await w.submit();
|
||||||
|
out.submitRoutedToRestore = routedTo === 'restore';
|
||||||
|
|
||||||
|
return JSON.stringify(out);
|
||||||
|
JS
|
||||||
|
|
||||||
|
J=$("$SHOT" --eval "/" "$DRIVE" 2>/dev/null)
|
||||||
|
if [[ -z "$J" ]] || ! jq -e . >/dev/null 2>&1 <<< "$J"; then
|
||||||
|
if lp_reachable; then
|
||||||
|
echo " FAIL the WebUI is up but the page returned nothing (browser failed?)"; exit 1
|
||||||
|
fi
|
||||||
|
echo " SKIP no WebUI reachable"; exit 0
|
||||||
|
fi
|
||||||
|
g(){ jq -r "$1" <<< "$J" 2>/dev/null; }
|
||||||
|
if [[ "$(g '.error // empty')" != "" ]]; then echo " FAIL $(g .error)"; exit 1; fi
|
||||||
|
|
||||||
|
echo "the branch"
|
||||||
|
chk "new install is the default" "$(g .newIsDefault)" true
|
||||||
|
chk "picking restore switches mode" "$(g .mode)" restore
|
||||||
|
chk "Start shows in both" "$(g .startAlwaysShown)" true
|
||||||
|
chk "restore shows no install steps" "$(g .restoreLeaksNewStep)" false
|
||||||
|
chk "new shows no restore steps" "$(g .newLeaksRestoreStep)" false
|
||||||
|
chk "restore step set" "$(g '.restoreSteps | join(",")')" "Start,Source,Contents,Rebuild"
|
||||||
|
|
||||||
|
echo "the backup source form"
|
||||||
|
chk "every backend offered" "$(g '.kinds | join(",")')" "local,sftp,rest,s3,b2"
|
||||||
|
chk "local shows only its own" "$(g .localShowsOnlyLocal)" true
|
||||||
|
chk "sftp shows only its own" "$(g .sftpShowsOnlySftp)" true
|
||||||
|
chk "empty path refused" "$(g .emptyPathRefused)" true
|
||||||
|
chk "relative path refused" "$(g .relativePathRefused)" true
|
||||||
|
chk "missing password refused" "$(g .missingPasswordRefused)" true
|
||||||
|
chk "a complete source accepted" "$(g .completeAccepted)" true
|
||||||
|
|
||||||
|
echo "the password"
|
||||||
|
chk "goes through the secret channel" "$(g .passwordWasStashed)" true
|
||||||
|
chk "leaves the payload as a ref" "$(g .payloadCarriesRef)" true
|
||||||
|
chk "and never as a value" "$(g .payloadCarriesNoPassword)" true
|
||||||
|
chk "and is cleared from the DOM" "$(g .passwordClearedFromDom)" true
|
||||||
|
|
||||||
|
echo "submit"
|
||||||
|
chk "routes to the restore path" "$(g .submitRoutedToRestore)" true
|
||||||
|
|
||||||
|
[[ $fail -eq 0 ]] && echo "restore wizard test: OK"
|
||||||
|
exit $fail
|
||||||
@ -45,6 +45,12 @@ read -r -d '' DRIVE <<'JS'
|
|||||||
const w = window.setupWizard;
|
const w = window.setupWizard;
|
||||||
if (!w) return JSON.stringify({ error: 'wizard handle missing' });
|
if (!w) return JSON.stringify({ error: 'wizard handle missing' });
|
||||||
const fire = (el, ev) => el.dispatchEvent(new Event(ev, { bubbles: true }));
|
const fire = (el, ev) => el.dispatchEvent(new Event(ev, { bubbles: true }));
|
||||||
|
// Looked up by name, never hardcoded. Inserting the Start step moved Storage
|
||||||
|
// from 3 to 4, and a test pinned to the old number silently began validating
|
||||||
|
// the Domains step instead — reporting that nothing blocked, which is
|
||||||
|
// indistinguishable from validation being broken.
|
||||||
|
const STORAGE = w.stepNames.indexOf('Storage');
|
||||||
|
if (STORAGE < 0) return JSON.stringify({ error: 'no Storage step' });
|
||||||
const $ = s => document.querySelector(s);
|
const $ = s => document.querySelector(s);
|
||||||
|
|
||||||
// Re-queried after every renderStorage(), which rebuilds the selects — a
|
// Re-queried after every renderStorage(), which rebuilds the selects — a
|
||||||
@ -116,9 +122,9 @@ read -r -d '' DRIVE <<'JS'
|
|||||||
|
|
||||||
// --- editing a path ---
|
// --- editing a path ---
|
||||||
const inp = $('#sw-path-apps'), err = $('#sw-path-apps-err');
|
const inp = $('#sw-path-apps'), err = $('#sw-path-apps-err');
|
||||||
out.untouchedDefaultBlocks = !!w.validateStep(3);
|
out.untouchedDefaultBlocks = !!w.validateStep(STORAGE);
|
||||||
const probe = (v) => { inp.value = v; fire(inp, 'input');
|
const probe = (v) => { inp.value = v; fire(inp, 'input');
|
||||||
return { blocks: !!w.validateStep(3),
|
return { blocks: !!w.validateStep(STORAGE),
|
||||||
marked: inp.classList.contains('is-invalid'),
|
marked: inp.classList.contains('is-invalid'),
|
||||||
said: (err && err.textContent) || '' }; };
|
said: (err && err.textContent) || '' }; };
|
||||||
out.relative = probe('mnt/nas');
|
out.relative = probe('mnt/nas');
|
||||||
|
|||||||
@ -100,3 +100,54 @@ restoreFirstRunBulk()
|
|||||||
isSuccessful "First-run restore complete — $ok apps restored"
|
isSuccessful "First-run restore complete — $ok apps restored"
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The WebUI's rebuild: adopt the settings, reconcile the domains, restore the
|
||||||
|
# apps. Same order the installer uses and for the same reason — the system
|
||||||
|
# config carries every other backup location's credentials, so one password the
|
||||||
|
# user remembers unlocks the rest, and only then are apps worth restoring.
|
||||||
|
#
|
||||||
|
# restoreWebuiRebuild <location-idx> <host> [drop-domains]
|
||||||
|
#
|
||||||
|
# Called from a task, so its output is the progress the user watches.
|
||||||
|
restoreWebuiRebuild()
|
||||||
|
{
|
||||||
|
local idx="${1:-}" host="${2:-}" drop="${3:-no}"
|
||||||
|
if [[ -z "$idx" ]]; then
|
||||||
|
isError "restoreWebuiRebuild requires a backup location"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
isHeader "Rebuilding from backup"
|
||||||
|
|
||||||
|
# --- settings first ------------------------------------------------------
|
||||||
|
isNotice "Restoring settings and credentials…"
|
||||||
|
if backupRestoreSystemConfig "$idx" >/dev/null 2>&1; then
|
||||||
|
# --force: the WebUI is only reachable at all because this machine has
|
||||||
|
# a working install on it, so restoreAdoptIsFirstRun will say no. The
|
||||||
|
# user asked for this explicitly on the Rebuild step, which is the
|
||||||
|
# confirmation the guard exists to require.
|
||||||
|
if restoreSystemAdopt "" --force; then
|
||||||
|
isSuccessful "Settings and backup repositories restored"
|
||||||
|
else
|
||||||
|
isNotice "Settings were staged but could not be adopted — apps will still be restored."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
isNotice "No system config in this backup — apps will still be restored."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- domains -------------------------------------------------------------
|
||||||
|
restoreDomainReport || true
|
||||||
|
if [[ "$drop" == "yes" || "$drop" == "true" ]]; then
|
||||||
|
restoreDomainsDropElsewhere || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- apps ----------------------------------------------------------------
|
||||||
|
# No app list, deliberately: bulk discovers the host's apps and re-applies
|
||||||
|
# the preflight itself. Passing a list here is what let a 13-app restore
|
||||||
|
# arrive as four and still report success.
|
||||||
|
isNotice "Restoring apps — this takes a while."
|
||||||
|
restoreFirstRunBulk "$idx" "$host"
|
||||||
|
|
||||||
|
isSuccessful "Rebuild complete"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|||||||
@ -253,3 +253,32 @@ restoreConnectInspect()
|
|||||||
jq -c --arg idx "$idx" '. + {location_idx: $idx}' <<< "$out"
|
jq -c --arg idx "$idx" '. + {location_idx: $idx}' <<< "$out"
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Same connect-and-inspect, published where the WebUI can read it.
|
||||||
|
#
|
||||||
|
# The WebUI cannot read a task's stdout, so the result is written beside the
|
||||||
|
# other generated data and polled for. A nonce echoed back from the request is
|
||||||
|
# what lets the browser tell ITS answer from a stale document left by an
|
||||||
|
# earlier attempt — without it a second read shows the first read's repository,
|
||||||
|
# which is the kind of wrong that looks entirely plausible.
|
||||||
|
restoreConnectInspectPublish()
|
||||||
|
{
|
||||||
|
local b64="${1:-}" nonce="${2:-}"
|
||||||
|
local out_dir; out_dir="$(webuiDir)/frontend/data/system"
|
||||||
|
local out_file="$out_dir/restore_read.json"
|
||||||
|
createFolders "quiet" "$sudo_user_name" "$out_dir"
|
||||||
|
|
||||||
|
local body rc
|
||||||
|
body=$(restoreConnectInspect "$b64"); rc=$?
|
||||||
|
[[ -n "$body" ]] || body='{"error":"the read produced no result"}'
|
||||||
|
|
||||||
|
local tmp; tmp=$(mktemp) || return 1
|
||||||
|
jq -c --arg nonce "$nonce" --arg at "$(date -Iseconds)" \
|
||||||
|
'. + {nonce: $nonce, checked: $at}' <<< "$body" > "$tmp" 2>/dev/null \
|
||||||
|
|| printf '{"error":"the read produced unreadable output","nonce":"%s"}\n' \
|
||||||
|
"$(_lpJsonStr "$nonce")" > "$tmp"
|
||||||
|
|
||||||
|
runFileWrite "$out_file" < "$tmp"
|
||||||
|
rm -f "$tmp"
|
||||||
|
return $rc
|
||||||
|
}
|
||||||
|
|||||||
@ -911,6 +911,7 @@ declare -gA LP_FN_MAP=(
|
|||||||
[restoreAppRunHook]="restore/restore_app_hooks.sh"
|
[restoreAppRunHook]="restore/restore_app_hooks.sh"
|
||||||
[restoreAppStart]="restore/restore_app_start.sh"
|
[restoreAppStart]="restore/restore_app_start.sh"
|
||||||
[restoreConnectInspect]="restore/restore_inspect.sh"
|
[restoreConnectInspect]="restore/restore_inspect.sh"
|
||||||
|
[restoreConnectInspectPublish]="restore/restore_inspect.sh"
|
||||||
[restoreDbRehydratePreStart]="backup/db/backup_db.sh"
|
[restoreDbRehydratePreStart]="backup/db/backup_db.sh"
|
||||||
[restoreDbReplayPostStart]="backup/db/backup_db.sh"
|
[restoreDbReplayPostStart]="backup/db/backup_db.sh"
|
||||||
[restoreDomainCheck]="restore/restore_domains.sh"
|
[restoreDomainCheck]="restore/restore_domains.sh"
|
||||||
@ -930,6 +931,7 @@ declare -gA LP_FN_MAP=(
|
|||||||
[restorePreflightReport]="restore/restore_preflight.sh"
|
[restorePreflightReport]="restore/restore_preflight.sh"
|
||||||
[restoreServerPublicIp]="restore/restore_domains.sh"
|
[restoreServerPublicIp]="restore/restore_domains.sh"
|
||||||
[restoreSystemAdopt]="restore/restore_system_adopt.sh"
|
[restoreSystemAdopt]="restore/restore_system_adopt.sh"
|
||||||
|
[restoreWebuiRebuild]="restore/restore_first_run.sh"
|
||||||
[_rocketchatApi]="rocketchat/scripts/rocketchat_auth.sh"
|
[_rocketchatApi]="rocketchat/scripts/rocketchat_auth.sh"
|
||||||
[_rocketchatBaseUrl]="rocketchat/scripts/rocketchat_auth.sh"
|
[_rocketchatBaseUrl]="rocketchat/scripts/rocketchat_auth.sh"
|
||||||
[_rocketchatError]="rocketchat/scripts/rocketchat_auth.sh"
|
[_rocketchatError]="rocketchat/scripts/rocketchat_auth.sh"
|
||||||
@ -2175,6 +2177,7 @@ declare -gA LP_FN_ROOT=(
|
|||||||
[restoreAppRunHook]="scripts"
|
[restoreAppRunHook]="scripts"
|
||||||
[restoreAppStart]="scripts"
|
[restoreAppStart]="scripts"
|
||||||
[restoreConnectInspect]="scripts"
|
[restoreConnectInspect]="scripts"
|
||||||
|
[restoreConnectInspectPublish]="scripts"
|
||||||
[restoreDbRehydratePreStart]="scripts"
|
[restoreDbRehydratePreStart]="scripts"
|
||||||
[restoreDbReplayPostStart]="scripts"
|
[restoreDbReplayPostStart]="scripts"
|
||||||
[restoreDomainCheck]="scripts"
|
[restoreDomainCheck]="scripts"
|
||||||
@ -2194,6 +2197,7 @@ declare -gA LP_FN_ROOT=(
|
|||||||
[restorePreflightReport]="scripts"
|
[restorePreflightReport]="scripts"
|
||||||
[restoreServerPublicIp]="scripts"
|
[restoreServerPublicIp]="scripts"
|
||||||
[restoreSystemAdopt]="scripts"
|
[restoreSystemAdopt]="scripts"
|
||||||
|
[restoreWebuiRebuild]="scripts"
|
||||||
[_rocketchatApi]="containers"
|
[_rocketchatApi]="containers"
|
||||||
[_rocketchatBaseUrl]="containers"
|
[_rocketchatBaseUrl]="containers"
|
||||||
[_rocketchatError]="containers"
|
[_rocketchatError]="containers"
|
||||||
@ -3477,6 +3481,7 @@ restoreAdoptIsFirstRun() { unset -f restoreAdoptIsFirstRun; __lpAutoload "${inst
|
|||||||
restoreAppRunHook() { unset -f restoreAppRunHook; __lpAutoload "${install_scripts_dir}restore/restore_app_hooks.sh"; restoreAppRunHook "$@"; }
|
restoreAppRunHook() { unset -f restoreAppRunHook; __lpAutoload "${install_scripts_dir}restore/restore_app_hooks.sh"; restoreAppRunHook "$@"; }
|
||||||
restoreAppStart() { unset -f restoreAppStart; __lpAutoload "${install_scripts_dir}restore/restore_app_start.sh"; restoreAppStart "$@"; }
|
restoreAppStart() { unset -f restoreAppStart; __lpAutoload "${install_scripts_dir}restore/restore_app_start.sh"; restoreAppStart "$@"; }
|
||||||
restoreConnectInspect() { unset -f restoreConnectInspect; __lpAutoload "${install_scripts_dir}restore/restore_inspect.sh"; restoreConnectInspect "$@"; }
|
restoreConnectInspect() { unset -f restoreConnectInspect; __lpAutoload "${install_scripts_dir}restore/restore_inspect.sh"; restoreConnectInspect "$@"; }
|
||||||
|
restoreConnectInspectPublish() { unset -f restoreConnectInspectPublish; __lpAutoload "${install_scripts_dir}restore/restore_inspect.sh"; restoreConnectInspectPublish "$@"; }
|
||||||
restoreDbRehydratePreStart() { unset -f restoreDbRehydratePreStart; __lpAutoload "${install_scripts_dir}backup/db/backup_db.sh"; restoreDbRehydratePreStart "$@"; }
|
restoreDbRehydratePreStart() { unset -f restoreDbRehydratePreStart; __lpAutoload "${install_scripts_dir}backup/db/backup_db.sh"; restoreDbRehydratePreStart "$@"; }
|
||||||
restoreDbReplayPostStart() { unset -f restoreDbReplayPostStart; __lpAutoload "${install_scripts_dir}backup/db/backup_db.sh"; restoreDbReplayPostStart "$@"; }
|
restoreDbReplayPostStart() { unset -f restoreDbReplayPostStart; __lpAutoload "${install_scripts_dir}backup/db/backup_db.sh"; restoreDbReplayPostStart "$@"; }
|
||||||
restoreDomainCheck() { unset -f restoreDomainCheck; __lpAutoload "${install_scripts_dir}restore/restore_domains.sh"; restoreDomainCheck "$@"; }
|
restoreDomainCheck() { unset -f restoreDomainCheck; __lpAutoload "${install_scripts_dir}restore/restore_domains.sh"; restoreDomainCheck "$@"; }
|
||||||
@ -3496,6 +3501,7 @@ restorePreflightManifest() { unset -f restorePreflightManifest; __lpAutoload "${
|
|||||||
restorePreflightReport() { unset -f restorePreflightReport; __lpAutoload "${install_scripts_dir}restore/restore_preflight.sh"; restorePreflightReport "$@"; }
|
restorePreflightReport() { unset -f restorePreflightReport; __lpAutoload "${install_scripts_dir}restore/restore_preflight.sh"; restorePreflightReport "$@"; }
|
||||||
restoreServerPublicIp() { unset -f restoreServerPublicIp; __lpAutoload "${install_scripts_dir}restore/restore_domains.sh"; restoreServerPublicIp "$@"; }
|
restoreServerPublicIp() { unset -f restoreServerPublicIp; __lpAutoload "${install_scripts_dir}restore/restore_domains.sh"; restoreServerPublicIp "$@"; }
|
||||||
restoreSystemAdopt() { unset -f restoreSystemAdopt; __lpAutoload "${install_scripts_dir}restore/restore_system_adopt.sh"; restoreSystemAdopt "$@"; }
|
restoreSystemAdopt() { unset -f restoreSystemAdopt; __lpAutoload "${install_scripts_dir}restore/restore_system_adopt.sh"; restoreSystemAdopt "$@"; }
|
||||||
|
restoreWebuiRebuild() { unset -f restoreWebuiRebuild; __lpAutoload "${install_scripts_dir}restore/restore_first_run.sh"; restoreWebuiRebuild "$@"; }
|
||||||
_rocketchatApi() { unset -f _rocketchatApi; __lpAutoload "${install_containers_dir}rocketchat/scripts/rocketchat_auth.sh"; _rocketchatApi "$@"; }
|
_rocketchatApi() { unset -f _rocketchatApi; __lpAutoload "${install_containers_dir}rocketchat/scripts/rocketchat_auth.sh"; _rocketchatApi "$@"; }
|
||||||
_rocketchatBaseUrl() { unset -f _rocketchatBaseUrl; __lpAutoload "${install_containers_dir}rocketchat/scripts/rocketchat_auth.sh"; _rocketchatBaseUrl "$@"; }
|
_rocketchatBaseUrl() { unset -f _rocketchatBaseUrl; __lpAutoload "${install_containers_dir}rocketchat/scripts/rocketchat_auth.sh"; _rocketchatBaseUrl "$@"; }
|
||||||
_rocketchatError() { unset -f _rocketchatError; __lpAutoload "${install_containers_dir}rocketchat/scripts/rocketchat_auth.sh"; _rocketchatError "$@"; }
|
_rocketchatError() { unset -f _rocketchatError; __lpAutoload "${install_containers_dir}rocketchat/scripts/rocketchat_auth.sh"; _rocketchatError "$@"; }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user