package web import ( "context" "net/http" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" ) // R-193 — THE RECOVERY SCREEN. A customer whose machine was rebuilt has everything they need to get // their data back and, until this page, no way to find out: the only route was a command line. // // IT UNLOCKS, AND ONLY UNLOCKS (operator ruling, 2026-08-05). It explains the situation, takes the // recovery code, opens the repository, and shows what is in there. It does NOT restore anything. // Restoring is already per-app and already lives in the backups area; putting files back is a // separate item. A screen that unlocks and then offers to overwrite is two decisions wearing one // button. // // THREE WAYS OUT, and none of them is a dismiss button: // - RECOVER — the main path (/recovery/unlock). // - MOST NEM — the full page stops interrupting; the backups-area entry point stays PERMANENTLY, // because the data is still there whether or not anyone clicked (/recovery/postpone). // - I DO NOT WANT THE OLD DATA — deliberate, explained, confirmed TWICE, and it reaches the // SHIPPED move-aside (`/backup/offbox/reset`), which sets the store aside and never deletes. // // THE RECOVERY CODE IS HANDLED NO MORE LOOSELY THAN ON THE COMMAND LINE (§8.3): POST body only, never // a query string, never logged at any level, never persisted, never echoed back, cleared on every // path, and the page is served no-store with autocomplete off. // recoveryOffer reports whether this box is in the situation the screen exists for. The predicate and // its two shapes live in backup.OffsiteRecoveryOffer — read its header before changing anything here. func (s *Server) recoveryOffer() bool { // CLAIMED AND BEHIND THE PASSWORD (§8.1). Before claiming there is no customer, and this page // states metadata about the household's own backups — accepted as metadata rather than content // (operator ruling 2026-08-05), which is only true while it sits behind the household password. // A legacy-open box (no password anywhere) reaches ServeHTTP through RequireAuth's pass-through, // so WITHOUT this line the interception would fire on an unauthenticated visitor. Caught by // TestRecovery_B_DoesNotAppearForAnyoneElse/not_claimed, which failed before it was added. if !s.authEnabled() { return false } return s.backupMgr != nil && s.backupMgr.OffsiteRecoveryOffer() } // recoveryInterrupts reports whether the FULL PAGE should take over the landing pages. "Most nem" // suppresses this and nothing else — recoveryOffer stays true, so the backups-area entry point // survives. That asymmetry is the whole of Scenario E. func (s *Server) recoveryInterrupts() bool { if !s.recoveryOffer() { return false } return s.settings == nil || !s.settings.GetRecoveryNoticePostponed() } // recoveryNoStore stamps the page uncacheable. The rendered page carries no secret, but it does carry // the form the code is typed into, and a cached copy of a recovery form is a form served from disk. func recoveryNoStore(w http.ResponseWriter) { w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, private") w.Header().Set("Pragma", "no-cache") } // recoveryPageHandler renders the screen (GET /recovery). Reachable whenever the situation holds — // including after "most nem", which is how the backups-area entry point can point at it forever. func (s *Server) recoveryPageHandler(w http.ResponseWriter, r *http.Request) { s.renderRecovery(w, r, "", "", nil) } // renderRecovery is the one render path: pre-unlock (explanation + code form), or post-unlock (the // read-only listing). errorMsg/flash are already-customer-facing Hungarian; NEITHER EVER CONTAINS THE // CODE — see unlockHandler. func (s *Server) renderRecovery(w http.ResponseWriter, r *http.Request, errorMsg, flash string, inv *backup.OffsiteInventory) { recoveryNoStore(w) data := s.baseData("recovery", "Adatok visszaszerzése") data["CSRFField"] = s.csrfField(r) data["Offer"] = s.recoveryOffer() data["Postponed"] = s.settings != nil && s.settings.GetRecoveryNoticePostponed() data["Error"] = errorMsg data["Flash"] = flash data["SealedAt"] = s.recoverySealedAt() // The set-aside choice is offered ONLY when the shipped move-aside can actually run — it refuses // unless the tier is orphaned. Showing a button that is guaranteed to refuse would be worse than // not showing it, and rewriting the move-aside is explicitly out of scope. data["CanSetAside"] = s.backupMgr != nil && s.backupMgr.OffboxOrphaned() data["ConfirmSetAside"] = r.URL.Query().Get("setaside") == "1" if inv != nil { data["Unlocked"] = true data["InvApps"] = inv.Apps data["InvEmpty"] = inv.Empty data["InvUntagged"] = !inv.Empty && len(inv.Apps) == 0 } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.tmpl.ExecuteTemplate(w, "recovery", data); err != nil { s.logger.Printf("[ERROR] [web] Template error (recovery): %v", err) http.Error(w, "Internal error", http.StatusInternalServerError) } } // recoverySealedAt returns the human date the hub says the sealed package was created ("" // when unknown). Non-secret: a timestamp, and the page must not pretend to know more than that // before a code is entered. func (s *Server) recoverySealedAt() string { if s.escrowSealedAtFn == nil { return "" } return s.escrowSealedAtFn() } // SetEscrowSealedAt wires the ACK's escrow created_at (report.EscrowAutoConfirmer). INIT-ONLY. func (s *Server) SetEscrowSealedAt(fn func() string) { s.escrowSealedAtFn = fn } // recoveryRecoverer returns the agent seam that unseals the package. nil → the shared agentClient(), // the same channel the CLI uses; tests inject. The seam exists so the HANDLER can be driven in a test // — a helper-level test would not observe a mutation that lives in the handler (§10). func (s *Server) recoveryRecoverer() (backup.OffsiteKeyRecoverer, error) { if s.recoveryRecovererFn != nil { return s.recoveryRecovererFn() } return s.agentClient() } // SetRecoveryRecoverer overrides the agent seam (tests). func (s *Server) SetRecoveryRecoverer(fn func() (backup.OffsiteKeyRecoverer, error)) { s.recoveryRecovererFn = fn } // recoveryUnlockHandler takes the recovery code and drives the SHARED core (POST /recovery/unlock). // // R HANDLING, and it must not drift from the CLI's: // - POST body only. A GET with a query string would put R in the access log, the browser history // and any Referer header the page later emits. // - never logged. Not at any level, not truncated, not hashed-and-logged. // - never persisted — not in the session, not in a cookie, not in a file. // - cleared on every path below, success and failure alike. // - never echoed. No message built here contains it; the agent's errors name the step, not the code. // // NO LOCKOUT (§8.4). The code is a 10-word EFF phrase — guessing is not the risk — and locking a // customer out of their own data because they mistyped is a worse failure than anything it prevents. // Failures ARE logged locally (without the code) so a box being probed is visible in the debug ring. func (s *Server) recoveryUnlockHandler(w http.ResponseWriter, r *http.Request) { if !s.recoveryOffer() { http.Redirect(w, r, "/backups/remote", http.StatusFound) return } _ = r.ParseForm() code := r.PostFormValue("recovery_code") // PostFormValue: body only, never the query string if code == "" { s.renderRecovery(w, r, "Add meg a helyreállítási kódot.", "", nil) return } rec, err := s.recoveryRecoverer() if err != nil { code = "" s.logger.Printf("[ERROR] [web] recovery: agent channel unavailable: %v", err) s.renderRecovery(w, r, "A gép házon belüli kapcsolata most nem elérhető — próbáld újra néhány perc múlva.", "", nil) return } ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() // THE SHARED CORE — the same function the command line drives. There is no second recovery // implementation in this codebase (R-193 §8.5). res, rerr := backup.RecoverInstallCore(ctx, s.backupMgr, rec, code, true) code = "" // cleared here, before any branch below, on success and failure alike if rerr != nil { // The agent's error names the STEP (fetch / unseal / place) and carries no secret. It is not // shown raw: a customer needs to know what to check, not what age's KDF returned. s.logger.Printf("[WARN] [web] recovery: unlock failed: %v", rerr) s.renderRecovery(w, r, "A megadott helyreállítási kódot nem fogadtuk el. Ellenőrizd, hogy mind a tíz szót pontosan, szóközökkel elválasztva írtad be — a kis- és nagybetűk nem számítanak. Semmi nem változott, nyugodtan próbáld újra.", "", nil) return } switch res.Outcome { case backup.RecoverRefused: s.logger.Printf("[WARN] [web] recovery: refused — a different repository password is already present") s.renderRecovery(w, r, "Ezen a gépen már van egy másik mentési kulcs. A régi előzmény visszanyitása felülírná azt, ezért nem hajtottuk végre. Vedd fel a kapcsolatot a Felhom ügyfélszolgálatával.", "", nil) return } s.logger.Printf("[INFO] [web] recovery: the offsite repository key was recovered and placed (outcome=%s)", res.Outcome) // Unlocked. Now show what is in there — read-only. ictx, icancel := context.WithTimeout(context.Background(), 2*time.Minute) defer icancel() inv, ierr := s.backupMgr.OffsiteInventoryList(ictx) if ierr != nil { s.logger.Printf("[WARN] [web] recovery: unlocked but the inventory could not be read: %v", ierr) empty := backup.OffsiteInventory{} msg := "A kulcs visszakerült, de a mentések listáját most nem sikerült beolvasni. Nézd meg a Biztonsági mentés oldalt néhány perc múlva." if backup.ErrNoOffsiteTarget(ierr) { // The pristine rebuilt shape: the key is in place but the box has no off-site coordinates // yet. It resolves by itself once the tier is re-applied, so say that rather than showing a // failure the customer cannot act on. msg = "A kulcs visszakerült. A gép még most kapcsolódik újra a házon kívüli tárhelyhez — a mentéseid listája néhány perc múlva jelenik meg a Biztonsági mentés oldalon." } s.renderRecovery(w, r, msg, "", &empty) return } s.renderRecovery(w, r, "", "A mentéseid zárolása feloldva. Az alábbiakat találtuk a tárolóban — semmit nem állítottunk vissza.", &inv) } // recoveryPostponeHandler records "most nem" (POST /recovery/postpone). It suppresses the FULL-PAGE // interruption ONLY: recoveryOffer stays true, so the backups-area entry point survives permanently. func (s *Server) recoveryPostponeHandler(w http.ResponseWriter, r *http.Request) { if s.settings != nil { if err := s.settings.SetRecoveryNoticePostponed(true); err != nil { s.logger.Printf("[WARN] [web] recovery: recording the postpone failed: %v", err) } } s.logger.Printf("[INFO] [web] recovery: the customer chose to postpone; the backups-area entry point stays") http.Redirect(w, r, "/launcher", http.StatusFound) }