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 `).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,