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