internal/httpd/web.go
1537 lines · 43940 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 "sort"
17 "strconv"
18 "strings"
19 "time"
20
21 "github.com/alecthomas/chroma/v2/formatters/html"
22 "github.com/alecthomas/chroma/v2/lexers"
23 "github.com/alecthomas/chroma/v2/styles"
24 "github.com/microcosm-cc/bluemonday"
25 "github.com/niklasfasching/go-org/org"
26 "github.com/yuin/goldmark"
27 highlighting "github.com/yuin/goldmark-highlighting/v2"
28 "github.com/yuin/goldmark/extension"
29
30 "gitbay.org/gitbay/internal/autolink"
31 "gitbay.org/gitbay/internal/control"
32 "gitbay.org/gitbay/internal/gitutil"
33 "gitbay.org/gitbay/internal/sig"
34 "gitbay.org/gitbay/internal/store"
35 "gitbay.org/gitbay/internal/web"
36)
37
38const maxRenderBytes = 1 << 20 // largest blob rendered inline
39
40func (s *Server) render(w http.ResponseWriter, page string, data any) {
41 var buf bytes.Buffer
42 if err := web.Render(&buf, page, data); err != nil {
43 http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
44 return
45 }
46 w.Header().Set("Content-Type", "text/html; charset=utf-8")
47 buf.WriteTo(w)
48}
49
50func (s *Server) siteName() string {
51 h := strings.TrimPrefix(strings.TrimPrefix(s.cfg.Server.SiteURL, "https://"), "http://")
52 return strings.TrimSuffix(h, "/")
53}
54
55func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) {
56 w.Header().Set("Content-Type", "text/css; charset=utf-8")
57 w.Write(web.StyleCSS)
58 w.Write(chromaCSS)
59}
60
61func (s *Server) favicon(w http.ResponseWriter, r *http.Request) {
62 w.Header().Set("Content-Type", "image/svg+xml")
63 w.Write(web.FaviconSVG)
64}
65
66// font serves the embedded Atkinson Hyperlegible subsets. Same-origin,
67// so the CSP's default-src 'self' covers it — no font CDN.
68func (s *Server) font(w http.ResponseWriter, r *http.Request) {
69 data, err := web.FontFS.ReadFile("static" + r.URL.Path[len("/static"):])
70 if err != nil {
71 http.NotFound(w, r)
72 return
73 }
74 w.Header().Set("Content-Type", "font/woff2")
75 w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
76 w.Write(data)
77}
78
79// notFound renders the designed 404 page with a 404 status. Falls back to
80// the stock plain-text response if the template fails.
81func (s *Server) notFound(w http.ResponseWriter, r *http.Request) {
82 var buf bytes.Buffer
83 if err := web.Render(&buf, "404.html", s.base(r)); err != nil {
84 http.NotFound(w, r)
85 return
86 }
87 w.Header().Set("Content-Type", "text/html; charset=utf-8")
88 w.WriteHeader(http.StatusNotFound)
89 buf.WriteTo(w)
90}
91
92// describedRepo pairs a repo with the listing metadata: description,
93// topics, license, and last-updated date.
94type describedRepo struct {
95 store.Repo
96 Desc string
97 Topics []string
98 License string
99 Updated string
100}
101
102func (s *Server) describeAll(repos []store.Repo) []describedRepo {
103 var out []describedRepo
104 for _, r := range repos {
105 dir := control.RepoDir(s.cfg.Server.Root, r.OwnerName, r.Name)
106 d := describedRepo{
107 Repo: r,
108 Desc: gitutil.ReadDescription(dir),
109 License: detectLicense(dir, r.DefaultBranch),
110 Updated: gitutil.LastCommitDate(dir, r.DefaultBranch),
111 }
112 d.Topics, _ = s.st.ListTopics(r.ID)
113 out = append(out, d)
114 }
115 return out
116}
117
118// index is the homepage: a dashboard for logged-in users, a landing page
119// for everyone else. The full public listing lives at /explore.
120func (s *Server) index(w http.ResponseWriter, r *http.Request) {
121 if s.cfg.Web.Mode == "accounts" {
122 if viewer := s.viewer(r); viewer.ID != 0 {
123 s.dashboard(w, r, viewer)
124 return
125 }
126 }
127 host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(
128 s.cfg.Server.SiteURL, "https://"), "http://"), "/")
129 s.render(w, "landing.html", struct {
130 basePage
131 Host string
132 Accounts bool
133 Signup bool
134 }{basePage{Site: s.siteName()}, host, s.cfg.Web.Mode == "accounts",
135 s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed"})
136}
137
138func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.User) {
139 pinned, _ := s.st.PinnedRepos(viewer.ID)
140 var visible []store.Repo
141 for _, rp := range pinned {
142 grant, _ := s.st.AccessRole(rp.ID, viewer.ID)
143 if policy.CanRead(viewer, rp, grant) {
144 visible = append(visible, rp)
145 }
146 }
147 mrs, _ := s.st.DashboardMRs(viewer.ID)
148 issues, _ := s.st.DashboardIssues(viewer.ID)
149 s.render(w, "dashboard.html", struct {
150 basePage
151 Pinned []store.Repo
152 MRs []store.DashboardItem
153 Issues []store.DashboardItem
154 }{s.baseFor(viewer), visible, mrs, issues})
155}
156
157func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
158 repos, err := s.st.ListPublicRepos()
159 if err != nil {
160 http.Error(w, "internal error", http.StatusInternalServerError)
161 return
162 }
163 var viewer store.User
164 if s.cfg.Web.Mode == "accounts" {
165 viewer = s.viewer(r)
166 }
167 q := strings.TrimSpace(r.URL.Query().Get("q"))
168 s.render(w, "explore.html", struct {
169 basePage
170 Query string
171 Repos []describedRepo
172 }{s.baseFor(viewer), q, s.filterRepos(q, s.describeAll(repos))})
173}
174
175// privacy renders the privacy page: what the gitbay software does with
176// data, plus this instance's operator-provided notes.
177func (s *Server) privacy(w http.ResponseWriter, r *http.Request) {
178 s.render(w, "privacy.html", struct {
179 basePage
180 Host string
181 Notice string
182 }{s.base(r), s.cfg.SiteHost(), s.cfg.Web.PrivacyNotice})
183}
184
185// filterRepos keeps repos whose path, description, or topics contain the
186// query, case-insensitively. An empty query keeps everything.
187func (s *Server) filterRepos(q string, repos []describedRepo) []describedRepo {
188 if q == "" {
189 return repos
190 }
191 q = strings.ToLower(q)
192 var out []describedRepo
193 for _, d := range repos {
194 if strings.Contains(strings.ToLower(d.Path()), q) ||
195 strings.Contains(strings.ToLower(d.Desc), q) {
196 out = append(out, d)
197 continue
198 }
199 for _, t := range d.Topics {
200 if strings.Contains(t, q) {
201 out = append(out, d)
202 break
203 }
204 }
205 }
206 return out
207}
208
209// repoPage is the shared context for repo-scoped pages.
210type repoPage struct {
211 basePage
212 Desc string
213 Repo store.Repo
214 Ref string
215 CloneURL string
216 Dir string
217 Tab string // active tab in the repo header
218 Topics []string
219 Pinned bool // by the viewer
220 HasWiki bool
221 Host string
222 Mirrors []mirrorLine // repo admins only
223 // OpenIssues and OpenMRs are the counts on the header tabs.
224 OpenIssues int
225 OpenMRs int
226 // RepoHome asks the layout for the full header — description, topics,
227 // website, mirrors. Every other page gets identity and tabs only, so a
228 // repo describes itself once rather than on all twelve of its pages.
229 RepoHome bool
230}
231
232// mirrorLine is the admin-only mirror status shown in the repo header.
233// It carries no credentials: the stored URL is credential-free.
234type mirrorLine struct {
235 Direction string
236 URL string
237 Target string // URL without the scheme, for display
238 Synced string
239 Error string
240}
241
242// syncedAt trims a stored sync timestamp (2026-08-25T03:39:19.994Z) to a
243// readable "2026-08-25 03:39 UTC".
244func syncedAt(ts string) string {
245 if len(ts) < 16 {
246 return ts
247 }
248 return ts[:10] + " " + ts[11:16] + " UTC"
249}
250
251// repoFor resolves the repo for a web request; false means 404 was sent.
252// Anonymous visitors see public repos only; in accounts mode a logged-in
253// viewer additionally sees repos their grants allow. Private and missing
254// repos are indistinguishable either way.
255func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (repoPage, bool) {
256 var repo store.Repo
257 var viewer store.User
258 if s.cfg.Web.Mode == "accounts" {
259 viewer = s.viewer(r)
260 }
261 repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
262 ok := err == nil
263 grant := ""
264 if ok {
265 if viewer.ID != 0 {
266 grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
267 }
268 ok = policyCanRead(viewer, repo, grant)
269 }
270 if !ok {
271 s.notFound(w, r)
272 return repoPage{}, false
273 }
274 if ref == "" {
275 ref = repo.DefaultBranch
276 }
277 topics, _ := s.st.ListTopics(repo.ID)
278 pinned := false
279 if viewer.ID != 0 {
280 pinned = s.st.IsPinned(viewer.ID, repo.ID)
281 }
282 var mirrors []mirrorLine
283 if viewer.ID != 0 && policy.CanAdmin(viewer, repo, grant) {
284 ms, _ := s.st.ListMirrors(repo.ID)
285 for _, m := range ms {
286 mirrors = append(mirrors, mirrorLine{
287 Direction: m.Direction,
288 URL: m.URL,
289 Target: strings.TrimPrefix(strings.TrimPrefix(m.URL, "https://"), "http://"),
290 Synced: syncedAt(m.LastSync),
291 Error: m.LastError,
292 })
293 }
294 }
295 openIssues, openMRs := s.st.OpenCounts(repo.ID)
296 return repoPage{
297 basePage: s.baseFor(viewer),
298 Mirrors: mirrors,
299 Pinned: pinned,
300 HasWiki: s.wikiDir(repo.OwnerName, repo.Name) != "",
301 Host: s.cfg.SiteHost(),
302 Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)),
303 Repo: repo,
304 Ref: ref,
305 CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git",
306 Dir: control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name),
307 Topics: topics,
308 OpenIssues: openIssues,
309 OpenMRs: openMRs,
310 }, true
311}
312
313type crumb struct {
314 Name string
315 URL string
316}
317
318func crumbs(p repoPage, kind, filePath string) []crumb {
319 var cs []crumb
320 base := "/" + p.Repo.Path() + "/" + kind + "/" + p.Ref + "/"
321 acc := ""
322 for _, part := range strings.Split(filePath, "/") {
323 if part == "" {
324 continue
325 }
326 acc = path.Join(acc, part)
327 cs = append(cs, crumb{Name: part, URL: base + acc})
328 }
329 return cs
330}
331
332// ownerPage renders /{owner} for users and orgs: the repositories the
333// viewer may see, org membership either direction. Owner names are not
334// secret (they are on every commit); repository visibility rules hold.
335func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
336 name := r.PathValue("owner")
337 var viewer store.User
338 if s.cfg.Web.Mode == "accounts" {
339 viewer = s.viewer(r)
340 }
341
342 kind := "user"
343 var ownerID int64
344 var members []store.OrgMember
345 var orgs []store.OrgMember
346 if u, err := s.st.UserByUsername(name); err == nil {
347 ownerID = u.ID
348 orgs, _ = s.st.ListOrgsForUser(u.ID)
349 } else if o, err := s.st.OrgByName(name); err == nil {
350 kind, ownerID = "org", o.ID
351 members, _ = s.st.OrgMembers(o.ID)
352 } else {
353 s.notFound(w, r)
354 return
355 }
356 profile, _ := s.st.OwnerProfile(kind, ownerID)
357
358 all, err := s.st.ListReposForOwner(kind, ownerID)
359 if err != nil {
360 http.Error(w, "internal error", http.StatusInternalServerError)
361 return
362 }
363 var visible []store.Repo
364 for _, repo := range all {
365 grant := ""
366 if viewer.ID != 0 {
367 grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
368 }
369 if policy.CanRead(viewer, repo, grant) {
370 visible = append(visible, repo)
371 }
372 }
373 var counts map[string]int
374 if kind == "user" {
375 counts, _ = s.st.ActivityByDay(ownerID, activitySince())
376 } else {
377 counts, _ = s.st.OrgActivityByDay(ownerID, activitySince())
378 }
379 weeks, activityTotal := activityGrid(counts)
380
381 s.render(w, "owner.html", struct {
382 basePage
383 Owner string
384 Kind string
385 Profile store.Profile
386 Repos []describedRepo
387 Members []store.OrgMember
388 Orgs []store.OrgMember
389 Activity []activityWeek
390 ActivityTotal int
391 }{s.baseFor(viewer), name, kind, profile, s.describeAll(visible), members, orgs,
392 weeks, activityTotal})
393}
394
395func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
396 p, ok := s.repoFor(w, r, "")
397 if !ok {
398 return
399 }
400 p.Tab = "files"
401 p.RepoHome = true
402 s.renderTree(w, r, p, "")
403}
404
405func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
406 p, ok := s.repoFor(w, r, r.PathValue("ref"))
407 if !ok {
408 return
409 }
410 p.Tab = "files"
411 s.renderTree(w, r, p, strings.Trim(r.PathValue("path"), "/"))
412}
413
414func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) {
415 if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil {
416 // Empty repo: render the page with no entries rather than 404.
417 s.render(w, "tree.html", struct {
418 repoPage
419 Crumbs []crumb
420 Prefix string
421 DirPath string
422 RefKind string
423 Entries []gitutil.TreeEntry
424 Branches []gitutil.Ref
425 ReadmeName string
426 ReadmeHTML template.HTML
427 LastCommits map[string]gitutil.EntryCommit
428 Tip gitutil.EntryCommit
429 }{repoPage: p, RefKind: "tree"})
430 return
431 }
432 entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath)
433 if err != nil {
434 s.notFound(w, r)
435 return
436 }
437 // Directories first. git's tree order interleaves them with files, but
438 // a listing is scanned by shape before name. Stable, so each group
439 // keeps the ordering git gave it.
440 sort.SliceStable(entries, func(i, j int) bool {
441 return entries[i].Type == "tree" && entries[j].Type != "tree"
442 })
443 prefix := ""
444 if dirPath != "" {
445 prefix = dirPath + "/"
446 }
447
448 var readmeHTML template.HTML
449 readmeName := pickReadme(entries)
450 if readmeName != "" {
451 if raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+readmeName, maxRenderBytes); err == nil {
452 readmeHTML = rewriteRelativeLinks(renderReadme(readmeName, raw), p, dirPath)
453 }
454 }
455
456 branches, _ := gitutil.Refs(p.Dir, "heads")
457 names := make([]string, 0, len(entries))
458 for _, e := range entries {
459 names = append(names, e.Name)
460 }
461 s.render(w, "tree.html", struct {
462 repoPage
463 Crumbs []crumb
464 Prefix string
465 DirPath string
466 RefKind string
467 Entries []gitutil.TreeEntry
468 Branches []gitutil.Ref
469 ReadmeName string
470 ReadmeHTML template.HTML
471 LastCommits map[string]gitutil.EntryCommit
472 Tip gitutil.EntryCommit
473 }{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches,
474 readmeName, readmeHTML,
475 gitutil.LastCommits(p.Dir, p.Ref, dirPath, names),
476 gitutil.TipCommit(p.Dir, p.Ref)})
477}
478
479func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
480 p, ok := s.repoFor(w, r, r.PathValue("ref"))
481 if !ok {
482 return
483 }
484 p.Tab = "files"
485 filePath := strings.Trim(r.PathValue("path"), "/")
486 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, maxRenderBytes+1)
487 if err != nil {
488 s.notFound(w, r)
489 return
490 }
491 binary := gitutil.IsBinary(data) || len(data) > maxRenderBytes
492 _, image := imageTypes[strings.ToLower(path.Ext(filePath))]
493
494 var codeHTML template.HTML
495 if !binary && !image {
496 codeHTML = highlight(filePath, data)
497 }
498 cs := crumbs(p, "blob", filePath)
499 base := ""
500 if len(cs) > 0 {
501 base = cs[len(cs)-1].Name
502 cs = cs[:len(cs)-1]
503 }
504 branches, _ := gitutil.Refs(p.Dir, "heads")
505 lines := 0
506 if !binary && !image && len(data) > 0 {
507 lines = bytes.Count(data, []byte("\n"))
508 if data[len(data)-1] != '\n' {
509 lines++
510 }
511 }
512 // The file listing leads with the last commit now, so the facts about
513 // the file itself are reported here instead.
514 entry, _ := gitutil.StatPath(p.Dir, p.Ref, filePath)
515 s.render(w, "blob.html", struct {
516 repoPage
517 Crumbs []crumb
518 Base string
519 Path string
520 DirPath string
521 RefKind string
522 Binary bool
523 Image bool
524 Size int
525 Lines int
526 Exec bool
527 Symlink bool
528 Branches []gitutil.Ref
529 CodeHTML template.HTML
530 }{p, cs, base, filePath, filePath, "blob", binary, image, len(data), lines,
531 entry.Mode == "100755", entry.Mode == "120000", branches, codeHTML})
532}
533
534// releases lists tag-anchored releases with notes and assets.
535func (s *Server) releases(w http.ResponseWriter, r *http.Request) {
536 p, ok := s.repoFor(w, r, "")
537 if !ok {
538 return
539 }
540 p.Tab = "releases"
541 rels, err := s.st.ListReleases(p.Repo.ID)
542 if err != nil {
543 http.Error(w, "internal error", http.StatusInternalServerError)
544 return
545 }
546 md := s.ugcFor(r, p.Repo)
547 type relView struct {
548 store.Release
549 NotesHTML template.HTML
550 }
551 var views []relView
552 for _, rel := range rels {
553 views = append(views, relView{rel, md(rel.Notes)})
554 }
555 s.render(w, "releases.html", struct {
556 repoPage
557 Releases []relView
558 }{p, views})
559}
560
561// releaseAsset streams one uploaded asset. Tags containing '/' are not
562// reachable here (single path segment); SSH download always works.
563func (s *Server) releaseAsset(w http.ResponseWriter, r *http.Request) {
564 p, ok := s.repoFor(w, r, "")
565 if !ok {
566 return
567 }
568 rel, err := s.st.ReleaseByTag(p.Repo.ID, r.PathValue("tag"))
569 if err != nil {
570 s.notFound(w, r)
571 return
572 }
573 name := r.PathValue("name")
574 found := false
575 for _, a := range rel.Assets {
576 if a.Name == name {
577 found = true
578 }
579 }
580 if !found {
581 s.notFound(w, r)
582 return
583 }
584 f, err := os.Open(filepath.Join(control.RepoDir(s.cfg.Server.Root, p.Repo.OwnerName, p.Repo.Name),
585 "gitbay-releases", strconv.FormatInt(rel.ID, 10), name))
586 if err != nil {
587 s.notFound(w, r)
588 return
589 }
590 defer f.Close()
591 w.Header().Set("Content-Type", "application/octet-stream")
592 w.Header().Set("X-Content-Type-Options", "nosniff")
593 w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
594 if fi, err := f.Stat(); err == nil {
595 w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10))
596 }
597 io.Copy(w, f)
598}
599
600// milestones lists a repo's milestones with progress.
601func (s *Server) milestones(w http.ResponseWriter, r *http.Request) {
602 p, ok := s.repoFor(w, r, "")
603 if !ok {
604 return
605 }
606 p.Tab = "issues"
607 state := r.URL.Query().Get("state")
608 if state != "closed" && state != "all" {
609 state = "open"
610 }
611 ms, err := s.st.ListMilestones(p.Repo.ID, state)
612 if err != nil {
613 http.Error(w, "internal error", http.StatusInternalServerError)
614 return
615 }
616 type msView struct {
617 store.Milestone
618 Percent int
619 }
620 var views []msView
621 for _, m := range ms {
622 v := msView{Milestone: m}
623 if total := m.OpenItems + m.ClosedItems; total > 0 {
624 v.Percent = m.ClosedItems * 100 / total
625 }
626 views = append(views, v)
627 }
628 s.render(w, "milestones.html", struct {
629 repoPage
630 State string
631 Milestones []msView
632 }{p, state, views})
633}
634
635// search runs a bounded literal git grep over the repo's default branch.
636func (s *Server) search(w http.ResponseWriter, r *http.Request) {
637 p, ok := s.repoFor(w, r, "")
638 if !ok {
639 return
640 }
641 p.Tab = "search"
642 q := strings.TrimSpace(r.URL.Query().Get("q"))
643 type matchView struct {
644 Path string
645 Line int
646 TextHTML template.HTML
647 }
648 var matches []matchView
649 var queryErr string
650 if q != "" {
651 if len(q) < 2 || len(q) > 200 {
652 queryErr = "query must be 2 to 200 characters"
653 } else if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err == nil {
654 raw, err := gitutil.Grep(p.Dir, p.Ref, q, 200)
655 if err != nil {
656 http.Error(w, "internal error", http.StatusInternalServerError)
657 return
658 }
659 for _, m := range raw {
660 matches = append(matches, matchView{m.Path, m.Line, markMatch(m.Text, q)})
661 }
662 }
663 }
664 s.render(w, "search.html", struct {
665 repoPage
666 Query string
667 QueryErr string
668 Matches []matchView
669 Capped bool
670 }{p, q, queryErr, matches, len(matches) == 200})
671}
672
673// markMatch escapes a matched line and wraps case-insensitive occurrences
674// of the query in <mark>.
675func markMatch(text, q string) template.HTML {
676 lower, lq := strings.ToLower(text), strings.ToLower(q)
677 var b strings.Builder
678 pos := 0
679 for {
680 i := strings.Index(lower[pos:], lq)
681 if i < 0 {
682 break
683 }
684 i += pos
685 b.WriteString(template.HTMLEscapeString(text[pos:i]))
686 b.WriteString("<mark>")
687 b.WriteString(template.HTMLEscapeString(text[i : i+len(q)]))
688 b.WriteString("</mark>")
689 pos = i + len(q)
690 }
691 b.WriteString(template.HTMLEscapeString(text[pos:]))
692 return template.HTML(b.String())
693}
694
695// blamePageSize caps how many lines one blame page renders; blame is a
696// per-line subprocess cost, so large files paginate.
697const blamePageSize = 1000
698
699func (s *Server) blame(w http.ResponseWriter, r *http.Request) {
700 p, ok := s.repoFor(w, r, r.PathValue("ref"))
701 if !ok {
702 return
703 }
704 p.Tab = "files"
705 filePath := strings.Trim(r.PathValue("path"), "/")
706 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
707 if err != nil {
708 s.notFound(w, r)
709 return
710 }
711 total := bytes.Count(data, []byte("\n"))
712 if len(data) > 0 && !bytes.HasSuffix(data, []byte("\n")) {
713 total++
714 }
715 binary := gitutil.IsBinary(data)
716
717 type hunkView struct {
718 gitutil.BlameHunk
719 ShortSHA string
720 Date string
721 Sig sigView
722 Numbered []numberedLine
723 }
724 var hunks []hunkView
725 page, pages := 1, (total+blamePageSize-1)/blamePageSize
726 if pages == 0 {
727 pages = 1
728 }
729 if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n >= 1 && n <= pages {
730 page = n
731 }
732 if !binary && total > 0 {
733 start := (page-1)*blamePageSize + 1
734 end := min(total, page*blamePageSize)
735 raw, err := gitutil.Blame(p.Dir, p.Ref, filePath, start, end)
736 if err != nil {
737 s.notFound(w, r)
738 return
739 }
740 sigs := map[string]sigView{}
741 for _, h := range raw {
742 v, ok := sigs[h.SHA]
743 if !ok {
744 v, _ = s.sigFor(p.Repo, p.Dir, h.SHA)
745 sigs[h.SHA] = v
746 }
747 hv := hunkView{BlameHunk: h, ShortSHA: h.SHA[:10],
748 Date: time.Unix(h.AuthorUnix, 0).UTC().Format("2006-01-02"), Sig: v}
749 for i, l := range h.Lines {
750 hv.Numbered = append(hv.Numbered, numberedLine{h.StartLine + i, l})
751 }
752 hunks = append(hunks, hv)
753 }
754 }
755 cs := crumbs(p, "blame", filePath)
756 base := ""
757 if len(cs) > 0 {
758 base = cs[len(cs)-1].Name
759 cs = cs[:len(cs)-1]
760 }
761 s.render(w, "blame.html", struct {
762 repoPage
763 Crumbs []crumb
764 Base string
765 Path string
766 Binary bool
767 Hunks []hunkView
768 Page, Pages int
769 }{p, cs, base, filePath, binary, hunks, page, pages})
770}
771
772type numberedLine struct {
773 N int
774 Text string
775}
776
777// chromaFormatter emits class-based markup (no inline colors), so the
778// stylesheet can swap palettes with the color scheme.
779var chromaFormatter = html.New(html.WithClasses(true),
780 html.WithLineNumbers(true), html.LineNumbersInTable(false),
781 html.WithLinkableLineNumbers(true, "L"))
782
783func highlight(filePath string, data []byte) template.HTML {
784 lexer := lexers.Match(filePath)
785 if lexer == nil {
786 lexer = lexers.Fallback
787 }
788 iterator, err := lexer.Tokenise(nil, string(data))
789 if err != nil {
790 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
791 }
792 var buf bytes.Buffer
793 if err := chromaFormatter.Format(&buf, styles.Get("friendly"), iterator); err != nil {
794 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
795 }
796 return template.HTML(buf.String())
797}
798
799// chromaCSS is both syntax palettes: light by default, dark under the same
800// media query the rest of the stylesheet uses. The site's --code-bg stays
801// the background either way.
802var chromaCSS = func() []byte {
803 var buf bytes.Buffer
804 chromaFormatter.WriteCSS(&buf, styles.Get("friendly"))
805 buf.WriteString("\n@media (prefers-color-scheme: dark) {\n")
806 chromaFormatter.WriteCSS(&buf, styles.Get("github-dark"))
807 buf.WriteString("}\n.chroma, .bg { background: transparent !important; }\n")
808 return buf.Bytes()
809}()
810
811func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
812 p, ok := s.repoFor(w, r, r.PathValue("ref"))
813 if !ok {
814 return
815 }
816 filePath := strings.Trim(r.PathValue("path"), "/")
817 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
818 if err != nil {
819 s.notFound(w, r)
820 return
821 }
822 // Serve inert: never let repo content execute in the forge's origin.
823 // Images get their real type so <img> works under nosniff; SVG script
824 // is dead on arrival because the instance CSP is script-src 'none'.
825 ct := "text/plain; charset=utf-8"
826 if t, ok := imageTypes[strings.ToLower(path.Ext(filePath))]; ok {
827 ct = t
828 }
829 w.Header().Set("Content-Type", ct)
830 w.Header().Set("X-Content-Type-Options", "nosniff")
831 w.Write(data)
832}
833
834// imageTypes are the formats raw serves with a real content type and blob
835// pages preview inline.
836var imageTypes = map[string]string{
837 ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
838 ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif",
839 ".svg": "image/svg+xml", ".ico": "image/x-icon",
840}
841
842// readmeRank orders competing README files: richer renderers win.
843var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
844
845// pickReadme returns the best README-ish blob in a tree listing: any file
846// named "readme" or "readme.<ext>" (case-insensitive), preferring formats
847// we can render richly.
848func pickReadme(entries []gitutil.TreeEntry) string {
849 best, bestRank := "", 1<<30
850 for _, e := range entries {
851 if e.Type != "blob" {
852 continue
853 }
854 lower := strings.ToLower(e.Name)
855 if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
856 continue
857 }
858 rank, ok := readmeRank[path.Ext(lower)]
859 if !ok {
860 rank = 10 // plaintext fallback
861 }
862 if rank < bestRank {
863 best, bestRank = e.Name, rank
864 }
865 }
866 return best
867}
868
869// markdown is the shared renderer: GFM (tables, strikethrough, autolinks,
870// task lists) on top of CommonMark, with class-based fence highlighting
871// (the palette lives in the stylesheet, per scheme). Raw HTML is still
872// dropped.
873var markdown = goldmark.New(goldmark.WithExtensions(extension.GFM,
874 highlighting.NewHighlighting(highlighting.WithFormatOptions(html.WithClasses(true)))))
875
876// fenceHighlight renders one code block with chroma classes, for org and
877// anything else outside goldmark. Unknown languages fall back to plain.
878func fenceHighlight(source, lang string) string {
879 lexer := lexers.Get(lang)
880 if lexer == nil {
881 lexer = lexers.Fallback
882 }
883 iterator, err := lexer.Tokenise(nil, source)
884 if err != nil {
885 return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
886 }
887 var buf bytes.Buffer
888 f := html.New(html.WithClasses(true))
889 if err := f.Format(&buf, styles.Get("friendly"), iterator); err != nil {
890 return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
891 }
892 return buf.String()
893}
894
895// mdHTML renders user-authored markdown (issue and MR bodies, comments).
896// goldmark's default renderer drops raw HTML, so this is safe as-is.
897func mdHTML(raw string) template.HTML {
898 if strings.TrimSpace(raw) == "" {
899 return ""
900 }
901 var buf bytes.Buffer
902 if markdown.Convert([]byte(raw), &buf) != nil {
903 return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
904 }
905 return template.HTML(buf.String())
906}
907
908// webResolver answers autolink lookups for one viewer. Cross-repo
909// references to repositories the viewer cannot read stay plain text, per
910// the enumeration rule: a link would confirm the repo exists.
911type webResolver struct {
912 s *Server
913 viewer store.User
914}
915
916func (r webResolver) RefURL(owner, name string, kind byte, n int64) string {
917 repo, err := r.s.st.RepoByPath(owner + "/" + name)
918 if err != nil {
919 return ""
920 }
921 grant := ""
922 if r.viewer.ID != 0 {
923 grant, _ = r.s.st.AccessRole(repo.ID, r.viewer.ID)
924 }
925 if !policy.CanRead(r.viewer, repo, grant) {
926 return ""
927 }
928 if kind == '#' {
929 if _, err := r.s.st.IssueByNumber(repo.ID, n); err != nil {
930 return ""
931 }
932 return autolink.IssueURL(repo.OwnerName, repo.Name, n)
933 }
934 if _, err := r.s.st.MRByNumber(repo.ID, n); err != nil {
935 return ""
936 }
937 return autolink.MRURL(repo.OwnerName, repo.Name, n)
938}
939
940func (r webResolver) UserURL(name string) string {
941 if _, err := r.s.st.UserByUsername(name); err == nil {
942 return "/" + name
943 }
944 if _, err := r.s.st.OrgByName(name); err == nil {
945 return "/" + name
946 }
947 return ""
948}
949
950// ugcFor returns a renderer for user-authored markdown on one repo's pages:
951// mdHTML plus cross-reference and mention autolinking for this viewer.
952func (s *Server) ugcFor(r *http.Request, repo store.Repo) func(string) template.HTML {
953 viewer := store.User{}
954 if s.cfg.Web.Mode == "accounts" {
955 viewer = s.viewer(r)
956 }
957 res := webResolver{s, viewer}
958 return func(raw string) template.HTML {
959 h := mdHTML(raw)
960 if h == "" {
961 return h
962 }
963 return template.HTML(autolink.Rewrite(string(h), repo.OwnerName, repo.Name, res))
964 }
965}
966
967// renderedComment pairs a comment with its rendered body for templates.
968type renderedComment struct {
969 Author string
970 CreatedAt string
971 Kind string
972 BodyHTML template.HTML
973}
974
975func renderComments(cs []store.IssueComment, md func(string) template.HTML) []renderedComment {
976 var out []renderedComment
977 for _, c := range cs {
978 out = append(out, renderedComment{c.Author, c.CreatedAt, c.Kind, md(c.Body)})
979 }
980 return out
981}
982
983// ugcPolicy sanitizes rendered repo content before it enters the forge's
984// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
985// output and repo-authored HTML are not. Chroma's highlighting classes
986// must survive; the pattern admits only short token codes, not the site's
987// own class names.
988var ugcPolicy = func() *bluemonday.Policy {
989 p := bluemonday.UGCPolicy()
990 p.AllowAttrs("class").
991 Matching(regexp.MustCompile(`^(chroma|[a-z0-9]{1,3})( (chroma|[a-z0-9]{1,3}))*$`)).
992 OnElements("span", "pre", "code", "div")
993 return p
994}()
995
996// renderReadme renders a README by extension: markdown, org-mode, and
997// (sanitized) HTML richly; everything else as escaped plaintext.
998func renderReadme(name string, raw []byte) template.HTML {
999 plain := func() template.HTML {
1000 return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
1001 }
1002 if gitutil.IsBinary(raw) {
1003 return ""
1004 }
1005 switch path.Ext(strings.ToLower(name)) {
1006 case ".md", ".markdown":
1007 var buf bytes.Buffer
1008 if markdown.Convert(raw, &buf) != nil {
1009 return plain()
1010 }
1011 return template.HTML(buf.String())
1012 case ".org":
1013 doc := org.New().Parse(bytes.NewReader(raw), name)
1014 writer := org.NewHTMLWriter()
1015 writer.HighlightCodeBlock = func(source, lang string, inline bool, params map[string]string) string {
1016 if inline {
1017 return "<code>" + template.HTMLEscapeString(source) + "</code>"
1018 }
1019 return fenceHighlight(source, lang)
1020 }
1021 out, err := doc.Write(writer)
1022 if err != nil {
1023 return plain()
1024 }
1025 return template.HTML(ugcPolicy.Sanitize(out))
1026 case ".html", ".htm":
1027 return template.HTML(ugcPolicy.Sanitize(string(raw)))
1028 default:
1029 return plain()
1030 }
1031}
1032
1033type diffLine struct {
1034 Class string
1035 Text string
1036 Path string // file this line belongs to
1037 NewLine int64 // line number in the new file (0 when absent)
1038 OldLine int64 // line number in the old file (0 when absent)
1039 Threads []diffThread
1040}
1041
1042var hunkPat = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@`)
1043
1044// classifyDiff parses a unified diff into rendered lines, tracking the
1045// file and old/new line numbers so review threads can anchor inline.
1046func classifyDiff(patch string) []diffLine {
1047 var lines []diffLine
1048 path := ""
1049 var oldN, newN int64
1050 for _, l := range strings.Split(patch, "\n") {
1051 d := diffLine{Text: l}
1052 switch {
1053 case strings.HasPrefix(l, "+++ "):
1054 d.Class = "meta"
1055 path = strings.TrimPrefix(strings.TrimPrefix(l, "+++ "), "b/")
1056 case strings.HasPrefix(l, "--- "), strings.HasPrefix(l, "diff "), strings.HasPrefix(l, "index "):
1057 d.Class = "meta"
1058 case strings.HasPrefix(l, "@@"):
1059 d.Class = "hunk"
1060 if m := hunkPat.FindStringSubmatch(l); m != nil {
1061 oldN, _ = strconv.ParseInt(m[1], 10, 64)
1062 newN, _ = strconv.ParseInt(m[2], 10, 64)
1063 }
1064 case strings.HasPrefix(l, "+"):
1065 d.Class, d.Path, d.NewLine = "add", path, newN
1066 newN++
1067 case strings.HasPrefix(l, "-"):
1068 d.Class, d.Path, d.OldLine = "del", path, oldN
1069 oldN++
1070 default:
1071 d.Path, d.OldLine, d.NewLine = path, oldN, newN
1072 oldN++
1073 newN++
1074 }
1075 lines = append(lines, d)
1076 }
1077 return lines
1078}
1079
1080type diffThread struct {
1081 ID int64
1082 Resolved string
1083 Stale bool
1084 Comments []renderedComment
1085}
1086
1087// attachThreads injects review threads under their anchored diff lines;
1088// threads whose anchor no longer appears (stale after force-push, or on a
1089// context line outside the current diff) are returned separately.
1090func attachThreads(lines []diffLine, comments []store.DiffComment, headSHA string, md func(string) template.HTML) ([]diffLine, []diffThread) {
1091 type anchor struct {
1092 path string
1093 side string
1094 line int64
1095 }
1096 threads := map[int64]*diffThread{}
1097 anchors := map[int64]anchor{}
1098 var order []int64
1099 for _, cm := range comments {
1100 if cm.ReplyTo == 0 {
1101 threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
1102 Comments: []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body)}}}
1103 anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
1104 order = append(order, cm.ID)
1105 } else if th, ok := threads[cm.ReplyTo]; ok {
1106 th.Comments = append(th.Comments, renderedComment{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body)})
1107 }
1108 }
1109 placed := map[int64]bool{}
1110 for i := range lines {
1111 for _, id := range order {
1112 if placed[id] || threads[id].Stale {
1113 continue
1114 }
1115 a := anchors[id]
1116 if lines[i].Path != a.path {
1117 continue
1118 }
1119 if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") ||
1120 (a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") {
1121 lines[i].Threads = append(lines[i].Threads, *threads[id])
1122 placed[id] = true
1123 }
1124 }
1125 }
1126 var unplaced []diffThread
1127 for _, id := range order {
1128 if !placed[id] {
1129 unplaced = append(unplaced, *threads[id])
1130 }
1131 }
1132 return lines, unplaced
1133}
1134
1135type sigView struct {
1136 State string
1137 Signer string
1138 Fingerprint string
1139}
1140
1141func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
1142 raw, err := gitutil.ReadCommit(dir, sha)
1143 if err != nil {
1144 return sigView{State: "unsigned"}, nil
1145 }
1146 parsed, err := sig.ParseCommit(raw)
1147 if err != nil {
1148 return sigView{State: "unsigned"}, nil
1149 }
1150 res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
1151 if err != nil {
1152 return sigView{State: "unsigned"}, parsed
1153 }
1154 v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
1155 if res.SignerUserID != 0 {
1156 if u, err := s.st.UserByID(res.SignerUserID); err == nil {
1157 v.Signer = u.Username
1158 }
1159 }
1160 return v, parsed
1161}
1162
1163func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1164 ref := r.PathValue("ref")
1165 p, ok := s.repoFor(w, r, ref)
1166 if !ok {
1167 return
1168 }
1169 p.Tab = "log"
1170 const pageSize = 50
1171 // ?path= filters to commits touching one file or directory.
1172 filePath := strings.Trim(path.Clean("/"+r.URL.Query().Get("path")), "/")
1173 if filePath == "." {
1174 filePath = ""
1175 }
1176 var shas []string
1177 var err error
1178 if filePath != "" {
1179 shas, err = gitutil.RevListPath(p.Dir, p.Ref, filePath, pageSize+1)
1180 } else {
1181 shas, err = gitutil.RevList(p.Dir, p.Ref, pageSize+1)
1182 }
1183 if err != nil {
1184 s.notFound(w, r)
1185 return
1186 }
1187 next := ""
1188 if len(shas) > pageSize {
1189 next = shas[pageSize]
1190 shas = shas[:pageSize]
1191 }
1192 type row struct {
1193 SHA, ShortSHA, Subject, AuthorName, AuthorEmail, Date string
1194 Sig sigView
1195 }
1196 var rows []row
1197 for _, sha := range shas {
1198 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1199 rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v}
1200 if parsed != nil {
1201 rw.Subject = parsed.Subject
1202 rw.AuthorName = parsed.AuthorName
1203 rw.AuthorEmail = parsed.AuthorEmail
1204 rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1205 }
1206 rows = append(rows, rw)
1207 }
1208 s.render(w, "log.html", struct {
1209 repoPage
1210 Commits []row
1211 NextSHA string
1212 FilePath string
1213 }{p, rows, next, filePath})
1214}
1215
1216func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1217 p, ok := s.repoFor(w, r, "")
1218 if !ok {
1219 return
1220 }
1221 p.Tab = "log"
1222 sha := r.PathValue("sha")
1223 full, err := gitutil.ResolveRef(p.Dir, sha)
1224 if err != nil {
1225 s.notFound(w, r)
1226 return
1227 }
1228 v, parsed := s.sigFor(p.Repo, p.Dir, full)
1229 if parsed == nil {
1230 s.notFound(w, r)
1231 return
1232 }
1233 patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
1234 lines := classifyDiff(patch)
1235 committerEmail := ""
1236 if parsed.CommitterEmail != parsed.AuthorEmail {
1237 committerEmail = parsed.CommitterEmail
1238 }
1239 checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
1240 msg := ""
1241 if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
1242 msg = string(parsed.Payload[i+2:])
1243 }
1244 s.render(w, "commit.html", struct {
1245 repoPage
1246 SHA, ShortSHA, AuthorName, AuthorEmail, CommitterEmail, Date, Message string
1247 Parents []string
1248 Sig sigView
1249 Checks []store.CommitStatus
1250 DiffLines []diffLine
1251 }{p, full, full[:10], parsed.AuthorName, parsed.AuthorEmail, committerEmail,
1252 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1253 gitutil.Parents(p.Dir, full), v, checks, lines})
1254}
1255
1256// labelPalette provides default label chip colors: mid-tone hues that stay
1257// legible on light and dark backgrounds.
1258var labelPalette = []string{
1259 "#0969da", "#1a7f37", "#9a6700", "#cf222e",
1260 "#8250df", "#b93a86", "#0b6c80", "#bf5b16",
1261}
1262
1263var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
1264
1265// labelColors returns a complete label-name -> chip color map for a repo:
1266// the stored labels.color when it is a valid hex color, otherwise a
1267// stable default picked from the palette by name hash.
1268func (s *Server) labelColors(repoID int64) map[string]template.CSS {
1269 stored, _ := s.st.LabelColors(repoID)
1270 out := make(map[string]template.CSS, len(stored))
1271 for name, color := range stored {
1272 if !hexColorPat.MatchString(color) {
1273 h := fnv.New32a()
1274 h.Write([]byte(name))
1275 color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
1276 }
1277 out[name] = template.CSS("--chip:" + color)
1278 }
1279 return out
1280}
1281
1282func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1283 p, ok := s.repoFor(w, r, "")
1284 if !ok {
1285 return
1286 }
1287 p.Tab = "issues"
1288 state := r.URL.Query().Get("state")
1289 if state != "closed" && state != "all" {
1290 state = "open"
1291 }
1292 issues, err := s.st.ListIssues(p.Repo.ID, state)
1293 if err != nil {
1294 http.Error(w, "internal error", http.StatusInternalServerError)
1295 return
1296 }
1297 if labels, err := s.st.ListIssueLabels(p.Repo.ID); err == nil {
1298 for i := range issues {
1299 issues[i].Labels = labels[issues[i].ID]
1300 }
1301 }
1302 // ?label=x narrows to issues carrying that label (chips link here).
1303 labelFilter := r.URL.Query().Get("label")
1304 if labelFilter != "" {
1305 var kept []store.Issue
1306 for _, iss := range issues {
1307 for _, l := range iss.Labels {
1308 if l == labelFilter {
1309 kept = append(kept, iss)
1310 break
1311 }
1312 }
1313 }
1314 issues = kept
1315 }
1316 s.render(w, "issues.html", struct {
1317 repoPage
1318 State string
1319 Label string
1320 Issues []store.Issue
1321 LabelColors map[string]template.CSS
1322 }{p, state, labelFilter, issues, s.labelColors(p.Repo.ID)})
1323}
1324
1325func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1326 p, ok := s.repoFor(w, r, "")
1327 if !ok {
1328 return
1329 }
1330 p.Tab = "issues"
1331 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1332 if err != nil {
1333 s.notFound(w, r)
1334 return
1335 }
1336 iss, err := s.st.IssueByNumber(p.Repo.ID, n)
1337 if err != nil {
1338 s.notFound(w, r)
1339 return
1340 }
1341 comments, err := s.st.ListIssueComments(iss.ID)
1342 if err != nil {
1343 http.Error(w, "internal error", http.StatusInternalServerError)
1344 return
1345 }
1346 md := s.ugcFor(r, p.Repo)
1347 s.render(w, "issue.html", struct {
1348 repoPage
1349 Issue store.Issue
1350 BodyHTML template.HTML
1351 Comments []renderedComment
1352 CanEdit bool
1353 LabelColors map[string]template.CSS
1354 }{p, iss, md(iss.Body), renderComments(comments, md),
1355 s.canEditItem(r, p.Repo, iss.Author), s.labelColors(p.Repo.ID)})
1356}
1357
1358// canEditItem: the author or anyone with write access may edit.
1359func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool {
1360 if s.cfg.Web.Mode != "accounts" {
1361 return false
1362 }
1363 u := s.viewer(r)
1364 if u.ID == 0 {
1365 return false
1366 }
1367 if u.Username == author {
1368 return true
1369 }
1370 grant, _ := s.st.AccessRole(repo.ID, u.ID)
1371 return policy.CanWrite(u, repo, grant)
1372}
1373
1374func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
1375 p, ok := s.repoFor(w, r, "")
1376 if !ok {
1377 return
1378 }
1379 p.Tab = "merge requests"
1380 state := r.URL.Query().Get("state")
1381 if state == "" {
1382 state = "open"
1383 }
1384 valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
1385 if !valid[state] {
1386 state = "open"
1387 }
1388 mrs, err := s.st.ListMRs(p.Repo.ID, state)
1389 if err != nil {
1390 http.Error(w, "internal error", http.StatusInternalServerError)
1391 return
1392 }
1393 s.render(w, "mrs.html", struct {
1394 repoPage
1395 State string
1396 MRs []store.MR
1397 }{p, state, mrs})
1398}
1399
1400func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1401 p, ok := s.repoFor(w, r, "")
1402 if !ok {
1403 return
1404 }
1405 p.Tab = "merge requests"
1406 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1407 if err != nil {
1408 s.notFound(w, r)
1409 return
1410 }
1411 m, err := s.st.MRByNumber(p.Repo.ID, n)
1412 if err != nil {
1413 s.notFound(w, r)
1414 return
1415 }
1416 comments, _ := s.st.ListMRComments(m.ID)
1417 reviews, _ := s.st.ListMRReviews(m.ID)
1418 checks, _ := s.st.ListCommitStatuses(p.Repo.ID, m.HeadSHA)
1419 diffComments, _ := s.st.ListDiffComments(m.ID)
1420
1421 headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
1422 var lines []diffLine
1423 base := m.MergedBase
1424 if base == "" {
1425 if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
1426 base = b
1427 }
1428 }
1429 if base != "" {
1430 if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
1431 lines = classifyDiff(patch)
1432 }
1433 }
1434 md := s.ugcFor(r, p.Repo)
1435 var detachedThreads []diffThread
1436 lines, detachedThreads = attachThreads(lines, diffComments, m.HeadSHA, md)
1437 type diffStat struct{ Files, Adds, Dels int }
1438 var stat diffStat
1439 seenFiles := map[string]bool{}
1440 for _, l := range lines {
1441 switch l.Class {
1442 case "add":
1443 stat.Adds++
1444 case "del":
1445 stat.Dels++
1446 }
1447 if l.Path != "" && !seenFiles[l.Path] {
1448 seenFiles[l.Path] = true
1449 stat.Files++
1450 }
1451 }
1452 // The commits this MR carries: base..head, the same range as the diff.
1453 type commitRow struct {
1454 SHA, ShortSHA, Subject, AuthorName, Date string
1455 Sig sigView
1456 }
1457 var commits []commitRow
1458 if base != "" {
1459 const maxMRCommits = 100
1460 shas, _ := gitutil.RevListRange(p.Dir, base, headRef)
1461 if len(shas) > maxMRCommits {
1462 shas = shas[:maxMRCommits]
1463 }
1464 for _, sha := range shas {
1465 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1466 cr := commitRow{SHA: sha, ShortSHA: sha[:10], Sig: v}
1467 if parsed != nil {
1468 cr.Subject = parsed.Subject
1469 cr.AuthorName = parsed.AuthorName
1470 cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1471 }
1472 commits = append(commits, cr)
1473 }
1474 }
1475 // The diff is the reason most people open a merge request, so it gets
1476 // its own view rather than a fold at the foot of the conversation.
1477 // A query parameter keeps this working without JavaScript.
1478 view := r.URL.Query().Get("view")
1479 if view != "commits" && view != "diff" {
1480 view = "conversation"
1481 }
1482 s.render(w, "mr.html", struct {
1483 repoPage
1484 MR store.MR
1485 View string
1486 BodyHTML template.HTML
1487 Checks []store.CommitStatus
1488 Combined string
1489 Comments []renderedComment
1490 Reviews []store.MRReview
1491 DiffLines []diffLine
1492 Stat diffStat
1493 Commits []commitRow
1494 CanEdit bool
1495 DetachedThreads []diffThread
1496 }{p, m, view, md(m.Body), checks, store.CombinedStatus(checks), renderComments(comments, md),
1497 reviews, lines, stat, commits, s.canEditItem(r, p.Repo, m.Author), detachedThreads})
1498}
1499
1500func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
1501 p, ok := s.repoFor(w, r, "")
1502 if !ok {
1503 return
1504 }
1505 p.Tab = "refs"
1506 branches, _ := gitutil.Refs(p.Dir, "heads")
1507 tags, _ := gitutil.Refs(p.Dir, "tags")
1508 s.render(w, "refs.html", struct {
1509 repoPage
1510 Branches, Tags []gitutil.Ref
1511 }{p, branches, tags})
1512}
1513
1514func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
1515 p, ok := s.repoFor(w, r, "")
1516 if !ok {
1517 return
1518 }
1519 file := r.PathValue("file")
1520 ref, ok := strings.CutSuffix(file, ".tar.gz")
1521 if !ok {
1522 s.notFound(w, r)
1523 return
1524 }
1525 if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
1526 s.notFound(w, r)
1527 return
1528 }
1529 prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
1530 w.Header().Set("Content-Type", "application/gzip")
1531 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
1532 gitutil.Archive(p.Dir, ref, prefix, w)
1533}
1534
1535func policyCanRead(u store.User, repo store.Repo, grant string) bool {
1536 return policy.CanRead(u, repo, grant)
1537}