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