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