internal/httpd/web.go
1767 lines · 54492 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 // The same filters the CLI's issue list takes, as query parameters;
1491 // label chips and author links point here.
1492 qv := r.URL.Query()
1493 f := store.IssueFilter{State: state, Label: qv.Get("label"), Assignee: qv.Get("assignee"),
1494 Author: qv.Get("author"), Milestone: qv.Get("milestone")}
1495 issues, err := s.st.QueryIssues(p.Repo.ID, f)
1496 if err != nil {
1497 http.Error(w, "internal error", http.StatusInternalServerError)
1498 return
1499 }
1500 if labels, err := s.st.ListIssueLabels(p.Repo.ID); err == nil {
1501 for i := range issues {
1502 issues[i].Labels = labels[issues[i].ID]
1503 }
1504 }
1505 s.render(w, "issues.html", struct {
1506 repoPage
1507 State string
1508 Label string
1509 Filters []listFilter
1510 Issues []store.Issue
1511 LabelColors map[string]template.CSS
1512 }{p, state, f.Label, activeFilters(state, [][2]string{{"label", f.Label}, {"assignee", f.Assignee}, {"author", f.Author}, {"milestone", f.Milestone}}),
1513 issues, s.labelColors(p.Repo.ID)})
1514}
1515
1516func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1517 p, ok := s.repoFor(w, r, "")
1518 if !ok {
1519 return
1520 }
1521 p.Tab = "issues"
1522 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1523 if err != nil {
1524 s.notFound(w, r)
1525 return
1526 }
1527 iss, err := s.st.IssueByNumber(p.Repo.ID, n)
1528 if err != nil {
1529 s.notFound(w, r)
1530 return
1531 }
1532 comments, err := s.st.ListIssueComments(iss.ID)
1533 if err != nil {
1534 http.Error(w, "internal error", http.StatusInternalServerError)
1535 return
1536 }
1537 md := s.ugcFor(r, p.Repo)
1538 milestones, _ := s.st.ListMilestones(p.Repo.ID, "open")
1539 s.render(w, "issue.html", struct {
1540 repoPage
1541 Issue store.Issue
1542 BodyHTML template.HTML
1543 Comments []renderedComment
1544 CanEdit bool
1545 CanWrite bool
1546 Milestones []store.Milestone
1547 Notice string
1548 LabelColors map[string]template.CSS
1549 }{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md),
1550 s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
1551 milestones, r.URL.Query().Get("e"), s.labelColors(p.Repo.ID)})
1552}
1553
1554// canEditItem: the author or anyone with write access may edit.
1555// canWriteRepo reports whether the browser session may push to the repo,
1556// which is what gates the review and merge controls.
1557func (s *Server) canWriteRepo(r *http.Request, repo store.Repo) bool {
1558 if s.cfg.Web.Mode != "accounts" {
1559 return false
1560 }
1561 u := s.viewer(r)
1562 if u.ID == 0 {
1563 return false
1564 }
1565 grant, _ := s.st.AccessRole(repo.ID, u.ID)
1566 return policy.CanWrite(u, repo, grant)
1567}
1568
1569func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool {
1570 if s.cfg.Web.Mode != "accounts" {
1571 return false
1572 }
1573 u := s.viewer(r)
1574 if u.ID == 0 {
1575 return false
1576 }
1577 if u.Username == author {
1578 return true
1579 }
1580 grant, _ := s.st.AccessRole(repo.ID, u.ID)
1581 return policy.CanWrite(u, repo, grant)
1582}
1583
1584func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
1585 p, ok := s.repoFor(w, r, "")
1586 if !ok {
1587 return
1588 }
1589 p.Tab = "merge requests"
1590 state := r.URL.Query().Get("state")
1591 if state == "" {
1592 state = "open"
1593 }
1594 valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
1595 if !valid[state] {
1596 state = "open"
1597 }
1598 qv := r.URL.Query()
1599 mf := store.MRFilter{State: state, Author: qv.Get("author"), Milestone: qv.Get("milestone")}
1600 mrs, err := s.st.QueryMRs(p.Repo.ID, mf)
1601 if err != nil {
1602 http.Error(w, "internal error", http.StatusInternalServerError)
1603 return
1604 }
1605 s.render(w, "mrs.html", struct {
1606 repoPage
1607 State string
1608 Filters []listFilter
1609 MRs []store.MR
1610 }{p, state, activeFilters(state, [][2]string{{"author", mf.Author}, {"milestone", mf.Milestone}}), mrs})
1611}
1612
1613func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1614 p, ok := s.repoFor(w, r, "")
1615 if !ok {
1616 return
1617 }
1618 p.Tab = "merge requests"
1619 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1620 if err != nil {
1621 s.notFound(w, r)
1622 return
1623 }
1624 m, err := s.st.MRByNumber(p.Repo.ID, n)
1625 if err != nil {
1626 s.notFound(w, r)
1627 return
1628 }
1629 comments, _ := s.st.ListMRComments(m.ID)
1630 reviews, _ := s.st.ListMRReviews(m.ID)
1631 checks, combined, _ := s.st.ChecksForCommit(p.Repo.ID, m.HeadSHA)
1632 diffComments, _ := s.st.ListDiffComments(m.ID)
1633
1634 headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
1635 var files []diffFile
1636 base := m.MergedBase
1637 if base == "" {
1638 if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
1639 base = b
1640 }
1641 }
1642 if base != "" {
1643 if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
1644 files = parseDiff(patch)
1645 }
1646 }
1647 md := s.ugcFor(r, p.Repo)
1648 canWrite := s.canWriteRepo(r, p.Repo)
1649 var detachedThreads []diffThread
1650 files, detachedThreads = attachThreads(files, diffComments, m.HeadSHA, md,
1651 reviewRights{Viewer: p.Viewer, MRAuthor: m.Author, Write: canWrite})
1652 if p.Viewer != "" {
1653 markCompose(files, r.URL.Query())
1654 }
1655 stat := statOf(files)
1656 // The commits this MR carries: base..head, the same range as the diff.
1657 type commitRow struct {
1658 SHA, ShortSHA, Subject, AuthorName, AuthorUser, Date string
1659 Sig sigView
1660 }
1661 mrNames := s.authorNames()
1662 var commits []commitRow
1663 if base != "" {
1664 const maxMRCommits = 100
1665 shas, _ := gitutil.RevListRange(p.Dir, base, headRef)
1666 if len(shas) > maxMRCommits {
1667 shas = shas[:maxMRCommits]
1668 }
1669 for _, sha := range shas {
1670 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1671 cr := commitRow{SHA: sha, ShortSHA: sha[:10], Sig: v}
1672 if parsed != nil {
1673 cr.Subject = parsed.Subject
1674 cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName)
1675 cr.AuthorUser, _ = mrNames.account(parsed.AuthorEmail)
1676 cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1677 }
1678 commits = append(commits, cr)
1679 }
1680 }
1681 // The diff is the reason most people open a merge request, so it gets
1682 // its own view rather than a fold at the foot of the conversation.
1683 // A query parameter keeps this working without JavaScript.
1684 unresolved, _ := s.st.UnresolvedThreadCount(m.ID)
1685 branches, _ := gitutil.Refs(p.Dir, "heads")
1686 view := r.URL.Query().Get("view")
1687 if view != "commits" && view != "diff" {
1688 view = "conversation"
1689 }
1690 // The stack around an open merge request, for the header.
1691 var stackedOn *store.MR
1692 var stacked []store.MR
1693 if m.State == "open" {
1694 if parent, ok, err := s.st.OpenMRBySource(p.Repo.ID, m.TargetRef); err == nil && ok && parent.ID != m.ID {
1695 stackedOn = &parent
1696 }
1697 if m.SourceRepoID == p.Repo.ID {
1698 stacked, _ = s.st.OpenMRsByTarget(p.Repo.ID, m.SourceRef)
1699 }
1700 }
1701 s.render(w, "mr.html", struct {
1702 repoPage
1703 MR store.MR
1704 View string
1705 BodyHTML template.HTML
1706 Checks []store.Check
1707 Combined string
1708 Comments []renderedComment
1709 Reviews []store.MRReview
1710 DiffFiles []diffFile
1711 Stat diffStat
1712 Commits []commitRow
1713 Branches []gitutil.Ref
1714 CanEdit bool
1715 CanWrite bool
1716 Unresolved int
1717 Notice string
1718 DetachedThreads []diffThread
1719 StackedOn *store.MR
1720 Stacked []store.MR
1721 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
1722 reviews, files, stat, commits, branches, s.canEditItem(r, p.Repo, m.Author),
1723 canWrite, unresolved, r.URL.Query().Get("e"), detachedThreads, stackedOn, stacked})
1724}
1725
1726func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
1727 p, ok := s.repoFor(w, r, "")
1728 if !ok {
1729 return
1730 }
1731 p.Tab = "refs"
1732 branches, _ := gitutil.Refs(p.Dir, "heads")
1733 tags, _ := gitutil.Refs(p.Dir, "tags")
1734 s.render(w, "refs.html", struct {
1735 repoPage
1736 Branches, Tags []gitutil.Ref
1737 }{p, branches, tags})
1738}
1739
1740func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
1741 p, ok := s.repoFor(w, r, "")
1742 if !ok {
1743 return
1744 }
1745 file := r.PathValue("file")
1746 ref, ok := strings.CutSuffix(file, ".tar.gz")
1747 if !ok {
1748 s.notFound(w, r)
1749 return
1750 }
1751 if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
1752 s.notFound(w, r)
1753 return
1754 }
1755 prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
1756 w.Header().Set("Content-Type", "application/gzip")
1757 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
1758 gitutil.Archive(p.Dir, ref, prefix, w)
1759}
1760
1761func policyCanAdmin(u store.User, repo store.Repo, grant string) bool {
1762 return policy.CanAdmin(u, repo, grant)
1763}
1764
1765func policyCanRead(u store.User, repo store.Repo, grant string) bool {
1766 return policy.CanRead(u, repo, grant)
1767}