LibrePortal/scripts/restore/restore_scan.sh
librelad 601ea03b1a Backup step: drop the timestamp list, and make both answers cards
The pre-password "Taken" list goes. It could only ever be a column of
timestamps, and the card directly above it already said how many snapshots
there were and how recent the newest was — so it answered a question that had
just been answered. The real choice now lives on Contents, where snapshots have
names. The times came out of the scan and verify payloads with it; carried but
unread is debt.

The read result is a card too, matching the folder's. They were a card and a
sentence sitting one above the other, looking like two different kinds of
thing. Shortened to the host and what it holds — "Change-Me · settings + 2
apps". No "continue to see what will happen": Next is right there and has just
become available, which says it better.

And a real bug, caught by asserting Next's state after a genuine read rather
than a simulated one: readBackup CLEARS the password field the moment it hands
the value to the host, so a gate that re-checked the source fields reported a
missing password about a repository it had already opened. Next stayed disabled
for good after a successful read. Both the button and validateStep now treat an
open backup as settling the question — they have to agree, because
enabled-but-refused is worse than either alone.

The test that caught it could not run at first: BACKUP was declared after the
block using it, so the whole eval died in the temporal dead zone and reported
as "the browser failed".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 14:14:17 +01:00

190 lines
7.6 KiB
Bash

#!/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"
# The count and the newest, and no more. A list of bare timestamps was
# rendered here for a while, but it could only ever be timestamps — a
# snapshot's identity is in the encrypted object — and once Contents
# offered a real per-app chooser, the pre-password list was answering a
# question the card above it had already answered.
jq -nc --arg p "$d" --argjson n "${n:-0}" \
--arg newest "$([[ -n "$newest" ]] && date -d "@$newest" -Iseconds 2>/dev/null || printf '')" \
'{repo: true, path: $p, snapshots: $n, newest: $newest}'
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
}