Find the backup before asking for its password

The Backup step opened with an empty box and /mnt/usb/libreportal-backups as
the placeholder — a path nobody has, presented as the shape of the answer.
Someone rebuilding a server was being asked to recall from memory the one thing
they came here because they had lost.

Two additions, and the point of both is that neither needs the repository
password. A restic repository keeps one file per snapshot under snapshots/, so
"is there a backup here, and how many" is a directory listing. Nothing is
decrypted — reading what is IN those snapshots is the next step, and that does
need the password.

restore scan looks where a backup actually is: this install's own backups root
(the disk often survives), every location the install already knows about, and
one level under each non-OS mount, a just-plugged-in drive being the other half
of "the system drive died". Bounded to named shapes and maxdepth 1, never a
filesystem walk — a scan nobody waits for is a scan nobody uses. Results are
buttons, most snapshots first, each showing its count and the age of its
newest snapshot; clicking one fills the path in.

restore verify <path> answers the same for a typed path. Its most useful answer
is the near-miss: pointing at the folder that CONTAINS the repositories rather
than at one of them, which it names and offers as a button rather than
explaining the distinction in prose.

A repository is recognised by config plus the snapshots, keys and data
directories together. config alone would match any folder with a file of that
name, and offering a stray directory as someone's backup is worse than finding
nothing.

The placeholder now comes from this machine — the first repository found, or
the install's own backups root — since a placeholder's job is to show the shape
of the answer and only a real one does that. The backups root is in the storage
feed for it.

The found entries are buttons and had to own their geometry: .setup-app-card
carries no layout, it is a bare wrapper elsewhere, so a <button> wearing it
collapsed to one cramped line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-29 07:04:15 +01:00
parent 9bb9ed79a9
commit cf8a4b2c69
10 changed files with 563 additions and 4 deletions

View File

