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