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