Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Blame · server/web/home.go

Line attribution on main · 388 line(s)

074ae160
Keenan Nemetz
14d ago
1 package web
2
3 // The landing page and the instance-wide activity feed.
4 //
ff9870f1
Keenan Nemetz
1d ago
5 // The landing page is the instance introduction, then the repository grid beside
6 // a rail of what is happening and what the code is written in. The introduction
7 // is read from the instance repository like every other customization, so there
8 // is no second copy of the instance name or an editor to keep in sync. The cards
9 // and the rail come from one read per repository, so listing a repository does
10 // not cost a second walk over its history.
074ae160
Keenan Nemetz
14d ago
11
12 import (
6ce635a8
Keenan Nemetz
1d ago
13 "html/template"
074ae160
Keenan Nemetz
14d ago
14 "net/http"
c717d4eb
Keenan Nemetz
14d ago
15 "net/url"
074ae160
Keenan Nemetz
14d ago
16 "sort"
17 "strconv"
18 "strings"
19 "time"
20
d37d301f
Keenan Nemetz
20h ago
21 "rodent.software/vole/core/git"
22 "rodent.software/vole/forge/access"
23 "rodent.software/vole/forge/index"
24 "rodent.software/vole/forge/instancerepo"
25 "rodent.software/vole/forge/repo"
26 "rodent.software/vole/internal/lang"
074ae160
Keenan Nemetz
14d ago
27 )
28
29 const (
30 // homeEventLimit is what fits in the rail without turning the landing page
31 // back into a feed. The full feed has its own page at /activity.
32 homeEventLimit = 10
c8cdb6ac
Keenan Nemetz
10d ago
33 // instanceEventPage is what one page of /activity shows.
34 instanceEventPage = 50
074ae160
Keenan Nemetz
14d ago
35 )
36
ab326d3a
Keenan Nemetz
14d ago
37 // repoCard is one repository in the repository grid. It is a summary of the same
074ae160
Keenan Nemetz
14d ago
38 // activity feed the rail uses, not a second source of truth.
39 type repoCard struct {
40 Name string
41 Description string
42 Branch string
43 Private bool
44 Commit *git.Commit
45 LastTag *git.Tag
46 OpenIssues int
47 OpenChanges int
48 Language string
49 LangClass string
50 Updated time.Time
51 }
52
53 type homeData struct {
54 Page
ff9870f1
Keenan Nemetz
1d ago
55 // Intro is the rendered, sanitized introduction. Empty means there is no
56 // introduction to show.
6ce635a8
Keenan Nemetz
1d ago
57 Intro template.HTML
58 // RepoReady reports that the instance repository has a usable snapshot, so
59 // the page can be customized at all.
60 RepoReady bool
074ae160
Keenan Nemetz
14d ago
61 Cards []repoCard
62 Events []activityEvent
63 OpenRegistration bool
64 }
65
66 type instanceActivityData struct {
67 Page
68 Kind string
69 Events []activityEvent
c717d4eb
Keenan Nemetz
14d ago
70 Pager pager
074ae160
Keenan Nemetz
14d ago
71 }
72
73 // IndexPage serves GET /{$}: the landing page, or repository search results when
ab326d3a
Keenan Nemetz
14d ago
74 // q is set. Search results reuse the repository cards so a match looks like a
75 // repository rather than a table row.
074ae160
Keenan Nemetz
14d ago
76 func (w *Web) IndexPage(rw http.ResponseWriter, r *http.Request) {
77 query := strings.TrimSpace(r.URL.Query().Get("q"))
ff9870f1
Keenan Nemetz
1d ago
78 data, err := w.homeView(r, query)
074ae160
Keenan Nemetz
14d ago
79 if err != nil {
80 w.serverError(rw, r, "index unavailable: "+err.Error())
81 return
82 }
6ce635a8
Keenan Nemetz
1d ago
83 w.render(rw, "index", data, 0)
84 }
074ae160
Keenan Nemetz
14d ago
85
ff9870f1
Keenan Nemetz
1d ago
86 // homeView builds the landing page: the introduction, the repository grid, and
87 // the activity rail.
88 func (w *Web) homeView(r *http.Request, query string) (homeData, error) {
6ce635a8
Keenan Nemetz
1d ago
89 entries, err := w.visibleRepos(r, query)
90 if err != nil {
91 return homeData{}, err
92 }
074ae160
Keenan Nemetz
14d ago
93 page := w.page(r, "Repositories")
94 page.Query = query
95 data := homeData{
96 Page: page,
97 OpenRegistration: w.Cfg != nil && w.Cfg.OpenRegistration,
98 }
6ce635a8
Keenan Nemetz
1d ago
99 var snap *instancerepo.Snapshot
100 if w.InstanceRepo != nil {
101 snap = w.InstanceRepo.Current()
102 }
103 data.RepoReady = snap != nil
104 if snap != nil {
105 // A signed-in viewer sees the member-only README when one exists.
ff9870f1
Keenan Nemetz
1d ago
106 shown := snap.Readme
6ce635a8
Keenan Nemetz
1d ago
107 if page.LoggedIn && len(snap.PrivateReadme) > 0 {
ff9870f1
Keenan Nemetz
1d ago
108 shown = snap.PrivateReadme
6ce635a8
Keenan Nemetz
1d ago
109 }
ff9870f1
Keenan Nemetz
1d ago
110 if text := strings.TrimSpace(string(shown)); text != "" {
6ce635a8
Keenan Nemetz
1d ago
111 data.Intro = w.renderInstanceMarkdown(text)
112 }
113 }
074ae160
Keenan Nemetz
14d ago
114
115 var events []activityEvent
20442b59
Keenan Nemetz
10d ago
116 memo := memoOf(r)
117 if w.Index != nil {
118 data.Cards = w.repoCards(memo, entries)
119 events = w.homeEvents(memo, entries)
120 } else {
121 // A Web without an index (a test, or a CLI-shaped Web) builds the cards
122 // and the rail from git, as every page used to.
123 data.Cards, events = w.repoCardsFromGit(memo, entries)
124 }
074ae160
Keenan Nemetz
14d ago
125 if query != "" {
126 sort.SliceStable(data.Cards, func(i, j int) bool { return data.Cards[i].Name < data.Cards[j].Name })
127 } else {
128 sortCards(data.Cards)
129 if len(events) > homeEventLimit {
130 events = events[:homeEventLimit]
131 }
132 data.Events = events
133 }
6ce635a8
Keenan Nemetz
1d ago
134 return data, nil
074ae160
Keenan Nemetz
14d ago
135 }
136
137 // InstanceActivity serves GET /activity: the whole feed, with the type filter
138 // that would make the landing page rail too heavy.
139 func (w *Web) InstanceActivity(rw http.ResponseWriter, r *http.Request) {
140 kind := normalizeActivityKind(r.URL.Query().Get("kind"))
141 entries, err := w.visibleRepos(r, "")
142 if err != nil {
143 w.serverError(rw, r, "index unavailable: "+err.Error())
144 return
145 }
146 number, _ := strconv.Atoi(r.URL.Query().Get("page"))
73a732b1
Keenan Nemetz
10d ago
147 if w.Index == nil {
148 w.serverError(rw, r, "the activity feed is unavailable right now")
149 return
150 }
151 repoIDs := make([]string, 0, len(entries))
152 names := make(map[string]string, len(entries))
153 metas := make([]*repo.Meta, 0, len(entries))
154 memo := memoOf(r)
155 for _, e := range entries {
156 m, err := w.Repos.Meta(e.Name)
157 if err != nil {
158 continue
159 }
160 // The feed reads the index directly, so each repository it can show has
161 // to be current: a browser write never passes through the push hook.
162 if err := w.ensureIndex(memo, m); err != nil {
163 if w.Logger != nil {
164 w.Logger("web: activity index for %s: %v", e.Name, err)
165 }
166 continue
167 }
168 repoIDs = append(repoIDs, e.ID)
169 names[e.ID] = e.Name
170 metas = append(metas, m)
171 }
bca9ec24
Keenan Nemetz
10d ago
172 events, pager, err := w.activityPage(repoIDs, names, kind, "", "", true,
73a732b1
Keenan Nemetz
10d ago
173 number, instanceEventPage, instanceActivityURL(kind), "Activity pages")
174 if err != nil {
175 w.serverError(rw, r, "cannot read activity: "+err.Error())
176 return
177 }
074ae160
Keenan Nemetz
14d ago
178 w.render(rw, "instance_activity", instanceActivityData{
179 Page: w.page(r, "Activity"), Kind: kind, Events: events, Pager: pager,
180 }, 0)
181 }
182
183 // visibleRepos returns the repositories the viewer may read, optionally narrowed
184 // by a name/description search. A repository the viewer cannot read is absent,
185 // the same answer as a repository that does not exist.
186 func (w *Web) visibleRepos(r *http.Request, query string) ([]index.Entry, error) {
187 entries, err := w.Index.List()
188 if err != nil {
189 return nil, err
190 }
191 needle := strings.ToLower(strings.TrimSpace(query))
192 var visible []index.Entry
193 for _, e := range entries {
20442b59
Keenan Nemetz
10d ago
194 // Meta rather than Open: visibility needs the id and the ACL, not the
195 // default branch, so do not spend a git call per repository on it.
196 m, err := w.Repos.Meta(e.Name)
074ae160
Keenan Nemetz
14d ago
197 if err != nil {
198 continue
199 }
200 if !w.canRead(r, m) {
201 continue
202 }
203 if needle != "" &&
204 !strings.Contains(strings.ToLower(e.Name), needle) &&
205 !strings.Contains(strings.ToLower(e.Description), needle) {
206 continue
207 }
208 visible = append(visible, e)
209 }
210 return visible, nil
211 }
212
8dc56e4e
Keenan Nemetz
10d ago
213 // repoCards builds one card per repository from the index: the head summary and
214 // language were recorded when the push landed, the counts and the newest event
215 // time are index reads, and the ACL is the instance's rules. Nothing here starts
216 // a git process.
20442b59
Keenan Nemetz
10d ago
217 func (w *Web) repoCards(memo *requestMemo, entries []index.Entry) []repoCard {
218 type item struct {
8dc56e4e
Keenan Nemetz
10d ago
219 entry index.Entry
220 issues, changes int
221 acl *access.ACL
20442b59
Keenan Nemetz
10d ago
222 }
223 items := make([]item, 0, len(entries))
224 ids := make([]string, 0, len(entries))
225 for _, e := range entries {
8dc56e4e
Keenan Nemetz
10d ago
226 m, err := w.Repos.Meta(e.Name)
20442b59
Keenan Nemetz
10d ago
227 if err != nil {
228 continue
229 }
8dc56e4e
Keenan Nemetz
10d ago
230 it := item{entry: e}
231 if err := w.ensureIndex(memo, m); err == nil {
232 it.issues, _ = w.Index.IssueCount(e.ID, "open")
233 it.changes, _ = w.Index.RequestCount(e.ID, "open")
234 }
235 it.acl, _ = m.ACL()
236 items = append(items, it)
20442b59
Keenan Nemetz
10d ago
237 ids = append(ids, e.ID)
238 }
239 updated, _ := w.Index.LastEventTimes(ids)
240 releases, _ := w.Index.LastReleases(ids)
8dc56e4e
Keenan Nemetz
10d ago
241
20442b59
Keenan Nemetz
10d ago
242 cards := make([]repoCard, 0, len(items))
243 for _, it := range items {
8dc56e4e
Keenan Nemetz
10d ago
244 e := it.entry
20442b59
Keenan Nemetz
10d ago
245 card := repoCard{
8dc56e4e
Keenan Nemetz
10d ago
246 Name: e.Name, Description: e.Description, Branch: e.DefaultBranch,
247 Private: true, OpenIssues: it.issues, OpenChanges: it.changes,
20442b59
Keenan Nemetz
10d ago
248 }
8dc56e4e
Keenan Nemetz
10d ago
249 if e.HeadSHA != "" {
250 card.Commit = &git.Commit{
251 SHA: e.HeadSHA, Subject: e.HeadSubject, Author: e.HeadAuthor, When: e.HeadWhen,
20442b59
Keenan Nemetz
10d ago
252 }
8dc56e4e
Keenan Nemetz
10d ago
253 card.Language, card.LangClass = e.Language, e.LangClass
20442b59
Keenan Nemetz
10d ago
254 }
8dc56e4e
Keenan Nemetz
10d ago
255 if it.acl != nil {
256 card.Private = !it.acl.Can("", access.Read)
257 }
258 if name, ok := releases[e.ID]; ok {
259 card.LastTag = &git.Tag{Name: name}
20442b59
Keenan Nemetz
10d ago
260 }
8dc56e4e
Keenan Nemetz
10d ago
261 if t, ok := updated[e.ID]; ok {
20442b59
Keenan Nemetz
10d ago
262 card.Updated = t
8dc56e4e
Keenan Nemetz
10d ago
263 } else if e.HeadSHA != "" {
264 card.Updated = time.Unix(e.HeadWhen, 0).UTC()
20442b59
Keenan Nemetz
10d ago
265 }
266 cards = append(cards, card)
267 }
268 return cards
269 }
270
271 // homeEvents is the landing page rail: the newest events across every visible
272 // repository, one events-table query, bounded to what fits in the rail.
273 func (w *Web) homeEvents(memo *requestMemo, entries []index.Entry) []activityEvent {
274 repoIDs := make([]string, 0, len(entries))
275 names := make(map[string]string, len(entries))
276 for _, e := range entries {
277 m, err := w.Repos.Meta(e.Name)
278 if err != nil {
279 continue
280 }
281 if err := w.ensureIndex(memo, m); err != nil {
282 continue
283 }
284 repoIDs = append(repoIDs, e.ID)
285 names[e.ID] = e.Name
286 }
287 rows, err := w.Index.Events(repoIDs, "", "", "", homeEventLimit, 0)
288 if err != nil {
289 return nil
290 }
291 out := make([]activityEvent, 0, len(rows))
292 for _, row := range rows {
293 out = append(out, w.activityEventFromRow(row, names[row.RepoID], true))
294 }
295 return out
296 }
297
298 // repoCardsFromGit is the fallback for a Web without an index: one pass per
299 // repository builds the card and its events from git.
300 func (w *Web) repoCardsFromGit(memo *requestMemo, entries []index.Entry) ([]repoCard, []activityEvent) {
074ae160
Keenan Nemetz
14d ago
301 cards := make([]repoCard, 0, len(entries))
302 var events []activityEvent
303 for _, e := range entries {
5d440e37
Keenan Nemetz
10d ago
304 m, err := w.repo(memo, e.Name)
074ae160
Keenan Nemetz
14d ago
305 if err != nil {
306 continue
307 }
c8cdb6ac
Keenan Nemetz
10d ago
308 feed := w.repoActivity(memo, m, "", "", "", homeEventLimit)
074ae160
Keenan Nemetz
14d ago
309 card := repoCard{
310 Name: e.Name, Description: e.Description, Branch: m.DefaultBranch,
311 Private: true, Commit: feed.LastCommit, LastTag: feed.LastTag,
312 OpenIssues: feed.OpenIssues, OpenChanges: feed.OpenChanges,
313 }
314 if feed.LastCommit != nil {
315 if tree, err := m.Git().LsTreeR(feed.LastCommit.SHA); err == nil {
8dc56e4e
Keenan Nemetz
10d ago
316 card.Language, card.LangClass = lang.Top(tree)
074ae160
Keenan Nemetz
14d ago
317 }
318 }
319 if acl, err := m.ACL(); err == nil {
320 card.Private = !acl.Can("", access.Read)
321 }
322 if len(feed.Events) > 0 {
323 card.Updated = feed.Events[0].Time
324 } else if feed.LastCommit != nil {
325 card.Updated = time.Unix(feed.LastCommit.When, 0).UTC()
326 }
327 cards = append(cards, card)
328 for _, ev := range feed.Events {
329 ev.Repo = m.Name
330 events = append(events, ev)
331 }
332 }
333 sort.SliceStable(events, func(i, j int) bool { return events[i].Time.After(events[j].Time) })
334 return cards, events
335 }
336
c8cdb6ac
Keenan Nemetz
10d ago
337 // instanceActivityURL builds a page link for /activity, keeping the kind filter
338 // so paging never silently widens the feed.
339 func instanceActivityURL(kind string) func(int) string {
340 return func(n int) string {
c717d4eb
Keenan Nemetz
14d ago
341 v := url.Values{}
342 if kind != "" {
343 v.Set("kind", kind)
344 }
345 if n > 1 {
346 v.Set("page", strconv.Itoa(n))
347 }
348 if q := v.Encode(); q != "" {
ed03b3d1
Keenan Nemetz
7h ago
349 return "/-/activity?" + q
c717d4eb
Keenan Nemetz
14d ago
350 }
ed03b3d1
Keenan Nemetz
7h ago
351 return "/-/activity"
c8cdb6ac
Keenan Nemetz
10d ago
352 }
353 }
354
355 func paginate(events []activityEvent, number, perPage int, urlFor func(int) string, label string) ([]activityEvent, pager) {
356 p := newPager(len(events), perPage, number, urlFor)
357 p.Label = label
c717d4eb
Keenan Nemetz
14d ago
358 if len(events) == 0 {
359 return nil, p
074ae160
Keenan Nemetz
14d ago
360 }
c717d4eb
Keenan Nemetz
14d ago
361 from, to := p.rows()
362 return events[from:to], p
074ae160
Keenan Nemetz
14d ago
363 }
364
365 // sortCards puts the repository that changed most recently first. A repository
366 // with no history sorts last, by name, so a fresh instance still has a stable
367 // order.
368 func sortCards(cards []repoCard) {
369 sort.SliceStable(cards, func(i, j int) bool {
370 a, b := cards[i].Updated, cards[j].Updated
371 if !a.Equal(b) {
372 return a.After(b)
373 }
374 return cards[i].Name < cards[j].Name
375 })
376 }
377
3d707ea7
Keenan Nemetz
23h ago
378 // instanceName is what the instance calls itself. The config file names the
379 // forge; an instance that never set it is "Vole". Every page's document title and
380 // brand link carry it, and the home page shows it as its heading.
6ce635a8
Keenan Nemetz
1d ago
381 func (w *Web) instanceName() string {
074ae160
Keenan Nemetz
14d ago
382 if w.Cfg != nil {
383 if v := strings.TrimSpace(w.Cfg.InstanceName); v != "" {
6ce635a8
Keenan Nemetz
1d ago
384 return v
074ae160
Keenan Nemetz
14d ago
385 }
386 }
6ce635a8
Keenan Nemetz
1d ago
387 return "Vole"
074ae160
Keenan Nemetz
14d ago
388 }