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.
|
6c84d387
Keenan Nemetz
15d ago
|
1 | // Package web is Vole's HTML surface. Reads are filtered by the viewer's role; |
| 2 | // writes are the same typed metadata changes a push makes, performed by the | |
| 3 | // account whose passkey opened the session. | |
|
a5f19b3c
Keenan Nemetz
17d ago
|
4 | // |
|
6c84d387
Keenan Nemetz
15d ago
|
5 | // Anonymous readers still get 404 for a private repository rather than 403, so |
| 6 | // the web never confirms that a private repository exists. | |
|
c717d4eb
Keenan Nemetz
13d ago
|
7 | // |
| 8 | // Files are grouped by what they answer, not by layer: web.go is the Web type and | |
| 9 | // the request plumbing, page.go is the data every page starts from, and there is | |
| 10 | // one file per destination (issues.go, requests.go, codebrowser.go, home.go, ...). | |
| 11 | // Read surfaces answer a page or an HTMX fragment; writes.go and the *_actions | |
|
7defd797
Keenan Nemetz
10d ago
|
12 | // files answer a form POST. |
|
653e0cfc
Keenan Nemetz
18d ago
|
13 | package web |
| 14 | ||
| 15 | import ( | |
|
a72e39d5
Keenan Nemetz
10d ago
|
16 | "bytes" |
|
653e0cfc
Keenan Nemetz
18d ago
|
17 | "html/template" |
| 18 | "net/http" | |
|
edf9db6e
Keenan Nemetz
12d ago
|
19 | "strings" |
|
f09ef39e
Keenan Nemetz
15d ago
|
20 | "sync" |
|
653e0cfc
Keenan Nemetz
18d ago
|
21 | "time" |
| 22 | ||
|
d37d301f
Keenan Nemetz
15h ago
|
23 | "rodent.software/vole/ci/logs" |
| 24 | "rodent.software/vole/ci/runs" | |
| 25 | "rodent.software/vole/core/lfs" | |
| 26 | "rodent.software/vole/forge/access" | |
| 27 | "rodent.software/vole/forge/config" | |
| 28 | "rodent.software/vole/forge/identity" | |
| 29 | "rodent.software/vole/forge/index" | |
| 30 | "rodent.software/vole/forge/instancerepo" | |
| 31 | "rodent.software/vole/forge/pageview" | |
| 32 | "rodent.software/vole/forge/repo" | |
| 33 | "rodent.software/vole/forge/secret" | |
| 34 | "rodent.software/vole/forge/session" | |
|
653e0cfc
Keenan Nemetz
18d ago
|
35 | ) |
| 36 | ||
| 37 | type Web struct { | |
|
0c40a1d3
Keenan Nemetz
7d ago
|
38 | Repos *repo.Store |
| 39 | Index *index.Store | |
| 40 | Ident *identity.Store | |
| 41 | Sessions *session.Store | |
| 42 | Runs *runs.Store | |
| 43 | Logs *logs.Store | |
| 44 | LFS *lfs.Store | |
| 45 | Locks *lfs.LockStore | |
| 46 | PageViews *pageview.Store | |
|
6ce635a8
Keenan Nemetz
1d ago
|
47 | // InstanceRepo is the repository whose tree supplies the landing page |
| 48 | // introduction, CSS, favicon, and assets. Nil keeps the built-in presentation. | |
| 49 | InstanceRepo *instancerepo.Store | |
|
0c40a1d3
Keenan Nemetz
7d ago
|
50 | // Secrets is the per-repository store the settings page writes to. The page |
| 51 | // never reads a value back: it shows names, versions and read times. | |
| 52 | Secrets *secret.Store | |
|
6c84d387
Keenan Nemetz
15d ago
|
53 | Cfg *config.Config |
| 54 | Tmpl *template.Template | |
| 55 | Auth *Auth | |
| 56 | Ceremonies *ceremonyStore | |
| 57 | // AllowCI is the server's queue-side operation: record a maintainer's approval | |
| 58 | // for one contributor tip and enqueue it. The web owns the form and ACL check. | |
| 59 | AllowCI func(repo, ref, sha, approver string) error | |
|
e5478085
Keenan Nemetz
11d ago
|
60 | // EnsureCandidate publishes and queues the combined result for a request. The |
| 61 | // merge handler calls it before arming so the first look does not wait for the | |
| 62 | // reconciler, and so a Merge click is useful even before the next tick. | |
| 63 | EnsureCandidate func(repo, request string) error | |
|
eb07ff56
Keenan Nemetz
8d ago
|
64 | // MetadataChanged wakes the metadata event loop after a browser write, which does |
| 65 | // not go through a Git push and therefore has no spool entry. | |
| 66 | MetadataChanged func(repo string) | |
| 67 | // DispatchWorkflow queues a manual run of a workflow that declares | |
| 68 | // workflow-dispatch. The web owns the form and the ACL check. | |
| 69 | DispatchWorkflow func(repo, workflow, ref string, inputs map[string]string, actor string) (int, error) | |
|
2973f727
Keenan Nemetz
15d ago
|
70 | // RenameRepo and DeleteRepo are the instance operations that also move the |
| 71 | // index, queue, LFS namespace, and locks. The web owns the form and ACL check. | |
|
b1499c72
Keenan Nemetz
12d ago
|
72 | // DeleteRepo takes the account so the deletion is recorded against it. |
|
2973f727
Keenan Nemetz
15d ago
|
73 | RenameRepo func(oldName, newName string) error |
|
b1499c72
Keenan Nemetz
12d ago
|
74 | DeleteRepo func(name, actor string) error |
|
5cab7b18
Keenan Nemetz
15d ago
|
75 | Logger func(format string, args ...any) |
|
c56911e2
Keenan Nemetz
17d ago
|
76 | |
|
43e681d8
Keenan Nemetz
4d ago
|
77 | // Started is the process start, shown on the Admin page as uptime. |
|
09b15120
Keenan Nemetz
15d ago
|
78 | Started time.Time |
| 79 | ||
|
701232cf
Keenan Nemetz
12d ago
|
80 | // sem bounds archive streams; see releases.go. |
|
c56911e2
Keenan Nemetz
17d ago
|
81 | sem chan struct{} |
|
701232cf
Keenan Nemetz
12d ago
|
82 | // gitSem bounds the pages that shell out to git, and gitWait is how long a page |
| 83 | // waits for a slot; see gitbounds.go. | |
| 84 | gitSem chan struct{} | |
| 85 | gitWait time.Duration | |
|
f09ef39e
Keenan Nemetz
15d ago
|
86 | |
| 87 | // Development login, configured by dev_login. It is resolved once at startup | |
| 88 | // and only ever used for loopback requests; the session is created lazily. | |
| 89 | devAccount *identity.Account | |
| 90 | devSess *session.Session | |
| 91 | devMu sync.Mutex | |
|
4506a4d3
Keenan Nemetz
13d ago
|
92 | |
|
43e681d8
Keenan Nemetz
4d ago
|
93 | // statusCache holds the last filesystem walk shown on the Admin page. |
|
4506a4d3
Keenan Nemetz
13d ago
|
94 | statusCache statusCache |
|
edf9db6e
Keenan Nemetz
12d ago
|
95 | |
|
73ec800b
Keenan Nemetz
10d ago
|
96 | // History summaries, keyed by repository and commit. They are properties of |
| 97 | // the commit graph, so they cannot go stale while the id stays the same; see | |
| 98 | // cache.go. | |
| 99 | commitCounts commitCache[int] | |
| 100 | commitLanguages commitCache[[]languageShare] | |
|
2a8f7cef
Keenan Nemetz
9d ago
|
101 | readmes readmeCache |
|
73ec800b
Keenan Nemetz
10d ago
|
102 | |
|
edf9db6e
Keenan Nemetz
12d ago
|
103 | // limits bounds the authentication ceremonies; see ratelimit.go. |
| 104 | limits authLimits | |
|
653e0cfc
Keenan Nemetz
18d ago
|
105 | } |
| 106 | ||
|
35eaeccf
Keenan Nemetz
17d ago
|
107 | func New(repos *repo.Store, ix *index.Store, ident *identity.Store, rs *runs.Store, cfg *config.Config) (*Web, error) { |
|
474a812d
Keenan Nemetz
15d ago
|
108 | auth, err := newAuth(cfg) |
| 109 | if err != nil { | |
| 110 | return nil, err | |
| 111 | } | |
| 112 | w := &Web{Repos: repos, Index: ix, Ident: ident, Runs: rs, Cfg: cfg, | |
|
09b15120
Keenan Nemetz
15d ago
|
113 | Auth: auth, Ceremonies: newCeremonyStore(), Started: time.Now().UTC(), |
|
701232cf
Keenan Nemetz
12d ago
|
114 | sem: make(chan struct{}, archiveConc), gitSem: make(chan struct{}, webGitConcurrent), |
|
e5542ac6
Keenan Nemetz
12d ago
|
115 | gitWait: webGitWait, limits: newAuthLimits(cfg.ContributorWritesPerHour)} |
|
c717d4eb
Keenan Nemetz
13d ago
|
116 | // The templates are parsed after the Web exists because two of the functions |
| 117 | // they call (who, nav) read the roster through the Web: metadata files carry | |
| 118 | // account names, and a page should show the account name for either. | |
| 119 | w.Tmpl, err = template.New("vole").Funcs(w.templateFuncs()).ParseFS(templateFS, "templates/*.html") | |
|
653e0cfc
Keenan Nemetz
18d ago
|
120 | if err != nil { |
| 121 | return nil, err | |
| 122 | } | |
| 123 | w.Logger = func(string, ...any) {} | |
|
f09ef39e
Keenan Nemetz
15d ago
|
124 | if err := w.enableDevLogin(cfg); err != nil { |
| 125 | return nil, err | |
| 126 | } | |
|
653e0cfc
Keenan Nemetz
18d ago
|
127 | return w, nil |
| 128 | } | |
| 129 | ||
|
6c84d387
Keenan Nemetz
15d ago
|
130 | // canRead reports whether the viewer may see the repository. An anonymous |
| 131 | // viewer is the empty principal, which is the same rule the git HTTP transport | |
|
ab326d3a
Keenan Nemetz
14d ago
|
132 | // uses; a logged-in account gets its ACL role, and an instance admin may |
|
381d6aac
Keenan Nemetz
14d ago
|
133 | // read anything they can already change the rules for. |
|
6c84d387
Keenan Nemetz
15d ago
|
134 | func (w *Web) canRead(r *http.Request, m *repo.Meta) bool { |
|
653e0cfc
Keenan Nemetz
18d ago
|
135 | a, err := m.ACL() |
| 136 | if err != nil { | |
|
a5f19b3c
Keenan Nemetz
17d ago
|
137 | return false |
|
653e0cfc
Keenan Nemetz
18d ago
|
138 | } |
|
62576fa8
Keenan Nemetz
14d ago
|
139 | name := "" |
|
6c84d387
Keenan Nemetz
15d ago
|
140 | if p := w.principal(r); p != nil { |
|
62576fa8
Keenan Nemetz
14d ago
|
141 | name = p.account.Name |
|
6c84d387
Keenan Nemetz
15d ago
|
142 | } |
|
381d6aac
Keenan Nemetz
14d ago
|
143 | instanceAdmin := name != "" && w.Ident != nil && w.Ident.IsAdmin(name) |
| 144 | return a.EffectiveRole(name, instanceAdmin).AtLeast(access.Read) | |
|
653e0cfc
Keenan Nemetz
18d ago
|
145 | } |
| 146 | ||
|
f09ef39e
Keenan Nemetz
15d ago
|
147 | // htmxRequest reports whether a request came from HTMX. The same handler still |
| 148 | // supports the ordinary form POST; the header only changes the response shape. | |
| 149 | func htmxRequest(r *http.Request) bool { return r.Header.Get("HX-Request") == "true" } | |
| 150 | ||
| 151 | // notFound is the one answer for a missing page and for a repository the viewer | |
| 152 | // may not see: the two must be indistinguishable or a private repository leaks. | |
| 153 | func (w *Web) notFound(rw http.ResponseWriter, r *http.Request) { | |
| 154 | w.render(rw, "notfound", struct { | |
| 155 | Page | |
| 156 | Message string | |
| 157 | }{w.page(r, "Not found"), "That page does not exist, or you do not have access to it."}, http.StatusNotFound) | |
| 158 | } | |
| 159 | ||
|
ed03b3d1
Keenan Nemetz
2h ago
|
160 | // NotFoundPage is the catch-all route. It uses the same view as every other 404. |
| 161 | func (w *Web) NotFoundPage(rw http.ResponseWriter, r *http.Request) { w.notFound(rw, r) } | |
|
f09ef39e
Keenan Nemetz
15d ago
|
162 | |
|
edf9db6e
Keenan Nemetz
12d ago
|
163 | // loggablePath is a request path with any secret in it removed. An enrollment |
| 164 | // link used to carry its token as a path segment, and a log is read by far more | |
| 165 | // people than a link is sent to; a client following an old link must not be able | |
| 166 | // to put that secret in the log by making the page fail. | |
| 167 | func loggablePath(p string) string { | |
|
ed03b3d1
Keenan Nemetz
2h ago
|
168 | if strings.HasPrefix(p, "/-/invite/") { |
| 169 | return "/-/invite/[redacted]" | |
|
edf9db6e
Keenan Nemetz
12d ago
|
170 | } |
| 171 | return p | |
| 172 | } | |
| 173 | ||
|
e84a66f5
Keenan Nemetz
15d ago
|
174 | // serverError is the styled 500. The detail goes to the log rather than the |
| 175 | // page: a store path or a stack trace is not something a browser should learn. | |
| 176 | func (w *Web) serverError(rw http.ResponseWriter, r *http.Request, detail string) { | |
| 177 | if detail != "" && w.Logger != nil { | |
|
edf9db6e
Keenan Nemetz
12d ago
|
178 | w.Logger("server error %s %s: %s", r.Method, loggablePath(r.URL.Path), detail) |
|
e84a66f5
Keenan Nemetz
15d ago
|
179 | } |
| 180 | w.render(rw, "servererror", struct { | |
| 181 | Page | |
| 182 | Message string | |
| 183 | }{w.page(r, "Server error"), "The server could not complete this request. The problem has been logged."}, http.StatusInternalServerError) | |
| 184 | } | |
| 185 | ||
|
653e0cfc
Keenan Nemetz
18d ago
|
186 | func (w *Web) render(rw http.ResponseWriter, name string, data any, status int) { |
|
25343521
Keenan Nemetz
2d ago
|
187 | // Pages are rendered for the viewer who asked for them. Only the explicit |
| 188 | // no-store pages set their own directive; everything else is private and | |
| 189 | // revalidated, so a shared cache cannot hand one account's page to another. | |
| 190 | if rw.Header().Get("Cache-Control") == "" { | |
| 191 | rw.Header().Set("Cache-Control", "private, no-cache") | |
| 192 | } | |
|
a72e39d5
Keenan Nemetz
10d ago
|
193 | // Render into a buffer first. html/template stops at the first execution error |
| 194 | // and keeps everything it already wrote, which used to leave a page that looked | |
| 195 | // complete up to the row that failed and silently dropped the rest. A failed | |
| 196 | // page is a 500, not a short one. | |
| 197 | var out bytes.Buffer | |
| 198 | if err := w.Tmpl.ExecuteTemplate(&out, name, data); err != nil { | |
| 199 | if w.Logger != nil { | |
| 200 | w.Logger("render %s: %v", name, err) | |
| 201 | } | |
| 202 | http.Error(rw, "This page could not be rendered.", http.StatusInternalServerError) | |
| 203 | return | |
| 204 | } | |
|
653e0cfc
Keenan Nemetz
18d ago
|
205 | rw.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 206 | if status != 0 { | |
| 207 | rw.WriteHeader(status) | |
| 208 | } | |
|
a72e39d5
Keenan Nemetz
10d ago
|
209 | _, _ = out.WriteTo(rw) |
|
653e0cfc
Keenan Nemetz
18d ago
|
210 | } |
| 211 | ||
|
6c84d387
Keenan Nemetz
15d ago
|
212 | // open resolves a repository for the viewer, answering 404 when it is missing |
| 213 | // or not readable. A private repository is indistinguishable from a missing one. | |
|
a5f19b3c
Keenan Nemetz
17d ago
|
214 | func (w *Web) open(rw http.ResponseWriter, r *http.Request) (*repo.Meta, bool) { |
|
5d440e37
Keenan Nemetz
10d ago
|
215 | m, err := w.repo(memoOf(r), r.PathValue("name")) |
|
6c84d387
Keenan Nemetz
15d ago
|
216 | if err != nil || !w.canRead(r, m) { |
|
f09ef39e
Keenan Nemetz
15d ago
|
217 | w.notFound(rw, r) |
|
a5f19b3c
Keenan Nemetz
17d ago
|
218 | return nil, false |
|
653e0cfc
Keenan Nemetz
18d ago
|
219 | } |
|
a5f19b3c
Keenan Nemetz
17d ago
|
220 | return m, true |
|
653e0cfc
Keenan Nemetz
18d ago
|
221 | } |
|
25343521
Keenan Nemetz
2d ago
|
222 | |
| 223 | // downloadCacheControl is the Cache-Control for bytes that belong to m. A shared | |
| 224 | // cache may keep what an anonymous reader could fetch; it may not keep a private | |
| 225 | // repository's artifact or archive just because the current request happens to | |
| 226 | // be authenticated. | |
| 227 | func (w *Web) downloadCacheControl(m *repo.Meta) string { | |
| 228 | acl, err := m.ACL() | |
| 229 | if err == nil && acl.Can("", access.Read) { | |
| 230 | return "public, max-age=300" | |
| 231 | } | |
| 232 | return "private, no-store" | |
| 233 | } |