diff --git a/internal/web/server.go b/internal/web/server.go index 5029d35..f259322 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -142,6 +142,11 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo mux.HandleFunc("GET /"+ch.Slug+"/feed.xml", func(w http.ResponseWriter, r *http.Request) { s.handleFeedXML(w, r, ch.Slug) }) mux.HandleFunc("GET /"+ch.Slug+"/feed.json", func(w http.ResponseWriter, r *http.Request) { s.handleFeedJSON(w, r, ch.Slug) }) } + // Public source-health page. The handler renders a trimmed reader view for + // everyone and the full diagnostic view only for admins, so it lives outside + // the auth block. + mux.HandleFunc("GET /status", s.handleStatus) + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) @@ -158,7 +163,6 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo mux.HandleFunc("GET /api/state", s.handleState) mux.HandleFunc("GET /bookmarks", s.handleBookmarks) mux.HandleFunc("GET /for-you", s.handleForYou) - mux.HandleFunc("GET /status", s.handleStatus) if s.cfg.Push.Enabled { mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe) mux.HandleFunc("POST /api/push/unsubscribe", s.handlePushUnsubscribe) diff --git a/internal/web/status.go b/internal/web/status.go index 6d9f0ec..10ee8dd 100644 --- a/internal/web/status.go +++ b/internal/web/status.go @@ -48,17 +48,16 @@ type sourceStatus struct { type statusPage struct { pageData Sources []sourceStatus - DegradedCnt int // sources currently failing + DegradedCnt int // sources currently failing + Admin bool // viewer is an admin: show the full diagnostic columns } -// handleStatus renders the owner-facing source-health dashboard. Access is -// restricted to admin subjects; everyone else gets a 404 so the page's -// existence isn't advertised. +// handleStatus renders the source-health page. It's public: everyone sees a +// trimmed reader view (per-feed up/stale/idle and when each last updated), while +// admins additionally get the operator diagnostics (poll cadence, item counts, +// paywall rates, posting times, and raw fetch errors). func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { - if !s.isAdmin(r) { - http.NotFound(w, r) - return - } + admin := s.isAdmin(r) s.track(r, "status") health, err := storage.ListSourceHealth() @@ -92,6 +91,11 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { Paywalled: st.Paywalled, } row.Healthy = hasHealth && h.ConsecutiveFailures == 0 + // Raw fetch errors leak feed-specific workarounds and upstream URLs, so + // they stay out of the public payload entirely (not just hidden in CSS). + if !admin { + row.LastError = "" + } if h.LastPollAt > 0 { row.LastPollAt = time.Unix(h.LastPollAt, 0) } @@ -124,5 +128,5 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { base := s.base(r) base.Active = "status" - s.render(w, "status", statusPage{pageData: base, Sources: rows, DegradedCnt: degraded}) + s.render(w, "status", statusPage{pageData: base, Sources: rows, DegradedCnt: degraded, Admin: admin}) } diff --git a/internal/web/status_render_test.go b/internal/web/status_render_test.go index 792d8c9..4ce6611 100644 --- a/internal/web/status_render_test.go +++ b/internal/web/status_render_test.go @@ -13,26 +13,47 @@ func TestStatusTemplateExecutes(t *testing.T) { if err != nil { t.Fatal(err) } - data := statusPage{ - pageData: pageData{SiteTitle: "Pete", Channels: channels}, - DegradedCnt: 1, - Sources: []sourceStatus{ - {Name: "Broken Feed", Channel: "tech", Failures: 3, LastError: "dial tcp: timeout", - LastPollAt: time.Now(), NeverRun: false, Healthy: false, Total: 4, Paywalled: 2, PaywallRate: 50}, - {Name: "Good Feed", Channel: "eu", Healthy: true, LastPollAt: time.Now(), - LastSuccessAt: time.Now(), LastItemCount: 12, Total: 30, Classified: 28, - LastSeenAt: time.Now(), LastPostedAt: time.Now()}, - {Name: "Idle Feed", Channel: "music", NeverRun: true}, - }, + sources := []sourceStatus{ + {Name: "Broken Feed", Channel: "tech", Failures: 3, LastError: "dial tcp: timeout", + LastPollAt: time.Now(), NeverRun: false, Healthy: false, Total: 4, Paywalled: 2, PaywallRate: 50}, + {Name: "Good Feed", Channel: "eu", Healthy: true, LastPollAt: time.Now(), + LastSuccessAt: time.Now(), LastItemCount: 12, Total: 30, Classified: 28, + LastSeenAt: time.Now(), LastPostedAt: time.Now()}, + {Name: "Idle Feed", Channel: "music", NeverRun: true}, } - var b strings.Builder - if err := s.tpls["status"].ExecuteTemplate(&b, "layout", data); err != nil { - t.Fatal(err) + + render := func(admin bool) string { + data := statusPage{ + pageData: pageData{SiteTitle: "Pete", Channels: channels}, + DegradedCnt: 1, + Admin: admin, + Sources: sources, + } + var b strings.Builder + if err := s.tpls["status"].ExecuteTemplate(&b, "layout", data); err != nil { + t.Fatal(err) + } + return b.String() } - out := b.String() + + // Admin view: every feed plus the operator diagnostics and raw errors. + admin := render(true) for _, want := range []string{"Broken Feed", "Good Feed", "dial tcp: timeout", "1 degraded", "Source health"} { - if !strings.Contains(out, want) { - t.Errorf("rendered status page missing %q", want) + if !strings.Contains(admin, want) { + t.Errorf("admin status page missing %q", want) + } + } + + // Public view: same feeds and states, but no error strings or ops columns. + pub := render(false) + for _, want := range []string{"Broken Feed", "Good Feed", "Idle Feed", "delayed", "Source health"} { + if !strings.Contains(pub, want) { + t.Errorf("public status page missing %q", want) + } + } + for _, leak := range []string{"dial tcp: timeout", "degraded", "Last posted", "Paywall"} { + if strings.Contains(pub, leak) { + t.Errorf("public status page leaked operator detail %q", leak) } } } diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index 3ead847..2437684 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -88,6 +88,15 @@ + + + Source health + {{if .AuthEnabled}} {{if .User}} Bookmarks - {{if .IsAdmin}} - - - Source health - - {{end}} diff --git a/internal/web/templates/status.html b/internal/web/templates/status.html index 161286b..57d792f 100644 --- a/internal/web/templates/status.html +++ b/internal/web/templates/status.html @@ -1,12 +1,14 @@ {{define "title"}}Source health — {{.SiteTitle}}{{end}} {{define "main"}} +{{$admin := .Admin}}
-

