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