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 @@ "net/http" "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 @@ } // ---- 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 @@ // // 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 @@ s.fail(w, r, err) 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 @@ s.fail(w, r, err) 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 @@ for _, b := range snap.Archive.Backlinks(page.ID) { 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) } @@ -547,6 +557,15 @@ Space string Section string Score float64 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