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