Files
felhom-controller/controller/internal/web/recovery_handlers.go
T
admin 1e759a16ec R-224/R-226 Part 1: why the unlock failed decides what we say
The failure branch was a two-way choice — superseded? M4 : M1 — and BOTH are
statements about the customer's code. rerr was never inspected, so a hub that
refused, an agent that was stopped and a genuinely mistyped code all produced
the same accusation. Measured live 2026-08-05 with a CORRECT current code: hub
firewalled off 0.0556s, agent stopped 0.0299s, against ~1.0s for a real unseal.

Five classes, from the VALUE and never the text:
  hub-unreachable    502/503 from the agent — the code was NOT used
  agent-unreachable  no agent verdict at all (transport) — NOT used
  no-bundle          404
  bundle-too-old     409
  asked-and-refused  400 — the ONLY class that may mention typing
  unknown            everything else -> NEUTRAL, the safe default

agentapi.RecoveryRefusal carries the status as a value (refusalError flattened
it into a sentence, and a sentence is not something a caller can branch on).

THE OLD-AGENT CASE IS WHY THIS NEEDS A COUPLING. Agent < 0.126.0 answers 400
for both a fetch failure and a wrong code, so a 400 from one cannot be read as
a refusal. FeatureRecoveryFailureClass (MinAgent 0.126.0) withholds that
reading and the 400 degrades to neutral. The gate BLOCKS NOTHING — it only
decides whether the customer may be told to check their typing.

R-226: the superseded message now names BOTH possibilities and restores the
ten-words prompt. The two are indistinguishable at the engine; the honest
message says so. It still does not promise the earlier package can be opened.

Elapsed time is logged (it is what diagnosed this) and is NEVER a classifier.

Tests: scenarios A-E at the HANDLER + the classifier table asserting the same
sentence under two statuses classifies two ways. Red-proofs, each demonstrated
failing then restored: delete the 502 case (A), remove the mistype clause (C),
default to the accusation (D), route an instant transport failure to the typing
message (E).

Two existing tests encoded the defect and were corrected, not deleted: the web
fake returned a BARE error for 'wrong code' (which is the shape of a failure we
cannot classify), and R-222's test forbade any mention of typing on a
superseded box — half of which R-226 deliberately reverses.

28 packages ok, vet clean, all controller gates OK.
2026-08-06 08:06:57 +02:00

479 lines
29 KiB
Go

