1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "fmt"
8 "html"
9 "html/template"
10 "io"
11 "net/http"
12 "strings"
13 "time"
14
15 "github.com/a-h/templ"
16 "github.com/bnema/zerowrap"
17
18 "bnema.dev/ermite/internal/domain"
19 "bnema.dev/ermite/internal/ports"
20)
21
22// maxReadmeBytes is the largest README that is rendered in the page; a
23// larger one is replaced by a note.
24const maxReadmeBytes = 512 << 10
25
26// recentCommits is how many commits the rail of the summary lists.
27const recentCommits = 5
28
29// readmeNames are the README file names in order of preference (compared
30// lowercase): Markdown first.
31var readmeNames = []string{"readme.md", "readme.markdown", "readme", "readme.txt"}
32
33// summary is the page of a repository: description, README, and in the rail
34// the clone URL, the facts, the recent commits and the README outline.
35func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component, int, error) {
36 d, err := s.loadRepo(ctx, r.PathValue("repo"))
37 if err != nil {
38 return nil, 0, err
39 }
40 if d.head == nil {
41 l, st := s.emptyState(ctx, d)
42 return State(l, st), http.StatusOK, nil
43 }
44 ref := d.head.ShortName
45 now := s.cfg.Now()
46
47 p := SummaryPage{
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.commitList(ctx, d.repo.Name, d.head.CommitID.String(), "", recentCommits, "")
53 if err != nil {
54 return nil, 0, err
55 }
56 branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags)
57 for i, c := range commits.Commits {
58 id := c.ID.String()
59 row := commitRow(d.name(), c, now)
60 row.Branches, row.Tags, row.Cursor = branchesAt[id], tagsAt[id], 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.Description
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.repo.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.ShortName + " · " + plural(len(d.branches), "branch", "branches")},
106 }
107 if c := d.repo.CreatedAt; !c.IsZero() {
108 fs = append(fs, Fact{Key: "created", Val: absolute(c), Time: rfc3339(c)})
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 ports.RepoView, now time.Time) Fact {
116 last := Fact{Key: "last push", Val: "never", ID: "last-push-" + r.Name.String()}
117 if t := r.PushedAt; !t.IsZero() {
118 last.Val = ago(now, t) + " · " + absolute(t)
119 last.Time = rfc3339(t)
120 }
121 return last
122}
123
124// latestTag is the tag whose commit is the newest.
125func latestTag(tags []domain.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.CommitID.String()]
130 if ok && c.short != "" && (name == "" || c.at > best.at) {
131 name, best = t.ShortName, 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 use case failures are errors.
146func (s *Server) readme(ctx context.Context, d *repoData, ref string) (*renderedReadme, error) {
147 rev := d.head.CommitID.String()
148 tree, err := s.getTree(ctx, d.repo.Name, rev, "", false)
149 if err != nil {
150 return nil, err
151 }
152 return s.readmeIn(ctx, d.repo.Name, rev, ref, "", tree.Entries)
153}
154
155// readmeIn picks the README among the entries of the directory dir ("" for
156// the root) of the commit rev, reads it and renders it. Links in it are
157// relative to dir, on the ref ref. It is nil when the directory has none.
158//
159//hexcheck:ignore suspicious-business-logic-in-adapter picks the README among the entries of a directory to show; display only
160func (s *Server) readmeIn(ctx context.Context, repo domain.RepoName, rev, ref, dir string, entries []domain.TreeEntry) (*renderedReadme, error) {
161 var best *domain.TreeEntry
162 rank := len(readmeNames)
163 for i := range entries {
164 e := &entries[i]
165 if e.Kind != domain.EntryFile && e.Kind != domain.EntryExecutable {
166 continue
167 }
168 for i, n := range readmeNames {
169 if i < rank && strings.EqualFold(e.Name, n) {
170 best, rank = e, i
171 }
172 }
173 }
174 if best == nil {
175 return nil, nil
176 }
177 rd := &Readme{Name: best.Name}
178 if best.Size > maxReadmeBytes {
179 rd.Note = best.Name + " is too large to show here (" + humanSize(best.Size) + ")."
180 return &renderedReadme{readme: rd}, nil
181 }
182 body, binary, err := s.readBlob(ctx, repo, rev, best.Path, maxReadmeBytes)
183 if err != nil {
184 return nil, err
185 }
186 if binary {
187 rd.Note = best.Name + " is not text."
188 return &renderedReadme{readme: rd}, nil
189 }
190 name := strings.ToLower(best.Name)
191 if strings.HasSuffix(name, ".md") || strings.HasSuffix(name, ".markdown") {
192 h, outline, err := s.md.render(body, repo.String(), ref, dir)
193 if err == nil {
194 rd.HTML = h
195 return &renderedReadme{readme: rd, outline: outline}, nil
196 }
197 log := zerowrap.FromCtx(ctx)
198 log.Warn().Err(err).Str("repo", repo.String()).Msg("render README, showing plain text")
199 }
200 rd.HTML = template.HTML("<pre>" + html.EscapeString(string(body)) + "</pre>") // #nosec G203 -- escaped
201 return &renderedReadme{readme: rd}, nil
202}
203
204// readBlob reads a file, at most limit bytes. It reports a binary file
205// without its content.
206func (s *Server) readBlob(ctx context.Context, repo domain.RepoName, rev, path string, limit int64) (body []byte, binary bool, err error) {
207 info, rc, err := s.openBlobAt(ctx, repo, rev, path)
208 if err != nil {
209 return nil, false, err
210 }
211 defer func() { _ = rc.Close() }()
212 if info.Binary {
213 return nil, true, nil
214 }
215 body, err = io.ReadAll(io.LimitReader(rc, limit+1))
216 if err != nil {
217 return nil, false, err
218 }
219 if int64(len(body)) > limit {
220 return body[:limit], false, nil
221 }
222 return body, false, nil
223}
224
225// humanSize formats a size in bytes.
226func humanSize(n int64) string {
227 switch {
228 case n < 1024:
229 return itoa(int(n)) + " B"
230 case n < 1<<20:
231 return fmt.Sprintf("%.1f KiB", float64(n)/1024)
232 }
233 return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20))
234}