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