krz/gitbay

A CLI-first git forge.

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

main: 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	profile, _ := s.st.OwnerProfile(kind, ownerID)
185
186	all, err := s.st.ListReposForOwner(kind, ownerID)
187	if err != nil {
188		http.Error(w, "internal error", http.StatusInternalServerError)
189		return
190	}
191	var visible []store.Repo
192	for _, repo := range all {
193		grant := ""
194		if viewer.ID != 0 {
195			grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
196		}
197		if policy.CanRead(viewer, repo, grant) {
198			visible = append(visible, repo)
199		}
200	}
201	s.render(w, "owner.html", struct {
202		Site    string
203		Viewer  string
204		Owner   string
205		Kind    string
206		Profile store.Profile
207		Repos   []describedRepo
208		Members []store.OrgMember
209		Orgs    []store.OrgMember
210	}{s.siteName(), viewer.Username, name, kind, profile, s.describeAll(visible), members, orgs})
211}
212
213func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
214	p, ok := s.repoFor(w, r, "")
215	if !ok {
216		return
217	}
218	s.renderTree(w, r, p, "")
219}
220
221func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
222	p, ok := s.repoFor(w, r, r.PathValue("ref"))
223	if !ok {
224		return
225	}
226	s.renderTree(w, r, p, strings.Trim(r.PathValue("path"), "/"))
227}
228
229func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) {
230	if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil {
231		// Empty repo: render the page with no entries rather than 404.
232		s.render(w, "tree.html", struct {
233			repoPage
234			Crumbs     []crumb
235			Prefix     string
236			Entries    []gitutil.TreeEntry
237			ReadmeHTML template.HTML
238		}{repoPage: p})
239		return
240	}
241	entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath)
242	if err != nil {
243		http.NotFound(w, r)
244		return
245	}
246	prefix := ""
247	if dirPath != "" {
248		prefix = dirPath + "/"
249	}
250
251	var readmeHTML template.HTML
252	if name := pickReadme(entries); name != "" {
253		if raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+name, maxRenderBytes); err == nil {
254			readmeHTML = renderReadme(name, raw)
255		}
256	}
257
258	s.render(w, "tree.html", struct {
259		repoPage
260		Crumbs     []crumb
261		Prefix     string
262		Entries    []gitutil.TreeEntry
263		ReadmeHTML template.HTML
264	}{p, crumbs(p, "tree", dirPath), prefix, entries, readmeHTML})
265}
266
267func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
268	p, ok := s.repoFor(w, r, r.PathValue("ref"))
269	if !ok {
270		return
271	}
272	filePath := strings.Trim(r.PathValue("path"), "/")
273	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, maxRenderBytes+1)
274	if err != nil {
275		http.NotFound(w, r)
276		return
277	}
278	binary := gitutil.IsBinary(data) || len(data) > maxRenderBytes
279
280	var codeHTML template.HTML
281	if !binary {
282		codeHTML = highlight(filePath, data)
283	}
284	cs := crumbs(p, "blob", filePath)
285	base := ""
286	if len(cs) > 0 {
287		base = cs[len(cs)-1].Name
288		cs = cs[:len(cs)-1]
289	}
290	s.render(w, "blob.html", struct {
291		repoPage
292		Crumbs   []crumb
293		Base     string
294		Path     string
295		Binary   bool
296		Size     int
297		CodeHTML template.HTML
298	}{p, cs, base, filePath, binary, len(data), codeHTML})
299}
300
301func highlight(filePath string, data []byte) template.HTML {
302	lexer := lexers.Match(filePath)
303	if lexer == nil {
304		lexer = lexers.Fallback
305	}
306	style := styles.Get("friendly")
307	formatter := html.New(html.WithLineNumbers(true), html.LineNumbersInTable(false))
308	iterator, err := lexer.Tokenise(nil, string(data))
309	if err != nil {
310		return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
311	}
312	var buf bytes.Buffer
313	if err := formatter.Format(&buf, style, iterator); err != nil {
314		return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
315	}
316	return template.HTML(buf.String())
317}
318
319func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
320	p, ok := s.repoFor(w, r, r.PathValue("ref"))
321	if !ok {
322		return
323	}
324	filePath := strings.Trim(r.PathValue("path"), "/")
325	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
326	if err != nil {
327		http.NotFound(w, r)
328		return
329	}
330	// Serve inert: never let repo content execute in the forge's origin.
331	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
332	w.Header().Set("X-Content-Type-Options", "nosniff")
333	w.Write(data)
334}
335
336// readmeRank orders competing README files: richer renderers win.
337var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
338
339// pickReadme returns the best README-ish blob in a tree listing: any file
340// named "readme" or "readme.<ext>" (case-insensitive), preferring formats
341// we can render richly.
342func pickReadme(entries []gitutil.TreeEntry) string {
343	best, bestRank := "", 1<<30
344	for _, e := range entries {
345		if e.Type != "blob" {
346			continue
347		}
348		lower := strings.ToLower(e.Name)
349		if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
350			continue
351		}
352		rank, ok := readmeRank[path.Ext(lower)]
353		if !ok {
354			rank = 10 // plaintext fallback
355		}
356		if rank < bestRank {
357			best, bestRank = e.Name, rank
358		}
359	}
360	return best
361}
362
363// mdHTML renders user-authored markdown (issue and MR bodies, comments).
364// goldmark's default renderer drops raw HTML, so this is safe as-is.
365func mdHTML(raw string) template.HTML {
366	if strings.TrimSpace(raw) == "" {
367		return ""
368	}
369	var buf bytes.Buffer
370	if goldmark.Convert([]byte(raw), &buf) != nil {
371		return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
372	}
373	return template.HTML(buf.String())
374}
375
376// renderedComment pairs a comment with its rendered body for templates.
377type renderedComment struct {
378	Author    string
379	CreatedAt string
380	BodyHTML  template.HTML
381}
382
383func renderComments(cs []store.IssueComment) []renderedComment {
384	var out []renderedComment
385	for _, c := range cs {
386		out = append(out, renderedComment{c.Author, c.CreatedAt, mdHTML(c.Body)})
387	}
388	return out
389}
390
391// ugcPolicy sanitizes rendered repo content before it enters the forge's
392// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
393// output and repo-authored HTML are not.
394var ugcPolicy = bluemonday.UGCPolicy()
395
396// renderReadme renders a README by extension: markdown, org-mode, and
397// (sanitized) HTML richly; everything else as escaped plaintext.
398func renderReadme(name string, raw []byte) template.HTML {
399	plain := func() template.HTML {
400		return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
401	}
402	if gitutil.IsBinary(raw) {
403		return ""
404	}
405	switch path.Ext(strings.ToLower(name)) {
406	case ".md", ".markdown":
407		var buf bytes.Buffer
408		if goldmark.Convert(raw, &buf) != nil {
409			return plain()
410		}
411		return template.HTML(buf.String())
412	case ".org":
413		doc := org.New().Parse(bytes.NewReader(raw), name)
414		html, err := doc.Write(org.NewHTMLWriter())
415		if err != nil {
416			return plain()
417		}
418		return template.HTML(ugcPolicy.Sanitize(html))
419	case ".html", ".htm":
420		return template.HTML(ugcPolicy.Sanitize(string(raw)))
421	default:
422		return plain()
423	}
424}
425
426type diffLine struct {
427	Class string
428	Text  string
429}
430
431func classifyDiff(patch string) []diffLine {
432	var lines []diffLine
433	for _, l := range strings.Split(patch, "\n") {
434		class := ""
435		switch {
436		case strings.HasPrefix(l, "+++"), strings.HasPrefix(l, "---"), strings.HasPrefix(l, "diff "), strings.HasPrefix(l, "index "):
437			class = "meta"
438		case strings.HasPrefix(l, "@@"):
439			class = "hunk"
440		case strings.HasPrefix(l, "+"):
441			class = "add"
442		case strings.HasPrefix(l, "-"):
443			class = "del"
444		}
445		lines = append(lines, diffLine{class, l})
446	}
447	return lines
448}
449
450type sigView struct {
451	State       string
452	Signer      string
453	Fingerprint string
454}
455
456func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
457	raw, err := gitutil.ReadCommit(dir, sha)
458	if err != nil {
459		return sigView{State: "unsigned"}, nil
460	}
461	parsed, err := sig.ParseCommit(raw)
462	if err != nil {
463		return sigView{State: "unsigned"}, nil
464	}
465	res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
466	if err != nil {
467		return sigView{State: "unsigned"}, parsed
468	}
469	v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
470	if res.SignerUserID != 0 {
471		if u, err := s.st.UserByID(res.SignerUserID); err == nil {
472			v.Signer = u.Username
473		}
474	}
475	return v, parsed
476}
477
478func (s *Server) log(w http.ResponseWriter, r *http.Request) {
479	ref := r.PathValue("ref")
480	p, ok := s.repoFor(w, r, ref)
481	if !ok {
482		return
483	}
484	const pageSize = 50
485	shas, err := gitutil.RevList(p.Dir, p.Ref, pageSize+1)
486	if err != nil {
487		http.NotFound(w, r)
488		return
489	}
490	next := ""
491	if len(shas) > pageSize {
492		next = shas[pageSize]
493		shas = shas[:pageSize]
494	}
495	type row struct {
496		SHA, ShortSHA, Subject, AuthorName, AuthorEmail, Date string
497		Sig                                                   sigView
498	}
499	var rows []row
500	for _, sha := range shas {
501		v, parsed := s.sigFor(p.Repo, p.Dir, sha)
502		rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v}
503		if parsed != nil {
504			rw.Subject = parsed.Subject
505			rw.AuthorName = parsed.AuthorName
506			rw.AuthorEmail = parsed.AuthorEmail
507			rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
508		}
509		rows = append(rows, rw)
510	}
511	s.render(w, "log.html", struct {
512		repoPage
513		Commits []row
514		NextSHA string
515	}{p, rows, next})
516}
517
518func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
519	p, ok := s.repoFor(w, r, "")
520	if !ok {
521		return
522	}
523	sha := r.PathValue("sha")
524	full, err := gitutil.ResolveRef(p.Dir, sha)
525	if err != nil {
526		http.NotFound(w, r)
527		return
528	}
529	v, parsed := s.sigFor(p.Repo, p.Dir, full)
530	if parsed == nil {
531		http.NotFound(w, r)
532		return
533	}
534	patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
535	lines := classifyDiff(patch)
536	committerEmail := ""
537	if parsed.CommitterEmail != parsed.AuthorEmail {
538		committerEmail = parsed.CommitterEmail
539	}
540	msg := ""
541	if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
542		msg = string(parsed.Payload[i+2:])
543	}
544	s.render(w, "commit.html", struct {
545		repoPage
546		SHA, ShortSHA, AuthorName, AuthorEmail, CommitterEmail, Date, Message string
547		Sig                                                                   sigView
548		DiffLines                                                             []diffLine
549	}{p, full, full[:10], parsed.AuthorName, parsed.AuthorEmail, committerEmail,
550		time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, v, lines})
551}
552
553func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
554	p, ok := s.repoFor(w, r, "")
555	if !ok {
556		return
557	}
558	state := r.URL.Query().Get("state")
559	if state != "closed" && state != "all" {
560		state = "open"
561	}
562	issues, err := s.st.ListIssues(p.Repo.ID, state)
563	if err != nil {
564		http.Error(w, "internal error", http.StatusInternalServerError)
565		return
566	}
567	s.render(w, "issues.html", struct {
568		repoPage
569		State  string
570		Issues []store.Issue
571	}{p, state, issues})
572}
573
574func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
575	p, ok := s.repoFor(w, r, "")
576	if !ok {
577		return
578	}
579	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
580	if err != nil {
581		http.NotFound(w, r)
582		return
583	}
584	iss, err := s.st.IssueByNumber(p.Repo.ID, n)
585	if err != nil {
586		http.NotFound(w, r)
587		return
588	}
589	comments, err := s.st.ListIssueComments(iss.ID)
590	if err != nil {
591		http.Error(w, "internal error", http.StatusInternalServerError)
592		return
593	}
594	s.render(w, "issue.html", struct {
595		repoPage
596		Issue    store.Issue
597		BodyHTML template.HTML
598		Comments []renderedComment
599	}{p, iss, mdHTML(iss.Body), renderComments(comments)})
600}
601
602func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
603	p, ok := s.repoFor(w, r, "")
604	if !ok {
605		return
606	}
607	state := r.URL.Query().Get("state")
608	if state == "" {
609		state = "open"
610	}
611	valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
612	if !valid[state] {
613		state = "open"
614	}
615	mrs, err := s.st.ListMRs(p.Repo.ID, state)
616	if err != nil {
617		http.Error(w, "internal error", http.StatusInternalServerError)
618		return
619	}
620	s.render(w, "mrs.html", struct {
621		repoPage
622		State string
623		MRs   []store.MR
624	}{p, state, mrs})
625}
626
627func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
628	p, ok := s.repoFor(w, r, "")
629	if !ok {
630		return
631	}
632	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
633	if err != nil {
634		http.NotFound(w, r)
635		return
636	}
637	m, err := s.st.MRByNumber(p.Repo.ID, n)
638	if err != nil {
639		http.NotFound(w, r)
640		return
641	}
642	comments, _ := s.st.ListMRComments(m.ID)
643	reviews, _ := s.st.ListMRReviews(m.ID)
644
645	headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
646	var lines []diffLine
647	base := m.MergedBase
648	if base == "" {
649		if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
650			base = b
651		}
652	}
653	if base != "" {
654		if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
655			lines = classifyDiff(patch)
656		}
657	}
658	s.render(w, "mr.html", struct {
659		repoPage
660		MR        store.MR
661		BodyHTML  template.HTML
662		Comments  []renderedComment
663		Reviews   []store.MRReview
664		DiffLines []diffLine
665	}{p, m, mdHTML(m.Body), renderComments(comments), reviews, lines})
666}
667
668func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
669	p, ok := s.repoFor(w, r, "")
670	if !ok {
671		return
672	}
673	branches, _ := gitutil.Refs(p.Dir, "heads")
674	tags, _ := gitutil.Refs(p.Dir, "tags")
675	s.render(w, "refs.html", struct {
676		repoPage
677		Branches, Tags []gitutil.Ref
678	}{p, branches, tags})
679}
680
681func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
682	p, ok := s.repoFor(w, r, "")
683	if !ok {
684		return
685	}
686	file := r.PathValue("file")
687	ref, ok := strings.CutSuffix(file, ".tar.gz")
688	if !ok {
689		http.NotFound(w, r)
690		return
691	}
692	if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
693		http.NotFound(w, r)
694		return
695	}
696	prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
697	w.Header().Set("Content-Type", "application/gzip")
698	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
699	gitutil.Archive(p.Dir, ref, prefix, w)
700}
701
702func policyCanRead(u store.User, repo store.Repo, grant string) bool {
703	return policy.CanRead(u, repo, grant)
704}