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