1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "fmt"
8 "html"
9 "html/template"
10 "net/http"
11 "strings"
12 "time"
13
14 "connectrpc.com/connect"
15 "github.com/a-h/templ"
16 "github.com/bnema/zerowrap"
17
18 pb "bnema.dev/ermite-api/gen/ermite"
19)
20
21// maxReadmeBytes is the largest README that is rendered in the page; a
22// larger one is replaced by a note.
23const maxReadmeBytes = 512 << 10
24
25// recentCommits is how many commits the rail of the summary lists.
26const recentCommits = 5
27
28// readmeNames are the README file names in order of preference (compared
29// lowercase): Markdown first.
30var readmeNames = []string{"readme.md", "readme.markdown", "readme", "readme.txt"}
31
32// summary is the page of a repository: description, README, and in the rail
33// the clone URL, the facts, the recent commits and the README outline.
34func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component, int, error) {
35 d, err := s.loadRepo(ctx, r.PathValue("repo"))
36 if err != nil {
37 return nil, 0, err
38 }
39 if d.head == nil {
40 l, st := s.emptyState(ctx, d)
41 return State(l, st), http.StatusOK, nil
42 }
43 ref := d.head.GetShortName()
44 now := s.cfg.Now()
45
46 p := SummaryPage{
47 Desc: d.repo.GetDescription(),
48 AllHref: repoHref(d.name(), "commits", ref),
49 Clone: Clone{ID: "clone-" + d.name(), Lines: []string{"git clone " + s.cloneURL(d.name())}},
50 }
51
52 commits, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{
53 Repo: d.name(), Revision: d.head.GetCommitId(), PageSize: recentCommits,
54 }))
55 if err != nil {
56 return nil, 0, err
57 }
58 branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags)
59 for i, c := range commits.Msg.GetCommits() {
60 row := commitRow(d.name(), c, now)
61 row.Branches, row.Tags, row.Cursor = branchesAt[c.GetId()], tagsAt[c.GetId()], i == 0
62 p.Recent = append(p.Recent, row)
63 }
64
65 p.Facts = s.facts(ctx, d, now)
66
67 if rd, err := s.readme(ctx, d, ref); err != nil {
68 return nil, 0, err
69 } else if rd != nil {
70 p.Readme, p.Outline = rd.readme, rd.outline
71 }
72
73 l := s.repoLayout(ctx, d, "summary")
74 l.Title = d.name() + " · " + s.instance
75 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=summary")
76 l.Status = Status{
77 Mode: "summary", Context: plural(len(commits.Msg.GetCommits()), "recent commit", "recent commits"),
78 Hints: "j/k move · enter open · b ref · y permalink · ? help", Cursor: true, Count: len(p.Recent),
79 }
80 l.HasRail, l.Read, l.RailSum = true, true, "clone · facts · recent commits · outline"
81 return Summary(l, p), http.StatusOK, nil
82}
83
84// maxTagDates is the most tags whose commit dates are read to name the
85// latest one; with more, the fact shows the count only.
86const maxTagDates = 20
87
88// facts are the key/value rows of the rail.
89func (s *Server) facts(ctx context.Context, d *repoData, now time.Time) []Fact {
90 fs := []Fact{
91 lastPushFact(d.repo, now),
92 {Key: "branch", Val: d.head.GetShortName() + " · " + plural(len(d.branches), "branch", "branches")},
93 }
94 if n := len(d.tags); n > 0 {
95 val := plural(n, "tag", "tags")
96 if n <= maxTagDates {
97 if name, c := latestTag(d.tags, s.commitsOf(ctx, d.name(), d.tags)); name != "" {
98 val = name + " · " + c.when + " · " + val
99 }
100 }
101 fs = append(fs, Fact{Key: "tag", Val: val})
102 }
103 if c := d.repo.GetCreatedAt(); c.IsValid() {
104 fs = append(fs, Fact{Key: "created", Val: absolute(c.AsTime()), Time: rfc3339(c.AsTime())})
105 }
106 return fs
107}
108
109// lastPushFact is the "last push" fact of a repository; the live updates
110// render it again.
111func lastPushFact(r *pb.Repo, now time.Time) Fact {
112 last := Fact{Key: "last push", Val: "never", ID: "last-push-" + r.GetName()}
113 if t := r.GetPushedAt(); t.IsValid() && t.GetSeconds() != 0 {
114 last.Val = ago(now, t.AsTime()) + " · " + absolute(t.AsTime())
115 last.Time = rfc3339(t.AsTime())
116 }
117 return last
118}
119
120// latestTag is the tag whose commit is the newest.
121func latestTag(tags []*pb.Ref, commits map[string]refCommit) (string, refCommit) {
122 var name string
123 var best refCommit
124 for _, t := range tags {
125 c, ok := commits[t.GetCommitId()]
126 if ok && c.short != "" && (name == "" || c.at > best.at) {
127 name, best = t.GetShortName(), c
128 }
129 }
130 return name, best
131}
132
133// renderedReadme is the README of a repository, ready for the page.
134type renderedReadme struct {
135 readme *Readme
136 outline []Heading
137}
138
139// readme finds the README at the root of the tree of the head commit, reads
140// it and renders it. It is nil when the repository has none. Rendering
141// problems degrade to plain text; only API failures are errors.
142func (s *Server) readme(ctx context.Context, d *repoData, ref string) (*renderedReadme, error) {
143 tree, err := s.cfg.Git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: d.name(), Revision: d.head.GetCommitId()}))
144 if err != nil {
145 return nil, err
146 }
147 return s.readmeIn(ctx, d.name(), d.head.GetCommitId(), ref, "", tree.Msg.GetEntries())
148}
149
150// readmeIn picks the README among the entries of the directory dir ("" for
151// the root) of the commit rev, reads it and renders it. Links in it are
152// relative to dir, on the ref ref. It is nil when the directory has none.
153func (s *Server) readmeIn(ctx context.Context, repo, rev, ref, dir string, entries []*pb.TreeEntry) (*renderedReadme, error) {
154 var best *pb.TreeEntry
155 rank := len(readmeNames)
156 for _, e := range entries {
157 if e.GetKind() != pb.EntryKind_ENTRY_KIND_FILE && e.GetKind() != pb.EntryKind_ENTRY_KIND_EXECUTABLE {
158 continue
159 }
160 for i, n := range readmeNames {
161 if i < rank && strings.EqualFold(e.GetName(), n) {
162 best, rank = e, i
163 }
164 }
165 }
166 if best == nil {
167 return nil, nil
168 }
169 rd := &Readme{Name: best.GetName()}
170 if best.GetSize() > maxReadmeBytes {
171 rd.Note = best.GetName() + " is too large to show here (" + humanSize(best.GetSize()) + ")."
172 return &renderedReadme{readme: rd}, nil
173 }
174 body, binary, err := s.readBlob(ctx, repo, rev, best.GetPath(), maxReadmeBytes)
175 if err != nil {
176 return nil, err
177 }
178 if binary {
179 rd.Note = best.GetName() + " is not text."
180 return &renderedReadme{readme: rd}, nil
181 }
182 name := strings.ToLower(best.GetName())
183 if strings.HasSuffix(name, ".md") || strings.HasSuffix(name, ".markdown") {
184 h, outline, err := s.md.render(body, repo, ref, dir)
185 if err == nil {
186 rd.HTML = h
187 return &renderedReadme{readme: rd, outline: outline}, nil
188 }
189 log := zerowrap.FromCtx(ctx)
190 log.Warn().Err(err).Str("repo", repo).Msg("render README, showing plain text")
191 }
192 rd.HTML = template.HTML("<pre>" + html.EscapeString(string(body)) + "</pre>") // #nosec G203 -- escaped
193 return &renderedReadme{readme: rd}, nil
194}
195
196// readBlob reads a file through the GetBlob stream, at most limit bytes. It
197// reports a binary file without its content.
198func (s *Server) readBlob(ctx context.Context, repo, rev, path string, limit int64) (body []byte, binary bool, err error) {
199 stream, err := s.cfg.Git.GetBlob(ctx, connect.NewRequest(&pb.GetBlobRequest{Repo: repo, Revision: rev, Path: path}))
200 if err != nil {
201 return nil, false, err
202 }
203 defer func() { _ = stream.Close() }()
204 for stream.Receive() {
205 m := stream.Msg()
206 if m.GetInfo().GetBinary() {
207 return nil, true, nil
208 }
209 body = append(body, m.GetChunk()...)
210 if int64(len(body)) > limit {
211 return body[:limit], false, nil
212 }
213 }
214 if err := stream.Err(); err != nil {
215 return nil, false, err
216 }
217 return body, false, nil
218}
219
220// humanSize formats a size in bytes.
221func humanSize(n int64) string {
222 switch {
223 case n < 1024:
224 return itoa(int(n)) + " B"
225 case n < 1<<20:
226 return fmt.Sprintf("%.1f KiB", float64(n)/1024)
227 }
228 return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20))
229}