Files
felhom-controller/controller/internal/web/escrow_banner.go
T
admin 1170b48e59 fix(R-1218, R-1219): stage the off-site password when its file exists; no recovery ceremony during a recovery offer
R-1218: escrowStart stages when OffboxRepoPasswordOnBox() (not OffboxConfigured()). R-1219: the top bar hides during an
offer; the start handler answers 409 (api.escrow.recovery_offer_open, hu+en) before staging. Red-proved. Commit only.
2026-10-26 20:20:59 +01:00

102 lines
4.8 KiB
Go

package web
import "net/http"
// The escrow reminder bar (R-543) — the household is ASKED for its recovery code.
//
// Off-site backup is ON by default (hub v0.116.0), and it does not RUN until the household has
// created its recovery code. That pause is a DESIGN, not a defect: the escrow is zero-knowledge, the
// household's code is the only key, and a run started without one would produce a copy nobody could
// ever open. Nothing here touches that mechanism.
//
// What was missing is that nothing ASKED. A fresh box sat at „Kulcsletétre vár" indefinitely while
// the backup page told the household their files were protected — measured on a fresh box
// 2026-09-16. A promise plus a pause nobody mentions is the same class of untruth as R-537 and R-538.
//
// This is the R-241 reminder bar, SECOND INSTANCE, on purpose: same session-cookie dismissal, same
// layout block shape, same "back at the next visit" behaviour. No second banner system was built.
const escrowBannerCookie = "felhom_escrow_banner"
// escrowPaused reads the same two facts tier3State reads (backup_page_state.go): the off-site tier is
// configured, and its escrow is not complete. Deliberately one predicate — a second copy would let
// the bar and the app rows tell the household two different stories about the same box.
func (s *Server) escrowPaused() bool {
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() || s.settings == nil {
return false
}
t := s.settings.GetOffboxTarget()
if t == nil {
return false
}
// The vocabulary is "" | "pending" | "escrowed" (agentapi). Anything that is not the finished
// state is a paused state — fail LOUD, so an unknown value reminds rather than goes quiet.
return t.EscrowState != "escrowed"
}
// hasAdminSession — the household is logged in right now.
//
// It mirrors RequireAuth's own check (auth.go), including the legacy-open box where no password is
// configured and every page is served. It exists because the bar hangs off executeTemplate, the
// render choke point for EVERY page: the login and claim pages bypass that function entirely, and
// this check is what additionally keeps a household reminder off the public guest share page, which
// carries a capability token and no admin session.
func (s *Server) hasAdminSession(r *http.Request) bool {
if !s.authEnabled() {
return true
}
c, err := r.Cookie(sessionCookieName)
return err == nil && s.isValidSession(c.Value)
}
// escrowBannerVisible — logged in, the tier is paused, and they have not clicked it away this visit.
func (s *Server) escrowBannerVisible(r *http.Request) bool {
if r == nil || !s.hasAdminSession(r) || !s.escrowPaused() {
return false
}
// R-1219: during a recovery offer a NEW code would replace the key that opens the old off-site copy (R-199) — the
// backups page hides the ceremony then, and so does this bar. Pinned by TestR1219_NoCeremonyDuringARecoveryOffer.
if s.recoveryOffer() {
return false
}
// R-546: while the agent says the ceremony cannot run yet, do not urge the household into it.
// Unknown readiness keeps the bar (fail loud).
if ready, known := s.escrowReadiness(r.Context(), false); known && !ready {
return false
}
if c, err := r.Cookie(escrowBannerCookie); err == nil && c.Value == "1" {
return false
}
return true
}
// addEscrowBanner is called from executeTemplate for every authenticated page.
func (s *Server) addEscrowBanner(data map[string]interface{}, r *http.Request) {
if data == nil || !s.escrowBannerVisible(r) {
return
}
data["EscrowBanner"] = true
data["EscrowBannerBack"] = r.URL.Path
if data["CSRFField"] == nil {
data["CSRFField"] = s.csrfField(r)
}
// STATE, never customer data: which page, and the reason the bar is up. DEBUG because this fires
// on every page render and INFO is the operator's state-change level.
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] escrow reminder: rendered on %s (offsite configured, escrow not complete)", r.URL.Path)
}
}
// escrowBannerDismissHandler records „Most nem" (POST /backup/escrow/banner/dismiss) — a browser
// SESSION cookie and nothing durable, exactly like R-241. The off-site tier is still paused whether
// or not anyone clicked, so the bar is back at the next visit and gone for good only when the escrow
// state becomes "escrowed".
func (s *Server) escrowBannerDismissHandler(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: escrowBannerCookie, Value: "1", Path: "/",
HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: r.TLS != nil,
// NO MaxAge and NO Expires — a session cookie, deliberately.
})
s.logger.Printf("[INFO] [web] escrow reminder: dismissed for this browser session; the off-site tier stays paused until the recovery code exists")
http.Redirect(w, r, redirectBackTo(r, "/launcher"), http.StatusFound)
}