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