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