5 Commits

Author SHA1 Message Date
librelad
cf8a4b2c69 Find the backup before asking for its password
The Backup step opened with an empty box and /mnt/usb/libreportal-backups as
the placeholder — a path nobody has, presented as the shape of the answer.
Someone rebuilding a server was being asked to recall from memory the one thing
they came here because they had lost.

Two additions, and the point of both is that neither needs the repository
password. A restic repository keeps one file per snapshot under snapshots/, so
"is there a backup here, and how many" is a directory listing. Nothing is
decrypted — reading what is IN those snapshots is the next step, and that does
need the password.

restore scan looks where a backup actually is: this install's own backups root
(the disk often survives), every location the install already knows about, and
one level under each non-OS mount, a just-plugged-in drive being the other half
of "the system drive died". Bounded to named shapes and maxdepth 1, never a
filesystem walk — a scan nobody waits for is a scan nobody uses. Results are
buttons, most snapshots first, each showing its count and the age of its
newest snapshot; clicking one fills the path in.

restore verify <path> answers the same for a typed path. Its most useful answer
is the near-miss: pointing at the folder that CONTAINS the repositories rather
than at one of them, which it names and offers as a button rather than
explaining the distinction in prose.

A repository is recognised by config plus the snapshots, keys and data
directories together. config alone would match any folder with a file of that
name, and offering a stray directory as someone's backup is worse than finding
nothing.

The placeholder now comes from this machine — the first repository found, or
the install's own backups root — since a placeholder's job is to show the shape
of the answer and only a real one does that. The backups root is in the storage
feed for it.

The found entries are buttons and had to own their geometry: .setup-app-card
carries no layout, it is a bare wrapper elsewhere, so a <button> wearing it
collapsed to one cramped line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 07:04:15 +01:00
librelad
9bb9ed79a9 Storage defaults: hang them off the mount, not the app-data path
The advanced Storage step offered /mnt/disk/apps/libreportal-system as the
default home for LibrePortal's own tree. A registered location's path is where
APP DATA goes and is usually a subdirectory of the drive, so deriving anything
else from it nests that thing inside the app data — LibrePortal's own files
buried under it, on a path that reads as a mistake because it is one.

Both defaults now come off the location's mount point, which meant adding
"mount" to each entry in the storage feed; only the system block carried one.

  LibrePortal                    /mnt/disk/apps/libreportal-system
                              -> /mnt/disk/libreportal-system
  New apps, unregistered drive   /mnt/disk
                              -> /mnt/disk/libreportal-apps
  New apps, registered location  unchanged — it exists and may hold data, and
                                 proposing a different directory on the same
                                 drive would strand it

Names follow the layout the rest of the product uses (libreportal-system,
libreportal-containers, libreportal-backups) rather than a bare "apps", so a
drive shared with anything else stays legible.

collectStorage() no longer registers the drive picked for LibrePortal. A
storage location is somewhere app data lives; the system tree is not app data
and relocate creates that directory itself as root. Picking a drive there was
producing a location nobody asked for, on a mount chosen for something else.

Also in this change, from the Backup step:

  - The backend-specific fields are boxed under their own heading with a note,
    so choosing SFTP reveals "the SFTP part" rather than three more loose rows.

  - Fields had no vertical spacing. .setup-step gives its DIRECT children a
    16px gap, which is where every other step's fields get theirs; these sit a
    level deeper inside a .setup-section and inherited none of it, so each
    input ran into the next field's label.

  - Two field icons carried U+FE0F. Those codepoints have a text form and the
    selector only requests the emoji one, so they sat on a different baseline
    to the plain emoji beside them — the box measured perfectly centred while
    the glyph did not look it.

  - ?mode=restore&type=sftp makes the restore branch reachable by URL. Getting
    there previously took a click and a change event, so every screenshot and
    test had to drive the page before it could look at it.

Two test bugs fixed while doing it: a duplicate `const visible` in one scope
(a parse error, so the whole eval silently returned nothing), and a stub that
covered the POST but not the poll, leaving a 60s loop running that kept the
page from ever going network-idle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 06:48:03 +01:00
librelad
42afc20ee0 Backup step: the wizard's own field layout, and a name that matches
Two things about the restore source step were wrong.

The progress bar said "Source" while the heading said "Where is your backup?",
which reads as two different steps. Every other step's section title is its step
name — Storage, Backups, Import — so this one is "Backup" in both places, with
the friendly question moved to the hint where the rest of the wizard puts it.
Contents and Rebuild got the same treatment.

