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