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.
|
c717d4eb
Keenan Nemetz
13d ago
|
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 ( | |
|
a2ec9561
Keenan Nemetz
9d ago
|
9 | "net/http" |
|
a6b42433
Keenan Nemetz
7d ago
|
10 | "net/url" |
|
c717d4eb
Keenan Nemetz
13d ago
|
11 | "strings" |
| 12 | ||
|
d37d301f
Keenan Nemetz
16h ago
|
13 | "rodent.software/vole/forge/access" |
| 14 | "rodent.software/vole/forge/repo" | |
|
c717d4eb
Keenan Nemetz
13d ago
|
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 { | |
|
5d440e37
Keenan Nemetz
10d ago
|
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 | |
|
c717d4eb
Keenan Nemetz
13d ago
|
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 | |
|
0c40a1d3
Keenan Nemetz
7d ago
|
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 | |
|
816cc543
Keenan Nemetz
11d ago
|
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 | |
|
c717d4eb
Keenan Nemetz
13d ago
|
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 | |
|
a6b42433
Keenan Nemetz
7d ago
|
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 | |
|
1265ea55
Keenan Nemetz
9d ago
|
55 | // Query is the repository search on the index page. Other pages leave it |
| 56 | // empty. | |
|
c717d4eb
Keenan Nemetz
13d ago
|
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 | |
|
3d707ea7
Keenan Nemetz
18h ago
|
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 | |
|
6ce635a8
Keenan Nemetz
1d ago
|
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 | |
|
f7a34d87
Keenan Nemetz
1d ago
|
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 | |
|
5d440e37
Keenan Nemetz
10d ago
|
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 | |
|
c717d4eb
Keenan Nemetz
13d ago
|
79 | } |
| 80 | ||
|
f7a34d87
Keenan Nemetz
1d ago
|
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 | ||
|
6c03e6d3
Keenan Nemetz
9d ago
|
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. | |
|
c717d4eb
Keenan Nemetz
13d ago
|
93 | func pageSection(path string) string { |
|
ed03b3d1
Keenan Nemetz
3h ago
|
94 | if path == "/" { |
|
6c03e6d3
Keenan Nemetz
9d ago
|
95 | return "home" |
|
ed03b3d1
Keenan Nemetz
3h ago
|
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": | |
|
c717d4eb
Keenan Nemetz
13d ago
|
104 | return "repos" |
|
ed03b3d1
Keenan Nemetz
3h ago
|
105 | case strings.HasPrefix(path, "/-/activity"): |
|
c717d4eb
Keenan Nemetz
13d ago
|
106 | return "activity" |
|
ed03b3d1
Keenan Nemetz
3h ago
|
107 | case path == "/-/admin" || path == "/-/status" || path == "/-/users" || strings.HasPrefix(path, "/-/users/"): |
|
43e681d8
Keenan Nemetz
4d ago
|
108 | return "admin" |
|
ed03b3d1
Keenan Nemetz
3h ago
|
109 | case path == "/-/u" || strings.HasPrefix(path, "/-/u/"): |
|
a6b42433
Keenan Nemetz
7d ago
|
110 | return "people" |
|
ed03b3d1
Keenan Nemetz
3h ago
|
111 | case strings.HasPrefix(path, "/-/account"): |
|
c717d4eb
Keenan Nemetz
13d ago
|
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 | ||
|
a6b42433
Keenan Nemetz
7d ago
|
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 | |
|
ed03b3d1
Keenan Nemetz
3h ago
|
153 | ref.Href = "/-/u/" + url.PathEscape(a.Name) |
|
a6b42433
Keenan Nemetz
7d ago
|
154 | ref.Known = true |
| 155 | return ref | |
| 156 | } | |
| 157 | ||
|
c717d4eb
Keenan Nemetz
13d ago
|
158 | // repoNav is the repository header's data: which repository, which tab is |
|
816cc543
Keenan Nemetz
11d ago
|
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 | |
|
5d440e37
Keenan Nemetz
10d ago
|
164 | // {{template "repohead" (nav .Page "issues")}}. |
| 165 | func (w *Web) repoNav(p Page, tab string) repoNavData { | |
|
0c40a1d3
Keenan Nemetz
7d ago
|
166 | n := repoNavData{Name: p.RepoName, Tab: tab, Private: true, CanAdmin: p.CanMaintain || p.CanAdmin, |
|
5d440e37
Keenan Nemetz
10d ago
|
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) | |
|
c717d4eb
Keenan Nemetz
13d ago
|
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 | } | |
|
5d440e37
Keenan Nemetz
10d ago
|
179 | n.OpenIssues, n.OpenRequests = w.openCounts(p.memo, m) |
|
c717d4eb
Keenan Nemetz
13d ago
|
180 | return n |
| 181 | } | |
| 182 | ||
|
a196e404
Keenan Nemetz
11d ago
|
183 | // openCounts returns the counts the repository tabs show. They describe the |
|
5d440e37
Keenan Nemetz
10d ago
|
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 | } | |
|
a196e404
Keenan Nemetz
11d ago
|
198 | if w.Index != nil { |
|
73ec800b
Keenan Nemetz
10d ago
|
199 | if err := w.ensureIndex(memo, m); err == nil { |
|
a196e404
Keenan Nemetz
11d ago
|
200 | if n, err := w.Index.IssueCount(m.ID, "open"); err == nil { |
| 201 | issues = n | |
| 202 | } | |
|
b739915c
Keenan Nemetz
10d ago
|
203 | if n, err := w.Index.RequestCount(m.ID, "open"); err == nil { |
| 204 | requests = n | |
| 205 | } | |
|
a196e404
Keenan Nemetz
11d ago
|
206 | } |
|
b739915c
Keenan Nemetz
10d ago
|
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. | |
|
a196e404
Keenan Nemetz
11d ago
|
210 | for _, req := range list { |
| 211 | if req.State == "open" { | |
| 212 | requests++ | |
| 213 | } | |
| 214 | } | |
| 215 | } | |
|
5d440e37
Keenan Nemetz
10d ago
|
216 | if memo != nil { |
| 217 | memo.mu.Lock() | |
| 218 | memo.counts[m.Name] = memoCounts{issues: issues, requests: requests} | |
| 219 | memo.mu.Unlock() | |
| 220 | } | |
|
a196e404
Keenan Nemetz
11d ago
|
221 | return issues, requests |
| 222 | } | |
| 223 | ||
|
816cc543
Keenan Nemetz
11d ago
|
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 | ||
|
a2ec9561
Keenan Nemetz
9d ago
|
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 | ||
|
c717d4eb
Keenan Nemetz
13d ago
|
266 | type repoNavData struct { |
|
a196e404
Keenan Nemetz
11d ago
|
267 | Name string |
| 268 | Description string | |
| 269 | Tab string | |
| 270 | Private bool | |
| 271 | CanAdmin bool | |
|
816cc543
Keenan Nemetz
11d ago
|
272 | CloneURL string |
| 273 | SSHURL string | |
| 274 | Site string | |
|
a196e404
Keenan Nemetz
11d ago
|
275 | OpenIssues int |
| 276 | OpenRequests int | |
|
a2ec9561
Keenan Nemetz
9d ago
|
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 | |
|
c717d4eb
Keenan Nemetz
13d ago
|
280 | } |