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