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 "$@"; }