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/requests.go +51−8
| @@ -4,7 +4,9 @@ import ( | |||
| 4 | 4 | "html/template" | |
| 5 | 5 | "net/http" | |
| 6 | 6 | "sort" | |
| 7 | "strings" | ||
| 7 | 8 | ||
| 9 | "github.com/rodent-software/vole/forge/access" | ||
| 8 | 10 | "github.com/rodent-software/vole/metadata" | |
| 9 | 11 | ) | |
| 10 | 12 | ||
| @@ -38,14 +40,51 @@ func (w *Web) Requests(rw http.ResponseWriter, r *http.Request) { | |||
| 38 | 40 | } | |
| 39 | 41 | return rows[i].ID > rows[j].ID | |
| 40 | 42 | }) | |
| 43 | // The head picker is the interesting one: a contributor may only open a | ||
| 44 | // request from their own namespace, so the branches they can choose are | ||
| 45 | // filtered before the page is rendered. The handler checks again. | ||
| 46 | var branches []string | ||
| 47 | metadataBranch := strings.TrimPrefix(metadata.Ref, "refs/heads/") | ||
| 48 | if refs, err := m.Git().ListRefs("refs/heads/"); err == nil { | ||
| 49 | for ref := range refs { | ||
| 50 | name := strings.TrimPrefix(ref, "refs/heads/") | ||
| 51 | if name == "" || name == metadataBranch { | ||
| 52 | continue | ||
| 53 | } | ||
| 54 | branches = append(branches, name) | ||
| 55 | } | ||
| 56 | sort.Strings(branches) | ||
| 57 | } | ||
| 58 | baseBranches := branches | ||
| 59 | headBranches := branches | ||
| 60 | accountID := w.accountID(r) | ||
| 61 | acl, _ := m.ACL() | ||
| 62 | canOpen := acl != nil && accountID != "" && acl.CanOpenRequest(accountID) | ||
| 63 | if acl != nil && acl.RoleFor(accountID) == access.Contributor { | ||
| 64 | headBranches = nil | ||
| 65 | for _, name := range branches { | ||
| 66 | if access.IsContributorRef(accountID, "refs/heads/"+name) { | ||
| 67 | headBranches = append(headBranches, name) | ||
| 68 | } | ||
| 69 | } | ||
| 70 | } | ||
| 71 | base := m.DefaultBranch | ||
| 41 | 72 | w.render(rw, "requests", struct { | |
| 42 | 73 | Page | |
| 43 | Name string | ||
| 44 | Rows []*metadata.Request | ||
| 45 | State string | ||
| 46 | Open int | ||
| 47 | Total int | ||
| 48 | }{Page: w.base(m.Name + " · change requests"), Name: m.Name, Rows: rows, State: want, Open: open, Total: len(list)}, 0) | ||
| 74 | Name string | ||
| 75 | Rows []*metadata.Request | ||
| 76 | State string | ||
| 77 | Open int | ||
| 78 | Total int | ||
| 79 | CanOpen bool | ||
| 80 | Base string | ||
| 81 | BaseBranches []string | ||
| 82 | HeadBranches []string | ||
| 83 | }{ | ||
| 84 | Page: w.page(r, m.Name+" · change requests"), Name: m.Name, Rows: rows, State: want, | ||
| 85 | Open: open, Total: len(list), CanOpen: canOpen, Base: base, | ||
| 86 | BaseBranches: baseBranches, HeadBranches: headBranches, | ||
| 87 | }, 0) | ||
| 49 | 88 | } | |
| 50 | 89 | ||
| 51 | 90 | // RequestView renders one change request: what it would land, what changed, and | |
| @@ -236,6 +275,8 @@ func (w *Web) RequestView(rw http.ResponseWriter, r *http.Request) { | |||
| 236 | 275 | current++ | |
| 237 | 276 | } | |
| 238 | 277 | } | |
| 278 | acl, _ := m.ACL() | ||
| 279 | canApprove := acl != nil && req.Open() && w.canApprove(r, acl, req.AuthorID) | ||
| 239 | 280 | ||
| 240 | 281 | w.render(rw, "request", struct { | |
| 241 | 282 | Page | |
| @@ -254,9 +295,11 @@ func (w *Web) RequestView(rw http.ResponseWriter, r *http.Request) { | |||
| 254 | 295 | MergeChecked bool | |
| 255 | 296 | Required int | |
| 256 | 297 | Current int | |
| 257 | }{Page: w.base(m.Name + " · " + req.Title), Name: m.Name, Request: req, | ||
| 298 | CanApprove bool | ||
| 299 | }{Page: w.page(r, m.Name+" · "+req.Title), Name: m.Name, Request: req, | ||
| 258 | 300 | Description: w.renderMarkdown(m.Name, req.Description), Approvals: approvals, Checks: checks, | |
| 259 | 301 | Files: files, Added: added, Removed: removed, Truncated: truncated, AlreadyMerged: alreadyMerged, | |
| 260 | 302 | Comments: comments, | |
| 261 | Conflicts: conflicts, MergeChecked: mergeChecked, Required: required, Current: current}, 0) | ||
| 303 | Conflicts: conflicts, MergeChecked: mergeChecked, Required: required, Current: current, | ||
| 304 | CanApprove: canApprove}, 0) | ||
| 262 | 305 | } | |