1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "html"
10 "html/template"
11 "io"
12 "net/http"
13 "strings"
14 "time"
15
16 "github.com/a-h/templ"
17 "github.com/bnema/zerowrap"
18
19 "bnema.dev/ermite/internal/domain"
20 "bnema.dev/ermite/internal/ports"
21)
22
23// maxReadmeBytes is the largest README that is rendered in the page; a
24// larger one is replaced by a note.
25const maxReadmeBytes = 512 << 10
26
27// recentCommits is how many commits the rail of the summary lists.
28const recentCommits = 5
29
30// recentRuns is how many runs the rail of the summary lists.
31const recentRuns = 5
32
33// readmeNames are the README file names in order of preference (compared
34// lowercase): Markdown first.
35var readmeNames = []string{"readme.md", "readme.markdown", "readme", "readme.txt"}
36
37// summary is the page of a repository: description, README, and in the rail
38// the clone URL, the facts, the recent commits and the README outline.
39func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component, int, error) {
40 d, err := s.loadRepo(ctx, r.PathValue("repo"))
41 if err != nil {
42 return nil, 0, err
43 }
44 if d.head == nil {
45 l, st := s.emptyState(ctx, d)
46 return State(l, st), http.StatusOK, nil
47 }
48 ref := d.head.ShortName
49 now := s.cfg.Now()
50
51 p := SummaryPage{
52 Mirror: s.mirrorBadge(ctx, d.repo.Name),
53 AllHref: repoHref(d.name(), "commits", ref),
54 Clone: Clone{ID: "clone-" + d.name(), Lines: []string{"git clone " + s.cloneURL(d.name())}},
55 }
56
57 commits, err := s.commitList(ctx, d.repo.Name, d.head.CommitID.String(), "", recentCommits, "")
58 if err != nil {
59 return nil, 0, err
60 }
61 branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags)
62 for i, c := range commits.Commits {
63 id := c.ID.String()
64 row := commitRow(d.name(), c, now)
65 row.Branches, row.Tags, row.Cursor = branchesAt[id], tagsAt[id], i == 0
66 p.Recent = append(p.Recent, row)
67 }
68
69 p.Facts = s.facts(d, now)
70 p.Latest = s.latest(ctx, d)
71 p.Release = s.summaryRelease(ctx, d.repo.Name, now)
72 p.Actions = s.recentActions(ctx, d.repo.Name, now)
73
74 if rd, err := s.readme(ctx, d, ref); err != nil {
75 return nil, 0, err
76 } else if rd != nil {
77 p.Readme, p.Outline = rd.readme, rd.outline
78 }
79
80 l := s.repoLayout(ctx, d, "summary")
81 l.Title = d.name() + " · " + s.instance
82 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=summary")
83 l.HasRail = true
84 l.Lede = d.repo.Description
85 return Summary(l, p), http.StatusOK, nil
86}
87
88// maxTagDates is the most tags whose commit dates are read to name the
89// latest one; with more, the rail shows the count only.
90const maxTagDates = 20
91
92// latest is the newest tag of the repository for the rail, nil without tags.
93func (s *Server) latest(ctx context.Context, d *repoData) *LatestTag {
94 n := len(d.tags)
95 if n == 0 {
96 return nil
97 }
98 lt := &LatestTag{Count: n, RefsHref: repoHref(d.name(), "refs")}
99 if n <= maxTagDates {
100 if name, c := latestTag(d.tags, s.commitsOf(ctx, d.repo.Name, d.tags)); name != "" {
101 lt.Name, lt.Href = name, repoHref(d.name(), "tree", name)
102 lt.Subject, lt.When, lt.WhenAt = c.subject, c.when, c.whenAt
103 }
104 }
105 return lt
106}
107
108// facts are the key/value rows of the rail.
109func (s *Server) facts(d *repoData, now time.Time) []Fact {
110 fs := []Fact{
111 lastPushFact(d.repo, now),
112 {Key: "branch", Val: d.head.ShortName + " · " + plural(len(d.branches), "branch", "branches")},
113 }
114 if c := d.repo.CreatedAt; !c.IsZero() {
115 fs = append(fs, Fact{Key: "created", Val: absolute(c), Time: rfc3339(c)})
116 }
117 return fs
118}
119
120// lastPushFact is the "last push" fact of a repository; the live updates
121// render it again.
122func lastPushFact(r ports.RepoView, now time.Time) Fact {
123 last := Fact{Key: "last push", Val: "never", ID: "last-push-" + r.Name.String()}
124 if t := r.PushedAt; !t.IsZero() {
125 last.Val = ago(now, t) + " · " + absolute(t)
126 last.Time = rfc3339(t)
127 }
128 return last
129}
130
131// latestTag is the tag whose commit is the newest.
132func latestTag(tags []domain.Ref, commits map[string]refCommit) (string, refCommit) {
133 var name string
134 var best refCommit
135 for _, t := range tags {
136 c, ok := commits[t.CommitID.String()]
137 if ok && c.short != "" && (name == "" || c.at > best.at) {
138 name, best = t.ShortName, c
139 }
140 }
141 return name, best
142}
143
144// renderedReadme is the README of a repository, ready for the page.
145type renderedReadme struct {
146 readme *Readme
147 outline []Heading
148}
149
150// readme finds the README at the root of the tree of the head commit, reads
151// it and renders it. It is nil when the repository has none. Rendering
152// problems degrade to plain text; only use case failures are errors.
153func (s *Server) readme(ctx context.Context, d *repoData, ref string) (*renderedReadme, error) {
154 rev := d.head.CommitID.String()
155 tree, err := s.getTree(ctx, d.repo.Name, rev, "", false)
156 if err != nil {
157 return nil, err
158 }
159 return s.readmeIn(ctx, d.repo.Name, rev, ref, "", tree.Entries)
160}
161
162// readmeIn picks the README among the entries of the directory dir ("" for
163// the root) of the commit rev, reads it and renders it. Links in it are
164// relative to dir, on the ref ref. It is nil when the directory has none.
165//
166//hexcheck:ignore suspicious-business-logic-in-adapter picks the README among the entries of a directory to show; display only
167func (s *Server) readmeIn(ctx context.Context, repo domain.RepoName, rev, ref, dir string, entries []domain.TreeEntry) (*renderedReadme, error) {
168 var best *domain.TreeEntry
169 rank := len(readmeNames)
170 for i := range entries {
171 e := &entries[i]
172 if e.Kind != domain.EntryFile && e.Kind != domain.EntryExecutable {
173 continue
174 }
175 for i, n := range readmeNames {
176 if i < rank && strings.EqualFold(e.Name, n) {
177 best, rank = e, i
178 }
179 }
180 }
181 if best == nil {
182 return nil, nil
183 }
184 rd := &Readme{Name: best.Name}
185 if best.Size > maxReadmeBytes {
186 rd.Note = best.Name + " is too large to show here (" + humanSize(best.Size) + ")."
187 return &renderedReadme{readme: rd}, nil
188 }
189 body, binary, err := s.readBlob(ctx, repo, rev, best.Path, maxReadmeBytes)
190 if err != nil {
191 return nil, err
192 }
193 if binary {
194 rd.Note = best.Name + " is not text."
195 return &renderedReadme{readme: rd}, nil
196 }
197 name := strings.ToLower(best.Name)
198 if strings.HasSuffix(name, ".md") || strings.HasSuffix(name, ".markdown") {
199 h, outline, err := s.md.render(body, repo.String(), ref, dir)
200 if err == nil {
201 rd.HTML = h
202 return &renderedReadme{readme: rd, outline: outline}, nil
203 }
204 log := zerowrap.FromCtx(ctx)
205 log.Warn().Err(err).Str("repo", repo.String()).Msg("render README, showing plain text")
206 }
207 rd.HTML = template.HTML("<pre>" + html.EscapeString(string(body)) + "</pre>") // #nosec G203 -- escaped
208 return &renderedReadme{readme: rd}, nil
209}
210
211// readBlob reads a file, at most limit bytes. It reports a binary file
212// without its content.
213func (s *Server) readBlob(ctx context.Context, repo domain.RepoName, rev, path string, limit int64) (body []byte, binary bool, err error) {
214 info, rc, err := s.openBlobAt(ctx, repo, rev, path)
215 if err != nil {
216 return nil, false, err
217 }
218 defer func() { _ = rc.Close() }()
219 if info.Binary {
220 return nil, true, nil
221 }
222 body, err = io.ReadAll(io.LimitReader(rc, limit+1))
223 if err != nil {
224 return nil, false, err
225 }
226 if int64(len(body)) > limit {
227 return body[:limit], false, nil
228 }
229 return body, false, nil
230}
231
232// humanSize formats a size in bytes.
233func humanSize(n int64) string {
234 switch {
235 case n < 1024:
236 return itoa(int(n)) + " B"
237 case n < 1<<20:
238 return fmt.Sprintf("%.1f KiB", float64(n)/1024)
239 case n < 1<<30:
240 return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20))
241 }
242 return fmt.Sprintf("%.1f GiB", float64(n)/(1<<30))
243}
244
245// recentActions is the newest runs for the rail, as the caller of the request
246// sees them (the permission of the actions page). It is nil when the server
247// runs no CI or the caller cannot read it; a failure of the read is logged and
248// the page goes on without, since the code does not depend on CI.
249func (s *Server) recentActions(ctx context.Context, repo domain.RepoName, now time.Time) *RecentActions {
250 if s.cfg.Runs == nil {
251 return nil
252 }
253 a, err := s.listRecentActions(ctx, repo, now)
254 if err != nil {
255 if !errors.Is(err, domain.ErrNotFound) {
256 log := zerowrap.FromCtx(ctx)
257 log.Warn().Err(err).Str("repo", repo.String()).Msg("read the recent runs")
258 }
259 return nil
260 }
261 return &a
262}
263
264// listRecentActions reads the newest runs of a repository.
265func (s *Server) listRecentActions(ctx context.Context, repo domain.RepoName, now time.Time) (RecentActions, error) {
266 page, err := s.cfg.Runs.ListRuns(ctx, principal(ctx), repo, ports.ListRunsParams{PageSize: recentRuns})
267 if err != nil {
268 return RecentActions{}, err
269 }
270 a := RecentActions{AllHref: actionsBase(repo.String())}
271 for _, run := range page.Runs {
272 a.Runs = append(a.Runs, s.runRow(repo.String(), run, now))
273 }
274 return a, nil
275}
276
277// summaryRelease uses the same Latest selection as the releases page. A
278// missing service or an unavailable release must not prevent repository browsing.
279func (s *Server) summaryRelease(ctx context.Context, repo domain.RepoName, now time.Time) *LatestRelease {
280 if s.cfg.Releases == nil {
281 return nil
282 }
283 rel, err := s.cfg.Releases.GetRelease(ctx, principal(ctx), repo, ports.ReleaseRef{Latest: true})
284 if err != nil {
285 if !errors.Is(err, domain.ErrNotFound) {
286 log := zerowrap.FromCtx(ctx)
287 log.Warn().Err(err).Str("repo", repo.String()).Msg("read the latest release")
288 }
289 return nil
290 }
291 if rel.Draft || rel.PublishedAt.IsZero() {
292 return nil
293 }
294 return &LatestRelease{Tag: rel.Tag, Href: releaseHref(repo.String(), rel.Tag), When: ago(now, rel.PublishedAt), WhenAt: rfc3339(rel.PublishedAt), AllHref: releasesBase(repo.String())}
295}