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.

Blame · server/web/web.go

Line attribution on main · 233 line(s)

6c84d387
Keenan Nemetz
16d 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
18d ago
4 //
6c84d387
Keenan Nemetz
16d 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
13d ago
19 "strings"
f09ef39e
Keenan Nemetz
15d ago
20 "sync"
653e0cfc
Keenan Nemetz
18d ago
21 "time"
22
d37d301f
Keenan Nemetz
19h 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
16d 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
13d 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
13d 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
14d ago
92
43e681d8
Keenan Nemetz
4d ago
93 // statusCache holds the last filesystem walk shown on the Admin page.
4506a4d3
Keenan Nemetz
14d ago
94 statusCache statusCache
edf9db6e
Keenan Nemetz
13d 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
10d ago
101 readmes readmeCache
73ec800b
Keenan Nemetz
10d ago
102
edf9db6e
Keenan Nemetz
13d 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
16d 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
16d 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
16d 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
18d ago
137 return false
653e0cfc
Keenan Nemetz
18d ago
138 }
62576fa8
Keenan Nemetz
14d ago
139 name := ""
6c84d387
Keenan Nemetz
16d ago
140 if p := w.principal(r); p != nil {
62576fa8
Keenan Nemetz
14d ago
141 name = p.account.Name
6c84d387
Keenan Nemetz
16d 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
6h 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
13d 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
6h ago
168 if strings.HasPrefix(p, "/-/invite/") {
169 return "/-/invite/[redacted]"
edf9db6e
Keenan Nemetz
13d 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
13d 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
3d 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
16d 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
18d 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
16d ago
216 if err != nil || !w.canRead(r, m) {
f09ef39e
Keenan Nemetz
15d ago
217 w.notFound(rw, r)
a5f19b3c
Keenan Nemetz
18d ago
218 return nil, false
653e0cfc
Keenan Nemetz
18d ago
219 }
a5f19b3c
Keenan Nemetz
18d ago
220 return m, true
653e0cfc
Keenan Nemetz
18d ago
221 }
25343521
Keenan Nemetz
3d 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 }