gitmote / internal / webui / browse.go · history · branches
20155 bytes · raw
package webui
import (
"errors"
"html/template"
"io"
"mime"
"net/http"
"path"
"strings"
"github.com/atmin/gitmote/internal/meta"
"github.com/atmin/gitmote/internal/render"
"github.com/atmin/gitmote/internal/repo"
)
// logLimit caps how many commits a single history page shows; a 101st commit,
// if present, becomes the "more" marker rather than being silently dropped.
const logLimit = 100
// browseLanding serves the bare /{repo}: the README + tree at the default
// branch (an empty ref selects it, an empty path is the repo root).
func (h *Handler) browseLanding(w http.ResponseWriter, r *http.Request) {
repoName := r.PathValue("repo")
if !h.authorizeRead(w, r, repoName) {
return
}
c, _, ok := h.resolve(w, r, repoName, "")
if !ok {
return
}
h.renderTree(w, r, c, "")
}
// browseTreeRoute serves /{repo}/tree/<ref>/<path>: the unified content verb. It
// renders a directory listing for a tree and the file view for a blob, chosen by
// the entry's type.
func (h *Handler) browseTreeRoute(w http.ResponseWriter, r *http.Request) {
repoName := r.PathValue("repo")
if !h.authorizeRead(w, r, repoName) {
return
}
c, p, ok := h.resolve(w, r, repoName, r.PathValue("rest"))
if !ok {
return
}
typ, err := repo.EntryType(r.Context(), c.dir, c.sha, p)
if errors.Is(err, repo.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
h.serverError(w, "entry type", err)
return
}
if typ == "blob" {
h.renderBlob(w, r, c, p)
return
}
h.renderTree(w, r, c, p)
}
// browseBlobRoute serves /{repo}/blob/<ref>/<path>: an explicit file view. A
// path that is actually a directory 301s to its tree URL (canonicalize, don't
// 404), so blob self-heals and tree never guesses wrong.
func (h *Handler) browseBlobRoute(w http.ResponseWriter, r *http.Request) {
repoName := r.PathValue("repo")
if !h.authorizeRead(w, r, repoName) {
return
}
c, p, ok := h.resolve(w, r, repoName, r.PathValue("rest"))
if !ok {
return
}
typ, err := repo.EntryType(r.Context(), c.dir, c.sha, p)
if errors.Is(err, repo.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
h.serverError(w, "entry type", err)
return
}
if typ == "tree" {
http.Redirect(w, r, treeURL(c.repo.Name, c.ref, p), http.StatusMovedPermanently)
return
}
h.renderBlob(w, r, c, p)
}
// browseRawRoute serves /{repo}/raw/<ref>/<path>: the file's bytes. It is
// file-only — a directory 404s (BlobSize fails on a non-blob).
func (h *Handler) browseRawRoute(w http.ResponseWriter, r *http.Request) {
repoName := r.PathValue("repo")
if !h.authorizeRead(w, r, repoName) {
return
}
c, p, ok := h.resolve(w, r, repoName, r.PathValue("rest"))
if !ok {
return
}
h.renderRaw(w, r, c, p)
}
// browseCommitsRoute serves /{repo}/commits/<ref>/<path>: the history of ref,
// optionally scoped to a path.
func (h *Handler) browseCommitsRoute(w http.ResponseWriter, r *http.Request) {
repoName := r.PathValue("repo")
if !h.authorizeRead(w, r, repoName) {
return
}
c, p, ok := h.resolve(w, r, repoName, r.PathValue("rest"))
if !ok {
return
}
h.renderCommits(w, r, c, p)
}
// browseCommitRoute serves /{repo}/commit/<sha>: one commit's metadata and diff.
// The URL carries no ref, so the header context resolves the default branch.
func (h *Handler) browseCommitRoute(w http.ResponseWriter, r *http.Request) {
repoName := r.PathValue("repo")
if !h.authorizeRead(w, r, repoName) {
return
}
c, _, ok := h.resolve(w, r, repoName, "")
if !ok {
return
}
h.renderCommit(w, r, c, r.PathValue("sha"))
}
// browseRefsRoute serves /{repo}/refs: the repo's branches and tags. It resolves
// no ref of its own — deliberately, since it is the redirect target when the
// default branch is missing, so it must render even then (and it reads refs from
// meta, needing no materialization). Calling the redirecting resolve here would
// loop that redirect back onto itself.
func (h *Handler) browseRefsRoute(w http.ResponseWriter, r *http.Request) {
repoName := r.PathValue("repo")
if !h.authorizeRead(w, r, repoName) {
return
}
rp, err := h.md.GetRepo(r.Context(), repoName)
if errors.Is(err, meta.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
h.serverError(w, "get repo", err)
return
}
c := browseCtx{repo: rp, ref: rp.DefaultBranch}
h.render(w, "browse_refs.html", refsData{
browseBase: h.browseHeader(r, c),
Refs: h.refChoices(r, c.repo.ID),
})
}
// browseCompareRoute serves /{repo}/compare/{base}...{head}: the commits head
// adds over base and their combined diff. The two refs live in one path segment
// split on the literal "..." — git forbids ".." in a ref name, so the separator
// is unambiguous even when a ref itself contains slashes. Each side resolves as
// a branch, tag, or raw SHA.
func (h *Handler) browseCompareRoute(w http.ResponseWriter, r *http.Request) {
repoName := r.PathValue("repo")
if !h.authorizeRead(w, r, repoName) {
return
}
base, head, ok := strings.Cut(r.PathValue("rest"), "...")
if !ok || base == "" || head == "" {
http.NotFound(w, r) // not a "base...head" spec
return
}
ctx := r.Context()
rp, err := h.md.GetRepo(ctx, repoName)
if errors.Is(err, meta.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
h.serverError(w, "get repo", err)
return
}
refs, err := h.md.ListRefs(ctx, rp.ID)
if err != nil {
h.serverError(w, "list refs", err)
return
}
dir, err := h.mz.Materialize(ctx, repoName)
if err != nil {
h.serverError(w, "materialize repo", err)
return
}
baseSHA, ok := h.resolveSide(w, r, dir, refs, base)
if !ok {
return
}
headSHA, ok := h.resolveSide(w, r, dir, refs, head)
if !ok {
return
}
commits, diff, err := repo.Compare(ctx, dir, baseSHA, headSHA)
if errors.Is(err, repo.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
h.serverError(w, "compare", err)
return
}
c := browseCtx{repo: rp, dir: dir, ref: head}
h.render(w, "browse_compare.html", compareData{
browseBase: h.browseHeader(r, c),
Base: base,
Head: head,
Commits: commits,
Diff: render.Diff(diff),
})
}
// resolveSide resolves one side of a compare — a whole branch or tag name, or a
// raw SHA — to a commit SHA against the materialized dir. A named ref is
// qualified so a branch beats a same-named tag; anything else is handed to git
// as a rev. It writes a 404 and returns ok=false when the side resolves to
// nothing.
func (h *Handler) resolveSide(w http.ResponseWriter, r *http.Request, dir string, refs []meta.Ref, side string) (string, bool) {
rev := side
if name, tail, branch, ok := greedyRef(refs, side, ""); ok && name == side && tail == "" {
rev = qualify(name, branch)
}
sha, err := repo.ResolveRef(r.Context(), dir, rev)
if errors.Is(err, repo.ErrNotFound) {
http.NotFound(w, r)
return "", false
}
if err != nil {
h.serverError(w, "resolve ref", err)
return "", false
}
return sha, true
}
// ciRunsRoute serves /{repo}/runs (the list) and /{repo}/runs/<id>[/job/…] (one
// run or a job log), sharing the repo-read gate with the other browse verbs.
func (h *Handler) ciRunsRoute(w http.ResponseWriter, r *http.Request) {
repoName := r.PathValue("repo")
if !h.authorizeRead(w, r, repoName) {
return
}
if rest := r.PathValue("rest"); rest != "" {
h.ciRun(w, r, repoName, rest)
return
}
h.ciRuns(w, r, repoName)
}
// treeURL builds the canonical tree URL for a ref+path (ref and path may both
// contain slashes; both live in the path, never a query).
func treeURL(repoName, ref, p string) string {
u := "/" + repoName + "/tree/" + ref
if p != "" {
u += "/" + p
}
return u
}
// authorizeRead gates a browse request on repo-read: a public repo is readable
// by anyone, a private repo needs a signed-in user with an ACL (CanRead). It
// writes the response and returns false on refusal — 404 for an unknown repo, a
// login redirect for an anonymous viewer of a private repo, 403 for a signed-in
// viewer without access. Management routes stay admin-gated (requireAdmin).
func (h *Handler) authorizeRead(w http.ResponseWriter, r *http.Request, repoName string) bool {
// The viewer is optional here: a valid session resolves to its user, else the
// request is anonymous (uid 0), which CanRead allows only for a public repo.
var uid int64
if id, ok := h.sess.verify(r, h.now()); ok {
if u, err := h.md.GetUserByID(r.Context(), id); err == nil {
uid = u.ID
}
}
rp, err := h.md.GetRepo(r.Context(), repoName)
if errors.Is(err, meta.ErrNotFound) {
// Don't reveal a private forge's inventory to anonymous callers: a missing
// repo redirects to login rather than 404ing (a signed-in user gets the 404).
if uid == 0 {
h.denyUnauth(w, r)
return false
}
http.NotFound(w, r)
return false
}
if err != nil {
h.serverError(w, "get repo", err)
return false
}
can, err := h.md.CanRead(r.Context(), rp, uid)
if err != nil {
h.serverError(w, "authorize read", err)
return false
}
if can {
return true
}
if uid == 0 {
h.denyUnauth(w, r) // anonymous → login
return false
}
http.Error(w, "forbidden", http.StatusForbidden)
return false
}
// browseCtx is the resolved starting point every browse handler needs: the repo
// record, its materialized on-disk dir, the selected ref, and the commit SHA
// that ref points at.
type browseCtx struct {
repo *meta.Repo
dir string
ref string
sha string
}
// resolve turns a repo name and an in-path "rest" (ref + path, both possibly
// slashed) into a browseCtx and the remaining path. The ref is resolved greedily
// against the repo's refs — the longest leading prefix of rest that names a real
// branch or tag (branch beats a tag on a tie); an empty rest selects the default
// branch. It writes a 404 for an unknown repo, a rest that names no ref, or a
// ref that fails to resolve (and a 500 for anything else), returning ok=false —
// except a missing *default* branch (empty rest), which redirects to the repo's
// ref list rather than dead-ending on a 404.
func (h *Handler) resolve(w http.ResponseWriter, r *http.Request, repoName, rest string) (browseCtx, string, bool) {
ctx := r.Context()
rp, err := h.md.GetRepo(ctx, repoName)
if errors.Is(err, meta.ErrNotFound) {
http.NotFound(w, r)
return browseCtx{}, "", false
}
if err != nil {
h.serverError(w, "get repo", err)
return browseCtx{}, "", false
}
refs, err := h.md.ListRefs(ctx, rp.ID)
if err != nil {
h.serverError(w, "list refs", err)
return browseCtx{}, "", false
}
ref, treePath, branch, ok := greedyRef(refs, rest, rp.DefaultBranch)
if !ok {
http.NotFound(w, r) // rest names no ref
return browseCtx{}, "", false
}
dir, err := h.mz.Materialize(ctx, repoName)
if err != nil {
h.serverError(w, "materialize repo", err)
return browseCtx{}, "", false
}
// Resolve the fully-qualified ref so a branch beats a same-named tag (git's
// bare-name precedence favors tags) and an annotated tag peels to its commit.
sha, err := repo.ResolveRef(ctx, dir, qualify(ref, branch))
if errors.Is(err, repo.ErrNotFound) {
if strings.Trim(rest, "/") == "" {
// Empty rest selected the configured default branch, but it doesn't
// exist (e.g. default "main" while the repo pushed "master") — there is
// no landing content, so send the visitor to the ref list to pick a real
// branch instead of a dead-end 404.
http.Redirect(w, r, "/"+repoName+"/refs", http.StatusFound)
return browseCtx{}, "", false
}
http.NotFound(w, r)
return browseCtx{}, "", false
}
if err != nil {
h.serverError(w, "resolve ref", err)
return browseCtx{}, "", false
}
return browseCtx{repo: rp, dir: dir, ref: ref, sha: sha}, treePath, true
}
// greedyRef splits rest into a ref name and the remaining path: the longest
// leading prefix of rest that names a real branch or tag. A branch beats a tag
// on the same prefix; a longer prefix beats a shorter one regardless of type. An
// empty rest selects defaultBranch with an empty path. branch reports whether
// the chosen ref is a branch (to qualify it for resolution); ok is false when no
// prefix names a ref.
func greedyRef(refs []meta.Ref, rest, defaultBranch string) (name, treePath string, branch, ok bool) {
branches := make(map[string]bool)
tags := make(map[string]bool)
for _, rf := range refs {
if n, found := strings.CutPrefix(rf.Name, "refs/heads/"); found {
branches[n] = true
} else if n, found := strings.CutPrefix(rf.Name, "refs/tags/"); found {
tags[n] = true
}
}
rest = strings.Trim(rest, "/")
if rest == "" {
return defaultBranch, "", true, true
}
segs := strings.Split(rest, "/")
for i := len(segs); i > 0; i-- {
cand := strings.Join(segs[:i], "/")
tail := strings.Join(segs[i:], "/")
if branches[cand] {
return cand, tail, true, true
}
if tags[cand] {
return cand, tail, false, true
}
}
return "", "", false, false
}
// qualify makes a ref name unambiguous for resolution: refs/heads/<name> for a
// branch, refs/tags/<name> for a tag.
func qualify(name string, branch bool) string {
if branch {
return "refs/heads/" + name
}
return "refs/tags/" + name
}
// base fills the shared browse header (nav identity, repo, selected ref, and the
// ref switcher's options) for c.
func (h *Handler) browseHeader(r *http.Request, c browseCtx) browseBase {
return browseBase{
base: h.base(r, "", ""),
Repo: c.repo.Name,
Ref: c.ref,
Refs: h.refChoices(r, c.repo.ID),
}
}
// refChoices lists the repo's branches and tags (short names) for the switcher.
// A ref-listing failure is non-fatal: the page still renders, just without a
// switcher.
func (h *Handler) refChoices(r *http.Request, repoID int64) []refChoice {
refs, err := h.md.ListRefs(r.Context(), repoID)
if err != nil {
h.log.Error("list refs for switcher", "error", err)
return nil
}
choices := make([]refChoice, 0, len(refs))
for _, ref := range refs {
name := ref.Name
switch {
case strings.HasPrefix(name, "refs/heads/"):
name = strings.TrimPrefix(name, "refs/heads/")
case strings.HasPrefix(name, "refs/tags/"):
name = strings.TrimPrefix(name, "refs/tags/")
}
choices = append(choices, refChoice{Name: name, Ref: name})
}
return choices
}
func (h *Handler) renderTree(w http.ResponseWriter, r *http.Request, c browseCtx, treePath string) {
entries, err := repo.Tree(r.Context(), c.dir, c.sha, treePath)
if errors.Is(err, repo.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
h.serverError(w, "list tree", err)
return
}
readme := h.readme(r, c, treePath, entries)
data := treeData{
browseBase: h.browseHeader(r, c),
Path: treePath,
Crumbs: crumbs(treePath),
Entries: entries,
Readme: readme,
}
data.Mermaid = render.HasMermaid(readme)
h.render(w, "browse_tree.html", data)
}
func (h *Handler) renderBlob(w http.ResponseWriter, r *http.Request, c browseCtx, blobPath string) {
content, size, binary, err := repo.Blob(r.Context(), c.dir, c.sha, blobPath)
if errors.Is(err, repo.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
h.serverError(w, "read blob", err)
return
}
data := blobData{
browseBase: h.browseHeader(r, c),
Path: blobPath,
Crumbs: crumbs(blobPath),
Binary: binary,
Size: size,
}
// Text under the size cap is rendered: markdown for a .md blob, otherwise
// syntax-highlighted source. Anything else (binary, oversize, or a
// highlight error) falls back to the plain <pre> on .Text.
switch {
case binary:
case size > render.MaxSize:
data.Text = string(content)
case render.IsMarkdown(blobPath):
data.Rendered = render.MarkdownLinks(content, h.linkContext(r, c, path.Dir(blobPath)))
data.Mermaid = render.HasMermaid(data.Rendered)
default:
if hl, err := render.Highlight(content, blobPath); err == nil {
data.Highlighted = hl
} else {
data.Text = string(content)
}
}
h.render(w, "browse_blob.html", data)
}
func (h *Handler) renderRaw(w http.ResponseWriter, r *http.Request, c browseCtx, blobPath string) {
// Confirm the blob exists (404 before any bytes are written), then stream
// it — never buffering the whole object.
if _, err := repo.BlobSize(r.Context(), c.dir, c.sha, blobPath); errors.Is(err, repo.ErrNotFound) {
http.NotFound(w, r)
return
} else if err != nil {
h.serverError(w, "stat blob", err)
return
}
rc, err := repo.BlobReader(r.Context(), c.dir, c.sha, blobPath)
if err != nil {
h.serverError(w, "open blob", err)
return
}
defer func() { _ = rc.Close() }()
if ct := mime.TypeByExtension(path.Ext(blobPath)); ct != "" {
w.Header().Set("Content-Type", ct)
} else {
w.Header().Set("Content-Type", "application/octet-stream")
}
w.Header().Set("Content-Disposition", "attachment; filename=\""+path.Base(blobPath)+"\"")
w.Header().Set("X-Content-Type-Options", "nosniff")
if _, err := io.Copy(w, rc); err != nil {
h.log.Error("stream blob", "repo", c.repo.Name, "path", blobPath, "error", err)
}
}
func (h *Handler) renderCommits(w http.ResponseWriter, r *http.Request, c browseCtx, scope string) {
commits, more, err := repo.Log(r.Context(), c.dir, c.sha, scope, logLimit)
if errors.Is(err, repo.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
h.serverError(w, "commit log", err)
return
}
h.render(w, "browse_commits.html", commitsData{
browseBase: h.browseHeader(r, c),
Path: scope,
Crumbs: crumbs(scope),
Commits: commits,
More: more,
})
}
func (h *Handler) renderCommit(w http.ResponseWriter, r *http.Request, c browseCtx, sha string) {
commit, diff, err := repo.Show(r.Context(), c.dir, sha)
if errors.Is(err, repo.ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
h.serverError(w, "show commit", err)
return
}
// Best-effort CI badge: the latest run for this exact commit, if any. A
// lookup failure is non-fatal — the commit still renders, just without a badge.
var run *meta.Run
if got, err := h.md.LatestRunForSHA(r.Context(), c.repo.ID, commit.SHA); err == nil {
run = got
} else if !errors.Is(err, meta.ErrNotFound) {
h.log.Error("latest run for sha", "repo", c.repo.Name, "sha", commit.SHA, "error", err)
}
h.render(w, "browse_commit.html", commitData{
browseBase: h.browseHeader(r, c),
Commit: commit,
Diff: render.Diff(diff),
Run: run,
})
}
// readme renders a directory's README.md (if any) to HTML for display below
// the tree listing, with its relative links resolved against dir (the listed
// directory). It is best-effort: a missing, binary, oversize, or unreadable
// README simply yields no rendered block.
func (h *Handler) readme(r *http.Request, c browseCtx, dir string, entries []repo.TreeEntry) template.HTML {
for _, e := range entries {
if e.Type != "blob" || !render.IsReadme(e.Name) {
continue
}
content, size, binary, err := repo.Blob(r.Context(), c.dir, c.sha, e.Path)
if err != nil || binary || size > render.MaxSize {
return ""
}
return render.MarkdownLinks(content, h.linkContext(r, c, dir))
}
return ""
}
// linkContext builds the markdown link rewriter's context for a file whose
// relative links resolve against dir: the repo, the selected ref, and a Stat
// callback backed by the materialized tree (so a nav link maps to blob vs tree).
func (h *Handler) linkContext(r *http.Request, c browseCtx, dir string) *render.LinkContext {
return &render.LinkContext{
Repo: c.repo.Name,
Ref: c.ref,
Dir: dir,
Stat: func(p string) (string, bool) {
kind, err := repo.EntryType(r.Context(), c.dir, c.sha, p)
if err != nil {
return "", false
}
return kind, true
},
}
}
// crumbs turns a path into its breadcrumb trail, each segment linking to the
// tree at that depth. The empty path yields no crumbs (the repo root link in
// the template stands alone).
func crumbs(p string) []crumb {
p = strings.Trim(p, "/")
if p == "" {
return nil
}
var out []crumb
var acc string
for _, seg := range strings.Split(p, "/") {
if acc == "" {
acc = seg
} else {
acc += "/" + seg
}
out = append(out, crumb{Name: seg, Path: acc})
}
return out
}