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 · 51m ago
4b8c9e1d
| 1 | package web |
| 2 | |
| 3 | // The data every page and every fragment starts from: the document title, who is |
| 4 | // looking, the CSRF token, and which navigation item is current. A handler builds |
| 5 | // it with w.page, never by hand, so the chrome cannot disagree with the ACL that |
| 6 | // admitted the request. |
| 7 | |
| 8 | import ( |
| 9 | "net/http" |
| 10 | "net/url" |
| 11 | "strings" |
| 12 | |
| 13 | "rodent.software/vole/forge/access" |
| 14 | "rodent.software/vole/forge/repo" |
| 15 | ) |
| 16 | |
| 17 | // Page is embedded by every view. Exported so html/template can promote its |
| 18 | // fields; an unexported embedded type is not reachable from a template. |
| 19 | type Page struct { |
| 20 | Title string |
| 21 | Error string |
| 22 | // RepoName is the repository the path names, empty on instance-wide pages. |
| 23 | // The header reads it instead of parsing the URL again. |
| 24 | RepoName string |
| 25 | LoggedIn bool |
| 26 | User string |
| 27 | AccountName string |
| 28 | // CSRF is derived from the session token and must be on every state-changing |
| 29 | // form or fetch. |
| 30 | CSRF string |
| 31 | // DevLogin names the account a loopback browser is signed in as. Empty on a |
| 32 | // normal instance; the header uses it to make the development state visible. |
| 33 | DevLogin string |
| 34 | // CanAdmin reports whether the viewer may administer the repository named in |
| 35 | // the request path. The repository header uses it to show Settings. |
| 36 | CanAdmin bool |
| 37 | // CanMaintain reports whether the viewer holds the maintainer capability. The |
| 38 | // header shows Settings for a maintainer because that page holds the |
| 39 | // repository's secrets, and a maintainer is who owns them. |
| 40 | CanMaintain bool |
| 41 | // CloneURL and SSHURL are the two transports the repository header offers to |
| 42 | // copy. They are empty when the path does not name a repository. |
| 43 | CloneURL string |
| 44 | SSHURL string |
| 45 | // Site is the repository's deployed site URL. It is empty unless sites are |
| 46 | // enabled, the repository is public, and its pages branch exists. |
| 47 | Site string |
| 48 | // InstanceAdmin reports whether the viewer may use the instance-wide admin |
| 49 | // surface. The global bar uses it to show Admin. |
| 50 | InstanceAdmin bool |
| 51 | // NoIndex asks a crawler to leave the page out of an index. The account |
| 52 | // directory and a profile are public on purpose, but a scraped roster is not |
| 53 | // what "public" has to mean. |
| 54 | NoIndex bool |
| 55 | // Query is the repository search on the index page. Other pages leave it |
| 56 | // empty. |
| 57 | Query string |
| 58 | // Section is the global destination the current page belongs to, used by the |
| 59 | // rail to mark the current item. It is presentation only. |
| 60 | Section string |
| 61 | // InstanceName is what the instance calls itself: the configured |
| 62 | // instance_name, or "Vole" when the file does not name one. The document |
| 63 | // title and the brand link read it, so a renamed instance is renamed in the |
| 64 | // tab as well as in the heading. It is never empty. |
| 65 | InstanceName string |
| 66 | // InstanceCSS and InstanceFavicon are the instance repository's overrides, |
| 67 | // empty when the repository has no such file. The chrome links them after the |
| 68 | // built-in assets so a theme wins without removing the defaults. |
| 69 | InstanceCSS string |
| 70 | InstanceFavicon string |
| 71 | // InstanceThemes are the selectable theme stylesheets from the instance |
| 72 | // repository. The chrome links all of them; each is scoped to its own |
| 73 | // data-theme value, so only the selected one applies. |
| 74 | InstanceThemes []instanceThemeView |
| 75 | // memo is what this request has already read. It is unexported because no |
| 76 | // template may depend on it: the header only needs it to stop repeating work |
| 77 | // the handler did. |
| 78 | memo *requestMemo |
| 79 | } |
| 80 | |
| 81 | // instanceThemeView is one theme in the Appearance picker and its stylesheet |
| 82 | // URL. The URL carries the commit so a push cannot leave a browser on a stale |
| 83 | // theme file. |
| 84 | type instanceThemeView struct { |
| 85 | ID string |
| 86 | Name string |
| 87 | URL string |
| 88 | } |
| 89 | |
| 90 | // pageSection maps a request path to the global destination it belongs to. The |
| 91 | // landing page is Home; a repository page belongs to Repositories, which is not |
| 92 | // the same place, so the bar does not mark Home current there. |
| 93 | func pageSection(path string) string { |
| 94 | if path == "/" { |
| 95 | return "home" |
| 96 | } |
| 97 | // Everything the forge itself serves lives under /-/, and everything else is |
| 98 | // a repository page. A repository named "admin" is a repository. |
| 99 | if !strings.HasPrefix(path, "/-/") { |
| 100 | return "repos" |
| 101 | } |
| 102 | switch { |
| 103 | case path == "/-/repos/new": |
| 104 | return "repos" |
| 105 | case strings.HasPrefix(path, "/-/activity"): |
| 106 | return "activity" |
| 107 | case path == "/-/admin" || path == "/-/status" || path == "/-/users" || strings.HasPrefix(path, "/-/users/"): |
| 108 | return "admin" |
| 109 | case path == "/-/u" || strings.HasPrefix(path, "/-/u/"): |
| 110 | return "people" |
| 111 | case strings.HasPrefix(path, "/-/account"): |
| 112 | return "account" |
| 113 | } |
| 114 | return "" |
| 115 | } |
| 116 | |
| 117 | // who renders an account name for a page. It is a presentation helper only: |
| 118 | // nothing is authorized by what this returns. |
| 119 | func (w *Web) who(name string) string { |
| 120 | if w.Ident == nil || name == "" { |
| 121 | return name |
| 122 | } |
| 123 | a, err := w.Ident.Find(name) |
| 124 | if err != nil { |
| 125 | return name |
| 126 | } |
| 127 | return a.Name |
| 128 | } |
| 129 | |
| 130 | // userRef is an account name as a template should render it. Known is false for |
| 131 | // a name that does not belong to an account, and the template then prints the |
| 132 | // name without a link: a commit author who never registered is a string, not a |
| 133 | // person, and a typo in a mention should not become a 404 somebody can click. |
| 134 | // |
| 135 | // Nothing is authorized by what this returns; the profile page applies the same |
| 136 | // read rules as every other page. |
| 137 | type userRef struct { |
| 138 | Name string |
| 139 | Href string |
| 140 | Known bool |
| 141 | } |
| 142 | |
| 143 | func (w *Web) userRef(name string) userRef { |
| 144 | ref := userRef{Name: name} |
| 145 | if w.Ident == nil || name == "" { |
| 146 | return ref |
| 147 | } |
| 148 | a, err := w.Ident.Find(name) |
| 149 | if err != nil { |
| 150 | return ref |
| 151 | } |
| 152 | ref.Name = a.Name |
| 153 | ref.Href = "/-/u/" + url.PathEscape(a.Name) |
| 154 | ref.Known = true |
| 155 | return ref |
| 156 | } |
| 157 | |
| 158 | // repoNav is the repository header's data: which repository, which tab is |
| 159 | // current, whether anonymous readers can see it, the clone transports and site |
| 160 | // to offer, and how many open issues and change requests its tabs should |
| 161 | // advertise. Visibility comes from the same ACL rule the handlers use, so the |
| 162 | // lock/globe icon cannot disagree with whether an anonymous clone works. |
| 163 | // Templates call it as |
| 164 | // {{template "repohead" (nav .Page "issues")}}. |
| 165 | func (w *Web) repoNav(p Page, tab string) repoNavData { |
| 166 | n := repoNavData{Name: p.RepoName, Tab: tab, Private: true, CanAdmin: p.CanMaintain || p.CanAdmin, |
| 167 | CloneURL: p.CloneURL, SSHURL: p.SSHURL, Site: p.Site} |
| 168 | if p.RepoName == "" { |
| 169 | return n |
| 170 | } |
| 171 | m, err := w.repo(p.memo, p.RepoName) |
| 172 | if err != nil { |
| 173 | return n |
| 174 | } |
| 175 | n.Description = m.Description |
| 176 | if acl, err := m.ACL(); err == nil { |
| 177 | n.Private = !acl.Can("", access.Read) |
| 178 | } |
| 179 | n.OpenIssues, n.OpenRequests = w.openCounts(p.memo, m) |
| 180 | return n |
| 181 | } |
| 182 | |
| 183 | // openCounts returns the counts the repository tabs show. They describe the |
| 184 | // whole repository, not a filtered page: an "open" badge that changes when you |
| 185 | // searched would be a lie. A read that fails leaves a zero rather than failing |
| 186 | // the page the reader asked for; the tab still links to the list, which reports |
| 187 | // its own error if the same read fails there. The counts are remembered for the |
| 188 | // rest of the request, because the page asked for the same list. |
| 189 | func (w *Web) openCounts(memo *requestMemo, m *repo.Meta) (issues, requests int) { |
| 190 | if memo != nil { |
| 191 | memo.mu.Lock() |
| 192 | c, ok := memo.counts[m.Name] |
| 193 | memo.mu.Unlock() |
| 194 | if ok { |
| 195 | return c.issues, c.requests |
| 196 | } |
| 197 | } |
| 198 | if w.Index != nil { |
| 199 | if err := w.ensureIndex(memo, m); err == nil { |
| 200 | if n, err := w.Index.IssueCount(m.ID, "open"); err == nil { |
| 201 | issues = n |
| 202 | } |
| 203 | if n, err := w.Index.RequestCount(m.ID, "open"); err == nil { |
| 204 | requests = n |
| 205 | } |
| 206 | } |
| 207 | } else if list, err := w.requestsAt(memo, m); err == nil { |
| 208 | // A server without an index (a test, or a CLI-shaped Web) still counts |
| 209 | // from the branch. |
| 210 | for _, req := range list { |
| 211 | if req.State == "open" { |
| 212 | requests++ |
| 213 | } |
| 214 | } |
| 215 | } |
| 216 | if memo != nil { |
| 217 | memo.mu.Lock() |
| 218 | memo.counts[m.Name] = memoCounts{issues: issues, requests: requests} |
| 219 | memo.mu.Unlock() |
| 220 | } |
| 221 | return issues, requests |
| 222 | } |
| 223 | |
| 224 | // siteDeployed reports whether the repository actually has a site to link to. |
| 225 | // A configured pages domain is not enough: the site server only answers for a |
| 226 | // public repository's pages branch, so a link shown otherwise would lead to a |
| 227 | // 404. A missing branch is the normal state before the first deploy. |
| 228 | func (w *Web) siteDeployed(m *repo.Meta, acl *access.ACL) bool { |
| 229 | if w.Cfg == nil || strings.TrimSpace(w.Cfg.PagesDomain) == "" || acl == nil { |
| 230 | return false |
| 231 | } |
| 232 | if !acl.Can("", access.Read) { |
| 233 | return false |
| 234 | } |
| 235 | ref := strings.TrimSpace(w.Cfg.PagesRef) |
| 236 | if ref == "" { |
| 237 | ref = "pages" |
| 238 | } |
| 239 | full := "refs/heads/" + ref |
| 240 | refs, err := m.Git().ListRefs(full) |
| 241 | if err != nil { |
| 242 | return false |
| 243 | } |
| 244 | _, ok := refs[full] |
| 245 | return ok |
| 246 | } |
| 247 | |
| 248 | // RepoCounts answers the poll behind the repository tabs: the two open counts as |
| 249 | // out-of-band updates to the badges the header already shows. It reads the same |
| 250 | // counts the page did, so a new issue or change appears while the reader is on |
| 251 | // the page, and it sends nothing else, so the current tab, the clone menu and |
| 252 | // any form the reader has open stay where they are. A repository the viewer may |
| 253 | // not read answers 404 like every other page, so the poll cannot confirm that a |
| 254 | // private repository exists. |
| 255 | func (w *Web) RepoCounts(rw http.ResponseWriter, r *http.Request) { |
| 256 | m, ok := w.open(rw, r) |
| 257 | if !ok { |
| 258 | return |
| 259 | } |
| 260 | issues, requests := w.openCounts(memoOf(r), m) |
| 261 | w.render(rw, "repo_counts_live", repoNavData{ |
| 262 | Name: m.Name, OpenIssues: issues, OpenRequests: requests, Live: true, |
| 263 | }, 0) |
| 264 | } |
| 265 | |
| 266 | type repoNavData struct { |
| 267 | Name string |
| 268 | Description string |
| 269 | Tab string |
| 270 | Private bool |
| 271 | CanAdmin bool |
| 272 | CloneURL string |
| 273 | SSHURL string |
| 274 | Site string |
| 275 | OpenIssues int |
| 276 | OpenRequests int |
| 277 | // Live marks a render of the count badges alone, where each one carries the |
| 278 | // out-of-band swap the header's poll answers with. |
| 279 | Live bool |
| 280 | } |
| 281 |