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.
Add browser auth, contributor access, and gated CI
6c84d387e5f608360e30484a1a5b1cff41076850 · Keenan Nemetz · 16d ago · parent c36d5073
Add browser auth, contributor access, and gated CI Browser sign-in now uses passkeys. Enrollment links are one-time, sessions are hashed in users.db, and issue/comment/approval writes use the session account. HTTPS public_url is required for WebAuthn. Add the contributor role between read and maintain. Contributors can discuss and open change requests, and may push only refs/heads/contrib/<account-id>/. Contributor CI is held per commit until a maintainer allows the exact tip from the repository page. Rename the write role to maintain. write remains accepted as the historical spelling, and access.Write remains as a Go alias so existing callers and ACL rows keep working. Update the web, CLI, queue schema, and guides for the new flows, and cover the role, namespace, auth, and CI-gating paths in tests.
server/server.go +31−3
| @@ -18,6 +18,7 @@ import ( | |||
| 18 | 18 | "github.com/rodent-software/vole/forge/identity" | |
| 19 | 19 | "github.com/rodent-software/vole/forge/index" | |
| 20 | 20 | "github.com/rodent-software/vole/forge/repo" | |
| 21 | "github.com/rodent-software/vole/forge/session" | ||
| 21 | 22 | "github.com/rodent-software/vole/server/githttp" | |
| 22 | 23 | "github.com/rodent-software/vole/server/pages" | |
| 23 | 24 | "github.com/rodent-software/vole/server/ssh" | |
| @@ -30,6 +31,7 @@ type Server struct { | |||
| 30 | 31 | Cfg *config.Config | |
| 31 | 32 | Repos *repo.Store | |
| 32 | 33 | Identity *identity.Store | |
| 34 | Sessions *session.Store | ||
| 33 | 35 | Runs *runs.Store | |
| 34 | 36 | Index *index.Store | |
| 35 | 37 | LFS *lfs.Store | |
| @@ -93,12 +95,18 @@ func New(cfg *config.Config, binPath string, log *slog.Logger) (*Server, error) | |||
| 93 | 95 | } | |
| 94 | 96 | wp.LFS = inst.LFS | |
| 95 | 97 | wp.Locks = inst.Locks | |
| 98 | wp.Sessions = inst.Sessions | ||
| 96 | 99 | wp.Logger = func(format string, args ...any) { log.Warn(fmt.Sprintf(format, args...)) } | |
| 100 | if wp.Auth != nil && !wp.Auth.Enabled() { | ||
| 101 | log.Info("browser login disabled", "reason", wp.Auth.Disabled) | ||
| 102 | } | ||
| 97 | 103 | ||
| 98 | 104 | s := &Server{ | |
| 99 | Cfg: cfg, Repos: inst.Repos, Identity: inst.Identity, Runs: inst.Runs, Index: inst.Index, | ||
| 105 | Cfg: cfg, Repos: inst.Repos, Identity: inst.Identity, Sessions: inst.Sessions, | ||
| 106 | Runs: inst.Runs, Index: inst.Index, | ||
| 100 | 107 | LFS: inst.LFS, Locks: inst.Locks, Proxy: proxy, SSH: ssh, Web: wp, Log: log, | |
| 101 | 108 | } | |
| 109 | wp.AllowCI = s.allowContributorCI | ||
| 102 | 110 | // Sites are opt-in: without a pages domain there is no host to serve them on, | |
| 103 | 111 | // and an instance that never wanted them pays no processes for the idea. | |
| 104 | 112 | if cfg.PagesDomain != "" { | |
| @@ -130,6 +138,17 @@ func (s *Server) routes() *http.ServeMux { | |||
| 130 | 138 | w := s.Web | |
| 131 | 139 | ||
| 132 | 140 | mux.HandleFunc("GET /{$}", w.IndexPage) | |
| 141 | mux.HandleFunc("GET /login", w.LoginPage) | ||
| 142 | mux.HandleFunc("POST /auth/login/begin", w.LoginBegin) | ||
| 143 | mux.HandleFunc("POST /auth/login/finish", w.LoginFinish) | ||
| 144 | mux.HandleFunc("POST /logout", w.Logout) | ||
| 145 | mux.HandleFunc("GET /invite/{token}", w.InvitePage) | ||
| 146 | mux.HandleFunc("POST /auth/invite/begin", w.InviteBegin) | ||
| 147 | mux.HandleFunc("POST /auth/invite/finish", w.InviteFinish) | ||
| 148 | mux.HandleFunc("GET /account", w.AccountPage) | ||
| 149 | mux.HandleFunc("POST /account/passkeys/begin", w.AddPasskeyBegin) | ||
| 150 | mux.HandleFunc("POST /account/passkeys/finish", w.AddPasskeyFinish) | ||
| 151 | mux.HandleFunc("GET /auth.js", w.AuthJS) | ||
| 133 | 152 | mux.HandleFunc("GET /runners", w.RunnersPage) | |
| 134 | 153 | mux.HandleFunc("GET /r/{name}", w.Repo) | |
| 135 | 154 | mux.HandleFunc("GET /r/{name}/tree/{ref}/{path...}", w.Tree) | |
| @@ -138,9 +157,14 @@ func (s *Server) routes() *http.ServeMux { | |||
| 138 | 157 | mux.HandleFunc("GET /r/{name}/commits/{ref}", w.Commits) | |
| 139 | 158 | mux.HandleFunc("GET /r/{name}/commit/{sha}", w.CommitView) | |
| 140 | 159 | mux.HandleFunc("GET /r/{name}/issues", w.Issues) | |
| 160 | mux.HandleFunc("POST /r/{name}/issues", w.CreateIssue) | ||
| 141 | 161 | mux.HandleFunc("GET /r/{name}/issues/{id}", w.IssueView) | |
| 162 | mux.HandleFunc("POST /r/{name}/issues/{id}/comments", w.AddIssueComment) | ||
| 142 | 163 | mux.HandleFunc("GET /r/{name}/requests", w.Requests) | |
| 164 | mux.HandleFunc("POST /r/{name}/requests", w.CreateChangeRequest) | ||
| 143 | 165 | mux.HandleFunc("GET /r/{name}/requests/{id}", w.RequestView) | |
| 166 | mux.HandleFunc("POST /r/{name}/requests/{id}/approve", w.ApproveRequest) | ||
| 167 | mux.HandleFunc("POST /r/{name}/ci/allow", w.AllowContributorCI) | ||
| 144 | 168 | // A tag may contain "/", so the detail route takes the rest of the path; the | |
| 145 | 169 | // two archive routes are more specific and win for their suffixes. | |
| 146 | 170 | mux.HandleFunc("GET /r/{name}/locks", w.LocksPage) | |
| @@ -171,15 +195,16 @@ func (s *Server) routes() *http.ServeMux { | |||
| 171 | 195 | // Handler returns the HTTP surface. Site hosts are dispatched first: a request | |
| 172 | 196 | // for <repo>.<pages_domain> is a page, and everything else is the forge UI. | |
| 173 | 197 | func (s *Server) Handler() http.Handler { | |
| 198 | forge := s.Web.Middleware(s.mux) | ||
| 174 | 199 | if s.Pages == nil { | |
| 175 | return s.mux | ||
| 200 | return forge | ||
| 176 | 201 | } | |
| 177 | 202 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 178 | 203 | if _, ok := s.Pages.Host(r.Host); ok { | |
| 179 | 204 | s.Pages.ServeHTTP(w, r) | |
| 180 | 205 | return | |
| 181 | 206 | } | |
| 182 | s.mux.ServeHTTP(w, r) | ||
| 207 | forge.ServeHTTP(w, r) | ||
| 183 | 208 | }) | |
| 184 | 209 | } | |
| 185 | 210 | ||
| @@ -267,6 +292,9 @@ func (s *Server) Close() error { | |||
| 267 | 292 | return err | |
| 268 | 293 | } | |
| 269 | 294 | } | |
| 295 | if s.Sessions != nil { | ||
| 296 | _ = s.Sessions.Close() | ||
| 297 | } | ||
| 270 | 298 | if s.Identity != nil { | |
| 271 | 299 | return s.Identity.Close() | |
| 272 | 300 | } | |