LibrePortal/scripts/setup/setup_apply.sh
librelad aa44e0b542 feat(setup): Import step — bring apps in from .lpapp files by path
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>
2026-08-27 10:10:13 +01:00

330 lines
14 KiB
Bash

#!/bin/bash
setupApplyConfig()
{
local payload_b64="$1"
if [[ -z "$payload_b64" ]]; then
isError "setupApplyConfig: no payload provided"
return 1
fi
local payload
payload=$(echo "$payload_b64" | base64 -d 2>/dev/null)
if [[ -z "$payload" ]]; then
isError "setupApplyConfig: failed to decode payload"
return 1
fi
isHeader "Applying Setup Wizard Configuration"
local install_name=$(echo "$payload" | jq -r '.install_name // empty')
local timezone=$(echo "$payload" | jq -r '.timezone // empty')
local install_level=$(echo "$payload" | jq -r '.install_level // empty')
local dev_mode=$(echo "$payload" | jq -r '.dev_mode // empty')
local traefik_email=$(echo "$payload" | jq -r '.traefik_email // empty')
local domains_json=$(echo "$payload" | jq -c '.domains // []')
local storage_json=$(echo "$payload" | jq -c '.storage // []')
local storage_fstab_json=$(echo "$payload" | jq -c '.storage_fstab // []')
local storage_default=$(echo "$payload" | jq -r '.storage_default // "primary"')
local backup_dest=$(echo "$payload" | jq -r '.backup_dest // ""')
local import_files_json=$(echo "$payload" | jq -c '.import_files // []')
if [[ -n "$install_name" ]]; then
updateConfigOption "CFG_INSTALL_NAME" "$install_name"
isSuccessful "Install name set to '$install_name'"
fi
if [[ -n "$timezone" ]]; then
updateConfigOption "CFG_TIMEZONE" "$timezone"
isSuccessful "Timezone set to '$timezone'"
fi
# Experience level — seeds the WebUI's Advanced UI mode on first paint
# so a Beginner gets a stripped-down view and an Advanced user sees
# everything by default. The WebUI also exposes a per-browser toggle
# that overrides this; we just provide the install-time default.
if [[ "$install_level" == "beginner" || "$install_level" == "advanced" ]]; then
updateConfigOption "CFG_INSTALL_LEVEL" "$install_level"
isSuccessful "Experience level set to '$install_level'"
fi
# Developer mode — opt-in via the wizard's Advanced-card easter egg (10
# taps). Unlocks the **DEV**-marked CFG_* fields across the WebUI.
if [[ "$dev_mode" == "true" ]]; then
updateConfigOption "CFG_DEV_MODE" "true"
isSuccessful "Developer mode enabled"
fi
# Storage locations ticked on the wizard's Storage step. This is a REQUEST,
# not an instruction: storageAdd re-runs the fitness checks and the root
# helper re-runs admission, so a path that should not be accepted is not,
# no matter what arrived in the payload.
local storage_count=$(echo "$storage_json" | jq -r 'length')
if [[ "$storage_count" -gt 0 ]]; then
local s i=0
while [[ $i -lt $storage_count ]]; do
s=$(echo "$storage_json" | jq -r ".[$i]")
if [[ -n "$s" && "$s" != "null" ]]; then
# Name it after the mount point's basename — short, recognisable,
# and the user can rename it later from the location config.
local sname="${s##*/}"
[[ -z "$sname" ]] && sname="disk$((i+1))"
if storageAdd "$s" "$sname" >/dev/null; then
isSuccessful "Storage location '$sname' registered at $s"
# Only when explicitly asked on the Storage step. The helper
# validates independently and writes with nofail, so a
# missing drive can never block boot.
if echo "$storage_fstab_json" | jq -e --arg p "$s" 'index($p) != null' >/dev/null 2>&1; then
local fline
if fline=$(runStorage fstab-add "$s" 2>&1); then
isSuccessful "Added to /etc/fstab so it mounts at boot: $fline"
else
isNotice "Could not update /etc/fstab for $s: $fline"
fi
fi
else
isNotice "Could not register '$s' as a storage location — continuing without it."
fi
fi
i=$((i+1))
done
fi
# Where newly installed apps keep their data. Stored as a location NAME, not
# a path: app configs ship CFG_<APP>_STORAGE=default meaning "follow this",
# so one choice at setup places every app installed afterwards without
# touching 37 configs. Resolved AFTER registration, since the name only
# exists once the location has been accepted.
if [[ -n "$storage_default" && "$storage_default" != "primary" && "$storage_default" != "null" ]]; then
local default_name=""
if declare -f storageLocationName >/dev/null 2>&1; then
default_name=$(storageLocationName "$storage_default" 2>/dev/null) || default_name=""
fi
if [[ -n "$default_name" && "$default_name" != "default" ]]; then
updateConfigOption "CFG_STORAGE_DEFAULT" "$default_name"
isSuccessful "New apps will store their data on '$default_name'"
else
isNotice "Could not resolve '$storage_default' to a storage location — new apps will use the system disk."
fi
fi
# Backup destination chosen on the Backups step. Configured here rather than
# left for the user to find later: the people most likely to need a restore
# are the ones who never got round to setting one up.
#
# Created disabled by locationAdd, so enable it and initialise the repo —
# an un-initialised location is a destination that silently backs up
# nothing.
if [[ -n "$backup_dest" && "$backup_dest" != "null" ]]; then
local bpath bname
if [[ "$backup_dest" == "primary" ]]; then
bpath="${backup_dir%/}"
bname="local"
else
bpath="${backup_dest%/}/libreportal-backups"
bname="${backup_dest##*/}"
fi
local bidx
if bidx=$(locationAdd "$bname" local 2>/dev/null | tail -1) && [[ "$bidx" =~ ^[0-9]+$ ]]; then
local bcfg; bcfg=$(backupLocationConfig "$bidx")
if [[ "$backup_dest" != "primary" ]]; then
updateConfigOption "CFG_BACKUP_LOC_${bidx}_PATH_MODE" "custom" "$bcfg" >/dev/null
updateConfigOption "CFG_BACKUP_LOC_${bidx}_PATH" "$bpath" "$bcfg" >/dev/null
fi
updateConfigOption "CFG_BACKUP_LOC_${bidx}_ENABLED" "true" "$bcfg" >/dev/null
source "$bcfg" 2>/dev/null
if engineInitLocation "$bidx" >/dev/null 2>&1; then
isSuccessful "Backups will go to $bpath"
# Deliberately NOT echoed here: the repository password is a
# secret and task output is logged. It is shown on the Backup
# page, which is what the wizard told the user.
isNotice "The repository password is on the Backup page — keep a copy somewhere other than this machine."
else
isNotice "Backup location '$bname' was created but could not be initialised — open the Backup page to finish it."
fi
else
isNotice "Could not create a backup location at $bpath — set one up from the Backup page."
fi
fi
# Apps the user accepted on the Import step. appImport re-runs its own
# checks rather than trusting the payload — the machine may have changed
# between the check and here, and the payload arrives from a browser.
local import_count=$(echo "$import_files_json" | jq -r 'length')
if [[ "$import_count" -gt 0 ]]; then
local f i=0
while [[ $i -lt $import_count ]]; do
f=$(echo "$import_files_json" | jq -r ".[$i]")
if [[ -n "$f" && "$f" != "null" && -f "$f" ]]; then
if appImport "$f" >/dev/null 2>&1; then
isSuccessful "Imported $(basename "$f")"
else
isNotice "Could not import $(basename "$f") — run 'libreportal app import-check' on it to see why."
fi
fi
i=$((i+1))
done
fi
local domains_count=$(echo "$domains_json" | jq -r 'length')
if [[ "$domains_count" -gt 0 ]]; then
local i=0
while [[ $i -lt $domains_count && $i -lt 9 ]]; do
local d=$(echo "$domains_json" | jq -r ".[$i]")
updateConfigOption "CFG_DOMAIN_$((i+1))" "$d"
isSuccessful "Domain $((i+1)) set to '$d'"
((i++))
done
fi
if [[ -n "$traefik_email" && "$traefik_email" != "null" ]]; then
# CFG_TRAEFIK_EMAIL lives in containers/traefik/traefik.config, not in
# the system $configs_dir, so findConfigFileForOption can't auto-locate
# it. Point updateConfigOption at the source file directly. Traefik
# may not be installed yet at this point — config gets copied from
# install_containers_dir into containers_dir during the app-install
# task, so we always update the source.
local traefik_config_file="$install_containers_dir/traefik/traefik.config"
if [[ -f "$traefik_config_file" ]]; then
updateConfigOption "CFG_TRAEFIK_EMAIL" "$traefik_email" "$traefik_config_file"
isSuccessful "Traefik LetsEncrypt email set to '$traefik_email'"
else
isNotice "Traefik source config not found at $traefik_config_file; skipping email write."
fi
fi
# App sub-options are no longer handled here. The setup-routes backend
# folds payload.appOptions into each install command's config_variables
# arg (CFG_REQUIREMENT_<APP>_<OPT>=<bool>) and dockerInstallApp writes
# them into the template config before install<App> runs.
sourceScanFiles "libreportal_configs"
isSuccessful "Configuration written. Selected apps will install next."
}
setupApplyFinalize()
{
# setup_group is passed by the WebUI finalize task so we can read back the
# sibling app-install tasks and tell "install ready" apart from "install
# ran but an app failed". Empty when finalize is invoked standalone.
local setup_group="$1"
isNotice "Initializing backup engine..."
if declare -f installResticHost >/dev/null 2>&1; then
installResticHost
else
isNotice "installResticHost not loaded; backup repos will init on first backup."
fi
isNotice "Refreshing WebUI data snapshots so the config page reflects wizard changes..."
if declare -f webuiLibrePortalUpdate >/dev/null 2>&1; then
webuiLibrePortalUpdate
else
isNotice "webuiLibrePortalUpdate not loaded; skipping refresh."
fi
if declare -f webuiGenerateBackupLocations >/dev/null 2>&1; then
webuiGenerateBackupLocations
webuiGenerateBackupDashboard
webuiGenerateBackupSnapshots all
webuiGenerateBackupAppStatus
fi
setupWizardMarkComplete
# Roll up the per-app install results for this setup group. Each ticked app
# ran as its own `app install` task; the host daemon writes their terminal
# status back into the task JSON before it ever reaches this finalize task
# (FIFO, one at a time), so by now every sibling is settled. We only LOG the
# verdict here — finalize itself still succeeds (it did finalize) and the
# failed app's own task row is already red; the WebUI watcher is what gates
# the "your install is ready" hand-off on this same group-level result.
if [[ -n "$setup_group" ]]; then
local tasks_dir="$(webuiDir)/frontend/data/tasks"
local total=0 failed=0 failed_names="" f
if [[ -d "$tasks_dir" ]]; then
for f in "$tasks_dir"/task_*.json; do
[[ -f "$f" ]] || continue
local grp role
grp=$(jq -r '.setupGroup // empty' "$f" 2>/dev/null)
role=$(jq -r '.setupRole // empty' "$f" 2>/dev/null)
[[ "$grp" == "$setup_group" && "$role" == "app" ]] || continue
total=$((total + 1))
local st ec app
st=$(jq -r '.status // empty' "$f" 2>/dev/null)
ec=$(jq -r '.exit_code // .exitCode // empty' "$f" 2>/dev/null)
app=$(jq -r '.app // "app"' "$f" 2>/dev/null)
if [[ "$st" == "failed" || "$st" == "cancelled" \
|| ( -n "$ec" && "$ec" != "0" && "$ec" != "null" ) ]]; then
failed=$((failed + 1))
failed_names+="${failed_names:+, }$app"
fi
done
fi
if [[ "$total" -eq 0 ]]; then
isNotice "No apps were selected — add apps any time from the App Center."
elif [[ "$failed" -eq 0 ]]; then
isSuccessful "All $total selected app(s) installed successfully."
else
isError "$failed of $total app(s) failed to install: ${failed_names}. Setup is marked complete — retry the failed app(s) from the App Center."
fi
fi
isSuccessful "Setup Wizard complete — your install is configured and ready."
}
setupApply()
{
setupApplyConfig "$1" || return 1
local payload=$(echo "$1" | base64 -d 2>/dev/null)
local apps_json=$(echo "$payload" | jq -c '.apps // []')
local apps_count=$(echo "$apps_json" | jq -r 'length')
if [[ "$apps_count" -gt 0 ]]; then
isHeader "Installing Selected Apps"
local i=0
while [[ $i -lt $apps_count ]]; do
local app_name=$(echo "$apps_json" | jq -r ".[$i]")
isNotice "[$((i+1))/$apps_count] Installing $app_name..."
dockerInstallApp "$app_name"
((i++))
done
fi
setupApplyFinalize
}
setupGenerateName()
{
if declare -f generateInstallName >/dev/null 2>&1; then
generateInstallName
else
echo "QuantumOtter"
fi
}
setupCheckDomainPointsHere()
{
local domain="$1"
if [[ -z "$domain" ]]; then
echo '{"matches":false,"error":"no domain"}'
return 1
fi
local server_ip
server_ip=$(dig +short +time=3 +tries=1 myip.opendns.com @resolver1.opendns.com 2>/dev/null | head -1)
[[ -z "$server_ip" ]] && server_ip=$(hostname -I 2>/dev/null | awk '{print $1}')
local domain_ip
domain_ip=$(dig +short +time=3 +tries=1 "$domain" A 2>/dev/null | head -1)
local matches="false"
[[ -n "$server_ip" && "$server_ip" == "$domain_ip" ]] && matches="true"
printf '{"matches":%s,"server_ip":"%s","domain_ip":"%s"}\n' "$matches" "$server_ip" "$domain_ip"
}