internal/httpd/web.go
1782 lines · 55188 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 // Markdown and org render like a README, with the source one click
551 // away; ?view=source shows the text instead.
552 renderable := false
553 switch path.Ext(strings.ToLower(filePath)) {
554 case ".md", ".markdown", ".org":
555 renderable = !binary
556 }
557 var renderedHTML template.HTML
558 rendered := renderable && r.URL.Query().Get("view") != "source"
559 if rendered {
560 renderedHTML = rewriteRelativeLinks(renderReadme(path.Base(filePath), data), p, path.Dir(filePath))
561 }
562 cs := crumbs(p, "blob", filePath)
563 base := ""
564 if len(cs) > 0 {
565 base = cs[len(cs)-1].Name
566 cs = cs[:len(cs)-1]
567 }
568 branches, _ := gitutil.Refs(p.Dir, "heads")
569 lines := 0
570 if !binary && !image && len(data) > 0 {
571 lines = bytes.Count(data, []byte("\n"))
572 if data[len(data)-1] != '\n' {
573 lines++
574 }
575 }
576 // The file listing leads with the last commit now, so the facts about
577 // the file itself are reported here instead.
578 entry, _ := gitutil.StatPath(p.Dir, p.Ref, filePath)
579 s.render(w, "blob.html", struct {
580 repoPage
581 Crumbs []crumb
582 Base string
583 Path string
584 DirPath string
585 RefKind string
586 Binary bool
587 Image bool
588 Size int
589 Lines int
590 Exec bool
591 Symlink bool
592 Branches []gitutil.Ref
593 CodeHTML template.HTML
594 Renderable bool // markdown or org: the toggle is offered
595 Rendered bool // this response shows the rendering
596 RenderedHTML template.HTML
597 }{p, cs, base, filePath, filePath, "blob", binary, image, len(data), lines,
598 entry.Mode == "100755", entry.Mode == "120000", branches, codeHTML, renderable, rendered, renderedHTML})
599}
600
601// releases lists tag-anchored releases with notes and assets.
602func (s *Server) releases(w http.ResponseWriter, r *http.Request) {
603 p, ok := s.repoFor(w, r, "")
604 if !ok {
605 return
606 }
607 p.Tab = "releases"
608 rels, err := s.st.ListReleases(p.Repo.ID)
609 if err != nil {
610 http.Error(w, "internal error", http.StatusInternalServerError)
611 return
612 }
613 md := s.ugcFor(r, p.Repo)
614 type relView struct {
615 store.Release
616 NotesHTML template.HTML
617 }
618 var views []relView
619 for _, rel := range rels {
620 views = append(views, relView{rel, md(rel.Notes, rel.NotesFormat)})
621 }
622 // Tags without a release yet are what a create form can offer.
623 released := map[string]bool{}
624 for _, rel := range rels {
625 released[rel.Tag] = true
626 }
627 var freeTags []string
628 if tags, err := gitutil.Refs(p.Dir, "tags"); err == nil {
629 for _, tg := range tags {
630 if !released[tg.Name] {
631 freeTags = append(freeTags, tg.Name)
632 }
633 }
634 }
635 s.render(w, "releases.html", struct {
636 repoPage
637 Releases []relView
638 FreeTags []string
639 CanWrite bool
640 Notice string
641 }{p, views, freeTags, s.canWriteRepo(r, p.Repo), r.URL.Query().Get("e")})
642}
643
644// releaseAsset streams one uploaded asset. Tags containing '/' are not
645// reachable here (single path segment); SSH download always works.
646func (s *Server) releaseAsset(w http.ResponseWriter, r *http.Request) {
647 p, ok := s.repoFor(w, r, "")
648 if !ok {
649 return
650 }
651 rel, err := s.st.ReleaseByTag(p.Repo.ID, r.PathValue("tag"))
652 if err != nil {
653 s.notFound(w, r)
654 return
655 }
656 name := r.PathValue("name")
657 found := false
658 for _, a := range rel.Assets {
659 if a.Name == name {
660 found = true
661 }
662 }
663 if !found {
664 s.notFound(w, r)
665 return
666 }
667 f, err := os.Open(filepath.Join(control.RepoDir(s.cfg.Server.Root, p.Repo.OwnerName, p.Repo.Name),
668 "gitbay-releases", strconv.FormatInt(rel.ID, 10), name))
669 if err != nil {
670 s.notFound(w, r)
671 return
672 }
673 defer f.Close()
674 w.Header().Set("Content-Type", "application/octet-stream")
675 w.Header().Set("X-Content-Type-Options", "nosniff")
676 w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
677 if fi, err := f.Stat(); err == nil {
678 w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10))
679 }
680 io.Copy(w, f)
681}
682
683// milestones lists a repo's milestones with progress.
684func (s *Server) milestones(w http.ResponseWriter, r *http.Request) {
685 p, ok := s.repoFor(w, r, "")
686 if !ok {
687 return
688 }
689 p.Tab = "issues"
690 state := r.URL.Query().Get("state")
691 if state != "closed" && state != "all" {
692 state = "open"
693 }
694 ms, err := s.st.ListMilestones(p.Repo.ID, state)
695 if err != nil {
696 http.Error(w, "internal error", http.StatusInternalServerError)
697 return
698 }
699 type msView struct {
700 store.Milestone
701 Percent int
702 }
703 var views []msView
704 for _, m := range ms {
705 v := msView{Milestone: m}
706 if total := m.OpenItems + m.ClosedItems; total > 0 {
707 v.Percent = m.ClosedItems * 100 / total
708 }
709 views = append(views, v)
710 }
711 s.render(w, "milestones.html", struct {
712 repoPage
713 State string
714 Milestones []msView
715 }{p, state, views})
716}
717
718// search runs a bounded literal git grep over the repo's default branch.
719func (s *Server) search(w http.ResponseWriter, r *http.Request) {
720 p, ok := s.repoFor(w, r, "")
721 if !ok {
722 return
723 }
724 p.Tab = "search"
725 q := strings.TrimSpace(r.URL.Query().Get("q"))
726 type matchView struct {
727 Path string
728 Line int
729 TextHTML template.HTML
730 }
731 var matches []matchView
732 var queryErr string
733 if q != "" {
734 if len(q) < 2 || len(q) > 200 {
735 queryErr = "query must be 2 to 200 characters"
736 } else if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err == nil {
737 raw, err := gitutil.Grep(p.Dir, p.Ref, q, 200)
738 if err != nil {
739 http.Error(w, "internal error", http.StatusInternalServerError)
740 return
741 }
742 for _, m := range raw {
743 matches = append(matches, matchView{m.Path, m.Line, markMatch(m.Text, q)})
744 }
745 }
746 }
747 s.render(w, "search.html", struct {
748 repoPage
749 Query string
750 QueryErr string
751 Matches []matchView
752 Capped bool
753 }{p, q, queryErr, matches, len(matches) == 200})
754}
755
756// markMatch escapes a matched line and wraps case-insensitive occurrences
757// of the query in <mark>.
758func markMatch(text, q string) template.HTML {
759 lower, lq := strings.ToLower(text), strings.ToLower(q)
760 var b strings.Builder
761 pos := 0
762 for {
763 i := strings.Index(lower[pos:], lq)
764 if i < 0 {
765 break
766 }
767 i += pos
768 b.WriteString(template.HTMLEscapeString(text[pos:i]))
769 b.WriteString("<mark>")
770 b.WriteString(template.HTMLEscapeString(text[i : i+len(q)]))
771 b.WriteString("</mark>")
772 pos = i + len(q)
773 }
774 b.WriteString(template.HTMLEscapeString(text[pos:]))
775 return template.HTML(b.String())
776}
777
778func (s *Server) blame(w http.ResponseWriter, r *http.Request) {
779 p, ok := s.repoFor(w, r, r.PathValue("ref"))
780 if !ok {
781 return
782 }
783 p.Tab = "files"
784 filePath := strings.Trim(r.PathValue("path"), "/")
785
786 // Blame is a control command; the web renders what it returns rather
787 // than shelling out to git itself, so all three surfaces agree.
788 page := 1
789 if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n >= 1 {
790 page = n
791 }
792 from := (page-1)*control.BlameSpan + 1
793
794 var out struct {
795 From int `json:"from"`
796 To int `json:"to"`
797 TotalLines int `json:"total_lines"`
798 Hunks []struct {
799 SHA string `json:"sha"`
800 AuthorName string `json:"author_name"`
801 AuthorEmail string `json:"author_email"`
802 Date string `json:"date"`
803 Summary string `json:"summary"`
804 StartLine int `json:"start_line"`
805 Lines []string `json:"lines"`
806 } `json:"hunks"`
807 }
808 argv := []string{"repo", "blame", p.Repo.Path(), filePath,
809 "--ref", p.Ref, "--from", strconv.Itoa(from), "--to", strconv.Itoa(from + control.BlameSpan - 1)}
810 var viewer store.User
811 if s.cfg.Web.Mode == "accounts" {
812 viewer = s.viewer(r)
813 }
814 msg, ok := s.runControlInto(viewer, argv, &out)
815
816 // A binary or empty file is a refusal, not a 404: the page still
817 // renders and says why there is nothing to attribute.
818 binary := false
819 if !ok {
820 if strings.Contains(msg, "is binary") {
821 binary = true
822 } else {
823 s.notFound(w, r)
824 return
825 }
826 }
827
828 type hunkView struct {
829 gitutil.BlameHunk
830 ShortSHA string
831 Date string
832 Sig sigView
833 Numbered []numberedLine
834 }
835 var hunks []hunkView
836 sigs := map[string]sigView{}
837 for _, h := range out.Hunks {
838 v, seen := sigs[h.SHA]
839 if !seen {
840 v, _ = s.sigFor(p.Repo, p.Dir, h.SHA)
841 sigs[h.SHA] = v
842 }
843 date := h.Date
844 if t, err := time.Parse(time.RFC3339, h.Date); err == nil {
845 date = t.Format("2006-01-02")
846 }
847 hv := hunkView{
848 BlameHunk: gitutil.BlameHunk{SHA: h.SHA, AuthorName: h.AuthorName,
849 AuthorEmail: h.AuthorEmail, Summary: h.Summary,
850 StartLine: h.StartLine, Lines: h.Lines},
851 ShortSHA: h.SHA[:min(10, len(h.SHA))], Date: date, Sig: v,
852 }
853 for i, l := range h.Lines {
854 hv.Numbered = append(hv.Numbered, numberedLine{h.StartLine + i, l})
855 }
856 hunks = append(hunks, hv)
857 }
858
859 pages := (out.TotalLines + control.BlameSpan - 1) / control.BlameSpan
860 if pages == 0 {
861 pages = 1
862 }
863 if page > pages {
864 page = pages
865 }
866
867 cs := crumbs(p, "blame", filePath)
868 base := ""
869 if len(cs) > 0 {
870 base = cs[len(cs)-1].Name
871 cs = cs[:len(cs)-1]
872 }
873 s.render(w, "blame.html", struct {
874 repoPage
875 Crumbs []crumb
876 Base string
877 Path string
878 Binary bool
879 Hunks []hunkView
880 Page, Pages int
881 }{p, cs, base, filePath, binary, hunks, page, pages})
882}
883
884type numberedLine struct {
885 N int
886 Text string
887}
888
889// chromaFormatter emits class-based markup (no inline colors), so the
890// stylesheet can swap palettes with the color scheme.
891var chromaFormatter = html.New(html.WithClasses(true),
892 html.WithLineNumbers(true), html.LineNumbersInTable(false),
893 html.WithLinkableLineNumbers(true, "L"))
894
895func highlight(filePath string, data []byte) template.HTML {
896 lexer := lexers.Match(filePath)
897 if lexer == nil {
898 lexer = lexers.Fallback
899 }
900 iterator, err := lexer.Tokenise(nil, string(data))
901 if err != nil {
902 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
903 }
904 var buf bytes.Buffer
905 if err := chromaFormatter.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
906 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
907 }
908 return template.HTML(buf.String())
909}
910
911// chromaCSS is both syntax palettes, each scoped to the scheme it is for.
912// The light one cannot be left unscoped: the two palettes do not name the
913// same token set, and every token github-dark omits would keep its
914// light-theme colour on a black ground — NameAttribute landed at 2.97:1.
915// Scoped, an unnamed token inherits the wrapper's colour instead, which is
916// readable in both. The site's --code-bg stays the background either way.
917// lightStyle and darkStyle are chosen on measured contrast against the
918// grounds code actually sits on here — page, code block, and the diff
919// tints. friendly, the chroma default, put 61 token/ground pairs under
920// 4.5:1; xcode puts one.
921const (
922 lightStyle = "xcode"
923 darkStyle = "github-dark"
924)
925
926var chromaCSS = func() []byte {
927 var buf bytes.Buffer
928 buf.WriteString("@media (prefers-color-scheme: light) {\n")
929 chromaFormatter.WriteCSS(&buf, styles.Get(lightStyle))
930 // xcode's NameAttribute is its one token under 4.5:1 against the diff
931 // tints (4.51 on additions, 4.38 on deletions); darkened it clears both.
932 buf.WriteString(".chroma .na { color: #6f5a21 }\n")
933 buf.WriteString("}\n@media (prefers-color-scheme: dark) {\n")
934 chromaFormatter.WriteCSS(&buf, styles.Get(darkStyle))
935 buf.WriteString("}\n.chroma, .bg { background: transparent !important; }\n")
936 // Line numbers take the site's own gutter colour in both schemes. Left
937 // alone they are github-dark's #6e7681 (4.31:1 on the page) in dark and
938 // chroma's built-in #7f7f7f (3.67:1 on a code block) in light — the
939 // latter is a formatter fallback, not a style entry, so no palette test
940 // can see it.
941 buf.WriteString(".chroma .lnt, .chroma .ln { color: var(--muted) }\n")
942 return buf.Bytes()
943}()
944
945func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
946 p, ok := s.repoFor(w, r, r.PathValue("ref"))
947 if !ok {
948 return
949 }
950 filePath := strings.Trim(r.PathValue("path"), "/")
951 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
952 if err != nil {
953 s.notFound(w, r)
954 return
955 }
956 // Serve inert: never let repo content execute in the forge's origin.
957 // Images get their real type so <img> works under nosniff; SVG script
958 // is dead on arrival because the instance CSP is script-src 'none'.
959 ct := "text/plain; charset=utf-8"
960 if t, ok := imageTypes[strings.ToLower(path.Ext(filePath))]; ok {
961 ct = t
962 }
963 w.Header().Set("Content-Type", ct)
964 w.Header().Set("X-Content-Type-Options", "nosniff")
965 w.Write(data)
966}
967
968// imageTypes are the formats raw serves with a real content type and blob
969// pages preview inline.
970var imageTypes = map[string]string{
971 ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
972 ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif",
973 ".svg": "image/svg+xml", ".ico": "image/x-icon",
974}
975
976// readmeRank orders competing README files: richer renderers win.
977var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
978
979// pickReadme returns the best README-ish blob in a tree listing: any file
980// named "readme" or "readme.<ext>" (case-insensitive), preferring formats
981// we can render richly.
982func pickReadme(entries []gitutil.TreeEntry) string {
983 best, bestRank := "", 1<<30
984 for _, e := range entries {
985 if e.Type != "blob" {
986 continue
987 }
988 lower := strings.ToLower(e.Name)
989 if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
990 continue
991 }
992 rank, ok := readmeRank[path.Ext(lower)]
993 if !ok {
994 rank = 10 // plaintext fallback
995 }
996 if rank < bestRank {
997 best, bestRank = e.Name, rank
998 }
999 }
1000 return best
1001}
1002
1003// markdown is the shared renderer: GFM (tables, strikethrough, autolinks,
1004// task lists) on top of CommonMark, with class-based fence highlighting
1005// (the palette lives in the stylesheet, per scheme). Raw HTML is still
1006// dropped.
1007var markdown = goldmark.New(goldmark.WithExtensions(extension.GFM,
1008 highlighting.NewHighlighting(highlighting.WithFormatOptions(html.WithClasses(true)))))
1009
1010// fenceHighlight renders one code block with chroma classes, for org and
1011// anything else outside goldmark. Unknown languages fall back to plain.
1012func fenceHighlight(source, lang string) string {
1013 lexer := lexers.Get(lang)
1014 if lexer == nil {
1015 lexer = lexers.Fallback
1016 }
1017 iterator, err := lexer.Tokenise(nil, source)
1018 if err != nil {
1019 return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
1020 }
1021 var buf bytes.Buffer
1022 f := html.New(html.WithClasses(true))
1023 if err := f.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
1024 return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
1025 }
1026 return buf.String()
1027}
1028
1029// mdHTML renders user-authored markdown (issue and MR bodies, comments).
1030// goldmark's default renderer drops raw HTML, so this is safe as-is.
1031func mdHTML(raw string) template.HTML {
1032 if strings.TrimSpace(raw) == "" {
1033 return ""
1034 }
1035 var buf bytes.Buffer
1036 if markdown.Convert([]byte(raw), &buf) != nil {
1037 return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
1038 }
1039 return template.HTML(buf.String())
1040}
1041
1042// aboutHTML renders a profile's about text. It has no filename to
1043// dispatch on, so the stored format picks the extension; anything other
1044// than org is markdown.
1045func aboutHTML(p store.Profile) template.HTML {
1046 if strings.TrimSpace(p.About) == "" {
1047 return ""
1048 }
1049 name := "about.md"
1050 if p.AboutFormat == "org" {
1051 name = "about.org"
1052 }
1053 return renderReadme(name, []byte(p.About))
1054}
1055
1056// webResolver answers autolink lookups for one viewer. Cross-repo
1057// references to repositories the viewer cannot read stay plain text, per
1058// the enumeration rule: a link would confirm the repo exists.
1059type webResolver struct {
1060 s *Server
1061 viewer store.User
1062}
1063
1064func (r webResolver) RefURL(owner, name string, kind byte, n int64) string {
1065 repo, err := r.s.st.RepoByPath(owner + "/" + name)
1066 if err != nil {
1067 return ""
1068 }
1069 grant := ""
1070 if r.viewer.ID != 0 {
1071 grant, _ = r.s.st.AccessRole(repo.ID, r.viewer.ID)
1072 }
1073 if !policy.CanRead(r.viewer, repo, grant) {
1074 return ""
1075 }
1076 if kind == '#' {
1077 if _, err := r.s.st.IssueByNumber(repo.ID, n); err != nil {
1078 return ""
1079 }
1080 return autolink.IssueURL(repo.OwnerName, repo.Name, n)
1081 }
1082 if _, err := r.s.st.MRByNumber(repo.ID, n); err != nil {
1083 return ""
1084 }
1085 return autolink.MRURL(repo.OwnerName, repo.Name, n)
1086}
1087
1088func (r webResolver) UserURL(name string) string {
1089 if _, err := r.s.st.UserByUsername(name); err == nil {
1090 return "/" + name
1091 }
1092 if _, err := r.s.st.OrgByName(name); err == nil {
1093 return "/" + name
1094 }
1095 return ""
1096}
1097
1098// ugcRenderer renders one user-authored body in the format it was written in.
1099// The format travels with the body: it is recorded when the text is written, so
1100// changing a preference later cannot re-interpret prose that already exists.
1101type ugcRenderer func(raw, format string) template.HTML
1102
1103// ugcHTML renders a user-authored body. Anything other than "org" is markdown,
1104// so a body stored before formats existed — and any row whose column defaulted —
1105// renders exactly as it did before.
1106//
1107// Org goes through renderReadme, the same path READMEs, wiki pages and profile
1108// about text take, so it inherits that function's include guard and sanitising
1109// rather than growing a second org renderer to keep in step.
1110func ugcHTML(raw, format string) template.HTML {
1111 if format == "org" {
1112 return renderOrg("body.org", []byte(raw), false, func() template.HTML {
1113 return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
1114 })
1115 }
1116 return mdHTML(raw)
1117}
1118
1119// ugcFor returns a renderer for user-authored bodies on one repo's pages:
1120// ugcHTML plus cross-reference and mention autolinking for this viewer.
1121func (s *Server) ugcFor(r *http.Request, repo store.Repo) ugcRenderer {
1122 viewer := store.User{}
1123 if s.cfg.Web.Mode == "accounts" {
1124 viewer = s.viewer(r)
1125 }
1126 res := webResolver{s, viewer}
1127 return func(raw, format string) template.HTML {
1128 h := ugcHTML(raw, format)
1129 if h == "" {
1130 return h
1131 }
1132 return template.HTML(autolink.Rewrite(string(h), repo.OwnerName, repo.Name, res))
1133 }
1134}
1135
1136// renderedComment pairs a comment with its rendered body for templates.
1137type renderedComment struct {
1138 Author string
1139 CreatedAt string
1140 Kind string
1141 BodyHTML template.HTML
1142}
1143
1144func renderComments(cs []store.IssueComment, ugc ugcRenderer) []renderedComment {
1145 var out []renderedComment
1146 for _, c := range cs {
1147 out = append(out, renderedComment{c.Author, c.CreatedAt, c.Kind, ugc(c.Body, c.BodyFormat)})
1148 }
1149 return out
1150}
1151
1152// ugcPolicy sanitizes rendered repo content before it enters the forge's
1153// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
1154// output and repo-authored HTML are not. Chroma's highlighting classes
1155// must survive; the pattern admits only short token codes, not the site's
1156// own class names.
1157var ugcPolicy = func() *bluemonday.Policy {
1158 p := bluemonday.UGCPolicy()
1159 p.AllowAttrs("class").
1160 Matching(regexp.MustCompile(`^(chroma|[a-z0-9]{1,3})( (chroma|[a-z0-9]{1,3}))*$`)).
1161 OnElements("span", "pre", "code", "div")
1162 return p
1163}()
1164
1165// renderReadme renders a README by extension: markdown, org-mode, and
1166// (sanitized) HTML richly; everything else as escaped plaintext.
1167// orgConfig is the go-org configuration for rendering untrusted org.
1168//
1169// go-org's default reads #+INCLUDE: and #+SETUPFILE: targets off disk with
1170// os.ReadFile. Everything rendered here is content someone pushed — a README, a
1171// wiki page, a profile — so both keywords are refused outright: the file is
1172// never opened and the keyword stays the inert text it is. There is no safe
1173// subset to allow instead. An absolute path skips go-org's relative-path join,
1174// a relative one resolves against the daemon's working directory, and a repo
1175// has no directory to scope to anyway because the content came from a git
1176// object rather than a checkout.
1177//
1178// The default logger writes parse warnings to stderr, which would let pushed
1179// content write to the server's log; discard them.
1180func orgConfig() *org.Configuration {
1181 c := org.New()
1182 c.ReadFile = func(string) ([]byte, error) {
1183 return nil, errOrgIncludeDisabled
1184 }
1185 c.Log = log.New(io.Discard, "", 0)
1186 return c
1187}
1188
1189var errOrgIncludeDisabled = errors.New("org: #+INCLUDE and #+SETUPFILE are disabled")
1190
1191// renderOrg renders org to sanitized HTML. `contents` asks go-org for its table
1192// of contents: a README or wiki page is a document and carries one, an issue
1193// comment is a remark and should not sprout one above two headings. `fallback`
1194// supplies the plaintext rendering used when the writer fails.
1195func renderOrg(name string, raw []byte, contents bool, fallback func() template.HTML) template.HTML {
1196 c := orgConfig()
1197 if !contents {
1198 // DefaultSettings is a fresh map per org.New(), so this is local.
1199 c.DefaultSettings["OPTIONS"] = strings.ReplaceAll(c.DefaultSettings["OPTIONS"], "toc:t", "toc:nil")
1200 }
1201 doc := c.Parse(bytes.NewReader(raw), name)
1202 writer := org.NewHTMLWriter()
1203 writer.HighlightCodeBlock = func(source, lang string, inline bool, params map[string]string) string {
1204 if inline {
1205 return "<code>" + template.HTMLEscapeString(source) + "</code>"
1206 }
1207 return fenceHighlight(source, lang)
1208 }
1209 out, err := doc.Write(writer)
1210 if err != nil {
1211 return fallback()
1212 }
1213 return template.HTML(ugcPolicy.Sanitize(out))
1214}
1215
1216func renderReadme(name string, raw []byte) template.HTML {
1217 plain := func() template.HTML {
1218 return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
1219 }
1220 if gitutil.IsBinary(raw) {
1221 return ""
1222 }
1223 switch path.Ext(strings.ToLower(name)) {
1224 case ".md", ".markdown":
1225 var buf bytes.Buffer
1226 if markdown.Convert(raw, &buf) != nil {
1227 return plain()
1228 }
1229 return template.HTML(buf.String())
1230 case ".org":
1231 return renderOrg(name, raw, true, plain)
1232 case ".html", ".htm":
1233 return template.HTML(ugcPolicy.Sanitize(string(raw)))
1234 default:
1235 return plain()
1236 }
1237}
1238
1239type diffThread struct {
1240 ID int64
1241 Resolved string
1242 Stale bool
1243 CanResolve bool
1244 Comments []renderedComment
1245}
1246
1247// reviewRights decides which thread controls a viewer sees. mr resolve
1248// admits the thread author, the MR author, or anyone with write, so the
1249// page needs all three to render the button truthfully.
1250type reviewRights struct {
1251 Viewer string
1252 MRAuthor string
1253 Write bool
1254}
1255
1256func (r reviewRights) canResolve(threadAuthor string) bool {
1257 return r.Viewer != "" && (r.Write || r.Viewer == r.MRAuthor || r.Viewer == threadAuthor)
1258}
1259
1260// attachThreads injects review threads under their anchored diff lines;
1261// threads whose anchor no longer appears (stale after force-push, or on a
1262// context line outside the current diff) are returned separately.
1263func attachThreads(files []diffFile, comments []store.DiffComment, headSHA string, md ugcRenderer, rights reviewRights) ([]diffFile, []diffThread) {
1264 type anchor struct {
1265 path string
1266 side string
1267 line int64
1268 }
1269 // Diff-line comments have no stored format yet, so they stay markdown.
1270 // They are the one user-authored body left without the choice; see #51.
1271 threads := map[int64]*diffThread{}
1272 anchors := map[int64]anchor{}
1273 var order []int64
1274 for _, cm := range comments {
1275 if cm.ReplyTo == 0 {
1276 threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
1277 CanResolve: rights.canResolve(cm.Author),
1278 Comments: []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")}}}
1279 anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
1280 order = append(order, cm.ID)
1281 } else if th, ok := threads[cm.ReplyTo]; ok {
1282 th.Comments = append(th.Comments, renderedComment{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")})
1283 }
1284 }
1285 placed := map[int64]bool{}
1286 for f := range files {
1287 lines := files[f].Lines
1288 for i := range lines {
1289 for _, id := range order {
1290 if placed[id] || threads[id].Stale {
1291 continue
1292 }
1293 a := anchors[id]
1294 if lines[i].Path != a.path {
1295 continue
1296 }
1297 if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") ||
1298 (a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") {
1299 lines[i].Threads = append(lines[i].Threads, *threads[id])
1300 files[f].Threads++
1301 files[f].Open = true
1302 placed[id] = true
1303 }
1304 }
1305 }
1306 }
1307 var unplaced []diffThread
1308 for _, id := range order {
1309 if !placed[id] {
1310 unplaced = append(unplaced, *threads[id])
1311 }
1312 }
1313 return files, unplaced
1314}
1315
1316// markCompose opens the new-thread form under one diff line. There is no
1317// JavaScript, so "comment on this line" is a plain GET carrying the
1318// anchor and the page renders the form where the reader asked for it.
1319func markCompose(files []diffFile, q url.Values) {
1320 path := q.Get("cpath")
1321 line, _ := strconv.ParseInt(q.Get("cline"), 10, 64)
1322 if path == "" || line < 1 {
1323 return
1324 }
1325 old := q.Get("cside") == "old"
1326 for f := range files {
1327 for i := range files[f].Lines {
1328 ln := &files[f].Lines[i]
1329 if ln.Path != path {
1330 continue
1331 }
1332 if (old && ln.Class == "del" && ln.OldLine == line) ||
1333 (!old && ln.Class != "del" && ln.NewLine == line) {
1334 ln.Compose = true
1335 files[f].Open = true
1336 return
1337 }
1338 }
1339 }
1340}
1341
1342type sigView struct {
1343 State string
1344 Signer string
1345 Fingerprint string
1346}
1347
1348func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
1349 raw, err := gitutil.ReadCommit(dir, sha)
1350 if err != nil {
1351 return sigView{State: "unsigned"}, nil
1352 }
1353 parsed, err := sig.ParseCommit(raw)
1354 if err != nil {
1355 return sigView{State: "unsigned"}, nil
1356 }
1357 res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
1358 if err != nil {
1359 return sigView{State: "unsigned"}, parsed
1360 }
1361 v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
1362 if res.SignerUserID != 0 {
1363 if u, err := s.st.UserByID(res.SignerUserID); err == nil {
1364 v.Signer = u.Username
1365 }
1366 }
1367 return v, parsed
1368}
1369
1370func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1371 ref := r.PathValue("ref")
1372 p, ok := s.repoFor(w, r, ref)
1373 if !ok {
1374 return
1375 }
1376 p.Tab = "log"
1377 const pageSize = 50
1378 // ?path= filters to commits touching one file or directory.
1379 filePath := strings.Trim(path.Clean("/"+r.URL.Query().Get("path")), "/")
1380 if filePath == "." {
1381 filePath = ""
1382 }
1383 var shas []string
1384 var err error
1385 if filePath != "" {
1386 shas, err = gitutil.RevListPath(p.Dir, p.Ref, filePath, pageSize+1)
1387 } else {
1388 shas, err = gitutil.RevList(p.Dir, p.Ref, pageSize+1)
1389 }
1390 if err != nil {
1391 s.notFound(w, r)
1392 return
1393 }
1394 next := ""
1395 if len(shas) > pageSize {
1396 next = shas[pageSize]
1397 shas = shas[:pageSize]
1398 }
1399 type row struct {
1400 SHA, ShortSHA, Subject, AuthorName, AuthorEmail, AuthorUser, Date string
1401 Sig sigView
1402 Check string // combined status, "" when none ran
1403 }
1404 names := s.authorNames()
1405 checks, _ := s.st.CombinedStatusFor(p.Repo.ID, shas)
1406 var rows []row
1407 for _, sha := range shas {
1408 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1409 rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v, Check: checks[sha]}
1410 if parsed != nil {
1411 rw.Subject = parsed.Subject
1412 rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
1413 rw.AuthorUser, _ = names.account(parsed.AuthorEmail)
1414 rw.AuthorEmail = parsed.AuthorEmail
1415 rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1416 }
1417 rows = append(rows, rw)
1418 }
1419 s.render(w, "log.html", struct {
1420 repoPage
1421 Commits []row
1422 NextSHA string
1423 FilePath string
1424 }{p, rows, next, filePath})
1425}
1426
1427func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1428 p, ok := s.repoFor(w, r, "")
1429 if !ok {
1430 return
1431 }
1432 p.Tab = "log"
1433 sha := r.PathValue("sha")
1434 full, err := gitutil.ResolveRef(p.Dir, sha)
1435 if err != nil {
1436 s.notFound(w, r)
1437 return
1438 }
1439 v, parsed := s.sigFor(p.Repo, p.Dir, full)
1440 if parsed == nil {
1441 s.notFound(w, r)
1442 return
1443 }
1444 patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
1445 files := parseDiff(patch)
1446 committerEmail := ""
1447 if parsed.CommitterEmail != parsed.AuthorEmail {
1448 committerEmail = parsed.CommitterEmail
1449 }
1450 checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
1451 commitNames := s.authorNames()
1452 commitUser, _ := commitNames.account(parsed.AuthorEmail)
1453 msg := ""
1454 if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
1455 msg = string(parsed.Payload[i+2:])
1456 }
1457 s.render(w, "commit.html", struct {
1458 repoPage
1459 SHA, ShortSHA, AuthorName, AuthorEmail, AuthorUser, CommitterEmail, Date, Message string
1460 Parents []string
1461 Sig sigView
1462 Checks []store.CommitStatus
1463 DiffFiles []diffFile
1464 }{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail,
1465 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1466 gitutil.Parents(p.Dir, full), v, checks, files})
1467}
1468
1469// labelPalette provides default label chip colors: mid-tone hues that stay
1470// legible on light and dark backgrounds.
1471var labelPalette = []string{
1472 "#0969da", "#1a7f37", "#9a6700", "#cf222e",
1473 "#8250df", "#b93a86", "#0b6c80", "#bf5b16",
1474}
1475
1476var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
1477
1478// labelColors returns a complete label-name -> chip color map for a repo:
1479// the stored labels.color when it is a valid hex color, otherwise a
1480// stable default picked from the palette by name hash.
1481func (s *Server) labelColors(repoID int64) map[string]template.CSS {
1482 stored, _ := s.st.LabelColors(repoID)
1483 out := make(map[string]template.CSS, len(stored))
1484 for name, color := range stored {
1485 if !hexColorPat.MatchString(color) {
1486 h := fnv.New32a()
1487 h.Write([]byte(name))
1488 color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
1489 }
1490 out[name] = template.CSS("--chip:" + color)
1491 }
1492 return out
1493}
1494
1495func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1496 p, ok := s.repoFor(w, r, "")
1497 if !ok {
1498 return
1499 }
1500 p.Tab = "issues"
1501 state := r.URL.Query().Get("state")
1502 if state != "closed" && state != "all" {
1503 state = "open"
1504 }
1505 // The same filters the CLI's issue list takes, as query parameters;
1506 // label chips and author links point here.
1507 qv := r.URL.Query()
1508 f := store.IssueFilter{State: state, Label: qv.Get("label"), Assignee: qv.Get("assignee"),
1509 Author: qv.Get("author"), Milestone: qv.Get("milestone")}
1510 issues, err := s.st.QueryIssues(p.Repo.ID, f)
1511 if err != nil {
1512 http.Error(w, "internal error", http.StatusInternalServerError)
1513 return
1514 }
1515 if labels, err := s.st.ListIssueLabels(p.Repo.ID); err == nil {
1516 for i := range issues {
1517 issues[i].Labels = labels[issues[i].ID]
1518 }
1519 }
1520 s.render(w, "issues.html", struct {
1521 repoPage
1522 State string
1523 Label string
1524 Filters []listFilter
1525 Issues []store.Issue
1526 LabelColors map[string]template.CSS
1527 }{p, state, f.Label, activeFilters(state, [][2]string{{"label", f.Label}, {"assignee", f.Assignee}, {"author", f.Author}, {"milestone", f.Milestone}}),
1528 issues, s.labelColors(p.Repo.ID)})
1529}
1530
1531func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1532 p, ok := s.repoFor(w, r, "")
1533 if !ok {
1534 return
1535 }
1536 p.Tab = "issues"
1537 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1538 if err != nil {
1539 s.notFound(w, r)
1540 return
1541 }
1542 iss, err := s.st.IssueByNumber(p.Repo.ID, n)
1543 if err != nil {
1544 s.notFound(w, r)
1545 return
1546 }
1547 comments, err := s.st.ListIssueComments(iss.ID)
1548 if err != nil {
1549 http.Error(w, "internal error", http.StatusInternalServerError)
1550 return
1551 }
1552 md := s.ugcFor(r, p.Repo)
1553 milestones, _ := s.st.ListMilestones(p.Repo.ID, "open")
1554 s.render(w, "issue.html", struct {
1555 repoPage
1556 Issue store.Issue
1557 BodyHTML template.HTML
1558 Comments []renderedComment
1559 CanEdit bool
1560 CanWrite bool
1561 Milestones []store.Milestone
1562 Notice string
1563 LabelColors map[string]template.CSS
1564 }{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md),
1565 s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
1566 milestones, r.URL.Query().Get("e"), s.labelColors(p.Repo.ID)})
1567}
1568
1569// canEditItem: the author or anyone with write access may edit.
1570// canWriteRepo reports whether the browser session may push to the repo,
1571// which is what gates the review and merge controls.
1572func (s *Server) canWriteRepo(r *http.Request, repo store.Repo) bool {
1573 if s.cfg.Web.Mode != "accounts" {
1574 return false
1575 }
1576 u := s.viewer(r)
1577 if u.ID == 0 {
1578 return false
1579 }
1580 grant, _ := s.st.AccessRole(repo.ID, u.ID)
1581 return policy.CanWrite(u, repo, grant)
1582}
1583
1584func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool {
1585 if s.cfg.Web.Mode != "accounts" {
1586 return false
1587 }
1588 u := s.viewer(r)
1589 if u.ID == 0 {
1590 return false
1591 }
1592 if u.Username == author {
1593 return true
1594 }
1595 grant, _ := s.st.AccessRole(repo.ID, u.ID)
1596 return policy.CanWrite(u, repo, grant)
1597}
1598
1599func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
1600 p, ok := s.repoFor(w, r, "")
1601 if !ok {
1602 return
1603 }
1604 p.Tab = "merge requests"
1605 state := r.URL.Query().Get("state")
1606 if state == "" {
1607 state = "open"
1608 }
1609 valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
1610 if !valid[state] {
1611 state = "open"
1612 }
1613 qv := r.URL.Query()
1614 mf := store.MRFilter{State: state, Author: qv.Get("author"), Milestone: qv.Get("milestone")}
1615 mrs, err := s.st.QueryMRs(p.Repo.ID, mf)
1616 if err != nil {
1617 http.Error(w, "internal error", http.StatusInternalServerError)
1618 return
1619 }
1620 s.render(w, "mrs.html", struct {
1621 repoPage
1622 State string
1623 Filters []listFilter
1624 MRs []store.MR
1625 }{p, state, activeFilters(state, [][2]string{{"author", mf.Author}, {"milestone", mf.Milestone}}), mrs})
1626}
1627
1628func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1629 p, ok := s.repoFor(w, r, "")
1630 if !ok {
1631 return
1632 }
1633 p.Tab = "merge requests"
1634 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1635 if err != nil {
1636 s.notFound(w, r)
1637 return
1638 }
1639 m, err := s.st.MRByNumber(p.Repo.ID, n)
1640 if err != nil {
1641 s.notFound(w, r)
1642 return
1643 }
1644 comments, _ := s.st.ListMRComments(m.ID)
1645 reviews, _ := s.st.ListMRReviews(m.ID)
1646 checks, combined, _ := s.st.ChecksForCommit(p.Repo.ID, m.HeadSHA)
1647 diffComments, _ := s.st.ListDiffComments(m.ID)
1648
1649 headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
1650 var files []diffFile
1651 base := m.MergedBase
1652 if base == "" {
1653 if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
1654 base = b
1655 }
1656 }
1657 if base != "" {
1658 if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
1659 files = parseDiff(patch)
1660 }
1661 }
1662 md := s.ugcFor(r, p.Repo)
1663 canWrite := s.canWriteRepo(r, p.Repo)
1664 var detachedThreads []diffThread
1665 files, detachedThreads = attachThreads(files, diffComments, m.HeadSHA, md,
1666 reviewRights{Viewer: p.Viewer, MRAuthor: m.Author, Write: canWrite})
1667 if p.Viewer != "" {
1668 markCompose(files, r.URL.Query())
1669 }
1670 stat := statOf(files)
1671 // The commits this MR carries: base..head, the same range as the diff.
1672 type commitRow struct {
1673 SHA, ShortSHA, Subject, AuthorName, AuthorUser, Date string
1674 Sig sigView
1675 }
1676 mrNames := s.authorNames()
1677 var commits []commitRow
1678 if base != "" {
1679 const maxMRCommits = 100
1680 shas, _ := gitutil.RevListRange(p.Dir, base, headRef)
1681 if len(shas) > maxMRCommits {
1682 shas = shas[:maxMRCommits]
1683 }
1684 for _, sha := range shas {
1685 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1686 cr := commitRow{SHA: sha, ShortSHA: sha[:10], Sig: v}
1687 if parsed != nil {
1688 cr.Subject = parsed.Subject
1689 cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName)
1690 cr.AuthorUser, _ = mrNames.account(parsed.AuthorEmail)
1691 cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1692 }
1693 commits = append(commits, cr)
1694 }
1695 }
1696 // The diff is the reason most people open a merge request, so it gets
1697 // its own view rather than a fold at the foot of the conversation.
1698 // A query parameter keeps this working without JavaScript.
1699 unresolved, _ := s.st.UnresolvedThreadCount(m.ID)
1700 branches, _ := gitutil.Refs(p.Dir, "heads")
1701 view := r.URL.Query().Get("view")
1702 if view != "commits" && view != "diff" {
1703 view = "conversation"
1704 }
1705 // The stack around an open merge request, for the header.
1706 var stackedOn *store.MR
1707 var stacked []store.MR
1708 if m.State == "open" {
1709 if parent, ok, err := s.st.OpenMRBySource(p.Repo.ID, m.TargetRef); err == nil && ok && parent.ID != m.ID {
1710 stackedOn = &parent
1711 }
1712 if m.SourceRepoID == p.Repo.ID {
1713 stacked, _ = s.st.OpenMRsByTarget(p.Repo.ID, m.SourceRef)
1714 }
1715 }
1716 s.render(w, "mr.html", struct {
1717 repoPage
1718 MR store.MR
1719 View string
1720 BodyHTML template.HTML
1721 Checks []store.Check
1722 Combined string
1723 Comments []renderedComment
1724 Reviews []store.MRReview
1725 DiffFiles []diffFile
1726 Stat diffStat
1727 Commits []commitRow
1728 Branches []gitutil.Ref
1729 CanEdit bool
1730 CanWrite bool
1731 Unresolved int
1732 Notice string
1733 DetachedThreads []diffThread
1734 StackedOn *store.MR
1735 Stacked []store.MR
1736 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
1737 reviews, files, stat, commits, branches, s.canEditItem(r, p.Repo, m.Author),
1738 canWrite, unresolved, r.URL.Query().Get("e"), detachedThreads, stackedOn, stacked})
1739}
1740
1741func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
1742 p, ok := s.repoFor(w, r, "")
1743 if !ok {
1744 return
1745 }
1746 p.Tab = "refs"
1747 branches, _ := gitutil.Refs(p.Dir, "heads")
1748 tags, _ := gitutil.Refs(p.Dir, "tags")
1749 s.render(w, "refs.html", struct {
1750 repoPage
1751 Branches, Tags []gitutil.Ref
1752 }{p, branches, tags})
1753}
1754
1755func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
1756 p, ok := s.repoFor(w, r, "")
1757 if !ok {
1758 return
1759 }
1760 file := r.PathValue("file")
1761 ref, ok := strings.CutSuffix(file, ".tar.gz")
1762 if !ok {
1763 s.notFound(w, r)
1764 return
1765 }
1766 if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
1767 s.notFound(w, r)
1768 return
1769 }
1770 prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
1771 w.Header().Set("Content-Type", "application/gzip")
1772 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
1773 gitutil.Archive(p.Dir, ref, prefix, w)
1774}
1775
1776func policyCanAdmin(u store.User, repo store.Repo, grant string) bool {
1777 return policy.CanAdmin(u, repo, grant)
1778}
1779
1780func policyCanRead(u store.User, repo store.Repo, grant string) bool {
1781 return policy.CanRead(u, repo, grant)
1782}