Files
admin 72368654e4 R-241 part 5: escalating reminders, and operator levers for a running countdown
REMINDERS (SEC 2.3). The offer epoch now stamps when it began, and the
undecided reminder escalates in EMPHASIS at 1, 3, 7 and 14 days.

THE READING IS STATED BECAUSE THE SPEC IS AMBIGUOUS, and it is written into
the code where it can be corrected. For an ABANDONING box, 5/3/1 are
unambiguously days REMAINING before a deletion. An undecided box has no
deadline - nothing counts down to anything, because SEC 7.5 deliberately does
NOT auto-abandon - so 14/7/3/1 cannot be "remaining" and are taken as days
ELAPSED, with the wording firming up rather than the bar appearing and
disappearing. If the operator meant something else, one function changes.

The stamp is re-set on every entry into the offered state, so a box that
settles and is later rebuilt starts its ladder again instead of inheriting an
old one.

OPERATOR LEVERS (SEC 7.5). --abandon-status, --abandon-extend=N and
--abandon-stop on the controller CLI, beside the existing operator
subcommands. They exist because the path that ACTUALLY happens is the customer
telephoning, and support needs something to press.

They live on the CLI and not in the customer UI deliberately: extending a
deletion the customer asked for is an operator judgement, and a customer who
wants it stopped already has the self-service route - they recover with their
code, which cancels it.

BOTH REFUSE RATHER THAN NO-OP, in two situations: when no countdown is
running, and when the store has already been deleted. A silent success is the
thing an operator most easily mistakes for "handled" - they would tell the
customer their data was safe when it is gone. Pinned by two tests.

--abandon-extend counts from NOW, not from the old due date, and a test proves
the old date passes without deleting anything.

Green: go build, go vet, go test ./... all pass; controller gates OK.
2026-08-07 12:08:11 +02:00

