internal/httpd/web.go

4485496a075180586eab7210ec46a6b6f4285770
gitbay/internal/httpd/web.go history · blame · raw

680 lines · 17935 bytes

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