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