diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cccc1e..125d1bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,36 @@ +## v0.245.0 — the household is asked for the recovery code, and the page says „szünetel" until then (2026-09-16, R-543) + +**MinAgent: 0.131.0** (unchanged — nothing here needs a newer agent) + +- **R-543 — off-site backup is ON by default and does NOT RUN until the household creates its + recovery code, and nothing anywhere asked them to.** The pause is the DESIGN, not a defect: the + escrow is zero-knowledge (07-backup-architecture §6.3), the household's code is the only key, and a + run started without one would produce a copy nobody could ever open. Nothing in this release + touches that mechanism. What was missing is the ASKING. Measured on a fresh box 2026-09-16: the + off-site tier sat at „Kulcsletétre vár" from the first minute, no snapshot was ever written, and + the app-backup page told the household their files were protected by that very copy. + - **A reminder bar on every dashboard page** while the off-site tier is configured and its escrow + is not complete: „A távoli mentés szünetel, amíg nem hozod létre a helyreállítási kódot." with + the route to `/backup/escrow`. It is the **R-241 bar, second instance** — same session-cookie + dismissal („Most nem"), back at the next visit, gone for good when the state is `escrowed`. **No + second banner system was built.** It hangs off `executeTemplate`, the single render choke point, + so it reaches every authenticated page rather than the three pages a per-handler helper would + have covered; the login and claim pages render through `ExecuteTemplate` directly and never pass + through it, and a session check keeps it off the public guest share page. + - **The tier-1 sentence now renders by STATE, not by the app's shape.** v0.244.0 fixed the label + (R-537) and put a new promise in its place: „a távoli másolat … védi", printed whenever an app + keeps files on the drive. On a paused box that named a protection which had never run once. It + now reads „…**védené** — a távoli mentés a helyreállítási kód létrehozásáig szünetel" with the + route out; with no off-site and no second drive it says there is no copy and names both ways to + get one. It takes `tier3State`'s own vocabulary rather than re-deriving the state, so the row and + the sentence cannot disagree. + - Red-proofs, each seen failing: delete the one `addEscrowBanner` line from `executeTemplate` → + `TestR543_A_PausedBoxAsksOnEveryPage` fails on BOTH pages („does not tell the household the + off-site copy is PAUSED"); compute the sentence from the app's shape alone as v0.244.0 did → + `TestR543_Tier1Sentence_PausedStateDoesNotPromise` fails quoting the exact promise a fresh box + read all day. Negative controls: an escrowed box is not nagged, an unconfigured box is not + nagged, and an app with no drive-side files gets no sentence in any state. + ## v0.244.0 — the backup page stops promising what it does not hold, and a restore refuses to lie (2026-09-16, R-537 / R-538 / R-536) **MinAgent: 0.131.0** (unchanged — nothing here needs a newer agent) diff --git a/controller/README.md b/controller/README.md index b717b49..a1ad72d 100644 --- a/controller/README.md +++ b/controller/README.md @@ -1576,6 +1576,15 @@ page renders one of four real states via the pure `tier3State` helper (`internal never a false success) / `active` (status badge + `restic → ` + relative last-run). Off-box run status is repo-global (one `LastRun`); no per-app run time is fabricated. +**The household is ASKED for the recovery code (v0.245.0, R-543).** While the off-site tier is +configured and its escrow state is not `escrowed`, every authenticated page carries a reminder bar — +„A távoli mentés szünetel, amíg nem hozod létre a helyreállítási kódot." linking `/backup/escrow`. +It is the **R-241 bar, second instance** (`internal/web/escrow_banner.go`): same session-cookie +dismissal, back at the next visit, gone for good when the state is `escrowed`. It is added in +`executeTemplate`, the single render choke point, so it reaches every page; the login and claim +pages bypass that function, and a session check keeps it off the public guest share page. The pause +itself is UNCHANGED — it is the zero-knowledge escrow design, not a defect. + #### Restore (`internal/backup/restore.go`) Both **Tier 1** (restic) and **Tier 2** (rsync) restores are supported. All deployed apps @@ -1645,7 +1654,7 @@ Every app starts as yellow (1 tier only). Green requires Tier 2 configured with ("Kulcsletetre var"), `active` (status badge + "restic -> " + relative last-run) **Backup contents per app** (shown per tier): -- Apps whose files live on the data drive (class A: calibre-web, immich, nextcloud, paperless-ngx): Tier 1 reads **"DB + Konfig"** — a Tier-1 unit has no file-copy step, so it does not hold them — and a sentence under the row says the files are protected by the off-site copy (and a second drive). Tier 2/3 read "DB + Konfig + Adatok", because those tiers DO carry the file legs. +- Apps whose files live on the data drive (class A: calibre-web, immich, nextcloud, paperless-ngx): Tier 1 reads **"DB + Konfig"** — a Tier-1 unit has no file-copy step, so it does not hold them — and a sentence under the row says where the files ARE protected. **That sentence renders by tier-3 STATE, not by the app's shape (v0.245.0, R-543)** (`driveFilesNoteFor`): `active` → „…védi"; `escrow_pending` → „…**védené** — a távoli mentés a helyreállítási kód létrehozásáig szünetel" + the route; no off-site and no second drive → „Az alkalmazás fájljairól jelenleg nincs másolat…" + both ways out. It takes `tier3State`'s own vocabulary, so the row and the sentence cannot disagree. Tier 2/3 read "DB + Konfig + Adatok", because those tiers DO carry the file legs. - Apps whose data is entirely in Docker volumes (45 of 53 templates): "Konfig + DB + Adatok" or "Konfig + Adatok" on every tier — the unit really does hold their data. - Apps with DB only: "DB + Konfig" - **The restore refuses rather than lying (v0.244.0, R-538):** „Visszaállítás indítása" on a unit that cannot return an app's drive-side files is REFUSED before anything is stopped, naming the route that can (the off-site „Teljes visszaállítás (fájlok + adatbázis)", or the second drive's „Fájlok visszaállítása"), and saying plainly when no copy exists. A database-and-settings-only restore is a separately-worded second step. diff --git a/controller/internal/web/backup_page_state.go b/controller/internal/web/backup_page_state.go index 0cb05ac..309752e 100644 --- a/controller/internal/web/backup_page_state.go +++ b/controller/internal/web/backup_page_state.go @@ -47,3 +47,36 @@ func tier3State(configured, toggled bool, escrowState string) string { return "active" } } + +// driveFilesNoteFor is the one sentence under a class-A app's Tier-1 row: where the household's own +// FILES are protected, said in the state the box is actually in. +// +// R-543. v0.244.0 shipped this sentence in one form — „a távoli másolat … védi" — and it was true of +// the design and false of a fresh box: off-site is ON by default (hub v0.116.0) but PAUSED until the +// household performs the escrow ceremony, which nothing asked them to do. A sentence that promises a +// copy which is not running is the same class of lie R-537 and R-538 were filed for, committed by the +// fix for them. +// +// It is pure, and it takes tier3State's OWN vocabulary rather than re-deriving the state, so the row +// and the sentence can never disagree: +// "active" → the copy exists and runs → „védi" +// "escrow_pending" → enabled, paused for the code → „védené … szünetel" + the route to fix it +// "off"/"unconfig" → no off-site at all → say so, and name both ways out +// The link is returned separately so the template renders a real anchor and the copy gate sees plain +// text; empty note means render nothing (an app whose data is in its volumes needs no sentence). +func driveFilesNoteFor(hasDriveFileLegs bool, tier3State string, tier2Configured bool) (note, linkHref, linkText string) { + if !hasDriveFileLegs { + return "", "", "" + } + switch tier3State { + case "active": + return "Az alkalmazás fájljait a távoli másolat (és a második meghajtó) védi — ez a helyi mentés a beállításokat és az adatbázist tartalmazza.", "", "" + case "escrow_pending": + return "Az alkalmazás fájljait a távoli másolat védené — a távoli mentés a helyreállítási kód létrehozásáig szünetel.", "/backup/escrow", "Helyreállítási kód létrehozása" + default: + if tier2Configured { + return "Az alkalmazás fájljait a második meghajtóra készülő másolat védi — ez a helyi mentés a beállításokat és az adatbázist tartalmazza.", "", "" + } + return "Az alkalmazás fájljairól jelenleg nincs másolat — kapcsold be a távoli mentést vagy adj hozzá második meghajtót.", "/backups/remote", "Távoli mentés beállítása" + } +} diff --git a/controller/internal/web/escrow_banner.go b/controller/internal/web/escrow_banner.go new file mode 100644 index 0000000..780a5c3 --- /dev/null +++ b/controller/internal/web/escrow_banner.go @@ -0,0 +1,91 @@ +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 + } + 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) +} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 8ee36b9..153821d 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -1182,7 +1182,12 @@ type AppBackupRow struct { Tier23Contents string // DriveFilesNote is non-empty exactly when this app keeps files on the data drive that a Tier-1 // unit cannot hold — the one sentence that tells the household where those files ARE protected. - DriveFilesNote string + // Its wording follows the tier-3 STATE, not the app's shape (R-543): a copy that is paused for + // the recovery code protects nothing yet, and „védi" would be the same lie R-537 was filed for. + // DriveFilesNoteLink is the route out of that state, empty when there is nothing to press. + DriveFilesNote string + DriveFilesNoteLink string + DriveFilesNoteLinkText string // RestoreHeld (R-379) — this app is deliberately stopped because a database restore failed AND // the rollback failed. Distinct from any backup status: it is about the app's LIVE data, not its @@ -1379,11 +1384,6 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup tier1Contents := withData(app.HasVolumeData && !hasDriveFileLegs) // Tier 2 / Tier 3 carry the file legs, so for them „Adatok" is true either way. tier23Contents := withData(app.HasVolumeData || hasDriveFileLegs) - driveFilesNote := "" - if hasDriveFileLegs { - driveFilesNote = "Az alkalmazás fájljait a távoli másolat (és a második meghajtó) védi — ez a helyi mentés a beállításokat és az adatbázist tartalmazza." - } - slug := "" if s.stackMgr != nil { if st, ok := s.stackMgr.GetStack(app.StackName); ok { @@ -1403,7 +1403,6 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup HDDSizeHuman: app.HDDSizeHuman, Tier1Contents: tier1Contents, Tier23Contents: tier23Contents, - DriveFilesNote: driveFilesNote, Tier1DBStatus: tier1DBStatus, } @@ -1515,6 +1514,12 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup } } + // The one sentence about the app's own FILES. Set HERE, at the end of the loop, because it + // depends on the tier states resolved above — a note computed from the app's shape alone + // promised a copy that was paused (R-543). See driveFilesNoteFor. + row.DriveFilesNote, row.DriveFilesNoteLink, row.DriveFilesNoteLinkText = + driveFilesNoteFor(hasDriveFileLegs, row.Tier3State, row.Tier2Configured) + rows = append(rows, row) } // R-487: the list is keyed on the DRIVES, not on what is deployed — a removed app whose unit was diff --git a/controller/internal/web/r543_escrow_banner_test.go b/controller/internal/web/r543_escrow_banner_test.go new file mode 100644 index 0000000..dac63ec --- /dev/null +++ b/controller/internal/web/r543_escrow_banner_test.go @@ -0,0 +1,148 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// ── R-543 — the household is ASKED for the recovery code ──────────────────────────────────────── +// +// Off-site backup is ON by default and does NOT run until the escrow ceremony is done. A fresh box +// measured on 2026-09-16 sat at „Kulcsletétre vár" with nothing anywhere asking for the code, while +// the backup page told the household their files were protected. +// +// These drive the REAL pages through ServeHTTP, so they bite on the WIRING — the bar hangs off +// executeTemplate, and a bar that renders only where someone remembered to call a helper is the +// seam-built-but-never-wired failure this repo has shipped four times. + +const ( + escrowBarSentence = "A távoli mentés szünetel, amíg nem hozod létre a helyreállítási kódot." + escrowBarLink = `href="/backup/escrow"` +) + +// escrowServer builds a box whose off-site tier is genuinely CONFIGURED — enabled, valid, with the +// key and password files on disk — and whose escrow sits in the given state. The fixture asserts +// OffboxConfigured() itself: a test that silently loses its precondition measures nothing. +func escrowServer(t *testing.T, escrowState string) *Server { + t.Helper() + s := newDashboardServer(t, time.Time{}) + if err := s.settings.SetOffboxTarget(&settings.OffboxTarget{ + Enabled: true, Host: "nas.local", User: "felhom", RepoPath: "/srv/repo", + EscrowState: escrowState, + }); err != nil { + t.Fatalf("set offbox target: %v", err) + } + dir := filepath.Join(s.cfg.Paths.DataDir, "offbox") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "ssh_key"), []byte("not-a-real-key"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "repo_password"), []byte(strings.Repeat("a", 64)), 0o600); err != nil { + t.Fatal(err) + } + if !s.backupMgr.OffboxConfigured() { + t.Fatal("fixture invalid: the off-site tier is not configured, so nothing here measures the paused state") + } + return s +} + +// ── SCENARIO A — paused, and EVERY dashboard page asks ────────────────────────────────────────── +// +// RED-PROOF: remove `s.addEscrowBanner(data, r)` from executeTemplate (server.go) and this fails on +// every page — the fresh box goes back to saying nothing while the copy never runs. +func TestR543_A_PausedBoxAsksOnEveryPage(t *testing.T) { + s := escrowServer(t, "pending") + + for _, path := range []string{"/dashboard", "/launcher"} { + rec := getPage(t, s, path) + if rec.Code != 200 { + t.Fatalf("GET %s = %d: %s", path, rec.Code, rec.Body.String()) + } + body := rec.Body.String() + if !strings.Contains(body, escrowBarSentence) { + t.Errorf("R-543: %s does not tell the household the off-site copy is PAUSED. "+ + "The tier is on, nothing is running, and the page is silent about it", path) + } + if !strings.Contains(body, escrowBarLink) { + t.Errorf("R-543: %s states the pause but names no route to end it — a reminder without "+ + "its door is the shape that left a fresh box waiting indefinitely", path) + } + } +} + +// ── SCENARIO B — the finished state is silent (negative control) ──────────────────────────────── +func TestR543_B_EscrowedBoxIsNotNagged(t *testing.T) { + s := escrowServer(t, "escrowed") + + rec := getPage(t, s, "/dashboard") + if rec.Code != 200 { + t.Fatalf("GET /dashboard = %d", rec.Code) + } + if strings.Contains(rec.Body.String(), escrowBarSentence) { + t.Error("R-543: a box whose recovery code EXISTS is still told the copy is paused — " + + "the bar must disappear for good when the state is escrowed, not merely be dismissable") + } +} + +// ── SCENARIO C — an UNCONFIGURED off-site tier is not nagged either ───────────────────────────── +// +// A box with no off-site target at all has a different, honest sentence on the backups page. Asking +// it for a recovery code would be asking about a copy it has not chosen. +func TestR543_C_UnconfiguredBoxIsNotNagged(t *testing.T) { + s := newDashboardServer(t, time.Time{}) // no offbox target at all + + rec := getPage(t, s, "/dashboard") + if rec.Code != 200 { + t.Fatalf("GET /dashboard = %d", rec.Code) + } + if strings.Contains(rec.Body.String(), escrowBarSentence) { + t.Error("R-543: a box with no off-site tier is asked for a recovery code it does not need") + } +} + +// ── SCENARIO D — „Most nem" silences it for the VISIT, and the next visit asks again ──────────── +// +// RED-PROOF: give the cookie a MaxAge in escrowBannerDismissHandler and the last assertion fails — +// the reminder becomes permanent while the copy stays paused, which is the R-241 lesson exactly. +func TestR543_D_DismissIsForThisVisitOnly(t *testing.T) { + s := escrowServer(t, "pending") + + // The dismiss POST issues a SESSION cookie: no Max-Age and no Expires. + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/backup/escrow/banner/dismiss", nil) + s.ServeHTTP(rec, req) + if rec.Code != http.StatusFound { + t.Fatalf("dismiss POST = %d, want 302: %s", rec.Code, rec.Body.String()) + } + setCookie := rec.Header().Get("Set-Cookie") + if !strings.Contains(setCookie, escrowBannerCookie+"=1") { + t.Fatalf("dismiss set no banner cookie: %q", setCookie) + } + if strings.Contains(strings.ToLower(setCookie), "max-age") || strings.Contains(strings.ToLower(setCookie), "expires") { + t.Errorf("R-543: the dismissal outlives the browser session (%q). The off-site copy is still "+ + "paused tomorrow, so the question must still be asked tomorrow", setCookie) + } + + // A visit carrying the cookie: quiet. + rec2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodGet, "/dashboard", nil) + req2.AddCookie(&http.Cookie{Name: escrowBannerCookie, Value: "1"}) + s.ServeHTTP(rec2, req2) + if strings.Contains(rec2.Body.String(), escrowBarSentence) { + t.Error("R-543: the dismissal does not quieten the bar for this visit") + } + + // A NEW visit (no cookie): asked again. + if !strings.Contains(getPage(t, s, "/dashboard").Body.String(), escrowBarSentence) { + t.Error("R-543: the next visit is not asked again, although nothing about the box changed") + } +} diff --git a/controller/internal/web/r543_tier1_sentence_test.go b/controller/internal/web/r543_tier1_sentence_test.go new file mode 100644 index 0000000..4cff451 --- /dev/null +++ b/controller/internal/web/r543_tier1_sentence_test.go @@ -0,0 +1,80 @@ +package web + +import ( + "strings" + "testing" +) + +// ── R-543 (second half) — the tier-1 sentence tells the truth about the STATE ─────────────────── +// +// v0.244.0 fixed the label (R-537) and introduced a new promise in its place: „a távoli másolat … +// védi", printed from the app's SHAPE alone. On a fresh box the off-site tier is on and PAUSED for +// the recovery code, so that sentence named a protection that had never run once. +// +// The states are tier3State's own vocabulary — the row and the sentence read one source. + +const ( + protectedNow = "védi" // the active claim + protectedWould = "védené" // the paused, conditional form + pausedClause = "szünetel" // ...and WHY it has not happened +) + +func TestR543_Tier1Sentence_ActiveStateKeepsThePromise(t *testing.T) { + note, link, _ := driveFilesNoteFor(true, "active", false) + if !strings.Contains(note, protectedNow) { + t.Errorf("a RUNNING off-site copy must say so plainly; got %q", note) + } + if link != "" { + t.Errorf("a box in the finished state is offered a button it does not need: %q", link) + } +} + +// RED-PROOF: return the v0.244.0 sentence for every state and this fails — the paused box is told +// its files are protected by a copy that has never run. +func TestR543_Tier1Sentence_PausedStateDoesNotPromise(t *testing.T) { + note, link, linkText := driveFilesNoteFor(true, "escrow_pending", false) + + if strings.Contains(note, protectedNow) { + t.Errorf("R-543: the sentence claims the files ARE protected while the copy is paused for the "+ + "recovery code. This is the exact promise a fresh box read for its whole first day: %q", note) + } + if !strings.Contains(note, protectedWould) || !strings.Contains(note, pausedClause) { + t.Errorf("R-543: the paused sentence must say the copy WOULD protect them and that it is "+ + "waiting; got %q", note) + } + if link != "/backup/escrow" || linkText == "" { + t.Errorf("R-543: the paused sentence names no route out (link=%q text=%q)", link, linkText) + } +} + +// No off-site, no second drive: say there is no copy. Silence here was how the gap survived. +func TestR543_Tier1Sentence_NoCopyAtAllSaysSo(t *testing.T) { + note, link, _ := driveFilesNoteFor(true, "off", false) + if note == "" { + t.Fatal("R-543: an app whose files have NO copy renders no sentence at all") + } + if strings.Contains(note, protectedNow) || strings.Contains(note, protectedWould) { + t.Errorf("R-543: a box with no copy of these files still talks about protection: %q", note) + } + if link == "" { + t.Error("R-543: the no-copy sentence names neither of the two ways out") + } +} + +// A second drive IS a real copy of the files — it must not be told it has none. +func TestR543_Tier1Sentence_SecondDriveCounts(t *testing.T) { + note, _, _ := driveFilesNoteFor(true, "off", true) + if !strings.Contains(note, protectedNow) { + t.Errorf("a box whose second drive holds the files is told they are unprotected: %q", note) + } +} + +// Negative control: an app whose data really is inside its captured volumes gets no sentence at all, +// in any state — the note exists only for drive-side file legs. +func TestR543_Tier1Sentence_NoFileLegsNoSentence(t *testing.T) { + for _, st := range []string{"active", "escrow_pending", "off", "unconfigured"} { + if note, _, _ := driveFilesNoteFor(false, st, false); note != "" { + t.Errorf("state %q: an app with no drive-side files gets a sentence about them: %q", st, note) + } + } +} diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 2ef251e..fc1aba8 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -568,6 +568,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { case path == "/backup/shares/place" && r.Method == http.MethodPost: s.sharesPlaceHandler(w, r) // Controller-driven escrow ceremony wizard (v0.127.0): the customer-facing R flow. + case path == "/backup/escrow/banner/dismiss" && r.Method == http.MethodPost: + s.escrowBannerDismissHandler(w, r) case path == "/backup/escrow" && r.Method == http.MethodGet: s.escrowWizardPageHandler(w, r) // fork-4: escrow atomicity — confirm the R-escrow ceremony; DR pre-place the recovered password. @@ -759,6 +761,11 @@ func (s *Server) executeTemplate(w http.ResponseWriter, r *http.Request, name st } data["CSRFField"] = s.csrfField(r) data["CSRFToken"] = s.csrfToken(r) + // R-543 — the escrow reminder hangs HERE, on the single render choke point, so it reaches every + // dashboard page instead of the three pages a per-handler call would have covered. The login and + // claim pages render through s.tmpl.ExecuteTemplate directly and never pass through here; the + // session check inside keeps it off the guest share page, which has no admin session. + s.addEscrowBanner(data, r) var buf bytes.Buffer if err := s.tmpl.ExecuteTemplate(&buf, name, data); err != nil { s.logger.Printf("[ERROR] [web] Template error (%s): %v", name, err) diff --git a/controller/internal/web/templates/backups_apps.html b/controller/internal/web/templates/backups_apps.html index 8a96e23..e1242e1 100644 --- a/controller/internal/web/templates/backups_apps.html +++ b/controller/internal/web/templates/backups_apps.html @@ -203,7 +203,7 @@ {{end}} {{.Tier1Contents}} - {{if .DriveFilesNote}}{{.DriveFilesNote}}{{end}} + {{if .DriveFilesNote}}{{.DriveFilesNote}}{{if .DriveFilesNoteLink}} {{.DriveFilesNoteLinkText}} →{{end}}{{end}} {{if and .HasDB (eq .Tier1DBStatus "error")}} DB dump hiba {{end}} diff --git a/controller/internal/web/templates/layout.html b/controller/internal/web/templates/layout.html index 4e7e480..376d96a 100644 --- a/controller/internal/web/templates/layout.html +++ b/controller/internal/web/templates/layout.html @@ -174,6 +174,27 @@ {{end}} +{{/* R-543 (v0.245.0) — the escrow reminder bar. The off-site tier is ON by default and does NOT RUN + until the household creates its recovery code (zero-knowledge escrow: their code is the only key). + The pause is the design; what was missing was ASKING. Same mechanism as the R-241 bar above — + dismissable for the visit, back at the next one, gone for good when the escrow completes. */}} +{{if .EscrowBanner}} +
+
+ + + A távoli mentés szünetel, amíg nem hozod létre a helyreállítási kódot. + Helyreállítási kód létrehozása → + + +
+ {{.CSRFField}} + +
+
+
+
+{{end}} {{if .Alerts}}
{{range .Alerts}}