3a9d744360
The offsite restore list rendered up to five inline forms per app row. Two of them —
„Helyreállítás az élő adatok közé (csak a hiányzó fájlok)" and „Teljes visszaállítás
(fájlok + adatbázis)" — were sibling buttons whose difference is whether the customer's
data comes back at all. That mis-selection CAUSED the round-2 incident: an operator who
had read the source pressed the missing-only button and /backup/offbox/reconstitute was
never hit (DIAG-immich-restore-round2-2026-07-19, finding 1).
The rule this establishes: two adjacent controls whose difference is "your data comes
back" vs "your data cannot come back" must never be distinguishable only by layout.
Each row now carries ONE „Visszaállítás…" entry linking to a per-app wizard at
GET /backups/restore/app?name=<app>: three intent CARDS with consequence sentences, a
visible phase strip, danger styling plus the R-43 double-confirm carried over verbatim on
the destructive one, and the pair-honesty panel bound to real OffsiteScratchPair facts.
deriveWizardStep is pure over (op running, size-gate flash, scratch ready); the step is
never accepted from the request, and a running op outranks a stale ?full_prep= so no
commit button survives into a restore. While ANY op runs every mutation form is suppressed
server-side instead of being offered and refused.
No new mutation endpoint: every card posts to the pre-existing /backup/offbox/* handler
with the same field names and gates, and the page works with JavaScript disabled.
internal/{backup,appbackup,selfupdate} untouched. R-45's job registry stays its own item.
Fixes a latent bug found on the way: offboxRedirectTo hardcoded "?" when appending its
flash, which against the wizard's ?name=<app> target would have buried the flash inside
the name value.
No new agent coupling — MinAgent stays 0.90.0.
191 lines
8.8 KiB
Go
191 lines
8.8 KiB
Go
package web
|
|
|
|
import (
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
|
)
|
|
|
|
// R-48 — the offsite restore wizard.
|
|
//
|
|
// WHY THIS EXISTS. Until v0.153.0 the „Ellenőrző visszaállítás a távoli tárolóból" list rendered up
|
|
// to five inline `<form style="display:inline">` blocks per app row. Two of them —
|
|
// „Helyreállítás az élő adatok közé (csak a hiányzó fájlok)" and
|
|
// „Teljes visszaállítás (fájlok + adatbázis)" — sat next to each other as sibling buttons, and the
|
|
// difference between them is whether the customer's data comes back at all. The missing-only merge
|
|
// cannot resurrect deleted content; the reconstitution can. An operator who had read the source
|
|
// still pressed the wrong one (audits/DIAG-immich-restore-round2-2026-07-19.md, finding 1): the
|
|
// controller log shows /backup/offbox/reconstitute was never hit.
|
|
//
|
|
// The rule R-48 establishes, worth stating where it is implemented: TWO ADJACENT CONTROLS WHOSE
|
|
// DIFFERENCE IS "YOUR DATA COMES BACK" VS "YOUR DATA CANNOT COME BACK" MUST NOT BE DISTINGUISHABLE
|
|
// ONLY BY LAYOUT. So the list page now carries ONE entry per app — „Visszaállítás…" — and this
|
|
// wizard makes the intent an explicit, separately-described choice.
|
|
//
|
|
// It is deliberately a THIN surface: no new mutation endpoint, no job registry (that is R-45), no
|
|
// JSON state API. Every step posts to the endpoint the old buttons posted to, with the same field
|
|
// names, and the server re-renders the next step. The only client-side JS is the card reveal the
|
|
// escrow wizard already established plus the EXISTING confirm helper and status poll.
|
|
|
|
// restoreWizardStep is which phase of the wizard the server decided to render. It is derived, never
|
|
// stored and never accepted from the request — a customer cannot navigate to a step whose
|
|
// preconditions do not hold.
|
|
type restoreWizardStep string
|
|
|
|
const (
|
|
// wizStepIntent is the choice: verify / bring back missing files / full restore.
|
|
wizStepIntent restoreWizardStep = "intent"
|
|
// wizStepPrepareConfirm is the size-gate reveal — the full-restore preparation ran, reported a
|
|
// size, and the customer confirms before the download starts.
|
|
wizStepPrepareConfirm restoreWizardStep = "prepare-confirm"
|
|
// wizStepExecution is "something is running" — every mutation form is suppressed SERVER-SIDE,
|
|
// because the backup manager's single-flight would refuse them anyway and offering a control
|
|
// that is guaranteed to fail is exactly the class of dishonesty R-48 is about.
|
|
wizStepExecution restoreWizardStep = "execution"
|
|
)
|
|
|
|
// restoreWizardInput is the complete set of facts the step derivation is allowed to see. Keeping it
|
|
// a plain struct (rather than reading off the Server) is what makes deriveWizardStep a pure,
|
|
// table-testable function — the Scenario-B table in restore_wizard_test.go is this struct's
|
|
// truth table.
|
|
type restoreWizardInput struct {
|
|
// App is the app this wizard page is for.
|
|
App string
|
|
// OpRunning is true when ANY backup/restore op is in flight — not just this app's. The
|
|
// single-flight is process-wide, so a restore running for app X must suppress app Y's controls.
|
|
OpRunning bool
|
|
// ScratchReady is true when a completed full-restore scratch exists for App. Both the
|
|
// missing-only merge and the true reconstitution require one.
|
|
ScratchReady bool
|
|
// FullPrepApp is the app the size-gate flash binds to (from ?full_prep=). It binds to ONE app:
|
|
// a prepare for X must not reveal a confirm on Y's page.
|
|
FullPrepApp string
|
|
}
|
|
|
|
// restoreWizardView is what the template renders. The enabled-flags are part of the derivation (not
|
|
// separate template conditionals) so that the whole "what may the customer do right now" decision is
|
|
// one pure function with one test table.
|
|
type restoreWizardView struct {
|
|
Step restoreWizardStep
|
|
// VerifyEnabled — intent 1: restore into a separate verification folder (mode=unit). Live data
|
|
// is untouched, so this is the only intent available without a prepared scratch.
|
|
VerifyEnabled bool
|
|
// PrepareEnabled — intent 3, first leg: no scratch yet, so the full restore must first be
|
|
// prepared (mode=full, size-gated).
|
|
PrepareEnabled bool
|
|
// PlaceEnabled — intent 2: missing-only merge (/backup/offbox/place). Needs a prepared scratch.
|
|
PlaceEnabled bool
|
|
// RestoreEnabled — intent 3, second leg: the TRUE restore (/backup/offbox/reconstitute). Needs a
|
|
// prepared scratch.
|
|
RestoreEnabled bool
|
|
// CommitPrepareEnabled — the revealed „Teljes visszaállítás indítása (~méret)" confirm
|
|
// (mode=full&confirm=1). Only on the prepare-confirm step.
|
|
CommitPrepareEnabled bool
|
|
}
|
|
|
|
// deriveWizardStep is the Scenario-B truth table: step and available intents are a PURE function of
|
|
// the state, in strict precedence order.
|
|
//
|
|
// 1. An op is running (any app) → execution; nothing is offered.
|
|
// 2. The size-gate flash is for THIS app → prepare-confirm; only the commit is offered.
|
|
// 3. Otherwise → intent. Verification is always available; the two data-touching intents unlock
|
|
// only with a prepared scratch, and without one the full-restore card offers preparation
|
|
// instead.
|
|
//
|
|
// Precedence matters: execution outranks the flash, because a stale ?full_prep= in the URL must
|
|
// never resurrect a commit button while a restore is mid-flight.
|
|
func deriveWizardStep(in restoreWizardInput) restoreWizardView {
|
|
if in.OpRunning {
|
|
return restoreWizardView{Step: wizStepExecution}
|
|
}
|
|
if in.FullPrepApp != "" && in.FullPrepApp == in.App {
|
|
return restoreWizardView{Step: wizStepPrepareConfirm, CommitPrepareEnabled: true}
|
|
}
|
|
return restoreWizardView{
|
|
Step: wizStepIntent,
|
|
VerifyEnabled: true,
|
|
PrepareEnabled: !in.ScratchReady,
|
|
PlaceEnabled: in.ScratchReady,
|
|
RestoreEnabled: in.ScratchReady,
|
|
}
|
|
}
|
|
|
|
// resolveWizardApp finds the wizard's app in the offsite-toggled set — the same gating the list page
|
|
// applies. Pure, so the two refusal rows (unknown app, app present but NOT toggled for offsite) are
|
|
// table-testable without a live backup manager.
|
|
//
|
|
// An app that is not toggled has no offsite snapshot to restore FROM, so its wizard would be a page
|
|
// of controls that cannot work. Both refusals return nil and the caller redirects — a customer-visible
|
|
// URL that survives a bookmark, an app rename or a toggle being switched off must never 500.
|
|
func resolveWizardApp(rows []OffboxAppRow, name string) *OffboxAppRow {
|
|
for _, a := range rows {
|
|
if a.Name == name && a.Enabled {
|
|
cp := a
|
|
return &cp
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// backupsRestoreWizardHandler renders GET /backups/restore/app?name=<app> — the single entry the
|
|
// list page now offers per app.
|
|
//
|
|
// Unknown app, an app that is not toggled for offsite backup, or an unconfigured offsite target all
|
|
// redirect back to the list with a Hungarian flash. They must never 500: the URL is customer-visible
|
|
// and survives a bookmark, an app rename and a toggle being switched off.
|
|
func (s *Server) backupsRestoreWizardHandler(w http.ResponseWriter, r *http.Request) {
|
|
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
|
|
offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true)
|
|
return
|
|
}
|
|
app := strings.TrimSpace(r.URL.Query().Get("name"))
|
|
if app == "" {
|
|
offboxRedirectTo(w, r, "/backups/restore", "Hiányzó alkalmazás.", true)
|
|
return
|
|
}
|
|
|
|
row := resolveWizardApp(s.buildOffboxApps(), app)
|
|
if row == nil {
|
|
offboxRedirectTo(w, r, "/backups/restore", "Ez az alkalmazás nincs távoli mentésre kijelölve.", true)
|
|
return
|
|
}
|
|
|
|
data := s.backupsCommonData("backups-restore", "Visszaállítás — "+row.DisplayName, r)
|
|
|
|
in := restoreWizardInput{
|
|
App: app,
|
|
OpRunning: s.backupMgr.IsRunning(),
|
|
ScratchReady: s.backupMgr.OffboxFullScratchReady(app),
|
|
FullPrepApp: strings.TrimSpace(r.URL.Query().Get("full_prep")),
|
|
}
|
|
view := deriveWizardStep(in)
|
|
|
|
data["App"] = app
|
|
data["AppDisplayName"] = row.DisplayName
|
|
data["AppSlug"] = row.Slug
|
|
data["Wizard"] = view
|
|
data["FullPrepSize"] = strings.TrimSpace(r.URL.Query().Get("full_size"))
|
|
// The pair-honesty panel (R-43): how old the database half is, whether the two halves come from
|
|
// the same run, and whether the dump looks customer-empty. Only meaningful once a scratch exists.
|
|
if in.ScratchReady {
|
|
pair := s.backupMgr.OffsiteScratchPair(app)
|
|
data["Pair"] = pair
|
|
} else {
|
|
data["Pair"] = backup.OffsitePairInfo{}
|
|
}
|
|
// The running op's identity, so the execution card can say WHAT is running rather than a bare
|
|
// "please wait" — including the case where it belongs to a different app.
|
|
st := s.backupMgr.RestoreStatus()
|
|
data["RunningStack"] = st.Stack
|
|
|
|
s.executeTemplate(w, r, "backups_restore_wizard", data)
|
|
}
|
|
|
|
// restoreWizardPath builds the wizard URL for an app. Handlers redirect here after an app-scoped
|
|
// mutation so the customer lands back on the surface they acted from, not on the list.
|
|
func restoreWizardPath(app string) string {
|
|
return "/backups/restore/app?name=" + url.QueryEscape(app)
|
|
}
|