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