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