feat(app): import-check — inspect an export without importing it

`libreportal app import-check <file-or-directory>` reports what an import
would do, as one JSON object per file, without touching anything. It runs
the same checks appImport makes — app still shipped, not already
installed, fits on the target, storage location still exists — so a UI can
show them and ask for acceptance before acting.

This is what makes a path-based import safe to drive from the WebUI when
the repository restore is not. A .lpapp is a plain tarball, not encrypted,
so there is no password to collect and nothing secret crosses from the
browser to the host — the blocker recorded in first-run-restore.md §4.1
simply does not apply.

Accepts a single file or a directory of them, so "point at this folder"
works as well as "point at this file".

Verified against real .lpapp files built for the purpose: app name read
from the tar's top-level directory rather than the filename, size read
from the manifest, a location the machine does not have downgraded to a
warning naming the fallback, and refusals for an already-installed app
and a file that is not an export at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-27 10:04:32 +01:00
parent ef60cce98a
commit da0136149b
3 changed files with 117 additions and 0 deletions

View File

@ -110,6 +110,106 @@ appImportName()
tar -tzf "$file" 2>/dev/null | head -1 | cut -d/ -f1
}
# Inspect one or more exports WITHOUT importing anything, and report what would
# happen. This is what lets the WebUI ask for acceptance before it acts: the
# same checks appImport makes, run early and rendered as a list.
#
# A .lpapp is a plain tarball — not encrypted — so unlike a backup repository
# there is no password to collect, and nothing secret has to cross from the
# browser to the host. That is the whole reason this can live in the WebUI when
# the repository restore cannot.
#
# Emits one JSON object per line: {"file","app","size_bytes","verdict","detail"}
# verdict is ok | warn | refuse.
appImportCheck()
{
local target="$1"
if [[ -z "$target" ]]; then
isError "Usage: app import-check <file-or-directory>"
return 1
fi
local -a files=()
if [[ -d "$target" ]]; then
while IFS= read -r f; do [[ -n "$f" ]] && files+=("$f"); done \
< <(find "$target" -maxdepth 1 -type f -name "*.${lpAppExtension}" 2>/dev/null | sort)
elif [[ -f "$target" ]]; then
files=("$target")
else
printf '{"file":"%s","app":"","size_bytes":0,"verdict":"refuse","detail":"No such file or directory"}\n' "$(_lpJsonStr "$target")"
return 1
fi
if (( ${#files[@]} == 0 )); then
printf '{"file":"%s","app":"","size_bytes":0,"verdict":"refuse","detail":"No .%s files found there"}\n' \
"$(_lpJsonStr "$target")" "$lpAppExtension"
return 1
fi
local f app manifest size_bytes loc dir verdict detail
for f in "${files[@]}"; do
verdict="ok"; detail="ready to import"
app=$(appImportName "$f")
if [[ -z "$app" || ! "$app" =~ ^[A-Za-z0-9._-]+$ ]]; then
_lpImportRow "$f" "" 0 refuse "Not a LibrePortal export"
continue
fi
manifest=$(appImportManifest "$f")
size_bytes=$(printf '%s' "$manifest" | grep -o '"size_bytes":[0-9]*' | head -1 | cut -d: -f2)
loc=$(printf '%s' "$manifest" | grep -o '"location":"[^"]*"' | head -1 | cut -d'"' -f4)
[[ "$size_bytes" =~ ^[0-9]+$ ]] || size_bytes=0
if [[ ! -f "${install_containers_dir%/}/$app/$app.config" ]]; then
_lpImportRow "$f" "$app" "$size_bytes" refuse "This version no longer ships $app"
continue
fi
if ! dir=$(appDir "$app" 2>/dev/null); then
_lpImportRow "$f" "$app" "$size_bytes" refuse "Its storage location is not mounted"
continue
fi
if [[ -d "$dir" ]]; then
_lpImportRow "$f" "$app" "$size_bytes" refuse "$app is already installed — uninstall it first"
continue
fi
if (( size_bytes > 0 )); then
local need_kb=$(( size_bytes / 1024 )) avail_kb
avail_kb=$(df -Pk "${dir%/*}" 2>/dev/null | awk 'NR==2 {print $4}')
if [[ -n "$avail_kb" ]] && (( avail_kb < need_kb )); then
_lpImportRow "$f" "$app" "$size_bytes" refuse \
"Needs $(( need_kb / 1048576 ))G, $(( avail_kb / 1048576 ))G free"
continue
fi
fi
if [[ -n "$loc" && "$loc" != "default" && "$loc" != "primary" ]] \
&& ! storageLocationPath "$loc" >/dev/null 2>&1; then
verdict="warn"
detail="came from location \"$loc\", which this machine does not have — will use $(storageLocationName "${dir%/*}")"
fi
_lpImportRow "$f" "$app" "$size_bytes" "$verdict" "$detail"
done
return 0
}
_lpJsonStr()
{
local s="$1"
s="${s//\\/\\\\}"; s="${s//\"/\\\"}"
s="${s//$'\t'/ }"; s="${s//$'\n'/ }"; s="${s//$'\r'/}"
printf '%s' "$s"
}
_lpImportRow()
{
printf '{"file":"%s","app":"%s","size_bytes":%s,"verdict":"%s","detail":"%s"}\n' \
"$(_lpJsonStr "$1")" "$(_lpJsonStr "$2")" "${3:-0}" "$4" "$(_lpJsonStr "$5")"
}
appImport()
{
local file="$1"

View File

@ -170,6 +170,17 @@ cliHandleAppCommands()
fi
;;
"import-check")
# Report what an import WOULD do, without doing it. Read-only, so it
# runs inline rather than through a task — the WebUI needs the answer
# before it can ask for acceptance.
if [[ -z "$app_name" ]]; then
isNotice "Usage: app import-check <file-or-directory>"
else
appImportCheck "$app_name"
fi
;;
"import")
# Here $app_name is the FILE, since there is no app yet.
if [[ -z "$app_name" ]]; then

View File

@ -48,6 +48,7 @@ declare -gA LP_FN_MAP=(
[appGluetunRecreateRouted]="gluetun/scripts/gluetun_recreate_routed.sh"
[appGluetunRefreshProviders]="gluetun/tools/gluetun_refresh_providers.sh"
[appImport]="app/app_portable.sh"
[appImportCheck]="app/app_portable.sh"
[appImportManifest]="app/app_portable.sh"
[appImportName]="app/app_portable.sh"
[appInstallCheckRequirements]="checks/requirements/check_app_install.sh"
@ -676,6 +677,7 @@ declare -gA LP_FN_MAP=(
[lpFetchSource]="source/fetch.sh"
[_lpFetchTool]="source/fetch.sh"
[lpFootprintStale]="source/fetch.sh"
[_lpImportRow]="app/app_portable.sh"
[lpIndexArtifactIds]="source/artifacts.sh"
[lpIndexTop]="source/artifacts.sh"
[lpInstalledFootprintVersion]="source/fetch.sh"
@ -1280,6 +1282,7 @@ declare -gA LP_FN_ROOT=(
[appGluetunRecreateRouted]="containers"
[appGluetunRefreshProviders]="containers"
[appImport]="scripts"
[appImportCheck]="scripts"
[appImportManifest]="scripts"
[appImportName]="scripts"
[appInstallCheckRequirements]="scripts"
@ -1908,6 +1911,7 @@ declare -gA LP_FN_ROOT=(
[lpFetchSource]="scripts"
[_lpFetchTool]="scripts"
[lpFootprintStale]="scripts"
[_lpImportRow]="scripts"
[lpIndexArtifactIds]="scripts"
[lpIndexTop]="scripts"
[lpInstalledFootprintVersion]="scripts"
@ -2550,6 +2554,7 @@ appGiteaSetAdmin() { unset -f appGiteaSetAdmin; __lpAutoload "${install_containe
appGluetunRecreateRouted() { unset -f appGluetunRecreateRouted; __lpAutoload "${install_containers_dir}gluetun/scripts/gluetun_recreate_routed.sh"; appGluetunRecreateRouted "$@"; }
appGluetunRefreshProviders() { unset -f appGluetunRefreshProviders; __lpAutoload "${install_containers_dir}gluetun/tools/gluetun_refresh_providers.sh"; appGluetunRefreshProviders "$@"; }
appImport() { unset -f appImport; __lpAutoload "${install_scripts_dir}app/app_portable.sh"; appImport "$@"; }
appImportCheck() { unset -f appImportCheck; __lpAutoload "${install_scripts_dir}app/app_portable.sh"; appImportCheck "$@"; }
appImportManifest() { unset -f appImportManifest; __lpAutoload "${install_scripts_dir}app/app_portable.sh"; appImportManifest "$@"; }
appImportName() { unset -f appImportName; __lpAutoload "${install_scripts_dir}app/app_portable.sh"; appImportName "$@"; }
appInstallCheckRequirements() { unset -f appInstallCheckRequirements; __lpAutoload "${install_scripts_dir}checks/requirements/check_app_install.sh"; appInstallCheckRequirements "$@"; }
@ -3178,6 +3183,7 @@ lpFetchRelease() { unset -f lpFetchRelease; __lpAutoload "${install_scripts_dir}
lpFetchSource() { unset -f lpFetchSource; __lpAutoload "${install_scripts_dir}source/fetch.sh"; lpFetchSource "$@"; }
_lpFetchTool() { unset -f _lpFetchTool; __lpAutoload "${install_scripts_dir}source/fetch.sh"; _lpFetchTool "$@"; }
lpFootprintStale() { unset -f lpFootprintStale; __lpAutoload "${install_scripts_dir}source/fetch.sh"; lpFootprintStale "$@"; }
_lpImportRow() { unset -f _lpImportRow; __lpAutoload "${install_scripts_dir}app/app_portable.sh"; _lpImportRow "$@"; }
lpIndexArtifactIds() { unset -f lpIndexArtifactIds; __lpAutoload "${install_scripts_dir}source/artifacts.sh"; lpIndexArtifactIds "$@"; }
lpIndexTop() { unset -f lpIndexTop; __lpAutoload "${install_scripts_dir}source/artifacts.sh"; lpIndexTop "$@"; }
lpInstalledFootprintVersion() { unset -f lpInstalledFootprintVersion; __lpAutoload "${install_scripts_dir}source/fetch.sh"; lpInstalledFootprintVersion "$@"; }