internal/httpd/web.go
1864 lines · 57766 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, r.URL.Query().Get("e")})
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), r.URL.Query().Get("e")})
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
1247func renderReadme(name string, raw []byte) template.HTML {
1248 plain := func() template.HTML {
1249 return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
1250 }
1251 if gitutil.IsBinary(raw) {
1252 return ""
1253 }
1254 switch path.Ext(strings.ToLower(name)) {
1255 case ".md", ".markdown":
1256 var buf bytes.Buffer
1257 if markdown.Convert(raw, &buf) != nil {
1258 return plain()
1259 }
1260 return template.HTML(buf.String())
1261 case ".org":
1262 return renderOrg(name, raw, true, plain)
1263 case ".html", ".htm":
1264 return template.HTML(ugcPolicy.Sanitize(string(raw)))
1265 default:
1266 return plain()
1267 }
1268}
1269
1270type diffThread struct {
1271 ID int64
1272 Resolved string
1273 Stale bool
1274 CanResolve bool
1275 Comments []renderedComment
1276}
1277
1278// reviewRights decides which thread controls a viewer sees. mr resolve
1279// admits the thread author, the MR author, or anyone with write, so the
1280// page needs all three to render the button truthfully.
1281type reviewRights struct {
1282 Viewer string
1283 MRAuthor string
1284 Write bool
1285}
1286
1287func (r reviewRights) canResolve(threadAuthor string) bool {
1288 return r.Viewer != "" && (r.Write || r.Viewer == r.MRAuthor || r.Viewer == threadAuthor)
1289}
1290
1291// attachThreads injects review threads under their anchored diff lines;
1292// threads whose anchor no longer appears (stale after force-push, or on a
1293// context line outside the current diff) are returned separately.
1294func attachThreads(files []diffFile, comments []store.DiffComment, headSHA string, md ugcRenderer, rights reviewRights) ([]diffFile, []diffThread) {
1295 type anchor struct {
1296 path string
1297 side string
1298 line int64
1299 }
1300 // Diff-line comments have no stored format yet, so they stay markdown.
1301 // They are the one user-authored body left without the choice; see #51.
1302 threads := map[int64]*diffThread{}
1303 anchors := map[int64]anchor{}
1304 var order []int64
1305 for _, cm := range comments {
1306 if cm.ReplyTo == 0 {
1307 threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
1308 CanResolve: rights.canResolve(cm.Author),
1309 Comments: []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")}}}
1310 anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
1311 order = append(order, cm.ID)
1312 } else if th, ok := threads[cm.ReplyTo]; ok {
1313 th.Comments = append(th.Comments, renderedComment{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")})
1314 }
1315 }
1316 placed := map[int64]bool{}
1317 for f := range files {
1318 lines := files[f].Lines
1319 for i := range lines {
1320 for _, id := range order {
1321 if placed[id] || threads[id].Stale {
1322 continue
1323 }
1324 a := anchors[id]
1325 if lines[i].Path != a.path {
1326 continue
1327 }
1328 if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") ||
1329 (a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") {
1330 lines[i].Threads = append(lines[i].Threads, *threads[id])
1331 files[f].Threads++
1332 files[f].Open = true
1333 placed[id] = true
1334 }
1335 }
1336 }
1337 }
1338 var unplaced []diffThread
1339 for _, id := range order {
1340 if !placed[id] {
1341 unplaced = append(unplaced, *threads[id])
1342 }
1343 }
1344 return files, unplaced
1345}
1346
1347// markCompose opens the new-thread form under one diff line. There is no
1348// JavaScript, so "comment on this line" is a plain GET carrying the
1349// anchor and the page renders the form where the reader asked for it.
1350func markCompose(files []diffFile, q url.Values) {
1351 path := q.Get("cpath")
1352 line, _ := strconv.ParseInt(q.Get("cline"), 10, 64)
1353 if path == "" || line < 1 {
1354 return
1355 }
1356 old := q.Get("cside") == "old"
1357 for f := range files {
1358 for i := range files[f].Lines {
1359 ln := &files[f].Lines[i]
1360 if ln.Path != path {
1361 continue
1362 }
1363 if (old && ln.Class == "del" && ln.OldLine == line) ||
1364 (!old && ln.Class != "del" && ln.NewLine == line) {
1365 ln.Compose = true
1366 files[f].Open = true
1367 return
1368 }
1369 }
1370 }
1371}
1372
1373type sigView struct {
1374 State string
1375 Signer string
1376 Fingerprint string
1377}
1378
1379func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
1380 raw, err := gitutil.ReadCommit(dir, sha)
1381 if err != nil {
1382 return sigView{State: "unsigned"}, nil
1383 }
1384 parsed, err := sig.ParseCommit(raw)
1385 if err != nil {
1386 return sigView{State: "unsigned"}, nil
1387 }
1388 res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
1389 if err != nil {
1390 return sigView{State: "unsigned"}, parsed
1391 }
1392 v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
1393 if res.SignerUserID != 0 {
1394 if u, err := s.st.UserByID(res.SignerUserID); err == nil {
1395 v.Signer = u.Username
1396 }
1397 }
1398 return v, parsed
1399}
1400
1401func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1402 ref := r.PathValue("ref")
1403 p, ok := s.repoFor(w, r, ref)
1404 if !ok {
1405 return
1406 }
1407 p.Tab = "log"
1408 const pageSize = 50
1409 // ?path= filters to commits touching one file or directory.
1410 filePath := strings.Trim(path.Clean("/"+r.URL.Query().Get("path")), "/")
1411 if filePath == "." {
1412 filePath = ""
1413 }
1414 var shas []string
1415 var err error
1416 if filePath != "" {
1417 shas, err = gitutil.RevListPath(p.Dir, p.Ref, filePath, pageSize+1)
1418 } else {
1419 shas, err = gitutil.RevList(p.Dir, p.Ref, pageSize+1)
1420 }
1421 if err != nil {
1422 s.notFound(w, r)
1423 return
1424 }
1425 next := ""
1426 if len(shas) > pageSize {
1427 next = shas[pageSize]
1428 shas = shas[:pageSize]
1429 }
1430 type row struct {
1431 SHA, ShortSHA, Subject, AuthorName, AuthorEmail, AuthorUser, Date string
1432 Sig sigView
1433 Check string // combined status, "" when none ran
1434 }
1435 names := s.authorNames()
1436 checks, _ := s.st.CombinedStatusFor(p.Repo.ID, shas)
1437 var rows []row
1438 for _, sha := range shas {
1439 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1440 rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v, Check: checks[sha]}
1441 if parsed != nil {
1442 rw.Subject = parsed.Subject
1443 rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
1444 rw.AuthorUser, _ = names.account(parsed.AuthorEmail)
1445 rw.AuthorEmail = parsed.AuthorEmail
1446 rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1447 }
1448 rows = append(rows, rw)
1449 }
1450 s.render(w, "log.html", struct {
1451 repoPage
1452 Commits []row
1453 NextSHA string
1454 FilePath string
1455 }{p, rows, next, filePath})
1456}
1457
1458func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1459 p, ok := s.repoFor(w, r, "")
1460 if !ok {
1461 return
1462 }
1463 p.Tab = "log"
1464 sha := r.PathValue("sha")
1465 full, err := gitutil.ResolveRef(p.Dir, sha)
1466 if err != nil {
1467 s.notFound(w, r)
1468 return
1469 }
1470 v, parsed := s.sigFor(p.Repo, p.Dir, full)
1471 if parsed == nil {
1472 s.notFound(w, r)
1473 return
1474 }
1475 patch, truncated, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
1476 files := parseDiff(patch)
1477 committerEmail := ""
1478 if parsed.CommitterEmail != parsed.AuthorEmail {
1479 committerEmail = parsed.CommitterEmail
1480 }
1481 checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
1482 commitNames := s.authorNames()
1483 commitUser, _ := commitNames.account(parsed.AuthorEmail)
1484 msg := ""
1485 if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
1486 msg = string(parsed.Payload[i+2:])
1487 }
1488 s.render(w, "commit.html", struct {
1489 repoPage
1490 SHA, ShortSHA, AuthorName, AuthorEmail, AuthorUser, CommitterEmail, Date, Message string
1491 Parents []string
1492 Sig sigView
1493 Checks []store.CommitStatus
1494 DiffFiles []diffFile
1495 DiffTruncated bool
1496 }{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail,
1497 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1498 gitutil.Parents(p.Dir, full), v, checks, files, truncated})
1499}
1500
1501// labelPalette provides default label chip colors: mid-tone hues that stay
1502// legible on light and dark backgrounds.
1503var labelPalette = []string{
1504 "#0969da", "#1a7f37", "#9a6700", "#cf222e",
1505 "#8250df", "#b93a86", "#0b6c80", "#bf5b16",
1506}
1507
1508var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
1509
1510// clampChip keeps a user-set label colour legible as text on both
1511// grounds. Contrast is defined on relative luminance, so that is what is
1512// held: between 0.12 and 0.28, where the chip clears 3:1 against white
1513// and against the dark ground alike, and where the palette's own colours
1514// sit. The hue is kept; the channels are scaled in linear light (#120).
1515func clampChip(hex string) string {
1516 lin := func(c int64) float64 {
1517 v := float64(c) / 255
1518 if v <= 0.04045 {
1519 return v / 12.92
1520 }
1521 return math.Pow((v+0.055)/1.055, 2.4)
1522 }
1523 r, g, b := lin(hexByte(hex[1:3])), lin(hexByte(hex[3:5])), lin(hexByte(hex[5:7]))
1524 y := 0.2126*r + 0.7152*g + 0.0722*b
1525 const lo, hi = 0.12, 0.28
1526 if y >= lo && y <= hi {
1527 return strings.ToLower(hex)
1528 }
1529 target := hi
1530 if y < lo {
1531 target = lo
1532 }
1533 if y == 0 {
1534 r, g, b = target, target, target
1535 } else {
1536 k := target / y
1537 r, g, b = math.Min(1, r*k), math.Min(1, g*k), math.Min(1, b*k)
1538 }
1539 enc := func(v float64) int {
1540 if v <= 0.0031308 {
1541 v *= 12.92
1542 } else {
1543 v = 1.055*math.Pow(v, 1/2.4) - 0.055
1544 }
1545 return int(math.Round(v * 255))
1546 }
1547 return fmt.Sprintf("#%02x%02x%02x", enc(r), enc(g), enc(b))
1548}
1549
1550func hexByte(s string) int64 {
1551 n, _ := strconv.ParseInt(s, 16, 32)
1552 return n
1553}
1554
1555// labelColors returns a complete label-name -> chip color map for a repo:
1556// the stored labels.color when it is a valid hex color, otherwise a
1557// stable default picked from the palette by name hash.
1558func (s *Server) labelColors(repoID int64) map[string]template.CSS {
1559 stored, _ := s.st.LabelColors(repoID)
1560 out := make(map[string]template.CSS, len(stored))
1561 for name, color := range stored {
1562 if !hexColorPat.MatchString(color) {
1563 h := fnv.New32a()
1564 h.Write([]byte(name))
1565 color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
1566 }
1567 out[name] = template.CSS("--chip:" + clampChip(color))
1568 }
1569 return out
1570}
1571
1572func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1573 p, ok := s.repoFor(w, r, "")
1574 if !ok {
1575 return
1576 }
1577 p.Tab = "issues"
1578 state := r.URL.Query().Get("state")
1579 if state != "closed" && state != "all" {
1580 state = "open"
1581 }
1582 // The same filters the CLI's issue list takes, as query parameters;
1583 // label chips and author links point here.
1584 qv := r.URL.Query()
1585 f := store.IssueFilter{State: state, Label: qv.Get("label"), Assignee: qv.Get("assignee"),
1586 Author: qv.Get("author"), Milestone: qv.Get("milestone")}
1587 issues, err := s.st.QueryIssues(p.Repo.ID, f)
1588 if err != nil {
1589 http.Error(w, "internal error", http.StatusInternalServerError)
1590 return
1591 }
1592 if labels, err := s.st.ListIssueLabels(p.Repo.ID); err == nil {
1593 for i := range issues {
1594 issues[i].Labels = labels[issues[i].ID]
1595 }
1596 }
1597 s.render(w, "issues.html", struct {
1598 repoPage
1599 State string
1600 Label string
1601 Filters []listFilter
1602 Issues []store.Issue
1603 LabelColors map[string]template.CSS
1604 }{p, state, f.Label, activeFilters(state, [][2]string{{"label", f.Label}, {"assignee", f.Assignee}, {"author", f.Author}, {"milestone", f.Milestone}}),
1605 issues, s.labelColors(p.Repo.ID)})
1606}
1607
1608func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1609 p, ok := s.repoFor(w, r, "")
1610 if !ok {
1611 return
1612 }
1613 p.Tab = "issues"
1614 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1615 if err != nil {
1616 s.notFound(w, r)
1617 return
1618 }
1619 iss, err := s.st.IssueByNumber(p.Repo.ID, n)
1620 if err != nil {
1621 s.notFound(w, r)
1622 return
1623 }
1624 comments, err := s.st.ListIssueComments(iss.ID)
1625 if err != nil {
1626 http.Error(w, "internal error", http.StatusInternalServerError)
1627 return
1628 }
1629 md := s.ugcFor(r, p.Repo)
1630 milestones, _ := s.st.ListMilestones(p.Repo.ID, "open")
1631 s.render(w, "issue.html", struct {
1632 repoPage
1633 Issue store.Issue
1634 BodyHTML template.HTML
1635 Comments []renderedComment
1636 CanEdit bool
1637 CanWrite bool
1638 Milestones []store.Milestone
1639 Notice string
1640 LabelColors map[string]template.CSS
1641 }{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md),
1642 s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
1643 milestones, r.URL.Query().Get("e"), s.labelColors(p.Repo.ID)})
1644}
1645
1646// canEditItem: the author or anyone with write access may edit.
1647// canWriteRepo reports whether the browser session may push to the repo,
1648// which is what gates the review and merge controls.
1649func (s *Server) canWriteRepo(r *http.Request, repo store.Repo) bool {
1650 if s.cfg.Web.Mode != "accounts" {
1651 return false
1652 }
1653 u := s.viewer(r)
1654 if u.ID == 0 {
1655 return false
1656 }
1657 grant, _ := s.st.AccessRole(repo.ID, u.ID)
1658 return policy.CanWrite(u, repo, grant)
1659}
1660
1661func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool {
1662 if s.cfg.Web.Mode != "accounts" {
1663 return false
1664 }
1665 u := s.viewer(r)
1666 if u.ID == 0 {
1667 return false
1668 }
1669 if u.Username == author {
1670 return true
1671 }
1672 grant, _ := s.st.AccessRole(repo.ID, u.ID)
1673 return policy.CanWrite(u, repo, grant)
1674}
1675
1676func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
1677 p, ok := s.repoFor(w, r, "")
1678 if !ok {
1679 return
1680 }
1681 p.Tab = "merge requests"
1682 state := r.URL.Query().Get("state")
1683 if state == "" {
1684 state = "open"
1685 }
1686 valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
1687 if !valid[state] {
1688 state = "open"
1689 }
1690 qv := r.URL.Query()
1691 mf := store.MRFilter{State: state, Author: qv.Get("author"), Milestone: qv.Get("milestone")}
1692 mrs, err := s.st.QueryMRs(p.Repo.ID, mf)
1693 if err != nil {
1694 http.Error(w, "internal error", http.StatusInternalServerError)
1695 return
1696 }
1697 s.render(w, "mrs.html", struct {
1698 repoPage
1699 State string
1700 Filters []listFilter
1701 MRs []store.MR
1702 }{p, state, activeFilters(state, [][2]string{{"author", mf.Author}, {"milestone", mf.Milestone}}), mrs})
1703}
1704
1705func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1706 p, ok := s.repoFor(w, r, "")
1707 if !ok {
1708 return
1709 }
1710 p.Tab = "merge requests"
1711 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1712 if err != nil {
1713 s.notFound(w, r)
1714 return
1715 }
1716 m, err := s.st.MRByNumber(p.Repo.ID, n)
1717 if err != nil {
1718 s.notFound(w, r)
1719 return
1720 }
1721 comments, _ := s.st.ListMRComments(m.ID)
1722 reviews, _ := s.st.ListMRReviews(m.ID)
1723 checks, combined, _ := s.st.ChecksForCommit(p.Repo.ID, m.HeadSHA)
1724 diffComments, _ := s.st.ListDiffComments(m.ID)
1725
1726 headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
1727 var files []diffFile
1728 base := m.MergedBase
1729 if base == "" {
1730 if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
1731 base = b
1732 }
1733 }
1734 var diffTruncated bool
1735 if base != "" {
1736 if patch, truncated, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
1737 files, diffTruncated = parseDiff(patch), truncated
1738 }
1739 }
1740 md := s.ugcFor(r, p.Repo)
1741 canWrite := s.canWriteRepo(r, p.Repo)
1742 var detachedThreads []diffThread
1743 files, detachedThreads = attachThreads(files, diffComments, m.HeadSHA, md,
1744 reviewRights{Viewer: p.Viewer, MRAuthor: m.Author, Write: canWrite})
1745 if p.Viewer != "" {
1746 markCompose(files, r.URL.Query())
1747 }
1748 stat := statOf(files)
1749 // The commits this MR carries: base..head, the same range as the diff.
1750 type commitRow struct {
1751 SHA, ShortSHA, Subject, AuthorName, AuthorUser, Date string
1752 Sig sigView
1753 }
1754 mrNames := s.authorNames()
1755 var commits []commitRow
1756 commitsTotal := 0
1757 if base != "" {
1758 const maxMRCommits = 100
1759 shas, _ := gitutil.RevListRange(p.Dir, base, headRef)
1760 commitsTotal = len(shas)
1761 if len(shas) > maxMRCommits {
1762 shas = shas[:maxMRCommits]
1763 }
1764 for _, sha := range shas {
1765 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1766 cr := commitRow{SHA: sha, ShortSHA: sha[:10], Sig: v}
1767 if parsed != nil {
1768 cr.Subject = parsed.Subject
1769 cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName)
1770 cr.AuthorUser, _ = mrNames.account(parsed.AuthorEmail)
1771 cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1772 }
1773 commits = append(commits, cr)
1774 }
1775 }
1776 // The diff is the reason most people open a merge request, so it gets
1777 // its own view rather than a fold at the foot of the conversation.
1778 // A query parameter keeps this working without JavaScript.
1779 unresolved, _ := s.st.UnresolvedThreadCount(m.ID)
1780 branches, _ := gitutil.Refs(p.Dir, "heads")
1781 view := r.URL.Query().Get("view")
1782 if view != "commits" && view != "diff" {
1783 view = "conversation"
1784 }
1785 // The stack around an open merge request, for the header.
1786 var stackedOn *store.MR
1787 var stacked []store.MR
1788 if m.State == "open" {
1789 if parent, ok, err := s.st.OpenMRBySource(p.Repo.ID, m.TargetRef); err == nil && ok && parent.ID != m.ID {
1790 stackedOn = &parent
1791 }
1792 if m.SourceRepoID == p.Repo.ID {
1793 stacked, _ = s.st.OpenMRsByTarget(p.Repo.ID, m.SourceRef)
1794 }
1795 }
1796 s.render(w, "mr.html", struct {
1797 repoPage
1798 MR store.MR
1799 View string
1800 BodyHTML template.HTML
1801 Checks []store.Check
1802 Combined string
1803 Comments []renderedComment
1804 Reviews []store.MRReview
1805 DiffFiles []diffFile
1806 DiffTruncated bool
1807 Stat diffStat
1808 Commits []commitRow
1809 CommitsTotal int
1810 Branches []gitutil.Ref
1811 CanEdit bool
1812 CanWrite bool
1813 Unresolved int
1814 Notice string
1815 DetachedThreads []diffThread
1816 StackedOn *store.MR
1817 Stacked []store.MR
1818 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
1819 reviews, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
1820 canWrite, unresolved, r.URL.Query().Get("e"), detachedThreads, stackedOn, stacked})
1821}
1822
1823func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
1824 p, ok := s.repoFor(w, r, "")
1825 if !ok {
1826 return
1827 }
1828 p.Tab = "refs"
1829 branches, _ := gitutil.Refs(p.Dir, "heads")
1830 tags, _ := gitutil.Refs(p.Dir, "tags")
1831 s.render(w, "refs.html", struct {
1832 repoPage
1833 Branches, Tags []gitutil.Ref
1834 }{p, branches, tags})
1835}
1836
1837func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
1838 p, ok := s.repoFor(w, r, "")
1839 if !ok {
1840 return
1841 }
1842 file := r.PathValue("file")
1843 ref, ok := strings.CutSuffix(file, ".tar.gz")
1844 if !ok {
1845 s.notFound(w, r)
1846 return
1847 }
1848 if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
1849 s.notFound(w, r)
1850 return
1851 }
1852 prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
1853 w.Header().Set("Content-Type", "application/gzip")
1854 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
1855 gitutil.Archive(p.Dir, ref, prefix, w)
1856}
1857
1858func policyCanAdmin(u store.User, repo store.Repo, grant string) bool {
1859 return policy.CanAdmin(u, repo, grant)
1860}
1861
1862func policyCanRead(u store.User, repo store.Repo, grant string) bool {
1863 return policy.CanRead(u, repo, grant)
1864}