package web
import (
"context"
"net/http"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"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.
//
// ⚠ GATED ON THE SAME PREDICATE AS THE INTERCEPTION (R-215, v0.201.0). It was not, and the page
// rendered its whole story — *"Ezt a gépet újratelepítették. A korábbi, házon kívüli mentéseid
// megvannak"* — on any claimed box, including one installed hours earlier that had never had an
// off-site backup in its life. Measured on 2026-08-05 (CAMPAIGN-11 §F9): the hub held no escrow row
// for that host at all, so `recoveryOffer()` was correctly false; the page simply never asked it.
//
// The predicate was right, the POST sibling below already consulted it, and the backups-area template
// gates the identical sentence on `.RecoveryOffer` — this GET was the one door left open. A box with
// no link to /recovery is only reached by typing the path or following a stale bookmark, but what it
// then asserts is false in two ways at once, on the one screen whose purpose is to be believed about
// backups.
func (s *Server) recoveryPageHandler(w http.ResponseWriter, r *http.Request) {
if !s.recoveryOffer() {
http.Redirect(w, r, "/backups/remote", http.StatusFound)
return
}
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) {
s.renderRecoveryState(w, r, errorMsg, flash, inv, false)
}
// renderRecoveryUnlockedNoList is the R-217 shape: the unlock SUCCEEDED but the repository could not
// be listed. It renders the unlocked page with NO listing block at all — never the zero-value
// inventory, whose Empty=false made the template claim the store had opened with unattributable
// content. "Opened with apps", "opened and empty" and "could not be read" are three states and this
// is the third.
func (s *Server) renderRecoveryUnlockedNoList(w http.ResponseWriter, r *http.Request, errorMsg string) {
s.renderRecoveryState(w, r, errorMsg, "", nil, true)
}
func (s *Server) renderRecoveryState(w http.ResponseWriter, r *http.Request, errorMsg, flash string, inv *backup.OffsiteInventory, unlockedNoList bool) {
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
// InvUntagged is "the repository OPENED and holds snapshots we could not attribute to an app".
// It is only ever computed from a real reading — a failed read reaches the branch below and
// renders no listing at all (R-217).
data["InvUntagged"] = !inv.Empty && len(inv.Apps) == 0
} else if unlockedNoList {
data["Unlocked"] = true
data["InvUnavailable"] = true
}
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
}
// recoverySupport is the R-216 capability verdict for the unlock path. It uses the SAME machinery the
// NAS gate uses (`netFeatures` + the agent client as prober), so the version channel decides without
// probe traffic whenever the agent's version is known.
//
// It returns SupportUnknown when the agent handle cannot be built — and the CALLER treats Unknown as
// "cannot ask". That is deliberate and is the whole point: a box that cannot reach its agent must not
// attempt an unlock whose failure would be read as a bad code.
func (s *Server) recoverySupport(ctx context.Context) agentapi.SupportState {
if s.recoverySupportFn != nil {
return s.recoverySupportFn(ctx)
}
agent, err := s.agentClient()
if err != nil {
return agentapi.SupportUnknown
}
state, source := s.netFeatures.SupportsWithSource(ctx, agent, agentapi.FeatureOffsiteKeyRecovery)
s.logger.Printf("[DEBUG] [web] recovery capability gate: %s=%s (source=%s)", agentapi.FeatureOffsiteKeyRecovery, state, source)
return state
}
// SetRecoverySupport overrides the capability verdict (tests). INIT-ONLY.
func (s *Server) SetRecoverySupport(fn func(context.Context) agentapi.SupportState) {
s.recoverySupportFn = fn
}
// recoveryRefusalTrusted reports whether a 400 from the agent may be read as "the bundle was fetched
// and the code was REFUSED" (R-224).
//
// Only agent >= v0.126.0 splits a failed fetch out to its own status. Before it, 400 covered both,
// and reading one as a refusal is precisely how a hub outage became an accusation. Anything other
// than a definite yes therefore withholds that reading, and the caller falls to the neutral message.
//
// ⚠ This gate BLOCKS NOTHING. The unlock is attempted either way — FeatureOffsiteKeyRecovery already
// decides that, fail-closed. This only decides whether the customer may be told to check their
// typing, and "not sure" means they may not.
func (s *Server) recoveryRefusalTrusted(ctx context.Context) bool {
if s.recoveryRefusalTrustedFn != nil {
return s.recoveryRefusalTrustedFn(ctx)
}
agent, err := s.agentClient()
if err != nil {
return false
}
state, _ := s.netFeatures.SupportsWithSource(ctx, agent, agentapi.FeatureRecoveryFailureClass)
return state == agentapi.SupportYes
}
// SetRecoveryRefusalTrusted overrides the R-224 version gate (tests). INIT-ONLY.
func (s *Server) SetRecoveryRefusalTrusted(fn func(context.Context) bool) {
s.recoveryRefusalTrustedFn = fn
}
// recoveryNow is the clock the unlock path measures itself against. Real time in production; tests
// inject so §7.2's guard — the typing message may only follow a REAL unseal — can be asserted
// without sleeping. It is an observability seam and a test seam: **it must never become a
// classifier.** Time is the symptom that diagnosed R-224; the agent's status is the fact.
func (s *Server) recoveryNow() time.Time {
if s.recoveryNowFn != nil {
return s.recoveryNowFn()
}
return time.Now()
}
// SetRecoveryClock overrides the unlock clock (tests). INIT-ONLY.
func (s *Server) SetRecoveryClock(fn func() time.Time) { s.recoveryNowFn = fn }
// recoverySuperseded reports the hub's statement that an EARLIER sealed package is kept, and when
// (R-222). Both zero on a pre-0.97.0 hub, which keeps the old message — an older hub simply cannot
// make the screen claim anything new.
func (s *Server) recoverySuperseded() (bool, string) {
if s.settings == nil {
return false, ""
}
return s.settings.GetHubEscrowSuperseded()
}
// SetRecoveryTierUp wires the off-site apply-bridge reconcile (R-219 / R-218). INIT-ONLY.
//
// The unlock calls it between placing the recovered key and reading the repository — the step that
// turns the screen's promised listing from structurally impossible into merely conditional. nil (or
// an error) is survivable: the key is placed either way and the listing branch says what is pending.
func (s *Server) SetRecoveryTierUp(fn func(context.Context) error) { s.recoveryTierUp = 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
}
// ── MESSAGE 2 of 4 — THE MACHINE CANNOT ASK (R-216). This gate FAILS CLOSED. ────────────────
//
// The unseal happens in the agent, behind POST /escrow/recover-offsite-password, which ships in
// agent v0.125.0. On an older agent that route 404s — and until v0.201.0 the unlock was attempted
// anyway and the 404 came back as "we did not accept your recovery code, check your ten words".
// A correct code, refused in 0.134 s, blamed on the customer (CAMPAIGN-11 Phase 1, the headline).
//
// So: anything other than SupportYes stops here. Unknown counts as cannot-ask, against this
// package's fail-open default — see FeatureOffsiteKeyRecovery for why this one feature inverts it.
// An attempt that cannot succeed must never be made, because its failure is attributed to the code.
if support := s.recoverySupport(r.Context()); support != agentapi.SupportYes {
code = ""
s.logger.Printf("[WARN] [web] recovery: refused before attempting — agent capability %s=%s (needs %s)",
agentapi.FeatureOffsiteKeyRecovery, support, agentapi.MinAgentFor(agentapi.FeatureOffsiteKeyRecovery))
s.renderRecovery(w, r, "Ez a gép még nem tudja megnyitni a mentéseidet — a hozzá tartozó házon belüli szolgáltatás régebbi, mint amit ehhez a lépéshez használunk. A kódoddal semmi baj, és nem is használtuk fel: tedd el biztonságos helyen. A gép magától frissül; próbáld újra később, vagy szólj a Felhom ügyfélszolgálatának, ha egy nap múlva sem működik.", "", 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).
unsealStart := s.recoveryNow()
res, rerr := backup.RecoverInstallCore(ctx, s.backupMgr, rec, code, true)
unsealTook := s.recoveryNow().Sub(unsealStart)
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.
//
// ── R-224 — WHY IT FAILED DECIDES WHAT WE SAY. ─────────────────────────────────────────
//
// This branch used to be a two-way choice — superseded? M4 : M1 — and BOTH are statements
// about the customer's code. `rerr` was never inspected, so a hub that refused, an agent that
// was stopped, and a genuinely mistyped code all produced the same accusation.
//
// Measured live on 2026-08-05 with a CORRECT current code: hub firewalled off → 0.0556 s;
// agent stopped → 0.0299 s. A genuine unseal costs ~1.0 s of scrypt, so neither had attempted
// one. **The machine accused the customer of something it had not tried.**
//
// The duration is logged because it is what DIAGNOSED this and it is the cheapest possible
// tell for the operator — but it is NEVER the classifier. Time is a symptom; the status is
// the fact.
class := agentapi.ClassifyRecoveryFailure(rerr, s.recoveryRefusalTrusted(r.Context()))
s.logger.Printf("[WARN] [web] recovery: unlock failed after %s (class=%s): %v", unsealTook.Round(time.Millisecond), class, rerr)
switch class {
case agentapi.RecoveryHubUnreachable:
// The agent answered and could not FETCH the package. The code was NEVER USED. Say that,
// name the connection, and say nothing whatever about whether the code is right — we do
// not know, and guessing here is the whole defect.
s.renderRecovery(w, r, "Most nem sikerült elérni a Felhom központi rendszerét, ezért a mentéseidet nem tudtuk megnyitni. A kódodat NEM használtuk fel, és semmi nem változott — tedd el biztonságos helyen, és próbáld újra néhány perc múlva. Ha egy óra múlva sem megy, szólj a Felhom ügyfélszolgálatának.", "", nil)
return
case agentapi.RecoveryAgentUnreachable:
// The machine's own in-house service never answered, so there is no verdict at all. A
// different fault from the one above, with a different remedy — and, again, the code was
// not used.
s.renderRecovery(w, r, "A gép házon belüli szolgáltatása most nem válaszol, ezért a mentéseidet nem tudtuk megnyitni. A kódodat NEM használtuk fel, és semmi nem változott — tedd el biztonságos helyen. A gép magától rendbe jöhet; próbáld újra néhány perc múlva, és ha egy óra múlva sem megy, szólj a Felhom ügyfélszolgálatának.", "", nil)
return
case agentapi.RecoveryNoBundle:
// The hub answered, and it holds nothing for this machine. Not the customer's doing, and
// not something a different code would fix.
s.renderRecovery(w, r, "Ehhez a géphez nem őrzünk lezárt csomagot, ezért nincs mit megnyitni. Ez nem a kódoddal van összefüggésben. Ha korábban készültek házon kívüli mentéseid, szólj a Felhom ügyfélszolgálatának.", "", nil)
return
case agentapi.RecoveryBundleTooOld:
// The code WORKED — the bundle opened. It simply predates the field we need.
s.renderRecovery(w, r, "A kódod megnyitotta a csomagot, de az még nem tartalmazza a házon kívüli tárhely kulcsát — régebben készült, mint amikor ezt elkezdtük belerakni, és utólag nem pótolható. A kódoddal semmi baj. Keresd a Felhom ügyfélszolgálatát.", "", nil)
return
case agentapi.RecoveryAskedAndRefused:
// The bundle was fetched and the code did not open it. THIS is the only class from which
// the customer may be told to check their typing — see the two messages below.
default:
// ── THE SAFE DEFAULT (R-224 §7.1). ────────────────────────────────────────────────
//
// The cause could not be determined: an unrecognised status, an unexpected transport
// shape, or an agent older than v0.126.0 whose 400 means "wrong code OR failed fetch"
// and cannot be told apart. **We do not know, so we do not guess — and we certainly do
// not guess the customer.**
//
// It claims neither that the code was wrong nor that it went unused; both would be
// inventions. This is the branch whose ABSENCE let the defect survive being fixed once.
s.renderRecovery(w, r, "A művelet nem fejeződött be, és nem tudjuk biztosan, miért. Semmi nem változott, és a mentéseid érintetlenek. Próbáld újra néhány perc múlva — ha másodszorra sem sikerül, szólj a Felhom ügyfélszolgálatának.", "", nil)
return
}
// ── MESSAGE 4 of 4 — AN EARLIER PACKAGE IS KEPT (R-222) ────────────────────────────────
//
// The unseal failed against the package the hub CURRENTLY holds. That is the right outcome for
// a wrong code — and it is also the right outcome for a code that is perfectly correct about an
// EARLIER package the hub is deliberately keeping. From here the two are indistinguishable: the
// engine fails closed either way, which is exactly what it should do.
//
// So when the hub has told us an earlier package exists, say that instead of blaming typing.
// Measured on 2026-08-05 (CAMPAIGN-11 Phase 3 step 7): the customer entered the correct code
// for their orphaned history, got a real 1.111 s unseal attempt, and was told to check their
// ten words — while the same screen showed a seal date from after the code they were holding.
//
// ⚠ It states two facts the hub knows and STOPS. It does not promise the earlier package can be
// opened, because it cannot be: serving a superseded blob is an unbuilt link (R-199's
// inventory), and a conditional promise that turns out false on this screen is worse than
// saying less (the R-202 lesson).
//
// ── R-226 — AND IT MUST NAME THE MISTYPE TOO. ─────────────────────────────────────────
//
// As shipped, this message spoke only of an earlier package and sent the customer to support.
// But it is tested BEFORE the typing message, so on every box the hub keeps an earlier package
// for — precisely the boxes whose customer has just been handed a NEW recovery code and is
// most likely to be typing one — a genuine mistype produced this text and the ten-words prompt
// became unreachable. Measured on 2026-08-05 (CAMPAIGN-11 F1): three deliberately wrong codes,
// three real ~1 s unseals, three copies of this message.
//
// The two are INDISTINGUISHABLE at the engine — both fail closed against the current package —
// so the honest message names both and does not pretend to know which. Do not try to tell them
// apart; there is nothing to tell them apart with.
if present, at := s.recoverySuperseded(); present {
when := ""
if at != "" {
when = " (" + at + ")"
}
s.renderRecovery(w, r, "Ez a kód nem nyitotta meg azt a csomagot, amit most őrzünk ehhez a géphez. Két oka lehet, és innen nem tudjuk megkülönböztetni őket. Lehet elgépelés: ellenőrizd, hogy mind a tíz szót pontosan, szóközökkel elválasztva írtad-e be — a kis- és nagybetűk nem számítanak. Vagy egy korábbi kódot adtál meg: a géped azóta új mentési kulcsot kapott, és a régebbi csomagot"+when+" nem töröltük — megőrizzük, megnyitni viszont innen egyelőre nem lehet. Ha újrapróbálod és úgy sem megy, és a régebbi mentéseidre van szükséged, keresd a Felhom ügyfélszolgálatát. A kódoddal semmi nem történt, és semmi nem változott.", "", nil)
return
}
// ── MESSAGE 1 of 4 — THE CODE DID NOT OPEN IT. The ONLY one that mentions typing. ──────────
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)
// ── FINISH THE JOB (R-219, v0.201.0) ───────────────────────────────────────────────────────
//
// The screen promises: *"feloldjuk a mentéseid zárolását és megmutatjuk, mi van bennük"*. On the
// shape this screen exists for that promise was structurally unkeepable, and this call is what
// makes it possible at all:
//
// - listing needs an off-site TARGET (OffsiteInventoryList returns errNoOffsiteTarget without);
// - a target cannot exist without a repository password, because ApplyOffsiteTarget →
// WriteOffboxSecrets MINTS one whenever none is present;
// - shape (a) is DEFINED by having no repository password. So shape (a) ⇒ no target ⇒ the
// listing could never render. Not sometimes. Ever.
//
// And there was no second chance: placing the key flips recoveryOffer() false (a password now
// exists and the box is not orphaned), so the unlock response was the customer's ONLY opportunity
// to see the listing — and it was guaranteed not to contain it.
//
// So bring the tier up here, synchronously, between placing the key and reading the repository.
// It is also the second half of R-218's deadlock: the apply-bridge only retried "on next config
// refresh/restart", and nothing triggered either.
if s.recoveryTierUp != nil {
tctx, tcancel := context.WithTimeout(context.Background(), 90*time.Second)
if terr := s.recoveryTierUp(tctx); terr != nil {
// NOT a failure of the unlock — the key IS placed. Logged, never swallowed, and the
// listing branch below says what is pending rather than what failed.
s.logger.Printf("[WARN] [web] recovery: the offsite tier could not be brought up yet: %v", terr)
}
tcancel()
}
// 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 {
// ── MESSAGE 3 of 4 — THE STORE COULD NOT BE READ (R-217) ────────────────────────────────
//
// ⚠ PASS nil, NOT backup.OffsiteInventory{}. The zero value has Empty=false and Apps=nil, which
// the template reads as InvUntagged — *"A tároló megnyílt, és van benne tartalom, de nem tudtuk
// alkalmazásokhoz rendelni."* Three assertions, none of them known: it did not open, its
// contents are unknown, and attribution is not the problem. Measured on 2026-08-05 (CAMPAIGN-11
// Phase 1), rendered directly beneath the honest message, contradicting it.
//
// OffsiteInventory.Empty exists precisely to prevent this — its own doc comment says it is
// "named rather than inferred from len(Apps)==0, WHICH IS ALSO WHAT A FAILED READ LOOKS LIKE".
// The field built for the hazard was defaulted past. nil means "no inventory to render", and
// the template shows no listing block at all.
s.logger.Printf("[WARN] [web] recovery: unlocked but the inventory could not be read: %v", ierr)
msg := "A kulcs visszakerült, de a mentések listáját most nem sikerült beolvasni. A mentéseid nincsenek veszélyben — nézd meg a Biztonsági mentés oldalt néhány perc múlva."
if backup.ErrNoOffsiteTarget(ierr) {
// The tier could not be brought up in time — the transport credential has not arrived yet.
// Nothing has failed; something is pending, and the declaration stays alive (R-218) so the
// hub's self-heal can still act.
msg = "A kulcs visszakerült, és biztonságban van. A gép még várja a házon kívüli tárhely kapcsolódási adatait — amint megvannak, a mentéseid listája megjelenik a Biztonsági mentés oldalon. Nincs teendőd."
}
s.renderRecoveryUnlockedNoList(w, r, msg)
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)
}