From 53e56db27ab35117d0c2a91f15533f7dd528612c Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sat, 8 Aug 2026 21:52:29 +0300 Subject: [PATCH] web: draw the chrome from sr-ht-ecore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nav/service-switcher, the login block, the environment banner and the brand were this service's own copy of code compare.sr.ht had already copied from somewhere else. They come from sourcecraft.dev/bigbes/sr-ht-ecore/chrome now: one chrome.Service built at startup from the shared config.ini, one chrome.Page per request, embedded in viewData so the shared partials find their fields on the dot. web/chrome.go is gone — buildNav, navItem, canonIndex, the login/logout/ profile URL building and the chrome half of viewData with it. The layout renders srht-env-banner and srht-nav instead of the local markup, the landing page's space list renders through srht-repo-list, and the template FuncMap starts from chrome.Funcs() (the local shortsha was a duplicate of the shared one). sameOrigin and the login redirect ask the chrome for our origin rather than keeping a second copy that could disagree with the links on the page. Three of ecore's policies differ from what this service did, and win, per that package's own doc: [sr.ht]site-name defaults to "sr.ht" rather than "sourcehut" and [sr.ht]environment to "development" rather than "production" when the key is absent, and the brand carries a fixed 15rem min-width so the switcher starts at the same x on every service. The instance's config.ini sets both keys, so on it only the brand width is visible. The nav tests that only restated ecore's rules — switcher order, the paste/pages/hub exclusion, the shape of a login URL — are dropped; ecore tests those. What is left covers this service's seam: that the identity authn resolved is the one the chrome is handed. --- .gitignore | 5 ++ web/chrome.go | 146 -------------------------------------- web/handlers.go | 49 +++++++++---- web/inbox.go | 3 +- web/proposal.go | 5 +- web/server.go | 69 ++++++++++-------- web/templates.go | 50 +++++++------ web/templates/index.html | 19 ++--- web/templates/layout.html | 57 +++++---------- web/view.go | 52 ++++++++++++++ web/web_test.go | 92 ++++++++++-------------- 11 files changed, 224 insertions(+), 323 deletions(-) delete mode 100644 web/chrome.go create mode 100644 web/view.go diff --git a/.gitignore b/.gitignore index 0cc826486d27881ec3786dc97307593a9d43f8f5..1a860550caf1b8a3c28106cdbb4e7d1b36eb4b20 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,11 @@ # Local instance config /config.ini +# Git worktrees. Both spellings, because the tooling picks either one and a +# worktree that shows up as untracked invites somebody to commit a checkout. +/.worktrees/ +/.claude/worktrees/ + # Local bare repos and the bleve index + render cache. Both are the service's # runtime state, not source; the cache is safe to delete at any time. /repos/ diff --git a/web/chrome.go b/web/chrome.go deleted file mode 100644 index e9c339679ac55b319b2134c818aed3bbc669d035..0000000000000000000000000000000000000000 --- a/web/chrome.go +++ /dev/null @@ -1,146 +0,0 @@ -package web - -import ( - "net/http" - "net/url" - "sort" - "strings" - - "github.com/vaughan0/go-ini" - "sourcecraft.dev/bigbes/sr-ht-core/config" - - "sourcecraft.dev/bigbes/sr-ht-spec/authn" -) - -// navCanonical is the SourceHut service-switcher order. Services not listed -// here (including our own spec) sort alphabetically after these. -var navCanonical = []string{"hub", "git", "hg", "lists", "todo", "builds", "man", "meta"} - -// navExcluded are service sections that never appear in the switcher: paste and -// pages have no top-level UI worth linking, and hub is rendered as the brand. -var navExcluded = map[string]bool{"paste": true, "pages": true, "hub": true} - -// navItem is one entry in the service switcher. -type navItem struct { - Name string // short service name, e.g. "git" - Origin string // external origin URL - Active bool // true for spec.sr.ht (this service) -} - -// buildNav derives the service switcher from the shared config: every section -// whose name ends in ".sr.ht" (with a configured origin) except paste/pages/hub, -// ordered by navCanonical then alphabetically, with spec.sr.ht marked active. -// -// The ".sr.ht" suffix is the whole membership rule — it is what -// core.sr.ht's own _network does, and it is why our section must be named -// literally "spec.sr.ht" no matter what host it is served from. -func buildNav(conf ini.File) []navItem { - var items []navItem - for section := range conf { - if !strings.HasSuffix(section, ".sr.ht") { - continue - } - short := strings.TrimSuffix(section, ".sr.ht") - if navExcluded[short] { - continue - } - origin := config.GetOrigin(conf, section, true) - if origin == "" { - continue - } - items = append(items, navItem{ - Name: short, - Origin: origin, - Active: section == authn.ConfigSection, - }) - } - sort.SliceStable(items, func(i, j int) bool { - ci, cj := canonIndex(items[i].Name), canonIndex(items[j].Name) - if ci != cj { - return ci < cj - } - return items[i].Name < items[j].Name - }) - return items -} - -// canonIndex returns a service's position in navCanonical, or a sentinel past -// the end for services that are not canonically ordered. -func canonIndex(name string) int { - for i, n := range navCanonical { - if n == name { - return i - } - } - return len(navCanonical) -} - -// viewData is the root value every template is executed against: the chrome -// fields are common to all pages; Data carries the page-specific payload. -type viewData struct { - Title string - SiteName string - HubOrigin string // non-empty ⇒ brand links to hub instead of "/" - Nav []navItem - Username string // "" for a viewer with no authority - LoginURL string - LogoutURL string - RegisterURL string - ProfileURL string - CSSHref string // "" when the binary was built without a stylesheet - Environment string - ShowBanner bool - - // ContainerClass selects the width of the page's content wrapper. - ContainerClass string - - Data any -} - -// chrome builds the common chrome fields for a 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 *authoritative* identity, not whatever the cookie said: a -// logged-in human who is not the instance owner resolves to anonymous, so the -// nav shows "log in" to them rather than greeting them by a name that grants -// nothing. -func (s *Server) chrome(r *http.Request) viewData { - p := authn.PrincipalFromContext(r.Context()) - username := "" - if p.IsOwner() { - username = p.Owner - } - - current := s.origin + r.URL.RequestURI() - loginURL := s.metaOrigin + "/login?return_to=" + url.QueryEscape(current) - logoutURL := s.metaOrigin + "/logout?return_to=" + url.QueryEscape(s.origin) - - profileURL := s.metaOrigin + "/profile" - if s.hubOrigin != "" && username != "" { - profileURL = s.hubOrigin + "/~" + username - } - - return viewData{ - ContainerClass: "container", - SiteName: s.siteName, - HubOrigin: s.hubOrigin, - Nav: s.nav, - Username: username, - LoginURL: loginURL, - LogoutURL: logoutURL, - RegisterURL: s.metaOrigin, - ProfileURL: profileURL, - CSSHref: s.cssHref, - Environment: strings.ToUpper(s.environment), - ShowBanner: s.environment != "" && s.environment != "production", - } -} - -// loginRedirect sends a viewer with no read authority to meta.sr.ht's login, -// with return_to pointing back at what they asked for. There is no login flow -// of our own — identity is the shared unified-login cookie and nothing else. -func (s *Server) loginRedirect(w http.ResponseWriter, r *http.Request) { - current := s.origin + r.URL.RequestURI() - http.Redirect(w, r, s.metaOrigin+"/login?return_to="+url.QueryEscape(current), http.StatusFound) -} diff --git a/web/handlers.go b/web/handlers.go index 7608021320617411f0d132a487c5e2a6c762193b..26a68db9b61300d17178b09cee819d113da1c4c3 100644 --- a/web/handlers.go +++ b/web/handlers.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/go-chi/chi/v5" + "sourcecraft.dev/bigbes/sr-ht-ecore/chrome" "sourcecraft.dev/bigbes/sr-ht-spec/authn" "sourcecraft.dev/bigbes/sr-ht-spec/core" @@ -166,14 +167,20 @@ func (s *Server) failFormat(w http.ResponseWriter, r *http.Request, f format, er // ---- landing -------------------------------------------------------------- -type spaceLink struct { - Ref string - Href string -} +// emptySpaces is what the landing page says when the owner has no spaces yet. +// It is a sentence and not "nothing here" because the remedy is not obvious: +// a space is created by pushing to it, not by a button on this page. +const emptySpaces = "No spaces yet. A space is a bare git repository owned by this service; " + + "it appears here once it has been created and pushed to." type indexData struct { LoggedIn bool - Spaces []spaceLink + + // Spaces is rendered by ecore's "srht-repo-list" partial, which is the + // listing markup every service on this instance converged on. A space has no + // visibility to show — this service has exactly one reader — so only the + // title and the href are filled in. + Spaces chrome.RepoList } // handleIndex is the landing page: the spaces you can read. @@ -181,10 +188,12 @@ type indexData struct { // It renders for an anonymous viewer too, because the chrome's login link has // to live somewhere reachable — but it lists nothing, so no space name leaks. func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { - vd := s.chrome(r) - vd.Title = s.siteName + " spec" + vd := s.view(r, "") + // The title is built from the chrome's own fields rather than from a second + // read of [sr.ht]site-name: the tab and the brand must name the same site. + vd.Title = vd.SiteName + " " + vd.SiteLabel - data := indexData{LoggedIn: mayRead(r)} + data := indexData{LoggedIn: mayRead(r), Spaces: chrome.RepoList{Empty: emptySpaces}} if data.LoggedIn { refs, err := s.reader.ListSpaces(r.Context()) if err != nil { @@ -192,7 +201,10 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { return } for _, ref := range refs { - data.Spaces = append(data.Spaces, spaceLink{Ref: ref.String(), Href: "/" + ref.String()}) + data.Spaces.Items = append(data.Spaces.Items, chrome.ListItem{ + Href: "/" + ref.String(), + Title: ref.String(), + }) } } vd.Data = data @@ -239,8 +251,7 @@ func (s *Server) handleSpace(w http.ResponseWriter, r *http.Request) { return } - vd := s.chrome(r) - vd.Title = ref.String() + vd := s.view(r, ref.String()) vd.Data = spaceData{ Ref: ref.String(), Rev: snap.Rev, @@ -505,8 +516,7 @@ func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) { data.Backlinks = append(data.Backlinks, docLink{Title: b.Title, Href: snap.Archive.DocHref(b) + rq}) } - vd := s.chrome(r) - vd.Title = page.Title + " — " + ref.String() + vd := s.view(r, page.Title+" — "+ref.String()) vd.Data = data s.render(w, http.StatusOK, "document", vd) } @@ -549,6 +559,15 @@ type searchHit struct { Snippet template.HTML } +// spaceLink is one option of the search form's space filter. It is not a +// chrome.ListItem: a filter option is a