internal/httpd/web.go
1994 lines · 62720 bytes
1package httpd
2
3import (
4 "bytes"
5 "crypto/sha256"
6 "encoding/hex"
7 "errors"
8 "fmt"
9 "hash/fnv"
10 "io"
11 "log"
12 "math"
13 "os"
14 "path/filepath"
15
16 "gitbay.org/gitbay/internal/policy"
17 "gitbay.org/gitbay/internal/protocol"
18 "html/template"
19 "net/http"
20 "net/url"
21 "path"
22 "regexp"
23 "sort"
24 "strconv"
25 "strings"
26 "time"
27
28 "github.com/alecthomas/chroma/v2/formatters/html"
29 "github.com/alecthomas/chroma/v2/lexers"
30 "github.com/alecthomas/chroma/v2/styles"
31 "github.com/microcosm-cc/bluemonday"
32 "github.com/niklasfasching/go-org/org"
33 "github.com/yuin/goldmark"
34 highlighting "github.com/yuin/goldmark-highlighting/v2"
35 "github.com/yuin/goldmark/extension"
36 "github.com/yuin/goldmark/parser"
37
38 "gitbay.org/gitbay/internal/autolink"
39 "gitbay.org/gitbay/internal/control"
40 "gitbay.org/gitbay/internal/gitutil"
41 "gitbay.org/gitbay/internal/sig"
42 "gitbay.org/gitbay/internal/store"
43 "gitbay.org/gitbay/internal/web"
44)
45
46const maxRenderBytes = 1 << 20 // largest blob rendered inline
47
48func (s *Server) render(w http.ResponseWriter, page string, data any) {
49 var buf bytes.Buffer
50 if err := web.Render(&buf, page, data); err != nil {
51 http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
52 return
53 }
54 w.Header().Set("Content-Type", "text/html; charset=utf-8")
55 buf.WriteTo(w)
56}
57
58// siteName is the instance's display name: the operator's [web] title,
59// or the site host when they have not set one.
60func (s *Server) siteName() string {
61 if t := strings.TrimSpace(s.cfg.Web.Title); t != "" {
62 return t
63 }
64 h := strings.TrimPrefix(strings.TrimPrefix(s.cfg.Server.SiteURL, "https://"), "http://")
65 return strings.TrimSuffix(h, "/")
66}
67
68// stylesheetETag is the hash of what stylesheet serves, computed once:
69// a browser revalidates with If-None-Match and gets a 304 until a deploy
70// changes the bytes (#132).
71var stylesheetETag = func() string {
72 h := sha256.New()
73 h.Write(web.StyleCSS)
74 h.Write(chromaCSS)
75 return `"` + hex.EncodeToString(h.Sum(nil))[:16] + `"`
76}()
77
78func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) {
79 w.Header().Set("ETag", stylesheetETag)
80 w.Header().Set("Cache-Control", "public, max-age=86400, must-revalidate")
81 if r.Header.Get("If-None-Match") == stylesheetETag {
82 w.WriteHeader(http.StatusNotModified)
83 return
84 }
85 w.Header().Set("Content-Type", "text/css; charset=utf-8")
86 w.Write(web.StyleCSS)
87 w.Write(chromaCSS)
88}
89
90func (s *Server) favicon(w http.ResponseWriter, r *http.Request) {
91 w.Header().Set("Content-Type", "image/svg+xml")
92 w.Write(web.FaviconSVG)
93}
94
95// font serves the embedded Atkinson Hyperlegible subsets. Same-origin,
96// so the CSP's default-src 'self' covers it — no font CDN.
97func (s *Server) font(w http.ResponseWriter, r *http.Request) {
98 data, err := web.FontFS.ReadFile("static" + r.URL.Path[len("/static"):])
99 if err != nil {
100 http.NotFound(w, r)
101 return
102 }
103 w.Header().Set("Content-Type", "font/woff2")
104 w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
105 w.Write(data)
106}
107
108// notFound renders the designed 404 page with a 404 status. Falls back to
109// the stock plain-text response if the template fails.
110func (s *Server) notFound(w http.ResponseWriter, r *http.Request) {
111 var buf bytes.Buffer
112 if err := web.Render(&buf, "404.html", s.base(r)); err != nil {
113 http.NotFound(w, r)
114 return
115 }
116 w.Header().Set("Content-Type", "text/html; charset=utf-8")
117 w.WriteHeader(http.StatusNotFound)
118 buf.WriteTo(w)
119}
120
121// describedRepo pairs a repo with the listing metadata: description,
122// topics, license, and last-updated date.
123type describedRepo struct {
124 store.Repo
125 Desc string
126 Topics []string
127 License string
128 Updated string
129}
130
131// Archived flattens the settings flag so the reporow partial can read the
132// same field name from a describedRepo and from a profile's repo row.
133func (d describedRepo) Archived() bool { return d.Settings.Archived }
134
135func (s *Server) describeAll(repos []store.Repo) []describedRepo {
136 var out []describedRepo
137 for _, r := range repos {
138 dir := control.RepoDir(s.cfg.Server.Root, r.OwnerName, r.Name)
139 d := describedRepo{
140 Repo: r,
141 Desc: gitutil.ReadDescription(dir),
142 License: control.DetectLicense(dir, r.DefaultBranch),
143 Updated: gitutil.LastCommitDate(dir, r.DefaultBranch),
144 }
145 d.Topics, _ = s.st.ListTopics(r.ID)
146 out = append(out, d)
147 }
148 return out
149}
150
151// index is the homepage: a dashboard for logged-in users, a landing page
152// for everyone else. The full public listing lives at /explore.
153func (s *Server) index(w http.ResponseWriter, r *http.Request) {
154 if s.cfg.Web.Mode == "accounts" {
155 if viewer := s.viewer(r); viewer.ID != 0 {
156 s.dashboard(w, r, viewer)
157 return
158 }
159 }
160 host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(
161 s.cfg.Server.SiteURL, "https://"), "http://"), "/")
162 s.render(w, "landing.html", struct {
163 basePage
164 Host string
165 Accounts bool
166 Signup bool
167 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, host, s.cfg.Web.Mode == "accounts",
168 s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed"})
169}
170
171func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.User) {
172 pinned, _ := s.st.PinnedRepos(viewer.ID)
173 var visible []store.Repo
174 for _, rp := range pinned {
175 grant, _ := s.st.AccessRole(rp.ID, viewer.ID)
176 if policy.CanRead(viewer, rp, grant) {
177 visible = append(visible, rp)
178 }
179 }
180 mrs, _ := s.st.DashboardMRs(viewer.ID)
181 issues, _ := s.st.DashboardIssues(viewer.ID)
182 reviews, _ := s.st.ReviewQueue(viewer.ID)
183 assigned, _ := s.st.AssignedIssues(viewer.ID)
184 events, _ := s.st.RecentEvents(viewer.ID, 20, 0)
185 s.render(w, "dashboard.html", struct {
186 basePage
187 Pinned []store.Repo
188 Reviews []store.DashboardItem
189 Assigned []store.DashboardItem
190 MRs []store.DashboardItem
191 Issues []store.DashboardItem
192 Feed []feedLine
193 }{s.baseFor(viewer), visible, reviews, assigned, mrs, issues, feedLines(events)})
194}
195
196func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
197 repos, err := s.st.ListPublicRepos()
198 if err != nil {
199 http.Error(w, "internal error", http.StatusInternalServerError)
200 return
201 }
202 var viewer store.User
203 if s.cfg.Web.Mode == "accounts" {
204 viewer = s.viewer(r)
205 }
206 q := strings.TrimSpace(r.URL.Query().Get("q"))
207 s.render(w, "explore.html", struct {
208 basePage
209 Query string
210 Repos []describedRepo
211 }{s.baseFor(viewer), q, s.filterRepos(q, s.describeAll(repos))})
212}
213
214// privacy renders the privacy page: what the gitbay software does with
215// data, plus this instance's operator-provided notes.
216func (s *Server) privacy(w http.ResponseWriter, r *http.Request) {
217 s.render(w, "privacy.html", struct {
218 basePage
219 Host string
220 Notice string
221 }{s.base(r), s.cfg.SiteHost(), s.cfg.Web.PrivacyNotice})
222}
223
224// filterRepos keeps repos matching the query by the same rule `repo
225// search` uses. An empty query keeps everything.
226func (s *Server) filterRepos(q string, repos []describedRepo) []describedRepo {
227 if q == "" {
228 return repos
229 }
230 var out []describedRepo
231 for _, d := range repos {
232 if control.MatchesRepo(q, d.Path(), d.Desc, d.Topics) {
233 out = append(out, d)
234 }
235 }
236 return out
237}
238
239// repoPage is the shared context for repo-scoped pages.
240type repoPage struct {
241 basePage
242 Desc string
243 Repo store.Repo
244 Ref string
245 CloneURL string
246 Dir string
247 Tab string // active tab in the repo header
248 Topics []string
249 Pinned bool // by the viewer
250 Marked bool // bookmarked by the viewer
251 Watch string // the viewer's watch state: watching, muted, or ""
252 HasWiki bool
253 Host string
254 Mirrors []mirrorLine // repo admins only
255 CanAdmin bool // gates the settings tab
256 Feed string // Atom feed for this page, if it has one
257 // OpenIssues and OpenMRs are the counts on the header tabs.
258 OpenIssues int
259 OpenMRs int
260 // RepoHome asks the layout for the full header — description, topics,
261 // website, mirrors. Every other page gets identity and tabs only, so a
262 // repo describes itself once rather than on all twelve of its pages.
263 RepoHome bool
264}
265
266// mirrorLine is the admin-only mirror status shown in the repo header.
267// It carries no credentials: the stored URL is credential-free.
268type mirrorLine struct {
269 Direction string
270 URL string
271 Target string // URL without the scheme, for display
272 Synced string
273 Error string
274}
275
276// syncedAt trims a stored sync timestamp (2026-08-25T03:39:19.994Z) to a
277// readable "2026-08-25 03:39 UTC".
278func syncedAt(ts string) string {
279 if len(ts) < 16 {
280 return ts
281 }
282 return ts[:10] + " " + ts[11:16] + " UTC"
283}
284
285// repoFor resolves the repo for a web request; false means 404 was sent.
286// Anonymous visitors see public repos only; in accounts mode a logged-in
287// viewer additionally sees repos their grants allow. Private and missing
288// repos are indistinguishable either way.
289func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (repoPage, bool) {
290 var repo store.Repo
291 var viewer store.User
292 if s.cfg.Web.Mode == "accounts" {
293 viewer = s.viewer(r)
294 }
295 repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
296 ok := err == nil
297 grant := ""
298 if ok {
299 if viewer.ID != 0 {
300 grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
301 }
302 ok = policyCanRead(viewer, repo, grant)
303 }
304 if !ok {
305 s.notFound(w, r)
306 return repoPage{}, false
307 }
308 if ref == "" {
309 ref = repo.DefaultBranch
310 }
311 topics, _ := s.st.ListTopics(repo.ID)
312 pinned, marked, watch := false, false, ""
313 if viewer.ID != 0 {
314 pinned = s.st.IsPinned(viewer.ID, repo.ID)
315 marked = s.st.IsBookmarked(viewer.ID, repo.ID)
316 watch = s.st.RepoWatchState(repo.ID, viewer.ID)
317 }
318 canAdmin := viewer.ID != 0 && policy.CanAdmin(viewer, repo, grant)
319 var mirrors []mirrorLine
320 if canAdmin {
321 ms, _ := s.st.ListMirrors(repo.ID)
322 for _, m := range ms {
323 mirrors = append(mirrors, mirrorLine{
324 Direction: m.Direction,
325 URL: m.URL,
326 Target: strings.TrimPrefix(strings.TrimPrefix(m.URL, "https://"), "http://"),
327 Synced: syncedAt(m.LastSync),
328 Error: m.LastError,
329 })
330 }
331 }
332 openIssues, openMRs := s.st.OpenCounts(repo.ID)
333 return repoPage{
334 basePage: s.baseFor(viewer),
335 CanAdmin: canAdmin,
336 Mirrors: mirrors,
337 Pinned: pinned,
338 Marked: marked,
339 Watch: watch,
340 HasWiki: s.hasWiki(repo),
341 Host: s.cfg.SiteHost(),
342 Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)),
343 Repo: repo,
344 Ref: ref,
345 CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git",
346 Dir: control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name),
347 Topics: topics,
348 OpenIssues: openIssues,
349 OpenMRs: openMRs,
350 }, true
351}
352
353type crumb struct {
354 Name string
355 URL string
356}
357
358// crumbs builds one crumb per path component. Every component but the
359// last is a directory and links to the tree; only the leaf is a page of
360// the given kind.
361func crumbs(p repoPage, kind, filePath string) []crumb {
362 var cs []crumb
363 parts := strings.Split(strings.Trim(filePath, "/"), "/")
364 acc := ""
365 for i, part := range parts {
366 if part == "" {
367 continue
368 }
369 acc = path.Join(acc, part)
370 k := "tree"
371 if i == len(parts)-1 {
372 k = kind
373 }
374 cs = append(cs, crumb{Name: part, URL: "/" + p.Repo.Path() + "/" + k + "/" + p.Ref + "/" + acc})
375 }
376 return cs
377}
378
379// profileView is profile show's payload, shaped for the templates. The
380// repo rows carry the same names the reporow partial reads, so a profile
381// listing renders identically to explore's.
382// profileView is profile show's payload with the repository rows wrapped
383// so the reporow partial can reach them. The fields themselves are the
384// command's: a field it gains appears here without being re-declared.
385type profileView struct {
386 control.ProfileOut
387 Repos []profileRepoRow `json:"repos"`
388}
389
390// profileRepoRow is one repository row on a profile. The partial asks for
391// OwnerName, Name and Desc; the payload carries a path and a description.
392type profileRepoRow struct {
393 control.ProfileRepo
394}
395
396func (p profileRepoRow) OwnerName() string { owner, _, _ := strings.Cut(p.Path, "/"); return owner }
397func (p profileRepoRow) Name() string { _, name, _ := strings.Cut(p.Path, "/"); return name }
398func (p profileRepoRow) Desc() string { return p.Description }
399
400// ownerPage renders /{owner} for users and orgs: the repositories the
401// viewer may see, org membership either direction. Owner names are not
402// secret (they are on every commit); repository visibility rules hold.
403func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
404 name := r.PathValue("owner")
405 var viewer store.User
406 if s.cfg.Web.Mode == "accounts" {
407 viewer = s.viewer(r)
408 }
409
410 // Everything on this page — membership, the repositories this viewer
411 // may see, the activity year — comes from profile show, so the page
412 // and the command cannot report different things.
413 var d profileView
414 code, msg := s.runControlIntoCode(viewer, []string{"profile", "show", name}, &d)
415 switch {
416 case code == protocol.ExitNotFound:
417 s.notFound(w, r)
418 return
419 case code != protocol.ExitOK:
420 log.Printf("profile %s: %s", name, msg)
421 http.Error(w, "internal error", http.StatusInternalServerError)
422 return
423 }
424
425 counts := make(map[string]int, len(d.Activity))
426 for _, day := range d.Activity {
427 counts[day.Date] = day.Count
428 }
429 weeks, activityTotal := activityGrid(counts)
430
431 teams, canAdmin := s.orgAdminView(viewer, d.Kind, name)
432 profile := store.Profile{Description: d.Description, Website: d.Website,
433 About: d.About, AboutFormat: d.AboutFormat, Links: d.Links}
434 s.render(w, "owner.html", struct {
435 basePage
436 Owner string
437 Kind string
438 Profile store.Profile
439 AboutHTML template.HTML
440 Repos []profileRepoRow
441 Members []control.ProfileMember
442 Orgs []control.ProfileMember
443 Activity []activityWeek
444 ActivityTotal int
445 Teams []teamView
446 CanAdmin bool
447 Self bool
448 Notice string
449 Feed string
450 }{s.baseFor(viewer), name, d.Kind, profile, aboutHTML(profile),
451 d.Repos, d.Members, d.Orgs,
452 weeks, activityTotal, teams, canAdmin,
453 d.Kind == "user" && viewer.ID != 0 && strings.EqualFold(viewer.Username, name),
454 s.takeFlash(w, r), "/" + name + "/activity.atom"})
455}
456
457func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
458 p, ok := s.repoFor(w, r, "")
459 if !ok {
460 return
461 }
462 p.Tab = "files"
463 p.RepoHome = true
464 s.renderTree(w, r, p, "")
465}
466
467func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
468 p, ok := s.repoFor(w, r, r.PathValue("ref"))
469 if !ok {
470 return
471 }
472 p.Tab = "files"
473 s.renderTree(w, r, p, strings.Trim(r.PathValue("path"), "/"))
474}
475
476// treePage is shared by the populated and empty-repository renders: two
477// anonymous structs drifted apart once already.
478type treePage struct {
479 repoPage
480 Crumbs []crumb
481 Prefix string
482 DirPath string
483 RefKind string
484 Entries []gitutil.TreeEntry
485 Branches []gitutil.Ref
486 ReadmeName string
487 ReadmeHTML template.HTML
488 LastCommits map[string]namedCommit
489 Tip namedCommit
490 Facts repoFacts
491 Notice string
492}
493
494func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) {
495 if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil {
496 // Empty repo: render the page with no entries rather than 404.
497 s.render(w, "tree.html", treePage{repoPage: p, RefKind: "tree", Notice: s.takeFlash(w, r)})
498 return
499 }
500 entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath)
501 if err != nil {
502 s.notFound(w, r)
503 return
504 }
505 // Directories first. git's tree order interleaves them with files, but
506 // a listing is scanned by shape before name. Stable, so each group
507 // keeps the ordering git gave it.
508 sort.SliceStable(entries, func(i, j int) bool {
509 return entries[i].Type == "tree" && entries[j].Type != "tree"
510 })
511 prefix := ""
512 if dirPath != "" {
513 prefix = dirPath + "/"
514 }
515
516 var readmeHTML template.HTML
517 readmeName := pickReadme(entries)
518 if readmeName != "" {
519 if raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+readmeName, maxRenderBytes); err == nil {
520 readmeHTML = rewriteRelativeLinks(renderReadme(readmeName, raw), p, dirPath)
521 }
522 }
523
524 branches, _ := gitutil.Refs(p.Dir, "heads")
525 names := make([]string, 0, len(entries))
526 for _, e := range entries {
527 names = append(names, e.Name)
528 }
529 // The facts bar is about the repository, not this directory, so it is
530 // computed once at the root and left off subdirectory listings.
531 var facts repoFacts
532 if dirPath == "" {
533 facts = s.factsFor(p)
534 }
535 s.render(w, "tree.html", treePage{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches,
536 readmeName, readmeHTML,
537 s.namedCommits(gitutil.LastCommits(p.Dir, p.Ref, dirPath, names)),
538 s.namedTip(gitutil.TipCommit(p.Dir, p.Ref)), facts, s.takeFlash(w, r)})
539}
540
541func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
542 p, ok := s.repoFor(w, r, r.PathValue("ref"))
543 if !ok {
544 return
545 }
546 p.Tab = "files"
547 filePath := strings.Trim(r.PathValue("path"), "/")
548 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, maxRenderBytes+1)
549 if err != nil {
550 s.notFound(w, r)
551 return
552 }
553 binary := gitutil.IsBinary(data) || len(data) > maxRenderBytes
554 _, image := imageTypes[strings.ToLower(path.Ext(filePath))]
555
556 var codeHTML template.HTML
557 if !binary && !image {
558 codeHTML = highlight(filePath, data)
559 }
560 // Markdown and org render like a README, with the source one click
561 // away; ?view=source shows the text instead.
562 renderable := false
563 switch path.Ext(strings.ToLower(filePath)) {
564 case ".md", ".markdown", ".org":
565 renderable = !binary
566 }
567 var renderedHTML template.HTML
568 rendered := renderable && r.URL.Query().Get("view") != "source"
569 if rendered {
570 renderedHTML = rewriteRelativeLinks(renderReadme(path.Base(filePath), data), p, path.Dir(filePath))
571 }
572 cs := crumbs(p, "blob", filePath)
573 base := ""
574 if len(cs) > 0 {
575 base = cs[len(cs)-1].Name
576 cs = cs[:len(cs)-1]
577 }
578 branches, _ := gitutil.Refs(p.Dir, "heads")
579 lines := 0
580 if !binary && !image && len(data) > 0 {
581 lines = bytes.Count(data, []byte("\n"))
582 if data[len(data)-1] != '\n' {
583 lines++
584 }
585 }
586 // The file listing leads with the last commit now, so the facts about
587 // the file itself are reported here instead.
588 entry, _ := gitutil.StatPath(p.Dir, p.Ref, filePath)
589 s.render(w, "blob.html", struct {
590 repoPage
591 Crumbs []crumb
592 Base string
593 Path string
594 DirPath string
595 RefKind string
596 Binary bool
597 Image bool
598 Size int
599 Lines int
600 Exec bool
601 Symlink bool
602 Branches []gitutil.Ref
603 CodeHTML template.HTML
604 Renderable bool // markdown or org: the toggle is offered
605 Rendered bool // this response shows the rendering
606 RenderedHTML template.HTML
607 }{p, cs, base, filePath, filePath, "blob", binary, image, len(data), lines,
608 entry.Mode == "100755", entry.Mode == "120000", branches, codeHTML, renderable, rendered, renderedHTML})
609}
610
611// releases lists tag-anchored releases with notes and assets.
612func (s *Server) releases(w http.ResponseWriter, r *http.Request) {
613 p, ok := s.repoFor(w, r, "")
614 if !ok {
615 return
616 }
617 p.Tab = "releases"
618 p.Feed = "/" + p.Repo.Path() + "/releases.atom"
619 rels, err := s.st.ListReleases(p.Repo.ID)
620 if err != nil {
621 http.Error(w, "internal error", http.StatusInternalServerError)
622 return
623 }
624 md := s.ugcFor(r, p.Repo)
625 type relView struct {
626 store.Release
627 NotesHTML template.HTML
628 }
629 var views []relView
630 for _, rel := range rels {
631 views = append(views, relView{rel, md(rel.Notes, rel.NotesFormat)})
632 }
633 // Tags without a release yet are what a create form can offer.
634 released := map[string]bool{}
635 for _, rel := range rels {
636 released[rel.Tag] = true
637 }
638 var freeTags []string
639 if tags, err := gitutil.Refs(p.Dir, "tags"); err == nil {
640 for _, tg := range tags {
641 if !released[tg.Name] {
642 freeTags = append(freeTags, tg.Name)
643 }
644 }
645 }
646 s.render(w, "releases.html", struct {
647 repoPage
648 Releases []relView
649 FreeTags []string
650 CanWrite bool
651 Notice string
652 }{p, views, freeTags, s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)})
653}
654
655// releaseAsset streams one uploaded asset. Tags containing '/' are not
656// reachable here (single path segment); SSH download always works.
657func (s *Server) releaseAsset(w http.ResponseWriter, r *http.Request) {
658 p, ok := s.repoFor(w, r, "")
659 if !ok {
660 return
661 }
662 rel, err := s.st.ReleaseByTag(p.Repo.ID, r.PathValue("tag"))
663 if err != nil {
664 s.notFound(w, r)
665 return
666 }
667 name := r.PathValue("name")
668 found := false
669 for _, a := range rel.Assets {
670 if a.Name == name {
671 found = true
672 }
673 }
674 if !found {
675 s.notFound(w, r)
676 return
677 }
678 f, err := os.Open(filepath.Join(control.RepoDir(s.cfg.Server.Root, p.Repo.OwnerName, p.Repo.Name),
679 "gitbay-releases", strconv.FormatInt(rel.ID, 10), name))
680 if err != nil {
681 s.notFound(w, r)
682 return
683 }
684 defer f.Close()
685 w.Header().Set("Content-Type", "application/octet-stream")
686 w.Header().Set("X-Content-Type-Options", "nosniff")
687 w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
688 if fi, err := f.Stat(); err == nil {
689 w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10))
690 }
691 io.Copy(w, f)
692}
693
694// milestones lists a repo's milestones with progress.
695func (s *Server) milestones(w http.ResponseWriter, r *http.Request) {
696 p, ok := s.repoFor(w, r, "")
697 if !ok {
698 return
699 }
700 p.Tab = "issues"
701 state := r.URL.Query().Get("state")
702 if state != "closed" && state != "all" {
703 state = "open"
704 }
705 readable, err := control.ReadableScope(s.st, s.viewer(r), p.Repo)
706 if err != nil {
707 http.Error(w, "internal error", http.StatusInternalServerError)
708 return
709 }
710 ms, err := s.st.ListMilestones(p.Repo, state, readable)
711 if err != nil {
712 http.Error(w, "internal error", http.StatusInternalServerError)
713 return
714 }
715 type msView struct {
716 store.Milestone
717 Percent int
718 }
719 var views []msView
720 for _, m := range ms {
721 v := msView{Milestone: m}
722 if total := m.OpenItems + m.ClosedItems; total > 0 {
723 v.Percent = m.ClosedItems * 100 / total
724 }
725 views = append(views, v)
726 }
727 s.render(w, "milestones.html", struct {
728 repoPage
729 State string
730 Milestones []msView
731 }{p, state, views})
732}
733
734// search runs a bounded literal git grep over the repo's default branch.
735func (s *Server) search(w http.ResponseWriter, r *http.Request) {
736 p, ok := s.repoFor(w, r, "")
737 if !ok {
738 return
739 }
740 p.Tab = "search"
741 q := strings.TrimSpace(r.URL.Query().Get("q"))
742 type matchView struct {
743 Path string
744 Line int
745 TextHTML template.HTML
746 }
747 var matches []matchView
748 var queryErr string
749 if q != "" {
750 if len(q) < 2 || len(q) > 200 {
751 queryErr = "query must be 2 to 200 characters"
752 } else if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err == nil {
753 raw, err := gitutil.Grep(p.Dir, p.Ref, q, 200)
754 if err != nil {
755 http.Error(w, "internal error", http.StatusInternalServerError)
756 return
757 }
758 for _, m := range raw {
759 matches = append(matches, matchView{m.Path, m.Line, markMatch(m.Text, q)})
760 }
761 }
762 }
763 s.render(w, "search.html", struct {
764 repoPage
765 Query string
766 QueryErr string
767 Matches []matchView
768 Capped bool
769 }{p, q, queryErr, matches, len(matches) == 200})
770}
771
772// markMatch escapes a matched line and wraps case-insensitive occurrences
773// of the query in <mark>.
774func markMatch(text, q string) template.HTML {
775 lower, lq := strings.ToLower(text), strings.ToLower(q)
776 var b strings.Builder
777 pos := 0
778 for {
779 i := strings.Index(lower[pos:], lq)
780 if i < 0 {
781 break
782 }
783 i += pos
784 b.WriteString(template.HTMLEscapeString(text[pos:i]))
785 b.WriteString("<mark>")
786 b.WriteString(template.HTMLEscapeString(text[i : i+len(q)]))
787 b.WriteString("</mark>")
788 pos = i + len(q)
789 }
790 b.WriteString(template.HTMLEscapeString(text[pos:]))
791 return template.HTML(b.String())
792}
793
794func (s *Server) blame(w http.ResponseWriter, r *http.Request) {
795 p, ok := s.repoFor(w, r, r.PathValue("ref"))
796 if !ok {
797 return
798 }
799 p.Tab = "files"
800 filePath := strings.Trim(r.PathValue("path"), "/")
801
802 // Blame is a control command; the web renders what it returns rather
803 // than shelling out to git itself, so all three surfaces agree.
804 page := 1
805 if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n >= 1 {
806 page = n
807 }
808 from := (page-1)*control.BlameSpan + 1
809
810 var out struct {
811 From int `json:"from"`
812 To int `json:"to"`
813 TotalLines int `json:"total_lines"`
814 Hunks []struct {
815 SHA string `json:"sha"`
816 AuthorName string `json:"author_name"`
817 AuthorEmail string `json:"author_email"`
818 Date string `json:"date"`
819 Summary string `json:"summary"`
820 StartLine int `json:"start_line"`
821 Lines []string `json:"lines"`
822 } `json:"hunks"`
823 }
824 argv := []string{"repo", "blame", p.Repo.Path(), filePath,
825 "--ref", p.Ref, "--from", strconv.Itoa(from), "--to", strconv.Itoa(from + control.BlameSpan - 1)}
826 var viewer store.User
827 if s.cfg.Web.Mode == "accounts" {
828 viewer = s.viewer(r)
829 }
830 msg, ok := s.runControlInto(viewer, argv, &out)
831
832 // A binary or empty file is a refusal, not a 404: the page still
833 // renders and says why there is nothing to attribute.
834 binary := false
835 if !ok {
836 if strings.Contains(msg, "is binary") {
837 binary = true
838 } else {
839 s.notFound(w, r)
840 return
841 }
842 }
843
844 type hunkView struct {
845 gitutil.BlameHunk
846 ShortSHA string
847 Date string
848 Sig sigView
849 Numbered []numberedLine
850 }
851 var hunks []hunkView
852 sigs := map[string]sigView{}
853 for _, h := range out.Hunks {
854 v, seen := sigs[h.SHA]
855 if !seen {
856 v, _ = s.sigFor(p.Repo, p.Dir, h.SHA)
857 sigs[h.SHA] = v
858 }
859 date := h.Date
860 if t, err := time.Parse(time.RFC3339, h.Date); err == nil {
861 date = t.Format("2006-01-02")
862 }
863 hv := hunkView{
864 BlameHunk: gitutil.BlameHunk{SHA: h.SHA, AuthorName: h.AuthorName,
865 AuthorEmail: h.AuthorEmail, Summary: h.Summary,
866 StartLine: h.StartLine, Lines: h.Lines},
867 ShortSHA: h.SHA[:min(10, len(h.SHA))], Date: date, Sig: v,
868 }
869 for i, l := range h.Lines {
870 hv.Numbered = append(hv.Numbered, numberedLine{h.StartLine + i, l})
871 }
872 hunks = append(hunks, hv)
873 }
874
875 pages := (out.TotalLines + control.BlameSpan - 1) / control.BlameSpan
876 if pages == 0 {
877 pages = 1
878 }
879 if page > pages {
880 page = pages
881 }
882
883 cs := crumbs(p, "blame", filePath)
884 base := ""
885 if len(cs) > 0 {
886 base = cs[len(cs)-1].Name
887 cs = cs[:len(cs)-1]
888 }
889 s.render(w, "blame.html", struct {
890 repoPage
891 Crumbs []crumb
892 Base string
893 Path string
894 Binary bool
895 Hunks []hunkView
896 Page, Pages int
897 }{p, cs, base, filePath, binary, hunks, page, pages})
898}
899
900type numberedLine struct {
901 N int
902 Text string
903}
904
905// chromaFormatter emits class-based markup (no inline colors), so the
906// stylesheet can swap palettes with the color scheme.
907var chromaFormatter = html.New(html.WithClasses(true),
908 html.WithLineNumbers(true), html.LineNumbersInTable(false),
909 html.WithLinkableLineNumbers(true, "L"))
910
911func highlight(filePath string, data []byte) template.HTML {
912 lexer := lexers.Match(filePath)
913 if lexer == nil {
914 lexer = lexers.Fallback
915 }
916 iterator, err := lexer.Tokenise(nil, string(data))
917 if err != nil {
918 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
919 }
920 var buf bytes.Buffer
921 if err := chromaFormatter.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
922 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
923 }
924 return template.HTML(buf.String())
925}
926
927// chromaCSS is both syntax palettes, each scoped to the scheme it is for.
928// The light one cannot be left unscoped: the two palettes do not name the
929// same token set, and every token github-dark omits would keep its
930// light-theme colour on a black ground — NameAttribute landed at 2.97:1.
931// Scoped, an unnamed token inherits the wrapper's colour instead, which is
932// readable in both. The site's --code-bg stays the background either way.
933// lightStyle and darkStyle are chosen on measured contrast against the
934// grounds code actually sits on here — page, code block, and the diff
935// tints. friendly, the chroma default, put 61 token/ground pairs under
936// 4.5:1; xcode puts one.
937const (
938 lightStyle = "xcode"
939 darkStyle = "github-dark"
940)
941
942var chromaCSS = func() []byte {
943 var buf bytes.Buffer
944 buf.WriteString("@media (prefers-color-scheme: light) {\n")
945 chromaFormatter.WriteCSS(&buf, styles.Get(lightStyle))
946 // xcode's NameAttribute is its one token under 4.5:1 against the diff
947 // tints (4.51 on additions, 4.38 on deletions); darkened it clears both.
948 buf.WriteString(".chroma .na { color: #6f5a21 }\n")
949 buf.WriteString("}\n@media (prefers-color-scheme: dark) {\n")
950 chromaFormatter.WriteCSS(&buf, styles.Get(darkStyle))
951 buf.WriteString("}\n.chroma, .bg { background: transparent !important; }\n")
952 // Line numbers take the site's own gutter colour in both schemes. Left
953 // alone they are github-dark's #6e7681 (4.31:1 on the page) in dark and
954 // chroma's built-in #7f7f7f (3.67:1 on a code block) in light — the
955 // latter is a formatter fallback, not a style entry, so no palette test
956 // can see it.
957 buf.WriteString(".chroma .lnt, .chroma .ln { color: var(--muted) }\n")
958 return buf.Bytes()
959}()
960
961func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
962 p, ok := s.repoFor(w, r, r.PathValue("ref"))
963 if !ok {
964 return
965 }
966 filePath := strings.Trim(r.PathValue("path"), "/")
967 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
968 if err != nil {
969 s.notFound(w, r)
970 return
971 }
972 // Serve inert: never let repo content execute in the forge's origin.
973 // Images get their real type so <img> works under nosniff; SVG script
974 // is dead on arrival because the instance CSP is script-src 'none'.
975 ct := "text/plain; charset=utf-8"
976 if t, ok := imageTypes[strings.ToLower(path.Ext(filePath))]; ok {
977 ct = t
978 }
979 w.Header().Set("Content-Type", ct)
980 w.Header().Set("X-Content-Type-Options", "nosniff")
981 w.Write(data)
982}
983
984// imageTypes are the formats raw serves with a real content type and blob
985// pages preview inline.
986var imageTypes = map[string]string{
987 ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
988 ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif",
989 ".svg": "image/svg+xml", ".ico": "image/x-icon",
990}
991
992// readmeRank orders competing README files: richer renderers win.
993var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
994
995// pickReadme returns the best README-ish blob in a tree listing: any file
996// named "readme" or "readme.<ext>" (case-insensitive), preferring formats
997// we can render richly.
998func pickReadme(entries []gitutil.TreeEntry) string {
999 best, bestRank := "", 1<<30
1000 for _, e := range entries {
1001 if e.Type != "blob" {
1002 continue
1003 }
1004 lower := strings.ToLower(e.Name)
1005 if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
1006 continue
1007 }
1008 rank, ok := readmeRank[path.Ext(lower)]
1009 if !ok {
1010 rank = 10 // plaintext fallback
1011 }
1012 if rank < bestRank {
1013 best, bestRank = e.Name, rank
1014 }
1015 }
1016 return best
1017}
1018
1019// markdown is the shared renderer: GFM (tables, strikethrough, autolinks,
1020// task lists) on top of CommonMark, with class-based fence highlighting
1021// (the palette lives in the stylesheet, per scheme). Raw HTML is still
1022// dropped.
1023// Headings carry ids so a README or wiki section can be linked to, the
1024// way org headings already are (#132).
1025var markdown = goldmark.New(
1026 goldmark.WithParserOptions(parser.WithAutoHeadingID()),
1027 goldmark.WithExtensions(extension.GFM,
1028 highlighting.NewHighlighting(highlighting.WithFormatOptions(html.WithClasses(true)))))
1029
1030// fenceHighlight renders one code block with chroma classes, for org and
1031// anything else outside goldmark. Unknown languages fall back to plain.
1032func fenceHighlight(source, lang string) string {
1033 lexer := lexers.Get(lang)
1034 if lexer == nil {
1035 lexer = lexers.Fallback
1036 }
1037 iterator, err := lexer.Tokenise(nil, source)
1038 if err != nil {
1039 return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
1040 }
1041 var buf bytes.Buffer
1042 f := html.New(html.WithClasses(true))
1043 if err := f.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
1044 return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
1045 }
1046 return buf.String()
1047}
1048
1049// mdHTML renders user-authored markdown (issue and MR bodies, comments).
1050// goldmark's default renderer drops raw HTML, so this is safe as-is.
1051func mdHTML(raw string) template.HTML {
1052 if strings.TrimSpace(raw) == "" {
1053 return ""
1054 }
1055 var buf bytes.Buffer
1056 if markdown.Convert([]byte(raw), &buf) != nil {
1057 return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
1058 }
1059 return template.HTML(buf.String())
1060}
1061
1062// aboutHTML renders a profile's about text. It has no filename to
1063// dispatch on, so the stored format picks the extension; anything other
1064// than org is markdown.
1065func aboutHTML(p store.Profile) template.HTML {
1066 if strings.TrimSpace(p.About) == "" {
1067 return ""
1068 }
1069 name := "about.md"
1070 if p.AboutFormat == "org" {
1071 name = "about.org"
1072 }
1073 return renderReadme(name, []byte(p.About))
1074}
1075
1076// webResolver answers autolink lookups for one viewer. Cross-repo
1077// references to repositories the viewer cannot read stay plain text, per
1078// the enumeration rule: a link would confirm the repo exists.
1079type webResolver struct {
1080 s *Server
1081 viewer store.User
1082}
1083
1084func (r webResolver) RefURL(owner, name string, kind byte, n int64) string {
1085 repo, err := r.s.st.RepoByPath(owner + "/" + name)
1086 if err != nil {
1087 return ""
1088 }
1089 grant := ""
1090 if r.viewer.ID != 0 {
1091 grant, _ = r.s.st.AccessRole(repo.ID, r.viewer.ID)
1092 }
1093 if !policy.CanRead(r.viewer, repo, grant) {
1094 return ""
1095 }
1096 if kind == '#' {
1097 if _, err := r.s.st.IssueByNumber(repo.ID, n); err != nil {
1098 return ""
1099 }
1100 return autolink.IssueURL(repo.OwnerName, repo.Name, n)
1101 }
1102 if _, err := r.s.st.MRByNumber(repo.ID, n); err != nil {
1103 return ""
1104 }
1105 return autolink.MRURL(repo.OwnerName, repo.Name, n)
1106}
1107
1108func (r webResolver) UserURL(name string) string {
1109 if _, err := r.s.st.UserByUsername(name); err == nil {
1110 return "/" + name
1111 }
1112 if _, err := r.s.st.OrgByName(name); err == nil {
1113 return "/" + name
1114 }
1115 return ""
1116}
1117
1118// ugcRenderer renders one user-authored body in the format it was written in.
1119// The format travels with the body: it is recorded when the text is written, so
1120// changing a preference later cannot re-interpret prose that already exists.
1121type ugcRenderer func(raw, format string) template.HTML
1122
1123// ugcHTML renders a user-authored body. Anything other than "org" is markdown,
1124// so a body stored before formats existed — and any row whose column defaulted —
1125// renders exactly as it did before.
1126//
1127// Org goes through renderReadme, the same path READMEs, wiki pages and profile
1128// about text take, so it inherits that function's include guard and sanitising
1129// rather than growing a second org renderer to keep in step.
1130func ugcHTML(raw, format string) template.HTML {
1131 if format == "org" {
1132 return renderOrg("body.org", []byte(raw), false, func() template.HTML {
1133 return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
1134 })
1135 }
1136 return mdHTML(raw)
1137}
1138
1139// ugcFor returns a renderer for user-authored bodies on one repo's pages:
1140// ugcHTML plus cross-reference and mention autolinking for this viewer.
1141func (s *Server) ugcFor(r *http.Request, repo store.Repo) ugcRenderer {
1142 viewer := store.User{}
1143 if s.cfg.Web.Mode == "accounts" {
1144 viewer = s.viewer(r)
1145 }
1146 res := webResolver{s, viewer}
1147 return func(raw, format string) template.HTML {
1148 h := ugcHTML(raw, format)
1149 if h == "" {
1150 return h
1151 }
1152 return template.HTML(autolink.Rewrite(string(h), repo.OwnerName, repo.Name, res))
1153 }
1154}
1155
1156// renderedComment pairs a comment with its rendered body for templates.
1157type renderedComment struct {
1158 Author string
1159 CreatedAt string
1160 Kind string
1161 BodyHTML template.HTML
1162}
1163
1164func renderComments(cs []store.IssueComment, ugc ugcRenderer) []renderedComment {
1165 var out []renderedComment
1166 for _, c := range cs {
1167 out = append(out, renderedComment{c.Author, c.CreatedAt, c.Kind, ugc(c.Body, c.BodyFormat)})
1168 }
1169 return out
1170}
1171
1172// ugcPolicy sanitizes rendered repo content before it enters the forge's
1173// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
1174// output and repo-authored HTML are not. Chroma's highlighting classes
1175// must survive; the pattern admits only short token codes, not the site's
1176// own class names.
1177var ugcPolicy = func() *bluemonday.Policy {
1178 p := bluemonday.UGCPolicy()
1179 p.AllowAttrs("class").
1180 Matching(regexp.MustCompile(`^(chroma|[a-z0-9]{1,3})( (chroma|[a-z0-9]{1,3}))*$`)).
1181 OnElements("span", "pre", "code", "div")
1182 return p
1183}()
1184
1185// renderReadme renders a README by extension: markdown, org-mode, and
1186// (sanitized) HTML richly; everything else as escaped plaintext.
1187// orgConfig is the go-org configuration for rendering untrusted org.
1188//
1189// go-org's default reads #+INCLUDE: and #+SETUPFILE: targets off disk with
1190// os.ReadFile. Everything rendered here is content someone pushed — a README, a
1191// wiki page, a profile — so both keywords are refused outright: the file is
1192// never opened and the keyword stays the inert text it is. There is no safe
1193// subset to allow instead. An absolute path skips go-org's relative-path join,
1194// a relative one resolves against the daemon's working directory, and a repo
1195// has no directory to scope to anyway because the content came from a git
1196// object rather than a checkout.
1197//
1198// The default logger writes parse warnings to stderr, which would let pushed
1199// content write to the server's log; discard them.
1200func orgConfig() *org.Configuration {
1201 c := org.New()
1202 c.ReadFile = func(string) ([]byte, error) {
1203 return nil, errOrgIncludeDisabled
1204 }
1205 c.Log = log.New(io.Discard, "", 0)
1206 return c
1207}
1208
1209var errOrgIncludeDisabled = errors.New("org: #+INCLUDE and #+SETUPFILE are disabled")
1210
1211// renderOrg renders org to sanitized HTML. `contents` asks go-org for its table
1212// of contents: a README or wiki page is a document and carries one, an issue
1213// comment is a remark and should not sprout one above two headings. `fallback`
1214// supplies the plaintext rendering used when the writer fails.
1215func renderOrg(name string, raw []byte, contents bool, fallback func() template.HTML) template.HTML {
1216 c := orgConfig()
1217 if !contents {
1218 // DefaultSettings is a fresh map per org.New(), so this is local.
1219 c.DefaultSettings["OPTIONS"] = strings.ReplaceAll(c.DefaultSettings["OPTIONS"], "toc:t", "toc:nil")
1220 }
1221 doc := c.Parse(bytes.NewReader(raw), name)
1222 writer := org.NewHTMLWriter()
1223 writer.HighlightCodeBlock = func(source, lang string, inline bool, params map[string]string) string {
1224 if inline {
1225 return "<code>" + template.HTMLEscapeString(source) + "</code>"
1226 }
1227 return fenceHighlight(source, lang)
1228 }
1229 writer.ExtendingWriter = &orgWriter{writer}
1230 out, err := doc.Write(writer)
1231 if err != nil {
1232 return fallback()
1233 }
1234 return template.HTML(ugcPolicy.Sanitize(out))
1235}
1236
1237// orgWriter overrides go-org's autolink rendering. go-org ends a bare URL
1238// at the first character outside RFC 3986's set, and that set includes
1239// `.`, `,` and `)`, so a URL closing a sentence or a parenthesis took the
1240// punctuation with it. Org stops a plain link before trailing punctuation
1241// and keeps a `)` only when a `(` inside the link opened it.
1242type orgWriter struct {
1243 *org.HTMLWriter
1244}
1245
1246func (w *orgWriter) WriteRegularLink(l org.RegularLink) {
1247 if !l.AutoLink {
1248 w.HTMLWriter.WriteRegularLink(l)
1249 return
1250 }
1251 url, rest := splitAutolinkPunctuation(l.URL)
1252 l.URL = url
1253 w.HTMLWriter.WriteRegularLink(l)
1254 if rest != "" {
1255 w.WriteText(org.Text{Content: rest})
1256 }
1257}
1258
1259// splitAutolinkPunctuation returns the URL without trailing sentence
1260// punctuation, and the punctuation it removed.
1261func splitAutolinkPunctuation(url string) (string, string) {
1262 end := len(url)
1263 for end > 0 {
1264 switch url[end-1] {
1265 case '.', ',', ';', ':', '!', '?', '\'', '"':
1266 end--
1267 continue
1268 case ')':
1269 if strings.Count(url[:end], ")") > strings.Count(url[:end], "(") {
1270 end--
1271 continue
1272 }
1273 }
1274 break
1275 }
1276 return url[:end], url[end:]
1277}
1278
1279// headingTag matches an opening or closing h1..h5 tag, so a rendered
1280// document's headings can move down one level.
1281var headingTag = regexp.MustCompile(`<(/?)h([1-5])([\s>])`)
1282
1283// demoteHeadings moves every heading in a rendered document down one
1284// level: the page it sits on already has its h1 (the repository, the
1285// file, the wiki page), so a README's own h1 would be a second top-level
1286// heading in the outline (#133). Ids and anchors are untouched.
1287func demoteHeadings(h template.HTML) template.HTML {
1288 return template.HTML(headingTag.ReplaceAllStringFunc(string(h), func(m string) string {
1289 sub := headingTag.FindStringSubmatch(m)
1290 return "<" + sub[1] + "h" + string(rune(sub[2][0]+1)) + sub[3]
1291 }))
1292}
1293
1294func renderReadme(name string, raw []byte) template.HTML {
1295 plain := func() template.HTML {
1296 return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
1297 }
1298 if gitutil.IsBinary(raw) {
1299 return ""
1300 }
1301 switch path.Ext(strings.ToLower(name)) {
1302 case ".md", ".markdown":
1303 var buf bytes.Buffer
1304 if markdown.Convert(raw, &buf) != nil {
1305 return plain()
1306 }
1307 return demoteHeadings(template.HTML(buf.String()))
1308 case ".org":
1309 return demoteHeadings(renderOrg(name, raw, true, plain))
1310 case ".html", ".htm":
1311 return template.HTML(ugcPolicy.Sanitize(string(raw)))
1312 default:
1313 return plain()
1314 }
1315}
1316
1317type diffThread struct {
1318 ID int64
1319 Resolved string
1320 Stale bool
1321 // Pending marks a thread in the viewer's own unsubmitted review. Only
1322 // they are shown it, and the page says so, since it looks exactly
1323 // like a posted one otherwise.
1324 Pending bool
1325 CanResolve bool
1326 Comments []renderedComment
1327}
1328
1329// reviewRights decides which thread controls a viewer sees. mr resolve
1330// admits the thread author, the MR author, or anyone with write, so the
1331// page needs all three to render the button truthfully.
1332type reviewRights struct {
1333 Viewer string
1334 MRAuthor string
1335 Write bool
1336}
1337
1338func (r reviewRights) canResolve(threadAuthor string) bool {
1339 return r.Viewer != "" && (r.Write || r.Viewer == r.MRAuthor || r.Viewer == threadAuthor)
1340}
1341
1342// attachThreads injects review threads under their anchored diff lines;
1343// threads whose anchor no longer appears (stale after force-push, or on a
1344// context line outside the current diff) are returned separately.
1345func attachThreads(files []diffFile, comments []store.DiffComment, headSHA string, md ugcRenderer, rights reviewRights) ([]diffFile, []diffThread) {
1346 type anchor struct {
1347 path string
1348 side string
1349 line int64
1350 }
1351 // Diff-line comments have no stored format yet, so they stay markdown.
1352 // They are the one user-authored body left without the choice; see #51.
1353 threads := map[int64]*diffThread{}
1354 anchors := map[int64]anchor{}
1355 var order []int64
1356 for _, cm := range comments {
1357 if cm.ReplyTo == 0 {
1358 threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
1359 Pending: cm.Pending,
1360 CanResolve: rights.canResolve(cm.Author),
1361 Comments: []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")}}}
1362 anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
1363 order = append(order, cm.ID)
1364 } else if th, ok := threads[cm.ReplyTo]; ok {
1365 th.Comments = append(th.Comments, renderedComment{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")})
1366 }
1367 }
1368 placed := map[int64]bool{}
1369 for f := range files {
1370 lines := files[f].Lines
1371 for i := range lines {
1372 for _, id := range order {
1373 if placed[id] || threads[id].Stale {
1374 continue
1375 }
1376 a := anchors[id]
1377 if lines[i].Path != a.path {
1378 continue
1379 }
1380 if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") ||
1381 (a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") {
1382 lines[i].Threads = append(lines[i].Threads, *threads[id])
1383 files[f].Threads++
1384 files[f].Open = true
1385 placed[id] = true
1386 }
1387 }
1388 }
1389 }
1390 var unplaced []diffThread
1391 for _, id := range order {
1392 if !placed[id] {
1393 unplaced = append(unplaced, *threads[id])
1394 }
1395 }
1396 return files, unplaced
1397}
1398
1399// markCompose opens the new-thread form under one diff line. There is no
1400// JavaScript, so "comment on this line" is a plain GET carrying the
1401// anchor and the page renders the form where the reader asked for it.
1402func markCompose(files []diffFile, q url.Values) {
1403 path := q.Get("cpath")
1404 line, _ := strconv.ParseInt(q.Get("cline"), 10, 64)
1405 if path == "" || line < 1 {
1406 return
1407 }
1408 old := q.Get("cside") == "old"
1409 for f := range files {
1410 for i := range files[f].Lines {
1411 ln := &files[f].Lines[i]
1412 if ln.Path != path {
1413 continue
1414 }
1415 if (old && ln.Class == "del" && ln.OldLine == line) ||
1416 (!old && ln.Class != "del" && ln.NewLine == line) {
1417 ln.Compose = true
1418 files[f].Open = true
1419 return
1420 }
1421 }
1422 }
1423}
1424
1425type sigView struct {
1426 State string
1427 Signer string
1428 Fingerprint string
1429}
1430
1431func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
1432 raw, err := gitutil.ReadCommit(dir, sha)
1433 if err != nil {
1434 return sigView{State: "unsigned"}, nil
1435 }
1436 parsed, err := sig.ParseCommit(raw)
1437 if err != nil {
1438 return sigView{State: "unsigned"}, nil
1439 }
1440 res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
1441 if err != nil {
1442 return sigView{State: "unsigned"}, parsed
1443 }
1444 v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
1445 if res.SignerUserID != 0 {
1446 if u, err := s.st.UserByID(res.SignerUserID); err == nil {
1447 v.Signer = u.Username
1448 }
1449 }
1450 return v, parsed
1451}
1452
1453func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1454 ref := r.PathValue("ref")
1455 p, ok := s.repoFor(w, r, ref)
1456 if !ok {
1457 return
1458 }
1459 p.Tab = "log"
1460 p.Feed = "/" + p.Repo.Path() + "/log.atom/" + p.Ref
1461 const pageSize = 50
1462 // ?path= filters to commits touching one file or directory.
1463 filePath := strings.Trim(path.Clean("/"+r.URL.Query().Get("path")), "/")
1464 if filePath == "." {
1465 filePath = ""
1466 }
1467 var shas []string
1468 var err error
1469 if filePath != "" {
1470 shas, err = gitutil.RevListPath(p.Dir, p.Ref, filePath, pageSize+1)
1471 } else {
1472 shas, err = gitutil.RevList(p.Dir, p.Ref, pageSize+1)
1473 }
1474 if err != nil {
1475 s.notFound(w, r)
1476 return
1477 }
1478 next := ""
1479 if len(shas) > pageSize {
1480 next = shas[pageSize]
1481 shas = shas[:pageSize]
1482 }
1483 type row struct {
1484 SHA, ShortSHA, Subject, AuthorName, AuthorEmail, AuthorUser, Date string
1485 Sig sigView
1486 Check string // combined status, "" when none ran
1487 }
1488 names := s.authorNames()
1489 checks, _ := s.st.CombinedStatusFor(p.Repo.ID, shas)
1490 var rows []row
1491 for _, sha := range shas {
1492 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1493 rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v, Check: checks[sha]}
1494 if parsed != nil {
1495 rw.Subject = parsed.Subject
1496 rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
1497 rw.AuthorUser, _ = names.account(parsed.AuthorEmail)
1498 rw.AuthorEmail = parsed.AuthorEmail
1499 rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1500 }
1501 rows = append(rows, rw)
1502 }
1503 s.render(w, "log.html", struct {
1504 repoPage
1505 Commits []row
1506 NextSHA string
1507 FilePath string
1508 }{p, rows, next, filePath})
1509}
1510
1511func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1512 p, ok := s.repoFor(w, r, "")
1513 if !ok {
1514 return
1515 }
1516 p.Tab = "log"
1517 sha := r.PathValue("sha")
1518 full, err := gitutil.ResolveRef(p.Dir, sha)
1519 if err != nil {
1520 s.notFound(w, r)
1521 return
1522 }
1523 v, parsed := s.sigFor(p.Repo, p.Dir, full)
1524 if parsed == nil {
1525 s.notFound(w, r)
1526 return
1527 }
1528 patch, truncated, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
1529 files := parseDiff(patch)
1530 committerEmail := ""
1531 if parsed.CommitterEmail != parsed.AuthorEmail {
1532 committerEmail = parsed.CommitterEmail
1533 }
1534 checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
1535 commitNames := s.authorNames()
1536 commitUser, _ := commitNames.account(parsed.AuthorEmail)
1537 msg := ""
1538 if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
1539 msg = string(parsed.Payload[i+2:])
1540 }
1541 s.render(w, "commit.html", struct {
1542 repoPage
1543 SHA, ShortSHA, AuthorName, AuthorEmail, AuthorUser, CommitterEmail, Date, Message string
1544 Parents []string
1545 Sig sigView
1546 Checks []store.CommitStatus
1547 DiffFiles []diffFile
1548 DiffTruncated bool
1549 }{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail,
1550 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1551 gitutil.Parents(p.Dir, full), v, checks, files, truncated})
1552}
1553
1554// labelPalette provides default label chip colors: mid-tone hues that stay
1555// legible on light and dark backgrounds.
1556var labelPalette = []string{
1557 "#0969da", "#1a7f37", "#9a6700", "#cf222e",
1558 "#8250df", "#b93a86", "#0b6c80", "#bf5b16",
1559}
1560
1561var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
1562
1563// clampChip keeps a user-set label colour legible as text on both
1564// grounds. Contrast is defined on relative luminance, so that is what is
1565// held: between 0.12 and 0.28, where the chip clears 3:1 against white
1566// and against the dark ground alike, and where the palette's own colours
1567// sit. The hue is kept; the channels are scaled in linear light (#120).
1568func clampChip(hex string) string {
1569 lin := func(c int64) float64 {
1570 v := float64(c) / 255
1571 if v <= 0.04045 {
1572 return v / 12.92
1573 }
1574 return math.Pow((v+0.055)/1.055, 2.4)
1575 }
1576 r, g, b := lin(hexByte(hex[1:3])), lin(hexByte(hex[3:5])), lin(hexByte(hex[5:7]))
1577 y := 0.2126*r + 0.7152*g + 0.0722*b
1578 const lo, hi = 0.12, 0.28
1579 if y >= lo && y <= hi {
1580 return strings.ToLower(hex)
1581 }
1582 target := hi
1583 if y < lo {
1584 target = lo
1585 }
1586 if y == 0 {
1587 r, g, b = target, target, target
1588 } else {
1589 k := target / y
1590 r, g, b = math.Min(1, r*k), math.Min(1, g*k), math.Min(1, b*k)
1591 }
1592 enc := func(v float64) int {
1593 if v <= 0.0031308 {
1594 v *= 12.92
1595 } else {
1596 v = 1.055*math.Pow(v, 1/2.4) - 0.055
1597 }
1598 return int(math.Round(v * 255))
1599 }
1600 return fmt.Sprintf("#%02x%02x%02x", enc(r), enc(g), enc(b))
1601}
1602
1603func hexByte(s string) int64 {
1604 n, _ := strconv.ParseInt(s, 16, 32)
1605 return n
1606}
1607
1608// labelColors returns a complete label-name -> chip color map for a repo:
1609// the stored labels.color when it is a valid hex color, otherwise a
1610// stable default picked from the palette by name hash.
1611func (s *Server) labelColors(repo store.Repo) map[string]template.CSS {
1612 stored, _ := s.st.LabelColors(repo)
1613 return colorStyles(stored)
1614}
1615
1616// colorStyles turns a label-name -> stored color map into chip styles: the
1617// stored color when it is a valid hex color, otherwise a stable default
1618// picked from the palette by name hash.
1619func colorStyles(stored map[string]string) map[string]template.CSS {
1620 out := make(map[string]template.CSS, len(stored))
1621 for name, color := range stored {
1622 if !hexColorPat.MatchString(color) {
1623 h := fnv.New32a()
1624 h.Write([]byte(name))
1625 color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
1626 }
1627 out[name] = template.CSS("--chip:" + clampChip(color))
1628 }
1629 return out
1630}
1631
1632// listPage is how many issues or merge requests a list page shows before
1633// it offers the older ones (#118). Keyset paging on the number, the same
1634// cursor the commands use, so every filter carries across pages.
1635const listPage = 50
1636
1637// olderLink is the current URL with before=<number> set.
1638func olderLink(r *http.Request, before int64) string {
1639 q := r.URL.Query()
1640 q.Set("before", strconv.FormatInt(before, 10))
1641 return "?" + q.Encode()
1642}
1643
1644func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1645 p, ok := s.repoFor(w, r, "")
1646 if !ok {
1647 return
1648 }
1649 p.Tab = "issues"
1650 state := r.URL.Query().Get("state")
1651 if state != "closed" && state != "all" {
1652 state = "open"
1653 }
1654 // The same filters the CLI's issue list takes, as query parameters;
1655 // label chips and author links point here.
1656 qv := r.URL.Query()
1657 f := store.IssueFilter{State: state, Label: qv.Get("label"), Assignee: qv.Get("assignee"),
1658 Author: qv.Get("author"), Milestone: qv.Get("milestone"),
1659 Search: strings.TrimSpace(qv.Get("q")), Limit: listPage + 1}
1660 f.Before, _ = strconv.ParseInt(qv.Get("before"), 10, 64)
1661 issues, err := s.st.QueryIssues(p.Repo.ID, f)
1662 if err != nil {
1663 http.Error(w, "internal error", http.StatusInternalServerError)
1664 return
1665 }
1666 older := ""
1667 if len(issues) > listPage {
1668 issues = issues[:listPage]
1669 older = olderLink(r, issues[len(issues)-1].Number)
1670 }
1671 if labels, err := s.st.ListIssueLabels(p.Repo.ID); err == nil {
1672 for i := range issues {
1673 issues[i].Labels = labels[issues[i].ID]
1674 }
1675 }
1676 s.render(w, "issues.html", struct {
1677 repoPage
1678 State string
1679 Label string
1680 Query string
1681 Filters []listFilter
1682 Issues []store.Issue
1683 LabelColors map[string]template.CSS
1684 Older string
1685 }{p, state, f.Label, f.Search,
1686 activeFilters(state, [][2]string{{"label", f.Label}, {"assignee", f.Assignee}, {"author", f.Author}, {"milestone", f.Milestone}}),
1687 issues, s.labelColors(p.Repo), older})
1688}
1689
1690func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1691 p, ok := s.repoFor(w, r, "")
1692 if !ok {
1693 return
1694 }
1695 p.Tab = "issues"
1696 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1697 if err != nil {
1698 s.notFound(w, r)
1699 return
1700 }
1701 iss, err := s.st.IssueByNumber(p.Repo.ID, n)
1702 if err != nil {
1703 s.notFound(w, r)
1704 return
1705 }
1706 comments, err := s.st.ListIssueComments(iss.ID)
1707 if err != nil {
1708 http.Error(w, "internal error", http.StatusInternalServerError)
1709 return
1710 }
1711 md := s.ugcFor(r, p.Repo)
1712 readable, err := control.ReadableScope(s.st, s.viewer(r), p.Repo)
1713 if err != nil {
1714 http.Error(w, "internal error", http.StatusInternalServerError)
1715 return
1716 }
1717 milestones, _ := s.st.ListMilestones(p.Repo, "open", readable)
1718 s.render(w, "issue.html", struct {
1719 repoPage
1720 Issue store.Issue
1721 BodyHTML template.HTML
1722 Comments []renderedComment
1723 CanEdit bool
1724 CanWrite bool
1725 Milestones []store.Milestone
1726 Notice string
1727 LabelColors map[string]template.CSS
1728 }{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md),
1729 s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
1730 milestones, s.takeFlash(w, r), s.labelColors(p.Repo)})
1731}
1732
1733// canEditItem: the author or anyone with write access may edit.
1734// canWriteRepo reports whether the browser session may push to the repo,
1735// which is what gates the review and merge controls.
1736func (s *Server) canWriteRepo(r *http.Request, repo store.Repo) bool {
1737 if s.cfg.Web.Mode != "accounts" {
1738 return false
1739 }
1740 u := s.viewer(r)
1741 if u.ID == 0 {
1742 return false
1743 }
1744 grant, _ := s.st.AccessRole(repo.ID, u.ID)
1745 return policy.CanWrite(u, repo, grant)
1746}
1747
1748func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool {
1749 if s.cfg.Web.Mode != "accounts" {
1750 return false
1751 }
1752 u := s.viewer(r)
1753 if u.ID == 0 {
1754 return false
1755 }
1756 if u.Username == author {
1757 return true
1758 }
1759 grant, _ := s.st.AccessRole(repo.ID, u.ID)
1760 return policy.CanWrite(u, repo, grant)
1761}
1762
1763func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
1764 p, ok := s.repoFor(w, r, "")
1765 if !ok {
1766 return
1767 }
1768 p.Tab = "merge requests"
1769 state := r.URL.Query().Get("state")
1770 if state == "" {
1771 state = "open"
1772 }
1773 valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
1774 if !valid[state] {
1775 state = "open"
1776 }
1777 qv := r.URL.Query()
1778 mf := store.MRFilter{State: state, Author: qv.Get("author"), Milestone: qv.Get("milestone"),
1779 Search: strings.TrimSpace(qv.Get("q")), Limit: listPage + 1}
1780 mf.Before, _ = strconv.ParseInt(qv.Get("before"), 10, 64)
1781 mrs, err := s.st.QueryMRs(p.Repo.ID, mf)
1782 if err != nil {
1783 http.Error(w, "internal error", http.StatusInternalServerError)
1784 return
1785 }
1786 older := ""
1787 if len(mrs) > listPage {
1788 mrs = mrs[:listPage]
1789 older = olderLink(r, mrs[len(mrs)-1].Number)
1790 }
1791 s.render(w, "mrs.html", struct {
1792 repoPage
1793 State string
1794 Query string
1795 Filters []listFilter
1796 MRs []store.MR
1797 Older string
1798 }{p, state, mf.Search,
1799 activeFilters(state, [][2]string{{"author", mf.Author}, {"milestone", mf.Milestone}}), mrs, older})
1800}
1801
1802func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1803 p, ok := s.repoFor(w, r, "")
1804 if !ok {
1805 return
1806 }
1807 p.Tab = "merge requests"
1808 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1809 if err != nil {
1810 s.notFound(w, r)
1811 return
1812 }
1813 m, err := s.st.MRByNumber(p.Repo.ID, n)
1814 if err != nil {
1815 s.notFound(w, r)
1816 return
1817 }
1818 comments, _ := s.st.ListMRComments(m.ID)
1819 reviews, _ := s.st.ListMRReviews(m.ID)
1820 // The same rule the merge gates apply, so the page cannot show an
1821 // approval the gate ignores (#147).
1822 reviewCounts := control.ReviewersWhoCount(s.st, p.Repo, reviews)
1823 reviewRows := make([]reviewRow, 0, len(reviews))
1824 for _, r := range reviews {
1825 reviewRows = append(reviewRows, reviewRow{MRReview: r, Counts: reviewCounts[r.Reviewer]})
1826 }
1827 checks, combined, _ := s.st.ChecksForCommit(p.Repo.ID, m.HeadSHA)
1828 // The viewer sees their own unsubmitted review comments and nobody
1829 // else's.
1830 diffComments, _ := s.st.ListDiffComments(m.ID, s.webViewer(r).ID)
1831
1832 headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
1833 var files []diffFile
1834 base := m.MergedBase
1835 if base == "" {
1836 if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
1837 base = b
1838 }
1839 }
1840 var diffTruncated bool
1841 if base != "" {
1842 if patch, truncated, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
1843 files, diffTruncated = parseDiff(patch), truncated
1844 }
1845 }
1846 md := s.ugcFor(r, p.Repo)
1847 canWrite := s.canWriteRepo(r, p.Repo)
1848 var detachedThreads []diffThread
1849 files, detachedThreads = attachThreads(files, diffComments, m.HeadSHA, md,
1850 reviewRights{Viewer: p.Viewer, MRAuthor: m.Author, Write: canWrite})
1851 if p.Viewer != "" {
1852 markCompose(files, r.URL.Query())
1853 }
1854 stat := statOf(files)
1855 // The commits this MR carries: base..head, the same range as the diff.
1856 type commitRow struct {
1857 SHA, ShortSHA, Subject, AuthorName, AuthorUser, Date string
1858 Sig sigView
1859 }
1860 mrNames := s.authorNames()
1861 var commits []commitRow
1862 commitsTotal := 0
1863 if base != "" {
1864 const maxMRCommits = 100
1865 shas, _ := gitutil.RevListRange(p.Dir, base, headRef)
1866 commitsTotal = len(shas)
1867 if len(shas) > maxMRCommits {
1868 shas = shas[:maxMRCommits]
1869 }
1870 for _, sha := range shas {
1871 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1872 cr := commitRow{SHA: sha, ShortSHA: sha[:10], Sig: v}
1873 if parsed != nil {
1874 cr.Subject = parsed.Subject
1875 cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName)
1876 cr.AuthorUser, _ = mrNames.account(parsed.AuthorEmail)
1877 cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1878 }
1879 commits = append(commits, cr)
1880 }
1881 }
1882 // The diff is the reason most people open a merge request, so it gets
1883 // its own view rather than a fold at the foot of the conversation.
1884 // A query parameter keeps this working without JavaScript.
1885 unresolved, _ := s.st.UnresolvedThreadCount(m.ID)
1886 // The revisions this merge request has had. A stale review is the
1887 // moment someone wants to know what moved, so the link to the
1888 // range-diff belongs next to it.
1889 revisions, _ := s.st.MRHeads(m.ID)
1890 branches, _ := gitutil.Refs(p.Dir, "heads")
1891 view := r.URL.Query().Get("view")
1892 if view != "commits" && view != "diff" {
1893 view = "conversation"
1894 }
1895 // Where the merge request stands against the gates, the same
1896 // computation mr merge refuses on (#199).
1897 var gates *control.GatesOut
1898 if m.State == "open" || m.State == "source_gone" {
1899 if targetSHA, err := gitutil.ResolveRef(p.Dir, "refs/heads/"+m.TargetRef); err == nil {
1900 if g, err := control.MergeGates(s.st, p.Repo, m, p.Dir, targetSHA, m.HeadSHA); err == nil {
1901 gates = &g
1902 }
1903 }
1904 }
1905 // The stack around an open merge request, for the header.
1906 var stackedOn *store.MR
1907 var stacked []store.MR
1908 if m.State == "open" {
1909 if parent, ok, err := s.st.OpenMRBySource(p.Repo.ID, m.TargetRef); err == nil && ok && parent.ID != m.ID {
1910 stackedOn = &parent
1911 }
1912 if m.SourceRepoID == p.Repo.ID {
1913 stacked, _ = s.st.OpenMRsByTarget(p.Repo.ID, m.SourceRef)
1914 }
1915 }
1916 s.render(w, "mr.html", struct {
1917 repoPage
1918 MR store.MR
1919 View string
1920 BodyHTML template.HTML
1921 Checks []store.Check
1922 Combined string
1923 Comments []renderedComment
1924 Reviews []reviewRow
1925 DiffFiles []diffFile
1926 DiffTruncated bool
1927 Stat diffStat
1928 Commits []commitRow
1929 CommitsTotal int
1930 Branches []gitutil.Ref
1931 CanEdit bool
1932 CanWrite bool
1933 Unresolved int
1934 Revisions []store.MRHead
1935 Notice string
1936 DetachedThreads []diffThread
1937 StackedOn *store.MR
1938 Stacked []store.MR
1939 Gates *control.GatesOut
1940 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
1941 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
1942 canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, gates})
1943}
1944
1945func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
1946 p, ok := s.repoFor(w, r, "")
1947 if !ok {
1948 return
1949 }
1950 p.Tab = "refs"
1951 branches, _ := gitutil.Refs(p.Dir, "heads")
1952 tags, _ := gitutil.Refs(p.Dir, "tags")
1953 s.render(w, "refs.html", struct {
1954 repoPage
1955 Branches, Tags []gitutil.Ref
1956 }{p, branches, tags})
1957}
1958
1959func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
1960 p, ok := s.repoFor(w, r, "")
1961 if !ok {
1962 return
1963 }
1964 file := r.PathValue("file")
1965 ref, ok := strings.CutSuffix(file, ".tar.gz")
1966 if !ok {
1967 s.notFound(w, r)
1968 return
1969 }
1970 if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
1971 s.notFound(w, r)
1972 return
1973 }
1974 prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
1975 w.Header().Set("Content-Type", "application/gzip")
1976 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
1977 gitutil.Archive(p.Dir, ref, prefix, w)
1978}
1979
1980func policyCanAdmin(u store.User, repo store.Repo, grant string) bool {
1981 return policy.CanAdmin(u, repo, grant)
1982}
1983
1984func policyCanRead(u store.User, repo store.Repo, grant string) bool {
1985 return policy.CanRead(u, repo, grant)
1986}
1987
1988// reviewRow is a review with whether the merge gates count it, which
1989// depends on the reviewer's access and so is not a property of the
1990// review row itself.
1991type reviewRow struct {
1992 store.MRReview
1993 Counts bool
1994}