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