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