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