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