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.
| 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. |
| 13 | package web |
| 14 | |
| 15 | import ( |
| 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 | |
| 37 | type 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 | |
| 107 | func 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. |
| 134 | func (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. |
| 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 | |
| 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) } |
| 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. |
| 167 | func 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. |
| 176 | func (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 | |
| 186 | func (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. |
| 214 | func (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. |
| 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 | } |
| 234 |