package web import ( "net/http" "net/url" "strings" "time" "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 `
` 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 op status // is process-wide, so a restore running for app X must suppress app Y's controls. // // Source it via restoreOpInFlight (the DISPLAY flag), never Manager.IsRunning() — see the note // on that helper. Reading the wrong flag makes this field silently always-false for the // verification restore, which is the wizard's most-used path. 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 // HasRecentResult is true when THIS app's restore finished a short while ago — see // hasRecentRestoreResult. It moves the phase strip to „Eredmény"; it never changes what the // customer may do (a finished restore leaves every intent available again). HasRecentResult bool } // 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 // Phase is which label the phase strip highlights. It is NOT the same as Step: a finished restore // is back on the intent step (everything is offered again) while the strip rightly says // „Eredmény". Keeping them separate is what stopped the strip from having to lie in one direction // or the other. Phase restoreWizardPhase // 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 } // restoreWizardPhase is the phase-strip highlight. Four labels, all reachable. type restoreWizardPhase string const ( wizPhasePrepare restoreWizardPhase = "elokeszites" wizPhaseConfirm restoreWizardPhase = "megerosites" wizPhaseExecute restoreWizardPhase = "vegrehajtas" wizPhaseResult restoreWizardPhase = "eredmeny" ) // restoreResultWindow bounds how long after a finished restore the strip still says „Eredmény". // Without a bound the last result would light that phase forever — landing on the page a week later // would claim you had just finished a restore. Same reasoning as escrowCeremonyGraceWindow; shorter, // because this answers "what just happened", not "are we still waiting". const restoreResultWindow = 10 * time.Minute // hasRecentRestoreResult reports whether THIS app has a just-finished restore to show. Pure (the // clock is a parameter) so the boundary and the wrong-app case are table-testable. // // Bound to the app on purpose: the op status is process-wide, so a finished bookstack restore must // not light „Eredmény" on immich's wizard and show bookstack's message there. func hasRecentRestoreResult(st backup.RestoreOpStatus, app string, now time.Time) bool { if st.Running || st.Last == nil || st.Last.Stack != app || st.Last.FinishedAt.IsZero() { return false } d := now.Sub(st.Last.FinishedAt) return d >= 0 && d < restoreResultWindow } // 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, Phase: wizPhaseExecute} } if in.FullPrepApp != "" && in.FullPrepApp == in.App { return restoreWizardView{Step: wizStepPrepareConfirm, Phase: wizPhaseConfirm, CommitPrepareEnabled: true} } phase := wizPhasePrepare if in.HasRecentResult { phase = wizPhaseResult } return restoreWizardView{ Step: wizStepIntent, Phase: phase, 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 } // restoreOpInFlight reports whether a restore op is in flight, FOR DISPLAY. // // **Use this, not `Manager.IsRunning()`.** The Manager carries two different booleans and they are // not interchangeable: // // - `m.running` (read by `IsRunning`) is the CONCURRENCY single-flight. It is acquired *inside* // the restore function, on the background goroutine — and `RestoreOffboxScratch` never acquires // it at all. So for the verification restore and the full-restore preparation — the wizard's two // most-used actions, and the long ones, since they stream from restic — `IsRunning()` is false // for the entire operation. // - `m.opRunning` (read by `RestoreStatus`) is the DISPLAY flag, set synchronously by // `BeginRestoreOp` in the handler *before* the goroutine launches and cleared by `EndRestoreOp`. // It covers all four offsite actions with no start-up window. // // v0.154.0 shipped with `IsRunning()` here, which made the execution step unreachable for // `RestoreOffboxScratch`: the page offered all three intents, with live buttons, while a restore was // downloading — and the progress banner (which polls the op status) contradicted it on the same // screen. Caught by the operator on the first live click-through. func restoreOpInFlight(st backup.RestoreOpStatus) bool { return st.Running } // backupsRestoreWizardHandler renders GET /backups/restore/app?name= — 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) st := s.backupMgr.RestoreStatus() in := restoreWizardInput{ App: app, OpRunning: restoreOpInFlight(st), ScratchReady: s.backupMgr.OffboxFullScratchReady(app), FullPrepApp: strings.TrimSpace(r.URL.Query().Get("full_prep")), HasRecentResult: hasRecentRestoreResult(st, app, time.Now()), } 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. data["RunningStack"] = st.Stack // The outcome card for the „Eredmény" phase — the same message the redirect flash carried, but it // survives a reload, which the flash does not. if in.HasRecentResult { data["LastResult"] = st.Last } 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) }