internal/httpd/web.go
2518 lines · 82317 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 "strconv"
24 "strings"
25 "time"
26
27 "github.com/alecthomas/chroma/v2/formatters/html"
28 "github.com/alecthomas/chroma/v2/lexers"
29 "github.com/alecthomas/chroma/v2/styles"
30 "github.com/microcosm-cc/bluemonday"
31 "github.com/niklasfasching/go-org/org"
32 "github.com/yuin/goldmark"
33 highlighting "github.com/yuin/goldmark-highlighting/v2"
34 "github.com/yuin/goldmark/extension"
35 "github.com/yuin/goldmark/parser"
36
37 "gitbay.org/gitbay/internal/autolink"
38 "gitbay.org/gitbay/internal/control"
39 "gitbay.org/gitbay/internal/gitutil"
40 "gitbay.org/gitbay/internal/sig"
41 "gitbay.org/gitbay/internal/store"
42 "gitbay.org/gitbay/internal/suggest"
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// stylesheetHash is the hash of what stylesheet serves, computed once. It
69// is the ETag, so a browser revalidating with If-None-Match gets a 304
70// until a deploy changes the bytes (#132), and it is the ?v= the layout
71// stamps on the URL, so a deploy the browser has not fetched yet cannot be
72// answered from its cache (#239).
73var stylesheetHash = func() string {
74 h := sha256.New()
75 h.Write(styleCSS)
76 h.Write(chromaCSS)
77 return hex.EncodeToString(h.Sum(nil))[:16]
78}()
79
80var stylesheetETag = `"` + stylesheetHash + `"`
81
82func init() { web.StyleVersion = stylesheetHash }
83
84func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) {
85 w.Header().Set("ETag", stylesheetETag)
86 // A URL carrying this build's hash names bytes that cannot change, so
87 // it never needs revalidating. The bare URL still can, and keeps the
88 // policy it had.
89 if r.URL.Query().Get("v") == stylesheetHash {
90 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
91 } else {
92 w.Header().Set("Cache-Control", "public, max-age=86400, must-revalidate")
93 }
94 if r.Header.Get("If-None-Match") == stylesheetETag {
95 w.WriteHeader(http.StatusNotModified)
96 return
97 }
98 w.Header().Set("Content-Type", "text/css; charset=utf-8")
99 w.Write(styleCSS)
100 w.Write(chromaCSS)
101}
102
103func (s *Server) favicon(w http.ResponseWriter, r *http.Request) {
104 w.Header().Set("Content-Type", "image/svg+xml")
105 w.Write(web.FaviconSVG)
106}
107
108// font serves the embedded Atkinson Hyperlegible subsets. Same-origin,
109// so the CSP's default-src 'self' covers it — no font CDN.
110func (s *Server) font(w http.ResponseWriter, r *http.Request) {
111 data, err := web.FontFS.ReadFile("static" + r.URL.Path[len("/static"):])
112 if err != nil {
113 http.NotFound(w, r)
114 return
115 }
116 w.Header().Set("Content-Type", "font/woff2")
117 w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
118 w.Write(data)
119}
120
121var staticTypes = map[string]string{
122 ".gif": "image/gif",
123 ".webm": "video/webm",
124 ".mp4": "video/mp4",
125}
126
127// image serves the embedded landing recording with the font cache policy.
128// ServeContent answers Range, which Safari needs to play video.
129func (s *Server) image(w http.ResponseWriter, r *http.Request) {
130 name := "static" + r.URL.Path[len("/static"):]
131 data, err := web.ImageFS.ReadFile(name)
132 if err != nil {
133 http.NotFound(w, r)
134 return
135 }
136 w.Header().Set("Content-Type", staticTypes[path.Ext(name)])
137 w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
138 http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(data))
139}
140
141// notFound renders the designed 404 page with a 404 status. Falls back to
142// the stock plain-text response if the template fails.
143func (s *Server) notFound(w http.ResponseWriter, r *http.Request) {
144 var buf bytes.Buffer
145 if err := web.Render(&buf, "404.html", s.base(r)); err != nil {
146 http.NotFound(w, r)
147 return
148 }
149 w.Header().Set("Content-Type", "text/html; charset=utf-8")
150 w.WriteHeader(http.StatusNotFound)
151 buf.WriteTo(w)
152}
153
154// describedRepo pairs a repo with the listing metadata: description,
155// topics, license, and last-updated date.
156type describedRepo struct {
157 store.Repo
158 Desc string
159 Topics []string
160 License string
161 Updated string
162}
163
164// Archived flattens the settings flag so the reporow partial can read the
165// same field name from a describedRepo and from a profile's repo row.
166func (d describedRepo) Archived() bool { return d.Settings.Archived }
167
168func (s *Server) describeAll(repos []store.Repo) []describedRepo {
169 var out []describedRepo
170 for _, r := range repos {
171 dir := control.RepoDir(s.cfg.Server.Root, r.OwnerName, r.Name)
172 d := describedRepo{
173 Repo: r,
174 Desc: gitutil.ReadDescription(dir),
175 License: control.DetectLicense(dir, r.DefaultBranch),
176 Updated: gitutil.LastCommitDate(dir, r.DefaultBranch),
177 }
178 d.Topics, _ = s.st.ListTopics(r.ID)
179 out = append(out, d)
180 }
181 return out
182}
183
184// index is the homepage: a dashboard for logged-in users, a landing page
185// for everyone else. The full public listing lives at /explore.
186func (s *Server) index(w http.ResponseWriter, r *http.Request) {
187 if s.cfg.Web.Mode == "accounts" {
188 if viewer := s.viewer(r); viewer.ID != 0 {
189 s.dashboard(w, r, viewer)
190 return
191 }
192 }
193 host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(
194 s.cfg.Server.SiteURL, "https://"), "http://"), "/")
195 s.render(w, "landing.html", struct {
196 basePage
197 Host string
198 Accounts bool
199 Signup bool
200 EmailLogin bool
201 }{s.anonBase(), host, s.cfg.Web.Mode == "accounts",
202 s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed",
203 s.emailLoginEnabled()})
204}
205
206func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.User) {
207 mrs, _ := s.st.DashboardMRs(viewer.ID)
208 issues, _ := s.st.DashboardIssues(viewer.ID)
209 reviews, _ := s.st.ReviewQueue(viewer.ID)
210 assigned, _ := s.st.AssignedIssues(viewer.ID)
211 events, _ := s.st.RecentEvents(viewer.ID, 20, 0)
212 queries, _ := control.PinnedQueries(s.st, viewer)
213 s.render(w, "dashboard.html", struct {
214 basePage
215 Tab string
216 Pins []pinnedRow
217 Reviews []store.DashboardItem
218 Assigned []store.DashboardItem
219 MRs []store.DashboardItem
220 Issues []store.DashboardItem
221 Queries []control.DashboardQuery
222 Feed []control.FeedLine
223 }{s.baseFor(viewer), "dashboard", s.pinnedRows(viewer), reviews, assigned, mrs, issues, queries, control.FeedLines(events)})
224}
225
226// explorePageSize is how many repositories one page of /explore lists.
227const explorePageSize = 20
228
229// explorePage is /explore: one page of the filtered listing, most recent
230// activity first. Total counts the whole filtered listing.
231type explorePage struct {
232 basePage
233 Tab string
234 Query string
235 Facets []facetGroup
236 Repos []describedRepo
237 Total int
238 Page int
239 Pages int
240 Prev string
241 Next string
242}
243
244func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
245 repos, err := s.st.ListPublicReposByActivity()
246 if err != nil {
247 http.Error(w, "internal error", http.StatusInternalServerError)
248 return
249 }
250 var viewer store.User
251 if s.cfg.Web.Mode == "accounts" {
252 viewer = s.viewer(r)
253 }
254 q := strings.TrimSpace(r.URL.Query().Get("q"))
255 described := s.describeAll(repos)
256 p := explorePage{basePage: s.baseFor(viewer), Tab: "explore", Query: q,
257 Facets: []facetGroup{topicFacets(described, q)}}
258 p.Repos, p.Total, p.Page, p.Pages = pageOf(s.filterRepos(q, described), r.URL.Query().Get("page"))
259 if p.Page > 1 {
260 p.Prev = explorePageURL(q, p.Page-1)
261 }
262 if p.Page < p.Pages {
263 p.Next = explorePageURL(q, p.Page+1)
264 }
265 s.render(w, "explore.html", p)
266}
267
268// pageOf returns page n (1-based, clamped) of repos at explorePageSize.
269func pageOf(repos []describedRepo, n string) (page []describedRepo, total, num, pages int) {
270 total = len(repos)
271 pages = max(1, (total+explorePageSize-1)/explorePageSize)
272 num, _ = strconv.Atoi(n)
273 num = min(max(num, 1), pages)
274 lo := (num - 1) * explorePageSize
275 return repos[lo:min(lo+explorePageSize, total)], total, num, pages
276}
277
278func explorePageURL(q string, page int) string {
279 v := url.Values{}
280 if q != "" {
281 v.Set("q", q)
282 }
283 if page > 1 {
284 v.Set("page", strconv.Itoa(page))
285 }
286 if len(v) == 0 {
287 return "/explore"
288 }
289 return "/explore?" + v.Encode()
290}
291
292// privacy renders the privacy page: what the gitbay software does with
293// data, plus this instance's operator-provided notes.
294func (s *Server) privacy(w http.ResponseWriter, r *http.Request) {
295 s.render(w, "privacy.html", struct {
296 basePage
297 Host string
298 Notice string
299 }{s.base(r), s.cfg.SiteHost(), s.cfg.Web.PrivacyNotice})
300}
301
302// filterRepos keeps repos matching the query by the same rule `repo
303// search` uses. An empty query keeps everything.
304func (s *Server) filterRepos(q string, repos []describedRepo) []describedRepo {
305 if q == "" {
306 return repos
307 }
308 var out []describedRepo
309 for _, d := range repos {
310 if control.MatchesRepo(q, d.Path(), d.Desc, d.Topics) {
311 out = append(out, d)
312 }
313 }
314 return out
315}
316
317// repoPage is the shared context for repo-scoped pages.
318type repoPage struct {
319 basePage
320 Desc string
321 Repo store.Repo
322 Ref string
323 CloneURL string
324 // SSHCloneURL is the same repository over the SSH transport, which is
325 // the one a push needs.
326 SSHCloneURL string
327 Dir string
328 Tab string // active tab in the repo header
329 Topics []string
330 Pinned bool // by the viewer
331 Marked bool // bookmarked by the viewer
332 Watch string // the viewer's watch state: watching, muted, or ""
333 HasWiki bool
334 Host string
335 Mirrors []mirrorLine // repo admins only
336 CanAdmin bool // gates the settings tab
337 Feed string // Atom feed for this page, if it has one
338 // OpenIssues and OpenMRs are the counts on the header tabs.
339 OpenIssues int
340 OpenMRs int
341 // RepoHome asks the layout for the full header — description, topics,
342 // website, mirrors. Every other page gets identity and tabs only, so a
343 // repo describes itself once rather than on all twelve of its pages.
344 RepoHome bool
345}
346
347// mirrorLine is the admin-only mirror status shown in the repo header.
348// It carries no credentials: the stored URL is credential-free.
349type mirrorLine struct {
350 Direction string
351 URL string
352 Target string // URL without the scheme, for display
353 Synced string
354 Error string
355}
356
357// syncedAt trims a stored sync timestamp (2026-08-25T03:39:19.994Z) to a
358// readable "2026-08-25 03:39 UTC".
359func syncedAt(ts string) string {
360 if len(ts) < 16 {
361 return ts
362 }
363 return ts[:10] + " " + ts[11:16] + " UTC"
364}
365
366// repoFor resolves the repo for a web request; false means 404 was sent.
367// Anonymous visitors see public repos only; in accounts mode a logged-in
368// viewer additionally sees repos their grants allow. Private and missing
369// repos are indistinguishable either way.
370func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (repoPage, bool) {
371 var repo store.Repo
372 var viewer store.User
373 if s.cfg.Web.Mode == "accounts" {
374 viewer = s.viewer(r)
375 }
376 repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
377 ok := err == nil
378 grant := ""
379 if ok {
380 if viewer.ID != 0 {
381 grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
382 }
383 ok = policyCanRead(viewer, repo, grant)
384 }
385 if !ok {
386 s.notFound(w, r)
387 return repoPage{}, false
388 }
389 if ref == "" {
390 ref = repo.DefaultBranch
391 }
392 topics, _ := s.st.ListTopics(repo.ID)
393 pinned, marked, watch := false, false, ""
394 if viewer.ID != 0 {
395 pinned = s.st.IsPinned(viewer.ID, repo.ID)
396 marked = s.st.IsBookmarked(viewer.ID, repo.ID)
397 watch = s.st.RepoWatchState(repo.ID, viewer.ID)
398 }
399 canAdmin := viewer.ID != 0 && policy.CanAdmin(viewer, repo, grant)
400 var mirrors []mirrorLine
401 if canAdmin {
402 ms, _ := s.st.ListMirrors(repo.ID)
403 for _, m := range ms {
404 mirrors = append(mirrors, mirrorLine{
405 Direction: m.Direction,
406 URL: m.URL,
407 Target: strings.TrimPrefix(strings.TrimPrefix(m.URL, "https://"), "http://"),
408 Synced: syncedAt(m.LastSync),
409 Error: m.LastError,
410 })
411 }
412 }
413 openIssues, openMRs := s.st.OpenCounts(repo.ID)
414 return repoPage{
415 basePage: s.baseFor(viewer),
416 CanAdmin: canAdmin,
417 Mirrors: mirrors,
418 Pinned: pinned,
419 Marked: marked,
420 Watch: watch,
421 HasWiki: s.hasWiki(repo),
422 Host: s.cfg.SiteHost(),
423 Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)),
424 Repo: repo,
425 Ref: ref,
426 CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git",
427 SSHCloneURL: s.sshCloneURL(repo),
428 Dir: control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name),
429 Topics: topics,
430 OpenIssues: openIssues,
431 OpenMRs: openMRs,
432 }, true
433}
434
435type crumb struct {
436 Name string
437 URL string
438}
439
440// crumbs builds one crumb per path component. Every component but the
441// last is a directory and links to the tree; only the leaf is a page of
442// the given kind.
443func crumbs(p repoPage, kind, filePath string) []crumb {
444 var cs []crumb
445 parts := strings.Split(strings.Trim(filePath, "/"), "/")
446 acc := ""
447 for i, part := range parts {
448 if part == "" {
449 continue
450 }
451 acc = path.Join(acc, part)
452 k := "tree"
453 if i == len(parts)-1 {
454 k = kind
455 }
456 cs = append(cs, crumb{Name: part, URL: "/" + p.Repo.Path() + "/" + k + "/" + p.Ref + "/" + acc})
457 }
458 return cs
459}
460
461// profileView is profile show's payload, shaped for the templates. The
462// repo rows carry the same names the reporow partial reads, so a profile
463// listing renders identically to explore's.
464// profileView is profile show's payload with the repository rows wrapped
465// so the reporow partial can reach them. The fields themselves are the
466// command's: a field it gains appears here without being re-declared.
467type profileView struct {
468 control.ProfileOut
469 Repos []profileRepoRow `json:"repos"`
470}
471
472// profileRepoRow is one repository row on a profile. The partial asks for
473// OwnerName, Name and Desc; the payload carries a path and a description.
474type profileRepoRow struct {
475 control.ProfileRepo
476}
477
478func (p profileRepoRow) OwnerName() string { owner, _, _ := strings.Cut(p.Path, "/"); return owner }
479func (p profileRepoRow) Name() string { _, name, _ := strings.Cut(p.Path, "/"); return name }
480func (p profileRepoRow) Desc() string { return p.Description }
481
482// ownerPage renders /{owner} for users and orgs: the repositories the
483// viewer may see, org membership either direction. Owner names are not
484// secret (they are on every commit); repository visibility rules hold.
485// profileTab is which section of a profile a URL asks for. The bare
486// /{owner} is About, the first tab; the rest hang off the /-/ namespace
487// the labels and milestones pages already use. What #242 asked for is
488// that the sections be separate pages rather than one stack a long
489// About pushes the repositories off the bottom of — not that any one of
490// them be the landing page.
491func profileTab(path string) string {
492 switch {
493 case strings.HasSuffix(path, "/-/repositories"):
494 return "repos"
495 case strings.HasSuffix(path, "/-/bookmarks"):
496 return "bookmarks"
497 case strings.HasSuffix(path, "/-/snippets"):
498 return "snippets"
499 case strings.HasSuffix(path, "/-/people"):
500 return "people"
501 }
502 return "about"
503}
504
505// profileEvents is how many activity lines the About tab lists under the
506// graph. The graph is a year at a glance; the log is what happened
507// lately, and a fixed count keeps the page the same length whatever the
508// account's pace.
509const profileEvents = 30
510
511// ownerFeed is the activity log under the graph on the About tab: the
512// newest of whatever the graph above it counts, on public repositories
513// only. That is the actor's own events for a user and the
514// organization's repositories' events for an org, matching
515// ActivityByDay and OrgActivityByDay respectively — a log that counted
516// something else would contradict the total printed over it. Only the
517// About tab renders it, so no other tab pays for the query.
518func (s *Server) ownerFeed(tab, kind, name string) []control.FeedLine {
519 if tab != "about" {
520 return nil
521 }
522 var events []store.FeedEvent
523 var err error
524 switch kind {
525 case "user":
526 u, uerr := s.st.UserByUsername(name)
527 if uerr != nil {
528 return nil
529 }
530 events, err = s.st.UserPublicEvents(u.ID, profileEvents)
531 case "org":
532 o, oerr := s.st.OrgByName(name)
533 if oerr != nil {
534 return nil
535 }
536 events, err = s.st.OwnerPublicEvents("org", o.ID, profileEvents)
537 }
538 if err != nil {
539 return nil
540 }
541 return control.FeedLines(events)
542}
543
544// ownerPage is what owner.html renders against. It is a named type
545// because the handler and the tests must agree on it field for field,
546// and an anonymous struct in two places drifts.
547type ownerPage struct {
548 basePage
549 Owner string
550 Kind string
551 Tab string
552 Profile store.Profile
553 AboutHTML template.HTML
554 Repos []profileRepoRow
555 Members []control.ProfileMember
556 Orgs []control.ProfileMember
557 Activity []activityWeek
558 ActivityTotal int
559 Log []control.FeedLine
560 Bookmarks []control.BookmarkOut
561 SnippetRows []snippetRow
562 SnippetsAll bool
563 Teams []teamView
564 CanAdmin bool
565 Self bool
566 Snippets int
567 Notice string
568 Reauth bool // Notice is the stale-session refusal: link to sign in
569 Feed string
570}
571
572func (s *Server) ownerProfile(w http.ResponseWriter, r *http.Request) {
573 name := r.PathValue("owner")
574 var viewer store.User
575 if s.cfg.Web.Mode == "accounts" {
576 viewer = s.viewer(r)
577 }
578
579 // Everything on this page — membership, the repositories this viewer
580 // may see, the activity year — comes from profile show, so the page
581 // and the command cannot report different things.
582 var d profileView
583 code, msg := s.runControlIntoCode(viewer, []string{"profile", "show", name}, &d)
584 switch {
585 case code == protocol.ExitNotFound:
586 s.notFound(w, r)
587 return
588 case code != protocol.ExitOK:
589 log.Printf("profile %s: %s", name, msg)
590 http.Error(w, "internal error", http.StatusInternalServerError)
591 return
592 }
593
594 counts := make(map[string]int, len(d.Activity))
595 for _, day := range d.Activity {
596 counts[day.Date] = day.Count
597 }
598 weeks, activityTotal := activityGrid(counts)
599
600 teams, canAdmin := s.orgAdminView(viewer, d.Kind, name)
601 self := d.Kind == "user" && viewer.ID != 0 && strings.EqualFold(viewer.Username, name)
602 tab := profileTab(r.URL.Path)
603 // A tab nobody may open is not a page: the people tab is the
604 // organization admin panel, bookmarks are the viewer's own and
605 // nobody else's, and only a user has snippets. Each answers the way
606 // a missing page does rather than rendering empty.
607 if (tab == "people" && !canAdmin) || (tab == "bookmarks" && !self) ||
608 (tab == "snippets" && d.Kind != "user") {
609 s.notFound(w, r)
610 return
611 }
612
613 var bookmarks []control.BookmarkOut
614 if tab == "bookmarks" {
615 s.runControlInto(viewer, []string{"repo", "bookmarks"}, &bookmarks)
616 }
617 var snippets []snippetRow
618 if tab == "snippets" {
619 var ok bool
620 if snippets, ok = s.ownerSnippets(w, r, viewer, name); !ok {
621 return
622 }
623 }
624 notice := s.takeFlash(w, r)
625 s.render(w, "owner.html", ownerPage{
626 basePage: s.baseFor(viewer),
627 Owner: name,
628 Kind: d.Kind,
629 Tab: tab,
630 Profile: store.Profile{Description: d.Description, Website: d.Website, Links: d.Links},
631 AboutHTML: aboutHTML(d.About, d.AboutFormat),
632 Repos: d.Repos,
633 Members: d.Members,
634 Orgs: d.Orgs,
635 Activity: weeks,
636 ActivityTotal: activityTotal,
637 Log: s.ownerFeed(tab, d.Kind, name),
638 Bookmarks: bookmarks,
639 SnippetRows: snippets,
640 SnippetsAll: self || viewer.IsAdmin,
641 Teams: teams,
642 CanAdmin: canAdmin,
643 Self: self,
644 Snippets: d.Snippets,
645 Notice: notice,
646 Reauth: s.reauthNotice(w, notice, r.URL.Path),
647 Feed: "/" + name + "/activity.atom",
648 })
649}
650
651func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
652 p, ok := s.repoFor(w, r, "")
653 if !ok {
654 return
655 }
656 p.Tab = "files"
657 p.RepoHome = true
658 s.renderTree(w, r, p, "")
659}
660
661func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
662 p, ok := s.repoFor(w, r, r.PathValue("ref"))
663 if !ok {
664 return
665 }
666 p.Tab = "files"
667 path := strings.Trim(r.PathValue("path"), "/")
668 // The root of the default branch is the same page as the bare repo
669 // URL, so its header must match: RepoHome is what picks the h1 over
670 // the p+link identity, not which route was typed.
671 p.RepoHome = path == "" && p.Ref == p.Repo.DefaultBranch
672 s.renderTree(w, r, p, path)
673}
674
675// treePage is shared by the populated and empty-repository renders: two
676// anonymous structs drifted apart once already.
677type treePage struct {
678 repoPage
679 Crumbs []crumb
680 Prefix string
681 DirPath string
682 RefKind string
683 Entries []gitutil.TreeEntry
684 Branches []gitutil.Ref
685 ReadmeName string
686 ReadmeHTML template.HTML
687 LastCommits map[string]namedCommit
688 Tip namedCommit
689 Facts repoFacts
690 Notice string
691}
692
693func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) {
694 if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil {
695 // Empty repo: render the page with no entries rather than 404.
696 s.render(w, "tree.html", treePage{repoPage: p, RefKind: "tree", Notice: s.takeFlash(w, r)})
697 return
698 }
699 entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath)
700 if err != nil {
701 s.notFound(w, r)
702 return
703 }
704 sortDirsFirst(entries)
705 prefix := ""
706 if dirPath != "" {
707 prefix = dirPath + "/"
708 }
709
710 var readmeHTML template.HTML
711 readmeName := control.PickReadme(entries)
712 if readmeName != "" {
713 if raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+readmeName, maxRenderBytes); err == nil {
714 readmeHTML = rewriteRelativeLinks(renderReadme(readmeName, raw), p, dirPath)
715 }
716 }
717
718 branches, _ := gitutil.Refs(p.Dir, "heads")
719 names := make([]string, 0, len(entries))
720 for _, e := range entries {
721 names = append(names, e.Name)
722 }
723 // The facts bar is about the repository, not this directory, so it is
724 // computed once at the root and left off subdirectory listings.
725 var facts repoFacts
726 if dirPath == "" {
727 facts = s.factsFor(p)
728 }
729 s.render(w, "tree.html", treePage{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches,
730 readmeName, readmeHTML,
731 s.namedCommits(gitutil.LastCommits(p.Dir, p.Ref, dirPath, names)),
732 s.namedTip(gitutil.TipCommit(p.Dir, p.Ref)), facts, s.takeFlash(w, r)})
733}
734
735func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
736 p, ok := s.repoFor(w, r, r.PathValue("ref"))
737 if !ok {
738 return
739 }
740 p.Tab = "files"
741 filePath := strings.Trim(r.PathValue("path"), "/")
742 // The file and its symbol links are read from one commit, even if the
743 // ref moves while the page renders.
744 commit, err := gitutil.ResolveRef(p.Dir, p.Ref)
745 if err != nil {
746 s.notFound(w, r)
747 return
748 }
749 data, err := gitutil.ReadBlob(p.Dir, commit, filePath, maxRenderBytes+1)
750 if err != nil {
751 s.notFound(w, r)
752 return
753 }
754 binary := gitutil.IsBinary(data) || len(data) > maxRenderBytes
755 _, image := imageTypes[strings.ToLower(path.Ext(filePath))]
756
757 var codeHTML template.HTML
758 if !binary && !image {
759 codeHTML = highlight(filePath, data)
760 }
761 var fileSymbols []store.SymbolRow
762 if !binary && !image {
763 codeHTML, fileSymbols = s.blobSymbols(p, commit, filePath, codeHTML)
764 }
765 // Markdown and org render like a README, with the source one click
766 // away; ?view=source shows the text instead.
767 renderable := markupFile(filePath) && !binary
768 var renderedHTML template.HTML
769 rendered := renderable && r.URL.Query().Get("view") != "source"
770 if rendered {
771 renderedHTML = rewriteRelativeLinks(renderReadme(path.Base(filePath), data), p, path.Dir(filePath))
772 }
773 cs := crumbs(p, "blob", filePath)
774 base := ""
775 if len(cs) > 0 {
776 base = cs[len(cs)-1].Name
777 cs = cs[:len(cs)-1]
778 }
779 branches, _ := gitutil.Refs(p.Dir, "heads")
780 navEntries, _ := gitutil.ListTree(p.Dir, p.Ref, navDir(filePath))
781 nav := fileNavFor(p.Repo.Path(), p.Ref, filePath, navEntries)
782 lines := 0
783 if !binary && !image && len(data) > 0 {
784 lines = bytes.Count(data, []byte("\n"))
785 if data[len(data)-1] != '\n' {
786 lines++
787 }
788 }
789 // The file listing leads with the last commit now, so the facts about
790 // the file itself are reported here instead.
791 entry, _ := gitutil.StatPath(p.Dir, p.Ref, filePath)
792 s.render(w, "blob.html", struct {
793 repoPage
794 Crumbs []crumb
795 Base string
796 Path string
797 DirPath string
798 RefKind string
799 Binary bool
800 Image bool
801 Size int
802 Lines int
803 Exec bool
804 Symlink bool
805 Branches []gitutil.Ref
806 CodeHTML template.HTML
807 Renderable bool // markdown or org: the toggle is offered
808 Rendered bool // this response shows the rendering
809 RenderedHTML template.HTML
810 Nav fileNav
811 Symbols []store.SymbolRow
812 }{p, cs, base, filePath, filePath, "blob", binary, image, len(data), lines,
813 entry.Mode == "100755", entry.Mode == "120000", branches, codeHTML, renderable, rendered, renderedHTML, nav, fileSymbols})
814}
815
816// releases lists tag-anchored releases with notes and assets.
817func (s *Server) releases(w http.ResponseWriter, r *http.Request) {
818 s.releasesPage(w, r, "")
819}
820
821// releasesPage lists releases. previewForm is "release" when the create
822// form asked to see its notes, or "release:<tag>" when that release's
823// edit form did (#235).
824func (s *Server) releasesPage(w http.ResponseWriter, r *http.Request, previewForm string) {
825 p, ok := s.repoFor(w, r, "")
826 if !ok {
827 return
828 }
829 p.Tab = "releases"
830 p.Feed = "/" + p.Repo.Path() + "/releases.atom"
831 rels, err := s.st.ListReleases(p.Repo.ID)
832 if err != nil {
833 http.Error(w, "internal error", http.StatusInternalServerError)
834 return
835 }
836 md := s.ugcFor(r, p.Repo)
837 type relView struct {
838 store.Release
839 NotesHTML template.HTML
840 }
841 var views []relView
842 for _, rel := range rels {
843 views = append(views, relView{rel, md(rel.Notes, rel.NotesFormat)})
844 }
845 // Tags without a release yet are what a create form can offer.
846 released := map[string]bool{}
847 for _, rel := range rels {
848 released[rel.Tag] = true
849 }
850 var freeTags []string
851 if tags, err := gitutil.Refs(p.Dir, "tags"); err == nil {
852 gitutil.SortVersions(tags)
853 for _, tg := range tags {
854 if !released[tg.Name] {
855 freeTags = append(freeTags, tg.Name)
856 }
857 }
858 }
859 // An edit keeps the release's stored format; a new release has no
860 // picker and is markdown, as release create stores with no --format.
861 var d *draft
862 if previewForm != "" {
863 format := "md"
864 if tag, ok := strings.CutPrefix(previewForm, "release:"); ok {
865 for _, v := range views {
866 if v.Tag == tag {
867 format = v.NotesFormat
868 }
869 }
870 }
871 d = s.draftFor(r, p.Repo, previewForm, "notes", format)
872 }
873 s.render(w, "releases.html", struct {
874 repoPage
875 Releases []relView
876 FreeTags []string
877 CanWrite bool
878 Notice string
879 Draft *draft
880 }{p, views, freeTags, s.canWriteRepo(r, p.Repo), s.takeFlash(w, r), d})
881}
882
883// releaseAsset streams one uploaded asset. Tags containing '/' are not
884// reachable here (single path segment); SSH download always works.
885func (s *Server) releaseAsset(w http.ResponseWriter, r *http.Request) {
886 p, ok := s.repoFor(w, r, "")
887 if !ok {
888 return
889 }
890 rel, err := s.st.ReleaseByTag(p.Repo.ID, r.PathValue("tag"))
891 if err != nil {
892 s.notFound(w, r)
893 return
894 }
895 name := r.PathValue("name")
896 found := false
897 for _, a := range rel.Assets {
898 if a.Name == name {
899 found = true
900 }
901 }
902 if !found {
903 s.notFound(w, r)
904 return
905 }
906 f, err := os.Open(filepath.Join(control.RepoDir(s.cfg.Server.Root, p.Repo.OwnerName, p.Repo.Name),
907 "gitbay-releases", strconv.FormatInt(rel.ID, 10), name))
908 if err != nil {
909 s.notFound(w, r)
910 return
911 }
912 defer f.Close()
913 w.Header().Set("Content-Type", "application/octet-stream")
914 w.Header().Set("X-Content-Type-Options", "nosniff")
915 w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
916 if fi, err := f.Stat(); err == nil {
917 w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10))
918 }
919 io.Copy(w, f)
920}
921
922// milestones lists a repo's milestones with progress.
923func (s *Server) milestones(w http.ResponseWriter, r *http.Request) {
924 p, ok := s.repoFor(w, r, "")
925 if !ok {
926 return
927 }
928 p.Tab = "issues"
929 state := r.URL.Query().Get("state")
930 if state != "closed" && state != "all" {
931 state = "open"
932 }
933 readable, err := control.ReadableScope(s.st, s.viewer(r), p.Repo)
934 if err != nil {
935 http.Error(w, "internal error", http.StatusInternalServerError)
936 return
937 }
938 ms, err := s.st.ListMilestones(p.Repo, state, readable)
939 if err != nil {
940 http.Error(w, "internal error", http.StatusInternalServerError)
941 return
942 }
943 type msView struct {
944 store.Milestone
945 Percent int
946 }
947 var views []msView
948 for _, m := range ms {
949 v := msView{Milestone: m}
950 if total := m.OpenItems + m.ClosedItems; total > 0 {
951 v.Percent = m.ClosedItems * 100 / total
952 }
953 views = append(views, v)
954 }
955 s.render(w, "milestones.html", struct {
956 repoPage
957 State string
958 Milestones []msView
959 CanWrite bool
960 Notice string
961 }{p, state, views, s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)})
962}
963
964// search runs a bounded literal git grep over the repo's default branch.
965func (s *Server) search(w http.ResponseWriter, r *http.Request) {
966 p, ok := s.repoFor(w, r, "")
967 if !ok {
968 return
969 }
970 p.Tab = "search"
971 q := strings.TrimSpace(r.URL.Query().Get("q"))
972 type matchView struct {
973 Path string
974 Line int
975 TextHTML template.HTML
976 }
977 var matches []matchView
978 var queryErr string
979 if q != "" {
980 if len(q) < 2 || len(q) > 200 {
981 queryErr = "query must be 2 to 200 characters"
982 } else if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err == nil {
983 raw, err := gitutil.Grep(p.Dir, p.Ref, q, 200)
984 if err != nil {
985 http.Error(w, "internal error", http.StatusInternalServerError)
986 return
987 }
988 for _, m := range raw {
989 matches = append(matches, matchView{m.Path, m.Line, markMatch(m.Text, q)})
990 }
991 }
992 }
993 s.render(w, "search.html", struct {
994 repoPage
995 Query string
996 QueryErr string
997 Matches []matchView
998 Capped bool
999 }{p, q, queryErr, matches, len(matches) == 200})
1000}
1001
1002// markMatch escapes a matched line and wraps case-insensitive occurrences
1003// of the query in <mark>.
1004func markMatch(text, q string) template.HTML {
1005 lower, lq := strings.ToLower(text), strings.ToLower(q)
1006 var b strings.Builder
1007 pos := 0
1008 for {
1009 i := strings.Index(lower[pos:], lq)
1010 if i < 0 {
1011 break
1012 }
1013 i += pos
1014 b.WriteString(template.HTMLEscapeString(text[pos:i]))
1015 b.WriteString("<mark>")
1016 b.WriteString(template.HTMLEscapeString(text[i : i+len(q)]))
1017 b.WriteString("</mark>")
1018 pos = i + len(q)
1019 }
1020 b.WriteString(template.HTMLEscapeString(text[pos:]))
1021 return template.HTML(b.String())
1022}
1023
1024func (s *Server) blame(w http.ResponseWriter, r *http.Request) {
1025 p, ok := s.repoFor(w, r, r.PathValue("ref"))
1026 if !ok {
1027 return
1028 }
1029 p.Tab = "files"
1030 filePath := strings.Trim(r.PathValue("path"), "/")
1031
1032 // Blame is a control command; the web renders what it returns rather
1033 // than shelling out to git itself, so all three surfaces agree.
1034 page := 1
1035 if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n >= 1 {
1036 page = n
1037 }
1038 from := (page-1)*control.BlameSpan + 1
1039
1040 var out struct {
1041 From int `json:"from"`
1042 To int `json:"to"`
1043 TotalLines int `json:"total_lines"`
1044 Hunks []struct {
1045 SHA string `json:"sha"`
1046 AuthorName string `json:"author_name"`
1047 AuthorEmail string `json:"author_email"`
1048 Date string `json:"date"`
1049 Summary string `json:"summary"`
1050 StartLine int `json:"start_line"`
1051 Lines []string `json:"lines"`
1052 } `json:"hunks"`
1053 }
1054 argv := []string{"repo", "blame", p.Repo.Path(), filePath,
1055 "--ref", p.Ref, "--from", strconv.Itoa(from), "--to", strconv.Itoa(from + control.BlameSpan - 1)}
1056 var viewer store.User
1057 if s.cfg.Web.Mode == "accounts" {
1058 viewer = s.viewer(r)
1059 }
1060 msg, ok := s.runControlInto(viewer, argv, &out)
1061
1062 // A binary or empty file is a refusal, not a 404: the page still
1063 // renders and says why there is nothing to attribute.
1064 binary := false
1065 if !ok {
1066 if strings.Contains(msg, "is binary") {
1067 binary = true
1068 } else {
1069 s.notFound(w, r)
1070 return
1071 }
1072 }
1073
1074 type hunkView struct {
1075 gitutil.BlameHunk
1076 ShortSHA string
1077 Date string
1078 Sig sigView
1079 Numbered []numberedLine
1080 }
1081 var hunks []hunkView
1082 sigs := map[string]sigView{}
1083 for _, h := range out.Hunks {
1084 v, seen := sigs[h.SHA]
1085 if !seen {
1086 v, _ = s.sigFor(p.Repo, p.Dir, h.SHA)
1087 sigs[h.SHA] = v
1088 }
1089 date := h.Date
1090 if t, err := time.Parse(time.RFC3339, h.Date); err == nil {
1091 date = t.Format(time.RFC3339)
1092 }
1093 hv := hunkView{
1094 BlameHunk: gitutil.BlameHunk{SHA: h.SHA, AuthorName: h.AuthorName,
1095 AuthorEmail: h.AuthorEmail, Summary: h.Summary,
1096 StartLine: h.StartLine, Lines: h.Lines},
1097 ShortSHA: h.SHA[:min(10, len(h.SHA))], Date: date, Sig: v,
1098 }
1099 for i, l := range h.Lines {
1100 hv.Numbered = append(hv.Numbered, numberedLine{h.StartLine + i, l})
1101 }
1102 hunks = append(hunks, hv)
1103 }
1104
1105 pages := (out.TotalLines + control.BlameSpan - 1) / control.BlameSpan
1106 if pages == 0 {
1107 pages = 1
1108 }
1109 if page > pages {
1110 page = pages
1111 }
1112
1113 cs := crumbs(p, "blame", filePath)
1114 base := ""
1115 if len(cs) > 0 {
1116 base = cs[len(cs)-1].Name
1117 cs = cs[:len(cs)-1]
1118 }
1119 navEntries, _ := gitutil.ListTree(p.Dir, p.Ref, navDir(filePath))
1120 nav := fileNavFor(p.Repo.Path(), p.Ref, filePath, navEntries)
1121 s.render(w, "blame.html", struct {
1122 repoPage
1123 Crumbs []crumb
1124 Base string
1125 Path string
1126 Binary bool
1127 Hunks []hunkView
1128 Page, Pages int
1129 Nav fileNav
1130 }{p, cs, base, filePath, binary, hunks, page, pages, nav})
1131}
1132
1133type numberedLine struct {
1134 N int
1135 Text string
1136}
1137
1138// chromaFormatter emits class-based markup (no inline colors), so the
1139// stylesheet can swap palettes with the color scheme.
1140var chromaFormatter = html.New(html.WithClasses(true),
1141 html.WithLineNumbers(true), html.LineNumbersInTable(false),
1142 html.WithLinkableLineNumbers(true, "L"))
1143
1144// chromaFormatterPlain is chromaFormatter without linkable line numbers,
1145// for a page that highlights more than one file: linkable ids are
1146// per-file line numbers, so several files on one page would repeat
1147// id="L1", id="L2", ...
1148var chromaFormatterPlain = html.New(html.WithClasses(true),
1149 html.WithLineNumbers(true), html.LineNumbersInTable(false))
1150
1151func highlight(filePath string, data []byte) template.HTML {
1152 return highlightWith(chromaFormatter, filePath, data)
1153}
1154
1155func highlightPlain(filePath string, data []byte) template.HTML {
1156 return highlightWith(chromaFormatterPlain, filePath, data)
1157}
1158
1159func highlightWith(formatter *html.Formatter, filePath string, data []byte) template.HTML {
1160 lexer := lexers.Match(filePath)
1161 if lexer == nil {
1162 lexer = lexers.Fallback
1163 }
1164 iterator, err := lexer.Tokenise(nil, string(data))
1165 if err != nil {
1166 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
1167 }
1168 var buf bytes.Buffer
1169 if err := formatter.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
1170 return focusableBlocks(template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>"))
1171 }
1172 return focusableBlocks(template.HTML(buf.String()))
1173}
1174
1175// chromaCSS is both syntax palettes, each scoped to the scheme it is for.
1176// The light one cannot be left unscoped: the two palettes do not name the
1177// same token set, and every token github-dark omits would keep its
1178// light-theme colour on a black ground — NameAttribute landed at 2.97:1.
1179// Scoped, an unnamed token inherits the wrapper's colour instead, which is
1180// readable in both. The site's --code-bg stays the background either way.
1181// lightStyle and darkStyle are chosen on measured contrast against the
1182// grounds code actually sits on here — page, code block, and the diff
1183// tints. friendly, the chroma default, put 61 token/ground pairs under
1184// 4.5:1; xcode puts one.
1185const (
1186 lightStyle = "xcode"
1187 darkStyle = "github-dark"
1188)
1189
1190var chromaCSS = func() []byte {
1191 var light, dark bytes.Buffer
1192 chromaFormatter.WriteCSS(&light, styles.Get(lightStyle))
1193 // xcode's NameAttribute is its one token under 4.5:1 against the diff
1194 // tints (4.51 on additions, 4.38 on deletions); darkened it clears both.
1195 light.WriteString(".chroma .na { color: #6f5a21 }\n")
1196 chromaFormatter.WriteCSS(&dark, styles.Get(darkStyle))
1197 // Each palette applies under its media query unless the page is
1198 // stamped with the other theme, and again, outside any media query,
1199 // when the page is stamped with its own (#232).
1200 var buf bytes.Buffer
1201 buf.WriteString("@media (prefers-color-scheme: light) {\n")
1202 buf.WriteString(scopeChroma(light.String(), `:root:not([data-theme="dark"])`))
1203 buf.WriteString("}\n@media (prefers-color-scheme: dark) {\n")
1204 buf.WriteString(scopeChroma(dark.String(), `:root:not([data-theme="light"])`))
1205 buf.WriteString("}\n")
1206 buf.WriteString(scopeChroma(light.String(), `:root[data-theme="light"]`))
1207 buf.WriteString(scopeChroma(dark.String(), `:root[data-theme="dark"]`))
1208 buf.WriteString(".chroma, .bg { background: transparent !important; }\n")
1209 // Line numbers take the site's own gutter colour in both schemes. Left
1210 // alone they are github-dark's #6e7681 (4.31:1 on the page) in dark and
1211 // chroma's built-in #7f7f7f (3.67:1 on a code block) in light — the
1212 // latter is a formatter fallback, not a style entry, so no palette test
1213 // can see it. !important because the scoped palette rules above outrank
1214 // a bare .chroma .ln.
1215 buf.WriteString(".chroma .lnt, .chroma .ln { color: var(--muted) !important }\n")
1216 return buf.Bytes()
1217}()
1218
1219func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
1220 p, ok := s.repoFor(w, r, r.PathValue("ref"))
1221 if !ok {
1222 return
1223 }
1224 filePath := strings.Trim(r.PathValue("path"), "/")
1225 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
1226 if err != nil {
1227 s.notFound(w, r)
1228 return
1229 }
1230 // Serve inert: never let repo content execute in the forge's origin.
1231 // Images get their real type so <img> works under nosniff; SVG script
1232 // is dead on arrival because the instance CSP is script-src 'none'.
1233 ct := "text/plain; charset=utf-8"
1234 if t, ok := imageTypes[strings.ToLower(path.Ext(filePath))]; ok {
1235 ct = t
1236 }
1237 w.Header().Set("Content-Type", ct)
1238 w.Header().Set("X-Content-Type-Options", "nosniff")
1239 w.Write(data)
1240}
1241
1242// imageTypes are the formats raw serves with a real content type and blob
1243// pages preview inline.
1244var imageTypes = map[string]string{
1245 ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
1246 ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif",
1247 ".svg": "image/svg+xml", ".ico": "image/x-icon",
1248}
1249
1250// markdown is the shared renderer: GFM (tables, strikethrough, autolinks,
1251// task lists) on top of CommonMark, with class-based fence highlighting
1252// (the palette lives in the stylesheet, per scheme), and TeX math as
1253// MathML (math.go). Raw HTML is still dropped.
1254// Headings carry ids so a README or wiki section can be linked to, the
1255// way org headings already are (#132).
1256var markdown = goldmark.New(
1257 goldmark.WithParserOptions(parser.WithAutoHeadingID()),
1258 goldmark.WithExtensions(extension.GFM, mathExtension{},
1259 highlighting.NewHighlighting(highlighting.WithFormatOptions(html.WithClasses(true)))))
1260
1261// fenceHighlight renders one code block with chroma classes, for org and
1262// anything else outside goldmark. Unknown languages fall back to plain.
1263func fenceHighlight(source, lang string) string {
1264 lexer := lexers.Get(lang)
1265 if lexer == nil {
1266 lexer = lexers.Fallback
1267 }
1268 iterator, err := lexer.Tokenise(nil, source)
1269 if err != nil {
1270 return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
1271 }
1272 var buf bytes.Buffer
1273 f := html.New(html.WithClasses(true))
1274 if err := f.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
1275 return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
1276 }
1277 return buf.String()
1278}
1279
1280// mdHTML renders user-authored markdown (issue and MR bodies, comments).
1281// goldmark's default renderer drops raw HTML, so this is safe as-is.
1282func mdHTML(raw string) template.HTML {
1283 if strings.TrimSpace(raw) == "" {
1284 return ""
1285 }
1286 var buf bytes.Buffer
1287 if markdown.Convert([]byte(raw), &buf) != nil {
1288 return focusableBlocks(template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>"))
1289 }
1290 return focusableBlocks(template.HTML(buf.String()))
1291}
1292
1293// aboutHTML renders a profile's about text. The format comes from the
1294// file it was read from: org is org, anything else markdown.
1295func aboutHTML(text, format string) template.HTML {
1296 if strings.TrimSpace(text) == "" {
1297 return ""
1298 }
1299 name := "about.md"
1300 if format == "org" {
1301 name = "about.org"
1302 }
1303 return renderReadme(name, []byte(text))
1304}
1305
1306// webResolver answers autolink lookups for one viewer. Cross-repo
1307// references to repositories the viewer cannot read stay plain text, per
1308// the enumeration rule: a link would confirm the repo exists.
1309type webResolver struct {
1310 s *Server
1311 viewer store.User
1312}
1313
1314func (r webResolver) RefURL(owner, name string, kind byte, n int64) string {
1315 repo, err := r.s.st.RepoByPath(owner + "/" + name)
1316 if err != nil {
1317 return ""
1318 }
1319 grant := ""
1320 if r.viewer.ID != 0 {
1321 grant, _ = r.s.st.AccessRole(repo.ID, r.viewer.ID)
1322 }
1323 if !policy.CanRead(r.viewer, repo, grant) {
1324 return ""
1325 }
1326 if kind == '#' {
1327 if _, err := r.s.st.IssueByNumber(repo.ID, n); err != nil {
1328 return ""
1329 }
1330 return autolink.IssueURL(repo.OwnerName, repo.Name, n)
1331 }
1332 if _, err := r.s.st.MRByNumber(repo.ID, n); err != nil {
1333 return ""
1334 }
1335 return autolink.MRURL(repo.OwnerName, repo.Name, n)
1336}
1337
1338func (r webResolver) UserURL(name string) string {
1339 if _, err := r.s.st.UserByUsername(name); err == nil {
1340 return "/" + name
1341 }
1342 if _, err := r.s.st.OrgByName(name); err == nil {
1343 return "/" + name
1344 }
1345 return ""
1346}
1347
1348// ugcRenderer renders one user-authored body in the format it was written in.
1349// The format travels with the body: it is recorded when the text is written, so
1350// changing a preference later cannot re-interpret prose that already exists.
1351type ugcRenderer func(raw, format string) template.HTML
1352
1353// ugcHTML renders a user-authored body. Anything other than "org" is markdown,
1354// so a body stored before formats existed — and any row whose column defaulted —
1355// renders exactly as it did before.
1356//
1357// Org goes through renderReadme, the same path READMEs, wiki pages and profile
1358// about text take, so it inherits that function's include guard and sanitising
1359// rather than growing a second org renderer to keep in step.
1360func ugcHTML(raw, format string) template.HTML {
1361 if format == "org" {
1362 return focusableBlocks(renderOrg("body.org", []byte(raw), false, func() template.HTML {
1363 return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
1364 }))
1365 }
1366 return mdHTML(raw)
1367}
1368
1369// ugcFor returns a renderer for user-authored bodies on one repo's pages:
1370// ugcHTML plus cross-reference and mention autolinking for this viewer.
1371func (s *Server) ugcFor(r *http.Request, repo store.Repo) ugcRenderer {
1372 viewer := store.User{}
1373 if s.cfg.Web.Mode == "accounts" {
1374 viewer = s.viewer(r)
1375 }
1376 res := webResolver{s, viewer}
1377 return func(raw, format string) template.HTML {
1378 h := ugcHTML(raw, format)
1379 if h == "" {
1380 return h
1381 }
1382 return template.HTML(autolink.Rewrite(string(h), repo.OwnerName, repo.Name, res))
1383 }
1384}
1385
1386// renderedComment pairs a comment with its rendered body for templates.
1387type renderedComment struct {
1388 ID int64
1389 Author string
1390 CreatedAt string
1391 Kind string
1392 BodyHTML template.HTML
1393}
1394
1395func renderComments(cs []store.IssueComment, ugc ugcRenderer) []renderedComment {
1396 var out []renderedComment
1397 for _, c := range cs {
1398 out = append(out, renderedComment{ID: c.ID, Author: c.Author, CreatedAt: c.CreatedAt, Kind: c.Kind, BodyHTML: ugc(c.Body, c.BodyFormat)})
1399 }
1400 return out
1401}
1402
1403// ugcPolicy sanitizes rendered repo content before it enters the forge's
1404// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
1405// output and repo-authored HTML are not. Chroma's highlighting classes
1406// must survive; the pattern admits only short token codes, not the site's
1407// own class names.
1408var ugcPolicy = func() *bluemonday.Policy {
1409 p := bluemonday.UGCPolicy()
1410 p.AllowAttrs("class").
1411 Matching(regexp.MustCompile(`^(chroma|[a-z0-9]{1,3})( (chroma|[a-z0-9]{1,3}))*$`)).
1412 OnElements("span", "pre", "code", "div")
1413 return p
1414}()
1415
1416// renderReadme renders a README by extension: markdown, org-mode, and
1417// (sanitized) HTML richly; everything else as escaped plaintext.
1418// orgConfig is the go-org configuration for rendering untrusted org.
1419//
1420// go-org's default reads #+INCLUDE: and #+SETUPFILE: targets off disk with
1421// os.ReadFile. Everything rendered here is content someone pushed — a README, a
1422// wiki page, a profile — so both keywords are refused outright: the file is
1423// never opened and the keyword stays the inert text it is. There is no safe
1424// subset to allow instead. An absolute path skips go-org's relative-path join,
1425// a relative one resolves against the daemon's working directory, and a repo
1426// has no directory to scope to anyway because the content came from a git
1427// object rather than a checkout.
1428//
1429// The default logger writes parse warnings to stderr, which would let pushed
1430// content write to the server's log; discard them.
1431func orgConfig() *org.Configuration {
1432 c := org.New()
1433 c.ReadFile = func(string) ([]byte, error) {
1434 return nil, errOrgIncludeDisabled
1435 }
1436 c.Log = log.New(io.Discard, "", 0)
1437 return c
1438}
1439
1440var errOrgIncludeDisabled = errors.New("org: #+INCLUDE and #+SETUPFILE are disabled")
1441
1442// renderOrg renders org to sanitized HTML. `contents` asks go-org for its table
1443// of contents: a README or wiki page is a document and carries one, an issue
1444// comment is a remark and should not sprout one above two headings. `fallback`
1445// supplies the plaintext rendering used when the writer fails.
1446func renderOrg(name string, raw []byte, contents bool, fallback func() template.HTML) template.HTML {
1447 c := orgConfig()
1448 if !contents {
1449 // DefaultSettings is a fresh map per org.New(), so this is local.
1450 c.DefaultSettings["OPTIONS"] = strings.ReplaceAll(c.DefaultSettings["OPTIONS"], "toc:t", "toc:nil")
1451 }
1452 doc := c.Parse(bytes.NewReader(raw), name)
1453 writer := org.NewHTMLWriter()
1454 writer.HighlightCodeBlock = func(source, lang string, inline bool, params map[string]string) string {
1455 if inline {
1456 return "<code>" + template.HTMLEscapeString(source) + "</code>"
1457 }
1458 return fenceHighlight(source, lang)
1459 }
1460 ow := &orgWriter{HTMLWriter: writer, math: newMathSlots()}
1461 writer.ExtendingWriter = ow
1462 out, err := doc.Write(writer)
1463 if err != nil {
1464 return fallback()
1465 }
1466 return imageAlt(template.HTML(ow.math.fill(ugcPolicy.Sanitize(out))))
1467}
1468
1469// orgWriter overrides go-org's autolink rendering. go-org ends a bare URL
1470// at the first character outside RFC 3986's set, and that set includes
1471// `.`, `,` and `)`, so a URL closing a sentence or a parenthesis took the
1472// punctuation with it. Org stops a plain link before trailing punctuation
1473// and keeps a `)` only when a `(` inside the link opened it. It also
1474// renders LaTeX fragments and blocks (math.go).
1475type orgWriter struct {
1476 *org.HTMLWriter
1477 math *mathSlots
1478}
1479
1480func (w *orgWriter) WriteRegularLink(l org.RegularLink) {
1481 if !l.AutoLink {
1482 w.HTMLWriter.WriteRegularLink(l)
1483 return
1484 }
1485 url, rest := splitAutolinkPunctuation(l.URL)
1486 l.URL = url
1487 w.HTMLWriter.WriteRegularLink(l)
1488 if rest != "" {
1489 w.WriteText(org.Text{Content: rest})
1490 }
1491}
1492
1493// splitAutolinkPunctuation returns the URL without trailing sentence
1494// punctuation, and the punctuation it removed.
1495func splitAutolinkPunctuation(url string) (string, string) {
1496 end := len(url)
1497 for end > 0 {
1498 switch url[end-1] {
1499 case '.', ',', ';', ':', '!', '?', '\'', '"':
1500 end--
1501 continue
1502 case ')':
1503 if strings.Count(url[:end], ")") > strings.Count(url[:end], "(") {
1504 end--
1505 continue
1506 }
1507 }
1508 break
1509 }
1510 return url[:end], url[end:]
1511}
1512
1513// headingTag matches an opening or closing h1..h5 tag, so a rendered
1514// document's headings can move down one level.
1515var headingTag = regexp.MustCompile(`<(/?)h([1-5])([\s>])`)
1516
1517// demoteHeadings moves every heading in a rendered document down one
1518// level: the page it sits on already has its h1 (the repository, the
1519// file, the wiki page), so a README's own h1 would be a second top-level
1520// heading in the outline (#133). Ids and anchors are untouched.
1521func demoteHeadings(h template.HTML) template.HTML {
1522 return template.HTML(headingTag.ReplaceAllStringFunc(string(h), func(m string) string {
1523 sub := headingTag.FindStringSubmatch(m)
1524 return "<" + sub[1] + "h" + string(rune(sub[2][0]+1)) + sub[3]
1525 }))
1526}
1527
1528func renderReadme(name string, raw []byte) template.HTML {
1529 plain := func() template.HTML {
1530 return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
1531 }
1532 if gitutil.IsBinary(raw) {
1533 return ""
1534 }
1535 var out template.HTML
1536 switch path.Ext(strings.ToLower(name)) {
1537 case ".md", ".markdown":
1538 var buf bytes.Buffer
1539 if markdown.Convert(raw, &buf) != nil {
1540 return focusableBlocks(plain())
1541 }
1542 out = demoteHeadings(template.HTML(buf.String()))
1543 case ".org":
1544 out = demoteHeadings(renderOrg(name, raw, true, plain))
1545 case ".html", ".htm":
1546 out = template.HTML(ugcPolicy.Sanitize(string(raw)))
1547 default:
1548 out = plain()
1549 }
1550 return focusableBlocks(out)
1551}
1552
1553type diffThread struct {
1554 ID int64
1555 Resolved string
1556 Stale bool
1557 // Pending marks a thread in the viewer's own unsubmitted review. Only
1558 // they are shown it, and the page says so, since it looks exactly
1559 // like a posted one otherwise.
1560 Pending bool
1561 CanResolve bool
1562 Comments []renderedComment
1563 Suggestion *suggestionView
1564}
1565
1566// suggestionView is a thread's suggestion as the page shows it: the lines
1567// it replaces and the ones it proposes, numbered from Start, and whether
1568// the viewer can apply it here or needs the CLI.
1569type suggestionView struct {
1570 Start int64
1571 Old, New []suggestionLine
1572 Outdated bool
1573 Reason string
1574 Local bool // the repositories require signed commits: apply from a clone
1575 CanApply bool // the viewer can push to the source branch of an open MR
1576 Command string // the CLI command that applies it
1577}
1578
1579type suggestionLine struct {
1580 N int64
1581 Text string
1582}
1583
1584// newSuggestionView lays out s for the page.
1585func newSuggestionView(s *control.SuggestionOut, canApply bool, command string) *suggestionView {
1586 v := &suggestionView{Start: s.StartLine, Outdated: s.Outdated, Reason: s.Reason,
1587 Local: s.Apply == "local", CanApply: canApply, Command: command}
1588 for i, l := range suggest.FromText(strings.ReplaceAll(s.Original, "\r\n", "\n")) {
1589 v.Old = append(v.Old, suggestionLine{s.StartLine + int64(i), l})
1590 }
1591 for i, l := range suggest.FromText(s.Replacement) {
1592 v.New = append(v.New, suggestionLine{s.StartLine + int64(i), l})
1593 }
1594 return v
1595}
1596
1597// reviewRights decides which thread controls a viewer sees. mr resolve
1598// admits the thread author, the MR author, or anyone with write, so the
1599// page needs all three to render the button truthfully.
1600type reviewRights struct {
1601 Viewer string
1602 MRAuthor string
1603 Write bool
1604}
1605
1606func (r reviewRights) canResolve(threadAuthor string) bool {
1607 return r.Viewer != "" && (r.Write || r.Viewer == r.MRAuthor || r.Viewer == threadAuthor)
1608}
1609
1610// attachThreads injects review threads under their anchored diff lines;
1611// threads whose anchor no longer appears (stale after force-push, or on a
1612// context line outside the current diff) are returned separately. A
1613// thread root in suggestions renders its suggestion as a diff, and its
1614// body without the block.
1615func attachThreads(files []diffFile, comments []store.DiffComment, headSHA string, md ugcRenderer, rights reviewRights, suggestions map[int64]*suggestionView) ([]diffFile, []diffThread) {
1616 type anchor struct {
1617 path string
1618 side string
1619 line int64
1620 }
1621 // Diff-line comments have no stored format yet, so they stay markdown.
1622 // They are the one user-authored body left without the choice; see #51.
1623 threads := map[int64]*diffThread{}
1624 anchors := map[int64]anchor{}
1625 var order []int64
1626 for _, cm := range comments {
1627 if cm.ReplyTo == 0 {
1628 body := cm.Body
1629 if suggestions[cm.ID] != nil {
1630 body = suggest.Strip(body)
1631 }
1632 threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
1633 Pending: cm.Pending,
1634 CanResolve: rights.canResolve(cm.Author),
1635 Suggestion: suggestions[cm.ID],
1636 Comments: []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(body, "md")}}}
1637 anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
1638 order = append(order, cm.ID)
1639 } else if th, ok := threads[cm.ReplyTo]; ok {
1640 th.Comments = append(th.Comments, renderedComment{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")})
1641 }
1642 }
1643 placed := map[int64]bool{}
1644 for f := range files {
1645 lines := files[f].Lines
1646 for i := range lines {
1647 for _, id := range order {
1648 if placed[id] || threads[id].Stale {
1649 continue
1650 }
1651 a := anchors[id]
1652 if lines[i].Path != a.path {
1653 continue
1654 }
1655 if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") ||
1656 (a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") {
1657 lines[i].Threads = append(lines[i].Threads, *threads[id])
1658 files[f].Threads++
1659 files[f].Open = true
1660 placed[id] = true
1661 }
1662 }
1663 }
1664 }
1665 var unplaced []diffThread
1666 for _, id := range order {
1667 if !placed[id] {
1668 unplaced = append(unplaced, *threads[id])
1669 }
1670 }
1671 return files, unplaced
1672}
1673
1674// markCompose opens the new-thread form under one diff line. There is no
1675// JavaScript, so "comment on this line" is a plain GET carrying the
1676// anchor and the page renders the form where the reader asked for it.
1677func markCompose(files []diffFile, q url.Values) {
1678 path := q.Get("cpath")
1679 line, _ := strconv.ParseInt(q.Get("cline"), 10, 64)
1680 if path == "" || line < 1 {
1681 return
1682 }
1683 old := q.Get("cside") == "old"
1684 for f := range files {
1685 for i := range files[f].Lines {
1686 ln := &files[f].Lines[i]
1687 if ln.Path != path {
1688 continue
1689 }
1690 if (old && ln.Class == "del" && ln.OldLine == line) ||
1691 (!old && ln.Class != "del" && ln.NewLine == line) {
1692 ln.Compose = true
1693 files[f].Open = true
1694 return
1695 }
1696 }
1697 }
1698}
1699
1700type sigView struct {
1701 State string
1702 Signer string
1703 Fingerprint string
1704}
1705
1706func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
1707 raw, err := gitutil.ReadCommit(dir, sha)
1708 if err != nil {
1709 return sigView{State: "unsigned"}, nil
1710 }
1711 parsed, err := sig.ParseCommit(raw)
1712 if err != nil {
1713 return sigView{State: "unsigned"}, nil
1714 }
1715 res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
1716 if err != nil {
1717 return sigView{State: "unsigned"}, parsed
1718 }
1719 v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
1720 if res.SignerUserID != 0 {
1721 if u, err := s.st.UserByID(res.SignerUserID); err == nil {
1722 v.Signer = u.Username
1723 }
1724 }
1725 return v, parsed
1726}
1727
1728func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1729 ref := r.PathValue("ref")
1730 p, ok := s.repoFor(w, r, ref)
1731 if !ok {
1732 return
1733 }
1734 p.Tab = "log"
1735 p.Feed = "/" + p.Repo.Path() + "/log.atom/" + p.Ref
1736 const pageSize = 50
1737 // ?path= filters to commits touching one file or directory.
1738 filePath := strings.Trim(path.Clean("/"+r.URL.Query().Get("path")), "/")
1739 if filePath == "." {
1740 filePath = ""
1741 }
1742 var shas []string
1743 var err error
1744 if filePath != "" {
1745 shas, err = gitutil.RevListPath(p.Dir, p.Ref, filePath, pageSize+1)
1746 } else {
1747 shas, err = gitutil.RevList(p.Dir, p.Ref, pageSize+1)
1748 }
1749 if err != nil {
1750 s.notFound(w, r)
1751 return
1752 }
1753 next := ""
1754 if len(shas) > pageSize {
1755 next = shas[pageSize]
1756 shas = shas[:pageSize]
1757 }
1758 type row struct {
1759 SHA, ShortSHA, Subject, AuthorName, AuthorEmail, AuthorUser, Date string
1760 Sig sigView
1761 Check string // combined status, "" when none ran
1762 }
1763 names := s.authorNames()
1764 checks, _ := s.st.CombinedStatusFor(p.Repo.ID, shas)
1765 var rows []row
1766 for _, sha := range shas {
1767 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1768 rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v, Check: checks[sha]}
1769 if parsed != nil {
1770 rw.Subject = parsed.Subject
1771 rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
1772 rw.AuthorUser, _ = names.account(parsed.AuthorEmail)
1773 rw.AuthorEmail = parsed.AuthorEmail
1774 rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339)
1775 }
1776 rows = append(rows, rw)
1777 }
1778 s.render(w, "log.html", struct {
1779 repoPage
1780 Commits []row
1781 NextSHA string
1782 FilePath string
1783 }{p, rows, next, filePath})
1784}
1785
1786func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1787 p, ok := s.repoFor(w, r, "")
1788 if !ok {
1789 return
1790 }
1791 p.Tab = "log"
1792 sha := r.PathValue("sha")
1793 full, err := gitutil.ResolveRef(p.Dir, sha)
1794 if err != nil {
1795 s.notFound(w, r)
1796 return
1797 }
1798 v, parsed := s.sigFor(p.Repo, p.Dir, full)
1799 if parsed == nil {
1800 s.notFound(w, r)
1801 return
1802 }
1803 patch, truncated, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
1804 files := parseDiff(patch)
1805 layout := s.diffLayoutFor(r)
1806 if layout.Split {
1807 splitFiles(files)
1808 }
1809 committerEmail := ""
1810 if parsed.CommitterEmail != parsed.AuthorEmail {
1811 committerEmail = parsed.CommitterEmail
1812 }
1813 checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
1814 commitNames := s.authorNames()
1815 commitUser, _ := commitNames.account(parsed.AuthorEmail)
1816 msg := ""
1817 if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
1818 msg = string(parsed.Payload[i+2:])
1819 }
1820 s.render(w, "commit.html", struct {
1821 repoPage
1822 SHA, ShortSHA, AuthorName, AuthorEmail, AuthorUser, CommitterEmail, Date, Message string
1823 Parents []string
1824 Sig sigView
1825 Checks []store.CommitStatus
1826 DiffFiles []diffFile
1827 DiffTruncated bool
1828 Layout diffLayout
1829 }{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail,
1830 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1831 gitutil.Parents(p.Dir, full), v, checks, files, truncated, layout})
1832}
1833
1834// labelPalette provides default label chip colors: mid-tone hues that stay
1835// legible on light and dark backgrounds.
1836var labelPalette = []string{
1837 "#0969da", "#1a7f37", "#9a6700", "#cf222e",
1838 "#8250df", "#b93a86", "#0b6c80", "#bf5b16",
1839}
1840
1841var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
1842
1843// The canvases a chip is drawn on, --canvas in each scheme, and the ratio
1844// its text owes them. Chip text is 12px, which WCAG reads as small text at
1845// 4.5:1. TestChipCanvasMatchesStylesheet keeps these in step with the
1846// tokens.
1847const (
1848 chipCanvasLight = "#ffffff"
1849 chipCanvasDark = "#101114"
1850 chipRatio = 4.5
1851)
1852
1853// chipTones returns a user-set label colour as it is drawn in each scheme.
1854// The chip's ground is mixed from the colour itself, and the luminance
1855// band that clears 4.5:1 on white ends below the band that clears it on
1856// the dark canvas, so one colour cannot serve both and each label carries
1857// two (#226, replacing the single clamp of #120). The hue is kept — the
1858// channels are scaled in linear light — and only a colour too dark to
1859// brighten any further, a saturated blue, is blended on toward white.
1860func chipTones(hex string) (light, dark string) {
1861 return chipTone(hex, chipCanvasLight, false), chipTone(hex, chipCanvasDark, true)
1862}
1863
1864// chipTone walks the colour along its ramp until it clears the ratio,
1865// stopping at the first tone that does: contrast rises with the distance
1866// travelled, so the bisection finds the tone nearest the one asked for.
1867func chipTone(hex, canvas string, up bool) string {
1868 if chipContrast(strings.ToLower(hex), canvas) >= chipRatio {
1869 return strings.ToLower(hex)
1870 }
1871 lo, hi := 0.0, 1.0
1872 for i := 0; i < 24; i++ {
1873 mid := (lo + hi) / 2
1874 if chipContrast(chipStep(hex, mid, up), canvas) >= chipRatio {
1875 hi = mid
1876 } else {
1877 lo = mid
1878 }
1879 }
1880 return chipStep(hex, hi, up)
1881}
1882
1883// chipStep is the colour s of the way along its ramp: down to black on a
1884// light canvas, and on a dark one up through the brightest tone that
1885// keeps the hue and from there on to white.
1886func chipStep(hex string, s float64, up bool) string {
1887 r, g, b := chipLinear(hex)
1888 switch m := math.Max(r, math.Max(g, b)); {
1889 case !up:
1890 k := 1 - s
1891 r, g, b = r*k, g*k, b*k
1892 case m == 0: // black has no hue to keep
1893 r, g, b = s, s, s
1894 case s <= 0.5:
1895 k := 1 + (s/0.5)*(1/m-1)
1896 r, g, b = r*k, g*k, b*k
1897 default:
1898 k, t := 1/m, (s-0.5)/0.5
1899 r, g, b = r*k, g*k, b*k
1900 r, g, b = r+t*(1-r), g+t*(1-g), b+t*(1-b)
1901 }
1902 return chipHex(r, g, b)
1903}
1904
1905// chipContrast is the WCAG ratio between a chip colour and its own
1906// ground, color-mix(in srgb, chip 10%, canvas).
1907func chipContrast(hex, canvas string) float64 {
1908 y, g := chipLuminance(hex), chipLuminance(chipGround(hex, canvas))
1909 if y < g {
1910 y, g = g, y
1911 }
1912 return (y + 0.05) / (g + 0.05)
1913}
1914
1915// chipGround mixes a tenth of the chip colour into the canvas, the blend
1916// color-mix(in srgb, ...) makes: gamma-encoded channels, not linear ones.
1917func chipGround(hex, canvas string) string {
1918 mix := func(a, b string) string {
1919 return fmt.Sprintf("%02x", int(math.Round(0.1*float64(hexByte(a))+0.9*float64(hexByte(b)))))
1920 }
1921 return "#" + mix(hex[1:3], canvas[1:3]) + mix(hex[3:5], canvas[3:5]) + mix(hex[5:7], canvas[5:7])
1922}
1923
1924// chipLinear is a #rrggbb colour in linear light, chipHex the way back,
1925// and chipLuminance the WCAG relative luminance of one.
1926func chipLinear(hex string) (r, g, b float64) {
1927 lin := func(c int64) float64 {
1928 v := float64(c) / 255
1929 if v <= 0.04045 {
1930 return v / 12.92
1931 }
1932 return math.Pow((v+0.055)/1.055, 2.4)
1933 }
1934 return lin(hexByte(hex[1:3])), lin(hexByte(hex[3:5])), lin(hexByte(hex[5:7]))
1935}
1936
1937func chipHex(r, g, b float64) string {
1938 enc := func(v float64) int {
1939 v = math.Min(1, math.Max(0, v))
1940 if v <= 0.0031308 {
1941 v *= 12.92
1942 } else {
1943 v = 1.055*math.Pow(v, 1/2.4) - 0.055
1944 }
1945 return int(math.Round(v * 255))
1946 }
1947 return fmt.Sprintf("#%02x%02x%02x", enc(r), enc(g), enc(b))
1948}
1949
1950func chipLuminance(hex string) float64 {
1951 r, g, b := chipLinear(hex)
1952 return 0.2126*r + 0.7152*g + 0.0722*b
1953}
1954
1955func hexByte(s string) int64 {
1956 n, _ := strconv.ParseInt(s, 16, 32)
1957 return n
1958}
1959
1960// labelColors returns a complete label-name -> chip color map for a repo:
1961// the stored labels.color when it is a valid hex color, otherwise a
1962// stable default picked from the palette by name hash.
1963func (s *Server) labelColors(repo store.Repo) map[string]template.CSS {
1964 stored, _ := s.st.LabelColors(repo)
1965 return colorStyles(stored)
1966}
1967
1968// colorStyles turns a label-name -> stored color map into chip styles: the
1969// stored color when it is a valid hex color, otherwise a stable default
1970// picked from the palette by name hash, as a tone per scheme.
1971func colorStyles(stored map[string]string) map[string]template.CSS {
1972 out := make(map[string]template.CSS, len(stored))
1973 for name, color := range stored {
1974 if !hexColorPat.MatchString(color) {
1975 h := fnv.New32a()
1976 h.Write([]byte(name))
1977 color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
1978 }
1979 light, dark := chipTones(color)
1980 out[name] = template.CSS("--chip-l:" + light + ";--chip-d:" + dark)
1981 }
1982 return out
1983}
1984
1985// listPage is how many issues or merge requests a list page shows before
1986// it offers the older ones (#118). Keyset paging on the number, the same
1987// cursor the commands use, so every filter carries across pages.
1988const listPage = 50
1989
1990// olderLink is the current URL with before=<number> set.
1991func olderLink(r *http.Request, before int64) string {
1992 q := r.URL.Query()
1993 q.Set("before", strconv.FormatInt(before, 10))
1994 return "?" + q.Encode()
1995}
1996
1997func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1998 p, ok := s.repoFor(w, r, "")
1999 if !ok {
2000 return
2001 }
2002 p.Tab = "issues"
2003 state := r.URL.Query().Get("state")
2004 if state != "closed" && state != "all" {
2005 state = "open"
2006 }
2007 // The same filters the CLI's issue list takes, as query parameters;
2008 // label chips and author links point here.
2009 qv := r.URL.Query()
2010 f := store.IssueFilter{State: state, Label: qv.Get("label"), Assignee: qv.Get("assignee"),
2011 Author: qv.Get("author"), Milestone: qv.Get("milestone"),
2012 Search: strings.TrimSpace(qv.Get("q")), Limit: listPage + 1}
2013 f.Before, _ = strconv.ParseInt(qv.Get("before"), 10, 64)
2014 issues, err := s.st.QueryIssues(p.Repo.ID, f)
2015 if err != nil {
2016 http.Error(w, "internal error", http.StatusInternalServerError)
2017 return
2018 }
2019 older := ""
2020 if len(issues) > listPage {
2021 issues = issues[:listPage]
2022 older = olderLink(r, issues[len(issues)-1].Number)
2023 }
2024 if labels, err := s.st.ListIssueLabels(p.Repo); err == nil {
2025 for i := range issues {
2026 issues[i].Labels = labels[issues[i].ID]
2027 }
2028 }
2029 base := url.Values{"state": {state}, "label": {f.Label}, "assignee": {f.Assignee}, "author": {f.Author}, "milestone": {f.Milestone}, "q": {f.Search}}
2030 readable, _ := control.ReadableScope(s.st, s.viewer(r), p.Repo)
2031 allLabels, _ := s.st.ListLabels(p.Repo, readable)
2032 openMS, _ := s.st.ListMilestones(p.Repo, "open", readable)
2033 facets := listFacets(base, []string{"open", "closed", "all"}, state, allLabels, openMS, false)
2034 s.render(w, "issues.html", struct {
2035 repoPage
2036 State string
2037 Label string
2038 Query string
2039 Filters []listFilter
2040 Facets []facetGroup
2041 Issues []store.Issue
2042 LabelColors map[string]template.CSS
2043 Older string
2044 }{p, state, f.Label, f.Search,
2045 activeFilters(state, [][2]string{{"label", f.Label}, {"assignee", f.Assignee}, {"author", f.Author}, {"milestone", f.Milestone}}),
2046 facets, issues, s.labelColors(p.Repo), older})
2047}
2048
2049func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
2050 s.issuePage(w, r, "")
2051}
2052
2053// issuePage renders an issue. previewForm names the form that asked to
2054// see its markup rather than save it — "edit" or "comment", "" for a
2055// plain read — and the page renders that draft above the form it came
2056// from, in the format the write would have stored (#235).
2057func (s *Server) issuePage(w http.ResponseWriter, r *http.Request, previewForm string) {
2058 p, ok := s.repoFor(w, r, "")
2059 if !ok {
2060 return
2061 }
2062 p.Tab = "issues"
2063 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
2064 if err != nil {
2065 s.notFound(w, r)
2066 return
2067 }
2068 iss, err := s.st.IssueByNumber(p.Repo.ID, n)
2069 if err != nil {
2070 s.notFound(w, r)
2071 return
2072 }
2073 comments, err := s.st.ListIssueComments(iss.ID)
2074 if err != nil {
2075 http.Error(w, "internal error", http.StatusInternalServerError)
2076 return
2077 }
2078 md := s.ugcFor(r, p.Repo)
2079 // An edit keeps the issue's stored format; a comment has no picker
2080 // and is markdown, which is what issue comment stores with no
2081 // --format.
2082 var d *draft
2083 if previewForm != "" {
2084 format := iss.BodyFormat
2085 if previewForm == "comment" {
2086 format = "md"
2087 }
2088 d = s.draftFor(r, p.Repo, previewForm, "body", format)
2089 }
2090 // nil readable: the picker lists titles, never the progress counts.
2091 milestones, _ := s.st.ListMilestones(p.Repo, "open", nil)
2092 bars := s.reactionBars(r, "issue", iss.ID, comments, fmt.Sprintf("/%s/%s/issues/%d/react", p.Repo.OwnerName, p.Repo.Name, iss.Number))
2093 s.render(w, "issue.html", struct {
2094 repoPage
2095 Issue store.Issue
2096 BodyHTML template.HTML
2097 Comments []renderedComment
2098 CanEdit bool
2099 CanWrite bool
2100 Milestones []store.Milestone
2101 Notice string
2102 LabelColors map[string]template.CSS
2103 Draft *draft
2104 Reactions map[int64]reactionBar
2105 }{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md),
2106 s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
2107 milestones, s.takeFlash(w, r), s.labelColors(p.Repo), d, bars})
2108}
2109
2110// canEditItem: the author or anyone with write access may edit.
2111// canWriteRepo reports whether the browser session may push to the repo,
2112// which is what gates the review and merge controls.
2113func (s *Server) canWriteRepo(r *http.Request, repo store.Repo) bool {
2114 if s.cfg.Web.Mode != "accounts" {
2115 return false
2116 }
2117 u := s.viewer(r)
2118 if u.ID == 0 {
2119 return false
2120 }
2121 return s.canWriteRepoAs(u, repo)
2122}
2123
2124// canWriteRepoAs is canWriteRepo for a handler that already has its
2125// viewer as a parameter (behind requireUser) rather than needing to
2126// resolve one from the request's session cookie.
2127func (s *Server) canWriteRepoAs(u store.User, repo store.Repo) bool {
2128 grant, _ := s.st.AccessRole(repo.ID, u.ID)
2129 return policy.CanWrite(u, repo, grant)
2130}
2131
2132func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool {
2133 if s.cfg.Web.Mode != "accounts" {
2134 return false
2135 }
2136 u := s.viewer(r)
2137 if u.ID == 0 {
2138 return false
2139 }
2140 if u.Username == author {
2141 return true
2142 }
2143 grant, _ := s.st.AccessRole(repo.ID, u.ID)
2144 return policy.CanWrite(u, repo, grant)
2145}
2146
2147// mrRow is one row of the merge request list: the MR plus its head's
2148// combined check state and its comment count. Errors gathering either
2149// fall back to zero values (#230) — the list must still render.
2150type mrRow struct {
2151 store.MR
2152 Check string
2153 Comments int
2154}
2155
2156func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
2157 p, ok := s.repoFor(w, r, "")
2158 if !ok {
2159 return
2160 }
2161 p.Tab = "merge requests"
2162 canWrite := s.canWriteRepo(r, p.Repo)
2163 state := r.URL.Query().Get("state")
2164 if state == "" {
2165 state = "open"
2166 }
2167 valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
2168 if !valid[state] {
2169 state = "open"
2170 }
2171 qv := r.URL.Query()
2172 mf := store.MRFilter{State: state, Label: qv.Get("label"), Author: qv.Get("author"),
2173 Milestone: qv.Get("milestone"), Search: strings.TrimSpace(qv.Get("q")), Limit: listPage + 1}
2174 mf.Before, _ = strconv.ParseInt(qv.Get("before"), 10, 64)
2175 mrs, err := s.st.QueryMRs(p.Repo.ID, mf)
2176 if err != nil {
2177 http.Error(w, "internal error", http.StatusInternalServerError)
2178 return
2179 }
2180 older := ""
2181 if len(mrs) > listPage {
2182 mrs = mrs[:listPage]
2183 older = olderLink(r, mrs[len(mrs)-1].Number)
2184 }
2185 shas := make([]string, len(mrs))
2186 ids := make([]int64, len(mrs))
2187 for i, m := range mrs {
2188 shas[i] = m.HeadSHA
2189 ids[i] = m.ID
2190 }
2191 checks, err := s.st.CombinedStatusFor(p.Repo.ID, shas)
2192 if err != nil {
2193 checks = map[string]string{}
2194 }
2195 comments, err := s.st.MRCommentCounts(p.Repo.ID, ids)
2196 if err != nil {
2197 comments = map[int64]int{}
2198 }
2199 labels, err := s.st.ListMRLabels(p.Repo)
2200 if err != nil {
2201 labels = map[int64][]string{}
2202 }
2203 rows := make([]mrRow, len(mrs))
2204 for i, m := range mrs {
2205 m.Labels = labels[m.ID]
2206 rows[i] = mrRow{MR: m, Check: checks[m.HeadSHA], Comments: comments[m.ID]}
2207 }
2208 base := url.Values{"state": {state}, "label": {mf.Label}, "author": {mf.Author}, "milestone": {mf.Milestone}, "q": {mf.Search}}
2209 readable, _ := control.ReadableScope(s.st, s.viewer(r), p.Repo)
2210 allLabels, _ := s.st.ListLabels(p.Repo, readable)
2211 openMS, _ := s.st.ListMilestones(p.Repo, "open", readable)
2212 facets := listFacets(base, []string{"open", "merged", "closed", "all"}, state, allLabels, openMS, true)
2213 canOpenMR := canWrite || len(s.writableForks(s.viewer(r), p.Repo)) > 0
2214 s.render(w, "mrs.html", struct {
2215 repoPage
2216 State string
2217 Query string
2218 Filters []listFilter
2219 Facets []facetGroup
2220 MRs []mrRow
2221 LabelColors map[string]template.CSS
2222 Older string
2223 CanOpenMR bool
2224 }{p, state, mf.Search,
2225 activeFilters(state, [][2]string{{"label", mf.Label}, {"author", mf.Author}, {"milestone", mf.Milestone}}),
2226 facets, rows, s.labelColors(p.Repo), older, canOpenMR})
2227}
2228
2229func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
2230 s.mrPage(w, r, "")
2231}
2232
2233// mrPage renders a merge request. previewForm names the form that asked
2234// to see its markup rather than save it — "edit" or "comment", "" for a
2235// plain read (#235).
2236func (s *Server) mrPage(w http.ResponseWriter, r *http.Request, previewForm string) {
2237 p, ok := s.repoFor(w, r, "")
2238 if !ok {
2239 return
2240 }
2241 p.Tab = "merge requests"
2242 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
2243 if err != nil {
2244 s.notFound(w, r)
2245 return
2246 }
2247 m, err := s.st.MRByNumber(p.Repo.ID, n)
2248 if err != nil {
2249 s.notFound(w, r)
2250 return
2251 }
2252 comments, _ := s.st.ListMRComments(m.ID)
2253 reviews, _ := s.st.ListMRReviews(m.ID)
2254 // The same rule the merge gates apply, so the page cannot show an
2255 // approval the gate ignores (#147).
2256 reviewCounts := control.ReviewersWhoCount(s.st, p.Repo, reviews)
2257 reviewRows := make([]reviewRow, 0, len(reviews))
2258 for _, r := range reviews {
2259 reviewRows = append(reviewRows, reviewRow{MRReview: r, Counts: reviewCounts[r.Reviewer]})
2260 }
2261 checks, combined, _ := s.st.ChecksForCommit(p.Repo.ID, m.HeadSHA)
2262 // The viewer sees their own unsubmitted review comments and nobody
2263 // else's.
2264 diffComments, _ := s.st.ListDiffComments(m.ID, s.webViewer(r).ID)
2265
2266 headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
2267 // An admin can prune the head ref; the diff is then unavailable, not
2268 // empty, and the page must not read as the latter.
2269 _, headErr := gitutil.ResolveRef(p.Dir, headRef)
2270 headPruned := headErr != nil
2271 var files []diffFile
2272 base := m.MergedBase
2273 if base == "" {
2274 if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
2275 base = b
2276 }
2277 }
2278 var diffTruncated bool
2279 if base != "" {
2280 if patch, truncated, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
2281 files, diffTruncated = parseDiff(patch), truncated
2282 }
2283 }
2284 // The head is already reachable from the target, so the diff is empty
2285 // by construction rather than because nothing changed.
2286 headMerged := false
2287 if len(files) == 0 && m.HeadSHA != "" {
2288 if targetSHA, err := gitutil.ResolveRef(p.Dir, "refs/heads/"+m.TargetRef); err == nil {
2289 if ok, err := gitutil.IsAncestor(p.Dir, m.HeadSHA, targetSHA); err == nil {
2290 headMerged = ok
2291 }
2292 }
2293 }
2294 md := s.ugcFor(r, p.Repo)
2295 canWrite := s.canWriteRepo(r, p.Repo)
2296 // Applying a suggestion pushes to the source branch, so the button
2297 // follows write on the source repository, which for a fork is not
2298 // the one this page is in.
2299 canApply := false
2300 if p.Viewer != "" && m.State == "open" {
2301 if src, err := s.st.RepoByID(m.SourceRepoID); err == nil {
2302 canApply = s.canWriteRepo(r, src)
2303 }
2304 }
2305 suggestions := map[int64]*suggestionView{}
2306 sgs := control.Suggestions(s.st, s.cfg.Server.Root, p.Repo, m, diffComments)
2307 for _, cm := range diffComments {
2308 if sg := sgs[cm.ID]; sg != nil {
2309 suggestions[cm.ID] = newSuggestionView(sg, canApply && !cm.Pending,
2310 fmt.Sprintf("gitbay mr apply-suggestion %s %d %d", p.Repo.Path(), m.Number, cm.ID))
2311 }
2312 }
2313 var detachedThreads []diffThread
2314 files, detachedThreads = attachThreads(files, diffComments, m.HeadSHA, md,
2315 reviewRights{Viewer: p.Viewer, MRAuthor: m.Author, Write: canWrite}, suggestions)
2316 if p.Viewer != "" {
2317 markCompose(files, r.URL.Query())
2318 }
2319 layout := s.diffLayoutFor(r)
2320 if layout.Split {
2321 splitFiles(files)
2322 }
2323 stat := statOf(files)
2324 // The commits this MR carries: base..head, the same range as the diff.
2325 type commitRow struct {
2326 SHA, ShortSHA, Subject, AuthorName, AuthorUser, Date string
2327 Sig sigView
2328 }
2329 mrNames := s.authorNames()
2330 var commits []commitRow
2331 commitsTotal := 0
2332 if base != "" {
2333 const maxMRCommits = 100
2334 shas, _ := gitutil.RevListRange(p.Dir, base, headRef)
2335 commitsTotal = len(shas)
2336 if len(shas) > maxMRCommits {
2337 shas = shas[:maxMRCommits]
2338 }
2339 for _, sha := range shas {
2340 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
2341 cr := commitRow{SHA: sha, ShortSHA: sha[:10], Sig: v}
2342 if parsed != nil {
2343 cr.Subject = parsed.Subject
2344 cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName)
2345 cr.AuthorUser, _ = mrNames.account(parsed.AuthorEmail)
2346 cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339)
2347 }
2348 commits = append(commits, cr)
2349 }
2350 }
2351 // The diff is the reason most people open a merge request, so it gets
2352 // its own view rather than a fold at the foot of the conversation.
2353 // A query parameter keeps this working without JavaScript.
2354 unresolved, _ := s.st.UnresolvedThreadCount(m.ID)
2355 // The revisions this merge request has had. A stale review is the
2356 // moment someone wants to know what moved, so the link to the
2357 // range-diff belongs next to it.
2358 revisions, _ := s.st.MRHeads(m.ID)
2359 branches, _ := gitutil.Refs(p.Dir, "heads")
2360 view := r.URL.Query().Get("view")
2361 if view != "commits" && view != "diff" {
2362 view = "conversation"
2363 }
2364 // Where the merge request stands against the gates, the same
2365 // computation mr merge refuses on (#199).
2366 var gates *control.GatesOut
2367 if m.State == "open" || m.State == "source_gone" {
2368 if targetSHA, err := gitutil.ResolveRef(p.Dir, "refs/heads/"+m.TargetRef); err == nil {
2369 if g, err := control.MergeGates(s.st, p.Repo, m, p.Dir, targetSHA, m.HeadSHA); err == nil {
2370 gates = &g
2371 }
2372 }
2373 }
2374 // The stack around an open merge request, for the header.
2375 var stackedOn *store.MR
2376 var stacked []store.MR
2377 if m.State == "open" {
2378 if parent, ok, err := s.st.OpenMRBySource(p.Repo.ID, m.TargetRef); err == nil && ok && parent.ID != m.ID {
2379 stackedOn = &parent
2380 }
2381 if m.SourceRepoID == p.Repo.ID {
2382 stacked, _ = s.st.OpenMRsByTarget(p.Repo.ID, m.SourceRef)
2383 }
2384 }
2385 // The merge requests this one superseded when it was closed, so the
2386 // page it points to can also say what it supersedes.
2387 supersedes, _ := s.st.MRsSuperseding(p.Repo.ID, m.Number)
2388 // An edit keeps the merge request's stored format; a comment has no
2389 // picker and is markdown, as mr comment stores with no --format.
2390 var d *draft
2391 if previewForm != "" {
2392 format := m.BodyFormat
2393 if previewForm == "comment" {
2394 format = "md"
2395 }
2396 d = s.draftFor(r, p.Repo, previewForm, "body", format)
2397 }
2398 bars := s.reactionBars(r, "mr", m.ID, comments, fmt.Sprintf("/%s/%s/mrs/%d/react", p.Repo.OwnerName, p.Repo.Name, m.Number))
2399 s.render(w, "mr.html", struct {
2400 repoPage
2401 MR store.MR
2402 View string
2403 BodyHTML template.HTML
2404 Checks []store.Check
2405 Combined string
2406 Comments []renderedComment
2407 Reviews []reviewRow
2408 DiffFiles []diffFile
2409 DiffTruncated bool
2410 Stat diffStat
2411 Commits []commitRow
2412 CommitsTotal int
2413 Branches []gitutil.Ref
2414 CanEdit bool
2415 CanWrite bool
2416 Unresolved int
2417 Revisions []store.MRHead
2418 Notice string
2419 DetachedThreads []diffThread
2420 StackedOn *store.MR
2421 Stacked []store.MR
2422 Supersedes []store.MR
2423 Gates *control.GatesOut
2424 SourceGone bool
2425 HeadMerged bool
2426 HeadPruned bool
2427 Base string
2428 LabelColors map[string]template.CSS
2429 Draft *draft
2430 Layout diffLayout
2431 Reactions map[int64]reactionBar
2432 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
2433 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
2434 canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, supersedes, gates,
2435 sourceGone(p, m), headMerged, headPruned, base, s.labelColors(p.Repo), d, layout, bars})
2436}
2437
2438// sourceGone reports whether an MR's source branch no longer exists: the
2439// push hook marks a deleted branch on an open MR, and a merged or closed
2440// one is checked here. A fork's branch lives in another repository and
2441// is left to the recorded state.
2442func sourceGone(p repoPage, m store.MR) bool {
2443 if m.State == "source_gone" {
2444 return true
2445 }
2446 if m.SourceRepoID != p.Repo.ID {
2447 return false
2448 }
2449 _, err := gitutil.ResolveRef(p.Dir, "refs/heads/"+m.SourceRef)
2450 return err != nil
2451}
2452
2453func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
2454 p, ok := s.repoFor(w, r, "")
2455 if !ok {
2456 return
2457 }
2458 p.Tab = "refs"
2459 branches, _ := gitutil.Refs(p.Dir, "heads")
2460 tags, _ := gitutil.Refs(p.Dir, "tags")
2461 gitutil.SortVersions(tags)
2462 s.render(w, "refs.html", struct {
2463 repoPage
2464 Branches, Tags []gitutil.Ref
2465 }{p, branches, tags})
2466}
2467
2468func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
2469 p, ok := s.repoFor(w, r, "")
2470 if !ok {
2471 return
2472 }
2473 file := r.PathValue("file")
2474 ref, ok := strings.CutSuffix(file, ".tar.gz")
2475 if !ok {
2476 s.notFound(w, r)
2477 return
2478 }
2479 if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
2480 s.notFound(w, r)
2481 return
2482 }
2483 out, kill, finish, ok := s.packSlot(w, r)
2484 if !ok {
2485 return
2486 }
2487 defer finish()
2488 prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
2489 w.Header().Set("Content-Type", "application/gzip")
2490 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
2491 gitutil.ArchiveUntil(p.Dir, ref, prefix, out, kill)
2492}
2493
2494func policyCanAdmin(u store.User, repo store.Repo, grant string) bool {
2495 return policy.CanAdmin(u, repo, grant)
2496}
2497
2498func policyCanRead(u store.User, repo store.Repo, grant string) bool {
2499 return policy.CanRead(u, repo, grant)
2500}
2501
2502// reviewRow is a review with whether the merge gates count it, which
2503// depends on the reviewer's access and so is not a property of the
2504// review row itself.
2505type reviewRow struct {
2506 store.MRReview
2507 Counts bool
2508}
2509
2510// sshCloneURL is the SSH clone URL for a repository, with the port only
2511// when it is not the default.
2512func (s *Server) sshCloneURL(repo store.Repo) string {
2513 host := s.cfg.SiteHost()
2514 if s.cfg.SSH.Port != 22 {
2515 host += ":" + strconv.Itoa(s.cfg.SSH.Port)
2516 }
2517 return "ssh://git@" + host + "/" + repo.Path() + ".git"
2518}