Adds the wizard step for importing existing apps, so the common case is answerable in the WebUI rather than only from a terminal. Path-based, not upload, and that is the design rather than a shortcut. A .lpapp is a plain tarball and the file is already on the server, so nothing secret crosses into the browser — which is exactly why this can live in the WebUI when the encrypted-repository restore cannot (§4.1). Accepts a single file or a folder of them. Check first, then accept: the step enqueues `app import-check --publish`, polls the document it writes, and renders one row per file with its verdict — ready, a warning (its old storage location is gone, so it will land on the default), or a refusal (already installed, no longer shipped, will not fit). Refused rows are shown greyed with the reason rather than hidden, and cannot be selected. setupApplyConfig re-runs appImport's own checks rather than trusting the payload: the machine can change between the check and the apply, and the list arrives from a browser. The backend route shell-quotes the path — it reaches a command line and is user input. Verified: the step renders as "Step 6 of 7", and the underlying check was proven against real .lpapp files (correct app name from the tar, size from the manifest, warning for a missing storage location, refusals for an already-installed app and a non-export). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
326 lines
12 KiB
Bash
326 lines
12 KiB
Bash
#!/bin/bash
|
|
|
|
# Portable per-app export/import — the "one file you can hand around".
|
|
#
|
|
# libreportal app export <app> [file] -> <app>-<date>.lpapp
|
|
# libreportal app import <file> [newname]
|
|
#
|
|
# WHAT THIS IS NOT
|
|
#
|
|
# Not a backup. A restic/borg/kopia repository gives you deduplication, history,
|
|
# retention and encryption; this gives you one tarball of one app at one moment.
|
|
# It exists because "email me that app" and "keep a copy before I break this" are
|
|
# real jobs that a repository answers badly — and because a single file is what
|
|
# people picture when they say "the backup file".
|
|
#
|
|
# The format is deliberately boring: gzipped tar of the app directory, with its
|
|
# .libreportal-manifest.json at the root. That manifest already records the
|
|
# compose hash, images, volumes, size, databases and storage location, so import
|
|
# gets the same reconciliation the restore preflight does, for free.
|
|
#
|
|
# The app is stopped for the duration of an export. A tar of a running Postgres
|
|
# is a corrupt Postgres, and a file that looks fine until you restore it is
|
|
# worse than a refusal.
|
|
|
|
lpAppExtension="lpapp"
|
|
|
|
appExport()
|
|
{
|
|
local app="$1"
|
|
local out="$2"
|
|
|
|
if [[ -z "$app" ]]; then
|
|
isError "Usage: app export <app_name> [file]"
|
|
return 1
|
|
fi
|
|
|
|
local dir
|
|
if ! dir=$(appDir "$app"); then
|
|
# appDir only refuses for an unmounted location; anything else is the
|
|
# app simply not being here, and saying "not mounted" for a typo sends
|
|
# people looking at their disks.
|
|
if [[ -d "$(primaryRoot)/$app" ]]; then
|
|
isError "$app is on a storage location that is not mounted."
|
|
else
|
|
isError "$app is not installed."
|
|
fi
|
|
return 1
|
|
fi
|
|
if [[ ! -d "$dir" ]]; then
|
|
isError "$app is not installed."
|
|
return 1
|
|
fi
|
|
|
|
[[ -z "$out" ]] && out="$(pwd)/${app}-$(date +%Y%m%d-%H%M%S).${lpAppExtension}"
|
|
case "$out" in */) out="${out}${app}-$(date +%Y%m%d-%H%M%S).${lpAppExtension}" ;; esac
|
|
if [[ -e "$out" ]]; then
|
|
isError "'$out' already exists — refusing to overwrite it."
|
|
return 1
|
|
fi
|
|
|
|
isHeader "Export $app"
|
|
|
|
# Refresh the manifest first: it is what makes the file self-describing, and
|
|
# a stale one would describe a different app than the tar contains.
|
|
declare -f manifestWrite >/dev/null 2>&1 && manifestWrite "$app" >/dev/null 2>&1
|
|
|
|
local was_running=0
|
|
if declare -f dockerComposeDown >/dev/null 2>&1; then
|
|
if runFileOp docker compose -f "$dir/docker-compose.yml" ps -q 2>/dev/null | grep -q .; then
|
|
was_running=1
|
|
fi
|
|
isNotice "Stopping $app — a copy taken while it runs can be corrupt."
|
|
dockerComposeDown "$app" >/dev/null 2>&1 || true
|
|
fi
|
|
|
|
isNotice "Writing $out"
|
|
# Run as the owner: app data holds container sub-UIDs the manager cannot
|
|
# read. --numeric-owner so those uids survive the round trip rather than
|
|
# being remapped through this machine's /etc/passwd.
|
|
if ! runFileOp tar --numeric-owner -C "${dir%/*}" -czf "$out" "$app" 2>/dev/null; then
|
|
isError "Export failed."
|
|
(( was_running )) && dockerComposeUp "$app" >/dev/null 2>&1
|
|
return 1
|
|
fi
|
|
runFileOp chmod 0640 "$out" 2>/dev/null
|
|
|
|
if (( was_running )); then
|
|
isNotice "Starting $app again."
|
|
dockerComposeUp "$app" >/dev/null 2>&1 || true
|
|
fi
|
|
|
|
local size; size=$(du -h "$out" 2>/dev/null | awk '{print $1}')
|
|
isSuccessful "Exported $app to $out (${size:-?})"
|
|
isNotice "This is a copy, not a backup — no history, no retention, and not encrypted."
|
|
echo "$out"
|
|
}
|
|
|
|
# Read the manifest out of an export without unpacking the whole thing.
|
|
appImportManifest()
|
|
{
|
|
local file="$1"
|
|
tar -xzOf "$file" --wildcards '*/.libreportal-manifest.json' 2>/dev/null | head -c 65536
|
|
}
|
|
|
|
# The app name an export contains, taken from the tar's top-level directory
|
|
# rather than the filename — the filename is whatever someone renamed it to.
|
|
appImportName()
|
|
{
|
|
local file="$1"
|
|
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
|
|
}
|
|
|
|
# Same check, published where the WebUI can read it. The CLI prints one JSON
|
|
# object per line (easy to pipe); the WebUI wants one document, so this wraps
|
|
# them and writes it beside the other generated data.
|
|
appImportCheckPublish()
|
|
{
|
|
local target="$1"
|
|
local out_dir="$(webuiDir)/frontend/data/system"
|
|
local out_file="$out_dir/import_check.json"
|
|
createFolders "quiet" "$sudo_user_name" "$out_dir"
|
|
|
|
local tmp; tmp=$(mktemp) || return 1
|
|
{
|
|
printf '{\n "path": "%s",\n "checked": "%s",\n "results": [\n' \
|
|
"$(_lpJsonStr "$target")" "$(date -Iseconds)"
|
|
local first=1 line
|
|
while IFS= read -r line; do
|
|
[[ -z "$line" ]] && continue
|
|
(( first )) || printf ',\n'
|
|
first=0
|
|
printf ' %s' "$line"
|
|
done < <(appImportCheck "$target" 2>/dev/null)
|
|
printf '\n ]\n}\n'
|
|
} > "$tmp"
|
|
|
|
runFileWrite "$out_file" < "$tmp"
|
|
rm -f "$tmp"
|
|
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"
|
|
local as_name="$2"
|
|
|
|
if [[ -z "$file" || ! -f "$file" ]]; then
|
|
isError "Usage: app import <file.${lpAppExtension}> [new_name]"
|
|
return 1
|
|
fi
|
|
|
|
isHeader "Import $file"
|
|
|
|
local app
|
|
app=$(appImportName "$file")
|
|
if [[ -z "$app" || ! "$app" =~ ^[A-Za-z0-9._-]+$ ]]; then
|
|
isError "'$file' does not look like a LibrePortal export."
|
|
return 1
|
|
fi
|
|
[[ -n "$as_name" ]] && {
|
|
isError "Importing under a different name is not supported yet — the app's config namespace (CFG_${app^^}_*) and compose identities would all need rewriting. Use 'libreportal instance create' for a second copy."
|
|
return 1
|
|
}
|
|
|
|
# --- the same checks the restore preflight makes -------------------------
|
|
local manifest size_bytes loc
|
|
manifest=$(appImportManifest "$file")
|
|
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)
|
|
|
|
if [[ ! -f "${install_containers_dir%/}/$app/$app.config" ]]; then
|
|
isError "This version of LibrePortal does not ship '$app' — it would restore into something that cannot start."
|
|
return 1
|
|
fi
|
|
|
|
local dir
|
|
if ! dir=$(appDir "$app"); then
|
|
isError "The storage location for '$app' is not mounted."
|
|
return 1
|
|
fi
|
|
if [[ -d "$dir" ]]; then
|
|
isError "'$app' is already installed at $dir — uninstall it first, or export it before overwriting."
|
|
return 1
|
|
fi
|
|
|
|
if [[ -n "$loc" && "$loc" != "default" && "$loc" != "primary" ]] \
|
|
&& ! storageLocationPath "$loc" >/dev/null 2>&1; then
|
|
isNotice "Came from storage location '$loc', which this machine does not have — using $(storageLocationName "${dir%/*}")."
|
|
fi
|
|
|
|
if [[ -n "$size_bytes" && "$size_bytes" =~ ^[0-9]+$ ]]; 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
|
|
isError "Needs $(( need_kb / 1048576 ))G, only $(( avail_kb / 1048576 ))G free at ${dir%/*}."
|
|
return 1
|
|
fi
|
|
fi
|
|
|
|
# --- unpack ---------------------------------------------------------------
|
|
isNotice "Unpacking into $dir"
|
|
runFileOp mkdir -p "${dir%/*}"
|
|
if ! runFileOp tar --numeric-owner -xzf "$file" -C "${dir%/*}" 2>/dev/null; then
|
|
isError "Unpack failed — removing the partial directory."
|
|
runFileOp rm -rf "$dir"
|
|
return 1
|
|
fi
|
|
|
|
# Ownership, then the normal install pipeline: the compose still carries the
|
|
# SOURCE machine's ports, IPs and domains, and re-running the pipeline is
|
|
# what re-allocates them for this one.
|
|
declare -f runOwnership >/dev/null 2>&1 && runOwnership app-perms >/dev/null 2>&1
|
|
|
|
isNotice "Wiring $app into this machine (ports, IPs, domains)."
|
|
dockerConfigSetupToContainer "silent" "$app"
|
|
initializeAppVariables "$app"
|
|
declare -f migrateUrlRewrite >/dev/null 2>&1 && migrateUrlRewrite "$app" >/dev/null 2>&1
|
|
dockerComposeUpdateAndStartApp "$app" install
|
|
dockerComposeUp "$app"
|
|
|
|
declare -f databaseInstallApp >/dev/null 2>&1 && databaseInstallApp "$app"
|
|
|
|
isSuccessful "$app imported and started"
|
|
return 0
|
|
}
|