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/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Code

395 commit(s) on main

update readme Keenan Nemetz · 30m ago 32290114
history blame raw 9.4 KiB
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.
4//
5// Anonymous readers still get 404 for a private repository rather than 403, so
6// the web never confirms that a private repository exists.
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
12// files answer a form POST.
13package web
14
15import (
16 "bytes"
17 "html/template"
18 "net/http"
19 "strings"
20 "sync"
21 "time"
22
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"
35)
36
37type Web struct {
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
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
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
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
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
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)
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.
72 // DeleteRepo takes the account so the deletion is recorded against it.
73 RenameRepo func(oldName, newName string) error
74 DeleteRepo func(name, actor string) error
75 Logger func(format string, args ...any)
76
77 // Started is the process start, shown on the Admin page as uptime.
78 Started time.Time
79
80 // sem bounds archive streams; see releases.go.
81 sem chan struct{}
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
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
92
93 // statusCache holds the last filesystem walk shown on the Admin page.
94 statusCache statusCache
95
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]
101 readmes readmeCache
102
103 // limits bounds the authentication ceremonies; see ratelimit.go.
104 limits authLimits
105}
106
107func New(repos *repo.Store, ix *index.Store, ident *identity.Store, rs *runs.Store, cfg *config.Config) (*Web, error) {
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,
113 Auth: auth, Ceremonies: newCeremonyStore(), Started: time.Now().UTC(),
114 sem: make(chan struct{}, archiveConc), gitSem: make(chan struct{}, webGitConcurrent),
115 gitWait: webGitWait, limits: newAuthLimits(cfg.ContributorWritesPerHour)}
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")
120 if err != nil {
121 return nil, err
122 }
123 w.Logger = func(string, ...any) {}
124 if err := w.enableDevLogin(cfg); err != nil {
125 return nil, err
126 }
127 return w, nil
128}
129
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
132// uses; a logged-in account gets its ACL role, and an instance admin may
133// read anything they can already change the rules for.
134func (w *Web) canRead(r *http.Request, m *repo.Meta) bool {
135 a, err := m.ACL()
136 if err != nil {
137 return false
138 }
139 name := ""
140 if p := w.principal(r); p != nil {
141 name = p.account.Name
142 }
143 instanceAdmin := name != "" && w.Ident != nil && w.Ident.IsAdmin(name)
144 return a.EffectiveRole(name, instanceAdmin).AtLeast(access.Read)
145}
146
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.
149func 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.
153func (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
160// NotFoundPage is the catch-all route. It uses the same view as every other 404.
161func (w *Web) NotFoundPage(rw http.ResponseWriter, r *http.Request) { w.notFound(rw, r) }
162
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.
167func loggablePath(p string) string {
168 if strings.HasPrefix(p, "/-/invite/") {
169 return "/-/invite/[redacted]"
170 }
171 return p
172}
173
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.
176func (w *Web) serverError(rw http.ResponseWriter, r *http.Request, detail string) {
177 if detail != "" && w.Logger != nil {
178 w.Logger("server error %s %s: %s", r.Method, loggablePath(r.URL.Path), detail)
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
186func (w *Web) render(rw http.ResponseWriter, name string, data any, status int) {
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 }
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 }
205 rw.Header().Set("Content-Type", "text/html; charset=utf-8")
206 if status != 0 {
207 rw.WriteHeader(status)
208 }
209 _, _ = out.WriteTo(rw)
210}
211
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.
214func (w *Web) open(rw http.ResponseWriter, r *http.Request) (*repo.Meta, bool) {
215 m, err := w.repo(memoOf(r), r.PathValue("name"))
216 if err != nil || !w.canRead(r, m) {
217 w.notFound(rw, r)
218 return nil, false
219 }
220 return m, true
221}
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.
227func (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}
234