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