josie / simplegit

package web

import (
	"html/template"
	"net/http"
	"net/url"
	"path"
	"strings"

	"git.josie-c.com/josie/simplegit/internal/db"
	"git.josie-c.com/josie/simplegit/internal/git"
	"git.josie-c.com/josie/simplegit/internal/render"
)

// readmeCandidates are tried in order at the repo root; first hit wins.
// Matched case-insensitively (a lowercase readme.md is just as valid as
// README.md), so candidates stay lowercase for comparison.
var readmeCandidates = []string{"readme.md", "readme.markdown", "readme.mkd", "readme"}

const (
	readmeLimit  = 1 << 20
	licenseLimit = 64 << 10
)

// treeNode is one entry in the Code tab's file tree: a directory (Children,
// no Href) or a file (Href to its blob view).
type treeNode struct {
	Name     string
	Href     string
	Children []*treeNode
}

// buildFileTree turns a recursive path list into nested nodes; directories
// materialize on demand in git's leaf order.
func buildFileTree(paths []string, href func(string) string) []*treeNode {
	var roots []*treeNode
	dirs := map[string]*treeNode{}
	for _, p := range paths {
		parts := strings.Split(p, "/")
		parent := &roots
		dirPath := ""
		for i, part := range parts {
			if i == len(parts)-1 {
				*parent = append(*parent, &treeNode{Name: part, Href: href(p)})
				continue
			}
			if dirPath != "" {
				dirPath += "/"
			}
			dirPath += part
			node, ok := dirs[dirPath]
			if !ok {
				node = &treeNode{Name: part + "/"}
				dirs[dirPath] = node
				*parent = append(*parent, node)
			}
			parent = &node.Children
		}
	}
	return roots
}

// buildTree lists ref's files and nests them for the Code tab sidebar. A
// listing failure (empty or unreadable repo) just leaves the tree empty.
func (s *Server) buildTree(r *http.Request, repo db.Repo, ref string) []*treeNode {
	paths, err := git.LsTreePaths(s.repoPathFor(r.PathValue("user"), repo), ref)
	if err != nil {
		return nil
	}
	hrefBase := "/" + r.PathValue("user") + "/" + repo.Name + "/blob/" + url.PathEscape(ref) + "/"
	return buildFileTree(paths, func(p string) string { return hrefBase + escapePath(p) })
}

type repoHomeData struct {
	navData
	Description   string
	Branch        string // the ref being viewed
	DefaultBranch string
	CloneURL      string
	Empty         bool
	Tree          []*treeNode
	Readme        template.HTML
	LicenseName   string
	LicenseHref   string
	CommitCount   int
	BranchCount   int
	TagCount      int
	Branches      []string
}

func (s *Server) baseRepoData(r *http.Request, repo db.Repo, user *db.User) repoHomeData {
	return repoHomeData{
		navData:       nav(r, repo, user, "code"),
		Description:   repo.Description,
		Branch:        repo.DefaultBranch,
		DefaultBranch: repo.DefaultBranch,
		CloneURL:      cloneURL(s.cfg.BaseURL, r.PathValue("user"), repo.Name),
	}
}

func (s *Server) handleRepoHome(w http.ResponseWriter, r *http.Request) {
	repo, repoPath, user, ok := s.resolveRepo(w, r)
	if !ok {
		return
	}
	data := s.baseRepoData(r, repo, user)

	exists, err := git.RefExists(repoPath, repo.DefaultBranch)
	if err != nil {
		s.internalError(w, r, err)
		return
	}
	if !exists {
		data.Empty = true
		s.render(w, "repo.html", http.StatusOK, data)
		return
	}

	entries, err := git.LsTree(repoPath, repo.DefaultBranch, "")
	if err != nil {
		s.internalError(w, r, err)
		return
	}
	s.renderCodeTab(w, r, repo, repoPath, user, repo.DefaultBranch, entries)
}