673 lines
38 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"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// 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()
}
// recoveryBannerCookie is the PER-VISIT banner dismissal (v0.206.0, R-241, §7.1). It is a browser
// SESSION cookie — no MaxAge, no Expires — and it is cleared on login, so "I have seen this" lasts
// for the visit and the reminder is back next time.
//
// It is deliberately NOT persisted in settings. A dismissal that outlived the visit would be a
// permanently-dismissed banner over data still sitting there, which is the failure Scenario H exists
// to catch. The durable, deliberate version of "stop reminding me" is the tick-box (§7.1), and that
// one is an explicit decision the customer takes, not a click to get a bar off the screen.
const recoveryBannerCookie = "felhom_recovery_banner"
// recoveryOfferEpoch advances and returns the offer-epoch view. Called from the landing-page
// interception, which runs on every dashboard/launcher GET — so the edge is detected promptly without
// a second scheduler job. Writes only on a transition.
func (s *Server) recoveryOfferEpoch() settings.RecoveryOfferView {
if s.settings == nil {
return settings.RecoveryOfferView{}
}
v, err := s.settings.SyncRecoveryOfferEpoch(s.recoveryOffer(), s.recoveryNow())
if err != nil {
s.logger.Printf("[WARN] [web] recovery: could not persist the offer epoch: %v", err)
}
return v
}
// recoveryInterrupts reports whether the FULL PAGE should take over the landing pages.
//
// ⚠ ONCE PER ENTRY INTO THE OFFERED STATE, NOT ONCE EVER (v0.206.0, §7.1). "Most nem" used to set a
// flag that nothing ever cleared, so a box that abandoned its history and was rebuilt months later —
// a genuinely NEW situation — would never show the page again. The epoch fixes that by arithmetic:
// a fresh entry advances it past the dismissal, with nothing to clear.
//
// It still suppresses the full page ONLY. `recoveryOffer` stays true, so the banner and the
// backups-area entry point both survive, and that asymmetry is the whole of Scenario E.
func (s *Server) recoveryInterrupts() bool {
// ⚠ THE EPOCH IS SYNCED FIRST AND UNCONDITIONALLY, and that ordering is the whole mechanism.
// The first draft returned early when the offer was false, so the FALLING edge was never
// recorded — `RecoveryOfferActive` stayed true through a settled period and the next entry
// therefore counted as a continuation rather than a new situation. The page never came back.
// Caught by TestR241_FullPageAppearsOncePerEntryNotOnceEver, not by review.
if s.settings == nil {
return s.recoveryOffer()
}
v := s.recoveryOfferEpoch()
if !v.Active {
return false
}
return v.Epoch > v.PostponedEpoch
}
// recoveryBannerVisible reports whether the per-visit reminder bar should render on ordinary pages.
// Three conditions, and each is a separate lever: the situation holds, the customer has not opted out
// of reminders for THIS epoch, and they have not clicked the bar away during this visit.
func (s *Server) recoveryBannerVisible(r *http.Request) bool {
if !s.recoveryOffer() || s.settings == nil {
return false
}
if c, err := r.Cookie(recoveryBannerCookie); err == nil && c.Value == "1" {
return false // dismissed for this visit only
}
v := s.settings.GetRecoveryOfferView()
return v.Epoch > v.OptOutEpoch
}
// 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()
// §7.3: the confirmation states the grace in days, from the constant the countdown actually uses —
// never a literal in the copy, which is how a number in prose drifts away from the number in code.
data["AbandonGraceDays"] = backup.AbandonGraceDays
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)
// ── SCENARIO G — CHANGING YOUR MIND INSIDE THE WINDOW (R-241, v0.206.0) ─────────────────────
//
// The customer may have chosen to abandon the old history and then found their code after all.
// The countdown is cancelled HERE, at the moment the code proves they still have it — the same
// act that makes the abandonment wrong is the act that stops it.
//
// It is placed before the tier-up and the listing deliberately: those can fail, and a countdown
// that survives a successful unlock because a later step errored would delete the very history
// the customer just proved they can open. Nothing has been deleted at this point by construction —
// AbandonSweep is the only deleter, and a running countdown means it has not fired.
if s.backupMgr != nil {
s.backupMgr.CancelAbandon("the customer recovered with their code")
}
// ── 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 {
// Recorded against the CURRENT epoch (v0.206.0): a dismissal is about the situation the
// customer is in, not about the screen for ever. A later fresh entry shows the page again.
if err := s.settings.PostponeRecoveryNoticeForEpoch(); 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 banner and the backups-area entry point both stay")
http.Redirect(w, r, "/launcher", http.StatusFound)
}
// recoveryBannerDismissHandler records "seen it, for now" (POST /recovery/banner/dismiss) — a browser
// SESSION cookie and nothing durable. The bar is back at the next login, because the data is still
// sitting there whether or not anyone clicked.
func (s *Server) recoveryBannerDismissHandler(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: recoveryBannerCookie, Value: "1", Path: "/",
HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: r.TLS != nil,
// NO MaxAge and NO Expires — a session cookie, deliberately. See recoveryBannerCookie.
})
http.Redirect(w, r, redirectBackTo(r, "/launcher"), http.StatusFound)
}
// recoveryRemindOptOutHandler records „ne emlékeztessen újra" (POST /recovery/remind-optout).
//
// ⚠ IT SILENCES THE BANNER AND NOTHING ELSE (§7.1 condition 3). It is not an abandonment, it starts
// no countdown, and it must never be presented as a way of deciding. The entry point on the backups
// page stays exactly where it was (condition 1) — silencing a reminder is not removing the route, and
// this whole session exists partly because a route disappeared. A fresh entry into the offered state
// reminds again (condition 2), by epoch arithmetic.
func (s *Server) recoveryRemindOptOutHandler(w http.ResponseWriter, r *http.Request) {
if s.settings != nil {
if err := s.settings.OptOutRecoveryRemindersForEpoch(); err != nil {
s.logger.Printf("[WARN] [web] recovery: recording the reminder opt-out failed: %v", err)
}
}
s.logger.Printf("[INFO] [web] recovery: reminders silenced for this situation at the customer's request — the backups-area entry point is UNCHANGED and no countdown was started")
http.Redirect(w, r, redirectBackTo(r, "/backups/remote"), http.StatusFound)
}
// redirectBackTo returns a SAFE same-site redirect target from the form, or the fallback. Only a
// leading single "/" is accepted: "//evil.example" is a protocol-relative URL and must not pass.
func redirectBackTo(r *http.Request, fallback string) string {
v := r.FormValue("back")
if len(v) > 1 && v[0] == '/' && v[1] != '/' {
return v
}
return fallback
}
// addRecoveryBanner decorates a page's data with the reminder bar's state (R-241, v0.206.0).
//
// It is an EXPLICIT call rather than a `baseData` change, deliberately: `baseData` has no request and
// the per-visit dismissal is a cookie, and threading a request through every caller to reach four
// pages would be a large diff for a small feature. The callers are the pages a customer actually
// lands on — the dashboard, the launcher and the backups area.
//
// ⚠ IT IS A REMINDER, NOT THE ROUTE. Nothing here gates the entry point on /backups/remote; that is
// driven by `.RecoveryOffer` in the template and stays put whatever the customer does about the bar.
func (s *Server) addRecoveryBanner(data map[string]interface{}, r *http.Request) {
if !s.recoveryBannerVisible(r) {
return
}
data["RecoveryBanner"] = true
data["RecoveryBannerBack"] = r.URL.Path
if data["CSRFField"] == nil {
data["CSRFField"] = s.csrfField(r)
}
// §2.3 — THE UNDECIDED LADDER. A customer who never decides is reminded with escalating
// EMPHASIS as the situation ages: marks at 1, 3, 7 and 14 days since this epoch began.
//
// ⚠ THE READING IS STATED BECAUSE THE SPEC IS AMBIGUOUS. For an ABANDONING box, 5/3/1 are
// unambiguously days REMAINING before a deletion. An undecided box has no deadline — nothing is
// counting down to anything, because §7.5 deliberately does NOT auto-abandon — so 14/7/3/1 cannot
// be "remaining" and are taken as days ELAPSED, with the wording escalating rather than the bar
// appearing and disappearing. If the operator meant something else, this is the line to change.
dw := s.recoveryDaysWaiting()
data["RecoveryDaysWaiting"] = dw
data["RecoveryReminderTier"] = RecoveryReminderTier(dw)
// While a countdown runs the bar counts it down instead of asking the same question — and the
// reminder opt-out is deliberately NOT offered there: a deletion date is not something to silence.
if s.backupMgr != nil {
if st := s.backupMgr.AbandonStatus(); st.Active {
for _, mark := range backup.AbandonRemindAtDays {
if st.DaysLeft <= mark {
data["RecoveryAbandonDays"] = st.DaysLeft
data["RecoveryAbandonDate"] = st.DueAt.Format("2006-01-02")
break
}
}
if data["RecoveryAbandonDays"] == nil {
// Outside the reminder marks the countdown is visible on the backups page only —
// a bar on every screen for fourteen days is a bar nobody reads by day three.
delete(data, "RecoveryBanner")
}
}
}
}
// recoveryDaysWaiting returns whole days since the current offered epoch began (0 when unknown).
// It drives the escalating emphasis of the undecided reminder — see addRecoveryBanner.
func (s *Server) recoveryDaysWaiting() int {
if s.settings == nil {
return 0
}
v := s.settings.GetRecoveryOfferView()
if v.Since == "" {
return 0
}
since, err := time.Parse(time.RFC3339, v.Since)
if err != nil {
return 0 // an unparseable stamp means "we do not know", never "it has been ages"
}
d := int(s.recoveryNow().Sub(since) / (24 * time.Hour))
if d < 0 {
return 0
}
return d
}
// RecoveryReminderTier maps days-waiting to the escalation marks in §2.3. It returns the HIGHEST
// mark reached, so the copy can firm up without the bar flickering in and out.
func RecoveryReminderTier(daysWaiting int) int {
tier := 0
for _, mark := range []int{1, 3, 7, 14} {
if daysWaiting >= mark {
tier = mark
}
}
return tier
}