diff --git a/README.md b/README.md index 9fca5337acce86f26ab0435485e287fb4996991a..97f7e563861cdadd65723ad3920e2d7e606e66f6 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ shared config.ini (`chrome.BuildNav`), per-request `chrome.Page` with login/logout/profile URLs against meta.sr.ht's unified login, embedded `srht-nav` / `srht-env-banner` template partials (circle brand + red service label + switcher + login box), and the generic template helpers (`dict`, - `shortsha`). + `shortsha`, `reltime`, `abstime`). - `grants` — the grant vocabulary of tokens.sr.ht (SPEC ch. 3): `:` members split on ASCII whitespace, `*` for every action of every service, the reserved `id:` member a registered token carries, @@ -92,6 +92,18 @@ The chrome bakes in the instance-wide decisions instead of parameterizing them: the switcher renders only for authenticated viewers; paste, pages and hub never appear in it; the brand is always circle + site name + red service -label and links to the service's own root; the profile link prefers hub's -`~username` page when hub is configured. Service-specific nav entries go +label, where the name links to hub (or to the service root on an instance +without one) and the label links to the service root; the profile link prefers +hub's `~username` page when hub is configured. Service-specific nav entries go through `Service.ExtraNav`; per-page width through `Page.ContainerClass`. + +The brand is two links rather than upstream's one because upstream has to +choose between them: with hub configured it points the whole brand at hub and +drops the service label, without hub it keeps the label and points at the +service root. Both halves are load-bearing — hub is excluded from the +switcher, so the brand is the only route to it, and chrome that does not name +its own service is worse chrome. + +Note that embedding names the field `Page`: a view struct that wants `Page` +for its own payload (a pagination counter, usually) must rename that field. +The collision is a compile error, not a silent shadow. diff --git a/chrome/chrome.go b/chrome/chrome.go index 3d3c1a02e9b7a313bff1aa5aaf0504f067a1edd9..7e6d1c6e5787c147ce70face07ac5b509dbaa55d 100644 --- a/chrome/chrome.go +++ b/chrome/chrome.go @@ -26,9 +26,10 @@ // service view struct that embeds it (promoted fields resolve in templates). // // Unified policy decisions, deliberately baked in rather than parameterized: // the switcher renders only for authenticated viewers; paste, pages and hub -// never appear in it (hub is the brand's business, and this brand links to the -// service's own root instead); the profile link prefers hub's ~username page -// when hub is configured; the brand is always circle + site name + red label. +// never appear in it (hub is the brand's business); the profile link prefers +// hub's ~username page when hub is configured; the brand is always circle + +// site name + red service label, with the name linking to hub and the label to +// the service's own root. package chrome import ( @@ -110,6 +111,10 @@ // Page is the chrome every rendered page shares. Services embed it in their // own view struct and add page payload (and service-specific chrome fields) // next to it. +// +// Embedding names the field Page, so a view struct that wants "Page" for its +// own payload — a pagination counter, most often — has to rename that field +// (PageNum, say). The collision is a compile error, not a silent shadow. type Page struct { Title string SiteName string @@ -201,13 +206,30 @@ // MetaOrigin returns meta.sr.ht's external origin. func (s *Service) MetaOrigin() string { return s.metaOrigin } +// HubOrigin returns hub.sr.ht's external origin, or "" when the instance has +// no hub. +func (s *Service) HubOrigin() string { return s.hubOrigin } + +// SiteName returns the instance's brand text. +func (s *Service) SiteName() string { return s.siteName } + +// Environment returns the configured environment as written in the config +// (lowercase); Page uppercases it for the banner. +func (s *Service) Environment() string { return s.environment } + +// LoginURLFor is meta.sr.ht's login with return_to pointing back at the URL +// being served — the same link the nav's "Log in" carries. Exported for the +// handlers that gate a page behind login and only need somewhere to redirect, +// so they do not have to build a whole Page to read one field off it. +func (s *Service) LoginURLFor(r *http.Request) string { + return s.metaOrigin + "/login?return_to=" + url.QueryEscape(s.selfOrigin+r.URL.RequestURI()) +} + // Page builds the chrome for one request. Login return_to is the current full // URL (so the viewer lands back where they were); logout return_to is this // service's origin. username is the caller's *authoritative* identity — pass // "" for viewers whose cookie grants nothing, and the nav offers login. func (s *Service) Page(r *http.Request, title, username string) Page { - current := s.selfOrigin + r.URL.RequestURI() - profileURL := s.metaOrigin + "/profile" if s.hubOrigin != "" && username != "" { profileURL = s.hubOrigin + "/~" + username @@ -220,7 +242,7 @@ SiteLabel: strings.TrimSuffix(s.Section, ".sr.ht"), Nav: s.nav, ExtraNav: s.ExtraNav, Username: username, - LoginURL: s.metaOrigin + "/login?return_to=" + url.QueryEscape(current), + LoginURL: s.LoginURLFor(r), LogoutURL: s.metaOrigin + "/logout?return_to=" + url.QueryEscape(s.selfOrigin), RegisterURL: s.metaOrigin, ProfileURL: profileURL, diff --git a/chrome/chrome_test.go b/chrome/chrome_test.go index b5f997472b258df41a23d9ab75979a308921468d..10190ea7eea8a5ad9ddbb87c3d9800b952e58e24 100644 --- a/chrome/chrome_test.go +++ b/chrome/chrome_test.go @@ -5,6 +5,7 @@ "html/template" "net/http/httptest" "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -107,7 +108,9 @@ p := svc.Page(httptest.NewRequest("GET", "/", nil), "t", "alice") out := render(t, p) assert.Contains(t, out, "icon icon-circle") - assert.Contains(t, out, `compare`) + // The brand is two links: the site name to hub, the red label to us. + assert.Contains(t, out, `srht.example`) + assert.Contains(t, out, `compare`) assert.Contains(t, out, `href="https://git.example"`) assert.Contains(t, out, `href="/tokens"`, "extra nav entries must render") assert.Contains(t, out, "Logged in as") @@ -123,6 +126,19 @@ // The switcher renders only for authenticated viewers. assert.NotContains(t, out, `href="https://git.example"`) assert.Contains(t, out, "Log in") assert.Contains(t, out, "Register") +} + +// TestNavBrandWithoutHub covers the instance that runs no hub: the site name +// has nowhere else to point, so it falls back to the service root and the +// brand becomes two links to the same place rather than a dead one. +func TestNavBrandWithoutHub(t *testing.T) { + conf := testConf() + delete(conf, "hub.sr.ht") + svc := NewService(conf, "compare.sr.ht") + + out := render(t, svc.Page(httptest.NewRequest("GET", "/", nil), "t", "alice")) + assert.Contains(t, out, `srht.example`) + assert.Contains(t, out, `compare`) } // TestNavTemplateEmbeddedPage guards the documented consumption pattern: a @@ -173,9 +189,51 @@ assert.NotContains(t, out, "event-list") assert.Contains(t, out, "No databases yet.") } +// TestLoginURLForMatchesTheNav guards the whole reason the accessor exists: a +// handler redirecting to login must land the viewer exactly where the nav's +// "Log in" would have. +func TestLoginURLForMatchesTheNav(t *testing.T) { + svc := NewService(testConf(), "compare.sr.ht") + r := httptest.NewRequest("GET", "/~alice/demo?a=1", nil) + + assert.Equal(t, svc.Page(r, "t", "").LoginURL, svc.LoginURLFor(r)) + assert.Equal(t, "https://meta.example/login?return_to="+ + "https%3A%2F%2Fcompare.example%2F~alice%2Fdemo%3Fa%3D1", svc.LoginURLFor(r)) +} + +func TestServiceAccessors(t *testing.T) { + svc := NewService(testConf(), "compare.sr.ht") + assert.Equal(t, "https://compare.example", svc.SelfOrigin()) + assert.Equal(t, "https://meta.example", svc.MetaOrigin()) + assert.Equal(t, "https://hub.example", svc.HubOrigin()) + assert.Equal(t, "srht.example", svc.SiteName()) + assert.Equal(t, "production", svc.Environment()) + + conf := testConf() + delete(conf, "hub.sr.ht") + assert.Empty(t, NewService(conf, "compare.sr.ht").HubOrigin()) +} + +func TestRelTimeFacesBothDirections(t *testing.T) { + now := time.Now() + assert.Equal(t, "just now", RelTime(now)) + assert.Equal(t, "just now", RelTime(now.Add(30*time.Second))) + assert.Equal(t, "3 hours ago", RelTime(now.Add(-3*time.Hour))) + assert.Equal(t, "in 3 hours", RelTime(now.Add(3*time.Hour+time.Minute))) + assert.Equal(t, "1 minute ago", RelTime(now.Add(-time.Minute-time.Second))) + assert.Equal(t, "2 years ago", RelTime(now.Add(-2*365*24*time.Hour))) + + assert.Equal(t, "2026-08-08 12:34:56 UTC", + AbsTime(time.Date(2026, 8, 8, 12, 34, 56, 0, time.UTC))) +} + func TestFuncs(t *testing.T) { assert.Equal(t, "12345678", ShortSHA("1234567890abcdef")) assert.Equal(t, "abc", ShortSHA("abc")) + + for _, name := range []string{"dict", "shortsha", "reltime", "abstime"} { + assert.Contains(t, Funcs(), name) + } m, err := Dict("a", 1, "b", "x") require.NoError(t, err) diff --git a/chrome/funcs.go b/chrome/funcs.go index d93af10b012f0c0816a9d202dbba8d13341e10ee..773a227f95944fab644c86afbd61dc7236deb190 100644 --- a/chrome/funcs.go +++ b/chrome/funcs.go @@ -3,6 +3,7 @@ import ( "fmt" "html/template" + "time" ) // Funcs returns the template helpers every service was carrying its own copy @@ -12,6 +13,8 @@ func Funcs() template.FuncMap { return template.FuncMap{ "dict": Dict, "shortsha": ShortSHA, + "reltime": RelTime, + "abstime": AbsTime, } } @@ -42,3 +45,57 @@ return s[:8] } return s } + +// RelTime is the coarse "3 hours ago" a listing wants, and AbsTime the exact +// UTC stamp an investigation wants. Both exist, and both are shared, because +// every service on the instance shows the same two columns and had grown its +// own spelling of them: the copies disagreed about the future, printing "in 3 +// hours" on one service and "just now" on the next for the same instant. +// +// A future instant gets the same arithmetic as a past one. "in 3 weeks" +// answers "do I have to deal with this today" without the reader working it +// out from a calendar stamp. +func RelTime(t time.Time) string { + d := time.Since(t) + switch { + case d < -time.Minute: + return "in " + coarse(-d) + case d < time.Minute: + // Covers both an instant that has just passed and one about to, which + // is also what two machines with unsynchronised clocks produce for the + // same instant. + return "just now" + default: + return coarse(d) + " ago" + } +} + +// AbsTime is the unambiguous stamp, one hover away from a RelTime. +func AbsTime(t time.Time) string { + return t.UTC().Format("2006-01-02 15:04:05 UTC") +} + +// coarse names a positive duration in its largest whole unit. The direction is +// the caller's to add, so that "in 3 weeks" and "3 weeks ago" cannot end up +// counting in different units. +func coarse(d time.Duration) string { + switch { + case d < time.Hour: + return plural(int(d/time.Minute), "minute") + case d < 24*time.Hour: + return plural(int(d/time.Hour), "hour") + case d < 30*24*time.Hour: + return plural(int(d/(24*time.Hour)), "day") + case d < 365*24*time.Hour: + return plural(int(d/(30*24*time.Hour)), "month") + default: + return plural(int(d/(365*24*time.Hour)), "year") + } +} + +func plural(n int, unit string) string { + if n == 1 { + return "1 " + unit + } + return fmt.Sprintf("%d %ss", n, unit) +} diff --git a/chrome/templates/chrome.tmpl b/chrome/templates/chrome.tmpl index 3552a59cd1ae480cc0d2f81990eb3b9f8eb1c9bf..aa7e599e0d3bc0dea96af108812adbb306e870da 100644 --- a/chrome/templates/chrome.tmpl +++ b/chrome/templates/chrome.tmpl @@ -51,13 +51,24 @@ same x-coordinate on every service: without it the menu shifts by the width of the red service label ("dolt" vs "compare") when hopping between services. Sized for the longest label on the instance; inline because the services build their CSS from the shared core tree and ecore ships none. + + Two links, not one. Upstream core.sr.ht makes the whole brand a single link + and has to choose: with hub configured it points the site name at hub and + drops the service label entirely, without hub it keeps the label and points + at the service root. Neither half is expendable — hub is excluded from the + switcher, so the brand is the only route to it, and a page that does not + name the service it belongs to is worse chrome. So the site name goes to + hub (falling back to the service root when the instance has no hub) and the + red label goes to the service root. + + The label stays wrapped in its own rather than + becoming a red : the theme colors ".navbar-light .navbar-brand a", which + outranks .text-danger and would repaint the label white in dark mode. */}} - - {{.SiteName}} - {{.SiteLabel}} - + {{.SiteName}} + {{.SiteLabel}}