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