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