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