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