diff --git a/containers/libreportal/backend/routes/setup-routes.js b/containers/libreportal/backend/routes/setup-routes.js
index b7c24cd..1d5e334 100644
--- a/containers/libreportal/backend/routes/setup-routes.js
+++ b/containers/libreportal/backend/routes/setup-routes.js
@@ -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.
diff --git a/containers/libreportal/frontend/core/setup/css/setup-wizard.css b/containers/libreportal/frontend/core/setup/css/setup-wizard.css
index 91f0fe8..9d3a630 100755
--- a/containers/libreportal/frontend/core/setup/css/setup-wizard.css
+++ b/containers/libreportal/frontend/core/setup/css/setup-wizard.css
@@ -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
+
+
` +
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')) +
+ `
+
+
+ \u{1F4C1}
+
+
+
+
`) +
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) => {
diff --git a/docs/roadmap/first-run-restore.md b/docs/roadmap/first-run-restore.md
index 52a9256..323c9a3 100644
--- a/docs/roadmap/first-run-restore.md
+++ b/docs/roadmap/first-run-restore.md
@@ -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 `** 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
diff --git a/scripts/cli/commands/restore/cli_restore_commands.sh b/scripts/cli/commands/restore/cli_restore_commands.sh
index f52954d..be64258 100755
--- a/scripts/cli/commands/restore/cli_restore_commands.sh
+++ b/scripts/cli/commands/restore/cli_restore_commands.sh
@@ -58,6 +58,24 @@ cliHandleRestoreCommands()
restoreConnectInspect "$action"
fi
;;
+ scan)
+ # Repositories already on this machine. No password needed.
+ # restore scan [--publish ]
+ if [[ "$action" == "--publish" ]]; then
+ restoreScanPublish "$name"
+ else
+ restoreScanLocal
+ fi
+ ;;
+ verify)
+ # Is there a repository at this path, and how many snapshots.
+ # restore verify [--publish ]
+ if [[ "$name" == "--publish" ]]; then
+ restoreVerifyPublish "$action" "$extra"
+ else
+ restoreVerifyPath "$action"
+ fi
+ ;;
inspect)
# Report what a restore from this repository would bring, without
# writing anything.
diff --git a/scripts/dev/lp-restore-wizard-test b/scripts/dev/lp-restore-wizard-test
index 2ec0bde..924e302 100755
--- a/scripts/dev/lp-restore-wizard-test
+++ b/scripts/dev/lp-restore-wizard-test
@@ -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
diff --git a/scripts/restore/restore_scan.sh b/scripts/restore/restore_scan.sh
new file mode 100644
index 0000000..1fc1871
--- /dev/null
+++ b/scripts/restore/restore_scan.sh
@@ -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
+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
+}
diff --git a/scripts/source/files/arrays/files_restore.sh b/scripts/source/files/arrays/files_restore.sh
index 8fc9a59..d17b83d 100755
--- a/scripts/source/files/arrays/files_restore.sh
+++ b/scripts/source/files/arrays/files_restore.sh
@@ -12,5 +12,6 @@ restore_scripts=(
"restore/restore_system_adopt.sh"
"restore/restore_domains.sh"
"restore/restore_inspect.sh"
+ "restore/restore_scan.sh"
)
diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh
index 6ff6391..141dfed 100644
--- a/scripts/source/files/arrays/function_manifest.sh
+++ b/scripts/source/files/arrays/function_manifest.sh
@@ -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 "$@"; }
diff --git a/scripts/webui/data/generators/system/webui_storage_candidates.sh b/scripts/webui/data/generators/system/webui_storage_candidates.sh
index 33dcd4c..7cd63b1 100644
--- a/scripts/webui/data/generators/system/webui_storage_candidates.sh
+++ b/scripts/webui/data/generators/system/webui_storage_candidates.sh
@@ -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,