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