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 · 2h ago 4b8c9e1d
history blame raw 8.8 KiB
1package web
2
3// The user directory: everyone who has an account here, and what they have been
4// working on.
5//
6// Account names on this instance are public, which is why this page lists them
7// and why /u/{name} answers for anybody in the roster. Activity is not public:
8// every count beside a name is limited to the repositories the reader may open,
9// so a private repository's work contributes nothing to a stranger's view. The
10// page says so, once, rather than repeating a caveat on every row.
11//
12// The table is read server-side. The search, the status filter, the order and the
13// page all live in the URL, so a view can be linked and paging cannot quietly
14// change the question. Sorting by a count needs every matched account's summary
15// before the page is cut, which is why the index answers for the whole directory
16// in one grouped query rather than one lookup per row.
17
18import (
19 "cmp"
20 "net/http"
21 "net/url"
22 "sort"
23 "strconv"
24 "strings"
25 "time"
26
27 "rodent.software/vole/forge/identity"
28 "rodent.software/vole/forge/index"
29)
30
31// peoplePage is how many accounts one page of the directory shows.
32const peoplePage = 50
33
34// peopleRow is one account in the table. The counts are the same summary a
35// profile reads, so a row and the page it opens tell the same story.
36type peopleRow struct {
37 Name string
38 Bot bool
39 Active bool
40 Joined time.Time
41 Role string // rendered only for an instance admin
42 Summary index.ActorSummary
43}
44
45// peopleColumn is one sortable header. Href orders by this column: it flips the
46// direction when the column is already the order, and takes the column's natural
47// direction otherwise.
48type peopleColumn struct {
49 Key string
50 Label string
51 Href string
52 Active bool
53 Desc bool
54 Numeric bool
55}
56
57// peopleFilter is one chip above the table: a status the directory can be
58// limited to, with the search and the order kept.
59type peopleFilter struct {
60 Label string
61 Href string
62 Active bool
63}
64
65type peopleView struct {
66 Page
67 Query string
68 Status string
69 Columns []peopleColumn
70 Filters []peopleFilter
71 Rows []peopleRow
72 Pager pager
73 Total int
74 Filtered bool
75 // Sort and Dir are what the search form has to carry so a search does not
76 // reset the order. They are empty for the default order, which keeps the
77 // common URL short.
78 Sort string
79 Dir string
80 ShowRole bool
81}
82
83// peopleSort is the order the table is read in.
84type peopleSort struct {
85 key string
86 desc bool
87}
88
89// peopleSortable is every column the directory can be ordered by.
90const (
91 sortName = "name"
92 sortRepos = "repos"
93 sortIssues = "issues"
94 sortChanges = "changes"
95 sortReviews = "reviews"
96 sortJoined = "joined"
97)
98
99func parsePeopleSort(key, dir string) peopleSort {
100 o := peopleSort{key: sortName}
101 switch key {
102 case sortName, sortRepos, sortIssues, sortChanges, sortReviews, sortJoined:
103 o.key = key
104 }
105 switch dir {
106 case "asc":
107 o.desc = false
108 case "desc":
109 o.desc = true
110 default:
111 o.desc = peopleSortDescFirst(o.key)
112 }
113 return o
114}
115
116// peopleSortDescFirst is the direction a column takes the first time it is
117// chosen: a count is most useful with the largest first, a joining date with the
118// newest first, and a name from A to Z.
119func peopleSortDescFirst(key string) bool {
120 return key != sortName
121}
122
123// peopleStatus narrows the table to active or retired accounts. An unknown value
124// is the whole directory rather than an error, so a stale link still shows
125// something.
126func parsePeopleStatus(value string) string {
127 switch value {
128 case "active", "retired":
129 return value
130 }
131 return ""
132}
133
134// peopleURL builds a directory link. The first page and the default order are
135// left out, so the URL a reader copies is the short one.
136func peopleURL(query, status string, order peopleSort, page int) string {
137 v := url.Values{}
138 if query != "" {
139 v.Set("q", query)
140 }
141 if status != "" {
142 v.Set("status", status)
143 }
144 if order.key != sortName || order.desc {
145 v.Set("sort", order.key)
146 if order.desc {
147 v.Set("dir", "desc")
148 } else {
149 v.Set("dir", "asc")
150 }
151 }
152 if page > 1 {
153 v.Set("page", strconv.Itoa(page))
154 }
155 if q := v.Encode(); q != "" {
156 return "/-/u?" + q
157 }
158 return "/-/u"
159}
160
161// peopleColumns builds the header row: one column per sortable field, each with
162// the link that orders by it.
163func peopleColumns(query, status string, order peopleSort) []peopleColumn {
164 defs := []struct {
165 key string
166 label string
167 numeric bool
168 }{
169 {sortName, "User", false},
170 {sortRepos, "Repositories", true},
171 {sortIssues, "Issues", true},
172 {sortChanges, "Changes", true},
173 {sortReviews, "Reviews", true},
174 {sortJoined, "Joined", false},
175 }
176 cols := make([]peopleColumn, 0, len(defs))
177 for _, d := range defs {
178 next := order
179 if order.key == d.key {
180 next.desc = !order.desc
181 } else {
182 next.key = d.key
183 next.desc = peopleSortDescFirst(d.key)
184 }
185 cols = append(cols, peopleColumn{
186 Key: d.key, Label: d.label, Numeric: d.numeric,
187 Href: peopleURL(query, status, next, 1),
188 Active: order.key == d.key, Desc: order.desc,
189 })
190 }
191 return cols
192}
193
194// peopleFilters builds the status chips, each keeping the search and the order.
195func peopleFilters(query string, order peopleSort, status string) []peopleFilter {
196 defs := []struct{ value, label string }{
197 {"", "Everyone"},
198 {"active", "Active"},
199 {"retired", "No longer active"},
200 }
201 out := make([]peopleFilter, 0, len(defs))
202 for _, d := range defs {
203 out = append(out, peopleFilter{
204 Label: d.label,
205 Href: peopleURL(query, d.value, order, 1),
206 Active: d.value == status,
207 })
208 }
209 return out
210}
211
212// PeoplePage serves GET /u, the directory of accounts.
213func (w *Web) PeoplePage(rw http.ResponseWriter, r *http.Request) {
214 if w.Ident == nil {
215 w.notFound(rw, r)
216 return
217 }
218 query := strings.TrimSpace(r.URL.Query().Get("q"))
219 status := parsePeopleStatus(r.URL.Query().Get("status"))
220 order := parsePeopleSort(r.URL.Query().Get("sort"), r.URL.Query().Get("dir"))
221 number, _ := strconv.Atoi(r.URL.Query().Get("page"))
222
223 // All reads the roster once. The filter and the order are applied here rather
224 // than in a store because the roster is already in memory as a snapshot.
225 accounts := w.Ident.All()
226 needle := strings.ToLower(query)
227 matched := make([]*identity.Account, 0, len(accounts))
228 for _, acct := range accounts {
229 if status != "" && acct.Status != status {
230 continue
231 }
232 if needle != "" && !strings.Contains(strings.ToLower(acct.Name), needle) {
233 continue
234 }
235 matched = append(matched, acct)
236 }
237
238 // The counts are read for every matched account, not only the visible page:
239 // the order can be by a count, and a page cannot be sorted by numbers the row
240 // does not have.
241 summaries := map[string]index.ActorSummary{}
242 if w.Index != nil && len(matched) > 0 {
243 entries, err := w.visibleRepos(r, "")
244 if err != nil {
245 w.serverError(rw, r, "index unavailable: "+err.Error())
246 return
247 }
248 repoIDs, _ := w.visibleIndexedRepos(memoOf(r), entries)
249 if summaries, err = w.Index.ActorSummaries(repoIDs); err != nil {
250 w.serverError(rw, r, "cannot read the directory: "+err.Error())
251 return
252 }
253 }
254
255 page := w.page(r, "Users")
256 page.NoIndex = true
257 rows := make([]peopleRow, 0, len(matched))
258 for _, acct := range matched {
259 row := peopleRow{
260 Name: acct.Name, Bot: acct.Bot(), Active: acct.Active(), Joined: acct.Created,
261 Summary: summaries[acct.Name],
262 }
263 if page.InstanceAdmin {
264 row.Role = acct.Role.String()
265 }
266 rows = append(rows, row)
267 }
268 sortPeople(rows, order)
269
270 p := newPager(len(rows), peoplePage, number, func(n int) string {
271 return peopleURL(query, status, order, n)
272 })
273 p.Label = "User pages"
274 from, to := p.rows()
275
276 view := peopleView{
277 Page: page, Query: query, Status: status, Pager: p,
278 Total: len(rows), Filtered: query != "" || status != "",
279 Columns: peopleColumns(query, status, order),
280 Filters: peopleFilters(query, order, status),
281 Rows: rows[from:to], ShowRole: page.InstanceAdmin,
282 }
283 if order.key != sortName || order.desc {
284 view.Sort = order.key
285 view.Dir = "asc"
286 if order.desc {
287 view.Dir = "desc"
288 }
289 }
290 w.render(rw, "people", view, 0)
291}
292
293// sortPeople orders the rows. Equal values fall back to the name, so the table
294// does not shuffle between requests that ask the same question.
295func sortPeople(rows []peopleRow, order peopleSort) {
296 sort.SliceStable(rows, func(i, j int) bool {
297 c := comparePeople(order.key, rows[i], rows[j])
298 if c == 0 {
299 return rows[i].Name < rows[j].Name
300 }
301 if order.desc {
302 return c > 0
303 }
304 return c < 0
305 })
306}
307
308func comparePeople(key string, a, b peopleRow) int {
309 switch key {
310 case sortRepos:
311 return cmp.Compare(a.Summary.Repos, b.Summary.Repos)
312 case sortIssues:
313 return cmp.Compare(a.Summary.Issues, b.Summary.Issues)
314 case sortChanges:
315 return cmp.Compare(a.Summary.Changes, b.Summary.Changes)
316 case sortReviews:
317 return cmp.Compare(a.Summary.Reviews, b.Summary.Reviews)
318 case sortJoined:
319 return a.Joined.Compare(b.Joined)
320 }
321 return strings.Compare(a.Name, b.Name)
322}
323