From aa44e0b54219cfc65201e6b7ae760ce9602101d2 Mon Sep 17 00:00:00 2001 From: librelad Date: Thu, 27 Aug 2026 10:10:13 +0100 Subject: [PATCH] =?UTF-8?q?feat(setup):=20Import=20step=20=E2=80=94=20brin?= =?UTF-8?q?g=20apps=20in=20from=20.lpapp=20files=20by=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../backend/routes/setup-routes.js | 26 ++++ .../frontend/core/setup/js/setup-wizard.js | 132 +++++++++++++++++- scripts/app/app_portable.sh | 29 ++++ scripts/cli/commands/app/cli_app_commands.sh | 3 + scripts/setup/setup_apply.sh | 20 +++ .../source/files/arrays/function_manifest.sh | 3 + 6 files changed, 206 insertions(+), 7 deletions(-) diff --git a/containers/libreportal/backend/routes/setup-routes.js b/containers/libreportal/backend/routes/setup-routes.js index d245e98..4ab2002 100644 --- a/containers/libreportal/backend/routes/setup-routes.js +++ b/containers/libreportal/backend/routes/setup-routes.js @@ -144,6 +144,32 @@ async function enqueueTask(spec) { return id; } +// Check a path full of .lpapp exports, without importing anything. +// +// Enqueues the host-side check and returns immediately; the result lands in +// frontend/data/system/import_check.json, which the wizard polls. Read-only, +// and a .lpapp is not encrypted, so no secret crosses this boundary — unlike a +// backup repository, which is why that one stays in the terminal installer. +router.post('/import-check', requireAuth, async (req, res) => { + const p = String((req.body && req.body.path) || '').trim(); + if (!p || !p.startsWith('/')) { + return res.status(400).json({ error: 'An absolute path is required' }); + } + // Shell-quote: this reaches a command line, and a path is user input. + const quoted = `'${p.replace(/'/g, "'\\''")}'`; + try { + const id = await enqueueTask({ + command: `libreportal app import-check ${quoted} --publish`, + type: 'import', + app: 'libreportal', + setupRole: 'config' + }); + res.json({ ok: true, taskId: id }); + } catch (e) { + res.status(500).json({ error: e.message || String(e) }); + } +}); + router.post('/save', requireAuth, async (req, res) => { const payload = req.body || {}; diff --git a/containers/libreportal/frontend/core/setup/js/setup-wizard.js b/containers/libreportal/frontend/core/setup/js/setup-wizard.js index e1e093d..b2b9bc8 100755 --- a/containers/libreportal/frontend/core/setup/js/setup-wizard.js +++ b/containers/libreportal/frontend/core/setup/js/setup-wizard.js @@ -21,8 +21,8 @@ class SetupWizard { // Storage sits BEFORE Recommended on purpose: a location has to exist // before an app can be placed on it, and the Recommended step can then // offer the big apps a home other than the system disk. - this.stepNames = ['Experience', 'Identity', 'Domains', 'Storage', 'Backups', 'Recommended', 'Metrics']; - this.stepIcons = ['🌱', '🪐', '🛰️', '💾', '🛟', '🛡️', '📊']; + this.stepNames = ['Experience', 'Identity', 'Domains', 'Storage', 'Backups', 'Import', 'Recommended', 'Metrics']; + this.stepIcons = ['🌱', '🪐', '🛰️', '💾', '🛟', '📦', '🛡️', '📊']; // Storage is skipped entirely when this box has nowhere else to put things // — one disk means one answer, and a step with nothing in it is noise. // Set by loadStorage() once the candidate scan comes back. @@ -38,6 +38,9 @@ class SetupWizard { this.storageSystemChoice = 'primary'; // Backup destination: '' = none, 'primary' = system disk, else a drive path. this.backupDest = ''; + // .lpapp exports found at the path the user gave, and which to import. + this.importResults = []; + this.importSelected = []; this.installLevel = 'beginner'; this.totalSteps = this._effectiveTotalSteps(); this.domainCount = 0; // tracked dynamically as the user adds rows @@ -262,8 +265,29 @@ class SetupWizard { - +
+
+
Import + ? +
+
+ Folder or file + + +
+
+