// attachTree builds the recursive file tree for the Code tab.
func (s *Server) attachTree(data *repoHomeData, repoPath string) {
	paths, err := git.LsTreePaths(repoPath, data.Branch)
	if err != nil {
		return
	}
	base := "/" + data.Owner + "/" + data.RepoName + "/blob/" + url.PathEscape(data.Branch) + "/"
	data.Tree = buildFileTree(paths, func(p string) string { return base + escapePath(p) })
}

// attachRepoMeta fills the Code tab's summary: commit/branch/tag counts and
// the branch list for the dropdown.
func (s *Server) attachRepoMeta(data *repoHomeData, repoPath string) {
	if n, err := git.CommitCount(repoPath, data.Branch); err == nil {
		data.CommitCount = n
	}
	if branches, err := git.Branches(repoPath); err == nil {
		data.Branches = branches
		data.BranchCount = len(branches)
	}
	if tags, err := git.Tags(repoPath); err == nil {
		data.TagCount = len(tags)
	}
}

// attachReadme renders the first README candidate at the repo root.
func (s *Server) attachReadme(data *repoHomeData, repoPath string, entries []git.TreeEntry) {
	var readme string
	for _, candidate := range readmeCandidates {
		for _, entry := range entries {
			if entry.Type == "blob" && strings.EqualFold(entry.Path, candidate) {
				readme = entry.Path
				break
			}
		}
		if readme != "" {
			break
		}
	}
	if readme == "" {
		return
	}
	source, err := git.ShowFile(repoPath, data.Branch, readme, readmeLimit)
	if err != nil || len(source) > readmeLimit {
		return
	}
	if strings.HasPrefix(strings.ToLower(readme), "readme.") {
		html, err := render.Markdown(source)
		if err != nil {
			return
		}
		data.Readme = template.HTML(html)
		return
	}
	data.Readme = template.HTML("<pre>" + template.HTMLEscapeString(string(source)) + "</pre>")
}

// attachLicense sniffs the first LICENSE*/COPYING* file at the repo root and
// links the license summary box to it.
func (s *Server) attachLicense(data *repoHomeData, repoPath string, entries []git.TreeEntry) {
	for _, entry := range entries {
		base := strings.ToUpper(path.Base(entry.Path))
		if entry.Type != "blob" || !strings.HasPrefix(base, "LICENSE") && !strings.HasPrefix(base, "COPYING") {
			continue
		}
		source, err := git.ShowFile(repoPath, data.Branch, entry.Path, licenseLimit)
		if err != nil {
			return
		}
		if len(source) > licenseLimit {
			source = source[:licenseLimit]
		}
		data.LicenseName = detectLicense(string(source))
		data.LicenseHref = "/" + data.Owner + "/" + data.RepoName + "/blob/" + escapePath(data.Branch+"/"+entry.Path)
		return
	}
}

// detectLicense is a deliberately dumb keyword sniff over the license
// text; anything unrecognised but present is "Custom".
func detectLicense(text string) string {
	upper := strings.ToUpper(text)
	switch {
	case strings.Contains(upper, "MIT LICENSE"),
		strings.Contains(upper, "PERMISSION IS HEREBY GRANTED, FREE OF CHARGE"):
		return "MIT"
	case strings.Contains(upper, "APACHE LICENSE, VERSION 2"):
		return "Apache-2.0"
	case strings.Contains(upper, "BSD 3-CLAUSE"), strings.Contains(upper, "BSD 3. CLAUSE"):
		return "BSD-3-Clause"
	case strings.Contains(upper, "BSD 2-CLAUSE"), strings.Contains(upper, "BSD 2. CLAUSE"):
		return "BSD-2-Clause"
	case strings.Contains(upper, "ISC LICENSE"):
		return "ISC"
	case strings.Contains(upper, "UNLICENSE"):
		return "Unlicense"
	case strings.Contains(upper, "GNU GENERAL PUBLIC LICENSE"):
		return "GPL"
	case strings.Contains(upper, "GNU LESSER GENERAL PUBLIC LICENSE"):
		return "LGPL"
	case strings.Contains(upper, "MOZILLA PUBLIC LICENSE"):
		return "MPL"
	default:
		return "Custom"
	}
}