M .gitignore => .gitignore +5 -0
@@ 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/
D web/chrome.go => web/chrome.go +0 -146
@@ 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)
-}
M web/handlers.go => web/handlers.go +34 -15
@@ 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 <option>, not a card, and the listing
+// on the landing page is the only thing on this service shaped like the
+// family's event list.
+type spaceLink struct {
+ Ref string
+ Href string
+}
+
type searchData struct {
Query string
Space string
@@ 611,8 630,8 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
}
}
- vd := s.chrome(r)
- vd.Title = "search — " + s.siteName + " spec"
+ vd := s.view(r, "")
+ vd.Title = "search — " + vd.SiteName + " " + vd.SiteLabel
vd.Data = data
s.render(w, http.StatusOK, "search", vd)
}
M web/inbox.go => web/inbox.go +1 -2
@@ 64,8 64,7 @@ func (s *Server) handleInbox(w http.ResponseWriter, r *http.Request) {
digestRows, newCount := digestRows(digest, mark, marked)
- vd := s.chrome(r)
- vd.Title = "Review queue"
+ vd := s.view(r, "Review queue")
vd.Data = inboxData{
Open: proposalRows(open),
Digest: digestRows,
M web/proposal.go => web/proposal.go +2 -3
@@ 154,8 154,7 @@ func (s *Server) handleProposal(w http.ResponseWriter, r *http.Request) {
lost = append(lost, ts...)
}
- vd := s.chrome(r)
- vd.Title = fmt.Sprintf("Proposal #%d — %s", p.ID, p.Title)
+ vd := s.view(r, fmt.Sprintf("Proposal #%d — %s", p.ID, p.Title))
vd.Data = proposalData{
Proposal: p,
SpaceHref: "/" + ref.String(),
@@ 234,7 233,7 @@ func (s *Server) sameOrigin(r *http.Request) bool {
if err != nil || got.Host == "" {
return false
}
- want, err := url.Parse(s.origin)
+ want, err := url.Parse(s.chromeSvc.SelfOrigin())
if err != nil {
return false
}
M web/server.go => web/server.go +39 -30
@@ 1,13 1,18 @@
-// Package web is spec.sr.ht's read plane in a browser: the SourceHut chrome
-// ported to Go html/templates, a space's document tree, a rendered document
-// with its metadata and backlinks, and keyword search — all served from one chi
-// router the daemon mounts.
+// Package web is spec.sr.ht's read plane in a browser: a space's document
+// tree, a rendered document with its metadata and backlinks, the proposal
+// review page and keyword search — all served from one chi router the daemon
+// mounts.
//
-// It is compare.sr.ht's web/ package with the diff views replaced by document
-// views: the nav/service-switcher, login block, environment banner, error page,
-// embedded-static pattern and hashed-asset resolution are the same code shaped
-// the same way, because "no upstream SourceHut modification" means every
-// service reimplements that chrome and two of them already agreed on how.
+// # The chrome is not ours
+//
+// The brand, the service switcher, the login block and the environment banner
+// come from sourcecraft.dev/bigbes/sr-ht-ecore/chrome, which every custom
+// service on this instance shares. This package builds one chrome.Service at
+// startup, asks it for a chrome.Page per request, and embeds that Page in its
+// own view struct so the fields promote into the templates (view.go). Nothing
+// here rebuilds the switcher or re-derives a login URL: this service's copy of
+// that code — inherited from compare.sr.ht, which had inherited it from
+// somewhere else — is what ecore exists to have deleted.
//
// # URL grammar
//
@@ 65,6 70,7 @@ import (
"github.com/vaughan0/go-ini"
"sourcecraft.dev/bigbes/sr-ht-core/config"
+ "sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
"sourcecraft.dev/bigbes/sr-ht-spec/authn"
"sourcecraft.dev/bigbes/sr-ht-spec/doc"
@@ 83,9 89,11 @@ const tokensSection = "tokens.sr.ht"
// Options is everything a Server needs. Every field is required; New says which
// one is missing rather than failing later inside a handler.
type Options struct {
- // Conf is the shared SourceHut config.ini. The nav switcher, the site name,
- // the environment banner and the meta.sr.ht login origin all come from it,
- // and it is read once at startup rather than per request.
+ // Conf is the shared SourceHut config.ini, and it is here for one reason:
+ // the chrome is built from it. The switcher is a question about every
+ // [*.sr.ht] section the instance defines and not about our own keys, so
+ // chrome.NewService reads the whole file once at startup; nothing here
+ // parses it again per request.
Conf ini.File
// Reader is the read surface over spaces and documents. NewReader adapts a
@@ 108,12 116,13 @@ type Server struct {
resolver *authn.Resolver
renderer *doc.Renderer
- siteName string
- environment string
- metaOrigin string
- origin string
- hubOrigin string
- cssHref string
+ // chromeSvc is the shared page frame of sr-ht-ecore: the brand, the service
+ // switcher, the login block and the environment banner, built once from
+ // config.ini and asked for a per-request Page in view (view.go). It is also
+ // this package's only reader of our own and meta's origins — sameOrigin and
+ // the login redirect ask it rather than keeping a second copy that could
+ // disagree with the links on the page.
+ chromeSvc *chrome.Service
// tokensOrigin is [tokens.sr.ht] origin in its *external* form. The only
// thing this package does with it is redirect a browser there, and a browser
@@ 122,7 131,6 @@ type Server struct {
// than papers over with a redirect to nowhere.
tokensOrigin string
- nav []navItem
staticFileServer http.Handler
}
@@ 146,12 154,16 @@ func New(opts Options) (*Server, error) {
return nil, fmt.Errorf("web: authn Resolver is required")
}
- origin := config.GetOrigin(opts.Conf, authn.ConfigSection, true)
- if origin == "" {
+ // The section is authn.ConfigSection and not a literal, because that
+ // constant is what the daemon, the config file and the switcher's "which
+ // entry is me" test all have to agree on. A service that spelled its section
+ // differently in two places would appear in the instance's navigation and
+ // fail to recognise itself in it.
+ chromeSvc := chrome.NewService(opts.Conf, authn.ConfigSection)
+ if chromeSvc.SelfOrigin() == "" {
return nil, fmt.Errorf("web: [%s] origin is required", authn.ConfigSection)
}
- metaOrigin := config.GetOrigin(opts.Conf, "meta.sr.ht", true)
- if metaOrigin == "" {
+ if chromeSvc.MetaOrigin() == "" {
return nil, fmt.Errorf("web: [meta.sr.ht] origin is required")
}
@@ 163,6 175,9 @@ func New(opts Options) (*Server, error) {
log.Printf("web: no main.min.*.css embedded in this binary — pages will " +
"render unstyled; run `make css` before `go build`")
}
+ // chrome.Page renders a bare page for an empty StyleHref rather than an
+ // empty <link>, so an unstyled build stays a presentation failure.
+ chromeSvc.StyleHref = cssHref
staticSub, err := fs.Sub(staticFS, "static")
if err != nil {
@@ 174,14 189,8 @@ func New(opts Options) (*Server, error) {
searcher: opts.Searcher,
resolver: opts.Resolver,
renderer: doc.NewRenderer(),
- siteName: config.GetString(opts.Conf, "sr.ht", "site-name", "sourcehut"),
- environment: config.GetString(opts.Conf, "sr.ht", "environment", "production"),
- metaOrigin: metaOrigin,
- origin: origin,
- hubOrigin: config.GetOrigin(opts.Conf, "hub.sr.ht", true),
+ chromeSvc: chromeSvc,
tokensOrigin: config.GetOrigin(opts.Conf, tokensSection, true),
- cssHref: cssHref,
- nav: buildNav(opts.Conf),
staticFileServer: http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))),
}, nil
}
M web/templates.go => web/templates.go +29 -21
@@ 7,6 7,8 @@ import (
"log"
"net/http"
"strings"
+
+ "sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
)
// tmplFS holds the page templates. Each page is parsed together with the shared
@@ 23,40 25,47 @@ var tmplFS embed.FS
//go:embed static
var staticFS embed.FS
-// funcMap holds the template helpers shared by every page.
-var funcMap = template.FuncMap{
- // shortsha abbreviates an object id to 8 hex chars.
- "shortsha": func(s string) string {
- if len(s) > 8 {
- return s[:8]
- }
- return s
- },
+// funcMap holds the template helpers available to every page.
+//
+// It starts from chrome.Funcs — the generic helpers every custom service on
+// this instance was carrying its own copy of, `shortsha` among them — and adds
+// this service's own on top, after, so that a name may be shadowed
+// deliberately rather than by accident of map ordering. Nothing shadows one
+// today, and a helper that diverged from the shared spelling of the same name
+// would be the drift ecore exists to prevent.
+var funcMap = func() template.FuncMap {
+ m := chrome.Funcs()
+
// indent renders a tree depth as non-breaking space, so the space view's
// hierarchy reads as a hierarchy without a nested-list template recursion.
// A negative depth (a level-1 heading, once decremented) indents nothing.
- "indent": func(depth int) template.HTML {
+ m["indent"] = func(depth int) template.HTML {
if depth <= 0 {
return ""
}
return template.HTML(strings.Repeat(" ", depth))
- },
+ }
// dec turns a 1-based heading level into a 0-based indent depth.
- "dec": func(n int) int { return n - 1 },
-}
+ m["dec"] = func(n int) int { return n - 1 }
+
+ return m
+}()
// pageNames are the content templates; each is parsed with layout.html.
var pageNames = []string{"index", "space", "document", "search", "error", "proposal", "inbox"}
-// pages maps a page name to its parsed template set (layout + partials + that
-// page). threads.html is parsed into every set rather than only into the
-// proposal page's: it defines review-thread markup and nothing else, and a
-// partial that only some sets know about is a lookup that fails on the page
-// that later needs it.
+// pages maps a page name to its parsed template set (layout + the shared
+// chrome partials + local partials + that page). threads.html is parsed into
+// every set rather than only into the proposal page's: it defines review-thread
+// markup and nothing else, and a partial that only some sets know about is a
+// lookup that fails on the page that later needs it. ecore's "srht-nav" and
+// "srht-env-banner" are attached to every set for the same reason, and through
+// MustAttach because a set that cannot draw the chrome is not a page this
+// binary should start serving.
var pages = func() map[string]*template.Template {
m := make(map[string]*template.Template, len(pageNames))
for _, name := range pageNames {
- t := template.New("layout.html").Funcs(funcMap)
+ t := chrome.MustAttach(template.New("layout.html").Funcs(funcMap))
t = template.Must(t.ParseFS(tmplFS,
"templates/layout.html", "templates/threads.html", "templates/"+name+".html"))
m[name] = t
@@ 111,8 120,7 @@ type errorData struct {
// renderError renders the chrome-wrapped error page. It never recurses into
// render on failure (render falls back to http.Error itself).
func (s *Server) renderError(w http.ResponseWriter, r *http.Request, status int, message string) {
- vd := s.chrome(r)
- vd.Title = http.StatusText(status)
+ vd := s.view(r, http.StatusText(status))
vd.Data = errorData{
Status: status,
StatusText: http.StatusText(status),
M web/templates/index.html => web/templates/index.html +4 -15
@@ 20,26 20,15 @@
</div>
<div class="col-md-8">
<hr class="d-md-none" />
- {{if .Data.Spaces}}
- <div class="event-list">
- {{range .Data.Spaces}}
- <div class="event">
- <h4><a href="{{.Href}}">{{.Ref}}</a></h4>
- </div>
- {{end}}
- </div>
- {{else}}
- <p class="text-muted">
- 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.
- </p>
- {{end}}
+ {{/* The listing markup is ecore's, so a space here and a repository on the
+ sibling services look like the same kind of thing. */}}
+ {{template "srht-repo-list" .Data.Spaces}}
</div>
</div>
{{else}}
<div class="row">
<div class="col-md-12">
- <h2>{{.SiteName}} <span class="text-danger">spec</span></h2>
+ <h2>{{.SiteName}} <span class="text-danger">{{.SiteLabel}}</span></h2>
<p>
Reviewable document storage: agents propose, you curate, agents read back
the approved text. Reads default to the approved revision; add
M web/templates/layout.html => web/templates/layout.html +19 -38
@@ 1,3 1,16 @@
+{{/*
+ The SourceHut chrome. The brand, the service switcher, the login block and
+ the environment banner are NOT rendered here: they come from sr-ht-ecore's
+ shared partials ("srht-env-banner", "srht-nav"), which every custom service
+ on this instance draws from one copy. The dot is this package's viewData,
+ which embeds chrome.Page, so the fields those partials read promote into it.
+
+ What is left is the document and three seams:
+ head — extra <head> content. A block, so a page that needs none renders.
+ content — the page itself; every page template defines it.
+ scripts — nothing uses it yet; the proposal page's script goes in "head"
+ with defer.
+*/}}
<!doctype html>
<html lang="en">
<head>
@@ 5,48 18,16 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title>
<link rel="icon" type="image/svg+xml" href="/static/logo.svg">
- {{if .CSSHref}}<link rel="stylesheet" href="{{.CSSHref}}">{{end}}
+ {{/* Empty when this binary was built without `make css`. The link is
+ guarded rather than emitted empty: <link href=""> re-requests the page
+ it is on, which is a page load per page load. */}}
+ {{if .StyleHref}}<link rel="stylesheet" href="{{.StyleHref}}">{{end}}
{{block "head" .}}{{end}}
</head>
<body>
- {{if .ShowBanner}}
- <div style="background: #228800; color: white; font-weight: bold; width: 100%; text-align: center">
- {{.Environment}} ENVIRONMENT
- </div>
- {{end}}
+ {{template "srht-env-banner" .}}
<nav class="container navbar navbar-light navbar-expand-sm">
- <span class="navbar-brand">
- <span class="icon icon-circle" aria-hidden="true"><svg width="22" height="22" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm0 448c-110.5 0-200-89.5-200-200S145.5 56 256 56s200 89.5 200 200-89.5 200-200 200z"/></svg></span>
- <a class="navbar-brand" href="/">
- {{.SiteName}}
- <span class="text-danger">spec</span>
- </a>
- </span>
- <ul class="navbar-nav">
- {{if .Username}}
- {{range .Nav}}
- <li class="nav-item {{if .Active}}active{{end}}">
- <a class="nav-link" href="{{.Origin}}">{{.Name}}</a>
- </li>
- {{end}}
- {{end}}
- </ul>
- <div class="login">
- {{if .Username}}
- <span class="navbar-text">
- Logged in as
- <a href="{{.ProfileURL}}">{{.Username}}</a>
- —
- <a href="{{.LogoutURL}}">Log out</a>
- </span>
- {{else}}
- <span class="navbar-text">
- <a href="{{.LoginURL}}" rel="nofollow">Log in</a>
- —
- <a href="{{.RegisterURL}}">Register</a>
- </span>
- {{end}}
- </div>
+ {{template "srht-nav" .}}
</nav>
<div class="{{.ContainerClass}}">
{{template "content" .}}
A web/view.go => web/view.go +52 -0
@@ 0,0 1,52 @@
+package web
+
+import (
+ "net/http"
+
+ "sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/authn"
+)
+
+// viewData is the root value every template is executed against.
+//
+// chrome.Page is embedded rather than copied field by field, so the shared
+// partials — "srht-nav", "srht-env-banner" — find the fields they read on the
+// dot they are handed, and a field ecore adds later arrives here without an
+// edit. The page's own payload lives under Data and is reached as
+// {{.Data.Something}}, which is what keeps a page from shadowing a chrome
+// field: a page wanting a "Username" of its own puts it in its payload, where
+// it cannot silently replace the one the login block reads.
+type viewData struct {
+ chrome.Page
+
+ // Data is the page's own payload.
+ Data any
+}
+
+// view builds the frame for one request: the shared chrome plus a title.
+//
+// The username handed to the chrome is the *authoritative* identity, not
+// whatever the cookie said: on this instance a logged-in human who is not the
+// owner resolves to anonymous, so the nav offers them a login rather than
+// greeting them by a name that grants nothing. An agent's bearer token is not
+// an identity for the nav either — it never renders a page for itself.
+func (s *Server) view(r *http.Request, title string) viewData {
+ username := ""
+ if p := authn.PrincipalFromContext(r.Context()); p.IsOwner() {
+ username = p.Owner
+ }
+ return viewData{Page: s.chromeSvc.Page(r, title, username)}
+}
+
+// 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.
+//
+// The URL comes from a throwaway chrome.Page rather than from a second
+// hand-rolled concatenation of the meta origin and an escaped return_to: the
+// link in the nav and the redirect a gate issues must be the same URL, and the
+// cheapest way to guarantee that is to have exactly one place that builds it.
+func (s *Server) loginRedirect(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, s.chromeSvc.Page(r, "", "").LoginURL, http.StatusFound)
+}
M web/web_test.go => web/web_test.go +39 -53
@@ 17,6 17,8 @@ import (
"time"
"github.com/fernet/fernet-go"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
"github.com/vaughan0/go-ini"
"sourcecraft.dev/bigbes/sr-ht-core/auth"
"sourcecraft.dev/bigbes/sr-ht-core/crypto"
@@ 476,7 478,9 @@ func testServerWith(t *testing.T, reader *fakeReader) (http.Handler, *fakeReader
"spec.sr.ht": ini.Section{"origin": "https://spec.example"},
"meta.sr.ht": ini.Section{"origin": "https://meta.example"},
"git.sr.ht": ini.Section{"origin": "https://git.example"},
- // Extra service sections to exercise nav ordering/exclusions.
+ // The rest of the instance's services. Which of them the switcher shows
+ // and in what order is sr-ht-ecore's decision and is tested there; they
+ // are here so that these pages render against a realistic config.
"todo.sr.ht": ini.Section{"origin": "https://todo.example"},
"paste.sr.ht": ini.Section{"origin": "https://paste.example"},
"pages.sr.ht": ini.Section{"origin": "https://pages.example"},
@@ 742,67 746,52 @@ func TestTrailingSlashRedirectsToTheSpace(t *testing.T) {
}
// ---- identity and chrome --------------------------------------------------
-
-func TestForgedCookieYieldsLoggedInNav(t *testing.T) {
+//
+// What the shared chrome itself decides — the switcher's order, the services it
+// excludes, the shape of a login URL, the environment banner — is sr-ht-ecore's
+// and is tested there. What is left here is the seam: that this service hands
+// ecore the identity its own authn resolved, and that the page around the
+// chrome shows the right thing to that identity.
+
+// The owner's cookie must reach the chrome as an identity: ecore renders the
+// login block from the username it is given, so a greeting by name is the proof
+// that view() passed one.
+func TestOwnerCookieReachesTheChromeAsAnIdentity(t *testing.T) {
h, _ := testServer(t)
rec := get(t, h, "/", "bigbes")
- if rec.Code != http.StatusOK {
- t.Fatalf("status = %d", rec.Code)
- }
+ require.Equal(t, http.StatusOK, rec.Code)
+
body := rec.Body.String()
- if !strings.Contains(body, "Logged in as") || !strings.Contains(body, ">bigbes<") {
- t.Fatalf("cookie did not produce a logged-in nav:\n%s", body)
- }
- if !strings.Contains(body, "~bigbes/rfcs") {
- t.Fatal("logged-in landing page missing the space list")
- }
- if !strings.Contains(body, "https://todo.example") {
- t.Fatal("nav missing an expected service")
- }
- if strings.Contains(body, "https://paste.example") || strings.Contains(body, "https://pages.example") {
- t.Fatal("nav must exclude paste/pages")
- }
- nav := body[strings.Index(body, `<ul class="navbar-nav">`):strings.Index(body, "</ul>")]
- if strings.Contains(nav, "hub.example") {
- t.Fatal("hub is the brand, never a switcher item")
- }
- if !strings.Contains(nav, "nav-item active") {
- t.Fatal("spec should be the active nav item")
- }
- if !strings.Contains(body, "DEVELOPMENT ENVIRONMENT") {
- t.Fatal("non-production environment banner missing")
- }
+ assert.Contains(t, body, "Logged in as")
+ assert.Contains(t, body, ">bigbes<")
+ assert.Contains(t, body, "~bigbes/rfcs", "the logged-in landing page lists the spaces")
}
// A cookie sealed for somebody who is not the instance owner carries no
-// authority: authn resolves it to anonymous, and the nav must agree.
+// authority: authn resolves it to anonymous, and the page must agree.
func TestNonOwnerCookieIsAnonymous(t *testing.T) {
h, _ := testServer(t)
rec := get(t, h, "/", "someoneelse")
+
body := rec.Body.String()
- if strings.Contains(body, "Logged in as") {
- t.Fatalf("a non-owner cookie produced a logged-in nav:\n%s", body)
- }
- if strings.Contains(body, "~bigbes/rfcs") {
- t.Fatal("a non-owner must not see the space list")
- }
+ assert.NotContains(t, body, "Logged in as", "a non-owner cookie produced a logged-in nav")
+ assert.NotContains(t, body, "~bigbes/rfcs", "a non-owner must not see the space list")
}
func TestAnonymousLandingRendersWithoutContent(t *testing.T) {
h, _ := testServer(t)
rec := get(t, h, "/", "")
- if rec.Code != http.StatusOK {
- t.Fatalf("status = %d, want 200", rec.Code)
- }
+ require.Equal(t, http.StatusOK, rec.Code)
+
body := rec.Body.String()
- if !strings.Contains(body, "return_to=") {
- t.Fatal("login URL missing return_to")
- }
- if strings.Contains(body, "~bigbes/rfcs") {
- t.Fatal("anonymous landing page leaked a space name")
- }
+ assert.Contains(t, body, "https://meta.example/login", "the landing page offers a login")
+ assert.NotContains(t, body, "~bigbes/rfcs", "anonymous landing page leaked a space name")
}
+// The read gate is this package's; the URL it redirects to is the chrome's. The
+// assertions below are about the gate firing at all and about it sending a
+// browser somewhere it can come back from — not about how ecore spells a login
+// URL.
func TestAnonymousContentRedirectsToLogin(t *testing.T) {
h, _ := testServer(t)
for _, target := range []string{
@@ 811,16 800,13 @@ func TestAnonymousContentRedirectsToLogin(t *testing.T) {
"/search?q=storage",
} {
rec := get(t, h, target, "")
- if rec.Code != http.StatusFound {
- t.Fatalf("%s: status = %d, want 302\n%s", target, rec.Code, rec.Body.String())
- }
+ require.Equal(t, http.StatusFound, rec.Code, "%s: body %s", target, rec.Body)
+
loc := rec.Header().Get("Location")
- if !strings.HasPrefix(loc, "https://meta.example/login?return_to=") {
- t.Fatalf("%s: location = %q", target, loc)
- }
- if !strings.Contains(loc, "spec.example") {
- t.Fatalf("%s: return_to does not point back at us: %q", target, loc)
- }
+ assert.True(t, strings.HasPrefix(loc, "https://meta.example/login?return_to="),
+ "%s: location = %q", target, loc)
+ assert.Contains(t, loc, "spec.example",
+ "%s: return_to does not point back at us: %q", target, loc)
}
}