diff --git a/containers/libreportal/frontend/core/setup/css/setup-wizard.css b/containers/libreportal/frontend/core/setup/css/setup-wizard.css
index 447d8b8..d88cea7 100755
--- a/containers/libreportal/frontend/core/setup/css/setup-wizard.css
+++ b/containers/libreportal/frontend/core/setup/css/setup-wizard.css
@@ -295,6 +295,11 @@ body.setup-wizard-open {
.setup-field input[type=text],
.setup-field input[type=email],
+/* password and number were missing, so any field using them — a backup
+ destination's credentials, an SSH port — rendered as a bare browser input in
+ the middle of styled ones. */
+.setup-field input[type=password],
+.setup-field input[type=number],
.setup-field select {
width: 100%;
background: rgba(var(--text-rgb), 0.06);
@@ -312,6 +317,8 @@ body.setup-wizard-open {
.setup-field input[type=text]:focus,
.setup-field input[type=email]:focus,
+.setup-field input[type=password]:focus,
+.setup-field input[type=number]:focus,
.setup-field select:focus {
outline: none;
background: rgba(var(--text-rgb), 0.10);
diff --git a/containers/libreportal/frontend/core/setup/js/setup-wizard.js b/containers/libreportal/frontend/core/setup/js/setup-wizard.js
index c17667b..b0ff706 100755
--- a/containers/libreportal/frontend/core/setup/js/setup-wizard.js
+++ b/containers/libreportal/frontend/core/setup/js/setup-wizard.js
@@ -71,6 +71,22 @@ class SetupWizard {
// Indices of the steps actually shown, in order. Everything else (progress,
// next/prev, validation, submit) works off this rather than raw indices, so
// hiding a step never leaves a gap in the numbering.
+ // Which step to open on. Out-of-range or missing means the first one; the
+ // value is an index into the VISIBLE steps, so it matches what the progress
+ // bar says rather than the raw list.
+ _stepFromQuery() {
+ try {
+ const raw = new URLSearchParams(window.location.search).get('step');
+ if (raw === null) return 0;
+ const n = parseInt(raw, 10);
+ if (!Number.isFinite(n)) return 0;
+ const max = this._visibleSteps().length - 1;
+ return Math.min(Math.max(n, 0), Math.max(max, 0));
+ } catch (e) {
+ return 0;
+ }
+ }
+
_visibleSteps() {
return this.stepNames.map((_, i) => i).filter((i) => this._stepVisible(i));
}
@@ -92,7 +108,11 @@ class SetupWizard {
// Same shape: the step is usable immediately and fills in when the
// install's existing destinations come back.
this.loadBackupLocations();
- this.showStep(0);
+ // ?step=N opens the wizard on a given step. Nothing about setup depends on
+ // the order, and it makes a step reachable without clicking through the
+ // ones before it — which is what lets a screenshot or a headless test look
+ // at, say, Backups directly.
+ this.showStep(this._stepFromQuery());
}
getWizardApps() {
@@ -787,7 +807,7 @@ class SetupWizard {
}).join('');
box.innerHTML = rows + `
- `;
+ `;
box.querySelectorAll('[data-backup-edit]').forEach(b => {
b.addEventListener('click', () => this.showBackupDestModal(Number(b.dataset.backupEdit)));
@@ -813,43 +833,67 @@ class SetupWizard {
if (typeof window.openEoModal !== 'function') return;
const adding = index < 0;
const loc = adding
- ? { name: '', type: 'local', path: '' }
- : Object.assign({}, this.backupLocations[index]);
+ ? { name: '', type: 'local', path: '', ssh_port: '22', ssh_auth: 'key' }
+ : Object.assign({ ssh_port: '22', ssh_auth: 'key' }, this.backupLocations[index]);
- const types = [
- ['local', 'This machine or a plugged-in disk'],
- ['sftp', 'SFTP server'],
- ['s3', 'S3'],
- ['b2', 'Backblaze B2']
- ];
+ const esc = (v) => this.escapeHtml(v == null ? '' : String(v));
+ // Fields and wording follow the location config itself, so what is asked
+ // here and what the Backup page shows afterwards are the same thing.
+ const field = (id, label, hint, input) => `
+
+ ${field('bk-path', 'Custom Path', 'Filesystem path on this server. Leave blank to use the default backup folder.',
+ text('bk-path', loc.path, '/mnt/usb/libreportal-backups'))}
-
-
-
-
+
+
+ ${field('bk-host', 'SSH Host', '', text('bk-host', loc.ssh_host, 'backup.example.org'))}
+ ${field('bk-user', 'SSH User', '', text('bk-user', loc.ssh_user, 'libreportal'))}
+ ${field('bk-rpath', 'SSH Remote Path', 'Path on the remote host where the repo lives.',
+ text('bk-rpath', loc.ssh_path, '/srv/backups'))}
+ ${field('bk-port', 'SSH Port', '', ``)}
+ ${field('bk-auth', 'SSH Authentication', 'A key is managed by LibrePortal and needs nothing from you here.', `
+ `)}
+
+ ${field('bk-sshpass', 'SSH Password', 'Sent straight to this machine and stored where only LibrePortal can read it — never part of the task log.',
+ secret('bk-sshpass'))}
-
-
-
-
-
-
-
-
-
- Sent straight to this machine and stored where only LibrePortal can read it — it is never part of the task log.
+
+
+ ${field('bk-s3uri', 'Bucket', 'For example s3:s3.amazonaws.com/my-bucket.',
+ text('bk-s3uri', loc.uri, 's3:s3.amazonaws.com/my-bucket'))}
+ ${field('bk-s3key', 'S3 Access Key', '', text('bk-s3key', loc.s3_access_key))}
+ ${field('bk-s3secret', 'S3 Secret Key', 'Stored where only LibrePortal can read it.', secret('bk-s3secret'))}
+
+
+
+ ${field('bk-b2uri', 'Bucket', 'For example b2:my-bucket.', text('bk-b2uri', loc.uri, 'b2:my-bucket'))}
+ ${field('bk-b2id', 'B2 Account ID', '', text('bk-b2id', loc.b2_account_id))}
+ ${field('bk-b2key', 'B2 Account Key', 'Stored where only LibrePortal can read it.', secret('bk-b2key'))}
`;
const m = window.openEoModal({
@@ -859,31 +903,48 @@ class SetupWizard {
body,
actions: [
{ label: 'Cancel', variant: 'secondary' },
- { label: adding ? 'Add' : 'Save', variant: 'primary', keep: true, onClick: async () => {
- const root = document;
- const type = root.querySelector('#bk-type').value;
- const name = (root.querySelector('#bk-name').value || '').trim() || (type === 'local' ? 'Local disk' : type);
- const next = Object.assign({}, loc, { name, type });
+ { label: adding ? 'Add' : 'Save', variant: 'primary', keep: true,
+ onClick: async () => {
+ const v = (id) => (document.getElementById(id)?.value || '').trim();
+ const type = v('bk-type') || 'local';
+ const next = Object.assign({}, loc, {
+ type,
+ name: v('bk-name') || (type === 'local' ? 'Local disk' : type.toUpperCase())
+ });
+
+ // Only the secrets go through the drop; everything else is ordinary
+ // configuration and travels in the payload as itself.
+ const stash = async (id, key) => {
+ const raw = document.getElementById(id)?.value || '';
+ if (!raw) return true;
+ const ref = await this.stashSecret(raw);
+ if (!ref) return false;
+ next[key] = ref;
+ return true;
+ };
if (type === 'local') {
- next.path = (root.querySelector('#bk-path').value || '').trim();
- } else {
- next.ssh_host = (root.querySelector('#bk-host').value || '').trim();
- next.ssh_user = (root.querySelector('#bk-user').value || '').trim();
- next.ssh_path = (root.querySelector('#bk-rpath').value || '').trim();
- const pw = root.querySelector('#bk-pass').value || '';
- if (pw) {
- const ref = await this.stashSecret(pw);
- if (!ref) return; // stashSecret already reported why
- next.password_ref = ref;
- }
+ next.path = v('bk-path');
+ } else if (type === 'sftp') {
+ next.ssh_host = v('bk-host');
+ next.ssh_user = v('bk-user');
+ next.ssh_path = v('bk-rpath');
+ next.ssh_port = v('bk-port') || '22';
+ next.ssh_auth = v('bk-auth') || 'key';
+ if (next.ssh_auth === 'password' && !await stash('bk-sshpass', 'ssh_pass_ref')) return;
+ } else if (type === 's3') {
+ next.uri = v('bk-s3uri');
+ next.s3_access_key = v('bk-s3key');
+ if (!await stash('bk-s3secret', 's3_secret_ref')) return;
+ } else if (type === 'b2') {
+ next.uri = v('bk-b2uri');
+ next.b2_account_id = v('bk-b2id');
+ if (!await stash('bk-b2key', 'b2_key_ref')) return;
}
if (adding) {
this.backupLocations.push(next);
} else {
- // Mark it so the payload carries it: an existing destination is
- // only submitted when the user actually changed something.
next.dirty = true;
this.backupLocations[index] = next;
}
@@ -893,14 +954,21 @@ class SetupWizard {
]
});
- // Local and remote want different fields; swap them as the type changes.
+ // Show only the fields the chosen type actually has, and the SSH password
+ // only when password auth is selected.
const sync = () => {
- const type = document.querySelector('#bk-type').value;
- document.querySelector('#bk-local').style.display = type === 'local' ? '' : 'none';
- document.querySelector('#bk-remote').style.display = type === 'local' ? 'none' : '';
+ const type = document.getElementById('bk-type')?.value || 'local';
+ document.querySelectorAll('[data-bk-group]').forEach(g => {
+ g.style.display = g.dataset.bkGroup === type ? '' : 'none';
+ });
+ const auth = document.getElementById('bk-auth')?.value || 'key';
+ document.querySelectorAll('[data-bk-auth]').forEach(g => {
+ g.style.display = (type === 'sftp' && auth === 'password') ? '' : 'none';
+ });
};
- const sel = document.querySelector('#bk-type');
- if (sel) { sel.addEventListener('change', sync); sync(); }
+ document.getElementById('bk-type')?.addEventListener('change', sync);
+ document.getElementById('bk-auth')?.addEventListener('change', sync);
+ sync();
}
// Hand a secret to the host and get back a reference to put in the payload.
diff --git a/scripts/dev/lp-backup-dialog-test b/scripts/dev/lp-backup-dialog-test
new file mode 100755
index 0000000..3c9a46a
--- /dev/null
+++ b/scripts/dev/lp-backup-dialog-test
@@ -0,0 +1,130 @@
+#!/bin/bash
+# Drive the wizard's "Add a backup destination" dialog in a real browser.
+#
+# scripts/dev/lp-backup-dialog-test # needs a running WebUI
+#
+# Everything here is behind a click, which is why it needs driving rather than
+# reading. The step shipped once with its add button carrying a class that does
+# not exist (.setup-add-domain — the real one is .setup-domain-add), so it
+# rendered as a bare browser button in the middle of a styled form, and nothing
+# that inspected the DOM structurally noticed.
+#
+# The assertion that matters most is the last: a credential typed here must
+# leave as a REFERENCE. The wizard payload is base64'd into a task's command
+# string and tasks are recorded world-readable, so a secret travelling as itself
+# would be readable by any local account.
+#
+# One page load, because a cold SPA boot is slow: the whole interaction runs in
+# a single `lp-shot --eval` and reports one JSON blob for bash to assert on.
+
+REPO="$(cd "$(dirname "$0")/../.." && pwd)"
+SHOT="$REPO/scripts/dev/lp-shot"
+fail=0
+chk(){ if [[ "$2" == "$3" ]]; then echo " ok $1"; else echo " FAIL $1: got '$2' want '$3'"; fail=1; fi; }
+command -v jq >/dev/null 2>&1 || { echo " SKIP jq not installed"; exit 0; }
+
+read -r -d '' DRIVE <<'JS'
+ const DUMMY = 'dummy-not-a-real-secret-0000';
+ const out = {};
+ const wait = ms => new Promise(r => setTimeout(r, ms));
+ const groupsShown = () => ['local','sftp','s3','b2']
+ .filter(g => {
+ const el = document.querySelector(`[data-bk-group="${g}"]`);
+ return el && el.style.display !== 'none';
+ });
+
+ const add = document.getElementById('sw-backup-add');
+ if (!add) return JSON.stringify({ error: 'add button missing' });
+ // "Is it styled" cannot be a fixed colour (themes) nor "is it transparent"
+ // (a native button is grey, not transparent). Compare it against a bare
+ // button dropped into the same parent: if nothing differs, no rule matched
+ // and the class in the markup is one the stylesheet never defines.
+ {
+ const bare = document.createElement('button');
+ bare.type = 'button';
+ add.parentElement.appendChild(bare);
+ const a = getComputedStyle(add), b = getComputedStyle(bare);
+ out.addButtonStyled = ['background-color','border-radius','color','padding']
+ .some(prop => a.getPropertyValue(prop) !== b.getPropertyValue(prop));
+ out.addButtonBg = a.backgroundColor;
+ bare.remove();
+ }
+ out.rowsBefore = document.querySelectorAll('[data-backup-edit]').length;
+
+ add.click();
+ await wait(800);
+ const type = document.getElementById('bk-type');
+ out.dialogOpen = !!type;
+ out.typeEnhanced = !!(type && type.closest('.custom-select'));
+ out.typeOptions = type ? [...type.options].map(o => o.value) : [];
+ out.labelsStyled = !!document.querySelector('.setup-field label');
+ out.groupsAtOpen = groupsShown();
+
+ // Each type shows only its own fields.
+ out.swap = {};
+ for (const want of ['sftp','s3','b2','local']) {
+ type.value = want; type.dispatchEvent(new Event('change'));
+ await wait(120);
+ out.swap[want] = groupsShown();
+ }
+
+ // The SSH password appears only for password auth.
+ type.value = 'sftp'; type.dispatchEvent(new Event('change'));
+ await wait(150);
+ const auth = document.getElementById('bk-auth');
+ out.pwWithKey = document.querySelector('[data-bk-auth="password"]').style.display !== 'none';
+ auth.value = 'password'; auth.dispatchEvent(new Event('change'));
+ await wait(150);
+ out.pwWithPassword = document.querySelector('[data-bk-auth="password"]').style.display !== 'none';
+ out.pwInputType = (document.getElementById('bk-sshpass') || {}).type || null;
+
+ // Fill it in and submit.
+ document.getElementById('bk-name').value = 'Offsite';
+ document.getElementById('bk-host').value = 'backup.example.org';
+ document.getElementById('bk-user').value = 'lp';
+ document.getElementById('bk-rpath').value = '/srv/lp';
+ document.getElementById('bk-sshpass').value = DUMMY;
+ [...document.querySelectorAll('button')].find(b => b.textContent.trim() === 'Add').click();
+ await wait(2000);
+
+ out.rowsAfter = document.querySelectorAll('[data-backup-edit]').length;
+ out.listsOffsite = /Offsite/.test((document.getElementById('sw-backup-dests') || {}).innerText || '');
+ out.leaksInDom = document.body.innerHTML.includes(DUMMY);
+ return JSON.stringify(out);
+JS
+
+OUT=$("$SHOT" --eval "/?step=4" "$DRIVE" 2>/dev/null)
+if [[ -z "$OUT" ]] || ! jq -e . >/dev/null 2>&1 <<< "$OUT"; then
+ echo " SKIP no WebUI reachable, or the step did not load"
+ exit 0
+fi
+if [[ "$(jq -r '.error // ""' <<< "$OUT")" != "" ]]; then
+ echo " FAIL $(jq -r .error <<< "$OUT")"; exit 1
+fi
+
+echo "--- the dialog opens, styled ---"
+chk "dialog open" "$(jq -r .dialogOpen <<< "$OUT")" "true"
+chk "type enhanced" "$(jq -r .typeEnhanced <<< "$OUT")" "true"
+chk "all four backends" "$(jq -r '.typeOptions | join(",")' <<< "$OUT")" "local,sftp,s3,b2"
+chk "labels styled" "$(jq -r .labelsStyled <<< "$OUT")" "true"
+chk "opens on local" "$(jq -r '.groupsAtOpen | join(",")' <<< "$OUT")" "local"
+chk "add button is styled" "$(jq -r .addButtonStyled <<< "$OUT")" "true"
+
+echo "--- each type shows only its own fields ---"
+for ty in local sftp s3 b2; do
+ chk "$ty" "$(jq -r --arg t "$ty" '.swap[$t] | join(",")' <<< "$OUT")" "$ty"
+done
+
+echo "--- the SSH password follows the auth choice ---"
+chk "hidden for key auth" "$(jq -r .pwWithKey <<< "$OUT")" "false"
+chk "shown for password auth" "$(jq -r .pwWithPassword <<< "$OUT")" "true"
+chk "masked" "$(jq -r .pwInputType <<< "$OUT")" "password"
+
+echo "--- submitting adds it, and the credential does not stay behind ---"
+chk "a row was added" "$(jq -r '(.rowsAfter - .rowsBefore)' <<< "$OUT")" "1"
+chk "listed by name" "$(jq -r .listsOffsite <<< "$OUT")" "true"
+chk "not left in the DOM" "$(jq -r .leaksInDom <<< "$OUT")" "false"
+
+echo ""
+if (( fail )); then echo "FAILED"; exit 1; fi
+echo "All backup-dialog checks passed."
diff --git a/scripts/dev/lp-shot b/scripts/dev/lp-shot
index 66dae89..395f74b 100755
--- a/scripts/dev/lp-shot
+++ b/scripts/dev/lp-shot
@@ -13,6 +13,9 @@ Arguments (all optional after the route):
Environment:
LP_SHOT_URL base URL of the WebUI (default: auto-detected, else http://localhost:3179)
LP_SHOT_VIEWPORT WIDTHxHEIGHT (default 1440x900)
+ --eval ROUTE JS run JS in the page and print the result; no screenshot
+ LP_SHOT_EVAL JS run in the page before capture — open a dialog, pick a
+ tab, expand a row. Awaited, so async handlers finish.
LP_SHOT_SCALE device pixel ratio (default 2 — that's the "crisp")
LP_SHOT_SETTLE extra seconds after load (default 1.5)
LP_SHOT_CHROME chromium binary to use (default: first found on PATH)
@@ -380,10 +383,27 @@ def main():
print(__doc__.strip())
sys.exit(0 if len(sys.argv) > 1 else 2)
- route = sys.argv[1]
- out = os.path.abspath(sys.argv[2]) if len(sys.argv) > 2 else DEFAULT_OUT
- pad = float(sys.argv[3]) if len(sys.argv) > 3 else 0.0
- selector = sys.argv[4] if len(sys.argv) > 4 else None
+ # --eval: drive the page and print what the expression returns, instead of
+ # taking a picture.
+ #
+ # A screenshot shows a route; it cannot assert anything about a dialog, and
+ # a dialog is state you reach by clicking. Without this, anything behind a
+ # click gets checked by reading the source and hoping — which is how a step
+ # ships with a button styled by a class that does not exist.
+ #
+ # lp-shot --eval /route 'document.querySelectorAll("x").length'
+ eval_mode = sys.argv[1] == "--eval"
+ if eval_mode:
+ if len(sys.argv) < 4:
+ die("usage: lp-shot --eval ")
+ route = sys.argv[2]
+ eval_expr = sys.argv[3]
+ out, pad, selector = None, 0.0, None
+ else:
+ route = sys.argv[1]
+ out = os.path.abspath(sys.argv[2]) if len(sys.argv) > 2 else DEFAULT_OUT
+ pad = float(sys.argv[3]) if len(sys.argv) > 3 else 0.0
+ selector = sys.argv[4] if len(sys.argv) > 4 else None
base = base_url()
url = route if re.match(r"^https?://", route) else base + "/" + route.lstrip("/")
@@ -459,6 +479,27 @@ def main():
die(AUTH_HELP)
time.sleep(settle)
+ # LP_SHOT_EVAL: run something in the page before capturing.
+ #
+ # A screenshot answers "does this route render". It cannot answer "does
+ # this dialog look right", because a dialog is state you reach by
+ # clicking — so verifying one meant driving a real browser by hand, and
+ # anything only reachable that way tends to get checked structurally
+ # and never actually looked at. This lets a capture open the thing
+ # first. Awaited, so an async handler finishes before the shutter.
+ if eval_mode:
+ # Awaited, so an async body finishes before the value is read.
+ print(cdp.eval(f"(async () => {{ {eval_expr} }})()"))
+ return
+
+ pre = os.environ.get("LP_SHOT_EVAL")
+ if pre:
+ try:
+ cdp.eval(f"(async () => {{ {pre} }})()")
+ except Exception as exc:
+ print(f" LP_SHOT_EVAL failed: {exc}", file=sys.stderr)
+ time.sleep(settle if settle else 0.6)
+
for e in cdp.events:
if e["method"] == "Log.entryAdded" and e["params"]["entry"].get("level") == "error":
print(f" page error: {e['params']['entry'].get('text')}", file=sys.stderr)