From cf8a4b2c69c87486596227a6fb74bf2aebb14914 Mon Sep 17 00:00:00 2001 From: librelad Date: Sat, 29 Aug 2026 07:04:15 +0100 Subject: [PATCH] Find the backup before asking for its password MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 `).join('') + + `

+ Pick one to fill it in below. You will still need its password.

`; + + 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 = `

Backup found \u2014 + ${d.snapshots} snapshot${d.snapshots === 1 ? '' : 's'}${ + d.newest ? `, newest ${this._restoreWhen(d.newest)}` : ''}. + Enter its password to see what is inside.

`; + 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 + ? ` ` + : ''; + box.innerHTML = `

${this.escapeHtml((d && d.reason) || 'No backup there.')}${suggest}

`; + 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 { ` + 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')) + + `
+ +
+ + + +
+
`) + 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,