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