+ Optional. Leave blank to skip. +

+
+
+ + +
Recommended Apps

Pre-selected to give you a working install out of the box.

@@ -287,7 +311,7 @@ class SetupWizard { default — they're only useful if the user wants the MONITORING toggle on apps to do anything. Advanced-only: this whole step is skipped when the user chose Beginner on step 1. --> -
+
Metrics Apps

Optional. Install these to enable per-app "Export metrics to Grafana" later.

@@ -336,6 +360,7 @@ class SetupWizard { this.attachLiveValidation(); + $('#sw-import-check').addEventListener('click', () => this.checkImportPath()); $('#sw-back').addEventListener('click', () => this.prev()); $('#sw-next').addEventListener('click', () => this.next()); @@ -582,6 +607,9 @@ class SetupWizard { this.storageSystemChoice = 'primary'; // Backup destination: '' = none, 'primary' = system disk, else a drive path. this.backupDest = ''; + // .lpapp exports found at the path the user gave, and which to import. + this.importResults = []; + this.importSelected = []; this.storageSystemChoice = 'primary'; return; } @@ -699,6 +727,94 @@ class SetupWizard { + 'It is shown on the Backup page once setup finishes.'; } + // Ask the host to inspect a path, then poll for the answer. + // + // The check runs on the host (it needs tar and the app templates), so this + // enqueues it and watches the file it publishes. Polling rather than a + // synchronous route because the task daemon owns the FIFO — the wizard has no + // way to run a command itself, by design. + async checkImportPath() { + const input = this.container.querySelector('#sw-import-path'); + const box = this.container.querySelector('#sw-import-results'); + const btn = this.container.querySelector('#sw-import-check'); + if (!input || !box) return; + + const path = input.value.trim(); + this.importSelected = []; + if (!path) { box.innerHTML = ''; return; } + if (!path.startsWith('/')) { + box.innerHTML = '
Use a full path, starting with /.
'; + return; + } + + btn && (btn.disabled = true, btn.textContent = 'Checking…'); + box.innerHTML = '
Looking…
'; + + try { + const res = await fetch('/api/setup/import-check', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path }) + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + + // Wait for the published document to catch up with this request. + const started = Date.now(); + let data = null; + while (Date.now() - started < 30000) { + await new Promise(r => setTimeout(r, 1000)); + try { + const f = await fetch('/data/system/import_check.json', { cache: 'no-store' }); + if (f.ok) { + const d = await f.json(); + if (d && d.path === path) { data = d; break; } + } + } catch {} + } + if (!data) throw new Error('the check did not finish in time'); + this.importResults = Array.isArray(data.results) ? data.results : []; + this.renderImportResults(); + } catch (e) { + box.innerHTML = `
Could not check that path: ${this.escapeHtml(e.message || e)}
`; + } finally { + btn && (btn.disabled = false, btn.textContent = 'Check'); + } + } + + renderImportResults() { + const box = this.container.querySelector('#sw-import-results'); + if (!box) return; + if (!this.importResults.length) { + box.innerHTML = '
Nothing importable found there.
'; + return; + } + + const icon = { refuse: '\u26d4', warn: '\u26a0\ufe0f', ok: '\u2705' }; + // Anything usable starts ticked: the user pointed at this folder on purpose. + this.importSelected = this.importResults + .filter(r => r.verdict !== 'refuse').map(r => r.file); + + box.innerHTML = this.importResults.map((r, i) => { + const bad = r.verdict === 'refuse'; + const size = r.size_bytes > 0 ? ` · ${(r.size_bytes / 1073741824).toFixed(1)}G` : ''; + return ` + `; + }).join(''); + + box.querySelectorAll('[data-import-file]').forEach((cb) => { + cb.addEventListener('change', () => { + this.importSelected = Array.from(box.querySelectorAll('[data-import-file]:checked')) + .map(x => x.dataset.importFile); + }); + }); + } + // Details modal — the technical spec, every check with its full explanation, // and (when the drive isn't in fstab) the offer to make it permanent. // @@ -861,8 +977,8 @@ class SetupWizard { } } } - // 5 = Recommended (Storage at 3 and Backups at 4 shifted this along). - if (idx === 5) { + // 6 = Recommended (Storage 3, Backups 4 and Import 5 shifted this along). + if (idx === 6) { const traefikBox = this.container.querySelector('input[data-app="traefik"]'); if (traefikBox && traefikBox.checked) { const tEmail = $('#sw-traefik-email').value.trim(); @@ -1223,7 +1339,9 @@ class SetupWizard { // Nothing acts on it: moving LibrePortal's own tree needs real root. storage_system: (this.storageSystemChoice && this.storageSystemChoice !== 'primary') ? this.storageSystemChoice : 'primary', // '' = don't configure backups; 'primary' = system disk; else a drive path. - backup_dest: this.backupDest || '' + backup_dest: this.backupDest || '', + // Absolute paths to .lpapp files the user accepted after the check. + import_files: this.importSelected || [] }; // Apply the experience choice to the WebUI immediately so the next diff --git a/scripts/app/app_portable.sh b/scripts/app/app_portable.sh index df6a212..454fae9 100644 --- a/scripts/app/app_portable.sh +++ b/scripts/app/app_portable.sh @@ -196,6 +196,35 @@ appImportCheck() 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" diff --git a/scripts/cli/commands/app/cli_app_commands.sh b/scripts/cli/commands/app/cli_app_commands.sh index 4721a0e..be2e101 100755 --- a/scripts/cli/commands/app/cli_app_commands.sh +++ b/scripts/cli/commands/app/cli_app_commands.sh @@ -176,6 +176,9 @@ cliHandleAppCommands() # before it can ask for acceptance. if [[ -z "$app_name" ]]; then isNotice "Usage: app import-check " + elif [[ "$initial_command4" == "--publish" ]]; then + # Write the result where the WebUI reads it, instead of stdout. + appImportCheckPublish "$app_name" else appImportCheck "$app_name" fi diff --git a/scripts/setup/setup_apply.sh b/scripts/setup/setup_apply.sh index 7b27cb8..b0bbe37 100644 --- a/scripts/setup/setup_apply.sh +++ b/scripts/setup/setup_apply.sh @@ -28,6 +28,7 @@ setupApplyConfig() 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" @@ -149,6 +150,25 @@ setupApplyConfig() 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 diff --git a/scripts/source/files/arrays/function_manifest.sh b/scripts/source/files/arrays/function_manifest.sh index 830fdfd..03ab01c 100644 --- a/scripts/source/files/arrays/function_manifest.sh +++ b/scripts/source/files/arrays/function_manifest.sh @@ -49,6 +49,7 @@ declare -gA LP_FN_MAP=( [appGluetunRefreshProviders]="gluetun/tools/gluetun_refresh_providers.sh" [appImport]="app/app_portable.sh" [appImportCheck]="app/app_portable.sh" + [appImportCheckPublish]="app/app_portable.sh" [appImportManifest]="app/app_portable.sh" [appImportName]="app/app_portable.sh" [appInstallCheckRequirements]="checks/requirements/check_app_install.sh" @@ -1283,6 +1284,7 @@ declare -gA LP_FN_ROOT=( [appGluetunRefreshProviders]="containers" [appImport]="scripts" [appImportCheck]="scripts" + [appImportCheckPublish]="scripts" [appImportManifest]="scripts" [appImportName]="scripts" [appInstallCheckRequirements]="scripts" @@ -2555,6 +2557,7 @@ appGluetunRecreateRouted() { unset -f appGluetunRecreateRouted; __lpAutoload "${ 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 "$@"; } +appImportCheckPublish() { unset -f appImportCheckPublish; __lpAutoload "${install_scripts_dir}app/app_portable.sh"; appImportCheckPublish "$@"; } 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 "$@"; }