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 AllHref: repoHref(d.name(), "commits", ref),
48 Clone: Clone{ID: "clone-" + d.name(), Lines: []string{"git clone " + s.cloneURL(d.name())}},
49 }
50
51 commits, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{
52 Repo: d.name(), Revision: d.head.GetCommitId(), PageSize: recentCommits,
53 }))
54 if err != nil {
55 return nil, 0, err
56 }
57 branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags)
58 for i, c := range commits.Msg.GetCommits() {
59 row := commitRow(d.name(), c, now)
60 row.Branches, row.Tags, row.Cursor = branchesAt[c.GetId()], tagsAt[c.GetId()], i == 0
61 p.Recent = append(p.Recent, row)
62 }
63
64 p.Facts = s.facts(d, now)
65 p.Latest = s.latest(ctx, d)
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.HasRail = true
77 l.Lede = d.repo.GetDescription()
78 return Summary(l, p), http.StatusOK, nil
79}
80
81// maxTagDates is the most tags whose commit dates are read to name the
82// latest one; with more, the rail shows the count only.
83const maxTagDates = 20
84
85// latest is the newest tag of the repository for the rail, nil without tags.
86func (s *Server) latest(ctx context.Context, d *repoData) *LatestTag {
87 n := len(d.tags)
88 if n == 0 {
89 return nil
90 }
91 lt := &LatestTag{Count: n, RefsHref: repoHref(d.name(), "refs")}
92 if n <= maxTagDates {
93 if name, c := latestTag(d.tags, s.commitsOf(ctx, d.name(), d.tags)); name != "" {
94 lt.Name, lt.Href = name, repoHref(d.name(), "tree", name)
95 lt.Subject, lt.When, lt.WhenAt = c.subject, c.when, c.whenAt
96 }
97 }
98 return lt
99}
100
101// facts are the key/value rows of the rail.
102func (s *Server) facts(d *repoData, now time.Time) []Fact {
103 fs := []Fact{
104 lastPushFact(d.repo, now),
105 {Key: "branch", Val: d.head.GetShortName() + " · " + plural(len(d.branches), "branch", "branches")},
106 }
107 if c := d.repo.GetCreatedAt(); c.IsValid() {
108 fs = append(fs, Fact{Key: "created", Val: absolute(c.AsTime()), Time: rfc3339(c.AsTime())})
109 }
110 return fs
111}
112
113// lastPushFact is the "last push" fact of a repository; the live updates
114// render it again.
115func lastPushFact(r *pb.Repo, now time.Time) Fact {
116 last := Fact{Key: "last push", Val: "never", ID: "last-push-" + r.GetName()}
117 if t := r.GetPushedAt(); t.IsValid() && t.GetSeconds() != 0 {
118 last.Val = ago(now, t.AsTime()) + " · " + absolute(t.AsTime())
119 last.Time = rfc3339(t.AsTime())
120 }
121 return last
122}
123
124// latestTag is the tag whose commit is the newest.
125func latestTag(tags []*pb.Ref, commits map[string]refCommit) (string, refCommit) {
126 var name string
127 var best refCommit
128 for _, t := range tags {
129 c, ok := commits[t.GetCommitId()]
130 if ok && c.short != "" && (name == "" || c.at > best.at) {
131 name, best = t.GetShortName(), c
132 }
133 }
134 return name, best
135}
136
137// renderedReadme is the README of a repository, ready for the page.
138type renderedReadme struct {
139 readme *Readme
140 outline []Heading
141}
142
143// readme finds the README at the root of the tree of the head commit, reads
144// it and renders it. It is nil when the repository has none. Rendering
145// problems degrade to plain text; only API failures are errors.
146func (s *Server) readme(ctx context.Context, d *repoData, ref string) (*renderedReadme, error) {
147 tree, err := s.cfg.Git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: d.name(), Revision: d.head.GetCommitId()}))
148 if err != nil {
149 return nil, err
150 }
151 return s.readmeIn(ctx, d.name(), d.head.GetCommitId(), ref, "", tree.Msg.GetEntries())
152}
153
154// readmeIn picks the README among the entries of the directory dir ("" for
155// the root) of the commit rev, reads it and renders it. Links in it are
156// relative to dir, on the ref ref. It is nil when the directory has none.
157func (s *Server) readmeIn(ctx context.Context, repo, rev, ref, dir string, entries []*pb.TreeEntry) (*renderedReadme, error) {
158 var best *pb.TreeEntry
159 rank := len(readmeNames)
160 for _, e := range entries {
161 if e.GetKind() != pb.EntryKind_ENTRY_KIND_FILE && e.GetKind() != pb.EntryKind_ENTRY_KIND_EXECUTABLE {
162 continue
163 }
164 for i, n := range readmeNames {
165 if i < rank && strings.EqualFold(e.GetName(), n) {
166 best, rank = e, i
167 }
168 }
169 }
170 if best == nil {
171 return nil, nil
172 }
173 rd := &Readme{Name: best.GetName()}
174 if best.GetSize() > maxReadmeBytes {
175 rd.Note = best.GetName() + " is too large to show here (" + humanSize(best.GetSize()) + ")."
176 return &renderedReadme{readme: rd}, nil
177 }
178 body, binary, err := s.readBlob(ctx, repo, rev, best.GetPath(), maxReadmeBytes)
179 if err != nil {
180 return nil, err
181 }
182 if binary {
183 rd.Note = best.GetName() + " is not text."
184 return &renderedReadme{readme: rd}, nil
185 }
186 name := strings.ToLower(best.GetName())
187 if strings.HasSuffix(name, ".md") || strings.HasSuffix(name, ".markdown") {
188 h, outline, err := s.md.render(body, repo, ref, dir)
189 if err == nil {
190 rd.HTML = h
191 return &renderedReadme{readme: rd, outline: outline}, nil
192 }
193 log := zerowrap.FromCtx(ctx)
194 log.Warn().Err(err).Str("repo", repo).Msg("render README, showing plain text")
195 }
196 rd.HTML = template.HTML("<pre>" + html.EscapeString(string(body)) + "</pre>") // #nosec G203 -- escaped
197 return &renderedReadme{readme: rd}, nil
198}
199
200// readBlob reads a file through the GetBlob stream, at most limit bytes. It
201// reports a binary file without its content.
202func (s *Server) readBlob(ctx context.Context, repo, rev, path string, limit int64) (body []byte, binary bool, err error) {
203 stream, err := s.cfg.Git.GetBlob(ctx, connect.NewRequest(&pb.GetBlobRequest{Repo: repo, Revision: rev, Path: path}))
204 if err != nil {
205 return nil, false, err
206 }
207 defer func() { _ = stream.Close() }()
208 for stream.Receive() {
209 m := stream.Msg()
210 if m.GetInfo().GetBinary() {
211 return nil, true, nil
212 }
213 body = append(body, m.GetChunk()...)
214 if int64(len(body)) > limit {
215 return body[:limit], false, nil
216 }
217 }
218 if err := stream.Err(); err != nil {
219 return nil, false, err
220 }
221 return body, false, nil
222}
223
224// humanSize formats a size in bytes.
225func humanSize(n int64) string {
226 switch {
227 case n < 1024:
228 return itoa(int(n)) + " B"
229 case n < 1<<20:
230 return fmt.Sprintf("%.1f KiB", float64(n)/1024)
231 }
232 return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20))
233}