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