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