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 · 51m ago 4b8c9e1d
history blame raw 9.6 KiB
1package 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
8import (
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.
19type 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.
84type 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.
93func 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.
119func (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.
137type userRef struct {
138 Name string
139 Href string
140 Known bool
141}
142
143func (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")}}.
165func (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.
189func (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.
228func (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.
255func (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
266type 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