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/web/web.go +107−67
| @@ -1,9 +1,9 @@ | |||
| 1 | // Package web is Vole's read-only HTML surface. | ||
| 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. | ||
| 2 | 4 | // | |
| 3 | // There is no login: everything the web serves already has to be readable by an | ||
| 4 | // anonymous principal, which is the same rule the HTTP git transport enforces. | ||
| 5 | // Repositories that are not public answer 404 rather than 403, so the web never | ||
| 6 | // confirms that a private repository exists. | ||
| 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 | 7 | package web | |
| 8 | 8 | ||
| 9 | 9 | import ( | |
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | "html/template" | |
| 12 | 12 | "net/http" | |
| 13 | 13 | "net/url" | |
| 14 | "sort" | ||
| 14 | 15 | "strings" | |
| 15 | 16 | "time" | |
| 16 | 17 | "unicode/utf8" | |
| @@ -23,19 +24,26 @@ import ( | |||
| 23 | 24 | "github.com/rodent-software/vole/forge/identity" | |
| 24 | 25 | "github.com/rodent-software/vole/forge/index" | |
| 25 | 26 | "github.com/rodent-software/vole/forge/repo" | |
| 27 | "github.com/rodent-software/vole/forge/session" | ||
| 26 | 28 | "github.com/rodent-software/vole/internal/format" | |
| 27 | 29 | ) | |
| 28 | 30 | ||
| 29 | 31 | type Web struct { | |
| 30 | Repos *repo.Store | ||
| 31 | Index *index.Store | ||
| 32 | Ident *identity.Store | ||
| 33 | Runs *runs.Store | ||
| 34 | LFS *lfs.Store | ||
| 35 | Locks *lfs.LockStore | ||
| 36 | Cfg *config.Config | ||
| 37 | Tmpl *template.Template | ||
| 38 | Logger func(format string, args ...any) | ||
| 32 | Repos *repo.Store | ||
| 33 | Index *index.Store | ||
| 34 | Ident *identity.Store | ||
| 35 | Sessions *session.Store | ||
| 36 | Runs *runs.Store | ||
| 37 | LFS *lfs.Store | ||
| 38 | Locks *lfs.LockStore | ||
| 39 | Cfg *config.Config | ||
| 40 | Tmpl *template.Template | ||
| 41 | Auth *Auth | ||
| 42 | Ceremonies *ceremonyStore | ||
| 43 | // AllowCI is the server's queue-side operation: record a maintainer's approval | ||
| 44 | // for one contributor tip and enqueue it. The web owns the form and ACL check. | ||
| 45 | AllowCI func(repo, ref, sha, approver string) error | ||
| 46 | Logger func(format string, args ...any) | ||
| 39 | 47 | ||
| 40 | 48 | // sem bounds compressed archive streams; see releases.go. | |
| 41 | 49 | sem chan struct{} | |
| @@ -43,16 +51,21 @@ type Web struct { | |||
| 43 | 51 | ||
| 44 | 52 | func New(repos *repo.Store, ix *index.Store, ident *identity.Store, rs *runs.Store, cfg *config.Config) (*Web, error) { | |
| 45 | 53 | t, err := template.New("vole").Funcs(template.FuncMap{ | |
| 46 | "since": humanSince, | ||
| 47 | "short": format.SHA, | ||
| 48 | "esc": url.PathEscape, | ||
| 49 | "bytes": format.Bytes, | ||
| 54 | "since": humanSince, | ||
| 55 | "short": format.SHA, | ||
| 56 | "esc": url.PathEscape, | ||
| 57 | "bytes": format.Bytes, | ||
| 58 | "credid": identity.CredentialIDString, | ||
| 50 | 59 | }).Parse(templates) | |
| 51 | 60 | if err != nil { | |
| 52 | 61 | return nil, err | |
| 53 | 62 | } | |
| 63 | auth, err := newAuth(cfg) | ||
| 64 | if err != nil { | ||
| 65 | return nil, err | ||
| 66 | } | ||
| 54 | 67 | w := &Web{Repos: repos, Index: ix, Ident: ident, Runs: rs, Cfg: cfg, Tmpl: t, | |
| 55 | sem: make(chan struct{}, archiveConc)} | ||
| 68 | Auth: auth, Ceremonies: newCeremonyStore(), sem: make(chan struct{}, archiveConc)} | ||
| 56 | 69 | w.Logger = func(string, ...any) {} | |
| 57 | 70 | return w, nil | |
| 58 | 71 | } | |
| @@ -60,17 +73,29 @@ func New(repos *repo.Store, ix *index.Store, ident *identity.Store, rs *runs.Sto | |||
| 60 | 73 | // Page is embedded by every view. Exported so html/template can promote its | |
| 61 | 74 | // fields; an unexported embedded type is not reachable from a template. | |
| 62 | 75 | type Page struct { | |
| 63 | Title string | ||
| 64 | Error string | ||
| 76 | Title string | ||
| 77 | Error string | ||
| 78 | LoggedIn bool | ||
| 79 | User string | ||
| 80 | AccountID string | ||
| 81 | // CSRF is derived from the session token and must be on every state-changing | ||
| 82 | // form or fetch. | ||
| 83 | CSRF string | ||
| 65 | 84 | } | |
| 66 | 85 | ||
| 67 | // public reports whether the repository may be served to an anonymous reader. | ||
| 68 | func (w *Web) public(m *repo.Meta) bool { | ||
| 86 | // canRead reports whether the viewer may see the repository. An anonymous | ||
| 87 | // viewer is the empty principal, which is the same rule the git HTTP transport | ||
| 88 | // uses; a logged-in account gets its ACL role. | ||
| 89 | func (w *Web) canRead(r *http.Request, m *repo.Meta) bool { | ||
| 69 | 90 | a, err := m.ACL() | |
| 70 | 91 | if err != nil { | |
| 71 | 92 | return false | |
| 72 | 93 | } | |
| 73 | return a.Can("", access.Read) | ||
| 94 | accountID := "" | ||
| 95 | if p := w.principal(r); p != nil { | ||
| 96 | accountID = p.account.ID | ||
| 97 | } | ||
| 98 | return a.Can(accountID, access.Read) | ||
| 74 | 99 | } | |
| 75 | 100 | ||
| 76 | 101 | func (w *Web) render(rw http.ResponseWriter, name string, data any, status int) { | |
| @@ -83,8 +108,6 @@ func (w *Web) render(rw http.ResponseWriter, name string, data any, status int) | |||
| 83 | 108 | } | |
| 84 | 109 | } | |
| 85 | 110 | ||
| 86 | func (w *Web) base(title string) Page { return Page{Title: title} } | ||
| 87 | |||
| 88 | 111 | // ---------- handlers ---------- | |
| 89 | 112 | ||
| 90 | 113 | func (w *Web) IndexPage(rw http.ResponseWriter, r *http.Request) { | |
| @@ -99,21 +122,21 @@ func (w *Web) IndexPage(rw http.ResponseWriter, r *http.Request) { | |||
| 99 | 122 | if err != nil { | |
| 100 | 123 | continue | |
| 101 | 124 | } | |
| 102 | if w.public(m) { | ||
| 125 | if w.canRead(r, m) { | ||
| 103 | 126 | visible = append(visible, e) | |
| 104 | 127 | } | |
| 105 | 128 | } | |
| 106 | 129 | w.render(rw, "index", struct { | |
| 107 | 130 | Page | |
| 108 | 131 | Repos []index.Entry | |
| 109 | }{w.base("Repositories"), visible}, 0) | ||
| 132 | }{w.page(r, "Repositories"), visible}, 0) | ||
| 110 | 133 | } | |
| 111 | 134 | ||
| 112 | // open resolves a repository for an anonymous reader, answering 404 when it is | ||
| 113 | // missing or not public. | ||
| 135 | // open resolves a repository for the viewer, answering 404 when it is missing | ||
| 136 | // or not readable. A private repository is indistinguishable from a missing one. | ||
| 114 | 137 | func (w *Web) open(rw http.ResponseWriter, r *http.Request) (*repo.Meta, bool) { | |
| 115 | 138 | m, err := w.Repos.Open(r.PathValue("name")) | |
| 116 | if err != nil || !w.public(m) { | ||
| 139 | if err != nil || !w.canRead(r, m) { | ||
| 117 | 140 | http.NotFound(rw, r) | |
| 118 | 141 | return nil, false | |
| 119 | 142 | } | |
| @@ -187,42 +210,59 @@ func (w *Web) Repo(rw http.ResponseWriter, r *http.Request) { | |||
| 187 | 210 | for k := range branches { | |
| 188 | 211 | names = append(names, strings.TrimPrefix(k, "refs/heads/")) | |
| 189 | 212 | } | |
| 213 | sort.Strings(names) | ||
| 214 | var contributorBranches []string | ||
| 215 | for _, name := range names { | ||
| 216 | if _, ok := access.ContributorRefAccount("refs/heads/" + name); ok { | ||
| 217 | contributorBranches = append(contributorBranches, name) | ||
| 218 | } | ||
| 219 | } | ||
| 220 | canAllowCI := false | ||
| 221 | if acl, err := m.ACL(); err == nil { | ||
| 222 | if id := w.accountID(r); id != "" && acl.CanApprove(id) { | ||
| 223 | canAllowCI = true | ||
| 224 | } | ||
| 225 | } | ||
| 190 | 226 | w.render(rw, "repo", struct { | |
| 191 | 227 | Page | |
| 192 | Name string | ||
| 193 | Description string | ||
| 194 | Ref string | ||
| 195 | CloneURL string | ||
| 196 | SSHURL string | ||
| 197 | Entries []git.Entry | ||
| 198 | Commits []git.Commit | ||
| 199 | Commit string | ||
| 200 | Count int | ||
| 201 | Branches []string | ||
| 202 | IssueCount int | ||
| 203 | ReleaseCount int | ||
| 204 | LockCount int | ||
| 205 | Site string | ||
| 206 | Readme template.HTML | ||
| 207 | Runs []*runs.Run | ||
| 228 | Name string | ||
| 229 | Description string | ||
| 230 | Ref string | ||
| 231 | CloneURL string | ||
| 232 | SSHURL string | ||
| 233 | Entries []git.Entry | ||
| 234 | Commits []git.Commit | ||
| 235 | Commit string | ||
| 236 | Count int | ||
| 237 | Branches []string | ||
| 238 | IssueCount int | ||
| 239 | ReleaseCount int | ||
| 240 | LockCount int | ||
| 241 | Site string | ||
| 242 | Readme template.HTML | ||
| 243 | Runs []*runs.Run | ||
| 244 | ContributorBranches []string | ||
| 245 | CanAllowCI bool | ||
| 208 | 246 | }{ | |
| 209 | Page: w.base(m.Name), | ||
| 210 | Name: m.Name, | ||
| 211 | Description: m.Description, | ||
| 212 | Ref: ref, | ||
| 213 | CloneURL: cloneURL(r, m.Name), | ||
| 214 | SSHURL: sshURL(w.Cfg.SSHAddr, m.Name), | ||
| 215 | Entries: entries, | ||
| 216 | Commits: commits, | ||
| 217 | Commit: commit, | ||
| 218 | Count: count, | ||
| 219 | Branches: names, | ||
| 220 | IssueCount: issueCount, | ||
| 221 | ReleaseCount: releaseCount, | ||
| 222 | LockCount: lockCount, | ||
| 223 | Site: site, | ||
| 224 | Readme: readme, | ||
| 225 | Runs: recentRuns, | ||
| 247 | Page: w.page(r, m.Name), | ||
| 248 | Name: m.Name, | ||
| 249 | Description: m.Description, | ||
| 250 | Ref: ref, | ||
| 251 | CloneURL: cloneURL(r, m.Name), | ||
| 252 | SSHURL: sshURL(w.Cfg.SSHAddr, m.Name), | ||
| 253 | Entries: entries, | ||
| 254 | Commits: commits, | ||
| 255 | Commit: commit, | ||
| 256 | Count: count, | ||
| 257 | Branches: names, | ||
| 258 | IssueCount: issueCount, | ||
| 259 | ReleaseCount: releaseCount, | ||
| 260 | LockCount: lockCount, | ||
| 261 | Site: site, | ||
| 262 | Readme: readme, | ||
| 263 | Runs: recentRuns, | ||
| 264 | ContributorBranches: contributorBranches, | ||
| 265 | CanAllowCI: canAllowCI, | ||
| 226 | 266 | }, 0) | |
| 227 | 267 | } | |
| 228 | 268 | ||
| @@ -258,7 +298,7 @@ func (w *Web) Tree(rw http.ResponseWriter, r *http.Request) { | |||
| 258 | 298 | Path string | |
| 259 | 299 | Entries []git.Entry | |
| 260 | 300 | Crumbs []crumb | |
| 261 | }{w.base(m.Name + " · " + ref), m.Name, ref, p, entries, crumbs(p)}, 0) | ||
| 301 | }{w.page(r, m.Name+" · "+ref), m.Name, ref, p, entries, crumbs(p)}, 0) | ||
| 262 | 302 | } | |
| 263 | 303 | ||
| 264 | 304 | func (w *Web) Blob(rw http.ResponseWriter, r *http.Request) { w.blob(rw, r, false) } | |
| @@ -317,7 +357,7 @@ func (w *Web) blob(rw http.ResponseWriter, r *http.Request, raw bool) { | |||
| 317 | 357 | Truncated bool | |
| 318 | 358 | Size int64 | |
| 319 | 359 | Crumbs []crumb | |
| 320 | }{w.base(m.Name + " · " + p), m.Name, ref, p, content, rendered, binary, truncated, int64(len(data)), crumbs(p)}, 0) | ||
| 360 | }{w.page(r, m.Name+" · "+p), m.Name, ref, p, content, rendered, binary, truncated, int64(len(data)), crumbs(p)}, 0) | ||
| 321 | 361 | } | |
| 322 | 362 | ||
| 323 | 363 | func (w *Web) Commits(rw http.ResponseWriter, r *http.Request) { | |
| @@ -342,7 +382,7 @@ func (w *Web) Commits(rw http.ResponseWriter, r *http.Request) { | |||
| 342 | 382 | Name string | |
| 343 | 383 | Ref string | |
| 344 | 384 | Commits []git.Commit | |
| 345 | }{w.base(m.Name + " · history"), m.Name, ref, commits}, 0) | ||
| 385 | }{w.page(r, m.Name+" · history"), m.Name, ref, commits}, 0) | ||
| 346 | 386 | } | |
| 347 | 387 | ||
| 348 | 388 | // ---------- helpers ---------- | |