git@cosmocr.at/gitweb.git @ main · summary refs log
main / internal / server / git.go /
internal/server/git.go · 14.7 KiB · raw
view: SourceMarkdown ☑ force

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, release, err := s.repos.Get(repoPath) if err != nil { s.storeErr(w, err) return } defer release() base := "/" + repoPath rest = strings.Trim(rest, "/") verb, rem, _ := strings.Cut(rest, "/")

switch verb {
case "":
	s.gitSummary(w, r, repo, repoPath, base)
case "refs":
	s.gitRefs(w, r, repo, repoPath, base)
case "tree":
	s.gitTree(w, r, repo, repoPath, base, rem)
case "blob":
	s.gitBlob(w, r, repo, repoPath, base, rem, false, r.URL.Query())
case "raw":
	s.gitBlob(w, r, repo, repoPath, base, rem, true, nil)
case "log":
	s.gitLog(w, r, repo, repoPath, base, rem)
case "commit":
	s.gitCommit(w, r, 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, r *http.Request, 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, r, 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, r *http.Request, 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, r, 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, r *http.Request, 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, r, 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, r *http.Request, 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, r, repoName, "log", struct { Nav gitNav Commits interface{} }{ Nav: newNav(repoName, base, ref), Commits: commits, }) }

// --- commit ---

func (s *Server) gitCommit(w http.ResponseWriter, r *http.Request, 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, r, 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, r *http.Request, repo *git.Repository, repoName, base string) { branches, tags, err := gitrepo.Refs(repo) if err != nil { s.serverErr(w, err) return } s.renderGit(w, r, 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, r *http.Request, 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, r, 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" }