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