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