internal/httpd/web.go
1005 lines · 27690 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
332// blamePageSize caps how many lines one blame page renders; blame is a
333// per-line subprocess cost, so large files paginate.
334const blamePageSize = 1000
335
336func (s *Server) blame(w http.ResponseWriter, r *http.Request) {
337 p, ok := s.repoFor(w, r, r.PathValue("ref"))
338 if !ok {
339 return
340 }
341 p.Tab = "files"
342 filePath := strings.Trim(r.PathValue("path"), "/")
343 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
344 if err != nil {
345 s.notFound(w, r)
346 return
347 }
348 total := bytes.Count(data, []byte("\n"))
349 if len(data) > 0 && !bytes.HasSuffix(data, []byte("\n")) {
350 total++
351 }
352 binary := gitutil.IsBinary(data)
353
354 type hunkView struct {
355 gitutil.BlameHunk
356 ShortSHA string
357 Date string
358 Sig sigView
359 Numbered []numberedLine
360 }
361 var hunks []hunkView
362 page, pages := 1, (total+blamePageSize-1)/blamePageSize
363 if pages == 0 {
364 pages = 1
365 }
366 if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n >= 1 && n <= pages {
367 page = n
368 }
369 if !binary && total > 0 {
370 start := (page-1)*blamePageSize + 1
371 end := min(total, page*blamePageSize)
372 raw, err := gitutil.Blame(p.Dir, p.Ref, filePath, start, end)
373 if err != nil {
374 s.notFound(w, r)
375 return
376 }
377 sigs := map[string]sigView{}
378 for _, h := range raw {
379 v, ok := sigs[h.SHA]
380 if !ok {
381 v, _ = s.sigFor(p.Repo, p.Dir, h.SHA)
382 sigs[h.SHA] = v
383 }
384 hv := hunkView{BlameHunk: h, ShortSHA: h.SHA[:10],
385 Date: time.Unix(h.AuthorUnix, 0).UTC().Format("2006-01-02"), Sig: v}
386 for i, l := range h.Lines {
387 hv.Numbered = append(hv.Numbered, numberedLine{h.StartLine + i, l})
388 }
389 hunks = append(hunks, hv)
390 }
391 }
392 cs := crumbs(p, "blame", filePath)
393 base := ""
394 if len(cs) > 0 {
395 base = cs[len(cs)-1].Name
396 cs = cs[:len(cs)-1]
397 }
398 s.render(w, "blame.html", struct {
399 repoPage
400 Crumbs []crumb
401 Base string
402 Path string
403 Binary bool
404 Hunks []hunkView
405 Page, Pages int
406 }{p, cs, base, filePath, binary, hunks, page, pages})
407}
408
409type numberedLine struct {
410 N int
411 Text string
412}
413
414func highlight(filePath string, data []byte) template.HTML {
415 lexer := lexers.Match(filePath)
416 if lexer == nil {
417 lexer = lexers.Fallback
418 }
419 style := styles.Get("friendly")
420 formatter := html.New(html.WithLineNumbers(true), html.LineNumbersInTable(false))
421 iterator, err := lexer.Tokenise(nil, string(data))
422 if err != nil {
423 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
424 }
425 var buf bytes.Buffer
426 if err := formatter.Format(&buf, style, iterator); err != nil {
427 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
428 }
429 return template.HTML(buf.String())
430}
431
432func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
433 p, ok := s.repoFor(w, r, r.PathValue("ref"))
434 if !ok {
435 return
436 }
437 filePath := strings.Trim(r.PathValue("path"), "/")
438 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
439 if err != nil {
440 s.notFound(w, r)
441 return
442 }
443 // Serve inert: never let repo content execute in the forge's origin.
444 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
445 w.Header().Set("X-Content-Type-Options", "nosniff")
446 w.Write(data)
447}
448
449// readmeRank orders competing README files: richer renderers win.
450var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
451
452// pickReadme returns the best README-ish blob in a tree listing: any file
453// named "readme" or "readme.<ext>" (case-insensitive), preferring formats
454// we can render richly.
455func pickReadme(entries []gitutil.TreeEntry) string {
456 best, bestRank := "", 1<<30
457 for _, e := range entries {
458 if e.Type != "blob" {
459 continue
460 }
461 lower := strings.ToLower(e.Name)
462 if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
463 continue
464 }
465 rank, ok := readmeRank[path.Ext(lower)]
466 if !ok {
467 rank = 10 // plaintext fallback
468 }
469 if rank < bestRank {
470 best, bestRank = e.Name, rank
471 }
472 }
473 return best
474}
475
476// mdHTML renders user-authored markdown (issue and MR bodies, comments).
477// goldmark's default renderer drops raw HTML, so this is safe as-is.
478func mdHTML(raw string) template.HTML {
479 if strings.TrimSpace(raw) == "" {
480 return ""
481 }
482 var buf bytes.Buffer
483 if goldmark.Convert([]byte(raw), &buf) != nil {
484 return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
485 }
486 return template.HTML(buf.String())
487}
488
489// webResolver answers autolink lookups for one viewer. Cross-repo
490// references to repositories the viewer cannot read stay plain text, per
491// the enumeration rule: a link would confirm the repo exists.
492type webResolver struct {
493 s *Server
494 viewer store.User
495}
496
497func (r webResolver) RefURL(owner, name string, kind byte, n int64) string {
498 repo, err := r.s.st.RepoByPath(owner + "/" + name)
499 if err != nil {
500 return ""
501 }
502 grant := ""
503 if r.viewer.ID != 0 {
504 grant, _ = r.s.st.AccessRole(repo.ID, r.viewer.ID)
505 }
506 if !policy.CanRead(r.viewer, repo, grant) {
507 return ""
508 }
509 if kind == '#' {
510 if _, err := r.s.st.IssueByNumber(repo.ID, n); err != nil {
511 return ""
512 }
513 return autolink.IssueURL(repo.OwnerName, repo.Name, n)
514 }
515 if _, err := r.s.st.MRByNumber(repo.ID, n); err != nil {
516 return ""
517 }
518 return autolink.MRURL(repo.OwnerName, repo.Name, n)
519}
520
521func (r webResolver) UserURL(name string) string {
522 if _, err := r.s.st.UserByUsername(name); err == nil {
523 return "/" + name
524 }
525 if _, err := r.s.st.OrgByName(name); err == nil {
526 return "/" + name
527 }
528 return ""
529}
530
531// ugcFor returns a renderer for user-authored markdown on one repo's pages:
532// mdHTML plus cross-reference and mention autolinking for this viewer.
533func (s *Server) ugcFor(r *http.Request, repo store.Repo) func(string) template.HTML {
534 viewer := store.User{}
535 if s.cfg.Web.Mode == "accounts" {
536 viewer = s.viewer(r)
537 }
538 res := webResolver{s, viewer}
539 return func(raw string) template.HTML {
540 h := mdHTML(raw)
541 if h == "" {
542 return h
543 }
544 return template.HTML(autolink.Rewrite(string(h), repo.OwnerName, repo.Name, res))
545 }
546}
547
548// renderedComment pairs a comment with its rendered body for templates.
549type renderedComment struct {
550 Author string
551 CreatedAt string
552 BodyHTML template.HTML
553}
554
555func renderComments(cs []store.IssueComment, md func(string) template.HTML) []renderedComment {
556 var out []renderedComment
557 for _, c := range cs {
558 out = append(out, renderedComment{c.Author, c.CreatedAt, md(c.Body)})
559 }
560 return out
561}
562
563// ugcPolicy sanitizes rendered repo content before it enters the forge's
564// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
565// output and repo-authored HTML are not.
566var ugcPolicy = bluemonday.UGCPolicy()
567
568// renderReadme renders a README by extension: markdown, org-mode, and
569// (sanitized) HTML richly; everything else as escaped plaintext.
570func renderReadme(name string, raw []byte) template.HTML {
571 plain := func() template.HTML {
572 return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
573 }
574 if gitutil.IsBinary(raw) {
575 return ""
576 }
577 switch path.Ext(strings.ToLower(name)) {
578 case ".md", ".markdown":
579 var buf bytes.Buffer
580 if goldmark.Convert(raw, &buf) != nil {
581 return plain()
582 }
583 return template.HTML(buf.String())
584 case ".org":
585 doc := org.New().Parse(bytes.NewReader(raw), name)
586 html, err := doc.Write(org.NewHTMLWriter())
587 if err != nil {
588 return plain()
589 }
590 return template.HTML(ugcPolicy.Sanitize(html))
591 case ".html", ".htm":
592 return template.HTML(ugcPolicy.Sanitize(string(raw)))
593 default:
594 return plain()
595 }
596}
597
598type diffLine struct {
599 Class string
600 Text string
601 Path string // file this line belongs to
602 NewLine int64 // line number in the new file (0 when absent)
603 OldLine int64 // line number in the old file (0 when absent)
604 Threads []diffThread
605}
606
607var hunkPat = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@`)
608
609// classifyDiff parses a unified diff into rendered lines, tracking the
610// file and old/new line numbers so review threads can anchor inline.
611func classifyDiff(patch string) []diffLine {
612 var lines []diffLine
613 path := ""
614 var oldN, newN int64
615 for _, l := range strings.Split(patch, "\n") {
616 d := diffLine{Text: l}
617 switch {
618 case strings.HasPrefix(l, "+++ "):
619 d.Class = "meta"
620 path = strings.TrimPrefix(strings.TrimPrefix(l, "+++ "), "b/")
621 case strings.HasPrefix(l, "--- "), strings.HasPrefix(l, "diff "), strings.HasPrefix(l, "index "):
622 d.Class = "meta"
623 case strings.HasPrefix(l, "@@"):
624 d.Class = "hunk"
625 if m := hunkPat.FindStringSubmatch(l); m != nil {
626 oldN, _ = strconv.ParseInt(m[1], 10, 64)
627 newN, _ = strconv.ParseInt(m[2], 10, 64)
628 }
629 case strings.HasPrefix(l, "+"):
630 d.Class, d.Path, d.NewLine = "add", path, newN
631 newN++
632 case strings.HasPrefix(l, "-"):
633 d.Class, d.Path, d.OldLine = "del", path, oldN
634 oldN++
635 default:
636 d.Path, d.OldLine, d.NewLine = path, oldN, newN
637 oldN++
638 newN++
639 }
640 lines = append(lines, d)
641 }
642 return lines
643}
644
645type diffThread struct {
646 ID int64
647 Resolved string
648 Stale bool
649 Comments []renderedComment
650}
651
652// attachThreads injects review threads under their anchored diff lines;
653// threads whose anchor no longer appears (stale after force-push, or on a
654// context line outside the current diff) are returned separately.
655func attachThreads(lines []diffLine, comments []store.DiffComment, headSHA string, md func(string) template.HTML) ([]diffLine, []diffThread) {
656 type anchor struct {
657 path string
658 side string
659 line int64
660 }
661 threads := map[int64]*diffThread{}
662 anchors := map[int64]anchor{}
663 var order []int64
664 for _, cm := range comments {
665 if cm.ReplyTo == 0 {
666 threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
667 Comments: []renderedComment{{cm.Author, cm.CreatedAt, md(cm.Body)}}}
668 anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
669 order = append(order, cm.ID)
670 } else if th, ok := threads[cm.ReplyTo]; ok {
671 th.Comments = append(th.Comments, renderedComment{cm.Author, cm.CreatedAt, md(cm.Body)})
672 }
673 }
674 placed := map[int64]bool{}
675 for i := range lines {
676 for _, id := range order {
677 if placed[id] || threads[id].Stale {
678 continue
679 }
680 a := anchors[id]
681 if lines[i].Path != a.path {
682 continue
683 }
684 if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") ||
685 (a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") {
686 lines[i].Threads = append(lines[i].Threads, *threads[id])
687 placed[id] = true
688 }
689 }
690 }
691 var unplaced []diffThread
692 for _, id := range order {
693 if !placed[id] {
694 unplaced = append(unplaced, *threads[id])
695 }
696 }
697 return lines, unplaced
698}
699
700type sigView struct {
701 State string
702 Signer string
703 Fingerprint string
704}
705
706func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
707 raw, err := gitutil.ReadCommit(dir, sha)
708 if err != nil {
709 return sigView{State: "unsigned"}, nil
710 }
711 parsed, err := sig.ParseCommit(raw)
712 if err != nil {
713 return sigView{State: "unsigned"}, nil
714 }
715 res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
716 if err != nil {
717 return sigView{State: "unsigned"}, parsed
718 }
719 v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
720 if res.SignerUserID != 0 {
721 if u, err := s.st.UserByID(res.SignerUserID); err == nil {
722 v.Signer = u.Username
723 }
724 }
725 return v, parsed
726}
727
728func (s *Server) log(w http.ResponseWriter, r *http.Request) {
729 ref := r.PathValue("ref")
730 p, ok := s.repoFor(w, r, ref)
731 if !ok {
732 return
733 }
734 p.Tab = "log"
735 const pageSize = 50
736 shas, err := gitutil.RevList(p.Dir, p.Ref, pageSize+1)
737 if err != nil {
738 s.notFound(w, r)
739 return
740 }
741 next := ""
742 if len(shas) > pageSize {
743 next = shas[pageSize]
744 shas = shas[:pageSize]
745 }
746 type row struct {
747 SHA, ShortSHA, Subject, AuthorName, AuthorEmail, Date string
748 Sig sigView
749 }
750 var rows []row
751 for _, sha := range shas {
752 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
753 rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v}
754 if parsed != nil {
755 rw.Subject = parsed.Subject
756 rw.AuthorName = parsed.AuthorName
757 rw.AuthorEmail = parsed.AuthorEmail
758 rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
759 }
760 rows = append(rows, rw)
761 }
762 s.render(w, "log.html", struct {
763 repoPage
764 Commits []row
765 NextSHA string
766 }{p, rows, next})
767}
768
769func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
770 p, ok := s.repoFor(w, r, "")
771 if !ok {
772 return
773 }
774 p.Tab = "log"
775 sha := r.PathValue("sha")
776 full, err := gitutil.ResolveRef(p.Dir, sha)
777 if err != nil {
778 s.notFound(w, r)
779 return
780 }
781 v, parsed := s.sigFor(p.Repo, p.Dir, full)
782 if parsed == nil {
783 s.notFound(w, r)
784 return
785 }
786 patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
787 lines := classifyDiff(patch)
788 committerEmail := ""
789 if parsed.CommitterEmail != parsed.AuthorEmail {
790 committerEmail = parsed.CommitterEmail
791 }
792 checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
793 msg := ""
794 if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
795 msg = string(parsed.Payload[i+2:])
796 }
797 s.render(w, "commit.html", struct {
798 repoPage
799 SHA, ShortSHA, AuthorName, AuthorEmail, CommitterEmail, Date, Message string
800 Sig sigView
801 Checks []store.CommitStatus
802 DiffLines []diffLine
803 }{p, full, full[:10], parsed.AuthorName, parsed.AuthorEmail, committerEmail,
804 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, v, checks, lines})
805}
806
807// labelPalette provides default label chip colors: mid-tone hues that stay
808// legible on light and dark backgrounds.
809var labelPalette = []string{
810 "#0969da", "#1a7f37", "#9a6700", "#cf222e",
811 "#8250df", "#b93a86", "#0b6c80", "#bf5b16",
812}
813
814var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
815
816// labelColors returns a complete label-name -> chip color map for a repo:
817// the stored labels.color when it is a valid hex color, otherwise a
818// stable default picked from the palette by name hash.
819func (s *Server) labelColors(repoID int64) map[string]template.CSS {
820 stored, _ := s.st.LabelColors(repoID)
821 out := make(map[string]template.CSS, len(stored))
822 for name, color := range stored {
823 if !hexColorPat.MatchString(color) {
824 h := fnv.New32a()
825 h.Write([]byte(name))
826 color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
827 }
828 out[name] = template.CSS("--chip:" + color)
829 }
830 return out
831}
832
833func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
834 p, ok := s.repoFor(w, r, "")
835 if !ok {
836 return
837 }
838 p.Tab = "issues"
839 state := r.URL.Query().Get("state")
840 if state != "closed" && state != "all" {
841 state = "open"
842 }
843 issues, err := s.st.ListIssues(p.Repo.ID, state)
844 if err != nil {
845 http.Error(w, "internal error", http.StatusInternalServerError)
846 return
847 }
848 if labels, err := s.st.ListIssueLabels(p.Repo.ID); err == nil {
849 for i := range issues {
850 issues[i].Labels = labels[issues[i].ID]
851 }
852 }
853 s.render(w, "issues.html", struct {
854 repoPage
855 State string
856 Issues []store.Issue
857 LabelColors map[string]template.CSS
858 }{p, state, issues, s.labelColors(p.Repo.ID)})
859}
860
861func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
862 p, ok := s.repoFor(w, r, "")
863 if !ok {
864 return
865 }
866 p.Tab = "issues"
867 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
868 if err != nil {
869 s.notFound(w, r)
870 return
871 }
872 iss, err := s.st.IssueByNumber(p.Repo.ID, n)
873 if err != nil {
874 s.notFound(w, r)
875 return
876 }
877 comments, err := s.st.ListIssueComments(iss.ID)
878 if err != nil {
879 http.Error(w, "internal error", http.StatusInternalServerError)
880 return
881 }
882 md := s.ugcFor(r, p.Repo)
883 s.render(w, "issue.html", struct {
884 repoPage
885 Issue store.Issue
886 BodyHTML template.HTML
887 Comments []renderedComment
888 LabelColors map[string]template.CSS
889 }{p, iss, md(iss.Body), renderComments(comments, md), s.labelColors(p.Repo.ID)})
890}
891
892func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
893 p, ok := s.repoFor(w, r, "")
894 if !ok {
895 return
896 }
897 p.Tab = "merge requests"
898 state := r.URL.Query().Get("state")
899 if state == "" {
900 state = "open"
901 }
902 valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
903 if !valid[state] {
904 state = "open"
905 }
906 mrs, err := s.st.ListMRs(p.Repo.ID, state)
907 if err != nil {
908 http.Error(w, "internal error", http.StatusInternalServerError)
909 return
910 }
911 s.render(w, "mrs.html", struct {
912 repoPage
913 State string
914 MRs []store.MR
915 }{p, state, mrs})
916}
917
918func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
919 p, ok := s.repoFor(w, r, "")
920 if !ok {
921 return
922 }
923 p.Tab = "merge requests"
924 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
925 if err != nil {
926 s.notFound(w, r)
927 return
928 }
929 m, err := s.st.MRByNumber(p.Repo.ID, n)
930 if err != nil {
931 s.notFound(w, r)
932 return
933 }
934 comments, _ := s.st.ListMRComments(m.ID)
935 reviews, _ := s.st.ListMRReviews(m.ID)
936 checks, _ := s.st.ListCommitStatuses(p.Repo.ID, m.HeadSHA)
937 diffComments, _ := s.st.ListDiffComments(m.ID)
938
939 headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
940 var lines []diffLine
941 base := m.MergedBase
942 if base == "" {
943 if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
944 base = b
945 }
946 }
947 if base != "" {
948 if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
949 lines = classifyDiff(patch)
950 }
951 }
952 md := s.ugcFor(r, p.Repo)
953 var detachedThreads []diffThread
954 lines, detachedThreads = attachThreads(lines, diffComments, m.HeadSHA, md)
955 s.render(w, "mr.html", struct {
956 repoPage
957 MR store.MR
958 BodyHTML template.HTML
959 Checks []store.CommitStatus
960 Combined string
961 Comments []renderedComment
962 Reviews []store.MRReview
963 DiffLines []diffLine
964 DetachedThreads []diffThread
965 }{p, m, md(m.Body), checks, store.CombinedStatus(checks), renderComments(comments, md), reviews, lines, detachedThreads})
966}
967
968func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
969 p, ok := s.repoFor(w, r, "")
970 if !ok {
971 return
972 }
973 p.Tab = "refs"
974 branches, _ := gitutil.Refs(p.Dir, "heads")
975 tags, _ := gitutil.Refs(p.Dir, "tags")
976 s.render(w, "refs.html", struct {
977 repoPage
978 Branches, Tags []gitutil.Ref
979 }{p, branches, tags})
980}
981
982func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
983 p, ok := s.repoFor(w, r, "")
984 if !ok {
985 return
986 }
987 file := r.PathValue("file")
988 ref, ok := strings.CutSuffix(file, ".tar.gz")
989 if !ok {
990 s.notFound(w, r)
991 return
992 }
993 if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
994 s.notFound(w, r)
995 return
996 }
997 prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
998 w.Header().Set("Content-Type", "application/gzip")
999 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
1000 gitutil.Archive(p.Dir, ref, prefix, w)
1001}
1002
1003func policyCanRead(u store.User, repo store.Repo, grant string) bool {
1004 return policy.CanRead(u, repo, grant)
1005}