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