diff --git a/.gitignore b/.gitignore index 15c619ac1f73238e5e1703fb120e401e8db75c0a..07789a4950ca68fbc68bd6bc657fa115bb28777e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,7 @@ *.tmp # Local instance config /config.ini + +# Git worktrees checked out inside the repo (both conventions seen here) +.worktrees/ +.claude/worktrees/ diff --git a/README.md b/README.md index ec21de6165b74aa8a14c87bbe37c0bd557bead15..ee411d3133c57b03fe0a3f0e9d53df3681770b5b 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,9 @@ which git.sr.ht's own loader scopes to public/unlisted repositories, so private repos are never leaked (an unauthorized or missing repo is always a 404, never a 403). Identity comes from decrypting the shared `sr.ht.unified-login.v1` cookie with the instance network key; there is no login flow of our own. Pages are -server-rendered Go templates that reproduce the SourceHut chrome (nav, -service-switcher, login block, environment banner). The diff/tree UI is one +server-rendered Go templates wrapped in the SourceHut chrome (nav, +service-switcher, login block, environment banner), which comes from the shared +[sr-ht-ecore] `chrome` package rather than from a copy of its own. The diff/tree UI is one vendored [pierre-libs] esbuild bundle driven by a JSON blob embedded in the page, so the browser does all diff rendering from a single request with no second authorization round-trip. A short-TTL in-memory cache in front of the @@ -34,6 +35,7 @@ [sourcehut-custom-service integration model]: https://man.sr.ht/ [go-git]: https://github.com/go-git/go-git [pierre-libs]: https://www.npmjs.com/org/pierre +[sr-ht-ecore]: https://git.srht.bigb.es/~bigbes/sr-ht-ecore ## Layout @@ -43,7 +45,8 @@ - `gitx/` — bare-repo access over go-git: refs, ref-to-ref diffs, single-commit diffs, and commit logs, all bounded by context timeouts and output-size caps. - `authz/` — cookie→identity and the git.sr.ht GraphQL authorizer with a short TTL cache. -- `web/` — chi router, handlers, chrome, Go templates, embedded static assets. +- `web/` — chi router, handlers, Go templates, embedded static assets. The page + chrome is not here: it is `sr-ht-ecore/chrome`, wired up in `web/server.go`. - `frontend/` + `scss/` — build-time TypeScript diff bundle and the SCSS entry. - `cmd/comparesrht/` — the daemon entry point and startup validation. - `contrib/` — nginx server block, systemd unit, and a dev GraphQL stub. diff --git a/go.mod b/go.mod index 409817556441a64c5b2887f4356d46cde4662922..739ba1fc7b768e5177a4651f9badfa79ed082a13 100644 --- a/go.mod +++ b/go.mod @@ -7,8 +7,10 @@ github.com/fernet/fernet-go v0.0.0-20211208181803-9f70042a33ee github.com/go-chi/chi/v5 v5.3.1 github.com/go-git/go-git/v5 v5.19.1 github.com/sirupsen/logrus v1.9.4 + github.com/stretchr/testify v1.11.1 github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152 + sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260808143603-174115990895 ) require ( @@ -25,6 +27,7 @@ github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/emersion/go-message v0.18.2 // indirect github.com/emersion/go-pgpmail v0.2.2 // indirect @@ -49,6 +52,7 @@ github.com/lib/pq v1.10.9 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pjbgf/sha1cd v0.6.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_golang v1.16.0 // indirect github.com/prometheus/client_model v0.4.0 // indirect github.com/prometheus/common v0.44.0 // indirect @@ -63,4 +67,5 @@ golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.36.0 // indirect google.golang.org/protobuf v1.33.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 90887800bcce98ed1ed7cb3533cde24a39bef3e8..54828e3a7c71bc945f5ece338a3500060698df09 100644 --- a/go.sum +++ b/go.sum @@ -322,3 +322,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152 h1:9kQC+tDO2CO8avlKadb9Z0if4a6vJuEK80+4zcb6/fU= sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152/go.mod h1:Mu1Vx39ws/OTKWGoVERXvkdRSPLBdhuFTYv0ftVV31c= +sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260808143603-174115990895 h1:OGZrtBtMoXhyZGXrPqMzmrNQnStoCLBVuegGo7yF1Us= +sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260808143603-174115990895/go.mod h1:KeoZjm+/nnsdtc1WxB7X/0EeC+Rggt2OkwJDEc6XWnw= diff --git a/web/chrome.go b/web/chrome.go deleted file mode 100644 index ca1bff30280211e9634d34675717fac554a32b0b..0000000000000000000000000000000000000000 --- a/web/chrome.go +++ /dev/null @@ -1,129 +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-compare/authz" -) - -// navCanonical is the SourceHut service-switcher order. Services not listed here -// (including our own compare) 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 compare.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 compare.sr.ht marked active. -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 == "compare.sr.ht", - }) - } - 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 an anonymous viewer - LoginURL string - LogoutURL string - RegisterURL string - ProfileURL string - CSSHref string - BundleHref string - Environment string - ShowBanner bool - - // ContainerClass selects the width of the page's content wrapper: the - // centered Bootstrap "container" by default, or "container-fluid" for the - // full-bleed diff views (commit / compare). - 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. -func (s *Server) chrome(r *http.Request) viewData { - username := authz.ForContext(r.Context()) - - current := s.compareOrigin + r.URL.RequestURI() - loginURL := s.metaOrigin + "/login?return_to=" + url.QueryEscape(current) - logoutURL := s.metaOrigin + "/logout?return_to=" + url.QueryEscape(s.compareOrigin) - - 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, - BundleHref: s.bundleHref, - Environment: strings.ToUpper(s.environment), - ShowBanner: s.environment != "" && s.environment != "production", - } -} diff --git a/web/handlers.go b/web/handlers.go index 19708bbe6e03b4a490542faf82d70d493c377d20..6058177ab327bd5ec7fe5a4a611943dcf33763c6 100644 --- a/web/handlers.go +++ b/web/handlers.go @@ -11,6 +11,7 @@ "strings" "github.com/go-chi/chi/v5" "github.com/sirupsen/logrus" + "sourcecraft.dev/bigbes/sr-ht-ecore/chrome" "sourcecraft.dev/bigbes/sr-ht-compare/authz" "sourcecraft.dev/bigbes/sr-ht-compare/core" @@ -129,15 +130,22 @@ // ---- index ---------------------------------------------------------------- type indexData struct { LoggedIn bool - Repos []authz.RepoInfo + + // Repos is the viewer's own repositories in the shape ecore's + // "srht-repo-list" partial renders, so the landing's listing is the same + // event-list card the sibling services draw for their own projects. + Repos chrome.RepoList } func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { ctx := r.Context() username := authz.ForContext(ctx) - vd := s.chrome(r) - vd.Title = s.siteName + " compare" + // The title is built from the chrome's own brand fields rather than from a + // second read of site-name, so the tab and the nav cannot name the instance + // differently. + vd := s.view(r, "") + vd.Title = vd.SiteName + " " + vd.SiteLabel if username == "" { vd.Data = indexData{LoggedIn: false} @@ -150,10 +158,27 @@ if err != nil { s.fail(w, r, err) return } - vd.Data = indexData{LoggedIn: true, Repos: repos} + vd.Data = indexData{LoggedIn: true, Repos: repoList(username, repos)} s.render(w, http.StatusOK, "index", vd) } +// repoList turns the authorizer's repositories into the listing ecore renders. +// The owner is always the viewer — MyRepos answers for one account — so the +// "~owner/name" title and the link are built from the same name and cannot point +// at somebody else's repository. +func repoList(owner string, repos []authz.RepoInfo) chrome.RepoList { + items := make([]chrome.ListItem, 0, len(repos)) + for _, info := range repos { + items = append(items, chrome.ListItem{ + Href: "/~" + owner + "/" + info.Name, + Title: "~" + owner + "/" + info.Name, + Visibility: info.Visibility, + Description: info.Description, + }) + } + return chrome.RepoList{Items: items, Empty: "You have no repositories yet."} +} + // ---- repo page ------------------------------------------------------------ type repoData struct { @@ -184,8 +209,7 @@ } def, _ := g.DefaultBranch(ctx) commits, _ := recentCommits(ctx, g, def, recentCommitLimit) - vd := s.chrome(r) - vd.Title = "~" + owner + "/" + repo + vd := s.view(r, "~"+owner+"/"+repo) vd.Data = repoData{ Owner: owner, Info: info, @@ -311,9 +335,8 @@ s.fail(w, r, err) return } - vd := s.chrome(r) + vd := s.view(r, fmt.Sprintf("~%s/%s: %s...%s", owner, repo, spec.Base, spec.Head)) vd.ContainerClass = "container-fluid" - vd.Title = fmt.Sprintf("~%s/%s: %s...%s", owner, repo, spec.Base, spec.Head) vd.Data = compareView{ Owner: owner, RepoName: repo, @@ -404,9 +427,8 @@ s.fail(w, r, err) return } - vd := s.chrome(r) + vd := s.view(r, fmt.Sprintf("~%s/%s: %s", owner, repo, ci.ShortSHA)) vd.ContainerClass = "container-fluid" - vd.Title = fmt.Sprintf("~%s/%s: %s", owner, repo, ci.ShortSHA) vd.Data = commitView{ Owner: owner, RepoName: repo, diff --git a/web/server.go b/web/server.go index cab70cc2b781a1c019401ed3fd2e5e87ae154b17..ce522c28276e7e8fcde5e25430cc100a7bf69142 100644 --- a/web/server.go +++ b/web/server.go @@ -1,8 +1,7 @@ -// Package web is the HTTP layer of compare.sr.ht. It ports the SourceHut chrome -// (nav/service-switcher, login block, environment banner) to Go html/templates, -// renders the repository landing, compare (base...head) and single-commit pages -// server-side, and embeds a compact JSON payload plus the vendored esbuild -// bundle so the browser renders the diff with @pierre/diffs and @pierre/trees. +// Package web is the HTTP layer of compare.sr.ht. It renders the repository +// landing, compare (base...head) and single-commit pages server-side, and +// embeds a compact JSON payload plus the vendored esbuild bundle so the browser +// renders the diff with @pierre/diffs and @pierre/trees. // // The package owns no state of its own: identity comes from the authz cookie // middleware, authorization from an authz.Authorizer (git.sr.ht GraphQL), and @@ -10,6 +9,22 @@ // git data from gitx over bare repositories on disk. Every request that touches // a repository authorizes first (a not-found or forbidden repo is a 404, never // a 403, so private-repo existence never leaks) and only then reads the disk. // +// # The chrome is not ours +// +// The nav/service-switcher, the brand, the login block and the environment +// banner come from sourcecraft.dev/bigbes/sr-ht-ecore/chrome, which every custom +// service on the instance shares. This package builds one chrome.Service at +// startup, asks it for a chrome.Page per request, and embeds that Page in +// viewData so the fields promote into the templates. Nothing here rebuilds the +// switcher or re-derives a login URL: the copy that used to live in web/chrome.go +// is exactly what ecore exists to have deleted. +// +// Two things about the chrome remain this service's own, because they are about +// what compare renders and not about the instance: the vendored bundle's href +// (BundleHref below), and the full-bleed ContainerClass the two diff views set — +// a side-by-side diff in a centered "container" is a column of code half the +// window wide. +// // # What the cmd layer must wire // // Register only installs routes; it assumes the following middleware is already @@ -35,11 +50,18 @@ "path" "regexp" "github.com/vaughan0/go-ini" - "sourcecraft.dev/bigbes/sr-ht-core/config" + "sourcecraft.dev/bigbes/sr-ht-ecore/chrome" "sourcecraft.dev/bigbes/sr-ht-compare/authz" ) +// configSection is this service's literal section in the shared config.ini. It +// is what the switcher's "which entry is me" test compares against, so it must +// be spelled the same here, in the config file and in the middleware the cmd +// layer installs — a service that spelled it two ways would appear in the +// instance's navigation and fail to recognise itself in it. +const configSection = "compare.sr.ht" + // hashedCSSRe matches the content-addressed stylesheet name so it can be served // with an immutable cache lifetime (the hash changes whenever the bytes do). var hashedCSSRe = regexp.MustCompile(`^main\.min\.[0-9a-f]{6,}\.css$`) @@ -55,43 +77,47 @@ // once at startup and is safe for concurrent use. type Server struct { authorizer authz.Authorizer reposRoot string - conf ini.File - siteName string - environment string - metaOrigin string - compareOrigin string - hubOrigin string - cssHref string - bundleHref 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 chrome.Page in view (chrome.go's + // job until this service stopped carrying its own copy). + chromeSvc *chrome.Service - nav []navItem + bundleHref string + staticFileServer http.Handler } // New assembles a Server from the shared SourceHut config. It reads // [git.sr.ht] repos, [meta.sr.ht] origin and [compare.sr.ht] origin (all -// required), the [sr.ht] site-name/environment display values, and resolves the -// hashed stylesheet name by globbing the embedded static FS. A missing required -// key is a clear error, not a panic, so the cmd layer can fail startup loudly. +// required), hands the whole file to chrome.NewService — the switcher is a +// question about every [*.sr.ht] section the instance defines, not about our own +// keys — and resolves the hashed stylesheet and bundle names by globbing the +// embedded static FS. A missing required key is a clear error, not a panic, so +// the cmd layer can fail startup loudly. func New(conf ini.File, authorizer authz.Authorizer) (*Server, error) { reposRoot, ok := conf.Get("git.sr.ht", "repos") if !ok || reposRoot == "" { return nil, fmt.Errorf("web: [git.sr.ht] repos is required") } - metaOrigin := config.GetOrigin(conf, "meta.sr.ht", true) - if metaOrigin == "" { + + // The two origins are checked through the chrome that will render them + // rather than read a second time here, so the startup refusal and the links + // on the page cannot disagree about which origins this service has. + chromeSvc := chrome.NewService(conf, configSection) + if chromeSvc.MetaOrigin() == "" { return nil, fmt.Errorf("web: [meta.sr.ht] origin is required") } - compareOrigin := config.GetOrigin(conf, "compare.sr.ht", true) - if compareOrigin == "" { - return nil, fmt.Errorf("web: [compare.sr.ht] origin is required") + if chromeSvc.SelfOrigin() == "" { + return nil, fmt.Errorf("web: [%s] origin is required", configSection) } cssHref, err := resolveCSSHref() if err != nil { return nil, err } + chromeSvc.StyleHref = cssHref bundleHref, err := resolveBundleHref() if err != nil { @@ -106,17 +132,41 @@ return &Server{ authorizer: authorizer, reposRoot: reposRoot, - conf: conf, - siteName: config.GetString(conf, "sr.ht", "site-name", "sourcehut"), - environment: config.GetString(conf, "sr.ht", "environment", "production"), - metaOrigin: metaOrigin, - compareOrigin: compareOrigin, - hubOrigin: config.GetOrigin(conf, "hub.sr.ht", true), - cssHref: cssHref, + chromeSvc: chromeSvc, bundleHref: bundleHref, - nav: buildNav(conf), staticFileServer: http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))), }, nil +} + +// 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", "srht-repo-list" — find the fields +// they need 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. +type viewData struct { + chrome.Page + + // BundleHref is the hashed front-end bundle's URL. It is chrome — every page + // may load it — but it is this service's alone: no other service on the + // instance ships a diff renderer, so it stays here and not in ecore's Page. + BundleHref string + + // Data is the page's own payload. + Data any +} + +// view builds the frame for one request: the shared chrome plus a title. +// +// The username is whatever the authz cookie middleware resolved, which is "" for +// a viewer whose cookie is missing, expired or unreadable — so the nav offers +// login to exactly the viewers the handlers treat as anonymous. +func (s *Server) view(r *http.Request, title string) viewData { + return viewData{ + Page: s.chromeSvc.Page(r, title, authz.ForContext(r.Context())), + BundleHref: s.bundleHref, + } } // resolveCSSHref globs the embedded static FS for the content-addressed diff --git a/web/templates.go b/web/templates.go index f3244d8522fcf15b912a3633a40a66b22f79d01f..086567fde4a22cce77660535dca33d3455421b88 100644 --- a/web/templates.go +++ b/web/templates.go @@ -8,6 +8,7 @@ "net/http" "time" "github.com/sirupsen/logrus" + "sourcecraft.dev/bigbes/sr-ht-ecore/chrome" ) // tmplFS holds the page templates. Each page is parsed together with the shared @@ -24,22 +25,24 @@ //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 - }, +// +// It starts from chrome.Funcs — so the shared partials find the helpers they +// were written against, and so "shortsha" is the instance's one abbreviation +// rule rather than this service's copy of it — and adds compare's own on top. +// Adding after is deliberate: a name may then be shadowed on purpose rather than +// by accident of map ordering. +var funcMap = func() template.FuncMap { + m := chrome.Funcs() + // date formats a commit timestamp for display. - "date": func(t time.Time) string { + m["date"] = func(t time.Time) string { return t.UTC().Format("2006-01-02 15:04 MST") - }, + } + // statusClass maps a git file-change status letter to a CSS modifier used by // the .diff-status badge (see the diff-status rules in layout.html). Anything // unrecognized falls back to the neutral "o". - "statusClass": func(s string) string { + m["statusClass"] = func(s string) string { if s == "" { return "o" } @@ -55,9 +58,10 @@ return "r" default: return "o" } - }, + } + // statusLabel spells out a status letter for the badge's tooltip. - "statusLabel": func(s string) string { + m["statusLabel"] = func(s string) string { if s == "" { return "changed" } @@ -73,17 +77,22 @@ return "renamed" default: return "changed" } - }, -} + } + + return m +}() // pageNames are the content templates; each is parsed with layout.html. var pageNames = []string{"index", "repo", "compare", "commit", "error"} -// pages maps a page name to its parsed template set (layout + that page). +// pages maps a page name to its parsed template set: the shared chrome partials +// of sr-ht-ecore, the layout, and that one page. The partials are attached to +// every set rather than to a shared one, for the same reason the layout is — +// each page defines its own "content", and one set would let the last parsed win. 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/"+name+".html")) m[name] = t } @@ -121,8 +130,7 @@ // 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), diff --git a/web/templates/index.html b/web/templates/index.html index 293464c7f8bf2bd19c22d9997418c5bd36d38b4e..003d0379b80ea89722de97f86862e2a326a94da2 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -22,31 +22,13 @@