owner view

+

{{if $admin}}owner view{{else}}live status{{end}}

Source health

- Per-feed poll status and content stats. Rows that are currently failing float to the top. + {{if $admin}}Per-feed poll status and content stats. Rows that are currently failing float to the top. + {{else}}Every feed Pete follows and whether it's still updating. This runs on a best-effort basis, so a source going quiet for a bit is normal.{{end}}

@@ -14,11 +16,11 @@ {{if .DegradedCnt}} - ⚠ {{.DegradedCnt}} degraded + ⚠ {{.DegradedCnt}} {{if $admin}}degraded{{else}}delayed{{end}} {{else}} - ✓ all healthy + ✓ all updating {{end}}
@@ -26,19 +28,21 @@
- +
+ + {{if $admin}} - + {{end}} @@ -50,14 +54,17 @@ {{if .NeverRun}} ◌ idle {{else if .Healthy}} - ● ok - {{else}} + ● live + {{else if $admin}} ✕ {{.Failures}} fail{{if ne .Failures 1}}s{{end}} + {{else}} + ✕ delayed {{end}} - + {{if $admin}} + + {{end}} - {{if .LastError}}{{if not .Healthy}} + {{if $admin}}{{if .LastError}}{{if not .Healthy}} - {{end}}{{end}} + {{end}}{{end}}{{end}} {{else}} - + {{end}}
Source Channel StatusLast updateLast pollLast success Items Stories Paywall Last story Last posted
{{if .LastPollAt.IsZero}}never{{else}}{{timeAgo .LastPollAt}}{{end}} {{if .LastSuccessAt.IsZero}}never{{else}}{{timeAgo .LastSuccessAt}}{{end}}{{if .LastPollAt.IsZero}}never{{else}}{{timeAgo .LastPollAt}}{{end}} {{.LastItemCount}} {{.Total}} @@ -65,14 +72,15 @@ {{if .LastSeenAt.IsZero}}—{{else}}{{timeAgo .LastSeenAt}}{{end}} {{if .LastPostedAt.IsZero}}—{{else}}{{timeAgo .LastPostedAt}}{{end}}
{{.LastError}}
No sources configured.
No sources configured.