R-241 part 4: the three-state surface, and the copy tells the truth about the date
FULL PAGE ONCE PER ENTRY, NOT ONCE EVER. "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 see the page again. The
offer now carries an EPOCH, advanced on the edge into the offered state, and a
dismissal is recorded against the epoch it was made in. A fresh entry passes
the dismissal by arithmetic, with nothing to clear and nothing that can be
forgotten to clear.
That is NOT the flag the operator's ruling forbids. The forbidden thing
remembers that the customer decided so the screen can be suppressed while the
state stays wrong. This records WHICH SITUATION a dismissal was about.
A REAL BUG, caught by the test and not by review: the first draft returned
early from recoveryInterrupts when the offer was false, so the FALLING edge
was never recorded, RecoveryOfferActive stayed true through a settled period,
and the next entry counted as a continuation. The page never came back - the
exact defect the epoch exists to fix, reintroduced inside the fix. The sync is
now unconditional and the ordering is commented as load-bearing.
THREE LEVERS, THREE SCOPES, and none of them removes the route:
- clicking the bar away -> a browser SESSION cookie, cleared on login, so
the reminder is genuinely back at the next login. Nothing persisted.
- "ne emlekeztessen ujra" -> durable, epoch-scoped, silences the BANNER ONLY.
It starts no countdown, abandons nothing, and a fresh entry reminds again.
- "most nem" -> suppresses the full page only, as before.
The entry point on /backups/remote is bound to the OFFER and to nothing else,
pinned by a test that fires all three dismissals and asserts it survives.
SEC 7.3 / Q7 - THE TRAP DOES NOT SURVIVE THIS SESSION. While a recovery is
outstanding the "Helyrealitasi kod letrehozasa" button is UNAVAILABLE, not
merely captioned: creating a new code seals the current key, demotes the
package that opens the earlier history to retained custody that no shipped
path can read (R-199), and re-enables the recovery screen through the orphan
route while invalidating the code that screen accepts. A warning beside a
button is a warning people click past. The card now explains and points at
/recovery instead.
SEC 2.4 - the abandon confirmation changes with the behaviour. It used to
promise "felretesszuk - nem toroljuk". It now states the grace in days (from
the constant the countdown actually uses, never a literal in prose), that the
sealed package goes with it, that the customer can change their mind, where
the date is visible, and that the question does not come back afterwards.
The countdown is shown on /backups/remote for the WHOLE window - the bar
elsewhere is a nudge, this is the record, and a deletion date must be findable
on a quiet day too.
Tests: once-per-entry across a full settle-and-re-enter cycle; the banner
dismissal proven to be a session cookie (MaxAge 0, no Expires) and to persist
nothing; the opt-out proven to silence the banner while leaving the offer, the
route and the countdown untouched, and to remind again on a fresh entry; the
entry point surviving all three dismissals; a settled box showing nothing; and
the back-redirect refusing "//evil.example".
An existing test (TestRecovery_E) was updated: it asserted the legacy boolean,
which the epoch replaces. It now asserts the dismissal landed on the current
epoch, which is the stronger property.
Green: go build, go vet, go test ./... all pass; controller gates OK.
This commit is contained in:
@@ -30,6 +30,11 @@ import (
|
||||
// AbandonRemindAtDays) — visible, reversible, and running out in public.
|
||||
const abandonGraceDays = 14
|
||||
|
||||
// AbandonGraceDays is the exported grace, for the customer-facing copy. The confirmation screen must
|
||||
// state the SAME number the countdown uses — a literal typed into prose is how a promise drifts away
|
||||
// from the code that keeps it.
|
||||
const AbandonGraceDays = abandonGraceDays
|
||||
|
||||
// AbandonRemindAtDays are the remaining-day marks at which the abandoning box reminds the customer.
|
||||
// Descending, so the surface can pick the first one that has been reached.
|
||||
var AbandonRemindAtDays = []int{5, 3, 1}
|
||||
|
||||
@@ -119,6 +119,33 @@ type Settings struct {
|
||||
// happened. It is deliberately NOT cleared by anything except the situation ending.
|
||||
RecoveryNoticePostponed bool `json:"recovery_notice_postponed,omitempty"`
|
||||
|
||||
// ── THE OFFER EPOCH (v0.206.0, R-241) ───────────────────────────────────────────────────────
|
||||
//
|
||||
// RecoveryOfferEpoch counts ENTRIES into the offered state; RecoveryOfferActive is the edge
|
||||
// detector that makes counting possible. Together they turn "once ever" into "once per entry".
|
||||
//
|
||||
// ⚠ WHY THIS IS NOT THE FLAG §2.1 FORBIDS. That ruling forbids remembering *that the customer
|
||||
// decided* so the screen can be suppressed while the underlying state stays wrong. These record
|
||||
// something else entirely: WHICH SITUATION a dismissal was about. A box that abandons, is rebuilt
|
||||
// months later and enters the offered state afresh is in a NEW situation, and a dismissal of the
|
||||
// old one must not swallow it. `RecoveryNoticePostponed` alone did exactly that — it was
|
||||
// deliberately never cleared by anything.
|
||||
//
|
||||
// RecoveryNoticePostponedEpoch / RecoveryRemindOptOutEpoch record the epoch a choice was made in.
|
||||
// The full page interrupts while `Epoch > PostponedEpoch`, and the banner reminds while
|
||||
// `Epoch > OptOutEpoch` — so a fresh entry resets BOTH by arithmetic, with nothing to clear and
|
||||
// nothing that can be forgotten to clear (§7.1 condition 2).
|
||||
RecoveryOfferEpoch int `json:"recovery_offer_epoch,omitempty"`
|
||||
RecoveryOfferActive bool `json:"recovery_offer_active,omitempty"`
|
||||
RecoveryNoticePostponedEpoch int `json:"recovery_notice_postponed_epoch,omitempty"`
|
||||
// RecoveryRemindOptOutEpoch — the customer ticked „ne emlékeztessen újra" in this epoch.
|
||||
//
|
||||
// It silences THE BANNER AND NOTHING ELSE (§7.1 condition 3). It is not an abandonment, it starts
|
||||
// no countdown, and the entry point on the backups page never goes away because of it — silencing
|
||||
// a reminder is not the same as removing the route, and this session exists partly because a route
|
||||
// disappeared.
|
||||
RecoveryRemindOptOutEpoch int `json:"recovery_remind_optout_epoch,omitempty"`
|
||||
|
||||
// Cached state
|
||||
DBValidations map[string]DBValidationCache `json:"db_validations,omitempty"`
|
||||
|
||||
@@ -2149,3 +2176,85 @@ func sameDayStamp(a, b string) bool {
|
||||
}
|
||||
return ta.UTC().Format("2006-01-02") == tb.UTC().Format("2006-01-02")
|
||||
}
|
||||
|
||||
// ── The recovery-offer epoch (v0.206.0, R-241) ─────────────────────────────────
|
||||
|
||||
// RecoveryOfferView is the epoch read model.
|
||||
type RecoveryOfferView struct {
|
||||
Epoch int
|
||||
Active bool
|
||||
PostponedEpoch int
|
||||
OptOutEpoch int
|
||||
}
|
||||
|
||||
// GetRecoveryOfferView returns the epoch state in one lock.
|
||||
func (s *Settings) GetRecoveryOfferView() RecoveryOfferView {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return RecoveryOfferView{
|
||||
Epoch: s.RecoveryOfferEpoch,
|
||||
Active: s.RecoveryOfferActive,
|
||||
PostponedEpoch: s.RecoveryNoticePostponedEpoch,
|
||||
OptOutEpoch: s.RecoveryRemindOptOutEpoch,
|
||||
}
|
||||
}
|
||||
|
||||
// SyncRecoveryOfferEpoch advances the epoch on the EDGE into the offered state and returns the view.
|
||||
// Idempotent: called on every landing page load, it writes only on a transition.
|
||||
//
|
||||
// THE LEGACY MIGRATION IS HERE AND HAPPENS ONCE. A box upgrading from ≤v0.205.0 may carry
|
||||
// `RecoveryNoticePostponed=true` — a customer who already said "most nem" about the situation they
|
||||
// are still in. Re-interrupting them on upgrade would be a regression dressed as a feature, so the
|
||||
// first epoch inherits that choice and the legacy flag is retired. A box that never dismissed keeps
|
||||
// PostponedEpoch 0 and is interrupted, which is correct.
|
||||
func (s *Settings) SyncRecoveryOfferEpoch(offered bool) (RecoveryOfferView, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
changed := false
|
||||
if offered && !s.RecoveryOfferActive {
|
||||
s.RecoveryOfferActive = true
|
||||
s.RecoveryOfferEpoch++
|
||||
if s.RecoveryOfferEpoch == 1 && s.RecoveryNoticePostponed {
|
||||
s.RecoveryNoticePostponedEpoch = 1 // inherit the pre-epoch dismissal, once
|
||||
s.RecoveryNoticePostponed = false
|
||||
}
|
||||
changed = true
|
||||
} else if !offered && s.RecoveryOfferActive {
|
||||
s.RecoveryOfferActive = false
|
||||
changed = true
|
||||
}
|
||||
view := RecoveryOfferView{
|
||||
Epoch: s.RecoveryOfferEpoch, Active: s.RecoveryOfferActive,
|
||||
PostponedEpoch: s.RecoveryNoticePostponedEpoch, OptOutEpoch: s.RecoveryRemindOptOutEpoch,
|
||||
}
|
||||
if !changed {
|
||||
return view, nil
|
||||
}
|
||||
return view, s.save()
|
||||
}
|
||||
|
||||
// PostponeRecoveryNoticeForEpoch records "most nem" for the CURRENT epoch. It suppresses the
|
||||
// full-page interruption only — the banner and the backups-area entry point both survive.
|
||||
func (s *Settings) PostponeRecoveryNoticeForEpoch() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.RecoveryNoticePostponedEpoch == s.RecoveryOfferEpoch {
|
||||
return nil
|
||||
}
|
||||
s.RecoveryNoticePostponedEpoch = s.RecoveryOfferEpoch
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// OptOutRecoveryRemindersForEpoch records „ne emlékeztessen újra" for the CURRENT epoch. It silences
|
||||
// the BANNER and nothing else: no countdown starts, nothing is abandoned, and the entry point on the
|
||||
// backups page remains. A later fresh entry into the offered state advances the epoch and reminds
|
||||
// again — the §7.1 conditions, satisfied by arithmetic rather than by remembering to clear a flag.
|
||||
func (s *Settings) OptOutRecoveryRemindersForEpoch() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.RecoveryRemindOptOutEpoch == s.RecoveryOfferEpoch {
|
||||
return nil
|
||||
}
|
||||
s.RecoveryRemindOptOutEpoch = s.RecoveryOfferEpoch
|
||||
return s.save()
|
||||
}
|
||||
|
||||
@@ -206,6 +206,11 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
Secure: isSecure,
|
||||
})
|
||||
|
||||
// R-241 (v0.206.0): a fresh login clears the per-visit recovery-banner dismissal, so the reminder
|
||||
// is genuinely "back at the next login" (§7.1 / Scenario H) rather than merely "back when the
|
||||
// browser is closed". The durable opt-out is a separate, explicit choice and is untouched here.
|
||||
http.SetCookie(w, &http.Cookie{Name: recoveryBannerCookie, Value: "", Path: "/", MaxAge: -1})
|
||||
|
||||
s.logger.Printf("[INFO] [web] Login from %s", r.RemoteAddr)
|
||||
|
||||
// Redirect to ?next= target if provided, otherwise to dashboard
|
||||
|
||||
@@ -176,6 +176,7 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
|
||||
sysInfo := system.GetInfo(s.primaryHDDPath(), s.cpuCollector)
|
||||
|
||||
data := s.baseData("dashboard", "Vezérlőpult")
|
||||
s.addRecoveryBanner(data, r) // R-241: the reminder bar, per visit
|
||||
data["SettingsWarning"] = s.settings.LoadWarning // non-empty if settings.json was recovered from corruption
|
||||
data["Stacks"] = deployedStacks
|
||||
data["MissingStorage"] = s.missingStorageMap(deployedStacks)
|
||||
@@ -312,6 +313,7 @@ func (s *Server) launcherApps() []LauncherApp {
|
||||
// "Indítópult megosztása" share state (v0.165.0) for the modal.
|
||||
func (s *Server) launcherHandler(w http.ResponseWriter, r *http.Request) {
|
||||
data := s.baseData("launcher", "Indítópult")
|
||||
s.addRecoveryBanner(data, r) // R-241: the reminder bar, per visit
|
||||
data["Apps"] = s.launcherApps()
|
||||
|
||||
// Share modal state. The share URL is built from the request Host at render time (the canonical
|
||||
@@ -925,6 +927,21 @@ func (s *Server) backupsRemoteHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// route to the data. A one-shot notice a flustered person clicks past is a notice that never
|
||||
// happened; this is what makes Scenario E true.
|
||||
data["RecoveryOffer"] = s.recoveryOffer()
|
||||
// R-241 (v0.206.0): the abandonment countdown, stated on the page the customer chose it from.
|
||||
// It is shown for the WHOLE window, not only at the reminder marks — the bar on other pages is a
|
||||
// nudge, this is the record, and a deletion date must be findable on a quiet day too.
|
||||
if s.backupMgr != nil {
|
||||
if st := s.backupMgr.AbandonStatus(); st.Active {
|
||||
data["AbandonActive"] = true
|
||||
data["AbandonDaysLeft"] = st.DaysLeft
|
||||
data["AbandonDate"] = st.DueAt.Format("2006-01-02")
|
||||
} else if st.PurgeRequested {
|
||||
// The store is deleted and the sealed package is on its way out. Say so rather than
|
||||
// showing nothing, or the page silently loses a thing the customer was watching.
|
||||
data["AbandonPurging"] = true
|
||||
}
|
||||
}
|
||||
s.addRecoveryBanner(data, r)
|
||||
s.executeTemplate(w, r, "backups_remote", data)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"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
|
||||
@@ -44,14 +45,67 @@ func (s *Server) recoveryOffer() bool {
|
||||
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.
|
||||
// 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())
|
||||
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 {
|
||||
if !s.recoveryOffer() {
|
||||
// ⚠ 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 s.settings == nil || !s.settings.GetRecoveryNoticePostponed()
|
||||
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
|
||||
@@ -112,6 +166,9 @@ func (s *Server) renderRecoveryState(w http.ResponseWriter, r *http.Request, err
|
||||
// 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
|
||||
@@ -483,10 +540,89 @@ func (s *Server) recoveryUnlockHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// 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 {
|
||||
// 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 backups-area entry point stays")
|
||||
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)
|
||||
}
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-241 §7.1 / Scenario H — the three-state surface.
|
||||
//
|
||||
// The full page once PER ENTRY into the offered state (not once ever), a per-visit banner, and an
|
||||
// entry point on the restore page that NOTHING removes.
|
||||
|
||||
// ── THE FULL PAGE APPEARS ONCE PER ENTRY, NOT ONCE EVER ─────────────────────────────────────────
|
||||
//
|
||||
// RED-PROOF: make recoveryInterrupts read the legacy boolean again (`!GetRecoveryNoticePostponed()`).
|
||||
// The second entry is then swallowed and this test fails — a box that abandoned its history and was
|
||||
// rebuilt months later would never see the page again.
|
||||
func TestR241_FullPageAppearsOncePerEntryNotOnceEver(t *testing.T) {
|
||||
f := newRecoveryFixture(t)
|
||||
|
||||
// Entry #1 → interrupts.
|
||||
if !f.s.recoveryInterrupts() {
|
||||
t.Fatal("the first entry into the offered state must interrupt")
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
f.s.recoveryPostponeHandler(rr, httptest.NewRequest(http.MethodPost, "/recovery/postpone", nil))
|
||||
if f.s.recoveryInterrupts() {
|
||||
t.Fatal("after 'most nem' the full page must stop interrupting for THIS situation")
|
||||
}
|
||||
|
||||
// The situation ends (the customer recovered, or the state was fixed): the offer goes false and
|
||||
// the epoch's active edge falls.
|
||||
if err := f.sett.SetHubEscrowIdentityPresent(false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.s.recoveryInterrupts() {
|
||||
t.Fatal("a settled box must not interrupt")
|
||||
}
|
||||
|
||||
// ENTRY #2 — a genuinely new situation months later.
|
||||
if err := f.sett.SetHubEscrowIdentityPresent(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !f.s.recoveryInterrupts() {
|
||||
t.Fatal("a FRESH entry into the offered state must show the full page again — a dismissal is about a situation, not for ever")
|
||||
}
|
||||
}
|
||||
|
||||
// ── THE BANNER IS PER-VISIT ─────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// RED-PROOF: persist the dismissal in settings (or give the cookie a MaxAge). It then survives the
|
||||
// visit and this test fails — a permanently-dismissed banner over data still sitting there.
|
||||
func TestR241_ScenarioH_BannerIsDismissedForTheVisitOnly(t *testing.T) {
|
||||
f := newRecoveryFixture(t)
|
||||
f.s.recoveryInterrupts() // establish the epoch
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/launcher", nil)
|
||||
if !f.s.recoveryBannerVisible(req) {
|
||||
t.Fatal("the banner should be visible while the situation holds")
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
f.s.recoveryBannerDismissHandler(rr, httptest.NewRequest(http.MethodPost, "/recovery/banner/dismiss", nil))
|
||||
var dismissed *http.Cookie
|
||||
for _, c := range rr.Result().Cookies() {
|
||||
if c.Name == recoveryBannerCookie {
|
||||
dismissed = c
|
||||
}
|
||||
}
|
||||
if dismissed == nil {
|
||||
t.Fatal("the dismissal must set its cookie")
|
||||
}
|
||||
// IT MUST BE A SESSION COOKIE — no MaxAge, no Expires. That is what makes it per-visit.
|
||||
if dismissed.MaxAge != 0 || !dismissed.Expires.IsZero() {
|
||||
t.Fatalf("the banner dismissal must be a SESSION cookie (MaxAge=0, no Expires), got MaxAge=%d Expires=%v", dismissed.MaxAge, dismissed.Expires)
|
||||
}
|
||||
// With the cookie presented, the banner is gone…
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/launcher", nil)
|
||||
req2.AddCookie(dismissed)
|
||||
if f.s.recoveryBannerVisible(req2) {
|
||||
t.Fatal("the banner must be hidden for the rest of this visit")
|
||||
}
|
||||
// …and NOTHING durable was written: a fresh visit (no cookie) sees it again.
|
||||
if !f.s.recoveryBannerVisible(httptest.NewRequest(http.MethodGet, "/launcher", nil)) {
|
||||
t.Fatal("the banner must be back on the next visit — the dismissal must not be persisted")
|
||||
}
|
||||
if v := f.sett.GetRecoveryOfferView(); v.OptOutEpoch != 0 {
|
||||
t.Fatalf("clicking the bar away must NOT record an opt-out, got %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// ── THE EXPLICIT OPT-OUT SILENCES THE BANNER AND NOTHING ELSE ───────────────────────────────────
|
||||
//
|
||||
// RED-PROOF: make the opt-out also clear the offer (or gate the backups entry point on it). The
|
||||
// route to the data then disappears and this test fails — the failure this whole session exists to
|
||||
// remove.
|
||||
func TestR241_ScenarioH_OptOutSilencesTheBannerOnly(t *testing.T) {
|
||||
f := newRecoveryFixture(t)
|
||||
f.s.recoveryInterrupts()
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
f.s.recoveryRemindOptOutHandler(rr, httptest.NewRequest(http.MethodPost, "/recovery/remind-optout", nil))
|
||||
if rr.Code != http.StatusFound {
|
||||
t.Fatalf("opt-out = %d, want a redirect", rr.Code)
|
||||
}
|
||||
|
||||
// 3. It silences the BANNER…
|
||||
if f.s.recoveryBannerVisible(httptest.NewRequest(http.MethodGet, "/launcher", nil)) {
|
||||
t.Fatal("the banner must be silenced after an explicit opt-out")
|
||||
}
|
||||
// 1. …and the ROUTE never goes away.
|
||||
if !f.s.recoveryOffer() {
|
||||
t.Fatal("CONDITION 1: the entry point must survive — silencing a reminder is not removing the route")
|
||||
}
|
||||
// …nor is it an abandonment: no countdown started.
|
||||
if st := f.s.backupMgr.AbandonStatus(); st.Active {
|
||||
t.Fatal("an opt-out must never start a countdown — it is not a decision about the data")
|
||||
}
|
||||
|
||||
// 2. A FRESH entry into the offered state reminds again.
|
||||
if err := f.sett.SetHubEscrowIdentityPresent(false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.s.recoveryInterrupts() // the edge falls
|
||||
if err := f.sett.SetHubEscrowIdentityPresent(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.s.recoveryInterrupts() // a new epoch
|
||||
if !f.s.recoveryBannerVisible(httptest.NewRequest(http.MethodGet, "/launcher", nil)) {
|
||||
t.Fatal("CONDITION 2: a fresh entry into the offered state must remind again")
|
||||
}
|
||||
}
|
||||
|
||||
// The backups-page entry point is bound to the OFFER and to nothing else — not to the interruption,
|
||||
// not to the banner, not to the opt-out. Pinned here because every one of those is a lever someone
|
||||
// could plausibly bind it to, and the last time a route disappeared it cost a walk.
|
||||
func TestR241_EntryPointSurvivesEveryDismissal(t *testing.T) {
|
||||
f := newRecoveryFixture(t)
|
||||
f.s.recoveryInterrupts()
|
||||
|
||||
f.s.recoveryPostponeHandler(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/recovery/postpone", nil))
|
||||
f.s.recoveryRemindOptOutHandler(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/recovery/remind-optout", nil))
|
||||
f.s.recoveryBannerDismissHandler(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/recovery/banner/dismiss", nil))
|
||||
|
||||
if !f.s.recoveryOffer() {
|
||||
t.Fatal("no combination of dismissals may remove the route to the customer's data")
|
||||
}
|
||||
// And the page itself still renders rather than redirecting away.
|
||||
rr := httptest.NewRecorder()
|
||||
f.s.recoveryPageHandler(rr, httptest.NewRequest(http.MethodGet, "/recovery", nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /recovery = %d after every dismissal, want 200", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A settled box shows no banner at all (Scenario D's surface half).
|
||||
func TestR241_SettledBoxHasNoBanner(t *testing.T) {
|
||||
f := newRecoveryFixture(t)
|
||||
if err := f.sett.SetHubEscrowIdentityPresent(false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.s.recoveryBannerVisible(httptest.NewRequest(http.MethodGet, "/launcher", nil)) {
|
||||
t.Fatal("a settled box must show nothing — no page, no banner, no entry point")
|
||||
}
|
||||
if f.s.recoveryInterrupts() {
|
||||
t.Fatal("a settled box must not interrupt")
|
||||
}
|
||||
}
|
||||
|
||||
// redirectBackTo must refuse an off-site target. A dismissal button that can be pointed at another
|
||||
// host is an open redirect on an authenticated page.
|
||||
func TestR241_BannerRedirectIsSameSiteOnly(t *testing.T) {
|
||||
for _, tc := range []struct{ in, want string }{
|
||||
{"/backups/remote", "/backups/remote"},
|
||||
{"//evil.example/x", "/launcher"},
|
||||
{"https://evil.example", "/launcher"},
|
||||
{"", "/launcher"},
|
||||
{"/", "/launcher"}, // len<=1 falls back; harmless and keeps the rule simple
|
||||
} {
|
||||
r := httptest.NewRequest(http.MethodPost, "/x?back="+tc.in, nil)
|
||||
if got := redirectBackTo(r, "/launcher"); got != tc.want {
|
||||
t.Errorf("redirectBackTo(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -337,13 +337,20 @@ func TestRecovery_D_WrongCodeFailsClosedAndIsKind(t *testing.T) {
|
||||
func TestRecovery_E_PostponeKeepsTheEntryPoint(t *testing.T) {
|
||||
f := newRecoveryFixture(t)
|
||||
|
||||
// v0.206.0 (R-241): the interruption is now epoch-scoped, so the epoch has to exist before a
|
||||
// dismissal can be recorded against it. recoveryInterrupts advances it on the edge, exactly as the
|
||||
// landing-page interception does in production.
|
||||
if !f.s.recoveryInterrupts() {
|
||||
t.Fatal("precondition: the full page should interrupt on the first entry into the offered state")
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
f.s.recoveryPostponeHandler(rr, httptest.NewRequest(http.MethodPost, "/recovery/postpone", nil))
|
||||
if rr.Code != http.StatusFound {
|
||||
t.Fatalf("postpone = %d, want a redirect", rr.Code)
|
||||
}
|
||||
if !f.sett.GetRecoveryNoticePostponed() {
|
||||
t.Fatal("the postpone was not recorded")
|
||||
// Recorded against the CURRENT epoch — the situation, not the screen for ever.
|
||||
if v := f.sett.GetRecoveryOfferView(); v.PostponedEpoch != v.Epoch || v.Epoch == 0 {
|
||||
t.Fatalf("the postpone was not recorded against the current epoch: %+v", v)
|
||||
}
|
||||
// The full page no longer interrupts…
|
||||
if f.s.recoveryInterrupts() {
|
||||
|
||||
@@ -416,6 +416,13 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.recoveryUnlockHandler(w, r)
|
||||
case path == "/recovery/postpone" && r.Method == http.MethodPost:
|
||||
s.recoveryPostponeHandler(w, r)
|
||||
// R-241 (v0.206.0): the per-visit banner dismissal and the durable reminder opt-out. They are
|
||||
// SEPARATE ROUTES because they are separate decisions — one is "not now", the other is "stop
|
||||
// asking about this situation", and neither removes the entry point on the backups page.
|
||||
case path == "/recovery/banner/dismiss" && r.Method == http.MethodPost:
|
||||
s.recoveryBannerDismissHandler(w, r)
|
||||
case path == "/recovery/remind-optout" && r.Method == http.MethodPost:
|
||||
s.recoveryRemindOptOutHandler(w, r)
|
||||
case path == "/dashboard":
|
||||
s.dashboardHandler(w, r)
|
||||
case path == "/launcher":
|
||||
|
||||
@@ -133,7 +133,19 @@
|
||||
<div class="card" style="border-left:3px solid var(--warn);margin:.75rem 0;padding:.75rem 1rem">
|
||||
<p style="margin:0 0 .35rem"><strong>Helyreállítási kód szükséges</strong></p>
|
||||
<p class="form-hint" style="margin:0 0 .5rem">A távoli mentések csak akkor állíthatók vissza egy teljes meghibásodás után, ha létrehozza a helyreállítási kódot.</p>
|
||||
{{if .EscrowAgentOK}}
|
||||
{{/* §7.3 / Q7 — THE TRAP THAT MUST NOT SURVIVE THIS SESSION. While a recovery is outstanding,
|
||||
creating a NEW code seals the CURRENT key and demotes the package that opens the earlier
|
||||
history to retained custody, which no shipped path can read (R-199). It also re-enables
|
||||
the recovery screen through the orphan route while invalidating the code that screen
|
||||
accepts. The button is therefore made UNAVAILABLE here rather than merely captioned:
|
||||
a warning beside a button is a warning people click past. */}}
|
||||
{{if .RecoveryOffer}}
|
||||
<p class="form-hint" style="margin:0">
|
||||
Ehhez a géphez <strong>egy korábbi helyreállítási kód tartozik</strong>, és a korábbi mentéseid még megvannak.
|
||||
Új kód létrehozása <strong>a régi mentéseidet elérhetetlenné tenné</strong>, ezért most nem indítható.
|
||||
Előbb <a href="/recovery">add meg a meglévő kódodat</a> — vagy ott jelezheted, ha nem kéred vissza a korábbi adatokat.
|
||||
</p>
|
||||
{{else if .EscrowAgentOK}}
|
||||
<a href="/backup/escrow" class="btn btn-sm btn-primary">Helyreállítási kód létrehozása</a>
|
||||
{{else}}
|
||||
<p class="form-hint" style="margin:0">A funkcióhoz az ügynök frissítése szükséges — a frissítés automatikusan megérkezik.</p>
|
||||
@@ -156,6 +168,22 @@
|
||||
{{if .EscrowAgentOK}}<a href="/backup/escrow" class="btn btn-sm btn-outline">Új helyreállítási kód készítése</a>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .AbandonActive}}
|
||||
{{/* R-241: the countdown, stated for the whole window on the page the choice was made from. */}}
|
||||
<div class="card" style="border-left:3px solid var(--warn);margin:.75rem 0;padding:.75rem 1rem">
|
||||
<p style="margin:0 0 .35rem"><strong>A korábbi mentések törlése folyamatban</strong></p>
|
||||
<p class="form-hint" style="margin:0 0 .5rem">
|
||||
A kérésed szerint a korábbi távoli mentéseidet <strong>{{.AbandonDate}}</strong> napján véglegesen töröljük
|
||||
(még <strong>{{.AbandonDaysLeft}} nap</strong>). Addig meggondolhatod magad: ha megvan a helyreállítási kódod,
|
||||
a mentéseid visszaszerezhetők, és a törlés elmarad.
|
||||
</p>
|
||||
<a href="/recovery" class="btn btn-sm btn-primary">Mégis visszaszerzem a kóddal</a>
|
||||
</div>
|
||||
{{else if .AbandonPurging}}
|
||||
<div class="card" style="border-left:3px solid var(--border,#2a3142);margin:.75rem 0;padding:.75rem 1rem">
|
||||
<p class="form-hint" style="margin:0">A korábbi távoli mentéseid törlése megtörtént. A hozzájuk tartozó lezárt helyreállítási csomag eltávolítása még folyamatban van.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .OffboxConfigured}}
|
||||
<div class="schedule-actions" style="margin-top:1rem">
|
||||
<form method="POST" action="/backup/offbox/run" style="display:inline">{{.CSRFField}}
|
||||
|
||||
@@ -130,6 +130,37 @@
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{/* R-241 (v0.206.0) — the recovery reminder bar. Rendered while the situation holds, the customer
|
||||
has not opted out of reminders for THIS situation, and they have not clicked it away this visit.
|
||||
It NEVER replaces the entry point on the Távoli mentés page — silencing a reminder is not the
|
||||
same as removing the route. */}}
|
||||
{{if .RecoveryBanner}}
|
||||
<div class="alerts-container">
|
||||
<div class="alert-banner alert-banner-warning">
|
||||
<span class="alert-icon"><svg class="ico"><use href="#i-triangle-alert"/></svg></span>
|
||||
<span class="alert-message">
|
||||
{{if .RecoveryAbandonDays}}
|
||||
A korábbi távoli mentéseidet <strong>{{.RecoveryAbandonDays}} nap múlva</strong> ({{.RecoveryAbandonDate}}) véglegesen töröljük, a kérésed szerint. Addig még visszaszerezheted őket a helyreállítási kóddal.
|
||||
{{else}}
|
||||
A korábbi távoli mentéseid megvannak, de ehhez a géphez a helyreállítási kódod szükséges.
|
||||
{{end}}
|
||||
<a href="/recovery">Megnézem</a>
|
||||
</span>
|
||||
<span class="alert-actions">
|
||||
<form method="POST" action="/recovery/banner/dismiss" style="display:inline">
|
||||
{{.CSRFField}}<input type="hidden" name="back" value="{{.RecoveryBannerBack}}">
|
||||
<button type="submit" class="btn btn-sm btn-outline">Most nem</button>
|
||||
</form>
|
||||
{{if not .RecoveryAbandonDays}}
|
||||
<form method="POST" action="/recovery/remind-optout" style="display:inline">
|
||||
{{.CSRFField}}<input type="hidden" name="back" value="{{.RecoveryBannerBack}}">
|
||||
<button type="submit" class="btn btn-sm btn-outline">Ne emlékeztessen újra</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Alerts}}
|
||||
<div class="alerts-container">
|
||||
{{range .Alerts}}
|
||||
|
||||
@@ -166,16 +166,22 @@
|
||||
<!-- ── THE EXCEPTIONAL PATH. Deliberately not an equal third button. ───────────────────── -->
|
||||
<hr style="margin:1.5rem 0;border:none;border-top:1px solid var(--border,#2a3142)">
|
||||
{{if .ConfirmSetAside}}
|
||||
{{/* §7.3 / §2.4 — THE COPY CHANGES WITH THE BEHAVIOUR (v0.206.0, R-241).
|
||||
It used to promise "félretesszük — nem töröljük". After this change the set-aside history IS
|
||||
deleted, on a date, together with the sealed package that protects it — which is what lets
|
||||
the question end instead of returning at every login. A confirmation that still said "we do
|
||||
not delete" would be the most consequential false sentence on the whole surface. */}}
|
||||
<div class="alert alert-error">
|
||||
<p><strong>Biztosan nem kéred vissza a korábbi mentéseket?</strong></p>
|
||||
<p>Ha megerősíted:</p>
|
||||
<ul>
|
||||
<li>a korábbi mentéseket <strong>félretesszük — nem töröljük</strong>;</li>
|
||||
<li>a félretett mentések <strong>innen többé nem nyithatók meg</strong> — sem kóddal, sem anélkül;</li>
|
||||
<li>a korábbi mentéseket <strong>most félretesszük</strong>, és <strong>{{.AbandonGraceDays}} nap múlva véglegesen töröljük</strong> — a lezárt helyreállítási csomaggal együtt;</li>
|
||||
<li>a {{.AbandonGraceDays}} nap alatt <strong>meggondolhatod magad</strong>: ha előkerül a helyreállítási kódod, a mentéseid visszaszerezhetők, és a törlés elmarad;</li>
|
||||
<li>a pontos dátumot a <strong>Távoli mentés</strong> oldalon végig látni fogod, és emlékeztetni is fogunk;</li>
|
||||
<li>a gép <strong>új, üres mentési tárolót kezd</strong>, és mostantól oda ment;</li>
|
||||
<li>ez az oldal <strong>többé nem jelenik meg</strong>.</li>
|
||||
<li>a törlés után <strong>ez a kérdés nem jön vissza többé</strong> — mert nem marad mit visszaszerezni.</li>
|
||||
</ul>
|
||||
<p>Ha csak most nincs kéznél a kódod, válaszd inkább a „Most nem” lehetőséget.</p>
|
||||
<p>Ha csak most nincs kéznél a kódod, válaszd inkább a „Most nem” lehetőséget — az semmit nem indít el.</p>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<form method="POST" action="/backup/offbox/reset">
|
||||
|
||||
Reference in New Issue
Block a user