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