diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cf88d1..5ca28ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ## [Unreleased] +- panel: every authenticated page now ends with the running version + (`SelfPost 1.1.0`) in a small footer. It is the value a backup manifest is + checked against on restore, and the first thing to establish when the panel + behaves unexpectedly. The login and setup pages deliberately do not show it. + - panel: the domain page now shows the **SPF and DMARC records it expects**, with host, value and a Copy button, next to the DKIM record it already showed — previously it only said "also configure SPF and DMARC (see the diff --git a/internal/web/static/panel.css b/internal/web/static/panel.css index 6bcaa2a..855e093 100644 --- a/internal/web/static/panel.css +++ b/internal/web/static/panel.css @@ -63,6 +63,9 @@ td.actions { text-align: right; } @media (prefers-color-scheme: dark) { .code { background: #14171a !important; border-color: #2b3138 !important; } } h2 { font-size: 1.05rem; margin: 0 0 0.4rem; } .back { display: inline-block; margin-bottom: 1rem; } +/* Build version, closing every authenticated page. Quiet on purpose: it is + reference material, not something to read on the way past. */ +.version { margin-top: 1.6rem; text-align: right; font-size: 0.8rem; color: #6b7280; } select, textarea { width: 100%; padding: 0.55rem 0.7rem; font-size: 1rem; border: 1px solid #cfd4da; border-radius: 6px; background: #fff; color: inherit; diff --git a/internal/web/templates.go b/internal/web/templates.go index acf0518..90a9b8a 100644 --- a/internal/web/templates.go +++ b/internal/web/templates.go @@ -79,10 +79,13 @@ func (s *Server) render(w http.ResponseWriter, status int, page string, data any // The layout's navigation compares .Active against each item, so the key // must exist on every authenticated page. Defaulting it here keeps a page // that forgets it from failing to render — it simply highlights nothing. + // .Version, shown in the layout's footer, is supplied the same way: it is + // the same value on every page, so no handler should have to pass it. if m, ok := data.(map[string]any); ok { if _, has := m["Active"]; !has { m["Active"] = "" } + m["Version"] = s.cfg.Version } var buf bytes.Buffer if err := tmpl.ExecuteTemplate(&buf, "layout.html", data); err != nil { diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index 112f70b..10bf9d7 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -19,6 +19,11 @@
{{if .User}}{{template "nav" .}}{{end}} {{template "content" .}} +{{/* The running version, on every authenticated page: it is what a backup + manifest is checked against on restore and the first thing to establish + when something behaves unexpectedly. Only for signed-in administrators — + the login and setup pages must not advertise it to the internet. */}} +{{if .User}}{{end}}
{{end}} diff --git a/internal/web/templates_test.go b/internal/web/templates_test.go index b8c6b32..2a22251 100644 --- a/internal/web/templates_test.go +++ b/internal/web/templates_test.go @@ -3,6 +3,8 @@ package web import ( "bytes" "io/fs" + "net/http" + "net/http/httptest" "path" "regexp" "strings" @@ -27,6 +29,68 @@ func TestEveryPageResolvesNav(t *testing.T) { } } +// The version comes from render(), not from each handler's data map, so the +// footer is only correct as long as every page composes with the layout and +// render keeps supplying the key. Both are asserted here rather than trusted. +func TestLayoutShowsTheVersionOnlyWhenSignedIn(t *testing.T) { + tmpl, err := loadTemplates() + if err != nil { + t.Fatalf("loadTemplates: %v", err) + } + rendered := 0 + for name := range tmpl.pages { + var buf bytes.Buffer + err := tmpl.pages[name].ExecuteTemplate(&buf, "layout.html", map[string]any{ + "Title": "t", "User": "admin", "Active": "", "Version": "9.9.9-test", + }) + if err != nil { + // Pages whose content block needs more data than this cannot be + // rendered here; the footer is in the shared layout, so one page + // that does render proves it for all of them. + continue + } + rendered++ + if !strings.Contains(buf.String(), "SelfPost 9.9.9-test") { + t.Errorf("page %q does not show the version in the layout footer", name) + } + } + if rendered == 0 { + t.Fatal("no page rendered, so the footer was never actually checked") + } + + // Signed out (login, setup) the version must not be advertised. + var buf bytes.Buffer + if err := tmpl.pages["login"].ExecuteTemplate(&buf, "layout.html", map[string]any{ + "Title": "t", "Active": "", "Version": "9.9.9-test", + }); err != nil { + t.Fatalf("execute login: %v", err) + } + if strings.Contains(buf.String(), "9.9.9-test") { + t.Errorf("the login page shows the version to unauthenticated visitors:\n%s", buf.String()) + } +} + +func TestRenderSuppliesTheVersion(t *testing.T) { + tmpl, err := loadTemplates() + if err != nil { + t.Fatalf("loadTemplates: %v", err) + } + s := &Server{tmpl: tmpl, cfg: Config{Version: "9.9.9-test"}} + rec := httptest.NewRecorder() + data := map[string]any{"Title": "t", "User": "admin"} + s.render(rec, http.StatusOK, "backup", data) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if got := data["Version"]; got != "9.9.9-test" { + t.Errorf("render did not supply Version (got %v)", got) + } + if !strings.Contains(rec.Body.String(), "SelfPost 9.9.9-test") { + t.Errorf("rendered page does not show the version:\n%s", rec.Body.String()) + } +} + func TestNavMarksActivePage(t *testing.T) { tmpl, err := loadTemplates() if err != nil {