- {{if .Data.Repos}} -
- {{range .Data.Repos}} -
-

- ~{{$.Username}}/{{.Name}} - {{if ne .Visibility "PUBLIC"}} - {{if eq .Visibility "UNLISTED"}}unlisted{{else}}private{{end}} - {{end}} -

- {{if .Description}} -

{{.Description}}

- {{end}} -
- {{end}} -
- {{else}} -

You have no repositories yet.

- {{end}} + {{template "srht-repo-list" .Data.Repos}}
{{else}}
-

{{.SiteName}} compare

+

{{.SiteName}} {{.SiteLabel}}

Compare two references — branches, tags or commits — of any git repository on this instance, and review the diff file by file. diff --git a/web/templates/layout.html b/web/templates/layout.html index 1a53b79f089260a7e1cf993aaefbf716592dbb4c..28ee73a13b7c3e093ed55046e6f064ee5dda796e 100644 --- a/web/templates/layout.html +++ b/web/templates/layout.html @@ -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 the + 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 here is the document, the diff-status colours below (compare's + own, and nobody else's), and three seams: "head" and "scripts" are blocks, so + a page that needs neither still renders; "content" is a {{template}} and must + stay one — a block would give every page an empty default, which is the chrome + around a hole served 200 for the page that forgot to define it. +*/}} @@ -5,7 +18,10 @@ {{.Title}} - + {{/* Guarded rather than emitted empty: re-requests the page + it is on, which is a page load per page load. New refuses to start + without a stylesheet, so in a deployed binary this is always taken. */}} + {{if .StyleHref}}{{end}} {{block "head" .}}{{end}} - {{if .ShowBanner}} -

