internal/httpd/web.go
2366 lines · 76906 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 := control.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// markdown is the shared renderer: GFM (tables, strikethrough, autolinks,
1186// task lists) on top of CommonMark, with class-based fence highlighting
1187// (the palette lives in the stylesheet, per scheme). Raw HTML is still
1188// dropped.
1189// Headings carry ids so a README or wiki section can be linked to, the
1190// way org headings already are (#132).
1191var markdown = goldmark.New(
1192 goldmark.WithParserOptions(parser.WithAutoHeadingID()),
1193 goldmark.WithExtensions(extension.GFM,
1194 highlighting.NewHighlighting(highlighting.WithFormatOptions(html.WithClasses(true)))))
1195
1196// fenceHighlight renders one code block with chroma classes, for org and
1197// anything else outside goldmark. Unknown languages fall back to plain.
1198func fenceHighlight(source, lang string) string {
1199 lexer := lexers.Get(lang)
1200 if lexer == nil {
1201 lexer = lexers.Fallback
1202 }
1203 iterator, err := lexer.Tokenise(nil, source)
1204 if err != nil {
1205 return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
1206 }
1207 var buf bytes.Buffer
1208 f := html.New(html.WithClasses(true))
1209 if err := f.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
1210 return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
1211 }
1212 return buf.String()
1213}
1214
1215// mdHTML renders user-authored markdown (issue and MR bodies, comments).
1216// goldmark's default renderer drops raw HTML, so this is safe as-is.
1217func mdHTML(raw string) template.HTML {
1218 if strings.TrimSpace(raw) == "" {
1219 return ""
1220 }
1221 var buf bytes.Buffer
1222 if markdown.Convert([]byte(raw), &buf) != nil {
1223 return focusableBlocks(template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>"))
1224 }
1225 return focusableBlocks(template.HTML(buf.String()))
1226}
1227
1228// aboutHTML renders a profile's about text. The format comes from the
1229// file it was read from: org is org, anything else markdown.
1230func aboutHTML(text, format string) template.HTML {
1231 if strings.TrimSpace(text) == "" {
1232 return ""
1233 }
1234 name := "about.md"
1235 if format == "org" {
1236 name = "about.org"
1237 }
1238 return renderReadme(name, []byte(text))
1239}
1240
1241// webResolver answers autolink lookups for one viewer. Cross-repo
1242// references to repositories the viewer cannot read stay plain text, per
1243// the enumeration rule: a link would confirm the repo exists.
1244type webResolver struct {
1245 s *Server
1246 viewer store.User
1247}
1248
1249func (r webResolver) RefURL(owner, name string, kind byte, n int64) string {
1250 repo, err := r.s.st.RepoByPath(owner + "/" + name)
1251 if err != nil {
1252 return ""
1253 }
1254 grant := ""
1255 if r.viewer.ID != 0 {
1256 grant, _ = r.s.st.AccessRole(repo.ID, r.viewer.ID)
1257 }
1258 if !policy.CanRead(r.viewer, repo, grant) {
1259 return ""
1260 }
1261 if kind == '#' {
1262 if _, err := r.s.st.IssueByNumber(repo.ID, n); err != nil {
1263 return ""
1264 }
1265 return autolink.IssueURL(repo.OwnerName, repo.Name, n)
1266 }
1267 if _, err := r.s.st.MRByNumber(repo.ID, n); err != nil {
1268 return ""
1269 }
1270 return autolink.MRURL(repo.OwnerName, repo.Name, n)
1271}
1272
1273func (r webResolver) UserURL(name string) string {
1274 if _, err := r.s.st.UserByUsername(name); err == nil {
1275 return "/" + name
1276 }
1277 if _, err := r.s.st.OrgByName(name); err == nil {
1278 return "/" + name
1279 }
1280 return ""
1281}
1282
1283// ugcRenderer renders one user-authored body in the format it was written in.
1284// The format travels with the body: it is recorded when the text is written, so
1285// changing a preference later cannot re-interpret prose that already exists.
1286type ugcRenderer func(raw, format string) template.HTML
1287
1288// ugcHTML renders a user-authored body. Anything other than "org" is markdown,
1289// so a body stored before formats existed — and any row whose column defaulted —
1290// renders exactly as it did before.
1291//
1292// Org goes through renderReadme, the same path READMEs, wiki pages and profile
1293// about text take, so it inherits that function's include guard and sanitising
1294// rather than growing a second org renderer to keep in step.
1295func ugcHTML(raw, format string) template.HTML {
1296 if format == "org" {
1297 return focusableBlocks(renderOrg("body.org", []byte(raw), false, func() template.HTML {
1298 return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
1299 }))
1300 }
1301 return mdHTML(raw)
1302}
1303
1304// ugcFor returns a renderer for user-authored bodies on one repo's pages:
1305// ugcHTML plus cross-reference and mention autolinking for this viewer.
1306func (s *Server) ugcFor(r *http.Request, repo store.Repo) ugcRenderer {
1307 viewer := store.User{}
1308 if s.cfg.Web.Mode == "accounts" {
1309 viewer = s.viewer(r)
1310 }
1311 res := webResolver{s, viewer}
1312 return func(raw, format string) template.HTML {
1313 h := ugcHTML(raw, format)
1314 if h == "" {
1315 return h
1316 }
1317 return template.HTML(autolink.Rewrite(string(h), repo.OwnerName, repo.Name, res))
1318 }
1319}
1320
1321// renderedComment pairs a comment with its rendered body for templates.
1322type renderedComment struct {
1323 Author string
1324 CreatedAt string
1325 Kind string
1326 BodyHTML template.HTML
1327}
1328
1329func renderComments(cs []store.IssueComment, ugc ugcRenderer) []renderedComment {
1330 var out []renderedComment
1331 for _, c := range cs {
1332 out = append(out, renderedComment{c.Author, c.CreatedAt, c.Kind, ugc(c.Body, c.BodyFormat)})
1333 }
1334 return out
1335}
1336
1337// ugcPolicy sanitizes rendered repo content before it enters the forge's
1338// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
1339// output and repo-authored HTML are not. Chroma's highlighting classes
1340// must survive; the pattern admits only short token codes, not the site's
1341// own class names.
1342var ugcPolicy = func() *bluemonday.Policy {
1343 p := bluemonday.UGCPolicy()
1344 p.AllowAttrs("class").
1345 Matching(regexp.MustCompile(`^(chroma|[a-z0-9]{1,3})( (chroma|[a-z0-9]{1,3}))*$`)).
1346 OnElements("span", "pre", "code", "div")
1347 return p
1348}()
1349
1350// renderReadme renders a README by extension: markdown, org-mode, and
1351// (sanitized) HTML richly; everything else as escaped plaintext.
1352// orgConfig is the go-org configuration for rendering untrusted org.
1353//
1354// go-org's default reads #+INCLUDE: and #+SETUPFILE: targets off disk with
1355// os.ReadFile. Everything rendered here is content someone pushed — a README, a
1356// wiki page, a profile — so both keywords are refused outright: the file is
1357// never opened and the keyword stays the inert text it is. There is no safe
1358// subset to allow instead. An absolute path skips go-org's relative-path join,
1359// a relative one resolves against the daemon's working directory, and a repo
1360// has no directory to scope to anyway because the content came from a git
1361// object rather than a checkout.
1362//
1363// The default logger writes parse warnings to stderr, which would let pushed
1364// content write to the server's log; discard them.
1365func orgConfig() *org.Configuration {
1366 c := org.New()
1367 c.ReadFile = func(string) ([]byte, error) {
1368 return nil, errOrgIncludeDisabled
1369 }
1370 c.Log = log.New(io.Discard, "", 0)
1371 return c
1372}
1373
1374var errOrgIncludeDisabled = errors.New("org: #+INCLUDE and #+SETUPFILE are disabled")
1375
1376// renderOrg renders org to sanitized HTML. `contents` asks go-org for its table
1377// of contents: a README or wiki page is a document and carries one, an issue
1378// comment is a remark and should not sprout one above two headings. `fallback`
1379// supplies the plaintext rendering used when the writer fails.
1380func renderOrg(name string, raw []byte, contents bool, fallback func() template.HTML) template.HTML {
1381 c := orgConfig()
1382 if !contents {
1383 // DefaultSettings is a fresh map per org.New(), so this is local.
1384 c.DefaultSettings["OPTIONS"] = strings.ReplaceAll(c.DefaultSettings["OPTIONS"], "toc:t", "toc:nil")
1385 }
1386 doc := c.Parse(bytes.NewReader(raw), name)
1387 writer := org.NewHTMLWriter()
1388 writer.HighlightCodeBlock = func(source, lang string, inline bool, params map[string]string) string {
1389 if inline {
1390 return "<code>" + template.HTMLEscapeString(source) + "</code>"
1391 }
1392 return fenceHighlight(source, lang)
1393 }
1394 writer.ExtendingWriter = &orgWriter{writer}
1395 out, err := doc.Write(writer)
1396 if err != nil {
1397 return fallback()
1398 }
1399 return imageAlt(template.HTML(ugcPolicy.Sanitize(out)))
1400}
1401
1402// orgWriter overrides go-org's autolink rendering. go-org ends a bare URL
1403// at the first character outside RFC 3986's set, and that set includes
1404// `.`, `,` and `)`, so a URL closing a sentence or a parenthesis took the
1405// punctuation with it. Org stops a plain link before trailing punctuation
1406// and keeps a `)` only when a `(` inside the link opened it.
1407type orgWriter struct {
1408 *org.HTMLWriter
1409}
1410
1411func (w *orgWriter) WriteRegularLink(l org.RegularLink) {
1412 if !l.AutoLink {
1413 w.HTMLWriter.WriteRegularLink(l)
1414 return
1415 }
1416 url, rest := splitAutolinkPunctuation(l.URL)
1417 l.URL = url
1418 w.HTMLWriter.WriteRegularLink(l)
1419 if rest != "" {
1420 w.WriteText(org.Text{Content: rest})
1421 }
1422}
1423
1424// splitAutolinkPunctuation returns the URL without trailing sentence
1425// punctuation, and the punctuation it removed.
1426func splitAutolinkPunctuation(url string) (string, string) {
1427 end := len(url)
1428 for end > 0 {
1429 switch url[end-1] {
1430 case '.', ',', ';', ':', '!', '?', '\'', '"':
1431 end--
1432 continue
1433 case ')':
1434 if strings.Count(url[:end], ")") > strings.Count(url[:end], "(") {
1435 end--
1436 continue
1437 }
1438 }
1439 break
1440 }
1441 return url[:end], url[end:]
1442}
1443
1444// headingTag matches an opening or closing h1..h5 tag, so a rendered
1445// document's headings can move down one level.
1446var headingTag = regexp.MustCompile(`<(/?)h([1-5])([\s>])`)
1447
1448// demoteHeadings moves every heading in a rendered document down one
1449// level: the page it sits on already has its h1 (the repository, the
1450// file, the wiki page), so a README's own h1 would be a second top-level
1451// heading in the outline (#133). Ids and anchors are untouched.
1452func demoteHeadings(h template.HTML) template.HTML {
1453 return template.HTML(headingTag.ReplaceAllStringFunc(string(h), func(m string) string {
1454 sub := headingTag.FindStringSubmatch(m)
1455 return "<" + sub[1] + "h" + string(rune(sub[2][0]+1)) + sub[3]
1456 }))
1457}
1458
1459func renderReadme(name string, raw []byte) template.HTML {
1460 plain := func() template.HTML {
1461 return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
1462 }
1463 if gitutil.IsBinary(raw) {
1464 return ""
1465 }
1466 var out template.HTML
1467 switch path.Ext(strings.ToLower(name)) {
1468 case ".md", ".markdown":
1469 var buf bytes.Buffer
1470 if markdown.Convert(raw, &buf) != nil {
1471 return focusableBlocks(plain())
1472 }
1473 out = demoteHeadings(template.HTML(buf.String()))
1474 case ".org":
1475 out = demoteHeadings(renderOrg(name, raw, true, plain))
1476 case ".html", ".htm":
1477 out = template.HTML(ugcPolicy.Sanitize(string(raw)))
1478 default:
1479 out = plain()
1480 }
1481 return focusableBlocks(out)
1482}
1483
1484type diffThread struct {
1485 ID int64
1486 Resolved string
1487 Stale bool
1488 // Pending marks a thread in the viewer's own unsubmitted review. Only
1489 // they are shown it, and the page says so, since it looks exactly
1490 // like a posted one otherwise.
1491 Pending bool
1492 CanResolve bool
1493 Comments []renderedComment
1494}
1495
1496// reviewRights decides which thread controls a viewer sees. mr resolve
1497// admits the thread author, the MR author, or anyone with write, so the
1498// page needs all three to render the button truthfully.
1499type reviewRights struct {
1500 Viewer string
1501 MRAuthor string
1502 Write bool
1503}
1504
1505func (r reviewRights) canResolve(threadAuthor string) bool {
1506 return r.Viewer != "" && (r.Write || r.Viewer == r.MRAuthor || r.Viewer == threadAuthor)
1507}
1508
1509// attachThreads injects review threads under their anchored diff lines;
1510// threads whose anchor no longer appears (stale after force-push, or on a
1511// context line outside the current diff) are returned separately.
1512func attachThreads(files []diffFile, comments []store.DiffComment, headSHA string, md ugcRenderer, rights reviewRights) ([]diffFile, []diffThread) {
1513 type anchor struct {
1514 path string
1515 side string
1516 line int64
1517 }
1518 // Diff-line comments have no stored format yet, so they stay markdown.
1519 // They are the one user-authored body left without the choice; see #51.
1520 threads := map[int64]*diffThread{}
1521 anchors := map[int64]anchor{}
1522 var order []int64
1523 for _, cm := range comments {
1524 if cm.ReplyTo == 0 {
1525 threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
1526 Pending: cm.Pending,
1527 CanResolve: rights.canResolve(cm.Author),
1528 Comments: []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")}}}
1529 anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
1530 order = append(order, cm.ID)
1531 } else if th, ok := threads[cm.ReplyTo]; ok {
1532 th.Comments = append(th.Comments, renderedComment{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")})
1533 }
1534 }
1535 placed := map[int64]bool{}
1536 for f := range files {
1537 lines := files[f].Lines
1538 for i := range lines {
1539 for _, id := range order {
1540 if placed[id] || threads[id].Stale {
1541 continue
1542 }
1543 a := anchors[id]
1544 if lines[i].Path != a.path {
1545 continue
1546 }
1547 if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") ||
1548 (a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") {
1549 lines[i].Threads = append(lines[i].Threads, *threads[id])
1550 files[f].Threads++
1551 files[f].Open = true
1552 placed[id] = true
1553 }
1554 }
1555 }
1556 }
1557 var unplaced []diffThread
1558 for _, id := range order {
1559 if !placed[id] {
1560 unplaced = append(unplaced, *threads[id])
1561 }
1562 }
1563 return files, unplaced
1564}
1565
1566// markCompose opens the new-thread form under one diff line. There is no
1567// JavaScript, so "comment on this line" is a plain GET carrying the
1568// anchor and the page renders the form where the reader asked for it.
1569func markCompose(files []diffFile, q url.Values) {
1570 path := q.Get("cpath")
1571 line, _ := strconv.ParseInt(q.Get("cline"), 10, 64)
1572 if path == "" || line < 1 {
1573 return
1574 }
1575 old := q.Get("cside") == "old"
1576 for f := range files {
1577 for i := range files[f].Lines {
1578 ln := &files[f].Lines[i]
1579 if ln.Path != path {
1580 continue
1581 }
1582 if (old && ln.Class == "del" && ln.OldLine == line) ||
1583 (!old && ln.Class != "del" && ln.NewLine == line) {
1584 ln.Compose = true
1585 files[f].Open = true
1586 return
1587 }
1588 }
1589 }
1590}
1591
1592type sigView struct {
1593 State string
1594 Signer string
1595 Fingerprint string
1596}
1597
1598func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
1599 raw, err := gitutil.ReadCommit(dir, sha)
1600 if err != nil {
1601 return sigView{State: "unsigned"}, nil
1602 }
1603 parsed, err := sig.ParseCommit(raw)
1604 if err != nil {
1605 return sigView{State: "unsigned"}, nil
1606 }
1607 res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
1608 if err != nil {
1609 return sigView{State: "unsigned"}, parsed
1610 }
1611 v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
1612 if res.SignerUserID != 0 {
1613 if u, err := s.st.UserByID(res.SignerUserID); err == nil {
1614 v.Signer = u.Username
1615 }
1616 }
1617 return v, parsed
1618}
1619
1620func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1621 ref := r.PathValue("ref")
1622 p, ok := s.repoFor(w, r, ref)
1623 if !ok {
1624 return
1625 }
1626 p.Tab = "log"
1627 p.Feed = "/" + p.Repo.Path() + "/log.atom/" + p.Ref
1628 const pageSize = 50
1629 // ?path= filters to commits touching one file or directory.
1630 filePath := strings.Trim(path.Clean("/"+r.URL.Query().Get("path")), "/")
1631 if filePath == "." {
1632 filePath = ""
1633 }
1634 var shas []string
1635 var err error
1636 if filePath != "" {
1637 shas, err = gitutil.RevListPath(p.Dir, p.Ref, filePath, pageSize+1)
1638 } else {
1639 shas, err = gitutil.RevList(p.Dir, p.Ref, pageSize+1)
1640 }
1641 if err != nil {
1642 s.notFound(w, r)
1643 return
1644 }
1645 next := ""
1646 if len(shas) > pageSize {
1647 next = shas[pageSize]
1648 shas = shas[:pageSize]
1649 }
1650 type row struct {
1651 SHA, ShortSHA, Subject, AuthorName, AuthorEmail, AuthorUser, Date string
1652 Sig sigView
1653 Check string // combined status, "" when none ran
1654 }
1655 names := s.authorNames()
1656 checks, _ := s.st.CombinedStatusFor(p.Repo.ID, shas)
1657 var rows []row
1658 for _, sha := range shas {
1659 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1660 rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v, Check: checks[sha]}
1661 if parsed != nil {
1662 rw.Subject = parsed.Subject
1663 rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
1664 rw.AuthorUser, _ = names.account(parsed.AuthorEmail)
1665 rw.AuthorEmail = parsed.AuthorEmail
1666 rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339)
1667 }
1668 rows = append(rows, rw)
1669 }
1670 s.render(w, "log.html", struct {
1671 repoPage
1672 Commits []row
1673 NextSHA string
1674 FilePath string
1675 }{p, rows, next, filePath})
1676}
1677
1678func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1679 p, ok := s.repoFor(w, r, "")
1680 if !ok {
1681 return
1682 }
1683 p.Tab = "log"
1684 sha := r.PathValue("sha")
1685 full, err := gitutil.ResolveRef(p.Dir, sha)
1686 if err != nil {
1687 s.notFound(w, r)
1688 return
1689 }
1690 v, parsed := s.sigFor(p.Repo, p.Dir, full)
1691 if parsed == nil {
1692 s.notFound(w, r)
1693 return
1694 }
1695 patch, truncated, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
1696 files := parseDiff(patch)
1697 committerEmail := ""
1698 if parsed.CommitterEmail != parsed.AuthorEmail {
1699 committerEmail = parsed.CommitterEmail
1700 }
1701 checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
1702 commitNames := s.authorNames()
1703 commitUser, _ := commitNames.account(parsed.AuthorEmail)
1704 msg := ""
1705 if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
1706 msg = string(parsed.Payload[i+2:])
1707 }
1708 s.render(w, "commit.html", struct {
1709 repoPage
1710 SHA, ShortSHA, AuthorName, AuthorEmail, AuthorUser, CommitterEmail, Date, Message string
1711 Parents []string
1712 Sig sigView
1713 Checks []store.CommitStatus
1714 DiffFiles []diffFile
1715 DiffTruncated bool
1716 }{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail,
1717 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1718 gitutil.Parents(p.Dir, full), v, checks, files, truncated})
1719}
1720
1721// labelPalette provides default label chip colors: mid-tone hues that stay
1722// legible on light and dark backgrounds.
1723var labelPalette = []string{
1724 "#0969da", "#1a7f37", "#9a6700", "#cf222e",
1725 "#8250df", "#b93a86", "#0b6c80", "#bf5b16",
1726}
1727
1728var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
1729
1730// The canvases a chip is drawn on, --canvas in each scheme, and the ratio
1731// its text owes them. Chip text is 12px, which WCAG reads as small text at
1732// 4.5:1. TestChipCanvasMatchesStylesheet keeps these in step with the
1733// tokens.
1734const (
1735 chipCanvasLight = "#ffffff"
1736 chipCanvasDark = "#101114"
1737 chipRatio = 4.5
1738)
1739
1740// chipTones returns a user-set label colour as it is drawn in each scheme.
1741// The chip's ground is mixed from the colour itself, and the luminance
1742// band that clears 4.5:1 on white ends below the band that clears it on
1743// the dark canvas, so one colour cannot serve both and each label carries
1744// two (#226, replacing the single clamp of #120). The hue is kept — the
1745// channels are scaled in linear light — and only a colour too dark to
1746// brighten any further, a saturated blue, is blended on toward white.
1747func chipTones(hex string) (light, dark string) {
1748 return chipTone(hex, chipCanvasLight, false), chipTone(hex, chipCanvasDark, true)
1749}
1750
1751// chipTone walks the colour along its ramp until it clears the ratio,
1752// stopping at the first tone that does: contrast rises with the distance
1753// travelled, so the bisection finds the tone nearest the one asked for.
1754func chipTone(hex, canvas string, up bool) string {
1755 if chipContrast(strings.ToLower(hex), canvas) >= chipRatio {
1756 return strings.ToLower(hex)
1757 }
1758 lo, hi := 0.0, 1.0
1759 for i := 0; i < 24; i++ {
1760 mid := (lo + hi) / 2
1761 if chipContrast(chipStep(hex, mid, up), canvas) >= chipRatio {
1762 hi = mid
1763 } else {
1764 lo = mid
1765 }
1766 }
1767 return chipStep(hex, hi, up)
1768}
1769
1770// chipStep is the colour s of the way along its ramp: down to black on a
1771// light canvas, and on a dark one up through the brightest tone that
1772// keeps the hue and from there on to white.
1773func chipStep(hex string, s float64, up bool) string {
1774 r, g, b := chipLinear(hex)
1775 switch m := math.Max(r, math.Max(g, b)); {
1776 case !up:
1777 k := 1 - s
1778 r, g, b = r*k, g*k, b*k
1779 case m == 0: // black has no hue to keep
1780 r, g, b = s, s, s
1781 case s <= 0.5:
1782 k := 1 + (s/0.5)*(1/m-1)
1783 r, g, b = r*k, g*k, b*k
1784 default:
1785 k, t := 1/m, (s-0.5)/0.5
1786 r, g, b = r*k, g*k, b*k
1787 r, g, b = r+t*(1-r), g+t*(1-g), b+t*(1-b)
1788 }
1789 return chipHex(r, g, b)
1790}
1791
1792// chipContrast is the WCAG ratio between a chip colour and its own
1793// ground, color-mix(in srgb, chip 10%, canvas).
1794func chipContrast(hex, canvas string) float64 {
1795 y, g := chipLuminance(hex), chipLuminance(chipGround(hex, canvas))
1796 if y < g {
1797 y, g = g, y
1798 }
1799 return (y + 0.05) / (g + 0.05)
1800}
1801
1802// chipGround mixes a tenth of the chip colour into the canvas, the blend
1803// color-mix(in srgb, ...) makes: gamma-encoded channels, not linear ones.
1804func chipGround(hex, canvas string) string {
1805 mix := func(a, b string) string {
1806 return fmt.Sprintf("%02x", int(math.Round(0.1*float64(hexByte(a))+0.9*float64(hexByte(b)))))
1807 }
1808 return "#" + mix(hex[1:3], canvas[1:3]) + mix(hex[3:5], canvas[3:5]) + mix(hex[5:7], canvas[5:7])
1809}
1810
1811// chipLinear is a #rrggbb colour in linear light, chipHex the way back,
1812// and chipLuminance the WCAG relative luminance of one.
1813func chipLinear(hex string) (r, g, b float64) {
1814 lin := func(c int64) float64 {
1815 v := float64(c) / 255
1816 if v <= 0.04045 {
1817 return v / 12.92
1818 }
1819 return math.Pow((v+0.055)/1.055, 2.4)
1820 }
1821 return lin(hexByte(hex[1:3])), lin(hexByte(hex[3:5])), lin(hexByte(hex[5:7]))
1822}
1823
1824func chipHex(r, g, b float64) string {
1825 enc := func(v float64) int {
1826 v = math.Min(1, math.Max(0, v))
1827 if v <= 0.0031308 {
1828 v *= 12.92
1829 } else {
1830 v = 1.055*math.Pow(v, 1/2.4) - 0.055
1831 }
1832 return int(math.Round(v * 255))
1833 }
1834 return fmt.Sprintf("#%02x%02x%02x", enc(r), enc(g), enc(b))
1835}
1836
1837func chipLuminance(hex string) float64 {
1838 r, g, b := chipLinear(hex)
1839 return 0.2126*r + 0.7152*g + 0.0722*b
1840}
1841
1842func hexByte(s string) int64 {
1843 n, _ := strconv.ParseInt(s, 16, 32)
1844 return n
1845}
1846
1847// labelColors returns a complete label-name -> chip color map for a repo:
1848// the stored labels.color when it is a valid hex color, otherwise a
1849// stable default picked from the palette by name hash.
1850func (s *Server) labelColors(repo store.Repo) map[string]template.CSS {
1851 stored, _ := s.st.LabelColors(repo)
1852 return colorStyles(stored)
1853}
1854
1855// colorStyles turns a label-name -> stored color map into chip styles: the
1856// stored color when it is a valid hex color, otherwise a stable default
1857// picked from the palette by name hash, as a tone per scheme.
1858func colorStyles(stored map[string]string) map[string]template.CSS {
1859 out := make(map[string]template.CSS, len(stored))
1860 for name, color := range stored {
1861 if !hexColorPat.MatchString(color) {
1862 h := fnv.New32a()
1863 h.Write([]byte(name))
1864 color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
1865 }
1866 light, dark := chipTones(color)
1867 out[name] = template.CSS("--chip-l:" + light + ";--chip-d:" + dark)
1868 }
1869 return out
1870}
1871
1872// listPage is how many issues or merge requests a list page shows before
1873// it offers the older ones (#118). Keyset paging on the number, the same
1874// cursor the commands use, so every filter carries across pages.
1875const listPage = 50
1876
1877// olderLink is the current URL with before=<number> set.
1878func olderLink(r *http.Request, before int64) string {
1879 q := r.URL.Query()
1880 q.Set("before", strconv.FormatInt(before, 10))
1881 return "?" + q.Encode()
1882}
1883
1884func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1885 p, ok := s.repoFor(w, r, "")
1886 if !ok {
1887 return
1888 }
1889 p.Tab = "issues"
1890 state := r.URL.Query().Get("state")
1891 if state != "closed" && state != "all" {
1892 state = "open"
1893 }
1894 // The same filters the CLI's issue list takes, as query parameters;
1895 // label chips and author links point here.
1896 qv := r.URL.Query()
1897 f := store.IssueFilter{State: state, Label: qv.Get("label"), Assignee: qv.Get("assignee"),
1898 Author: qv.Get("author"), Milestone: qv.Get("milestone"),
1899 Search: strings.TrimSpace(qv.Get("q")), Limit: listPage + 1}
1900 f.Before, _ = strconv.ParseInt(qv.Get("before"), 10, 64)
1901 issues, err := s.st.QueryIssues(p.Repo.ID, f)
1902 if err != nil {
1903 http.Error(w, "internal error", http.StatusInternalServerError)
1904 return
1905 }
1906 older := ""
1907 if len(issues) > listPage {
1908 issues = issues[:listPage]
1909 older = olderLink(r, issues[len(issues)-1].Number)
1910 }
1911 if labels, err := s.st.ListIssueLabels(p.Repo); err == nil {
1912 for i := range issues {
1913 issues[i].Labels = labels[issues[i].ID]
1914 }
1915 }
1916 base := url.Values{"state": {state}, "label": {f.Label}, "assignee": {f.Assignee}, "author": {f.Author}, "milestone": {f.Milestone}, "q": {f.Search}}
1917 readable, _ := control.ReadableScope(s.st, s.viewer(r), p.Repo)
1918 allLabels, _ := s.st.ListLabels(p.Repo, readable)
1919 openMS, _ := s.st.ListMilestones(p.Repo, "open", readable)
1920 facets := listFacets(base, []string{"open", "closed", "all"}, state, allLabels, openMS, false)
1921 s.render(w, "issues.html", struct {
1922 repoPage
1923 State string
1924 Label string
1925 Query string
1926 Filters []listFilter
1927 Facets []facetGroup
1928 Issues []store.Issue
1929 LabelColors map[string]template.CSS
1930 Older string
1931 }{p, state, f.Label, f.Search,
1932 activeFilters(state, [][2]string{{"label", f.Label}, {"assignee", f.Assignee}, {"author", f.Author}, {"milestone", f.Milestone}}),
1933 facets, issues, s.labelColors(p.Repo), older})
1934}
1935
1936func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1937 s.issuePage(w, r, "")
1938}
1939
1940// issuePage renders an issue. previewForm names the form that asked to
1941// see its markup rather than save it — "edit" or "comment", "" for a
1942// plain read — and the page renders that draft above the form it came
1943// from, in the format the write would have stored (#235).
1944func (s *Server) issuePage(w http.ResponseWriter, r *http.Request, previewForm string) {
1945 p, ok := s.repoFor(w, r, "")
1946 if !ok {
1947 return
1948 }
1949 p.Tab = "issues"
1950 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1951 if err != nil {
1952 s.notFound(w, r)
1953 return
1954 }
1955 iss, err := s.st.IssueByNumber(p.Repo.ID, n)
1956 if err != nil {
1957 s.notFound(w, r)
1958 return
1959 }
1960 comments, err := s.st.ListIssueComments(iss.ID)
1961 if err != nil {
1962 http.Error(w, "internal error", http.StatusInternalServerError)
1963 return
1964 }
1965 md := s.ugcFor(r, p.Repo)
1966 // An edit keeps the issue's stored format; a comment has no picker
1967 // and is markdown, which is what issue comment stores with no
1968 // --format.
1969 var d *draft
1970 if previewForm != "" {
1971 format := iss.BodyFormat
1972 if previewForm == "comment" {
1973 format = "md"
1974 }
1975 d = s.draftFor(r, p.Repo, previewForm, "body", format)
1976 }
1977 // nil readable: the picker lists titles, never the progress counts.
1978 milestones, _ := s.st.ListMilestones(p.Repo, "open", nil)
1979 s.render(w, "issue.html", struct {
1980 repoPage
1981 Issue store.Issue
1982 BodyHTML template.HTML
1983 Comments []renderedComment
1984 CanEdit bool
1985 CanWrite bool
1986 Milestones []store.Milestone
1987 Notice string
1988 LabelColors map[string]template.CSS
1989 Draft *draft
1990 }{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md),
1991 s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
1992 milestones, s.takeFlash(w, r), s.labelColors(p.Repo), d})
1993}
1994
1995// canEditItem: the author or anyone with write access may edit.
1996// canWriteRepo reports whether the browser session may push to the repo,
1997// which is what gates the review and merge controls.
1998func (s *Server) canWriteRepo(r *http.Request, repo store.Repo) bool {
1999 if s.cfg.Web.Mode != "accounts" {
2000 return false
2001 }
2002 u := s.viewer(r)
2003 if u.ID == 0 {
2004 return false
2005 }
2006 grant, _ := s.st.AccessRole(repo.ID, u.ID)
2007 return policy.CanWrite(u, repo, grant)
2008}
2009
2010func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool {
2011 if s.cfg.Web.Mode != "accounts" {
2012 return false
2013 }
2014 u := s.viewer(r)
2015 if u.ID == 0 {
2016 return false
2017 }
2018 if u.Username == author {
2019 return true
2020 }
2021 grant, _ := s.st.AccessRole(repo.ID, u.ID)
2022 return policy.CanWrite(u, repo, grant)
2023}
2024
2025// mrRow is one row of the merge request list: the MR plus its head's
2026// combined check state and its comment count. Errors gathering either
2027// fall back to zero values (#230) — the list must still render.
2028type mrRow struct {
2029 store.MR
2030 Check string
2031 Comments int
2032}
2033
2034func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
2035 p, ok := s.repoFor(w, r, "")
2036 if !ok {
2037 return
2038 }
2039 p.Tab = "merge requests"
2040 canWrite := s.canWriteRepo(r, p.Repo)
2041 state := r.URL.Query().Get("state")
2042 if state == "" {
2043 state = "open"
2044 }
2045 valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
2046 if !valid[state] {
2047 state = "open"
2048 }
2049 qv := r.URL.Query()
2050 mf := store.MRFilter{State: state, Label: qv.Get("label"), Author: qv.Get("author"),
2051 Milestone: qv.Get("milestone"), Search: strings.TrimSpace(qv.Get("q")), Limit: listPage + 1}
2052 mf.Before, _ = strconv.ParseInt(qv.Get("before"), 10, 64)
2053 mrs, err := s.st.QueryMRs(p.Repo.ID, mf)
2054 if err != nil {
2055 http.Error(w, "internal error", http.StatusInternalServerError)
2056 return
2057 }
2058 older := ""
2059 if len(mrs) > listPage {
2060 mrs = mrs[:listPage]
2061 older = olderLink(r, mrs[len(mrs)-1].Number)
2062 }
2063 shas := make([]string, len(mrs))
2064 ids := make([]int64, len(mrs))
2065 for i, m := range mrs {
2066 shas[i] = m.HeadSHA
2067 ids[i] = m.ID
2068 }
2069 checks, err := s.st.CombinedStatusFor(p.Repo.ID, shas)
2070 if err != nil {
2071 checks = map[string]string{}
2072 }
2073 comments, err := s.st.MRCommentCounts(p.Repo.ID, ids)
2074 if err != nil {
2075 comments = map[int64]int{}
2076 }
2077 labels, err := s.st.ListMRLabels(p.Repo)
2078 if err != nil {
2079 labels = map[int64][]string{}
2080 }
2081 rows := make([]mrRow, len(mrs))
2082 for i, m := range mrs {
2083 m.Labels = labels[m.ID]
2084 rows[i] = mrRow{MR: m, Check: checks[m.HeadSHA], Comments: comments[m.ID]}
2085 }
2086 base := url.Values{"state": {state}, "label": {mf.Label}, "author": {mf.Author}, "milestone": {mf.Milestone}, "q": {mf.Search}}
2087 readable, _ := control.ReadableScope(s.st, s.viewer(r), p.Repo)
2088 allLabels, _ := s.st.ListLabels(p.Repo, readable)
2089 openMS, _ := s.st.ListMilestones(p.Repo, "open", readable)
2090 facets := listFacets(base, []string{"open", "merged", "closed", "all"}, state, allLabels, openMS, true)
2091 s.render(w, "mrs.html", struct {
2092 repoPage
2093 State string
2094 Query string
2095 Filters []listFilter
2096 Facets []facetGroup
2097 MRs []mrRow
2098 LabelColors map[string]template.CSS
2099 Older string
2100 CanWrite bool
2101 }{p, state, mf.Search,
2102 activeFilters(state, [][2]string{{"label", mf.Label}, {"author", mf.Author}, {"milestone", mf.Milestone}}),
2103 facets, rows, s.labelColors(p.Repo), older, canWrite})
2104}
2105
2106func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
2107 s.mrPage(w, r, "")
2108}
2109
2110// mrPage renders a merge request. previewForm names the form that asked
2111// to see its markup rather than save it — "edit" or "comment", "" for a
2112// plain read (#235).
2113func (s *Server) mrPage(w http.ResponseWriter, r *http.Request, previewForm string) {
2114 p, ok := s.repoFor(w, r, "")
2115 if !ok {
2116 return
2117 }
2118 p.Tab = "merge requests"
2119 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
2120 if err != nil {
2121 s.notFound(w, r)
2122 return
2123 }
2124 m, err := s.st.MRByNumber(p.Repo.ID, n)
2125 if err != nil {
2126 s.notFound(w, r)
2127 return
2128 }
2129 comments, _ := s.st.ListMRComments(m.ID)
2130 reviews, _ := s.st.ListMRReviews(m.ID)
2131 // The same rule the merge gates apply, so the page cannot show an
2132 // approval the gate ignores (#147).
2133 reviewCounts := control.ReviewersWhoCount(s.st, p.Repo, reviews)
2134 reviewRows := make([]reviewRow, 0, len(reviews))
2135 for _, r := range reviews {
2136 reviewRows = append(reviewRows, reviewRow{MRReview: r, Counts: reviewCounts[r.Reviewer]})
2137 }
2138 checks, combined, _ := s.st.ChecksForCommit(p.Repo.ID, m.HeadSHA)
2139 // The viewer sees their own unsubmitted review comments and nobody
2140 // else's.
2141 diffComments, _ := s.st.ListDiffComments(m.ID, s.webViewer(r).ID)
2142
2143 headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
2144 // An admin can prune the head ref; the diff is then unavailable, not
2145 // empty, and the page must not read as the latter.
2146 _, headErr := gitutil.ResolveRef(p.Dir, headRef)
2147 headPruned := headErr != nil
2148 var files []diffFile
2149 base := m.MergedBase
2150 if base == "" {
2151 if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
2152 base = b
2153 }
2154 }
2155 var diffTruncated bool
2156 if base != "" {
2157 if patch, truncated, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
2158 files, diffTruncated = parseDiff(patch), truncated
2159 }
2160 }
2161 // The head is already reachable from the target, so the diff is empty
2162 // by construction rather than because nothing changed.
2163 headMerged := false
2164 if len(files) == 0 && m.HeadSHA != "" {
2165 if targetSHA, err := gitutil.ResolveRef(p.Dir, "refs/heads/"+m.TargetRef); err == nil {
2166 if ok, err := gitutil.IsAncestor(p.Dir, m.HeadSHA, targetSHA); err == nil {
2167 headMerged = ok
2168 }
2169 }
2170 }
2171 md := s.ugcFor(r, p.Repo)
2172 canWrite := s.canWriteRepo(r, p.Repo)
2173 var detachedThreads []diffThread
2174 files, detachedThreads = attachThreads(files, diffComments, m.HeadSHA, md,
2175 reviewRights{Viewer: p.Viewer, MRAuthor: m.Author, Write: canWrite})
2176 if p.Viewer != "" {
2177 markCompose(files, r.URL.Query())
2178 }
2179 stat := statOf(files)
2180 // The commits this MR carries: base..head, the same range as the diff.
2181 type commitRow struct {
2182 SHA, ShortSHA, Subject, AuthorName, AuthorUser, Date string
2183 Sig sigView
2184 }
2185 mrNames := s.authorNames()
2186 var commits []commitRow
2187 commitsTotal := 0
2188 if base != "" {
2189 const maxMRCommits = 100
2190 shas, _ := gitutil.RevListRange(p.Dir, base, headRef)
2191 commitsTotal = len(shas)
2192 if len(shas) > maxMRCommits {
2193 shas = shas[:maxMRCommits]
2194 }
2195 for _, sha := range shas {
2196 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
2197 cr := commitRow{SHA: sha, ShortSHA: sha[:10], Sig: v}
2198 if parsed != nil {
2199 cr.Subject = parsed.Subject
2200 cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName)
2201 cr.AuthorUser, _ = mrNames.account(parsed.AuthorEmail)
2202 cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339)
2203 }
2204 commits = append(commits, cr)
2205 }
2206 }
2207 // The diff is the reason most people open a merge request, so it gets
2208 // its own view rather than a fold at the foot of the conversation.
2209 // A query parameter keeps this working without JavaScript.
2210 unresolved, _ := s.st.UnresolvedThreadCount(m.ID)
2211 // The revisions this merge request has had. A stale review is the
2212 // moment someone wants to know what moved, so the link to the
2213 // range-diff belongs next to it.
2214 revisions, _ := s.st.MRHeads(m.ID)
2215 branches, _ := gitutil.Refs(p.Dir, "heads")
2216 view := r.URL.Query().Get("view")
2217 if view != "commits" && view != "diff" {
2218 view = "conversation"
2219 }
2220 // Where the merge request stands against the gates, the same
2221 // computation mr merge refuses on (#199).
2222 var gates *control.GatesOut
2223 if m.State == "open" || m.State == "source_gone" {
2224 if targetSHA, err := gitutil.ResolveRef(p.Dir, "refs/heads/"+m.TargetRef); err == nil {
2225 if g, err := control.MergeGates(s.st, p.Repo, m, p.Dir, targetSHA, m.HeadSHA); err == nil {
2226 gates = &g
2227 }
2228 }
2229 }
2230 // The stack around an open merge request, for the header.
2231 var stackedOn *store.MR
2232 var stacked []store.MR
2233 if m.State == "open" {
2234 if parent, ok, err := s.st.OpenMRBySource(p.Repo.ID, m.TargetRef); err == nil && ok && parent.ID != m.ID {
2235 stackedOn = &parent
2236 }
2237 if m.SourceRepoID == p.Repo.ID {
2238 stacked, _ = s.st.OpenMRsByTarget(p.Repo.ID, m.SourceRef)
2239 }
2240 }
2241 // The merge requests this one superseded when it was closed, so the
2242 // page it points to can also say what it supersedes.
2243 supersedes, _ := s.st.MRsSuperseding(p.Repo.ID, m.Number)
2244 // An edit keeps the merge request's stored format; a comment has no
2245 // picker and is markdown, as mr comment stores with no --format.
2246 var d *draft
2247 if previewForm != "" {
2248 format := m.BodyFormat
2249 if previewForm == "comment" {
2250 format = "md"
2251 }
2252 d = s.draftFor(r, p.Repo, previewForm, "body", format)
2253 }
2254 s.render(w, "mr.html", struct {
2255 repoPage
2256 MR store.MR
2257 View string
2258 BodyHTML template.HTML
2259 Checks []store.Check
2260 Combined string
2261 Comments []renderedComment
2262 Reviews []reviewRow
2263 DiffFiles []diffFile
2264 DiffTruncated bool
2265 Stat diffStat
2266 Commits []commitRow
2267 CommitsTotal int
2268 Branches []gitutil.Ref
2269 CanEdit bool
2270 CanWrite bool
2271 Unresolved int
2272 Revisions []store.MRHead
2273 Notice string
2274 DetachedThreads []diffThread
2275 StackedOn *store.MR
2276 Stacked []store.MR
2277 Supersedes []store.MR
2278 Gates *control.GatesOut
2279 SourceGone bool
2280 HeadMerged bool
2281 HeadPruned bool
2282 Base string
2283 LabelColors map[string]template.CSS
2284 Draft *draft
2285 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
2286 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
2287 canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, supersedes, gates,
2288 sourceGone(p, m), headMerged, headPruned, base, s.labelColors(p.Repo), d})
2289}
2290
2291// sourceGone reports whether an MR's source branch no longer exists: the
2292// push hook marks a deleted branch on an open MR, and a merged or closed
2293// one is checked here. A fork's branch lives in another repository and
2294// is left to the recorded state.
2295func sourceGone(p repoPage, m store.MR) bool {
2296 if m.State == "source_gone" {
2297 return true
2298 }
2299 if m.SourceRepoID != p.Repo.ID {
2300 return false
2301 }
2302 _, err := gitutil.ResolveRef(p.Dir, "refs/heads/"+m.SourceRef)
2303 return err != nil
2304}
2305
2306func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
2307 p, ok := s.repoFor(w, r, "")
2308 if !ok {
2309 return
2310 }
2311 p.Tab = "refs"
2312 branches, _ := gitutil.Refs(p.Dir, "heads")
2313 tags, _ := gitutil.Refs(p.Dir, "tags")
2314 gitutil.SortVersions(tags)
2315 s.render(w, "refs.html", struct {
2316 repoPage
2317 Branches, Tags []gitutil.Ref
2318 }{p, branches, tags})
2319}
2320
2321func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
2322 p, ok := s.repoFor(w, r, "")
2323 if !ok {
2324 return
2325 }
2326 file := r.PathValue("file")
2327 ref, ok := strings.CutSuffix(file, ".tar.gz")
2328 if !ok {
2329 s.notFound(w, r)
2330 return
2331 }
2332 if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
2333 s.notFound(w, r)
2334 return
2335 }
2336 prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
2337 w.Header().Set("Content-Type", "application/gzip")
2338 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
2339 gitutil.Archive(p.Dir, ref, prefix, w)
2340}
2341
2342func policyCanAdmin(u store.User, repo store.Repo, grant string) bool {
2343 return policy.CanAdmin(u, repo, grant)
2344}
2345
2346func policyCanRead(u store.User, repo store.Repo, grant string) bool {
2347 return policy.CanRead(u, repo, grant)
2348}
2349
2350// reviewRow is a review with whether the merge gates count it, which
2351// depends on the reviewer's access and so is not a property of the
2352// review row itself.
2353type reviewRow struct {
2354 store.MRReview
2355 Counts bool
2356}
2357
2358// sshCloneURL is the SSH clone URL for a repository, with the port only
2359// when it is not the default.
2360func (s *Server) sshCloneURL(repo store.Repo) string {
2361 host := s.cfg.SiteHost()
2362 if s.cfg.SSH.Port != 22 {
2363 host += ":" + strconv.Itoa(s.cfg.SSH.Port)
2364 }
2365 return "ssh://git@" + host + "/" + repo.Path() + ".git"
2366}