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" }