josie / simplegit

package web

import (
	"errors"
	"html/template"
	"net/http"
	"net/url"
	"path/filepath"
	"regexp"
	"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"
)

// refs reach git as part of single arguments; keep them boring.
var refPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._/-]*$`)

func validRef(ref string) bool {
	return refPattern.MatchString(ref) && !strings.Contains(ref, "..")
}

// escapePath percent-encodes each segment of a URL path built from
// repository data (refs, file paths). html/template does not encode these
// in href contexts, and names may contain '#', '%' or spaces.
func escapePath(p string) string {
	segs := strings.Split(p, "/")
	for i, seg := range segs {
		segs[i] = url.PathEscape(seg)
	}
	return strings.Join(segs, "/")
}

// blobLimit caps the size of blob content rendered in the browser.
const blobLimit = 1 << 20

// splitRefPath resolves the longest existing ref prefix in a /blob/ or /raw/
// URL tail (branches like feature/greeting contain slashes) and returns the
// ref plus the remaining file path. Refs are listed once up front so deep
// URLs cannot amplify into a git subprocess per path segment; commit SHAs
// and the listing-failure fallback spend at most one extra subprocess.
func splitRefPath(repoPath, ref, path string) (string, string, bool) {
	parts := append([]string{ref}, strings.Split(path, "/")...)
	names, err := git.RefNames(repoPath)
	if err != nil {
		// The listing failed; at most one direct check before giving up.
		if !validRef(ref) {
			return "", "", false
		}
		if exists, err := git.RefExists(repoPath, ref); err == nil && exists {
			return ref, path, true
		}
		return "", "", false
	}
	known := make(map[string]bool, len(names))
	for _, name := range names {
		known[name] = true
	}
	for i := len(parts) - 1; i >= 1; i-- {
		candidate := strings.Join(parts[:i], "/")
		if validRef(candidate) && known[candidate] {
			return candidate, strings.Join(parts[i:], "/"), true
		}
	}
	if shaPattern.MatchString(ref) {
		if exists, err := git.RefExists(repoPath, ref); err == nil && exists {
			return ref, path, true
		}
	}
	return "", "", false
}

// repoPathFor maps a repo back to its bare directory on disk.
func (s *Server) repoPathFor(owner string, repo db.Repo) string {
	return filepath.Join(s.cfg.DataDir, "repos", owner, repo.Name+".git")
}

// resolveRepo is the shared preamble for git-browsing pages: the repoPage
// gate plus the bare directory on disk. On failure repoPage has written the
// response.
func (s *Server) resolveRepo(w http.ResponseWriter, r *http.Request) (db.Repo, string, *db.User, bool) {
	repo, user, ok := s.repoPage(w, r)
	if !ok {
		return db.Repo{}, "", nil, false
	}
	return repo, s.repoPathFor(r.PathValue("user"), repo), user, true
}

// handleTree lists the tree at a non-default ref (branch, tag or commit sha).
func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
	repo, repoPath, user, ok := s.resolveRepo(w, r)
	if !ok {
		return
	}
	ref := strings.Trim(r.PathValue("ref"), "/")
	if !validRef(ref) {
		http.NotFound(w, r)
		return
	}
	exists, err := git.RefExists(repoPath, ref)
	if err != nil {
		s.internalError(w, r, err)
		return
	}
	if !exists {
		http.NotFound(w, r)
		return
	}
	entries, err := git.LsTree(repoPath, ref, "")
	if err != nil {
		s.internalError(w, r, err)
		return
	}
	s.renderCodeTab(w, r, repo, repoPath, user, ref, entries)
}

// renderCodeTab renders the shared Code tab: file tree, summary row,
// README, and license box for the given ref.
func (s *Server) renderCodeTab(w http.ResponseWriter, r *http.Request, repo db.Repo, repoPath string, user *db.User, ref string, entries []git.TreeEntry) {
	data := s.baseRepoData(r, repo, user)
	data.Branch = ref
	s.attachTree(&data, repoPath)
	s.attachRepoMeta(&data, repoPath)
	s.attachReadme(&data, repoPath, entries)
	s.attachLicense(&data, repoPath, entries)
	s.render(w, "repo.html", http.StatusOK, data)
}

type blobData struct {
	navData
	Ref      string
	Path     string
	Size     int
	Notice   string
	CodeHTML template.HTML
	RawText  string
	RawHref  string
	Tree     []*treeNode
}

// handleBlob shows one file: chroma-highlighted when a lexer matches,
// rendered markdown for README-style names, escaped <pre> otherwise.
func (s *Server) handleBlob(w http.ResponseWriter, r *http.Request) {
	repo, repoPath, user, ok := s.resolveRepo(w, r)
	if !ok {
		return
	}
	s.serveBlob(w, r, repo, repoPath, user)
}

func (s *Server) serveBlob(w http.ResponseWriter, r *http.Request, repo db.Repo, repoPath string, user *db.User) {
	rawPath := strings.Trim(r.PathValue("path"), "/")
	if rawPath == "" {
		http.NotFound(w, r)
		return
	}
	ref, filePath, ok := splitRefPath(repoPath, r.PathValue("ref"), rawPath)
	if !ok {
		http.NotFound(w, r)
		return
	}
	data := blobData{
		navData: nav(r, repo, user, "code"), Ref: ref, Path: filePath,
		RawHref: "/" + r.PathValue("user") + "/" + repo.Name + "/raw/" + escapePath(ref+"/"+filePath),
		Tree:    s.buildTree(r, repo, ref),
	}
	// One batched cat-file reports type, size, and binary-ness while
	// reading at most blobLimit+1 bytes, so oversized blobs cost the cap.
	typ, size, isBinary, truncated, err := git.CatFileInfo(repoPath, ref+":"+filePath, blobLimit)
	switch {
	case errors.Is(err, git.ErrNotFound):
		http.NotFound(w, r)
		return
	case err != nil:
		s.internalError(w, r, err)
		return
	case typ != "blob":
		http.NotFound(w, r)
		return
	}
	data.Size = size
	switch {
	case truncated || size > blobLimit:
		data.Notice = "File is larger than 1 MB; view the raw content."
	case isBinary:
		data.Notice = "This looks like a binary file; view the raw content."
	default:
		source, err := git.ShowFile(repoPath, ref, filePath, blobLimit)
		if err != nil {
			s.internalError(w, r, err)
			return
		}
		s.renderBlobContent(&data, source)
	}
	s.render(w, "blob.html", http.StatusOK, data)
}

func (s *Server) renderBlobContent(data *blobData, source []byte) {
	lowerPath := strings.ToLower(data.Path)
	if markdownExt(lowerPath) {
		html, err := render.Markdown(source)
		if err == nil {
			data.CodeHTML = template.HTML(html)
			return
		}
	}
	if html, handled, err := render.CodeHTML(data.Path, string(source)); err == nil && handled {
		data.CodeHTML = template.HTML(html)
		return
	}
	data.RawText = string(source)
}

func markdownExt(p string) bool {
	for _, ext := range []string{".md", ".markdown", ".mkd"} {
		if strings.HasSuffix(p, ext) {
			return true
		}
	}
	return false
}

// handleRaw serves the untouched file bytes. text/plain + nosniff keep the
// origin from ever running repo content inline, and a
// Content-Security-Policy headers off script even if a viewer downloads a
// file and opens it locally in a tab.
func (s *Server) handleRaw(w http.ResponseWriter, r *http.Request) {
	_, repoPath, _, ok := s.resolveRepo(w, r)
	if !ok {
		return
	}
	rawPath := strings.Trim(r.PathValue("path"), "/")
	if rawPath == "" {
		http.NotFound(w, r)
		return
	}
	ref, filePath, ok := splitRefPath(repoPath, r.PathValue("ref"), rawPath)
	if !ok {
		http.NotFound(w, r)
		return
	}
	kind, size, _, _, err := git.CatFileInfo(repoPath, ref+":"+filePath, 1)
	if errors.Is(err, git.ErrNotFound) {
		http.NotFound(w, r)
		return
	}
	if err != nil {
		s.internalError(w, r, err)
		return
	}
	if kind != "blob" {
		http.NotFound(w, r)
		return
	}
	// The raw path has no renderer cap, so bound the buffer here: a large
	// blob fetched in parallel must not multiply into an OOM.
	if size > rawLimit {
		http.Error(w, "file too large to serve raw", http.StatusRequestEntityTooLarge)
		return
	}
	source, err := git.ShowFile(repoPath, ref, filePath, rawLimit)
	if err != nil {
		s.internalError(w, r, err)
		return
	}
	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	w.Header().Set("X-Content-Type-Options", "nosniff")
	w.Header().Set("Content-Security-Policy", "default-src 'none'")
	_, _ = w.Write(source)
}