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>
This commit is contained in:
parent
da0136149b
commit
aa44e0b542
@ -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 || {};
|
||||
|
||||
|
||||
@ -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 {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Step 6: Recommended apps (Traefik + Fail2ban) -->
|
||||
<!-- Step 6: Import existing apps from .lpapp exports on this machine.
|
||||
Path-based, not upload: the file is already on the server, and a
|
||||
.lpapp is a plain tarball, so nothing secret crosses into the
|
||||
browser (unlike a backup repository — see first-run-restore.md). -->
|
||||
<section class="setup-step" data-step="5">
|
||||
<div class="setup-section">
|
||||
<div class="setup-section-title">Import
|
||||
<span class="setup-tooltip" tabindex="0" data-tip="Bring apps over from another LibrePortal using .lpapp files made with 'libreportal app export'. Point at a file or a folder of them, somewhere on this machine.">?</span>
|
||||
</div>
|
||||
<div class="setup-storage-choice">
|
||||
<span class="setup-storage-choice-label">Folder or file</span>
|
||||
<input type="text" id="sw-import-path" class="form-control" placeholder="/mnt/usb/exports">
|
||||
<button type="button" class="setup-storage-details" id="sw-import-check">Check</button>
|
||||
</div>
|
||||
<div id="sw-import-results"></div>
|
||||
<p class="setup-section-hint" id="sw-import-note" style="margin-top:10px;">
|
||||
Optional. Leave blank to skip.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Step 7: Recommended apps (Traefik + Fail2ban) -->
|
||||
<section class="setup-step" data-step="6">
|
||||
<div class="setup-section">
|
||||
<div class="setup-section-title">Recommended Apps</div>
|
||||
<p class="setup-section-hint">Pre-selected to give you a working install out of the box.</p>
|
||||
@ -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. -->
|
||||
<section class="setup-step" data-step="6">
|
||||
<section class="setup-step" data-step="7">
|
||||
<div class="setup-section">
|
||||
<div class="setup-section-title">Metrics Apps</div>
|
||||
<p class="setup-section-hint">Optional. Install these to enable per-app "Export metrics to Grafana" later.</p>
|
||||
@ -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 = '<div class="setup-storage-choice-msg">Use a full path, starting with /.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
btn && (btn.disabled = true, btn.textContent = 'Checking…');
|
||||
box.innerHTML = '<div class="setup-storage-choice-msg">Looking…</div>';
|
||||
|
||||
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 = `<div class="setup-storage-choice-msg">Could not check that path: ${this.escapeHtml(e.message || e)}</div>`;
|
||||
} 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 = '<div class="setup-storage-choice-msg">Nothing importable found there.</div>';
|
||||
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 `
|
||||
<label class="setup-app setup-storage-card${bad ? ' setup-storage-disabled' : ''}">
|
||||
<input type="checkbox" data-import-file="${this.escapeHtml(r.file)}" ${bad ? 'disabled' : 'checked'}>
|
||||
<span class="setup-app-body">
|
||||
<span class="setup-app-name">${icon[r.verdict] || ''} ${this.escapeHtml(r.app || r.file.split('/').pop())}${size}</span>
|
||||
<span class="setup-app-desc">${this.escapeHtml(r.detail)}</span>
|
||||
</span>
|
||||
</label>`;
|
||||
}).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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -176,6 +176,9 @@ cliHandleAppCommands()
|
||||
# before it can ask for acceptance.
|
||||
if [[ -z "$app_name" ]]; then
|
||||
isNotice "Usage: app import-check <file-or-directory>"
|
||||
elif [[ "$initial_command4" == "--publish" ]]; then
|
||||
# Write the result where the WebUI reads it, instead of stdout.
|
||||
appImportCheckPublish "$app_name"
|
||||
else
|
||||
appImportCheck "$app_name"
|
||||
fi
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 "$@"; }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user