89712563a0
gates / gates (push) Successful in 10s
The retrieval clause rendered unconditionally on every page and is false on a reachable state - the same screen where the orphan card says we cannot tell. The condition is a fingerprint PINNED at the decision, not a comparison against the current key. The obvious proxy asks about the wrong key: the set-aside copies were written under an older key the box no longer has, so on a twice-rebuilt box the proxy promises about copies nothing can open. Demonstrated - under the proxy, the replaced-package and legacy cases both flip back to promising. The pin is a recorded assumption and says so: nothing on the box records which key wrote those copies. Empty is not a match. A countdown started before this carries no pin and takes the cautious branch, not a backfill. A sweep of all 36 templates found a fourth instance (backups page, same condition applied) and a fifth (the confirmation screen, correctly left alone - true at the moment of the decision). New retrieval_promise_gate registers each claim with a reason rather than banning a verb: a string ban failed twice, and the honest replacement copy contains the stem.
676 lines
39 KiB
Go
676 lines
39 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")
|
|
// R-302: the retrieval clause is conditional; the deletion sentence is not. Taken
|
|
// from the read model so this surface cannot form its own opinion.
|
|
data["RecoveryAbandonRetrievalOffered"] = st.RetrievalStillOffered
|
|
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
|
|
}
|