krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

repo-descriptions: internal/httpd/web.go · raw

  1package httpd
  2
  3import (
  4	"bytes"
  5	"fmt"
  6
  7	"gitbay.org/gitbay/internal/policy"
  8	"html/template"
  9	"net/http"
 10	"path"
 11	"strconv"
 12	"strings"
 13	"time"
 14
 15	"github.com/alecthomas/chroma/v2/formatters/html"
 16	"github.com/alecthomas/chroma/v2/lexers"
 17	"github.com/alecthomas/chroma/v2/styles"
 18	"github.com/microcosm-cc/bluemonday"
 19	"github.com/niklasfasching/go-org/org"
 20	"github.com/yuin/goldmark"
 21
 22	"gitbay.org/gitbay/internal/control"
 23	"gitbay.org/gitbay/internal/gitutil"
 24	"gitbay.org/gitbay/internal/sig"
 25	"gitbay.org/gitbay/internal/store"
 26	"gitbay.org/gitbay/internal/web"
 27)
 28
 29const maxRenderBytes = 1 << 20 // largest blob rendered inline
 30
 31func (s *Server) render(w http.ResponseWriter, page string, data any) {
 32	var buf bytes.Buffer
 33	if err := web.Render(&buf, page, data); err != nil {
 34		http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
 35		return
 36	}
 37	w.Header().Set("Content-Type", "text/html; charset=utf-8")
 38	buf.WriteTo(w)
 39}
 40
 41func (s *Server) siteName() string {
 42	h := strings.TrimPrefix(strings.TrimPrefix(s.cfg.Server.SiteURL, "https://"), "http://")
 43	return strings.TrimSuffix(h, "/")
 44}
 45
 46func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) {
 47	w.Header().Set("Content-Type", "text/css; charset=utf-8")
 48	w.Write(web.StyleCSS)
 49}
 50
 51// describedRepo pairs a repo with its description for listings.
 52type describedRepo struct {
 53	store.Repo
 54	Desc string
 55}
 56
 57func (s *Server) describeAll(repos []store.Repo) []describedRepo {
 58	var out []describedRepo
 59	for _, r := range repos {
 60		out = append(out, describedRepo{r, gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, r.OwnerName, r.Name))})
 61	}
 62	return out
 63}
 64
 65func (s *Server) index(w http.ResponseWriter, r *http.Request) {
 66	repos, err := s.st.ListPublicRepos()
 67	if err != nil {
 68		http.Error(w, "internal error", http.StatusInternalServerError)
 69		return
 70	}
 71	var viewer store.User
 72	var mine []store.Repo
 73	if s.cfg.Web.Mode == "accounts" {
 74		if viewer = s.viewer(r); viewer.ID != 0 {
 75			all, err := s.st.ListReposForUser(viewer.ID)
 76			if err == nil {
 77				for _, rp := range all {
 78					if rp.Visibility == "private" {
 79						mine = append(mine, rp)
 80					}
 81				}
 82			}
 83		}
 84	}
 85	s.render(w, "index.html", struct {
 86		Site   string
 87		Viewer string
 88		Repos  []describedRepo
 89		Mine   []describedRepo
 90	}{s.siteName(), viewer.Username, s.describeAll(repos), s.describeAll(mine)})
 91}
 92
 93// repoPage is the shared context for repo-scoped pages.
 94type repoPage struct {
 95	Site     string
 96	Viewer   string
 97	Desc     string
 98	Repo     store.Repo
 99	Ref      string
100	CloneURL string
101	Dir      string
102}
103
104// repoFor resolves the repo for a web request; false means 404 was sent.
105// Anonymous visitors see public repos only; in accounts mode a logged-in
106// viewer additionally sees repos their grants allow. Private and missing
107// repos are indistinguishable either way.
108func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (repoPage, bool) {
109	var repo store.Repo
110	var viewer store.User
111	if s.cfg.Web.Mode == "accounts" {
112		viewer = s.viewer(r)
113	}
114	repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
115	ok := err == nil
116	if ok {
117		grant := ""
118		if viewer.ID != 0 {
119			grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
120		}
121		ok = policyCanRead(viewer, repo, grant)
122	}
123	if !ok {
124		http.NotFound(w, r)
125		return repoPage{}, false
126	}
127	if ref == "" {
128		ref = repo.DefaultBranch
129	}
130	return repoPage{
131		Site:     s.siteName(),
132		Viewer:   viewer.Username,
133		Desc:     gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)),
134		Repo:     repo,
135		Ref:      ref,
136		CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git",
137		Dir:      control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name),
138	}, true
139}
140
141type crumb struct {
142	Name string
143	URL  string
144}
145
146func crumbs(p repoPage, kind, filePath string) []crumb {
147	var cs []crumb
148	base := "/" + p.Repo.Path() + "/" + kind + "/" + p.Ref + "/"
149	acc := ""
150	for _, part := range strings.Split(filePath, "/") {
151		if part == "" {
152			continue
153		}
154		acc = path.Join(acc, part)
155		cs = append(cs, crumb{Name: part, URL: base + acc})
156	}
157	return cs
158}
159
160// ownerPage renders /{owner} for users and orgs: the repositories the
161// viewer may see, org membership either direction. Owner names are not
162// secret (they are on every commit); repository visibility rules hold.
163func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
164	name := r.PathValue("owner")
165	var viewer store.User
166	if s.cfg.Web.Mode == "accounts" {
167		viewer = s.viewer(r)
168	}
169
170	kind := "user"
171	var ownerID int64
172	var members []store.OrgMember
173	var orgs []store.OrgMember
174	if u, err := s.st.UserByUsername(name); err == nil {
175		ownerID = u.ID
176		orgs, _ = s.st.ListOrgsForUser(u.ID)
177	} else if o, err := s.st.OrgByName(name); err == nil {
178		kind, ownerID = "org", o.ID
179		members, _ = s.st.OrgMembers(o.ID)
180	} else {
181		http.NotFound(w, r)
182		return
183	}
184
185	all, err := s.st.ListReposForOwner(kind, ownerID)
186	if err != nil {
187		http.Error(w, "internal error", http.StatusInternalServerError)
188		return
189	}
190	var visible []store.Repo
191	for _, repo := range all {
192		grant := ""
193		if viewer.ID != 0 {
194			grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
195		}
196		if policy.CanRead(viewer, repo, grant) {
197			visible = append(visible, repo)
198		}
199	}
200	s.render(w, "owner.html", struct {
201		Site    string
202		Viewer  string
203		Owner   string
204		Kind    string
205		Repos   []describedRepo
206		Members []store.OrgMember
207		Orgs    []store.OrgMember
208	}{s.siteName(), viewer.Username, name, kind, s.describeAll(visible), members, orgs})
209}
210
211func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
212	p, ok := s.repoFor(w, r, "")
213	if !ok {
214		return
215	}
216	s.renderTree(w, r, p, "")
217}
218
219func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
220	p, ok := s.repoFor(w, r, r.PathValue("ref"))
221	if !ok {
222		return
223	}
224	s.renderTree(w, r, p, strings.Trim(r.PathValue("path"), "/"))
225}
226
227func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) {
228	if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil {
229		// Empty repo: render the page with no entries rather than 404.
230		s.render(w, "tree.html", struct {
231			repoPage
232			Crumbs     []crumb
233			Prefix     string
234			Entries    []gitutil.TreeEntry
235			ReadmeHTML template.HTML
236		}{repoPage: p})
237		return
238	}
239	entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath)
240	if err != nil {
241		http.NotFound(w, r)
242		return
243	}
244	prefix := ""
245	if dirPath != "" {
246		prefix = dirPath + "/"
247	}
248
249	var readmeHTML template.HTML
250	if name := pickReadme(entries); name != "" {
251		if raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+name, maxRenderBytes); err == nil {
252			readmeHTML = renderReadme(name, raw)
253		}
254	}
255
256	s.render(w, "tree.html", struct {
257		repoPage
258		Crumbs     []crumb
259		Prefix     string
260		Entries    []gitutil.TreeEntry
261		ReadmeHTML template.HTML
262	}{p, crumbs(p, "tree", dirPath), prefix, entries, readmeHTML})
263}
264
265func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
266	p, ok := s.repoFor(w, r, r.PathValue("ref"))
267	if !ok {
268		return
269	}
270	filePath := strings.Trim(r.PathValue("path"), "/")
271	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, maxRenderBytes+1)
272	if err != nil {
273		http.NotFound(w, r)
274		return
275	}
276	binary := gitutil.IsBinary(data) || len(data) > maxRenderBytes
277
278	var codeHTML template.HTML
279	if !binary {
280		codeHTML = highlight(filePath, data)
281	}
282	cs := crumbs(p, "blob", filePath)
283	base := ""
284	if len(cs) > 0 {
285		base = cs[len(cs)-1].Name
286		cs = cs[:len(cs)-1]
287	}
288	s.render(w, "blob.html", struct {
289		repoPage
290		Crumbs   []crumb
291		Base     string
292		Path     string
293		Binary   bool
294		Size     int
295		CodeHTML template.HTML
296	}{p, cs, base, filePath, binary, len(data), codeHTML})
297}
298
299func highlight(filePath string, data []byte) template.HTML {
300	lexer := lexers.Match(filePath)
301	if lexer == nil {
302		lexer = lexers.Fallback
303	}
304	style := styles.Get("friendly")
305	formatter := html.New(html.WithLineNumbers(true), html.LineNumbersInTable(false))
306	iterator, err := lexer.Tokenise(nil, string(data))
307	if err != nil {
308		return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
309	}
310	var buf bytes.Buffer
311	if err := formatter.Format(&buf, style, iterator); err != nil {
312		return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
313	}
314	return template.HTML(buf.String())
315}
316
317func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
318	p, ok := s.repoFor(w, r, r.PathValue("ref"))
319	if !ok {
320		return
321	}
322	filePath := strings.Trim(r.PathValue("path"), "/")
323	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
324	if err != nil {
325		http.NotFound(w, r)
326		return
327	}
328	// Serve inert: never let repo content execute in the forge's origin.
329	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
330	w.Header().Set("X-Content-Type-Options", "nosniff")
331	w.Write(data)
332}
333
334// readmeRank orders competing README files: richer renderers win.
335var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
336
337// pickReadme returns the best README-ish blob in a tree listing: any file
338// named "readme" or "readme.<ext>" (case-insensitive), preferring formats
339// we can render richly.
340func pickReadme(entries []gitutil.TreeEntry) string {
341	best, bestRank := "", 1<<30
342	for _, e := range entries {
343		if e.Type != "blob" {
344			continue
345		}
346		lower := strings.ToLower(e.Name)
347		if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
348			continue
349		}
350		rank, ok := readmeRank[path.Ext(lower)]
351		if !ok {
352			rank = 10 // plaintext fallback
353		}
354		if rank < bestRank {
355			best, bestRank = e.Name, rank
356		}
357	}
358	return best
359}
360
361// mdHTML renders user-authored markdown (issue and MR bodies, comments).
362// goldmark's default renderer drops raw HTML, so this is safe as-is.
363func mdHTML(raw string) template.HTML {
364	if strings.TrimSpace(raw) == "" {
365		return ""
366	}
367	var buf bytes.Buffer
368	if goldmark.Convert([]byte(raw), &buf) != nil {
369		return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
370	}
371	return template.HTML(buf.String())
372}
373
374// renderedComment pairs a comment with its rendered body for templates.
375type renderedComment struct {
376	Author    string
377	CreatedAt string
378	BodyHTML  template.HTML
379}
380
381func renderComments(cs []store.IssueComment) []renderedComment {
382	var out []renderedComment
383	for _, c := range cs {
384		out = append(out, renderedComment{c.Author, c.CreatedAt, mdHTML(c.Body)})
385	}
386	return out
387}
388
389// ugcPolicy sanitizes rendered repo content before it enters the forge's
390// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
391// output and repo-authored HTML are not.
392var ugcPolicy = bluemonday.UGCPolicy()
393
394// renderReadme renders a README by extension: markdown, org-mode, and
395// (sanitized) HTML richly; everything else as escaped plaintext.
396func renderReadme(name string, raw []byte) template.HTML {
397	plain := func() template.HTML {
398		return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
399	}
400	if gitutil.IsBinary(raw) {
401		return ""
402	}
403	switch path.Ext(strings.ToLower(name)) {
404	case ".md", ".markdown":
405		var buf bytes.Buffer
406		if goldmark.Convert(raw, &buf) != nil {
407			return plain()
408		}
409		return template.HTML(buf.String())
410	case ".org":
411		doc := org.New().Parse(bytes.NewReader(raw), name)
412		html, err := doc.Write(org.NewHTMLWriter())
413		if err != nil {
414			return plain()
415		}
416		return template.HTML(ugcPolicy.Sanitize(html))
417	case ".html", ".htm":
418		return template.HTML(ugcPolicy.Sanitize(string(raw)))
419	default:
420		return plain()
421	}
422}
423
424type diffLine struct {
425	Class string
426	Text  string
427}
428
429func classifyDiff(patch string) []diffLine {
430	var lines []diffLine
431	for _, l := range strings.Split(patch, "\n") {
432		class := ""
433		switch {
434		case strings.HasPrefix(l, "+++"), strings.HasPrefix(l, "---"), strings.HasPrefix(l, "diff "), strings.HasPrefix(l, "index "):
435			class = "meta"
436		case strings.HasPrefix(l, "@@"):
437			class = "hunk"
438		case strings.HasPrefix(l, "+"):
439			class = "add"
440		case strings.HasPrefix(l, "-"):
441			class = "del"
442		}
443		lines = append(lines, diffLine{class, l})
444	}
445	return lines
446}
447
448type sigView struct {
449	State       string
450	Signer      string
451	Fingerprint string
452}
453
454func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
455	raw, err := gitutil.ReadCommit(dir, sha)
456	if err != nil {
457		return sigView{State: "unsigned"}, nil
458	}
459	parsed, err := sig.ParseCommit(raw)
460	if err != nil {
461		return sigView{State: "unsigned"}, nil
462	}
463	res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
464	if err != nil {
465		return sigView{State: "unsigned"}, parsed
466	}
467	v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
468	if res.SignerUserID != 0 {
469		if u, err := s.st.UserByID(res.SignerUserID); err == nil {
470			v.Signer = u.Username
471		}
472	}
473	return v, parsed
474}
475
476func (s *Server) log(w http.ResponseWriter, r *http.Request) {
477	ref := r.PathValue("ref")
478	p, ok := s.repoFor(w, r, ref)
479	if !ok {
480		return
481	}
482	const pageSize = 50
483	shas, err := gitutil.RevList(p.Dir, p.Ref, pageSize+1)
484	if err != nil {
485		http.NotFound(w, r)
486		return
487	}
488	next := ""
489	if len(shas) > pageSize {
490		next = shas[pageSize]
491		shas = shas[:pageSize]
492	}
493	type row struct {
494		SHA, ShortSHA, Subject, AuthorName, AuthorEmail, Date string
495		Sig                                                   sigView
496	}
497	var rows []row
498	for _, sha := range shas {
499		v, parsed := s.sigFor(p.Repo, p.Dir, sha)
500		rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v}
501		if parsed != nil {
502			rw.Subject = parsed.Subject
503			rw.AuthorName = parsed.AuthorName
504			rw.AuthorEmail = parsed.AuthorEmail
505			rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
506		}
507		rows = append(rows, rw)
508	}
509	s.render(w, "log.html", struct {
510		repoPage
511		Commits []row
512		NextSHA string
513	}{p, rows, next})
514}
515
516func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
517	p, ok := s.repoFor(w, r, "")
518	if !ok {
519		return
520	}
521	sha := r.PathValue("sha")
522	full, err := gitutil.ResolveRef(p.Dir, sha)
523	if err != nil {
524		http.NotFound(w, r)
525		return
526	}
527	v, parsed := s.sigFor(p.Repo, p.Dir, full)
528	if parsed == nil {
529		http.NotFound(w, r)
530		return
531	}
532	patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
533	lines := classifyDiff(patch)
534	committerEmail := ""
535	if parsed.CommitterEmail != parsed.AuthorEmail {
536		committerEmail = parsed.CommitterEmail
537	}
538	msg := ""
539	if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
540		msg = string(parsed.Payload[i+2:])
541	}
542	s.render(w, "commit.html", struct {
543		repoPage
544		SHA, ShortSHA, AuthorName, AuthorEmail, CommitterEmail, Date, Message string
545		Sig                                                                   sigView
546		DiffLines                                                             []diffLine
547	}{p, full, full[:10], parsed.AuthorName, parsed.AuthorEmail, committerEmail,
548		time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, v, lines})
549}
550
551func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
552	p, ok := s.repoFor(w, r, "")
553	if !ok {
554		return
555	}
556	state := r.URL.Query().Get("state")
557	if state != "closed" && state != "all" {
558		state = "open"
559	}
560	issues, err := s.st.ListIssues(p.Repo.ID, state)
561	if err != nil {
562		http.Error(w, "internal error", http.StatusInternalServerError)
563		return
564	}
565	s.render(w, "issues.html", struct {
566		repoPage
567		State  string
568		Issues []store.Issue
569	}{p, state, issues})
570}
571
572func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
573	p, ok := s.repoFor(w, r, "")
574	if !ok {
575		return
576	}
577	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
578	if err != nil {
579		http.NotFound(w, r)
580		return
581	}
582	iss, err := s.st.IssueByNumber(p.Repo.ID, n)
583	if err != nil {
584		http.NotFound(w, r)
585		return
586	}
587	comments, err := s.st.ListIssueComments(iss.ID)
588	if err != nil {
589		http.Error(w, "internal error", http.StatusInternalServerError)
590		return
591	}
592	s.render(w, "issue.html", struct {
593		repoPage
594		Issue    store.Issue
595		BodyHTML template.HTML
596		Comments []renderedComment
597	}{p, iss, mdHTML(iss.Body), renderComments(comments)})
598}
599
600func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
601	p, ok := s.repoFor(w, r, "")
602	if !ok {
603		return
604	}
605	state := r.URL.Query().Get("state")
606	if state == "" {
607		state = "open"
608	}
609	valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
610	if !valid[state] {
611		state = "open"
612	}
613	mrs, err := s.st.ListMRs(p.Repo.ID, state)
614	if err != nil {
615		http.Error(w, "internal error", http.StatusInternalServerError)
616		return
617	}
618	s.render(w, "mrs.html", struct {
619		repoPage
620		State string
621		MRs   []store.MR
622	}{p, state, mrs})
623}
624
625func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
626	p, ok := s.repoFor(w, r, "")
627	if !ok {
628		return
629	}
630	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
631	if err != nil {
632		http.NotFound(w, r)
633		return
634	}
635	m, err := s.st.MRByNumber(p.Repo.ID, n)
636	if err != nil {
637		http.NotFound(w, r)
638		return
639	}
640	comments, _ := s.st.ListMRComments(m.ID)
641	reviews, _ := s.st.ListMRReviews(m.ID)
642
643	headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
644	var lines []diffLine
645	if base, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
646		if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
647			lines = classifyDiff(patch)
648		}
649	}
650	s.render(w, "mr.html", struct {
651		repoPage
652		MR        store.MR
653		BodyHTML  template.HTML
654		Comments  []renderedComment
655		Reviews   []store.MRReview
656		DiffLines []diffLine
657	}{p, m, mdHTML(m.Body), renderComments(comments), reviews, lines})
658}
659
660func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
661	p, ok := s.repoFor(w, r, "")
662	if !ok {
663		return
664	}
665	branches, _ := gitutil.Refs(p.Dir, "heads")
666	tags, _ := gitutil.Refs(p.Dir, "tags")
667	s.render(w, "refs.html", struct {
668		repoPage
669		Branches, Tags []gitutil.Ref
670	}{p, branches, tags})
671}
672
673func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
674	p, ok := s.repoFor(w, r, "")
675	if !ok {
676		return
677	}
678	file := r.PathValue("file")
679	ref, ok := strings.CutSuffix(file, ".tar.gz")
680	if !ok {
681		http.NotFound(w, r)
682		return
683	}
684	if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
685		http.NotFound(w, r)
686		return
687	}
688	prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
689	w.Header().Set("Content-Type", "application/gzip")
690	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
691	gitutil.Archive(p.Dir, ref, prefix, w)
692}
693
694func policyCanRead(u store.User, repo store.Repo, grant string) bool {
695	return policy.CanRead(u, repo, grant)
696}