A web/chrome.go => web/chrome.go +121 -0
@@ 0,0 1,121 @@
+package web
+
+import (
+ "net/http"
+ "net/url"
+ "sort"
+ "strings"
+
+ "git.sr.ht/~sircmpwn/core-go/config"
+ "github.com/vaughan0/go-ini"
+
+ "go.bigb.es/sourcehut-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
+ Environment string
+ ShowBanner bool
+
+ 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{
+ 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",
+ }
+}
A web/handlers.go => web/handlers.go +429 -0
@@ 0,0 1,429 @@
+package web
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "html/template"
+ "net/http"
+ "strings"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/sirupsen/logrus"
+
+ "go.bigb.es/sourcehut-compare/authz"
+ "go.bigb.es/sourcehut-compare/core"
+ "go.bigb.es/sourcehut-compare/gitx"
+)
+
+// recentCommitLimit bounds the first-parent history shown on the repo page.
+const recentCommitLimit = 20
+
+// compareLogLimit bounds the commit list on the compare page.
+const compareLogLimit = 50
+
+// ---- JSON transport (consumed by the front-end bundle) --------------------
+
+// jsonFile mirrors one gitx.FileChange for the browser. path is the plain repo
+// path with NO a/ or b/ prefix.
+type jsonFile struct {
+ Path string `json:"path"`
+ OldPath string `json:"oldPath"`
+ Status string `json:"status"`
+ Additions int `json:"additions"`
+ Deletions int `json:"deletions"`
+ Binary bool `json:"binary"`
+}
+
+type jsonSpec struct {
+ Base string `json:"base"`
+ Head string `json:"head"`
+ ThreeDot bool `json:"threeDot"`
+}
+
+type compareData struct {
+ Mode string `json:"mode"`
+ Patch string `json:"patch"`
+ Truncated bool `json:"truncated"`
+ Files []jsonFile `json:"files"`
+ Spec jsonSpec `json:"spec"`
+}
+
+// buildCompareJSON marshals the browser payload. json.Marshal escapes <, > and &
+// (Go's default HTML-safe mode), so the result is safe to drop verbatim inside a
+// <script> element even when a file path contains "</script>". The bytes are
+// returned as template.JS: any <script> is a JS context to html/template, so a
+// plain string (or template.HTML) would be JS-escaped and corrupted; template.JS
+// is emitted verbatim, and the marshaler's escaping already blocks a breakout.
+func buildCompareJSON(mode string, patch *gitx.Patch, files []gitx.FileChange, spec jsonSpec) (template.JS, error) {
+ cd := compareData{
+ Mode: mode,
+ Patch: patch.Text,
+ Truncated: patch.Truncated,
+ Files: []jsonFile{},
+ Spec: spec,
+ }
+ for _, f := range files {
+ cd.Files = append(cd.Files, jsonFile{
+ Path: f.Path,
+ OldPath: f.OldPath,
+ Status: f.Status,
+ Additions: f.Additions,
+ Deletions: f.Deletions,
+ Binary: f.Binary,
+ })
+ }
+ b, err := json.Marshal(cd)
+ if err != nil {
+ return "", err
+ }
+ return template.JS(b), nil
+}
+
+// ---- error mapping --------------------------------------------------------
+
+// httpStatusFor maps a domain error to an HTTP status. Repo visibility uses
+// core.ErrNotFound so a private repo is a 404, never a 403.
+func httpStatusFor(err error) int {
+ switch {
+ case errors.Is(err, core.ErrNotFound):
+ return http.StatusNotFound
+ case errors.Is(err, core.ErrBadRef):
+ return http.StatusBadRequest
+ case errors.Is(err, core.ErrForbidden):
+ return http.StatusForbidden
+ default:
+ return http.StatusInternalServerError
+ }
+}
+
+// fail renders the chrome error page for err, logging 5xx causes.
+func (s *Server) fail(w http.ResponseWriter, r *http.Request, err error) {
+ status := httpStatusFor(err)
+ if status >= 500 {
+ logrus.WithError(err).WithField("path", r.URL.Path).Error("web: request failed")
+ s.renderError(w, r, status, "an internal error occurred")
+ return
+ }
+ s.renderError(w, r, status, err.Error())
+}
+
+// resolve authorizes and opens a repository, returning the git handle and the
+// authz metadata. Any error is already mapped to the right HTTP status by the
+// caller via fail.
+func (s *Server) resolve(ctx context.Context, owner, repo string) (*gitx.Repo, *authz.RepoInfo, error) {
+ viewer := authz.ForContext(ctx)
+ info, err := s.authorizer.Repo(ctx, viewer, owner, repo)
+ if err != nil {
+ return nil, nil, err
+ }
+ g, err := gitx.Open(s.reposRoot, owner, repo)
+ if err != nil {
+ return nil, nil, err
+ }
+ return g, info, nil
+}
+
+// ---- index ----------------------------------------------------------------
+
+type indexData struct {
+ LoggedIn bool
+ Repos []authz.RepoInfo
+}
+
+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"
+
+ if username == "" {
+ vd.Data = indexData{LoggedIn: false}
+ s.render(w, http.StatusOK, "index", vd)
+ return
+ }
+
+ repos, err := s.authorizer.MyRepos(ctx, username)
+ if err != nil {
+ s.fail(w, r, err)
+ return
+ }
+ vd.Data = indexData{LoggedIn: true, Repos: repos}
+ s.render(w, http.StatusOK, "index", vd)
+}
+
+// ---- repo page ------------------------------------------------------------
+
+type repoData struct {
+ Owner string
+ Info *authz.RepoInfo
+ DefaultBranch string
+ Branches []gitx.Ref
+ Tags []gitx.Ref
+ Commits []gitx.CommitInfo
+}
+
+func (s *Server) handleRepo(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ owner := chi.URLParam(r, "owner")
+ repo := chi.URLParam(r, "repo")
+
+ g, info, err := s.resolve(ctx, owner, repo)
+ if err != nil {
+ s.fail(w, r, err)
+ return
+ }
+
+ branches, tags, err := g.Refs(ctx)
+ if err != nil {
+ s.fail(w, r, err)
+ return
+ }
+ def, _ := g.DefaultBranch(ctx)
+ commits, _ := recentCommits(ctx, g, def, recentCommitLimit)
+
+ vd := s.chrome(r)
+ vd.Title = "~" + owner + "/" + repo
+ vd.Data = repoData{
+ Owner: owner,
+ Info: info,
+ DefaultBranch: def,
+ Branches: branches,
+ Tags: tags,
+ Commits: commits,
+ }
+ s.render(w, http.StatusOK, "repo", vd)
+}
+
+// recentCommits walks first-parent history from rev, returning up to limit
+// commits. It relies only on gitx.ResolveCommit so it needs no dedicated log
+// range. An unresolvable starting revision (e.g. an empty repository) yields an
+// empty slice rather than an error.
+func recentCommits(ctx context.Context, g *gitx.Repo, rev string, limit int) ([]gitx.CommitInfo, error) {
+ if rev == "" {
+ return nil, nil
+ }
+ var out []gitx.CommitInfo
+ cur := rev
+ for i := 0; i < limit; i++ {
+ ci, err := g.ResolveCommit(ctx, cur)
+ if err != nil {
+ if i == 0 {
+ return nil, nil
+ }
+ break
+ }
+ out = append(out, *ci)
+ if len(ci.ParentSHAs) == 0 {
+ break
+ }
+ cur = ci.ParentSHAs[0]
+ }
+ return out, nil
+}
+
+// ---- compare page ---------------------------------------------------------
+
+type compareView struct {
+ Owner string
+ RepoName string
+ Info *authz.RepoInfo
+ Spec core.CompareSpec
+ MergeBase string
+ Commits []gitx.CommitInfo
+ Files []gitx.FileChange
+ Truncated bool
+ CompareURL string
+ PatchURL string
+ JSON template.JS
+}
+
+func (s *Server) handleCompare(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ owner := chi.URLParam(r, "owner")
+ repo := chi.URLParam(r, "repo")
+ raw := chi.URLParam(r, "*")
+
+ // Empty wildcard: this is the compare form's GET target. Canonicalize the
+ // base/head/mode query into a clean compare URL and redirect.
+ if raw == "" {
+ s.compareRedirect(w, r, owner, repo)
+ return
+ }
+
+ patchMode := strings.HasSuffix(raw, ".patch")
+ specRaw := strings.TrimSuffix(raw, ".patch")
+
+ spec, err := core.ParseCompareSpec(specRaw)
+ if err != nil {
+ s.fail(w, r, err)
+ return
+ }
+
+ g, info, err := s.resolve(ctx, owner, repo)
+ if err != nil {
+ s.fail(w, r, err)
+ return
+ }
+
+ compareURL := fmt.Sprintf("/~%s/%s/compare/%s", owner, repo, specRaw)
+
+ if patchMode {
+ patch, err := g.RawDiff(ctx, spec)
+ if err != nil {
+ s.fail(w, r, err)
+ return
+ }
+ s.writePatch(w, patch.Text)
+ return
+ }
+
+ patch, err := g.Diff(ctx, spec)
+ if err != nil {
+ s.fail(w, r, err)
+ return
+ }
+ files, err := g.DiffStat(ctx, spec)
+ if err != nil {
+ s.fail(w, r, err)
+ return
+ }
+ commits, err := g.Log(ctx, spec.Base, spec.Head, compareLogLimit)
+ if err != nil {
+ s.fail(w, r, err)
+ return
+ }
+
+ var mergeBase string
+ if spec.ThreeDot {
+ mergeBase, _ = g.MergeBase(ctx, spec.Base, spec.Head)
+ }
+
+ jsonPayload, err := buildCompareJSON("compare", patch, files, jsonSpec{
+ Base: spec.Base,
+ Head: spec.Head,
+ ThreeDot: spec.ThreeDot,
+ })
+ if err != nil {
+ s.fail(w, r, err)
+ return
+ }
+
+ vd := s.chrome(r)
+ vd.Title = fmt.Sprintf("~%s/%s: %s...%s", owner, repo, spec.Base, spec.Head)
+ vd.Data = compareView{
+ Owner: owner,
+ RepoName: repo,
+ Info: info,
+ Spec: spec,
+ MergeBase: mergeBase,
+ Commits: commits,
+ Files: files,
+ Truncated: patch.Truncated,
+ CompareURL: compareURL,
+ PatchURL: compareURL + ".patch",
+ JSON: jsonPayload,
+ }
+ s.render(w, http.StatusOK, "compare", vd)
+}
+
+// compareRedirect turns ?base=&head=&mode= into a canonical compare URL. mode
+// "two" selects the two-dot range; anything else (the default) is three-dot.
+func (s *Server) compareRedirect(w http.ResponseWriter, r *http.Request, owner, repo string) {
+ q := r.URL.Query()
+ base := strings.TrimSpace(q.Get("base"))
+ head := strings.TrimSpace(q.Get("head"))
+ if base == "" || head == "" {
+ s.renderError(w, r, http.StatusBadRequest, "both base and head are required")
+ return
+ }
+ sep := "..."
+ if q.Get("mode") == "two" {
+ sep = ".."
+ }
+ http.Redirect(w, r, fmt.Sprintf("/~%s/%s/compare/%s%s%s", owner, repo, base, sep, head), http.StatusFound)
+}
+
+// ---- commit page ----------------------------------------------------------
+
+type commitView struct {
+ Owner string
+ RepoName string
+ Info *authz.RepoInfo
+ Commit *gitx.CommitInfo
+ Files []gitx.FileChange
+ IsMerge bool
+ Truncated bool
+ CommitURL string
+ PatchURL string
+ JSON template.JS
+}
+
+func (s *Server) handleCommit(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ owner := chi.URLParam(r, "owner")
+ repo := chi.URLParam(r, "repo")
+ rev := chi.URLParam(r, "rev")
+
+ patchMode := strings.HasSuffix(rev, ".patch")
+ rev = strings.TrimSuffix(rev, ".patch")
+
+ g, info, err := s.resolve(ctx, owner, repo)
+ if err != nil {
+ s.fail(w, r, err)
+ return
+ }
+
+ patch, files, ci, err := g.CommitPatch(ctx, rev)
+ if err != nil {
+ s.fail(w, r, err)
+ return
+ }
+
+ if patchMode {
+ s.writePatch(w, patch.Text)
+ return
+ }
+
+ commitURL := fmt.Sprintf("/~%s/%s/commit/%s", owner, repo, rev)
+
+ base := ""
+ if len(ci.ParentSHAs) > 0 {
+ base = ci.ParentSHAs[0]
+ }
+ jsonPayload, err := buildCompareJSON("commit", patch, files, jsonSpec{
+ Base: base,
+ Head: ci.SHA,
+ ThreeDot: false,
+ })
+ if err != nil {
+ s.fail(w, r, err)
+ return
+ }
+
+ vd := s.chrome(r)
+ vd.Title = fmt.Sprintf("~%s/%s: %s", owner, repo, ci.ShortSHA)
+ vd.Data = commitView{
+ Owner: owner,
+ RepoName: repo,
+ Info: info,
+ Commit: ci,
+ Files: files,
+ IsMerge: len(ci.ParentSHAs) > 1,
+ Truncated: patch.Truncated,
+ CommitURL: commitURL,
+ PatchURL: commitURL + ".patch",
+ JSON: jsonPayload,
+ }
+ s.render(w, http.StatusOK, "commit", vd)
+}
+
+// writePatch emits a raw unified diff as an inline text/plain document.
+func (s *Server) writePatch(w http.ResponseWriter, text string) {
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ w.Header().Set("Content-Disposition", "inline")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(text))
+}
A web/router.go => web/router.go +58 -0
@@ 0,0 1,58 @@
+package web
+
+import (
+ "net/http"
+ "path"
+ "strings"
+
+ "github.com/go-chi/chi/v5"
+)
+
+// Register mounts every compare.sr.ht route onto r. The caller is responsible
+// for installing the middleware documented on the package (config + authz at a
+// minimum); Register adds no middleware of its own.
+func (s *Server) Register(r chi.Router) {
+ r.Get("/", s.handleIndex)
+ r.Get("/jump", s.handleJump)
+ r.Get("/healthz", s.handleHealthz)
+ r.Get("/static/*", s.handleStatic)
+
+ r.Get("/~{owner}/{repo}", s.handleRepo)
+ // A single wildcard route serves both the form target (empty wildcard ⇒
+ // redirect to the canonical URL) and the compare view itself.
+ r.Get("/~{owner}/{repo}/compare/*", s.handleCompare)
+ r.Get("/~{owner}/{repo}/commit/{rev}", s.handleCommit)
+}
+
+// handleHealthz is a dependency-free liveness probe.
+func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ _, _ = w.Write([]byte("ok\n"))
+}
+
+// handleStatic serves the embedded assets, tagging the content-addressed
+// stylesheet as immutable and forcing a JS content type for the bundle.
+func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
+ name := path.Base(r.URL.Path)
+ if strings.HasSuffix(name, ".js") {
+ w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
+ }
+ if hashedCSSRe.MatchString(name) {
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+ } else {
+ w.Header().Set("Cache-Control", "public, max-age=3600")
+ }
+ s.staticFileServer.ServeHTTP(w, r)
+}
+
+// handleJump powers the owner/repo jump form: it redirects to the canonical repo
+// URL. A leading "~" on the owner is tolerated.
+func (s *Server) handleJump(w http.ResponseWriter, r *http.Request) {
+ owner := strings.TrimPrefix(strings.TrimSpace(r.URL.Query().Get("owner")), "~")
+ repo := strings.TrimSpace(r.URL.Query().Get("repo"))
+ if owner == "" || repo == "" {
+ s.renderError(w, r, http.StatusBadRequest, "both owner and repository are required")
+ return
+ }
+ http.Redirect(w, r, "/~"+owner+"/"+repo, http.StatusFound)
+}
A web/server.go => web/server.go +120 -0
@@ 0,0 1,120 @@
+// 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.
+//
+// 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
+// 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.
+//
+// # What the cmd layer must wire
+//
+// Register only installs routes; it assumes the following middleware is already
+// applied to the router it is handed, in this order (outermost first):
+//
+// chi middleware.RealIP
+// chi middleware.Recoverer
+// chi middleware.Logger (optional, but recommended)
+// config.Middleware(conf, "compare.sr.ht") // required: authz + gitx read it
+// authz.Middleware() // required: never 401s; sets the viewer
+//
+// config.Middleware must run before authz.Middleware is irrelevant to authz
+// itself (it only reads the cookie), but the GraphQL authorizer invoked inside
+// handlers needs config.ForContext(ctx) to resolve git.sr.ht's API origin, so
+// config.Middleware is mandatory on every request that reaches a handler.
+package web
+
+import (
+ "fmt"
+ "io/fs"
+ "net/http"
+ "path"
+ "regexp"
+
+ "git.sr.ht/~sircmpwn/core-go/config"
+ "github.com/vaughan0/go-ini"
+
+ "go.bigb.es/sourcehut-compare/authz"
+)
+
+// 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$`)
+
+// Server holds the immutable configuration a request handler needs. It is built
+// 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
+
+ nav []navItem
+ 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.
+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 == "" {
+ 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")
+ }
+
+ cssHref, err := resolveCSSHref()
+ if err != nil {
+ return nil, err
+ }
+
+ staticSub, err := fs.Sub(staticFS, "static")
+ if err != nil {
+ return nil, fmt.Errorf("web: sub static FS: %w", err)
+ }
+
+ 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,
+ nav: buildNav(conf),
+ staticFileServer: http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))),
+ }, nil
+}
+
+// resolveCSSHref globs the embedded static FS for the content-addressed
+// stylesheet and returns its site-absolute URL.
+func resolveCSSHref() (string, error) {
+ matches, err := fs.Glob(staticFS, "static/main.min.*.css")
+ if err != nil {
+ return "", fmt.Errorf("web: glob stylesheet: %w", err)
+ }
+ if len(matches) == 0 {
+ return "", fmt.Errorf("web: no main.min.*.css in embedded static assets")
+ }
+ return "/static/" + path.Base(matches[0]), nil
+}
A web/templates.go => web/templates.go +94 -0
@@ 0,0 1,94 @@
+package web
+
+import (
+ "bytes"
+ "embed"
+ "html/template"
+ "net/http"
+ "time"
+
+ "github.com/sirupsen/logrus"
+)
+
+// tmplFS holds the page templates. Each page is parsed together with the shared
+// layout into its own template set so that per-page "content"/"scripts" defines
+// do not collide across pages.
+//
+//go:embed templates/*.html
+var tmplFS embed.FS
+
+// staticFS holds the built front-end assets (bundle.js, the hashed stylesheet,
+// logo.svg). It is served read-only under /static/.
+//
+//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
+ },
+ // date formats a commit timestamp for display.
+ "date": func(t time.Time) string {
+ return t.UTC().Format("2006-01-02 15:04 MST")
+ },
+}
+
+// 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).
+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 = template.Must(t.ParseFS(tmplFS, "templates/layout.html", "templates/"+name+".html"))
+ m[name] = t
+ }
+ return m
+}()
+
+// render executes a page into a buffer first, so a template error yields a clean
+// 500 rather than a half-written response. On success it writes the status and
+// the buffered HTML.
+func (s *Server) render(w http.ResponseWriter, status int, page string, vd viewData) {
+ t, ok := pages[page]
+ if !ok {
+ logrus.WithField("page", page).Error("web: unknown template page")
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ }
+ var buf bytes.Buffer
+ if err := t.ExecuteTemplate(&buf, "layout.html", vd); err != nil {
+ logrus.WithError(err).WithField("page", page).Error("web: template execution failed")
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ _, _ = buf.WriteTo(w)
+}
+
+// errorData is the payload of the error page.
+type errorData struct {
+ Status int
+ StatusText string
+ Message string
+}
+
+// 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.Data = errorData{
+ Status: status,
+ StatusText: http.StatusText(status),
+ Message: message,
+ }
+ s.render(w, status, "error", vd)
+}
A web/templates/commit.html => web/templates/commit.html +81 -0
@@ 0,0 1,81 @@
+{{define "content"}}
+{{$owner := .Data.Owner}}
+{{$repo := .Data.RepoName}}
+{{$c := .Data.Commit}}
+<div class="row">
+ <div class="col-md-12">
+ <h2>
+ <a href="/~{{$owner}}/{{$repo}}">~{{$owner}}/{{$repo}}</a>:
+ <code>{{$c.ShortSHA}}</code>
+ </h2>
+ <p><strong>{{$c.Subject}}</strong></p>
+ {{if $c.Body}}<pre class="commit-body">{{$c.Body}}</pre>{{end}}
+ <p class="text-muted">
+ {{$c.AuthorName}} <{{$c.AuthorEmail}}> — {{date $c.Date}}
+ </p>
+ <p class="text-muted">
+ Commit <code>{{$c.SHA}}</code> —
+ <a href="{{.Data.PatchURL}}">view raw patch</a>
+ </p>
+ {{if $c.ParentSHAs}}
+ <p>
+ Parent(s):
+ {{range $c.ParentSHAs}}
+ <a href="/~{{$owner}}/{{$repo}}/commit/{{.}}"><code>{{shortsha .}}</code></a>
+ {{end}}
+ </p>
+ {{end}}
+ </div>
+</div>
+
+{{if .Data.IsMerge}}
+<div class="alert alert-info">
+ This is a merge commit; the diff shown is against its first parent.
+</div>
+{{end}}
+
+{{if .Data.Truncated}}
+<div class="alert alert-warning compare-truncated">
+ This diff is too large to render in full.
+ <a href="{{.Data.PatchURL}}">Download the raw patch</a> to see everything.
+</div>
+{{end}}
+
+<div class="row">
+ <div class="col-md-12">
+ <h3>{{len .Data.Files}} changed file(s)</h3>
+ <table class="table">
+ <thead>
+ <tr><th>File</th><th>Status</th><th>+</th><th>−</th></tr>
+ </thead>
+ <tbody>
+ {{range .Data.Files}}
+ <tr>
+ <td>
+ {{if and .OldPath (ne .OldPath .Path)}}<code>{{.OldPath}}</code> → {{end}}<code>{{.Path}}</code>
+ {{if .Binary}}<span class="badge badge-secondary">BIN</span>{{end}}
+ </td>
+ <td>{{.Status}}</td>
+ <td class="text-success">{{if .Additions}}+{{.Additions}}{{end}}</td>
+ <td class="text-danger">{{if .Deletions}}-{{.Deletions}}{{end}}</td>
+ </tr>
+ {{end}}
+ </tbody>
+ </table>
+ </div>
+</div>
+
+<div class="diff-layout-toggle">
+ <button type="button" data-diff-layout="split">Split</button>
+ <button type="button" data-diff-layout="stacked">Unified</button>
+</div>
+<div id="compare-app">
+ <div id="tree-root"></div>
+ <div id="diff-root"></div>
+</div>
+<script id="compare-data" type="application/json">{{.Data.JSON}}</script>
+{{end}}
+
+{{define "scripts"}}
+<script type="module" src="/static/bundle.js"></script>
+{{end}}
A web/templates/compare.html => web/templates/compare.html +83 -0
@@ 0,0 1,83 @@
+{{define "content"}}
+{{$owner := .Data.Owner}}
+{{$repo := .Data.RepoName}}
+{{$sep := "..."}}{{if not .Data.Spec.ThreeDot}}{{$sep = ".."}}{{end}}
+<div class="row">
+ <div class="col-md-12">
+ <h2>
+ <a href="/~{{$owner}}/{{$repo}}">~{{$owner}}/{{$repo}}</a>:
+ <code>{{.Data.Spec.Base}}</code>{{$sep}}<code>{{.Data.Spec.Head}}</code>
+ </h2>
+ <p class="text-muted">
+ {{if .Data.Spec.ThreeDot}}
+ Three-dot comparison (symmetric difference from the merge base).
+ {{if .Data.MergeBase}}Merge base: <code>{{shortsha .Data.MergeBase}}</code>.{{end}}
+ {{else}}
+ Two-dot comparison (direct range <code>{{.Data.Spec.Base}}..{{.Data.Spec.Head}}</code>).
+ {{end}}
+ </p>
+ </div>
+</div>
+
+{{if .Data.Truncated}}
+<div class="alert alert-warning compare-truncated">
+ This diff is too large to render in full.
+ <a href="{{.Data.PatchURL}}">Download the raw patch</a> to see everything.
+</div>
+{{end}}
+
+<div class="row">
+ <div class="col-md-12">
+ <details open>
+ <summary>{{len .Data.Commits}} commit(s)</summary>
+ <ul class="list-unstyled">
+ {{range .Data.Commits}}
+ <li>
+ <a href="/~{{$owner}}/{{$repo}}/commit/{{.SHA}}"><code>{{.ShortSHA}}</code></a>
+ {{.Subject}}
+ <span class="text-muted">— {{.AuthorName}}, {{date .Date}}</span>
+ </li>
+ {{end}}
+ </ul>
+ </details>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col-md-12">
+ <h3>{{len .Data.Files}} changed file(s)</h3>
+ <table class="table">
+ <thead>
+ <tr><th>File</th><th>Status</th><th>+</th><th>−</th></tr>
+ </thead>
+ <tbody>
+ {{range .Data.Files}}
+ <tr>
+ <td>
+ {{if and .OldPath (ne .OldPath .Path)}}<code>{{.OldPath}}</code> → {{end}}<code>{{.Path}}</code>
+ {{if .Binary}}<span class="badge badge-secondary">BIN</span>{{end}}
+ </td>
+ <td>{{.Status}}</td>
+ <td class="text-success">{{if .Additions}}+{{.Additions}}{{end}}</td>
+ <td class="text-danger">{{if .Deletions}}-{{.Deletions}}{{end}}</td>
+ </tr>
+ {{end}}
+ </tbody>
+ </table>
+ </div>
+</div>
+
+<div class="diff-layout-toggle">
+ <button type="button" data-diff-layout="split">Split</button>
+ <button type="button" data-diff-layout="stacked">Unified</button>
+</div>
+<div id="compare-app">
+ <div id="tree-root"></div>
+ <div id="diff-root"></div>
+</div>
+<script id="compare-data" type="application/json">{{.Data.JSON}}</script>
+{{end}}
+
+{{define "scripts"}}
+<script type="module" src="/static/bundle.js"></script>
+{{end}}
A web/templates/error.html => web/templates/error.html +9 -0
@@ 0,0 1,9 @@
+{{define "content"}}
+<div class="row">
+ <div class="col-md-12">
+ <h2>{{.Data.Status}} — {{.Data.StatusText}}</h2>
+ {{if .Data.Message}}<p class="text-muted">{{.Data.Message}}</p>{{end}}
+ <p><a href="/">Return to the landing page</a>.</p>
+ </div>
+</div>
+{{end}}
A web/templates/index.html => web/templates/index.html +54 -0
@@ 0,0 1,54 @@
+{{define "content"}}
+<div class="row">
+ <div class="col-md-12">
+ <h2>{{.SiteName}} <span class="text-danger">compare</span></h2>
+ <p>
+ Compare two references — branches, tags or commits — of any git repository
+ on this instance, and review the diff file by file.
+ </p>
+ </div>
+</div>
+
+{{if .Data.LoggedIn}}
+<div class="row">
+ <div class="col-md-12">
+ <h3>Your repositories</h3>
+ {{if .Data.Repos}}
+ <ul class="list-unstyled">
+ {{range .Data.Repos}}
+ <li>
+ <a href="/~{{$.Username}}/{{.Name}}"><strong>~{{$.Username}}/{{.Name}}</strong></a>
+ <span class="badge badge-secondary">{{.Visibility}}</span>
+ {{if .Description}}<span class="text-muted"> — {{.Description}}</span>{{end}}
+ </li>
+ {{end}}
+ </ul>
+ {{else}}
+ <p class="text-muted">You have no repositories yet.</p>
+ {{end}}
+ </div>
+</div>
+{{else}}
+<div class="row">
+ <div class="col-md-12">
+ <p>
+ <a href="{{.LoginURL}}" rel="nofollow">Log in</a> to list your own
+ repositories, or jump directly to any repository below.
+ </p>
+ <form method="GET" action="/jump" class="form-inline">
+ <div class="form-group">
+ <label class="sr-only" for="owner">Owner</label>
+ <input class="form-control" type="text" id="owner" name="owner"
+ placeholder="~owner" autocomplete="off">
+ </div>
+ <div class="form-group">
+ <label class="sr-only" for="repo">Repository</label>
+ <input class="form-control" type="text" id="repo" name="repo"
+ placeholder="repo" autocomplete="off">
+ </div>
+ <button class="btn btn-primary" type="submit">Go</button>
+ </form>
+ </div>
+</div>
+{{end}}
+{{end}}
A web/templates/layout.html => web/templates/layout.html +61 -0
@@ 0,0 1,61 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>{{.Title}}</title>
+ <link rel="icon" type="image/svg+xml" href="/static/logo.svg">
+ <link rel="stylesheet" href="{{.CSSHref}}">
+ {{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}}
+ <nav class="container navbar navbar-light navbar-expand-sm">
+ {{if .HubOrigin}}
+ <span class="navbar-brand">
+ <a href="{{.HubOrigin}}">{{.SiteName}}</a>
+ </span>
+ {{else}}
+ <span class="navbar-brand">
+ <a class="navbar-brand" href="/">
+ {{.SiteName}}
+ <span class="text-danger">compare</span>
+ </a>
+ </span>
+ {{end}}
+ <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>
+ </nav>
+ <div class="container">
+ {{template "content" .}}
+ </div>
+ {{block "scripts" .}}{{end}}
+ </body>
+</html>
A web/templates/repo.html => web/templates/repo.html +72 -0
@@ 0,0 1,72 @@
+{{define "content"}}
+{{$owner := .Data.Owner}}
+{{$repo := .Data.Info.Name}}
+<div class="row">
+ <div class="col-md-12">
+ <h2>~{{$owner}}/{{$repo}}
+ <span class="badge badge-secondary">{{.Data.Info.Visibility}}</span>
+ </h2>
+ {{if .Data.Info.Description}}<p class="text-muted">{{.Data.Info.Description}}</p>{{end}}
+ {{if .Data.DefaultBranch}}
+ <p>Default branch: <code>{{.Data.DefaultBranch}}</code></p>
+ {{end}}
+ </div>
+</div>
+
+<div class="row">
+ <div class="col-md-12">
+ <h3>Compare</h3>
+ <form method="GET" action="/~{{$owner}}/{{$repo}}/compare/" class="form-inline">
+ <div class="form-group">
+ <label class="sr-only" for="base">Base</label>
+ <select class="form-control" id="base" name="base">
+ <optgroup label="Branches">
+ {{range .Data.Branches}}<option value="{{.Name}}">{{.Name}}</option>{{end}}
+ </optgroup>
+ <optgroup label="Tags">
+ {{range .Data.Tags}}<option value="{{.Name}}">{{.Name}}</option>{{end}}
+ </optgroup>
+ </select>
+ </div>
+ <span class="mx-2">...</span>
+ <div class="form-group">
+ <label class="sr-only" for="head">Head</label>
+ <select class="form-control" id="head" name="head">
+ <optgroup label="Branches">
+ {{range .Data.Branches}}<option value="{{.Name}}">{{.Name}}</option>{{end}}
+ </optgroup>
+ <optgroup label="Tags">
+ {{range .Data.Tags}}<option value="{{.Name}}">{{.Name}}</option>{{end}}
+ </optgroup>
+ </select>
+ </div>
+ <div class="form-group mx-2">
+ <select class="form-control" name="mode">
+ <option value="three">three-dot (merge base)</option>
+ <option value="two">two-dot (direct range)</option>
+ </select>
+ </div>
+ <button class="btn btn-primary" type="submit">Compare</button>
+ </form>
+ </div>
+</div>
+
+<div class="row">
+ <div class="col-md-12">
+ <h3>Recent commits</h3>
+ {{if .Data.Commits}}
+ <ul class="list-unstyled">
+ {{range .Data.Commits}}
+ <li>
+ <a href="/~{{$owner}}/{{$repo}}/commit/{{.SHA}}"><code>{{.ShortSHA}}</code></a>
+ {{.Subject}}
+ <span class="text-muted">— {{.AuthorName}}, {{date .Date}}</span>
+ </li>
+ {{end}}
+ </ul>
+ {{else}}
+ <p class="text-muted">No commits.</p>
+ {{end}}
+ </div>
+</div>
+{{end}}
A web/web_test.go => web/web_test.go +485 -0
@@ 0,0 1,485 @@
+package web
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "git.sr.ht/~sircmpwn/core-go/config"
+ "git.sr.ht/~sircmpwn/core-go/crypto"
+ "github.com/fernet/fernet-go"
+ "github.com/go-chi/chi/v5"
+ "github.com/vaughan0/go-ini"
+
+ "go.bigb.es/sourcehut-compare/authz"
+ "go.bigb.es/sourcehut-compare/core"
+ "go.bigb.es/sourcehut-compare/gitx"
+)
+
+// testConf carries the crypto keys established in TestMain so tests can seal
+// unified-login cookies.
+var testConf ini.File
+
+func TestMain(m *testing.M) {
+ var fk fernet.Key
+ if err := fk.Generate(); err != nil {
+ panic("generate fernet key: " + err.Error())
+ }
+ seed := make([]byte, 32)
+ if _, err := rand.Read(seed); err != nil {
+ panic("generate webhook seed: " + err.Error())
+ }
+ testConf = ini.File{
+ "sr.ht": ini.Section{"network-key": fk.Encode()},
+ "webhooks": ini.Section{"private-key": base64.StdEncoding.EncodeToString(seed)},
+ }
+ crypto.InitCrypto(testConf)
+ os.Exit(m.Run())
+}
+
+// ---- fixtures -------------------------------------------------------------
+
+// stubAuthorizer is a fixed-map Authorizer with optional error injection.
+type stubAuthorizer struct {
+ repos map[string]authz.RepoInfo // key "owner/name"
+ my []authz.RepoInfo
+ err error // when set, every call fails with this (transport-style) error
+}
+
+func (s *stubAuthorizer) Repo(_ context.Context, _, owner, name string) (*authz.RepoInfo, error) {
+ if s.err != nil {
+ return nil, s.err
+ }
+ owner = strings.TrimPrefix(owner, "~")
+ if info, ok := s.repos[owner+"/"+name]; ok {
+ return &info, nil
+ }
+ return nil, core.ErrNotFound
+}
+
+func (s *stubAuthorizer) MyRepos(_ context.Context, _ string) ([]authz.RepoInfo, error) {
+ if s.err != nil {
+ return nil, s.err
+ }
+ return s.my, nil
+}
+
+// gitFixture drives the git CLI to build a bare repo at <root>/~alice/demo:
+//
+// c1 (main): add a.txt
+// c2 (main): add b.txt, edit a.txt <- main HEAD
+// feature off c1: add feature.txt <- branch "feature"
+//
+// It returns the repos root and the full SHA of main's HEAD.
+func gitFixture(t *testing.T) (root, mainSHA string) {
+ t.Helper()
+ if _, err := exec.LookPath("git"); err != nil {
+ t.Skipf("git not available: %v", err)
+ }
+ root = t.TempDir()
+ work := t.TempDir()
+
+ git := func(date string, args ...string) string {
+ cmd := exec.Command("git", args...)
+ cmd.Dir = work
+ cmd.Env = append(os.Environ(),
+ "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
+ "GIT_TERMINAL_PROMPT=0", "LC_ALL=C",
+ "GIT_AUTHOR_NAME=Alice", "GIT_AUTHOR_EMAIL=alice@example.com",
+ "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)
+ }
+ return string(out)
+ }
+ write := func(name, data string) {
+ if err := os.WriteFile(filepath.Join(work, name), []byte(data), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ d1, d2, d3 := "2024-01-01T00:00:00Z", "2024-01-02T00:00:00Z", "2024-01-03T00:00:00Z"
+ git(d1, "init", "-b", "main")
+ write("a.txt", "hello\nworld\n")
+ git(d1, "add", "a.txt")
+ git(d1, "commit", "-m", "add a.txt")
+ git(d2, "branch", "feature")
+ write("a.txt", "hello\nworld\nmore\n")
+ write("b.txt", "bee\n")
+ git(d2, "add", "a.txt", "b.txt")
+ git(d2, "commit", "-m", "add b, edit a")
+ git(d3, "checkout", "feature")
+ write("feature.txt", "feature\n")
+ 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)
+ }
+ bare := filepath.Join(root, "~alice", "demo")
+ git(d3, "clone", "--bare", work, bare)
+
+ mainSHA = strings.TrimSpace(runGit(t, bare, "rev-parse", "main"))
+ return root, mainSHA
+}
+
+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)
+ }
+ return string(out)
+}
+
+// testServer wires a Server (fixture repo + given authorizer) behind the same
+// middleware the cmd layer installs, and returns the handler.
+func testServer(t *testing.T, root string, az authz.Authorizer) http.Handler {
+ t.Helper()
+ conf := ini.File{
+ "sr.ht": ini.Section{
+ "network-key": testConf.Section("sr.ht")["network-key"],
+ "site-name": "sourcehut",
+ "environment": "development",
+ },
+ "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"},
+ }
+ srv, err := New(conf, az)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ r := chi.NewRouter()
+ r.Use(config.Middleware(conf, "compare.sr.ht"))
+ r.Use(authz.Middleware())
+ srv.Register(r)
+ return r
+}
+
+// login seals a unified-login cookie for the given user onto a request.
+func login(req *http.Request, user string) {
+ payload, _ := json.Marshal(map[string]string{"name": user})
+ req.AddCookie(&http.Cookie{Name: authz.CookieName, Value: string(crypto.Encrypt(payload))})
+}
+
+func demoAuthorizer() *stubAuthorizer {
+ return &stubAuthorizer{
+ repos: map[string]authz.RepoInfo{
+ "alice/demo": {ID: 1, Name: "demo", Description: "the demo repo", Visibility: "PUBLIC"},
+ },
+ my: []authz.RepoInfo{
+ {ID: 1, Name: "demo", Description: "the demo repo", Visibility: "PUBLIC"},
+ {ID: 2, Name: "secret", Description: "", Visibility: "PRIVATE"},
+ },
+ }
+}
+
+func get(t *testing.T, h http.Handler, target string, user string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodGet, target, nil)
+ if user != "" {
+ login(req, user)
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ return rec
+}
+
+// ---- tests ----------------------------------------------------------------
+
+func TestComparePage(t *testing.T) {
+ 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())
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, `id="compare-data"`) {
+ t.Fatal("missing compare-data script")
+ }
+ if !strings.Contains(body, `src="/static/bundle.js"`) {
+ t.Fatal("missing bundle.js 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).
+ 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)
+ }
+ }
+ }
+ if !found {
+ t.Fatalf("feature.txt not in files: %+v", cd.Files)
+ }
+}
+
+func TestTwoDotVsThreeDot(t *testing.T) {
+ 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")
+ }
+ 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")
+ }
+}
+
+func TestComparePatchRoute(t *testing.T) {
+ 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())
+ }
+}
+
+func TestCommitPage(t *testing.T) {
+ 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())
+ }
+ cd := extractCompareData(t, rec.Body.String())
+ if cd.Mode != "commit" {
+ t.Fatalf("mode = %q, want commit", cd.Mode)
+ }
+ // c2 modifies a.txt and adds b.txt.
+ if len(cd.Files) == 0 {
+ t.Fatal("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")
+ }
+}
+
+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)
+ }
+}
+
+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)
+ rec := get(t, h, "/~alice/demo", "")
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404", rec.Code)
+ }
+}
+
+func TestAuthorizerTransportErrorIs500(t *testing.T) {
+ root, _ := gitFixture(t)
+ az := &stubAuthorizer{err: errors.New("graphql unreachable")}
+ h := testServer(t, root, az)
+ 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)
+ }
+}
+
+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)
+ }
+}
+
+func TestIndexAnonymous(t *testing.T) {
+ root, _ := gitFixture(t)
+ h := testServer(t, root, demoAuthorizer())
+ rec := get(t, h, "/", "")
+ 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")
+ }
+}
+
+func TestIndexLoggedIn(t *testing.T) {
+ root, _ := gitFixture(t)
+ h := testServer(t, root, demoAuthorizer())
+ rec := get(t, h, "/", "bigbes")
+ 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 badge")
+ }
+}
+
+func TestNavExclusionsAndActive(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, `<ul class="navbar-nav">`):strings.Index(body, "</ul>")]
+ 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")
+ }
+}
+
+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())
+ }
+}
+
+func TestStaticBundleAndCSS(t *testing.T) {
+ root, _ := gitFixture(t)
+ srvHandler := testServer(t, root, demoAuthorizer())
+
+ rec := get(t, srvHandler, "/static/bundle.js", "")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("bundle.js status = %d", rec.Code)
+ }
+ if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "javascript") {
+ t.Fatalf("bundle.js content-type = %q", ct)
+ }
+
+ 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)
+ }
+}
+
+// TestCompareJSONNoScriptBreakout verifies a file path containing "</script>"
+// cannot break out of the embedded <script> element.
+func TestCompareJSONNoScriptBreakout(t *testing.T) {
+ patch := &gitx.Patch{Text: "diff --git a/x b/x\n"}
+ files := []gitx.FileChange{{Path: "evil</script><script>alert(1)</script>.txt", Status: "A", Additions: 1}}
+ html, err := buildCompareJSON("compare", patch, files, jsonSpec{Base: "a", Head: "b"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ s := string(html)
+ // The '<' of "</script>" must be escaped, so no literal "</script" tag can
+ // appear to close the embedding element.
+ if strings.Contains(s, "</script") {
+ t.Fatalf("raw </script present in JSON (breakout possible): %s", s)
+ }
+ // ...and it must appear in its escaped </script form instead, proving
+ // the marshaler HTML-escaped the '<'.
+ if !strings.Contains(s, "\\u003c/script") {
+ t.Fatalf("expected escaped \\u003c/script, got: %s", s)
+ }
+}
+
+func cssName(t *testing.T) string {
+ t.Helper()
+ href, err := resolveCSSHref()
+ if err != nil {
+ t.Fatal(err)
+ }
+ return strings.TrimPrefix(href, "/static/")
+}
+
+// extractCompareData pulls and decodes the embedded JSON payload from a page.
+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)
+ }
+ rest := body[i+len(open):]
+ j := strings.Index(rest, "</script>")
+ if j < 0 {
+ t.Fatal("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])
+ }
+ return cd
+}