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