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