From da0136149be1496b0cc15511b40715f85054e1bf Mon Sep 17 00:00:00 2001 From: librelad Date: Thu, 27 Aug 2026 10:04:32 +0100 Subject: [PATCH] =?UTF-8?q?feat(app):=20import-check=20=E2=80=94=20inspect?= =?UTF-8?q?=20an=20export=20without=20importing=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `libreportal app import-check ` 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 --- scripts/app/app_portable.sh | 100 ++++++++++++++++++ scripts/cli/commands/app/cli_app_commands.sh | 11 ++ .../source/files/arrays/function_manifest.sh | 6 ++ 3 files changed, 117 insertions(+) diff --git a/scripts/app/app_portable.sh b/scripts/app/app_portable.sh index f770b4b..df6a212 100644 --- a/scripts/app/app_portable.sh +++ b/scripts/app/app_portable.sh @@ -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 " + 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" diff --git a/scripts/cli/commands/app/cli_app_commands.sh b/scripts/cli/commands/app/cli_app_commands.sh index e28928c..4721a0e 100755 --- a/scripts/cli/commands/app/cli_app_commands.sh +++ b/scripts/cli/commands/app/cli_app_commands.sh @@ -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 " + else + appImportCheck "$app_name" + fi + ;; + "import") # Here $app_name is the FILE, since there is no app yet. if [[ -z "$app_name" ]]; then diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh index 432f66e..830fdfd 100644 --- a/scripts/source/files/arrays/function_manifest.sh +++ b/scripts/source/files/arrays/function_manifest.sh @@ -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 "$@"; }