| internal/server/git.go |
| | package server |
| | |
| | import ( |
| | "bytes" |
| | "errors" |
| | "html/template" |
| | "net/http" |
| | "net/url" |
| | "path" |
| | "strconv" |
| | "strings" |
| | |
| | "github.com/go-git/go-git/v5" |
| | "github.com/go-git/go-git/v5/plumbing" |
| | "github.com/go-git/go-git/v5/plumbing/filemode" |
| | "github.com/go-git/go-git/v5/plumbing/object" |
| | |
| | "gitweb/internal/gitrepo" |
| | "gitweb/internal/render" |
| | ) |
| | |
| | var gitTmpl = template.Must(template.ParseFS(tmplFS, "templates/git.html")) |
| | |
| | const logLimit = 100 |
| | |
| | // serveGit handles a request that Locate resolved to a bare repo. repoPath is |
| | // the upspin repo root (e.g. "u@d.com/proj.git"); rest is the remaining URL |
| | // after it (e.g. "blob/main/src/x.go"). base is "/"+repoPath. |
| | func (s *Server) serveGit(w http.ResponseWriter, r *http.Request, repoPath, rest string) { |
| | repo, err := s.repos.Get(repoPath) |
| | if err != nil { |
| | s.storeErr(w, err) |
| | return |
| | } |
| | base := "/" + repoPath |
| | rest = strings.Trim(rest, "/") |
| | verb, rem, _ := strings.Cut(rest, "/") |
| | |
| | switch verb { |
| | case "": |
| | s.gitSummary(w, repo, repoPath, base) |
| | case "refs": |
| | s.gitRefs(w, repo, repoPath, base) |
| | case "tree": |
| | s.gitTree(w, repo, repoPath, base, rem) |
| | case "blob": |
| | s.gitBlob(w, repo, repoPath, base, rem, false, r.URL.Query()) |
| | case "raw": |
| | s.gitBlob(w, repo, repoPath, base, rem, true, nil) |
| | case "log": |
| | s.gitLog(w, repo, repoPath, base, rem) |
| | case "commit": |
| | s.gitCommit(w, repo, repoPath, base, rem) |
| | default: |
| | s.notFound(w) |
| | } |
| | } |
| | |
| | // --- view models --- |
| | |
| | type gitNav struct { |
| | RepoName string |
| | Base string |
| | Ref string |
| | LogURL string |
| | } |
| | |
| | func newNav(repoName, base, ref string) gitNav { |
| | n := gitNav{RepoName: repoName, Base: base, Ref: ref} |
| | if ref != "" { |
| | n.LogURL = base + "/log/" + escapePath(ref) |
| | } |
| | return n |
| | } |
| | |
| | type treeEntry struct { |
| | Name string |
| | IsDir bool |
| | URL string |
| | Size string |
| | } |
| | |
| | type crumb struct { |
| | Name string |
| | URL string // empty for the last crumb |
| | } |
| | |
| | type refLink struct { |
| | Name string |
| | Short string |
| | URL string |
| | LogURL string |
| | } |
| | |
| | // --- summary --- |
| | |
| | func (s *Server) gitSummary(w http.ResponseWriter, repo *git.Repository, repoName, base string) { |
| | hash, ref, _, err := gitrepo.ResolveRef(repo, "") |
| | if err != nil { |
| | s.notFound(w) |
| | return |
| | } |
| | nav := newNav(repoName, base, ref) |
| | |
| | tree, err := treeAt(repo, hash, "") |
| | if err != nil { |
| | s.serverErr(w, err) |
| | return |
| | } |
| | entries := treeEntries(repo, tree, base, ref, "") |
| | |
| | branches, tags, err := gitrepo.Refs(repo) |
| | if err != nil { |
| | s.serverErr(w, err) |
| | return |
| | } |
| | |
| | var readme template.HTML |
| | if name := findReadme(tree); name != "" { |
| | if f, err := tree.File(name); err == nil { |
| | if data, err := f.Contents(); err == nil { |
| | if h, err := s.renderer.ToHTML([]byte(data)); err == nil { |
| | readme = rebaseRepoHTML(template.HTML(h), base, ref, path.Dir(name)) |
| | } |
| | } |
| | } |
| | } |
| | |
| | s.renderGit(w, repoName, "summary", struct { |
| | Nav gitNav |
| | Branches []refLink |
| | Tags []refLink |
| | Entries []treeEntry |
| | Readme template.HTML |
| | }{ |
| | Nav: nav, |
| | Branches: refLinks(branches, base, "tree"), |
| | Tags: refLinks(tags, base, "tree"), |
| | Entries: entries, |
| | Readme: readme, |
| | }) |
| | } |
| | |
| | // --- tree --- |
| | |
| | func (s *Server) gitTree(w http.ResponseWriter, repo *git.Repository, repoName, base, rem string) { |
| | hash, ref, sub, err := gitrepo.ResolveRef(repo, rem) |
| | if err != nil { |
| | s.notFound(w) |
| | return |
| | } |
| | tree, err := treeAt(repo, hash, sub) |
| | if err != nil { |
| | s.notFound(w) |
| | return |
| | } |
| | nav := newNav(repoName, base, ref) |
| | title := repoName |
| | if sub != "" { |
| | title = repoName + "/" + sub |
| | } |
| | s.renderGit(w, repoName, "tree", struct { |
| | Nav gitNav |
| | Title string |
| | Crumbs []crumb |
| | Entries []treeEntry |
| | }{ |
| | Nav: nav, |
| | Title: title, |
| | Crumbs: buildCrumbs(base, ref, sub, true), |
| | Entries: treeEntries(repo, tree, base, ref, sub), |
| | }) |
| | } |
| | |
| | // --- blob / raw --- |
| | |
| | func (s *Server) gitBlob(w http.ResponseWriter, repo *git.Repository, repoName, base, rem string, raw bool, q url.Values) { |
| | hash, ref, sub, err := gitrepo.ResolveRef(repo, rem) |
| | if err != nil || sub == "" { |
| | s.notFound(w) |
| | return |
| | } |
| + | if isAccessFile(path.Base(sub)) { |
| + | s.notFound(w) // never render an upspin ACL, blob or raw |
| + | return |
| + | } |
| | tree, err := treeAt(repo, hash, "") |
| | if err != nil { |
| | s.notFound(w) |
| | return |
| | } |
| | file, err := tree.File(sub) |
| | if err != nil { |
| | s.notFound(w) |
| | return |
| | } |
| | data, err := file.Contents() |
| | if err != nil { |
| | s.serverErr(w, err) |
| | return |
| | } |
| | content := []byte(data) |
| | |
| | if raw { |
| | ctype := http.DetectContentType(content) |
| | w.Header().Set("Content-Type", ctype) |
| | w.Write(content) |
| | return |
| | } |
| | |
| | rawURL := base + "/raw/" + escapePath(ref) + "/" + escapePath(sub) |
| | binary := render.IsBinary(content) |
| | name := path.Base(sub) |
| | force := q.Get("force") == "1" |
| | selected := s.renderers.Select(name, content, q.Get("render"), force) |
| | |
| | var body template.HTML |
| | var choices []rendererChoice |
| | var forceURL string |
| | if !binary { |
| | body, err = selected.Render(name, content) |
| | if err != nil { |
| | s.serverErr(w, err) |
| | return |
| | } |
| | if selected.ID == "markdown" { |
| | // Rewrite relative links/images so they resolve to the file's |
| | // directory in the repo rather than the current URL. |
| | body = rebaseRepoHTML(body, base, ref, path.Dir(sub)) |
| | } |
| | blobURL := base + "/blob/" + escapePath(ref) + "/" + escapePath(sub) |
| | choices, forceURL = renderControls(s.renderers, blobURL, name, content, selected.ID, force) |
| | } |
| | |
| | s.renderGit(w, repoName, "blob", blobView{ |
| | Nav: newNav(repoName, base, ref), |
| | Crumbs: buildCrumbs(base, ref, sub, true), |
| | Path: sub, |
| | Size: humanSize(file.Size), |
| | RawURL: rawURL, |
| | Binary: binary, |
| | Body: body, |
| | Renderers: choices, |
| | Force: force, |
| | ForceURL: forceURL, |
| | }) |
| | } |
| | |
| | // blobView is the file-view model, including the renderer control bar. |
| | type blobView struct { |
| | Nav gitNav |
| | Crumbs []crumb |
| | Path string |
| | Size string |
| | RawURL string |
| | Binary bool |
| | Body template.HTML |
| | Renderers []rendererChoice |
| | Force bool |
| | ForceURL string |
| | } |
| | |
| | // rendererChoice is one selectable renderer link in the control bar. |
| | type rendererChoice struct { |
| | Label string |
| | URL string |
| | Active bool |
| | } |
| | |
| | // renderControls builds the renderer switch links and the force-toggle link for |
| | // a blob. With force on, every registered renderer is offered; otherwise only |
| | // the eligible ones. The default renderer's link carries no ?render= so its URL |
| | // stays clean. |
| | func renderControls(reg *render.Registry, blobURL, name string, content []byte, selectedID string, force bool) ([]rendererChoice, string) { |
| | mods := reg.Eligible(name, content) |
| | if force { |
| | mods = reg.All() |
| | } |
| | defID := reg.Default().ID |
| | |
| | choices := make([]rendererChoice, 0, len(mods)) |
| | for _, m := range mods { |
| | choices = append(choices, rendererChoice{ |
| | Label: m.Label, |
| | URL: blobURL + blobQuery(m.ID, defID, force), |
| | Active: m.ID == selectedID, |
| | }) |
| | } |
| | |
| | var forceURL string |
| | if force { |
| | // Turning force off: keep the selection only if it stays eligible. |
| | keep := selectedID |
| | if _, ok := find(reg.Eligible(name, content), keep); !ok { |
| | keep = defID |
| | } |
| | forceURL = blobURL + blobQuery(keep, defID, false) |
| | } else { |
| | forceURL = blobURL + blobQuery(selectedID, defID, true) |
| | } |
| | return choices, forceURL |
| | } |
| | |
| | // blobQuery builds the query string for a blob link with the given renderer and |
| | // force state. The default renderer is encoded as no ?render= param. |
| | func blobQuery(id, defID string, force bool) string { |
| | v := url.Values{} |
| | if id != "" && id != defID { |
| | v.Set("render", id) |
| | } |
| | if force { |
| | v.Set("force", "1") |
| | } |
| | if len(v) == 0 { |
| | return "" |
| | } |
| | return "?" + v.Encode() |
| | } |
| | |
| | func find(mods []render.Module, id string) (render.Module, bool) { |
| | for _, m := range mods { |
| | if m.ID == id { |
| | return m, true |
| | } |
| | } |
| | return render.Module{}, false |
| | } |
| | |
| | // --- log --- |
| | |
| | func (s *Server) gitLog(w http.ResponseWriter, repo *git.Repository, repoName, base, rem string) { |
| | hash, ref, _, err := gitrepo.ResolveRef(repo, rem) |
| | if err != nil { |
| | s.notFound(w) |
| | return |
| | } |
| | iter, err := repo.Log(&git.LogOptions{From: hash}) |
| | if err != nil { |
| | s.serverErr(w, err) |
| | return |
| | } |
| | type commitRow struct { |
| | Subject, Author, When, Short, URL string |
| | } |
| | var commits []commitRow |
| | _ = iter.ForEach(func(c *object.Commit) error { |
| | if len(commits) >= logLimit { |
| | return errStopIter |
| | } |
| | commits = append(commits, commitRow{ |
| | Subject: subjectOf(c.Message), |
| | Author: c.Author.Name, |
| | When: c.Author.When.Format("2006-01-02"), |
| | Short: c.Hash.String()[:8], |
| | URL: base + "/commit/" + c.Hash.String(), |
| | }) |
| | return nil |
| | }) |
| | s.renderGit(w, repoName, "log", struct { |
| | Nav gitNav |
| | Commits interface{} |
| | }{ |
| | Nav: newNav(repoName, base, ref), |
| | Commits: commits, |
| | }) |
| | } |
| | |
| | // --- commit --- |
| | |
| | func (s *Server) gitCommit(w http.ResponseWriter, repo *git.Repository, repoName, base, rem string) { |
| | rem = strings.Trim(rem, "/") |
| | h, err := repo.ResolveRevision(plumbing.Revision(rem)) |
| | if err != nil { |
| | s.notFound(w) |
| | return |
| | } |
| | c, err := repo.CommitObject(*h) |
| | if err != nil { |
| | s.notFound(w) |
| | return |
| | } |
| | |
| | var fromTree *object.Tree |
| | var parents []struct{ Short, URL string } |
| | if c.NumParents() > 0 { |
| | p, err := c.Parent(0) |
| | if err == nil { |
| | fromTree, _ = p.Tree() |
| | parents = append(parents, struct{ Short, URL string }{ |
| | Short: p.Hash.String()[:8], |
| | URL: base + "/commit/" + p.Hash.String(), |
| | }) |
| | } |
| | } |
| | toTree, err := c.Tree() |
| | if err != nil { |
| | s.serverErr(w, err) |
| | return |
| | } |
| | changes, err := object.DiffTree(fromTree, toTree) |
| | if err != nil { |
| | s.serverErr(w, err) |
| | return |
| | } |
| | patch, err := changes.Patch() |
| | if err != nil { |
| | s.serverErr(w, err) |
| | return |
| | } |
| | |
| | s.renderGit(w, repoName, "commit", struct { |
| | Nav gitNav |
| | Subject string |
| | Body string |
| | Hash string |
| | Author string |
| | When string |
| | Parents []struct{ Short, URL string } |
| | Diff template.HTML |
| | }{ |
| | Nav: newNav(repoName, base, ""), |
| | Subject: subjectOf(c.Message), |
| | Body: bodyOf(c.Message), |
| | Hash: c.Hash.String(), |
| | Author: c.Author.Name + " <" + c.Author.Email + ">", |
| | When: c.Author.When.Format("2006-01-02 15:04 -0700"), |
| | Parents: parents, |
| | Diff: render.Diff(patch), |
| | }) |
| | } |
| | |
| | // --- refs --- |
| | |
| | func (s *Server) gitRefs(w http.ResponseWriter, repo *git.Repository, repoName, base string) { |
| | branches, tags, err := gitrepo.Refs(repo) |
| | if err != nil { |
| | s.serverErr(w, err) |
| | return |
| | } |
| | s.renderGit(w, repoName, "refs", struct { |
| | Nav gitNav |
| | Branches []refLink |
| | Tags []refLink |
| | }{ |
| | Nav: newNav(repoName, base, ""), |
| | Branches: refLinks(branches, base, "tree"), |
| | Tags: refLinks(tags, base, "tree"), |
| | }) |
| | } |
| | |
| | // --- helpers --- |
| | |
| | // errStopIter is a sentinel to stop a commit ForEach early. |
| | var errStopIter = errors.New("stop") |
| | |
| | func (s *Server) renderGit(w http.ResponseWriter, repoName, name string, data interface{}) { |
| | var body bytes.Buffer |
| | if err := gitTmpl.ExecuteTemplate(&body, name, data); err != nil { |
| | s.serverErr(w, err) |
| | return |
| | } |
| | s.renderPage(w, repoName, template.HTML(body.String())) |
| | } |
| | |
| | // treeAt returns the tree at sub within the commit identified by hash. |
| | func treeAt(repo *git.Repository, hash plumbing.Hash, sub string) (*object.Tree, error) { |
| | c, err := repo.CommitObject(hash) |
| | if err != nil { |
| | return nil, err |
| | } |
| | tree, err := c.Tree() |
| | if err != nil { |
| | return nil, err |
| | } |
| | if sub == "" { |
| | return tree, nil |
| | } |
| | return tree.Tree(sub) |
| | } |
| | |
| | func treeEntries(repo *git.Repository, tree *object.Tree, base, ref, sub string) []treeEntry { |
| | out := make([]treeEntry, 0, len(tree.Entries)) |
| | for _, e := range tree.Entries { |
| + | if isAccessFile(e.Name) { |
| + | continue // don't list an upspin ACL committed into the tree |
| + | } |
| | child := e.Name |
| | if sub != "" { |
| | child = sub + "/" + e.Name |
| | } |
| | isDir := e.Mode == filemode.Dir |
| | te := treeEntry{Name: e.Name, IsDir: isDir} |
| | if isDir { |
| | te.URL = base + "/tree/" + escapePath(ref) + "/" + escapePath(child) |
| | } else { |
| | te.URL = base + "/blob/" + escapePath(ref) + "/" + escapePath(child) |
| | if b, err := repo.BlobObject(e.Hash); err == nil { |
| | te.Size = humanSize(b.Size) |
| | } |
| | } |
| | out = append(out, te) |
| | } |
| | // Directories first, then by name. |
| | sortTreeEntries(out) |
| | return out |
| | } |
| | |
| | func sortTreeEntries(es []treeEntry) { |
| | // Simple insertion sort keeps dirs first then name (lists are small). |
| | for i := 1; i < len(es); i++ { |
| | for j := i; j > 0 && less(es[j], es[j-1]); j-- { |
| | es[j], es[j-1] = es[j-1], es[j] |
| | } |
| | } |
| | } |
| | |
| | func less(a, b treeEntry) bool { |
| | if a.IsDir != b.IsDir { |
| | return a.IsDir |
| | } |
| | return a.Name < b.Name |
| | } |
| | |
| | func buildCrumbs(base, ref, sub string, dirLinks bool) []crumb { |
| | crumbs := []crumb{{Name: ref, URL: base + "/tree/" + escapePath(ref)}} |
| | if sub == "" { |
| | return crumbs |
| | } |
| | parts := strings.Split(sub, "/") |
| | acc := "" |
| | for i, p := range parts { |
| | if acc == "" { |
| | acc = p |
| | } else { |
| | acc = acc + "/" + p |
| | } |
| | c := crumb{Name: p} |
| | last := i == len(parts)-1 |
| | if !last || dirLinks { |
| | c.URL = base + "/tree/" + escapePath(ref) + "/" + escapePath(acc) |
| | } |
| | if last { |
| | c.URL = "" // current location, not a link |
| | } |
| | crumbs = append(crumbs, c) |
| | } |
| | return crumbs |
| | } |
| | |
| | func refLinks(refs []gitrepo.RefName, base, verb string) []refLink { |
| | out := make([]refLink, 0, len(refs)) |
| | for _, r := range refs { |
| | out = append(out, refLink{ |
| | Name: r.Name, |
| | Short: r.Hash.String()[:8], |
| | URL: base + "/" + verb + "/" + escapePath(r.Name), |
| | LogURL: base + "/log/" + escapePath(r.Name), |
| | }) |
| | } |
| | return out |
| | } |
| | |
| | func findReadme(tree *object.Tree) string { |
| | for _, name := range []string{"README.md", "readme.md", "README.markdown", "README"} { |
| | if _, err := tree.File(name); err == nil { |
| | return name |
| | } |
| | } |
| | return "" |
| | } |
| | |
| | // rebaseRepoHTML rewrites relative links/images in rendered repo markdown so |
| | // they resolve to the source file's location in the repo (blob view for links, |
| | // raw for images) at ref, given the markdown file's directory dir within the |
| | // tree ("" or "." for the repo root). |
| | func rebaseRepoHTML(h template.HTML, base, ref, dir string) template.HTML { |
| | prefix := escapePath(ref) + "/" |
| | if dir != "" && dir != "." { |
| | prefix += escapePath(dir) + "/" |
| | } |
| | return render.Rebase(h, base+"/blob/"+prefix, base+"/raw/"+prefix) |
| | } |
| | |
| | // escapePath URL-escapes each slash-separated segment, preserving slashes. |
| | func escapePath(p string) string { |
| | parts := strings.Split(p, "/") |
| | for i, s := range parts { |
| | parts[i] = url.PathEscape(s) |
| | } |
| | return strings.Join(parts, "/") |
| | } |
| | |
| | func subjectOf(msg string) string { |
| | if i := strings.IndexByte(msg, '\n'); i >= 0 { |
| | return strings.TrimSpace(msg[:i]) |
| | } |
| | return strings.TrimSpace(msg) |
| | } |
| | |
| | func bodyOf(msg string) string { |
| | if i := strings.IndexByte(msg, '\n'); i >= 0 { |
| | return strings.TrimSpace(msg[i+1:]) |
| | } |
| | return "" |
| | } |
| | |
| | func humanSize(n int64) string { |
| | const unit = 1024 |
| | if n < unit { |
| | return strconv.FormatInt(n, 10) + " B" |
| | } |
| | div, exp := int64(unit), 0 |
| | for m := n / unit; m >= unit; m /= unit { |
| | div *= unit |
| | exp++ |
| | } |
| | return strconv.FormatFloat(float64(n)/float64(div), 'f', 1, 64) + " " + string("KMGT"[exp]) + "iB" |
| | } |
| internal/server/git_test.go |
| | package server |
| | |
| | import ( |
| | "io" |
| | "net/http" |
| | "net/http/httptest" |
| | "os" |
| | "os/exec" |
| | "path/filepath" |
| | "strings" |
| | "testing" |
| | |
| | "gitweb/internal/store" |
| | ) |
| | |
| | // newTestServer builds a Server over a LocalStore namespace containing a bare |
| | // repo at u@d.com/demo.git and a plain dir u@d.com/docs. |
| | func newTestServer(t *testing.T) (*httptest.Server, func()) { |
| | t.Helper() |
| | if _, err := exec.LookPath("git"); err != nil { |
| | t.Skip("git not available") |
| | } |
| | ns := t.TempDir() |
| | if err := os.MkdirAll(filepath.Join(ns, "u@d.com", "docs"), 0o755); err != nil { |
| | t.Fatal(err) |
| | } |
| | os.WriteFile(filepath.Join(ns, "u@d.com", "docs", "note.txt"), []byte("hi"), 0o644) |
| | // Browsing is gated by Access files; make this whole namespace world-readable |
| | // so the anonymous browse tests below succeed. |
| | os.WriteFile(filepath.Join(ns, "u@d.com", "Access"), []byte("read,list: all\n*: u@d.com\n"), 0o644) |
| | |
| | work := t.TempDir() |
| | git := func(dir string, a ...string) { |
| | t.Helper() |
| | c := exec.Command("git", a...) |
| | c.Dir = dir |
| | c.Env = append(os.Environ(), |
| | "GIT_AUTHOR_NAME=T", "GIT_AUTHOR_EMAIL=t@e.com", |
| | "GIT_COMMITTER_NAME=T", "GIT_COMMITTER_EMAIL=t@e.com") |
| | if out, err := c.CombinedOutput(); err != nil { |
| | t.Fatalf("git %v: %v\n%s", a, err, out) |
| | } |
| | } |
| | git(work, "init", "-q", "-b", "main") |
| | os.WriteFile(filepath.Join(work, "README.md"), []byte("# Demo\n\nhello\n"), 0o644) |
| | os.WriteFile(filepath.Join(work, "main.go"), []byte("package main\n\nfunc main() {}\n"), 0o644) |
| + | // An Access file committed into the repo tree, to prove it is hidden there too. |
| + | os.WriteFile(filepath.Join(work, "Access"), []byte("read,list: all\n*: secret@d.com\n"), 0o644) |
| | git(work, "add", "-A") |
| | git(work, "commit", "-qm", "initial") |
| | git(work, "checkout", "-q", "-b", "feature/x") |
| | os.WriteFile(filepath.Join(work, "main.go"), []byte("package main\n\nfunc main() { _ = 1 }\n"), 0o644) |
| | git(work, "commit", "-qam", "tweak") |
| | git(work, "tag", "v1.0.0") |
| | git(work, "checkout", "-q", "main") |
| | git(work, "clone", "-q", "--bare", work, filepath.Join(ns, "u@d.com", "demo.git")) |
| | |
| | web := os.DirFS("../../web") |
| | srv, err := New(Config{Store: store.NewLocalStore(ns), Web: http.FS(web)}) |
| | if err != nil { |
| | t.Fatal(err) |
| | } |
| | ts := httptest.NewServer(srv.Handler()) |
| | return ts, ts.Close |
| + | } |
| + | |
| + | // TestAccessFilesHidden verifies that upspin Access files are never listed or |
| + | // rendered — neither in the plain namespace browse nor in a git repo tree — so |
| + | // the set of users/groups with access is not exposed to browsers. |
| + | func TestAccessFilesHidden(t *testing.T) { |
| + | ts, done := newTestServer(t) |
| + | defer done() |
| + | |
| + | get := func(p string) (int, string) { |
| + | resp, err := http.Get(ts.URL + p) |
| + | if err != nil { |
| + | t.Fatal(err) |
| + | } |
| + | defer resp.Body.Close() |
| + | b, _ := io.ReadAll(resp.Body) |
| + | return resp.StatusCode, string(b) |
| + | } |
| + | |
| + | // Namespace listing must not link to the Access file... |
| + | if code, body := get("/u@d.com/"); code != 200 { |
| + | t.Fatalf("namespace browse code %d", code) |
| + | } else if strings.Contains(body, `href="Access"`) { |
| + | t.Error("namespace listing exposed the Access file") |
| + | } |
| + | // ...and its contents must not be servable directly. |
| + | if code, _ := get("/u@d.com/Access"); code != 404 { |
| + | t.Errorf("GET /u@d.com/Access = %d, want 404", code) |
| + | } |
| + | |
| + | // Git tree listing must not link to a committed Access file... |
| + | if code, body := get("/u@d.com/demo.git/tree/main/"); code != 200 { |
| + | t.Fatalf("tree code %d", code) |
| + | } else if strings.Contains(body, "/blob/main/Access") { |
| + | t.Error("git tree listing exposed a committed Access file") |
| + | } |
| + | // ...and neither blob nor raw view will render it. |
| + | if code, body := get("/u@d.com/demo.git/blob/main/Access"); code != 404 { |
| + | t.Errorf("blob Access = %d, want 404", code) |
| + | } else if strings.Contains(body, "secret@d.com") { |
| + | t.Error("blob view leaked Access contents") |
| + | } |
| + | if code, _ := get("/u@d.com/demo.git/raw/main/Access"); code != 404 { |
| + | t.Errorf("raw Access = %d, want 404", code) |
| + | } |
| | } |
| | |
| | func TestGitVerbs(t *testing.T) { |
| | ts, done := newTestServer(t) |
| | defer done() |
| | |
| | get := func(p string) (int, string) { |
| | resp, err := http.Get(ts.URL + p) |
| | if err != nil { |
| | t.Fatal(err) |
| | } |
| | defer resp.Body.Close() |
| | b, _ := io.ReadAll(resp.Body) |
| | return resp.StatusCode, string(b) |
| | } |
| | |
| | cases := []struct { |
| | path string |
| | wantCode int |
| | contains []string |
| | }{ |
| | {"/u@d.com/docs/", 200, []string{"Index of", "note.txt"}}, |
| | {"/u@d.com/demo.git/", 200, []string{"feature/x", "v1.0.0", "README", "Demo"}}, |
| | {"/u@d.com/demo.git/refs", 200, []string{"Branches", "Tags", "feature/x", "v1.0.0"}}, |
| | {"/u@d.com/demo.git/tree/main/", 200, []string{"main.go", "README.md"}}, |
| | {"/u@d.com/demo.git/blob/main/main.go", 200, []string{"chroma", "func"}}, |
| | {"/u@d.com/demo.git/blob/feature/x/main.go", 200, []string{"chroma"}}, |
| | {"/u@d.com/demo.git/log/feature/x", 200, []string{"tweak", "/commit/"}}, |
| | {"/u@d.com/demo.git/bogus", 404, nil}, |
| | } |
| | for _, c := range cases { |
| | t.Run(c.path, func(t *testing.T) { |
| | code, body := get(c.path) |
| | if code != c.wantCode { |
| | t.Fatalf("code = %d, want %d", code, c.wantCode) |
| | } |
| | for _, want := range c.contains { |
| | if !strings.Contains(body, want) { |
| | t.Fatalf("body missing %q", want) |
| | } |
| | } |
| | }) |
| | } |
| | |
| | // Renderer switch: default README.md is highlighted source, with a |
| | // Markdown switch offered; ?render=markdown renders the markdown. |
| | { |
| | _, src := get("/u@d.com/demo.git/blob/main/README.md") |
| | if !strings.Contains(src, "chroma") { |
| | t.Fatal("README.md default view should be highlighted source") |
| | } |
| | if !strings.Contains(src, `href="/u@d.com/demo.git/blob/main/README.md?render=markdown"`) { |
| | t.Fatal("README.md should offer a Markdown switch link") |
| | } |
| | _, md := get("/u@d.com/demo.git/blob/main/README.md?render=markdown") |
| | if !strings.Contains(md, "<h1") { |
| | t.Fatal("?render=markdown should render markdown (expected <h1>)") |
| | } |
| | } |
| | |
| | // Non-markdown file offers only the default; force exposes & runs markdown. |
| | { |
| | _, src := get("/u@d.com/demo.git/blob/main/main.go") |
| | if strings.Contains(src, "?render=markdown\"") { |
| | t.Fatal("main.go should not offer markdown without force") |
| | } |
| | if !strings.Contains(src, "force=1") { |
| | t.Fatal("main.go should offer a force toggle") |
| | } |
| | code, _ := get("/u@d.com/demo.git/blob/main/main.go?render=markdown&force=1") |
| | if code != 200 { |
| | t.Fatalf("forced markdown on main.go: code %d", code) |
| | } |
| | } |
| | |
| | // CSP header is present on git pages. Scripts are restricted to same-origin |
| | // with no 'unsafe-inline', so markdown-injected inline script cannot run |
| | // (mermaid is served as a same-origin file). |
| | resp, _ := http.Get(ts.URL + "/u@d.com/demo.git/") |
| | resp.Body.Close() |
| | csp := resp.Header.Get("Content-Security-Policy") |
| | if !strings.Contains(csp, "script-src 'self'") || strings.Contains(csp, "script-src 'self' 'unsafe-inline'") { |
| | t.Fatalf("missing/weak script CSP on git page: %q", csp) |
| | } |
| | |
| | // Commit page renders a diff. |
| | _, logBody := get("/u@d.com/demo.git/log/feature/x") |
| | i := strings.Index(logBody, "/u@d.com/demo.git/commit/") |
| | if i < 0 { |
| | t.Fatal("no commit link in log") |
| | } |
| | url := logBody[i : i+len("/u@d.com/demo.git/commit/")+40] |
| | code, body := get(url) |
| | if code != 200 || !strings.Contains(body, "diff-add") || !strings.Contains(body, "diff-del") { |
| | t.Fatalf("commit page: code %d, diff classes present=%v", code, |
| | strings.Contains(body, "diff-add")) |
| | } |
| | } |
| internal/server/server.go |
| | // Package server is the HTTP layer of the git browser. The upspin path is the |
| | // URL path: GET /<user@domain>/<path...>. If the path resolves inside a bare |
| | // git repository the request is handled by the git browser (summary, tree, |
| | // blob, log, commit, refs); otherwise it falls back to plain upspin namespace |
| | // browsing (directory listings and raw files) so users can drill down to repos. |
| | package server |
| | |
| | import ( |
| | "bytes" |
| | "embed" |
| | "errors" |
| | "html/template" |
| | "io" |
| | "log" |
| | "mime" |
| | "net/http" |
| | "path" |
| | "strings" |
| | |
| | "gitweb/internal/auth" |
| | "gitweb/internal/gitrepo" |
| | "gitweb/internal/render" |
| | "gitweb/internal/store" |
| | ) |
| | |
| | //go:embed templates/*.html |
| | var tmplFS embed.FS |
| | |
| | var pageTmpl = template.Must(template.ParseFS(tmplFS, "templates/page.html")) |
| | |
| | // Config configures a Server. |
| | type Config struct { |
| | Store store.WriteStore // read for browse/clone; write for push |
| | Web http.FileSystem // static assets (style.css, landing index.html) |
| | Realm string // HTTP Basic auth realm; defaults to "gitweb" |
| | // Vault, if non-nil, verifies vault-issued SSO bearer tokens (the passkey |
| | // login path). When nil, only PATs are accepted. |
| | Vault *auth.VaultVerifier |
| | } |
| | |
| | // Server serves upspin-stored git repositories over HTTP: a browse UI, plus the |
| | // git smart-HTTP protocol for clone and push. Access is gated by upspin Access |
| | // files; private repos require Basic-auth credentials (a vault SSO token or a |
| | // personal access token). |
| | type Server struct { |
| | store store.Store |
| | web http.FileSystem |
| | renderer *render.Renderer |
| | renderers *render.Registry |
| | repos *gitrepo.Cache |
| | authz *auth.Authorizer |
| | pat *auth.PATVerifier |
| | vault *auth.VaultVerifier // may be nil |
| | pusher *gitrepo.Pusher |
| | realm string |
| | chroma []byte // generated highlighter stylesheet |
| | landing []byte // /index.html, served at "/" |
| | } |
| | |
| | // New builds a Server. |
| | func New(c Config) (*Server, error) { |
| | css, err := render.CSS() |
| | if err != nil { |
| | return nil, err |
| | } |
| | rnd := render.New() |
| | repos := gitrepo.NewCache(c.Store) |
| | realm := c.Realm |
| | if realm == "" { |
| | realm = "gitweb" |
| | } |
| | s := &Server{ |
| | store: c.Store, |
| | web: c.Web, |
| | renderer: rnd, |
| | renderers: render.NewRegistry(rnd), |
| | repos: repos, |
| | authz: auth.NewAuthorizer(c.Store), |
| | pat: auth.NewPATVerifier(c.Store), |
| | vault: c.Vault, |
| | pusher: gitrepo.NewPusher(c.Store, repos), |
| | realm: realm, |
| | chroma: css, |
| | } |
| | if f, err := c.Web.Open("index.html"); err == nil { |
| | s.landing, _ = io.ReadAll(f) |
| | f.Close() |
| | } |
| | return s, nil |
| | } |
| | |
| | // Handler returns the HTTP handler. |
| | func (s *Server) Handler() http.Handler { |
| | mux := http.NewServeMux() |
| | mux.HandleFunc("GET /_assets/chroma.css", s.handleChromaCSS) |
| | mux.Handle("GET /_assets/", http.StripPrefix("/_assets/", http.FileServer(s.web))) |
| | // The catch-all accepts every method: git smart-HTTP needs POST, and |
| | // ServeMux cannot match the "/info/refs", "/git-upload-pack", |
| | // "/git-receive-pack" suffixes, so handleRoot dispatches by suffix itself. |
| | mux.HandleFunc("/", s.handleRoot) |
| | return securityHeaders(mux) |
| | } |
| | |
| | // handleRoot dispatches between the git smart-HTTP endpoints and the browse UI. |
| | func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { |
| | if ep, ok := gitEndpoint(r); ok { |
| | s.handleGitHTTP(w, r, ep) |
| | return |
| | } |
| | if r.Method != http.MethodGet { |
| | http.Error(w, "405 method not allowed", http.StatusMethodNotAllowed) |
| | return |
| | } |
| | s.handlePath(w, r) |
| | } |
| | |
| | func (s *Server) handleChromaCSS(w http.ResponseWriter, r *http.Request) { |
| | w.Header().Set("Content-Type", "text/css; charset=utf-8") |
| | w.Write(s.chroma) |
| | } |
| | |
| | // handlePath dispatches a request whose URL path is an upspin path. |
| | func (s *Server) handlePath(w http.ResponseWriter, r *http.Request) { |
| | raw := strings.TrimPrefix(r.URL.Path, "/") |
| | if raw == "" { |
| | s.serveLanding(w) |
| | return |
| | } |
| | |
| | // First element is the upspin user; validate before building any path. |
| | user, rest, _ := strings.Cut(raw, "/") |
| | if !store.ValidUser(user) { |
| | s.notFound(w) |
| | return |
| | } |
| | // Clean the remainder; path.Clean collapses any ".." so it cannot escape |
| | // the user's tree. clean always begins with "/". |
| | clean := path.Clean("/" + rest) |
| | upath := user + clean // e.g. "u@d.com/proj.git"; root is "u@d.com/" |
| | |
| | // Identify the caller (anonymous if no/blank credentials) before deciding |
| | // what they may see. |
| | authUser, ok := s.authenticate(w, r) |
| | if !ok { |
| | return // 401 already written |
| | } |
| | |
| | // If the path lies inside a bare git repo, hand off to the git browser; |
| | // otherwise fall back to plain upspin namespace browsing. |
| | repoPath, gitRest, isRepo, err := gitrepo.Locate(s.store, user, clean) |
| | if err != nil { |
| | s.storeErr(w, err) |
| | return |
| | } |
| | |
| | // Gate the browse UI by the same public/private rule as clone: anonymous |
| | // users see only world-readable paths; others must be granted read. |
| | resource := upath |
| | if isRepo { |
| | resource = repoPath |
| | } |
| | allowed, err := s.authz.CanRead(authUser, resource) |
| | if err != nil { |
| | s.serverErr(w, err) |
| | return |
| | } |
| | if !allowed { |
| | s.denyRead(w, authUser) |
| | return |
| | } |
| | |
| | if isRepo { |
| | s.serveGit(w, r, repoPath, gitRest) |
| | return |
| | } |
| | |
| | isDir, err := s.store.Stat(upath) |
| | if err != nil { |
| | s.storeErr(w, err) |
| | return |
| | } |
| | |
| | if isDir { |
| | s.serveDir(w, r, user, clean, upath) |
| | return |
| | } |
| | s.serveFile(w, upath, clean) |
| | } |
| | |
| | // serveDir renders a directory: README.md/index.md if present, else a listing. |
| | func (s *Server) serveDir(w http.ResponseWriter, r *http.Request, user, clean, upath string) { |
| | // A trailing slash is required so relative links resolve against the dir. |
| | if !strings.HasSuffix(r.URL.Path, "/") { |
| | http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently) |
| | return |
| | } |
| | |
| | entries, err := s.store.List(upath) |
| | if err != nil { |
| | s.storeErr(w, err) |
| | return |
| | } |
| | |
| | // Prefer an index document if the directory has one. |
| | for _, name := range []string{"index.md", "README.md", "readme.md"} { |
| | if hasEntry(entries, name) { |
| | s.serveFile(w, strings.TrimRight(upath, "/")+"/"+name, clean) |
| | return |
| | } |
| | } |
| | |
| | var body bytes.Buffer |
| | body.WriteString(`<h1>Index of `) |
| | body.WriteString(template.HTMLEscapeString(displayPath(user, clean))) |
| | body.WriteString("</h1>\n<ul class=\"dir\">\n") |
| | if clean != "/" { |
| | body.WriteString(`<li><a href="../">../</a></li>` + "\n") |
| | } |
| | for _, e := range entries { |
| + | if isAccessFile(e.Name) { |
| + | continue // never expose upspin ACLs in a listing |
| + | } |
| | name := e.Name |
| | if e.IsDir { |
| | name += "/" |
| | } |
| | esc := template.HTMLEscapeString(name) |
| | body.WriteString(`<li><a href="` + esc + `">` + esc + "</a></li>\n") |
| | } |
| | body.WriteString("</ul>\n") |
| | s.renderPage(w, displayPath(user, clean), template.HTML(body.String())) |
| | } |
| | |
| | // serveFile renders a markdown file, or serves any other file raw. |
| | func (s *Server) serveFile(w http.ResponseWriter, upath, clean string) { |
| + | // Never serve an upspin Access file's contents (its user/group list), |
| + | // even when requested directly by path. |
| + | if isAccessFile(path.Base(upath)) { |
| + | s.notFound(w) |
| + | return |
| + | } |
| | data, err := s.store.Get(upath) |
| | if err != nil { |
| | s.storeErr(w, err) |
| | return |
| | } |
| | |
| | if isMarkdown(upath) { |
| | html, err := s.renderer.ToHTML(data) |
| | if err != nil { |
| | s.serverErr(w, err) |
| | return |
| | } |
| | s.renderPage(w, path.Base(clean), template.HTML(html)) |
| | return |
| | } |
| | |
| | // Raw asset (images referenced by markdown, etc.). |
| | ctype := mime.TypeByExtension(path.Ext(upath)) |
| | if ctype == "" { |
| | ctype = http.DetectContentType(data) |
| | } |
| | w.Header().Set("Content-Type", ctype) |
| | w.Write(data) |
| | } |
| | |
| | func (s *Server) renderPage(w http.ResponseWriter, title string, body template.HTML) { |
| | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| | err := pageTmpl.Execute(w, struct { |
| | Title string |
| | Body template.HTML |
| | }{Title: title, Body: body}) |
| | if err != nil { |
| | log.Printf("gitweb: template: %v", err) |
| | } |
| | } |
| | |
| | func (s *Server) serveLanding(w http.ResponseWriter) { |
| | if s.landing == nil { |
| | s.notFound(w) |
| | return |
| | } |
| | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| | w.Write(s.landing) |
| | } |
| | |
| | // --- errors --- |
| | |
| | func (s *Server) storeErr(w http.ResponseWriter, err error) { |
| | if errors.Is(err, store.ErrNotFound) { |
| | s.notFound(w) |
| | return |
| | } |
| | s.serverErr(w, err) |
| | } |
| | |
| | func (s *Server) notFound(w http.ResponseWriter) { |
| | http.Error(w, "404 not found", http.StatusNotFound) |
| | } |
| | |
| | // denyRead responds to a caller who may not read the requested resource. An |
| | // anonymous caller gets a Basic-auth challenge (so a git/browser client can |
| | // retry with credentials); an already-authenticated caller gets a 404, which |
| | // does not reveal whether the resource exists. |
| | func (s *Server) denyRead(w http.ResponseWriter, authUser string) { |
| | if authUser == "" { |
| | s.challenge(w) |
| | return |
| | } |
| | s.notFound(w) |
| | } |
| | |
| | // challenge writes a 401 with a Basic-auth challenge. The body is deliberately |
| | // vague so anonymous callers cannot distinguish "private" from "absent". |
| | func (s *Server) challenge(w http.ResponseWriter) { |
| | w.Header().Set("WWW-Authenticate", `Basic realm="`+s.realm+`", charset="UTF-8"`) |
| | http.Error(w, "not found, or authentication required", http.StatusUnauthorized) |
| | } |
| | |
| | func (s *Server) serverErr(w http.ResponseWriter, err error) { |
| | log.Printf("gitweb: 500: %v", err) |
| | http.Error(w, "500 internal server error", http.StatusInternalServerError) |
| | } |
| | |
| | // --- helpers --- |
| | |
| | func isMarkdown(p string) bool { |
| | switch strings.ToLower(path.Ext(p)) { |
| | case ".md", ".markdown": |
| | return true |
| | } |
| | return false |
| | } |
| + | |
| + | // isAccessFile reports whether name is an upspin Access file. gitweb never lists |
| + | // or renders these: their contents enumerate the upspin users and groups granted |
| + | // access, which must not be exposed to browsers. |
| + | func isAccessFile(name string) bool { return name == "Access" } |
| | |
| | func hasEntry(entries []store.Entry, name string) bool { |
| | for _, e := range entries { |
| | if !e.IsDir && e.Name == name { |
| | return true |
| | } |
| | } |
| | return false |
| | } |
| | |
| | // displayPath renders the upspin path for a heading: "u@d.com/docs/". |
| | func displayPath(user, clean string) string { |
| | if clean == "/" { |
| | return user + "/" |
| | } |
| | return user + clean |
| | } |
| | |
| | func securityHeaders(h http.Handler) http.Handler { |
| | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| | w.Header().Set("X-Content-Type-Options", "nosniff") |
| | w.Header().Set("X-Frame-Options", "DENY") |
| | w.Header().Set("Referrer-Policy", "no-referrer") |
| | // script-src 'self': the only scripts are our own, same-origin mermaid |
| | // files (mermaid-init.js and the vendored bundle). No 'unsafe-inline', so |
| | // script injected via rendered markdown cannot execute. style-src allows |
| | // 'unsafe-inline' because mermaid injects diagram styles at render time; |
| | // this permits inline styles but not scripts. Markdown may embed external |
| | // https images; everything else is same-origin. |
| | w.Header().Set("Content-Security-Policy", |
| | "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "+ |
| | "img-src 'self' data: https:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'") |
| | h.ServeHTTP(w, r) |
| | }) |
| | } |