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.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 2h ago
4b8c9e1d
| 1 | package 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 | |
| 18 | import ( |
| 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. |
| 32 | const 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. |
| 36 | type 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. |
| 48 | type 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. |
| 59 | type peopleFilter struct { |
| 60 | Label string |
| 61 | Href string |
| 62 | Active bool |
| 63 | } |
| 64 | |
| 65 | type 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. |
| 84 | type peopleSort struct { |
| 85 | key string |
| 86 | desc bool |
| 87 | } |
| 88 | |
| 89 | // peopleSortable is every column the directory can be ordered by. |
| 90 | const ( |
| 91 | sortName = "name" |
| 92 | sortRepos = "repos" |
| 93 | sortIssues = "issues" |
| 94 | sortChanges = "changes" |
| 95 | sortReviews = "reviews" |
| 96 | sortJoined = "joined" |
| 97 | ) |
| 98 | |
| 99 | func 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. |
| 119 | func 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. |
| 126 | func 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. |
| 136 | func 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. |
| 163 | func 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. |
| 195 | func 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. |
| 213 | func (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. |
| 295 | func 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 | |
| 308 | func 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 |