- {{.Environment}} ENVIRONMENT -
- {{end}} + {{template "srht-env-banner" .}}
{{template "content" .}} diff --git a/web/web_test.go b/web/web_test.go index a162fe94359ef76906590e8b884122865b91087e..3b8117b9e16ee7f9cfb6204d252e54b31f96b5e3 100644 --- a/web/web_test.go +++ b/web/web_test.go @@ -16,6 +16,8 @@ "testing" "github.com/fernet/fernet-go" "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/vaughan0/go-ini" "sourcecraft.dev/bigbes/sr-ht-core/config" "sourcecraft.dev/bigbes/sr-ht-core/crypto" @@ -99,15 +101,11 @@ "GIT_COMMITTER_NAME=Alice", "GIT_COMMITTER_EMAIL=alice@example.com", "GIT_AUTHOR_DATE="+date, "GIT_COMMITTER_DATE="+date, ) out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) - } + require.NoErrorf(t, err, "git %s:\n%s", strings.Join(args, " "), out) return string(out) } write := func(name, data string) { - if err := os.WriteFile(filepath.Join(work, name), []byte(data), 0o644); err != nil { - t.Fatal(err) - } + require.NoError(t, os.WriteFile(filepath.Join(work, name), []byte(data), 0o644)) } d1, d2, d3 := "2024-01-01T00:00:00Z", "2024-01-02T00:00:00Z", "2024-01-03T00:00:00Z" @@ -126,9 +124,7 @@ git(d3, "add", "feature.txt") git(d3, "commit", "-m", "add feature.txt") git(d3, "checkout", "main") - if err := os.MkdirAll(filepath.Join(root, "~alice"), 0o755); err != nil { - t.Fatal(err) - } + require.NoError(t, os.MkdirAll(filepath.Join(root, "~alice"), 0o755)) bare := filepath.Join(root, "~alice", "demo") git(d3, "clone", "--bare", work, bare) @@ -140,9 +136,7 @@ func runGit(t *testing.T, dir string, args ...string) string { t.Helper() cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) - } + require.NoErrorf(t, err, "git %s:\n%s", strings.Join(args, " "), out) return string(out) } @@ -160,18 +154,12 @@ "webhooks": ini.Section{"private-key": testConf.Section("webhooks")["private-key"]}, "compare.sr.ht": ini.Section{"origin": "https://compare.example"}, "meta.sr.ht": ini.Section{"origin": "https://meta.example"}, "git.sr.ht": ini.Section{"origin": "https://git.example", "repos": root}, - // Extra service sections to exercise nav ordering/exclusions. - "todo.sr.ht": ini.Section{"origin": "https://todo.example"}, - "builds.sr.ht": ini.Section{"origin": "https://builds.example"}, - "lists.sr.ht": ini.Section{"origin": "https://lists.example"}, - "paste.sr.ht": ini.Section{"origin": "https://paste.example"}, - "pages.sr.ht": ini.Section{"origin": "https://pages.example"}, - "hub.sr.ht": ini.Section{"origin": "https://hub.example"}, + "todo.sr.ht": ini.Section{"origin": "https://todo.example"}, + "hub.sr.ht": ini.Section{"origin": "https://hub.example"}, } srv, err := New(conf, az) - if err != nil { - t.Fatalf("New: %v", err) - } + require.NoError(t, err, "New") + r := chi.NewRouter() r.Use(config.Middleware(conf, "compare.sr.ht")) r.Use(authz.Middleware()) @@ -215,40 +203,27 @@ root, _ := gitFixture(t) h := testServer(t, root, demoAuthorizer()) rec := get(t, h, "/~alice/demo/compare/main...feature", "") - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200\n%s", rec.Code, rec.Body.String()) - } + require.Equalf(t, http.StatusOK, rec.Code, "body:\n%s", rec.Body.String()) + body := rec.Body.String() - if !strings.Contains(body, `id="compare-data"`) { - t.Fatal("missing compare-data script") - } - if !strings.Contains(body, `data-diff-wrap`) { - t.Fatal("missing long-line wrapping control") - } - if !strings.Contains(body, `src="/static/`+bundleName(t)+`"`) { - t.Fatal("missing hashed bundle script tag") - } + assert.Contains(t, body, `id="compare-data"`, "missing compare-data script") + assert.Contains(t, body, `data-diff-wrap`, "missing long-line wrapping control") + assert.Contains(t, body, `src="/static/`+bundleName(t)+`"`, "missing hashed bundle script tag") cd := extractCompareData(t, body) - if cd.Mode != "compare" { - t.Fatalf("mode = %q, want compare", cd.Mode) - } - if cd.Spec.Base != "main" || cd.Spec.Head != "feature" || !cd.Spec.ThreeDot { - t.Fatalf("spec = %+v, want base=main head=feature threeDot=true", cd.Spec) - } - // feature adds feature.txt relative to the merge base (c1). + assert.Equal(t, "compare", cd.Mode) + assert.Equal(t, jsonSpec{Base: "main", Head: "feature", ThreeDot: true}, cd.Spec) + + // feature adds feature.txt relative to the merge base (c1), and paths carry + // no a/ or b/ diff prefix. found := false for _, f := range cd.Files { if f.Path == "feature.txt" { found = true - if strings.HasPrefix(f.Path, "a/") || strings.HasPrefix(f.Path, "b/") { - t.Fatalf("file path has diff prefix: %q", f.Path) - } } + assert.NotRegexp(t, `^[ab]/`, f.Path, "file path has a diff prefix") } - if !found { - t.Fatalf("feature.txt not in files: %+v", cd.Files) - } + assert.Truef(t, found, "feature.txt not in files: %+v", cd.Files) } func TestTwoDotVsThreeDot(t *testing.T) { @@ -256,13 +231,10 @@ root, _ := gitFixture(t) h := testServer(t, root, demoAuthorizer()) two := extractCompareData(t, get(t, h, "/~alice/demo/compare/main..feature", "").Body.String()) - if two.Spec.ThreeDot { - t.Fatal("main..feature parsed as three-dot") - } + assert.False(t, two.Spec.ThreeDot, "main..feature parsed as three-dot") + three := extractCompareData(t, get(t, h, "/~alice/demo/compare/main...feature", "").Body.String()) - if !three.Spec.ThreeDot { - t.Fatal("main...feature parsed as two-dot") - } + assert.True(t, three.Spec.ThreeDot, "main...feature parsed as two-dot") } func TestComparePatchRoute(t *testing.T) { @@ -270,15 +242,10 @@ root, _ := gitFixture(t) h := testServer(t, root, demoAuthorizer()) rec := get(t, h, "/~alice/demo/compare/main...feature.patch", "") - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", rec.Code) - } - if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") { - t.Fatalf("content-type = %q, want text/plain", ct) - } - if !strings.Contains(rec.Body.String(), "diff --git") { - t.Fatalf("patch body missing diff header:\n%s", rec.Body.String()) - } + require.Equal(t, http.StatusOK, rec.Code) + assert.True(t, strings.HasPrefix(rec.Header().Get("Content-Type"), "text/plain"), + "content-type = %q, want text/plain", rec.Header().Get("Content-Type")) + assert.Contains(t, rec.Body.String(), "diff --git", "patch body missing diff header") } func TestCommitPage(t *testing.T) { @@ -286,161 +253,155 @@ root, mainSHA := gitFixture(t) h := testServer(t, root, demoAuthorizer()) rec := get(t, h, "/~alice/demo/commit/"+mainSHA, "") - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200\n%s", rec.Code, rec.Body.String()) - } - if !strings.Contains(rec.Body.String(), `data-diff-wrap`) { - t.Fatal("missing long-line wrapping control") - } + require.Equalf(t, http.StatusOK, rec.Code, "body:\n%s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `data-diff-wrap`, "missing long-line wrapping control") + cd := extractCompareData(t, rec.Body.String()) - if cd.Mode != "commit" { - t.Fatalf("mode = %q, want commit", cd.Mode) - } + assert.Equal(t, "commit", cd.Mode) // c2 modifies a.txt and adds b.txt. - if len(cd.Files) == 0 { - t.Fatal("commit page has no files") - } + assert.NotEmpty(t, cd.Files, "commit page has no files") } func TestCommitPatchRoute(t *testing.T) { root, mainSHA := gitFixture(t) h := testServer(t, root, demoAuthorizer()) + rec := get(t, h, "/~alice/demo/commit/"+mainSHA+".patch", "") - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", rec.Code) - } - if !strings.Contains(rec.Body.String(), "diff --git") { - t.Fatal("commit patch missing diff header") - } + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "diff --git", "commit patch missing diff header") } func TestUnknownRepoIs404(t *testing.T) { root, _ := gitFixture(t) h := testServer(t, root, demoAuthorizer()) + rec := get(t, h, "/~alice/nope/compare/main...feature", "") - if rec.Code != http.StatusNotFound { - t.Fatalf("status = %d, want 404", rec.Code) - } + assert.Equal(t, http.StatusNotFound, rec.Code) } func TestPrivateRepoInvisibleIs404(t *testing.T) { // Authorizer reports the repo as not-found (visibility hidden) even though // the bare repo exists on disk. root, _ := gitFixture(t) - az := &stubAuthorizer{repos: map[string]authz.RepoInfo{}} - h := testServer(t, root, az) + h := testServer(t, root, &stubAuthorizer{repos: map[string]authz.RepoInfo{}}) + rec := get(t, h, "/~alice/demo", "") - if rec.Code != http.StatusNotFound { - t.Fatalf("status = %d, want 404", rec.Code) - } + assert.Equal(t, http.StatusNotFound, rec.Code) } func TestAuthorizerTransportErrorIs500(t *testing.T) { root, _ := gitFixture(t) - az := &stubAuthorizer{err: errors.New("graphql unreachable")} - h := testServer(t, root, az) + h := testServer(t, root, &stubAuthorizer{err: errors.New("graphql unreachable")}) + rec := get(t, h, "/~alice/demo", "") - if rec.Code != http.StatusInternalServerError { - t.Fatalf("status = %d, want 500 (transport error must not be 404)", rec.Code) - } + assert.Equal(t, http.StatusInternalServerError, rec.Code, + "a transport error must not be reported as 404") } func TestBadRefIs400(t *testing.T) { root, _ := gitFixture(t) h := testServer(t, root, demoAuthorizer()) + rec := get(t, h, "/~alice/demo/compare/..bad", "") - if rec.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want 400", rec.Code) - } + assert.Equal(t, http.StatusBadRequest, rec.Code) } func TestIndexAnonymous(t *testing.T) { root, _ := gitFixture(t) h := testServer(t, root, demoAuthorizer()) + rec := get(t, h, "/", "") + require.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() - if rec.Code != http.StatusOK { - t.Fatalf("status = %d", rec.Code) - } - if !strings.Contains(body, `action="/jump"`) { - t.Fatal("anonymous index missing jump form") - } - if !strings.Contains(body, "return_to=") { - t.Fatal("login URL missing return_to") - } + assert.Contains(t, body, `action="/jump"`, "anonymous index missing jump form") + assert.Contains(t, body, "return_to=", "login URL missing return_to") } func TestIndexLoggedIn(t *testing.T) { root, _ := gitFixture(t) h := testServer(t, root, demoAuthorizer()) + rec := get(t, h, "/", "bigbes") + require.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() - if rec.Code != http.StatusOK { - t.Fatalf("status = %d", rec.Code) - } - if !strings.Contains(body, "/~bigbes/demo") { - t.Fatal("logged-in index missing repo link from MyRepos") - } - if !strings.Contains(body, `private`) { - t.Fatal("logged-in index missing visibility label on the event card") - } + assert.Contains(t, body, "/~bigbes/demo", "logged-in index missing repo link from MyRepos") + assert.Contains(t, body, `private`, + "logged-in index missing the visibility label on the event card") } -func TestNavExclusionsAndActive(t *testing.T) { +// TestChromeIsRendered checks that the layout really draws the shared partials +// of sr-ht-ecore — the brand's red service label and the login block. What the +// switcher contains and how it is ordered is ecore's business and ecore's test; +// this one only asserts that this service's layout invokes the chrome at all, +// which is the wiring a bad merge here would break. +func TestChromeIsRendered(t *testing.T) { root, _ := gitFixture(t) h := testServer(t, root, demoAuthorizer()) - // Nav switcher only renders for a logged-in viewer. - body := get(t, h, "/", "bigbes").Body.String() - if !strings.Contains(body, "https://git.example") || !strings.Contains(body, "https://todo.example") { - t.Fatal("nav missing expected services") - } - if strings.Contains(body, "https://paste.example") || strings.Contains(body, "https://pages.example") { - t.Fatal("nav must exclude paste/pages") - } - // hub is the brand, never a switcher item. - nav := body[strings.Index(body, `")] - if strings.Contains(nav, "hub.example") { - t.Fatal("hub must not appear in the switcher list") - } - if !strings.Contains(nav, `nav-item active`) { - t.Fatal("compare should be the active nav item") + anon := get(t, h, "/", "").Body.String() + assert.Contains(t, anon, `compare`, + "the brand's service label is missing") + assert.Contains(t, anon, "Log in", "an anonymous viewer must be offered the login") + // The environment is "development" in the test config, so the banner shows. + assert.Contains(t, anon, "DEVELOPMENT ENVIRONMENT", "missing the non-production banner") + + viewer := get(t, h, "/", "bigbes").Body.String() + assert.Contains(t, viewer, "Logged in as", "the login block does not name the viewer") + assert.Contains(t, viewer, "https://hub.example/~bigbes", + "the profile link should prefer hub's ~username page") + assert.Contains(t, viewer, "https://todo.example", "the switcher is missing a sibling service") +} + +// TestDiffPagesAreFullBleed pins the one chrome decision compare makes for +// itself: the diff views ask for the full window, because a side-by-side diff in +// the centered container is a column of code half the page wide. +func TestDiffPagesAreFullBleed(t *testing.T) { + root, mainSHA := gitFixture(t) + h := testServer(t, root, demoAuthorizer()) + + for _, tc := range []struct { + name, target, want string + }{ + {"index", "/", `
`}, + {"repo", "/~alice/demo", `
`}, + {"compare", "/~alice/demo/compare/main...feature", `
`}, + {"commit", "/~alice/demo/commit/" + mainSHA, `
`}, + } { + t.Run(tc.name, func(t *testing.T) { + rec := get(t, h, tc.target, "") + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), tc.want) + }) } } func TestHealthz(t *testing.T) { root, _ := gitFixture(t) h := testServer(t, root, demoAuthorizer()) + rec := get(t, h, "/healthz", "") - if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "ok") { - t.Fatalf("healthz = %d %q", rec.Code, rec.Body.String()) - } + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "ok") } func TestStaticBundleAndCSS(t *testing.T) { root, _ := gitFixture(t) - srvHandler := testServer(t, root, demoAuthorizer()) + h := testServer(t, root, demoAuthorizer()) bundle := bundleName(t) - rec := get(t, srvHandler, "/static/"+bundle, "") - if rec.Code != http.StatusOK { - t.Fatalf("%s status = %d", bundle, rec.Code) - } - if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "javascript") { - t.Fatalf("%s content-type = %q", bundle, ct) - } - if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") { - t.Fatalf("hashed bundle cache-control = %q, want immutable", cc) - } + rec := get(t, h, "/static/"+bundle, "") + require.Equalf(t, http.StatusOK, rec.Code, "%s", bundle) + assert.Contains(t, rec.Header().Get("Content-Type"), "javascript") + assert.Contains(t, rec.Header().Get("Cache-Control"), "immutable", + "a hashed bundle must be cacheable forever") css := cssName(t) - rec = get(t, srvHandler, "/static/"+css, "") - if rec.Code != http.StatusOK { - t.Fatalf("css status = %d", rec.Code) - } - if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") { - t.Fatalf("hashed css cache-control = %q, want immutable", cc) - } + rec = get(t, h, "/static/"+css, "") + require.Equalf(t, http.StatusOK, rec.Code, "%s", css) + assert.Contains(t, rec.Header().Get("Cache-Control"), "immutable", + "a hashed stylesheet must be cacheable forever") } // TestCompareJSONNoScriptBreakout verifies a file path containing "" @@ -448,29 +409,23 @@ // cannot break out of the embedded .txt", Status: "A", Additions: 1}} + html, err := buildCompareJSON("compare", patch, files, jsonSpec{Base: "a", Head: "b"}) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) + s := string(html) // The '<' of "" must be escaped, so no literal ".js). func bundleName(t *testing.T) string { t.Helper() href, err := resolveBundleHref() - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) return strings.TrimPrefix(href, "/static/") } @@ -489,17 +442,13 @@ func extractCompareData(t *testing.T, body string) compareData { t.Helper() const open = `id="compare-data" type="application/json">` i := strings.Index(body, open) - if i < 0 { - t.Fatalf("no compare-data script in body:\n%s", body) - } + require.GreaterOrEqualf(t, i, 0, "no compare-data script in body:\n%s", body) + rest := body[i+len(open):] j := strings.Index(rest, "") - if j < 0 { - t.Fatal("compare-data script not closed") - } + require.GreaterOrEqual(t, j, 0, "compare-data script not closed") + var cd compareData - if err := json.Unmarshal([]byte(rest[:j]), &cd); err != nil { - t.Fatalf("decode compare-data: %v\nraw: %s", err, rest[:j]) - } + require.NoErrorf(t, json.Unmarshal([]byte(rest[:j]), &cd), "raw: %s", rest[:j]) return cd }