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