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