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/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

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

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 1h ago 4b8c9e1d
history blame raw 11.6 KiB
1package web
2
3// The landing page and the instance-wide activity feed.
4//
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.
11
12import (
13 "html/template"
14 "net/http"
15 "net/url"
16 "sort"
17 "strconv"
18 "strings"
19 "time"
20
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"
27)
28
29const (
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
33 // instanceEventPage is what one page of /activity shows.
34 instanceEventPage = 50
35)
36
37// repoCard is one repository in the repository grid. It is a summary of the same
38// activity feed the rail uses, not a second source of truth.
39type 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
53type homeData struct {
54 Page
55 // Intro is the rendered, sanitized introduction. Empty means there is no
56 // introduction to show.
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
61 Cards []repoCard
62 Events []activityEvent
63 OpenRegistration bool
64}
65
66type instanceActivityData struct {
67 Page
68 Kind string
69 Events []activityEvent
70 Pager pager
71}
72
73// IndexPage serves GET /{$}: the landing page, or repository search results when
74// q is set. Search results reuse the repository cards so a match looks like a
75// repository rather than a table row.
76func (w *Web) IndexPage(rw http.ResponseWriter, r *http.Request) {
77 query := strings.TrimSpace(r.URL.Query().Get("q"))
78 data, err := w.homeView(r, query)
79 if err != nil {
80 w.serverError(rw, r, "index unavailable: "+err.Error())
81 return
82 }
83 w.render(rw, "index", data, 0)
84}
85
86// homeView builds the landing page: the introduction, the repository grid, and
87// the activity rail.
88func (w *Web) homeView(r *http.Request, query string) (homeData, error) {
89 entries, err := w.visibleRepos(r, query)
90 if err != nil {
91 return homeData{}, err
92 }
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 }
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.
106 shown := snap.Readme
107 if page.LoggedIn && len(snap.PrivateReadme) > 0 {
108 shown = snap.PrivateReadme
109 }
110 if text := strings.TrimSpace(string(shown)); text != "" {
111 data.Intro = w.renderInstanceMarkdown(text)
112 }
113 }
114
115 var events []activityEvent
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 }
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 }
134 return data, nil
135}
136
137// InstanceActivity serves GET /activity: the whole feed, with the type filter
138// that would make the landing page rail too heavy.
139func (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"))
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 }
172 events, pager, err := w.activityPage(repoIDs, names, kind, "", "", true,
173 number, instanceEventPage, instanceActivityURL(kind), "Activity pages")
174 if err != nil {
175 w.serverError(rw, r, "cannot read activity: "+err.Error())
176 return
177 }
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.
186func (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 {
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)
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
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.
217func (w *Web) repoCards(memo *requestMemo, entries []index.Entry) []repoCard {
218 type item struct {
219 entry index.Entry
220 issues, changes int
221 acl *access.ACL
222 }
223 items := make([]item, 0, len(entries))
224 ids := make([]string, 0, len(entries))
225 for _, e := range entries {
226 m, err := w.Repos.Meta(e.Name)
227 if err != nil {
228 continue
229 }
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)
237 ids = append(ids, e.ID)
238 }
239 updated, _ := w.Index.LastEventTimes(ids)
240 releases, _ := w.Index.LastReleases(ids)
241
242 cards := make([]repoCard, 0, len(items))
243 for _, it := range items {
244 e := it.entry
245 card := repoCard{
246 Name: e.Name, Description: e.Description, Branch: e.DefaultBranch,
247 Private: true, OpenIssues: it.issues, OpenChanges: it.changes,
248 }
249 if e.HeadSHA != "" {
250 card.Commit = &git.Commit{
251 SHA: e.HeadSHA, Subject: e.HeadSubject, Author: e.HeadAuthor, When: e.HeadWhen,
252 }
253 card.Language, card.LangClass = e.Language, e.LangClass
254 }
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}
260 }
261 if t, ok := updated[e.ID]; ok {
262 card.Updated = t
263 } else if e.HeadSHA != "" {
264 card.Updated = time.Unix(e.HeadWhen, 0).UTC()
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.
273func (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.
300func (w *Web) repoCardsFromGit(memo *requestMemo, entries []index.Entry) ([]repoCard, []activityEvent) {
301 cards := make([]repoCard, 0, len(entries))
302 var events []activityEvent
303 for _, e := range entries {
304 m, err := w.repo(memo, e.Name)
305 if err != nil {
306 continue
307 }
308 feed := w.repoActivity(memo, m, "", "", "", homeEventLimit)
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 {
316 card.Language, card.LangClass = lang.Top(tree)
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
337// instanceActivityURL builds a page link for /activity, keeping the kind filter
338// so paging never silently widens the feed.
339func instanceActivityURL(kind string) func(int) string {
340 return func(n int) string {
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 != "" {
349 return "/-/activity?" + q
350 }
351 return "/-/activity"
352 }
353}
354
355func 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
358 if len(events) == 0 {
359 return nil, p
360 }
361 from, to := p.rows()
362 return events[from:to], p
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.
368func 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
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.
381func (w *Web) instanceName() string {
382 if w.Cfg != nil {
383 if v := strings.TrimSpace(w.Cfg.InstanceName); v != "" {
384 return v
385 }
386 }
387 return "Vole"
388}
389