And the fields used the Storage step's label-left rows. That layout suits a
column of dropdowns; a form of typed values in the middle of a wizard that
looks nothing like the rest of it just reads as unfinished. They now use the
same shape as Identity: a label with a tooltip, then an icon beside the input.
Every field has both, including the password.

On automating the relocate: it cannot be a WebUI action, and the reason is the
thing the privilege model rests on. Root helpers have their paths baked at
install so the manager cannot redirect a privileged operation by editing
something it owns; relocating re-bakes those paths, so a helper that did it
from a caller-supplied path would hand the manager the whole trust boundary.
Narrowing to "registry targets only" does not help either — the manager can add
to that registry by design. libreportal-relocate says this at the top and is
deliberately outside the manager's sudoers.

What was fixable is the part that actually annoyed — being handed a command
with no idea whether it worked:

  - Copy button, with a fallback that selects the text and names the keys,
    because clipboard access needs a secure context and a LAN install on
    http:// is not one.
  - The pending move persists to localStorage, not session state: it happens in
    a terminal minutes or days later, after the tab is gone.
  - A watcher on every page shows the outstanding command, polls for the move
    landing, and reloads to the homepage — whatever route you were on belonged
    to the old install.

"Landed" means the host REPORTS its system dir as the target, which is why
system_dir is now in storage.json. Deliberately not "the server restarted": an
ordinary container restart is indistinguishable, and announcing a relocation
that never happened is worse than saying nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 06:10:27 +01:00
librelad
429ec3fa2d Contents step: show the two snapshot kinds as two things
A repository holds one system=config snapshot and one snapshot per app. They
are separate because they are used separately: the settings tree is small,
changes rarely and is meaningless per-app, while app data is large, changes at
its own rate, and has to be restorable, movable and ageable on its own — which
is what the per-app tag buys.

The step listed "Apps" and "Domains" as peer sections, which hid that entirely.
It read as though a backup held three kinds of thing, and gave no clue that the
domains come OUT of the system snapshot rather than being a third kind.

Now: a Settings section (one snapshot, dated, saying plainly that it carries
every backup repository with its credentials and is restored first because it
is what makes the others reachable) with the domains nested under it and each
one's DNS verdict; then an App data section, one snapshot each, dated and
sized. A repository with app data and no settings snapshot says so — the
consequence, that repositories and logins do not come back, is not something to
find out afterwards.

restoreInspect emits that shape now, assembled with jq against the discover
JSON rather than by hand-rolled string concatenation, and carries each
snapshot's date: a backup's age is what people actually judge it by.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 05:51:24 +01:00
librelad
a361e38562 Wizard: New install or Restore from backup
The wizard's first question is now "is this a new server, or a replacement for
one?", which §2 of the roadmap described and nothing implemented. Start asks,
and the answer selects one of two disjoint step sets:

  new      Start > Experience > Identity > Domains > Storage > Backups
                 > Import > Recommended > (Metrics)
  restore  Start > Source > Contents > Rebuild

Disjoint deliberately. A restore is never asked for an install name, domains or
an app list — the backup answers all three, and asking invites someone to type
an answer that is about to be written over. The test asserts non-overlap in
both directions, not just that the restore steps appear.

Source collects the repository the way the Backup page does, minus everything
that only means something for a place you write TO: no retention, no schedule,
no enable toggle. The password leaves through the one-shot secret:<ref> channel
and is cleared from the DOM, and the test asserts the value never appears in
the payload — that payload reaches a task command line, and tasks are recorded
world-readable.

Contents is the reconciliation, rendered: apps with sizes, and each domain with
a verdict, checked through the same /api/setup/dns-check the Domains step uses
rather than adding a second way to ask. Plus the offer to leave the strays out
until DNS is repointed.

Rebuild runs `restore rebuild`: settings first (they carry every other
repository's credentials), then domains, then apps with no explicit list so
bulk discovers and re-preflights them itself.

Inserting Start shifted every step index by one. validateStep was a chain of
idx === 1 … idx === 6, carrying a comment that already explained which earlier
insertions had moved them — it is keyed on the step name now.
lp-storage-step-test had the same pin and did not survive: it called
validateStep(3) for Storage, which had become Domains, and reported that
nothing blocked. That reads exactly like validation being broken. Tests look
their step up by name now too.

Also: locationRemove's fix means a failed connect can finally clean up after
itself, so a wrong password no longer leaves a dead destination behind on every
retry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 05:04:43 +01:00