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