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