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