@ -237,6 +237,47 @@ router.post('/import-check', requireAuth, async (req, res) => {
}
});
// Repositories already on this machine, and whether a given path is one.
//
// Neither needs the repository password: a restic repository keeps one file per
// snapshot under snapshots/, so "is this a backup, and how many" is a directory
// listing. Nothing is decrypted — reading what is IN the snapshots is the next
// step, and that does need the password.
router.post('/restore/scan', requireAuth, async (req, res) => {
const nonce = require('crypto').randomBytes(8).toString('hex');
try {
const id = await enqueueTask({
command: `libreportal restore scan --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) });
}
});
router.post('/restore/verify', requireAuth, async (req, res) => {
const p = String((req.body && req.body.path) || '').trim();
if (!p.startsWith('/')) {
return res.status(400).json({ error: 'A full path is required' });
}
if (p.length > 1024) {
return res.status(413).json({ error: 'Path is too long' });
}
// Shell-quoted: this reaches a command line and the path is user input.
const quoted = `'${p.replace(/'/g, "'\\''")}'`;
const nonce = require('crypto').randomBytes(8).toString('hex');
try {
const id = await enqueueTask({
command: `libreportal restore verify ${quoted} --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) });
}
});
// 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.

View File

@ -1565,3 +1565,44 @@ body.setup-wizard-open .custom-select-popup { z-index: 10001; }
.setup-step .setup-section > .setup-field + .setup-field {
margin-top: 16px;
}
/* A found repository is a button, not a row: its whole job is to be clicked.
It cannot borrow .setup-app-card's look, because that class carries no
layout of its own it is a bare wrapper elsewhere, and a <button> with no
layout collapses to a single cramped line. So this owns its geometry. */
button.setup-found-backup {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
margin: 0 0 8px;
padding: 11px 14px;
text-align: left;
cursor: pointer;
font: inherit;
color: inherit;
border-radius: 10px;
border: 1px solid rgba(var(--text-rgb), 0.14);
background: rgba(var(--text-rgb), 0.05);
transition: border-color 0.15s ease, background 0.15s ease, transform 0.08s ease;
}
button.setup-found-backup .setup-app-name { font-weight: 600; }
button.setup-found-backup .setup-app-desc {
margin-left: auto;
font-size: 0.85em;
opacity: 0.72;
white-space: nowrap;
}
button.setup-found-backup:hover {
border-color: rgba(79, 195, 247, 0.55);
background: rgba(79, 195, 247, 0.09);
}
button.setup-found-backup:active { transform: translateY(1px); }
button.setup-found-backup:focus-visible {
outline: 2px solid rgba(79, 195, 247, 0.75);
outline-offset: 2px;
}
@media (max-width: 640px) {
button.setup-found-backup { flex-wrap: wrap; }
button.setup-found-backup .setup-app-desc { margin-left: 0; width: 100%; }
}

View File

@ -37,6 +37,9 @@ class SetupWizard {
// What `restore read` found: host, hosts, apps, domains, location_idx.
this.restoreInfo = null;
this.restoreDomains = [];
// Repositories found on this machine, from `restore scan`.
this.foundBackups = [];
this.storageBackupRoot = '';
// Storage is skipped entirely when this box has nowhere else to put things
// — one disk means one answer, and a step with nothing in it is noise.
// Set by loadStorage() once the candidate scan comes back.
@ -124,6 +127,7 @@ class SetupWizard {
if (radio) radio.checked = true;
this.totalSteps = this._effectiveTotalSteps();
this.renderRestoreSource();
this.scanForBackups();
const type = q.get('type');
const sel = this.container.querySelector('#sw-rs-type');
if (type && sel && Array.from(sel.options).some(o => o.value === type)) {
@ -444,7 +448,9 @@ class SetupWizard {
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-found"></div>
<div id="sw-rs-fields"></div>
<div id="sw-rs-verify-result"></div>
<div class="setup-field">
<label for="sw-rs-pass">
@ -539,7 +545,13 @@ class SetupWizard {
r.addEventListener('change', () => {
if (!r.checked) return;
this.installMode = (r.value === 'restore') ? 'restore' : 'new';
if (this.installMode === 'restore') this.renderRestoreSource();
if (this.installMode === 'restore') {
this.renderRestoreSource();
// Started as soon as the branch is chosen rather than when the step
// is reached: it takes a moment, and by the time the user has read
// the Start step the answer is usually already there.
this.scanForBackups();
}
this.totalSteps = this._effectiveTotalSteps();
this.showStep(this.currentStep);
});
@ -643,6 +655,7 @@ class SetupWizard {
this.storageSystem = data.system || null;
this.storagePrimary = data.primary || '';
this.storagePrimarySystemDir = data.system_dir || '';
this.storageBackupRoot = data.backups_dir || '';
} catch (e) {
console.log('[setup] storage scan unavailable:', e.message);
this.storageCandidates = [];
@ -1052,6 +1065,148 @@ class SetupWizard {
// --- restore branch -------------------------------------------------------
// Enqueue a task and wait for the document it publishes.
//
// The WebUI cannot read a task's stdout, so the host writes the result where
// the browser can fetch it. Matched on the nonce rather than merely on the
// file existing: a document left by an earlier attempt would otherwise be
// read as this attempt's answer, which is wrong in a way that looks entirely
// plausible.
async _taskResult(endpoint, body, doc, timeoutMs = 60000) {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {})
});
const queued = await res.json();
if (!res.ok || queued.error) throw new Error(queued.error || `HTTP ${res.status}`);
const started = Date.now();
while (Date.now() - started < timeoutMs) {
await new Promise(r => setTimeout(r, 800));
try {
const f = await fetch(`/data/system/${doc}`, { cache: 'no-store' });
if (f.ok) {
const d = await f.json();
if (d && d.nonce === queued.nonce) return d;
}
} catch { /* not written yet */ }
}
throw new Error('the host did not answer in time');
}
// Repositories already on this machine.
//
// Rebuilding a server, the backup is nearly always somewhere obvious — the
// install's own backups root if the disk survived, or a drive just plugged
// in. Asking the user to type that path from memory, against a placeholder
// invented for an example, is asking them to recall the one thing they came
// here because they could not.
async scanForBackups() {
const box = this.container.querySelector('#sw-rs-found');
if (!box || this._scanned) return;
this._scanned = true;
box.innerHTML = '<p class="setup-section-hint">Looking for backups on this machine\u2026</p>';
try {
const d = await this._taskResult('/api/setup/restore/scan', {}, 'restore_scan.json', 45000);
this.foundBackups = Array.isArray(d.found) ? d.found : [];
} catch {
this.foundBackups = [];
}
this.renderFoundBackups();
// The fields are rendered before the scan (and before the storage feed)
// land, so the placeholder starts as the generic fallback. Now that a real
// path is known, use it — the placeholder's whole job is to show the SHAPE
// of the answer, and a shape from this machine is the only useful one.
const inp = this.container.querySelector('#sw-rs-path');
if (inp && !inp.value) inp.placeholder = this._backupPathPlaceholder();
}
renderFoundBackups() {
const box = this.container.querySelector('#sw-rs-found');
if (!box) return;
const found = this.foundBackups || [];
if (!found.length) {
// Said out loud rather than left blank: "we looked and there is nothing
// here" is information, and an empty area is not.
box.innerHTML = `<p class="setup-section-hint">
No backups found on this machine \u2014 point us at yours below.</p>`;
return;
}
box.innerHTML =
'<div class="setup-storage-divider"><span>Found on this machine</span></div>' +
found.map((f, i) => `
<button type="button" class="setup-app-card setup-found-backup" data-found="${i}">
<span class="setup-app-name">${this.escapeHtml(f.path)}</span>
<span class="setup-storage-badge setup-storage-badge-ok">${f.snapshots} snapshot${f.snapshots === 1 ? '' : 's'}</span>
<span class="setup-app-desc">${f.newest ? 'newest ' + this._restoreWhen(f.newest) : ''}</span>
</button>`).join('') +
`<p class="setup-section-hint">
Pick one to fill it in below. You will still need its password.</p>`;
box.querySelectorAll('.setup-found-backup').forEach((b) => {
b.addEventListener('click', () => {
const f = found[Number(b.dataset.found)];
if (!f) return;
const sel = this.container.querySelector('#sw-rs-type');
if (sel) { sel.value = 'local'; sel.dispatchEvent(new Event('change', { bubbles: true })); }
const path = this.container.querySelector('#sw-rs-path');
if (path) { path.value = f.path; path.dispatchEvent(new Event('input', { bubbles: true })); }
this.renderVerifyResult({ repo: true, path: f.path, snapshots: f.snapshots, newest: f.newest });
const pass = this.container.querySelector('#sw-rs-pass');
if (pass) pass.focus();
});
});
}
// Check a typed path without unlocking anything.
async verifyBackupPath() {
const inp = this.container.querySelector('#sw-rs-path');
const btn = this.container.querySelector('#sw-rs-verify');
if (!inp) return;
const path = inp.value.trim();
if (!path.startsWith('/')) {
this.renderVerifyResult({ repo: false, reason: 'Give a full path, starting with /.' });
return;
}
if (btn) { btn.disabled = true; btn.textContent = 'Checking\u2026'; }
try {
const d = await this._taskResult('/api/setup/restore/verify', { path }, 'restore_verify.json', 45000);
this.renderVerifyResult(d);
} catch (e) {
this.renderVerifyResult({ repo: false, reason: e.message || String(e) });
} finally {
if (btn) { btn.disabled = false; btn.textContent = 'Check'; }
}
}
renderVerifyResult(d) {
const box = this.container.querySelector('#sw-rs-verify-result');
if (!box) return;
if (d && d.repo) {
box.innerHTML = `<p class="setup-rs-ok">Backup found \u2014
<strong>${d.snapshots} snapshot${d.snapshots === 1 ? '' : 's'}</strong>${
d.newest ? `, newest ${this._restoreWhen(d.newest)}` : ''}.
Enter its password to see what is inside.</p>`;
return;
}
// A suggestion means they pointed at the folder holding the repositories
// rather than at one, which is the common near-miss and worth one click to
// fix rather than a paragraph explaining it.
const suggest = d && d.suggest
? ` <button type="button" class="setup-storage-details" id="sw-rs-usesuggest">Use ${this.escapeHtml(d.suggest)}</button>`
: '';
box.innerHTML = `<p class="setup-rs-error">${this.escapeHtml((d && d.reason) || 'No backup there.')}${suggest}</p>`;
const b = box.querySelector('#sw-rs-usesuggest');
if (b) {
b.addEventListener('click', () => {
const inp = this.container.querySelector('#sw-rs-path');
if (inp) inp.value = d.suggest;
this.verifyBackupPath();
});
}
}
// The repository fields.
//
// Laid out the way every other field in the wizard is — label with a
@ -1111,9 +1266,17 @@ class SetupWizard {
</div>` +
group('local', 'On this machine',
'A folder on a disk plugged into this server, or mounted on it.',
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')) +
`<div class="setup-field">
<label for="sw-rs-path">
Folder
<span class="setup-tooltip" tabindex="0" data-tip="The repository folder itself \u2014 the one containing config, data/ and snapshots/, not the folder above it.">?</span>
</label>
<div class="setup-input-row">
<span class="setup-field-icon setup-field-icon-emoji" aria-hidden="true">\u{1F4C1}</span>
<input type="text" id="sw-rs-path" class="setup-input-with-icon" placeholder="${this.escapeHtml(this._backupPathPlaceholder())}" autocomplete="off">
<button type="button" class="setup-storage-details" id="sw-rs-verify">Check</button>
</div>
</div>`) +
group('sftp', 'SFTP server',
'Reached over SSH, with the key or password this server already uses.',
field('sw-rs-ssh-user', 'SSH user',
@ -1154,9 +1317,21 @@ class SetupWizard {
};
const sel = this.container.querySelector('#sw-rs-type');
if (sel) sel.addEventListener('change', sync);
const verify = this.container.querySelector('#sw-rs-verify');
if (verify) verify.addEventListener('click', () => this.verifyBackupPath());
sync();
}
// A placeholder from THIS machine, not an invented example. /mnt/usb/… is a
// path nobody here has; showing it as the shape of the answer sends people
// looking for a folder that does not exist.
_backupPathPlaceholder() {
const found = (this.foundBackups || [])[0];
if (found && found.path) return found.path;
const base = (this.storageBackupRoot || '').replace(/\/$/, '');
return base ? `${base}/1` : '/path/to/your/backup-repository';
}
// Build the location half of the payload from whichever fields are showing.
_restoreLocationPayload() {
const v = (id) => {

View File

@ -538,6 +538,42 @@ 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.
### Finding the backup, before asking for the password
The step opened with an empty path box and `/mnt/usb/libreportal-backups` as
the placeholder — a path nobody has, shown as the shape of the answer. Someone
rebuilding a server is being asked to recall from memory the one thing they
came here because they had lost.
Two additions, and the point of both is that **neither needs the repository
password**. A restic repository keeps one file per snapshot under
`snapshots/`, so "is there a backup here, and how many" is a directory listing.
Nothing is decrypted; reading what is *in* those snapshots is the next step,
and that does need the password.
- **`restore scan`** looks in the places a backup actually is: this install's
own backups root (the disk often survives), every backup location the install
already knows about, and one level under each non-OS mount — a drive just
plugged in being the other half of "the system drive died". Bounded to named
shapes and `maxdepth 1`, never a filesystem walk: a scan nobody waits for is
a scan nobody uses. Results are offered as buttons, most snapshots first,
each showing its count and the age of its newest snapshot.
- **`restore verify <path>`** answers the same question for a typed path.
Its most useful answer is the near-miss: pointing at the folder that
*contains* the repositories rather than at one of them is the common mistake,
and it says so and offers the real path as a button rather than explaining
the distinction in a paragraph.
A repository is recognised by `config` plus the `snapshots`, `keys` and `data`
directories together. `config` alone would match any folder that happens to
contain a file of that name, and offering a stray directory as someone's backup
is worse than not finding it.
The placeholder now comes from this machine — the first repository found, or
the install's own backups root — because a placeholder's whole job is to show
the shape of the answer, and only a real one does that.
### The index that moved
Inserting `Start` shifted every step index by one, and `validateStep` was a

View File

@ -58,6 +58,24 @@ cliHandleRestoreCommands()
restoreConnectInspect "$action"
fi
;;
scan)
# Repositories already on this machine. No password needed.
# restore scan [--publish <nonce>]
if [[ "$action" == "--publish" ]]; then
restoreScanPublish "$name"
else
restoreScanLocal
fi
;;
verify)
# Is there a repository at this path, and how many snapshots.
# restore verify <path> [--publish <nonce>]
if [[ "$name" == "--publish" ]]; then
restoreVerifyPublish "$action" "$extra"
else
restoreVerifyPath "$action"
fi
;;
inspect)
# Report what a restore from this repository would bring, without
# writing anything.

View File

@ -118,6 +118,37 @@ read -r -d '' DRIVE <<'JS'
$('#sw-rs-pass').value = 'x';
out.completeAccepted = !w._restoreSourceProblem();
// Finding backups without a password. A restic repository keeps one file per
// snapshot under snapshots/, so "is this a backup, and how many" is a
// directory listing — nothing is decrypted. That is what lets the step tell
// the user something useful BEFORE asking for the password, which is the one
// thing a person rebuilding a server may not have to hand.
const t0 = Date.now();
while (Date.now() - t0 < 45000 && !(w.foundBackups || []).length) {
await new Promise(r => setTimeout(r, 700));
}
out.scanFoundSomething = (w.foundBackups || []).length > 0;
out.foundCarrySnapshotCounts = (w.foundBackups || []).every(f => typeof f.snapshots === 'number');
const card = $('.setup-found-backup');
out.foundRenderedAsButton = !!card && card.tagName === 'BUTTON';
if (card) {
card.click();
await new Promise(r => setTimeout(r, 200));
out.clickFillsThePath = ($('#sw-rs-path') || {}).value === (w.foundBackups[0] || {}).path;
out.clickReportsWithoutPassword = /snapshot/i.test(($('#sw-rs-verify-result') || {}).textContent || '');
}
// The placeholder must be a path from THIS machine, never an invented
// example: /mnt/usb/... sends someone looking for a folder that is not there.
out.placeholderIsReal = !/mnt\/usb/.test(($('#sw-rs-path') || {}).placeholder || '');
// Pointing at the folder that HOLDS the repositories is the common near-miss,
// and is worth one click to fix rather than a paragraph explaining it.
$('#sw-rs-path').value = '/libreportal-backups';
await w.verifyBackupPath();
out.parentFolderExplained = /holds backups rather than being one/i.test(
($('#sw-rs-verify-result') || {}).textContent || '');
out.offersTheRealPath = !!$('#sw-rs-usesuggest');
// 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
@ -232,6 +263,16 @@ chk "relative path refused" "$(g .relativePathRefused)" true
chk "missing password refused" "$(g .missingPasswordRefused)" true
chk "a complete source accepted" "$(g .completeAccepted)" true
echo "finding backups without a password"
chk "the scan found one" "$(g .scanFoundSomething)" true
chk "with a snapshot count" "$(g .foundCarrySnapshotCounts)" true
chk "rendered as a button" "$(g .foundRenderedAsButton)" true
chk "clicking fills the path" "$(g .clickFillsThePath)" true
chk "and reports before any password" "$(g .clickReportsWithoutPassword)" true
chk "placeholder is a real path" "$(g .placeholderIsReal)" true
chk "the parent-folder mistake is explained" "$(g .parentFolderExplained)" true
chk "and the real path is offered" "$(g .offersTheRealPath)" true
echo "the password"
chk "goes through the secret channel" "$(g .passwordWasStashed)" true
chk "leaves the payload as a ref" "$(g .payloadCarriesRef)" true

View File

@ -0,0 +1,184 @@
#!/bin/bash
# Find backup repositories already on this machine, and check one without
# unlocking it.
#
# Rebuilding a server, the repository is nearly always somewhere obvious: the
# install's own backups root if the disk survived, or a drive that was just
# plugged in. Making the user type that path from memory — while looking at a
# placeholder invented for an example — is asking them to recall the one thing
# they came here because they could not.
#
# NEITHER OF THESE NEEDS THE PASSWORD. A restic repository keeps one file per
# snapshot under snapshots/, so the count is a directory listing. Nothing is
# decrypted, nothing is opened; the password is still required to read what is
# actually IN those snapshots, which is the next step.
# The directories a restic repository always has. `config` alone is not enough
# — a folder someone named "config" would pass — and requiring the three that
# only restic creates keeps a stray directory from being offered as a backup.
_restoreRepoLooksReal()
{
local d="${1%/}"
[[ -n "$d" ]] || return 1
runFileOp test -f "$d/config" 2>/dev/null || return 1
runFileOp test -d "$d/snapshots" 2>/dev/null || return 1
runFileOp test -d "$d/keys" 2>/dev/null || return 1
runFileOp test -d "$d/data" 2>/dev/null || return 1
return 0
}
# How many snapshots, and when the newest arrived. Both from the directory
# listing, so this works on a repository we have no key for.
_restoreRepoStats()
{
local d="${1%/}"
local n newest
n=$(runFileOp find "$d/snapshots" -maxdepth 1 -type f 2>/dev/null | grep -c .)
# Not `ls -t`: a repository with thousands of snapshots would sort them all
# to answer one question.
newest=$(runFileOp find "$d/snapshots" -maxdepth 1 -type f -printf '%T@\n' 2>/dev/null \
| sort -rn | head -1 | cut -d. -f1)
printf '%s\t%s\n' "${n:-0}" "${newest:-}"
}
# Check one path. Prints JSON.
#
# restore verify <path>
restoreVerifyPath()
{
local d="${1:-}"
if [[ -z "$d" || "$d" != /* ]]; then
echo '{"repo":false,"reason":"Give a full path, starting with /."}'
return 1
fi
if ! runFileOp test -d "$d" 2>/dev/null; then
echo '{"repo":false,"reason":"Nothing at that path, or it is not readable from here."}'
return 1
fi
if ! _restoreRepoLooksReal "$d"; then
# The overwhelmingly common near-miss: pointing at the folder that
# CONTAINS the repositories rather than at one of them.
local inner first=""
while IFS= read -r inner; do
[[ -z "$inner" ]] && continue
if _restoreRepoLooksReal "$inner"; then first="$inner"; break; fi
done < <(runFileOp find "$d" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort)
if [[ -n "$first" ]]; then
printf '{"repo":false,"reason":"That folder holds backups rather than being one. Try %s","suggest":"%s"}\n' \
"$(_lpJsonStr "$first")" "$(_lpJsonStr "$first")"
return 1
fi
echo '{"repo":false,"reason":"No backup repository there."}'
return 1
fi
local stats n newest
stats=$(_restoreRepoStats "$d")
IFS=$'\t' read -r n newest <<< "$stats"
printf '{"repo":true,"path":"%s","snapshots":%s,"newest":"%s"}\n' \
"$(_lpJsonStr "$d")" "${n:-0}" \
"$([[ -n "$newest" ]] && date -d "@$newest" -Iseconds 2>/dev/null || printf '')"
return 0
}
# Where to look for repositories on this machine.
#
# Bounded deliberately: named shapes and one level under each mount, never a
# walk of the filesystem. A scan that takes a minute on a big disk is a scan
# nobody waits for, and the answer is nearly always in one of these places.
_restoreScanRoots()
{
# This install's own backups root — the disk may well have survived.
local b="${backup_dir%/}"
[[ -n "$b" ]] && runFileOp find "$b" -mindepth 1 -maxdepth 1 -type d 2>/dev/null
# Every backup location this install already knows about.
if declare -f resticEnabledLocations >/dev/null 2>&1; then
local idx p
while IFS= read -r idx; do
[[ -z "$idx" ]] && continue
p=$(backupLocationPath "$idx" 2>/dev/null)
[[ -n "$p" ]] && printf '%s\n' "${p%/}"
done < <(resticEnabledLocations 2>/dev/null)
fi
# Mounted filesystems that are not the OS: a plugged-in disk is the other
# half of "rebuilding after the system drive died".
command -v findmnt >/dev/null 2>&1 || return 0
local sys_dev; sys_dev=$(stat -c '%d' -- / 2>/dev/null)
local line target dev
while IFS= read -r line; do
target="${line#TARGET=\"}"; target="${target%%\"*}"
[[ -z "$target" ]] && continue
case "$target" in
/|/boot|/boot/*|/efi|/proc*|/sys*|/dev*|/run*|/snap*|/var/snap/*|/tmp) continue ;;
esac
dev=$(stat -c '%d' -- "$target" 2>/dev/null)
[[ -n "$dev" && "$dev" == "$sys_dev" ]] && continue
printf '%s\n' "$target"
runFileOp find "$target" -mindepth 1 -maxdepth 1 -type d 2>/dev/null
runFileOp find "$target/libreportal-backups" -mindepth 1 -maxdepth 1 -type d 2>/dev/null
done < <(findmnt -Pno TARGET 2>/dev/null)
}
# Every repository found, as a JSON array.
#
# restore scan
restoreScanLocal()
{
local -a seen=()
local out='[]' d stats n newest iso
while IFS= read -r d; do
d="${d%/}"
[[ -z "$d" ]] && continue
# A path can be reached by more than one root — the install's backups
# dir is also a registered location — and listing it twice would read
# as two different backups.
local dup=0 s
for s in "${seen[@]}"; do [[ "$s" == "$d" ]] && { dup=1; break; }; done
(( dup )) && continue
seen+=("$d")
_restoreRepoLooksReal "$d" || continue
stats=$(_restoreRepoStats "$d")
IFS=$'\t' read -r n newest <<< "$stats"
iso=""
[[ -n "$newest" ]] && iso=$(date -d "@$newest" -Iseconds 2>/dev/null)
out=$(jq -c --arg p "$d" --argjson n "${n:-0}" --arg t "$iso" \
'. + [{path: $p, snapshots: $n, newest: $t}]' <<< "$out")
done < <(_restoreScanRoots)
# Most snapshots first: on a machine with more than one, that is nearly
# always the one being rebuilt from.
jq -c 'sort_by(-.snapshots)' <<< "$out"
return 0
}
# Both, published where the WebUI polls for them.
restoreScanPublish()
{
local nonce="${1:-}"
local out_dir; out_dir="$(webuiDir)/frontend/data/system"
createFolders "quiet" "$sudo_user_name" "$out_dir"
local tmp; tmp=$(mktemp) || return 1
jq -nc --argjson found "$(restoreScanLocal)" --arg nonce "$nonce" \
'{found: $found, nonce: $nonce}' > "$tmp"
runFileWrite "$out_dir/restore_scan.json" < "$tmp"
rm -f "$tmp"
return 0
}
restoreVerifyPublish()
{
local path="${1:-}" nonce="${2:-}"
local out_dir; out_dir="$(webuiDir)/frontend/data/system"
createFolders "quiet" "$sudo_user_name" "$out_dir"
local body; body=$(restoreVerifyPath "$path")
local tmp; tmp=$(mktemp) || return 1
jq -c --arg nonce "$nonce" '. + {nonce: $nonce}' <<< "$body" > "$tmp" 2>/dev/null \
|| printf '{"repo":false,"reason":"unreadable result","nonce":"%s"}\n' "$(_lpJsonStr "$nonce")" > "$tmp"
runFileWrite "$out_dir/restore_verify.json" < "$tmp"
rm -f "$tmp"
return 0
}

View File

@ -12,5 +12,6 @@ restore_scripts=(
"restore/restore_system_adopt.sh"
"restore/restore_domains.sh"
"restore/restore_inspect.sh"
"restore/restore_scan.sh"
)

View File

@ -929,8 +929,15 @@ declare -gA LP_FN_MAP=(
[restorePreflightApp]="restore/restore_preflight.sh"
[restorePreflightManifest]="restore/restore_preflight.sh"
[restorePreflightReport]="restore/restore_preflight.sh"
[_restoreRepoLooksReal]="restore/restore_scan.sh"
[_restoreRepoStats]="restore/restore_scan.sh"
[restoreScanLocal]="restore/restore_scan.sh"
[restoreScanPublish]="restore/restore_scan.sh"
[_restoreScanRoots]="restore/restore_scan.sh"
[restoreServerPublicIp]="restore/restore_domains.sh"
[restoreSystemAdopt]="restore/restore_system_adopt.sh"
[restoreVerifyPath]="restore/restore_scan.sh"
[restoreVerifyPublish]="restore/restore_scan.sh"
[restoreWebuiRebuild]="restore/restore_first_run.sh"
[_rocketchatApi]="rocketchat/scripts/rocketchat_auth.sh"
[_rocketchatBaseUrl]="rocketchat/scripts/rocketchat_auth.sh"
@ -2195,8 +2202,15 @@ declare -gA LP_FN_ROOT=(
[restorePreflightApp]="scripts"
[restorePreflightManifest]="scripts"
[restorePreflightReport]="scripts"
[_restoreRepoLooksReal]="scripts"
[_restoreRepoStats]="scripts"
[restoreScanLocal]="scripts"
[restoreScanPublish]="scripts"
[_restoreScanRoots]="scripts"
[restoreServerPublicIp]="scripts"
[restoreSystemAdopt]="scripts"
[restoreVerifyPath]="scripts"
[restoreVerifyPublish]="scripts"
[restoreWebuiRebuild]="scripts"
[_rocketchatApi]="containers"
[_rocketchatBaseUrl]="containers"
@ -3499,8 +3513,15 @@ restorePickSnapshot() { unset -f restorePickSnapshot; __lpAutoload "${install_sc
restorePreflightApp() { unset -f restorePreflightApp; __lpAutoload "${install_scripts_dir}restore/restore_preflight.sh"; restorePreflightApp "$@"; }
restorePreflightManifest() { unset -f restorePreflightManifest; __lpAutoload "${install_scripts_dir}restore/restore_preflight.sh"; restorePreflightManifest "$@"; }
restorePreflightReport() { unset -f restorePreflightReport; __lpAutoload "${install_scripts_dir}restore/restore_preflight.sh"; restorePreflightReport "$@"; }
_restoreRepoLooksReal() { unset -f _restoreRepoLooksReal; __lpAutoload "${install_scripts_dir}restore/restore_scan.sh"; _restoreRepoLooksReal "$@"; }
_restoreRepoStats() { unset -f _restoreRepoStats; __lpAutoload "${install_scripts_dir}restore/restore_scan.sh"; _restoreRepoStats "$@"; }
restoreScanLocal() { unset -f restoreScanLocal; __lpAutoload "${install_scripts_dir}restore/restore_scan.sh"; restoreScanLocal "$@"; }
restoreScanPublish() { unset -f restoreScanPublish; __lpAutoload "${install_scripts_dir}restore/restore_scan.sh"; restoreScanPublish "$@"; }
_restoreScanRoots() { unset -f _restoreScanRoots; __lpAutoload "${install_scripts_dir}restore/restore_scan.sh"; _restoreScanRoots "$@"; }
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 "$@"; }
restoreVerifyPath() { unset -f restoreVerifyPath; __lpAutoload "${install_scripts_dir}restore/restore_scan.sh"; restoreVerifyPath "$@"; }
restoreVerifyPublish() { unset -f restoreVerifyPublish; __lpAutoload "${install_scripts_dir}restore/restore_scan.sh"; restoreVerifyPublish "$@"; }
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 "$@"; }
_rocketchatBaseUrl() { unset -f _rocketchatBaseUrl; __lpAutoload "${install_containers_dir}rocketchat/scripts/rocketchat_auth.sh"; _rocketchatBaseUrl "$@"; }

View File

@ -166,6 +166,7 @@ webuiGenerateStorageCandidates()
{
"primary": "$(_lpJsonEsc "$(primaryRoot)")",
"system_dir": "$(_lpJsonEsc "${LP_SYSTEM_DIR:-${system_dir%/}}")",
"backups_dir": "$(_lpJsonEsc "${LP_BACKUPS_DIR:-${backup_dir%/}}")",
"system": $system_json,
"locations": $locations,
"candidates": $candidates,