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