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