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