Backup step: the wizard's own field layout, and a name that matches
Two things about the restore source step were wrong.
The progress bar said "Source" while the heading said "Where is your backup?",
which reads as two different steps. Every other step's section title is its step
name — Storage, Backups, Import — so this one is "Backup" in both places, with
the friendly question moved to the hint where the rest of the wizard puts it.
Contents and Rebuild got the same treatment.
And the fields used the Storage step's label-left rows. That layout suits a
column of dropdowns; a form of typed values in the middle of a wizard that
looks nothing like the rest of it just reads as unfinished. They now use the
same shape as Identity: a label with a tooltip, then an icon beside the input.
Every field has both, including the password.
On automating the relocate: it cannot be a WebUI action, and the reason is the
thing the privilege model rests on. Root helpers have their paths baked at
install so the manager cannot redirect a privileged operation by editing
something it owns; relocating re-bakes those paths, so a helper that did it
from a caller-supplied path would hand the manager the whole trust boundary.
Narrowing to "registry targets only" does not help either — the manager can add
to that registry by design. libreportal-relocate says this at the top and is
deliberately outside the manager's sudoers.
What was fixable is the part that actually annoyed — being handed a command
with no idea whether it worked:
- Copy button, with a fallback that selects the text and names the keys,
because clipboard access needs a secure context and a LAN install on
http:// is not one.
- The pending move persists to localStorage, not session state: it happens in
a terminal minutes or days later, after the tab is gone.
- A watcher on every page shows the outstanding command, polls for the move
landing, and reloads to the homepage — whatever route you were on belonged
to the old install.
"Landed" means the host REPORTS its system dir as the target, which is why
system_dir is now in storage.json. Deliberately not "the server restarted": an
ordinary container restart is indistinguishable, and announcing a relocation
that never happened is worse than saying nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
429ec3fa2d
commit
42afc20ee0
@ -129,7 +129,12 @@ class SystemOrchestrator {
|
||||
|
||||
// Handle normal loading sequence
|
||||
async handleNormalLoading() {
|
||||
|
||||
// A relocation the user was told to run may still be outstanding, on this
|
||||
// page load or any later one. Started here rather than at setup, because
|
||||
// the move happens in a terminal minutes or days afterwards and the tab
|
||||
// that showed the command is usually long gone.
|
||||
try { RelocateWatcher.start(); } catch { /* never block loading for this */ }
|
||||
|
||||
// Wait longer for all scripts to fully load
|
||||
await new Promise(resolve => setTimeout(resolve, 2500));
|
||||
|
||||
@ -346,3 +351,129 @@ if (document.readyState === 'loading') {
|
||||
await window.systemOrchestrator.initialize();
|
||||
})();
|
||||
}
|
||||
|
||||
// Follow a pending `libreportal-relocate` and reload when it lands.
|
||||
//
|
||||
// Relocating LibrePortal's own tree cannot be a WebUI action: it re-bakes the
|
||||
// paths inside every root-owned helper, and those are baked at install exactly
|
||||
// so the manager cannot redirect a privileged operation by editing something
|
||||
// it owns. A helper that re-baked the others from a caller-supplied path would
|
||||
// hand the manager the whole trust boundary. So a human runs it with real
|
||||
// root, and libreportal-relocate is deliberately absent from the manager's
|
||||
// sudoers.
|
||||
//
|
||||
// What is left to us is the part that was actually annoying: being told to run
|
||||
// a command and then having no idea whether it worked. This keeps the command
|
||||
// to hand, notices the move landing, and follows it — so the last step of a
|
||||
// relocation is the page coming back on its own rather than a manual refresh.
|
||||
class RelocateWatcher {
|
||||
static KEY = 'lp.pendingRelocate';
|
||||
|
||||
static pending() {
|
||||
try { return JSON.parse(localStorage.getItem(RelocateWatcher.KEY) || 'null'); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
static clear() {
|
||||
try { localStorage.removeItem(RelocateWatcher.KEY); } catch {}
|
||||
}
|
||||
|
||||
static start() {
|
||||
const p = RelocateWatcher.pending();
|
||||
if (!p || !p.target) return;
|
||||
new RelocateWatcher(p).run();
|
||||
}
|
||||
|
||||
constructor(pending) {
|
||||
this.pending = pending;
|
||||
this.banner = null;
|
||||
}
|
||||
|
||||
async run() {
|
||||
// Already done — the user ran it before this page loaded.
|
||||
if (await this.landed()) { RelocateWatcher.clear(); return; }
|
||||
this.render();
|
||||
// Every 5s. The move is a human typing a command and then a file copy, so
|
||||
// there is nothing to be gained by asking faster, and the feed is
|
||||
// regenerated on a schedule anyway.
|
||||
this.timer = setInterval(async () => {
|
||||
if (await this.landed()) {
|
||||
clearInterval(this.timer);
|
||||
this.done();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// The move has landed when the host reports its system dir as the target.
|
||||
// Deliberately NOT "the server restarted": an ordinary container restart
|
||||
// would look identical, and announcing a relocation that never happened is
|
||||
// worse than saying nothing.
|
||||
async landed() {
|
||||
try {
|
||||
const r = await fetch('/data/system/storage.json', { cache: 'no-store' });
|
||||
if (!r.ok) return false;
|
||||
const d = await r.json();
|
||||
const now = String(d.system_dir || '').replace(/\/$/, '');
|
||||
return !!now && now === String(this.pending.target).replace(/\/$/, '');
|
||||
} catch {
|
||||
// Unreachable is the expected middle of a relocation, not a failure.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'lp-relocate-banner';
|
||||
el.innerHTML = `
|
||||
<div class="lp-relocate-text">
|
||||
<strong>LibrePortal is set to move to ${this.esc(this.pending.target)}</strong>
|
||||
<span>Run this in a terminal on the server. This page will follow it and reload itself.</span>
|
||||
<code>${this.esc(this.pending.cmd)}</code>
|
||||
</div>
|
||||
<div class="lp-relocate-actions">
|
||||
<button type="button" data-act="copy">Copy</button>
|
||||
<button type="button" data-act="dismiss" title="Stop watching">Dismiss</button>
|
||||
</div>`;
|
||||
document.body.appendChild(el);
|
||||
this.banner = el;
|
||||
|
||||
el.querySelector('[data-act="copy"]').addEventListener('click', async (e) => {
|
||||
const b = e.currentTarget;
|
||||
try { await navigator.clipboard.writeText(this.pending.cmd); b.textContent = 'Copied'; }
|
||||
catch {
|
||||
// http:// on a LAN is not a secure context, which is most installs.
|
||||
const code = el.querySelector('code');
|
||||
const r = document.createRange(); r.selectNodeContents(code);
|
||||
const s = window.getSelection(); s.removeAllRanges(); s.addRange(r);
|
||||
b.textContent = 'Press \u2318/Ctrl+C';
|
||||
}
|
||||
setTimeout(() => { b.textContent = 'Copy'; }, 2500);
|
||||
});
|
||||
|
||||
el.querySelector('[data-act="dismiss"]').addEventListener('click', () => {
|
||||
clearInterval(this.timer);
|
||||
RelocateWatcher.clear();
|
||||
el.remove();
|
||||
});
|
||||
}
|
||||
|
||||
done() {
|
||||
RelocateWatcher.clear();
|
||||
if (this.banner) {
|
||||
this.banner.querySelector('.lp-relocate-text').innerHTML =
|
||||
'<strong>Moved.</strong><span>Reloading\u2026</span>';
|
||||
this.banner.querySelector('.lp-relocate-actions').remove();
|
||||
}
|
||||
// Straight to the homepage: whatever route this was, it was a route on the
|
||||
// old install.
|
||||
setTimeout(() => { window.location.href = '/'; }, 1200);
|
||||
}
|
||||
|
||||
esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
window.RelocateWatcher = RelocateWatcher;
|
||||
|
||||
@ -1461,3 +1461,61 @@ body.setup-wizard-open .custom-select-popup { z-index: 10001; }
|
||||
.setup-storage-choice input.is-invalid {
|
||||
border-color: rgba(255, 120, 100, 0.75);
|
||||
}
|
||||
|
||||
/* The relocate command, beside a copy button. Not a wrapped <code> on its own
|
||||
line: the point is that it can be taken in one action. */
|
||||
.setup-cmd-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 6px 0 4px;
|
||||
}
|
||||
.setup-cmd-row code {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Shown on any page while a relocation is outstanding, so the command survives
|
||||
the tab that produced it. */
|
||||
.lp-relocate-banner {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 18px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9998;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
max-width: min(920px, calc(100vw - 32px));
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 190, 60, 0.34);
|
||||
background: rgba(20, 40, 62, 0.96);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
|
||||
color: #e8f2fb;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.lp-relocate-text { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||
.lp-relocate-text span { opacity: 0.8; font-size: 0.85em; }
|
||||
.lp-relocate-text code {
|
||||
margin-top: 3px;
|
||||
padding: 4px 7px;
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.32);
|
||||
word-break: break-all;
|
||||
}
|
||||
.lp-relocate-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
.lp-relocate-actions button {
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.lp-relocate-actions button:hover { background: rgba(255, 255, 255, 0.14); }
|
||||
@media (max-width: 640px) {
|
||||
.lp-relocate-banner { flex-direction: column; align-items: stretch; }
|
||||
}
|
||||
|
||||
@ -29,7 +29,7 @@ class SetupWizard {
|
||||
// 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'];
|
||||
'Backup', '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.
|
||||
@ -84,7 +84,7 @@ class SetupWizard {
|
||||
// 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'];
|
||||
const RESTORE_ONLY = ['Backup', 'Contents', 'Rebuild'];
|
||||
if (name === 'Start') return true;
|
||||
if (RESTORE_ONLY.includes(name)) return this.installMode === 'restore';
|
||||
if (this.installMode === 'restore') return false;
|
||||
@ -412,27 +412,28 @@ class SetupWizard {
|
||||
|
||||
<!-- Restore branch. Only ever shown when Start says "restore". -->
|
||||
|
||||
<!-- Where is your backup? -->
|
||||
<!-- Which backup to rebuild from -->
|
||||
<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>
|
||||
<div class="setup-section-title">Backup</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.
|
||||
Where is it? Backups live in a repository \u2014 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 class="setup-field">
|
||||
<label for="sw-rs-pass">
|
||||
Repository password
|
||||
<span class="setup-tooltip" tabindex="0" data-tip="The password this repository was encrypted with. It is handed to the host through a one-shot reference, so it never lands in a task or a log.">?</span>
|
||||
</label>
|
||||
<div class="setup-input-row">
|
||||
<span class="setup-field-icon setup-field-icon-emoji" aria-hidden="true">\u{1F510}</span>
|
||||
<input type="password" id="sw-rs-pass" class="setup-input-with-icon" placeholder="Unlocks the repository" autocomplete="off">
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<div class="setup-field">
|
||||
<button type="button" class="setup-domain-add" id="sw-rs-read">Read this backup</button>
|
||||
</div>
|
||||
<div id="sw-rs-status"></div>
|
||||
@ -442,7 +443,8 @@ class SetupWizard {
|
||||
<!-- 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 class="setup-section-title">Contents</div>
|
||||
<p class="setup-section-hint">What this backup would bring back.</p>
|
||||
<div id="sw-rs-contents">
|
||||
<p class="setup-section-hint">Go back a step and read the backup first.</p>
|
||||
</div>
|
||||
@ -452,7 +454,8 @@ class SetupWizard {
|
||||
<!-- Confirm -->
|
||||
<section class="setup-step" data-step="11">
|
||||
<div class="setup-section">
|
||||
<div class="setup-section-title">Rebuild this server</div>
|
||||
<div class="setup-section-title">Rebuild</div>
|
||||
<p class="setup-section-hint">What is about to happen, and in what order.</p>
|
||||
<div id="sw-rs-summary"></div>
|
||||
</div>
|
||||
</section>
|
||||
@ -615,6 +618,7 @@ class SetupWizard {
|
||||
unregistered.filter(c => !seen.has((c.path || '').replace(/\/$/, ''))));
|
||||
this.storageSystem = data.system || null;
|
||||
this.storagePrimary = data.primary || '';
|
||||
this.storagePrimarySystemDir = data.system_dir || '';
|
||||
} catch (e) {
|
||||
console.log('[setup] storage scan unavailable:', e.message);
|
||||
this.storageCandidates = [];
|
||||
@ -997,42 +1001,79 @@ class SetupWizard {
|
||||
|
||||
// --- 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.
|
||||
// The repository fields.
|
||||
//
|
||||
// Laid out the way every other field in the wizard is — label with a
|
||||
// tooltip, then an icon beside the input — rather than the label-left row
|
||||
// the Storage step uses. That row suits a column of dropdowns; a form of
|
||||
// typed values in the middle of a wizard that looks nothing like the rest of
|
||||
// it just reads as unfinished.
|
||||
//
|
||||
// Rendered here rather than reusing the Backup page's 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">
|
||||
const field = (id, label, tip, icon, ph, type) => `
|
||||
<div class="setup-field" data-rs-group="${type}">
|
||||
<label for="${id}">
|
||||
${label}
|
||||
<span class="setup-tooltip" tabindex="0" data-tip="${this.escapeHtml(tip)}">?</span>
|
||||
</label>
|
||||
<div class="setup-input-row">
|
||||
<span class="setup-field-icon setup-field-icon-emoji" aria-hidden="true">${icon}</span>
|
||||
<input type="text" id="${id}" class="setup-input-with-icon" placeholder="${this.escapeHtml(ph)}" autocomplete="off">
|
||||
</div>
|
||||
</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 class="setup-field">
|
||||
<label for="sw-rs-type">
|
||||
Where it lives
|
||||
<span class="setup-tooltip" tabindex="0" data-tip="A folder for a disk plugged into this machine or mounted on it; the others are remote servers holding the repository.">?</span>
|
||||
</label>
|
||||
<div class="setup-input-row">
|
||||
<span class="setup-field-icon setup-field-icon-emoji" aria-hidden="true">\u{1F5C4}\u{FE0F}</span>
|
||||
<select id="sw-rs-type" class="setup-input-with-icon 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>
|
||||
</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');
|
||||
field('sw-rs-path', 'Folder',
|
||||
"The repository folder itself \u2014 the one containing config, data/ and snapshots/, not the folder above it.",
|
||||
'\u{1F4C1}', '/mnt/usb/libreportal-backups', 'local') +
|
||||
field('sw-rs-ssh-user', 'SSH user',
|
||||
'The account used to reach the server over SSH.',
|
||||
'\u{1F464}', 'backups', 'sftp') +
|
||||
field('sw-rs-ssh-host', 'SSH host',
|
||||
'Hostname or IP of the server holding the repository.',
|
||||
'\u{1F5A5}\u{FE0F}', 'nas.example.com', 'sftp') +
|
||||
field('sw-rs-ssh-path', 'Remote folder',
|
||||
'Path to the repository on that server.',
|
||||
'\u{1F4C1}', '/srv/libreportal-backups', 'sftp') +
|
||||
field('sw-rs-uri', 'Server URL',
|
||||
'The full repository URL, as restic writes it.',
|
||||
'\u{1F517}', 'rest:https://backup.example.com/', 'rest') +
|
||||
field('sw-rs-s3uri', 'Bucket URL',
|
||||
'The bucket holding the repository, as restic writes it.',
|
||||
'\u{1FAA3}', 's3:s3.amazonaws.com/my-bucket', 's3') +
|
||||
field('sw-rs-s3id', 'Access key ID',
|
||||
'The key ID for that bucket. Its secret goes in the password field below.',
|
||||
'\u{1F511}', 'AKIA\u2026', 's3') +
|
||||
field('sw-rs-b2uri', 'Bucket',
|
||||
'The B2 bucket holding the repository.',
|
||||
'\u{1FAA3}', 'b2:my-bucket', 'b2') +
|
||||
field('sw-rs-b2id', 'Account ID',
|
||||
'Your B2 account or application key ID.',
|
||||
'\u{1F194}', '', 'b2');
|
||||
|
||||
const sync = () => {
|
||||
const type = (this.container.querySelector('#sw-rs-type') || {}).value || 'local';
|
||||
@ -1370,10 +1411,47 @@ class SetupWizard {
|
||||
msg.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const cmd = `sudo libreportal-relocate --system-dir=${this.storageSystemTarget || (this.storageSystemChoice + '/libreportal-system')}`;
|
||||
msg.style.display = '';
|
||||
msg.innerHTML = `Moving LibrePortal itself needs root, so it happens outside the WebUI.
|
||||
Finish setup, then run:<br>
|
||||
<code>sudo libreportal-relocate --system-dir=${this.escapeHtml(this.storageSystemTarget || (this.storageSystemChoice + '/libreportal-system'))}</code>`;
|
||||
// Not a button, and it cannot be one. Relocating re-bakes the paths inside
|
||||
// every root-owned helper, and those paths are baked at install precisely
|
||||
// so the manager cannot redirect a privileged operation by editing
|
||||
// something it owns. A helper that re-baked the others from a
|
||||
// caller-supplied path would hand the manager the whole trust boundary
|
||||
// those helpers exist to defend — so libreportal-relocate is deliberately
|
||||
// not in the manager's sudoers, and the WebUI can only ever tell you the
|
||||
// command. See scripts/system/libreportal-relocate.
|
||||
//
|
||||
// What the WebUI CAN do is make running it painless: copy it in one click,
|
||||
// and then watch for the move landing and follow it to the new install.
|
||||
msg.innerHTML = `Moving LibrePortal itself needs real root, so it happens in a terminal
|
||||
rather than here. Finish setup, then run:
|
||||
<div class="setup-cmd-row">
|
||||
<code id="sw-relocate-cmd">${this.escapeHtml(cmd)}</code>
|
||||
<button type="button" class="setup-storage-details" id="sw-relocate-copy">Copy</button>
|
||||
</div>
|
||||
<span class="setup-section-hint">This page will follow it and reload itself when the move finishes.</span>`;
|
||||
|
||||
const btn = msg.querySelector('#sw-relocate-copy');
|
||||
if (btn) {
|
||||
btn.addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(cmd);
|
||||
btn.textContent = 'Copied';
|
||||
} catch {
|
||||
// Clipboard access needs a secure context, and a LAN install on
|
||||
// http:// is not one — which is most of them. Select the text so
|
||||
// the usual keyboard copy still works rather than failing silently.
|
||||
const el = msg.querySelector('#sw-relocate-cmd');
|
||||
if (el) {
|
||||
const r = document.createRange(); r.selectNodeContents(el);
|
||||
const s = window.getSelection(); s.removeAllRanges(); s.addRange(r);
|
||||
}
|
||||
btn.textContent = 'Press \u2318/Ctrl+C';
|
||||
}
|
||||
setTimeout(() => { btn.textContent = 'Copy'; }, 2500);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Backups: one question, asked at setup rather than left to be discovered.
|
||||
@ -2425,6 +2503,20 @@ class SetupWizard {
|
||||
}));
|
||||
} catch { /* sessionStorage may be unavailable in private mode */ }
|
||||
|
||||
// The relocate command is shown on a step the user is about to navigate
|
||||
// away from, so it has to outlive the wizard. Persisted rather than
|
||||
// sessioned: the move happens in a terminal, possibly after closing the
|
||||
// tab, and losing the command at that point means going to look it up.
|
||||
try {
|
||||
if (this.installMode !== 'restore' && this.storageSystemTarget) {
|
||||
localStorage.setItem('lp.pendingRelocate', JSON.stringify({
|
||||
target: this.storageSystemTarget,
|
||||
from: this.storagePrimarySystemDir || '',
|
||||
cmd: `sudo libreportal-relocate --system-dir=${this.storageSystemTarget}`
|
||||
}));
|
||||
}
|
||||
} catch { /* private mode */ }
|
||||
|
||||
if (typeof this.onComplete === 'function') {
|
||||
try { this.onComplete(); } catch (err) { console.error('[setup] onComplete threw:', err); }
|
||||
}
|
||||
|
||||
@ -487,7 +487,7 @@ of **two disjoint step sets**:
|
||||
```
|
||||
new Start -> Experience -> Identity -> Domains -> Storage
|
||||
-> Backups -> Import -> Recommended -> (Metrics)
|
||||
restore Start -> Source -> Contents -> Rebuild
|
||||
restore Start -> Backup -> Contents -> Rebuild
|
||||
```
|
||||
|
||||
Disjoint on purpose. A restore is never asked for an install name, domains or
|
||||
@ -496,7 +496,7 @@ 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
|
||||
**Backup** 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
|
||||
|
||||
@ -631,6 +631,49 @@ checked nothing at all. They now probe the WebUI with `lp-shot --url` and curl:
|
||||
if it answers HTTP then the browser is the only thing that can have broken, and
|
||||
that is a failure, not a skip.
|
||||
|
||||
## 12.7 — The relocate hand-off, and why it stays a command
|
||||
|
||||
Asked directly: why is moving LibrePortal itself not just a button that runs at
|
||||
the end of setup and reloads the page?
|
||||
|
||||
Because of the thing the whole privilege model rests on. Every root-owned
|
||||
helper has its paths **baked in at install** (§3), specifically so the manager
|
||||
cannot redirect a privileged operation by editing something it owns. Relocating
|
||||
re-bakes those paths. A helper that re-baked the other helpers from a
|
||||
caller-supplied path would hand the manager the entire trust boundary those
|
||||
helpers exist to defend — the manager picks the path, the path becomes where
|
||||
privileged chowns land. `libreportal-relocate` is therefore deliberately absent
|
||||
from the manager's sudoers, in the same category as uninstall, and
|
||||
`scripts/system/libreportal-relocate` says so at the top.
|
||||
|
||||
Narrowing it does not rescue it. "Only allow targets already in the root-owned
|
||||
storage registry" sounds bounded until you notice the manager can *add* to that
|
||||
registry — `libreportal-storage add` is in its sudoers, by design. The only
|
||||
safe source for that path is root, and root is not present when the wizard
|
||||
runs: `initPickRoots` asks "where should LibrePortal keep things?" during
|
||||
install, as root, before anything is created — which is the friction-free
|
||||
answer, and why choosing the disk up front is always better than moving later.
|
||||
|
||||
What *was* fixable is the part that actually annoyed: being handed a command
|
||||
with no idea whether it worked.
|
||||
|
||||
- The command now comes with a **Copy** button. Clipboard access needs a secure
|
||||
context and a LAN install on `http://` is not one, which is most of them — so
|
||||
the fallback selects the text and says which keys to press, rather than
|
||||
failing silently.
|
||||
- The pending move is persisted to `localStorage`, not session state. The move
|
||||
happens in a terminal minutes or days later, usually after the tab that
|
||||
showed the command is gone.
|
||||
- A **watcher** on every page load shows the outstanding command and polls for
|
||||
the move landing, then reloads to the homepage — whatever route you were on
|
||||
belonged to the old install.
|
||||
|
||||
"Landed" means *the host reports its system dir as the target*, which is why
|
||||
`system_dir` was added to `storage.json`. Deliberately not "the server went
|
||||
away and came back": an ordinary container restart is indistinguishable from
|
||||
that, and announcing a relocation that never happened is worse than saying
|
||||
nothing.
|
||||
|
||||
## 13. Open questions
|
||||
|
||||
1. ~~**Naming.** "Storage location" vs "backup location" in the same UI~~ — **resolved (2026-08-24):** build the Disks view (§7.1). The device becomes the organising concept and the two registries become *roles* on it, so the user never has to hold the distinction to understand their own hardware. Registries stay separate underneath.
|
||||
|
||||
@ -50,12 +50,23 @@ read -r -d '' DRIVE <<'JS'
|
||||
|
||||
// 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'];
|
||||
const RESTORE_ONLY = ['Backup', '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.
|
||||
// The progress bar said "Source" while the heading said "Where is your
|
||||
// backup?", which read as two different steps. They have to agree.
|
||||
const sec = document.querySelector('.setup-step[data-step="9"] .setup-section-title');
|
||||
out.titleMatchesStepName = !!sec && sec.textContent.trim() === 'Backup';
|
||||
// Fields laid out like the rest of the wizard: label with a tooltip, and an
|
||||
// icon beside the input — not the label-left rows the Storage step uses.
|
||||
out.fieldsHaveIcons = document.querySelectorAll('#sw-rs-fields .setup-field-icon').length > 0;
|
||||
out.fieldsHaveTooltips = document.querySelectorAll('#sw-rs-fields .setup-tooltip').length > 0;
|
||||
out.passwordHasIcon = !!document.querySelector('#sw-rs-pass')
|
||||
?.closest('.setup-input-row')?.querySelector('.setup-field-icon');
|
||||
|
||||
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');
|
||||
@ -163,10 +174,15 @@ 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"
|
||||
chk "restore step set" "$(g '.restoreSteps | join(",")')" "Start,Backup,Contents,Rebuild"
|
||||
|
||||
chk "step name matches its title" "$(g .titleMatchesStepName)" true
|
||||
|
||||
echo "the backup source form"
|
||||
chk "every backend offered" "$(g '.kinds | join(",")')" "local,sftp,rest,s3,b2"
|
||||
chk "fields carry icons" "$(g .fieldsHaveIcons)" true
|
||||
chk "fields carry tooltips" "$(g .fieldsHaveTooltips)" true
|
||||
chk "so does the password field" "$(g .passwordHasIcon)" true
|
||||
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
|
||||
|
||||
@ -143,6 +143,51 @@ read -r -d '' DRIVE <<'JS'
|
||||
out.staleAfterDriveChange = $('#sw-path-apps').value;
|
||||
out.staleWant = other;
|
||||
}
|
||||
// --- the relocate hand-off ---
|
||||
// Moving LibrePortal's own tree cannot be a WebUI action: it re-bakes the
|
||||
// paths inside every root helper, and those are baked at install so the
|
||||
// manager cannot redirect a privileged operation. So the wizard prints a
|
||||
// command — and the watcher makes that bearable by noticing the move
|
||||
// landing and following it.
|
||||
//
|
||||
// "Landed" must mean the host REPORTS the new system dir, never "the server
|
||||
// restarted": an ordinary container restart looks identical, and announcing
|
||||
// a relocation that never happened is worse than saying nothing.
|
||||
const W = window.RelocateWatcher;
|
||||
out.watcherExists = !!W;
|
||||
if (W) {
|
||||
out.notLandedForAnUnrelatedPath =
|
||||
await new W({ target: '/mnt/__nope/libreportal-system', cmd: 'x' }).landed();
|
||||
const feed = await (await fetch('/data/system/storage.json', { cache: 'no-store' })).json();
|
||||
out.feedReportsSystemDir = !!feed.system_dir;
|
||||
out.landsWhenFeedMatches =
|
||||
await new W({ target: feed.system_dir, cmd: 'x' }).landed();
|
||||
out.trailingSlashTolerated =
|
||||
await new W({ target: feed.system_dir + '/', cmd: 'x' }).landed();
|
||||
|
||||
localStorage.removeItem('lp.pendingRelocate');
|
||||
W.start();
|
||||
out.silentWhenNothingPending = !document.querySelector('.lp-relocate-banner');
|
||||
|
||||
localStorage.setItem('lp.pendingRelocate', JSON.stringify({
|
||||
target: '/mnt/__nope/libreportal-system',
|
||||
cmd: 'sudo libreportal-relocate --system-dir=/mnt/__nope/libreportal-system' }));
|
||||
W.start();
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
const b = document.querySelector('.lp-relocate-banner');
|
||||
out.bannerCarriesTheCommand = !!b && b.textContent.includes('libreportal-relocate --system-dir=/mnt/__nope');
|
||||
out.bannerOffersCopy = !!b && !!b.querySelector('[data-act="copy"]');
|
||||
if (b) b.remove();
|
||||
|
||||
// Already done before this page loaded: clear, do not nag.
|
||||
localStorage.setItem('lp.pendingRelocate', JSON.stringify({ target: feed.system_dir, cmd: 'x' }));
|
||||
W.start();
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
out.clearsWhenAlreadyLanded =
|
||||
!document.querySelector('.lp-relocate-banner') && !localStorage.getItem('lp.pendingRelocate');
|
||||
localStorage.removeItem('lp.pendingRelocate');
|
||||
}
|
||||
|
||||
return JSON.stringify(out);
|
||||
JS
|
||||
|
||||
@ -203,5 +248,16 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "the relocate hand-off"
|
||||
chk "the watcher is loaded" "$(g .watcherExists)" true
|
||||
chk "the feed reports the system dir" "$(g .feedReportsSystemDir)" true
|
||||
chk "an unrelated path has not landed" "$(g .notLandedForAnUnrelatedPath)" false
|
||||
chk "landed when the feed matches" "$(g .landsWhenFeedMatches)" true
|
||||
chk "a trailing slash is tolerated" "$(g .trailingSlashTolerated)" true
|
||||
chk "silent when nothing is pending" "$(g .silentWhenNothingPending)" true
|
||||
chk "the banner carries the command" "$(g .bannerCarriesTheCommand)" true
|
||||
chk "and offers to copy it" "$(g .bannerOffersCopy)" true
|
||||
chk "clears when already landed" "$(g .clearsWhenAlreadyLanded)" true
|
||||
|
||||
[[ $fail -eq 0 ]] && echo "storage step test: OK"
|
||||
exit $fail
|
||||
|
||||
@ -157,6 +157,7 @@ webuiGenerateStorageCandidates()
|
||||
cat > "$tmp" <<EOF
|
||||
{
|
||||
"primary": "$(_lpJsonEsc "$(primaryRoot)")",
|
||||
"system_dir": "$(_lpJsonEsc "${LP_SYSTEM_DIR:-${system_dir%/}}")",
|
||||
"system": $system_json,
|
||||
"locations": $locations,
|
||||
"candidates": $candidates,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user