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/ci.go +57
| @@ -0,0 +1,57 @@ | |||
| 1 | package web | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "net/http" | ||
| 5 | "strings" | ||
| 6 | |||
| 7 | "github.com/rodent-software/vole/forge/access" | ||
| 8 | ) | ||
| 9 | |||
| 10 | // AllowContributorCI records a maintainer's decision that the current tip of one | ||
| 11 | // contributor branch may run. The approval is per commit: a new push waits again. | ||
| 12 | // The queue-side operation is supplied by the server (Web.AllowCI), because the | ||
| 13 | // web package does not own workflow discovery or the job queue. | ||
| 14 | func (w *Web) AllowContributorCI(rw http.ResponseWriter, r *http.Request) { | ||
| 15 | pr, ok := w.requireHTMLUser(rw, r) | ||
| 16 | if !ok { | ||
| 17 | return | ||
| 18 | } | ||
| 19 | r.Body = http.MaxBytesReader(rw, r.Body, 64<<10) | ||
| 20 | if !w.checkCSRF(r) { | ||
| 21 | http.Error(rw, "bad request", http.StatusForbidden) | ||
| 22 | return | ||
| 23 | } | ||
| 24 | m, ok := w.open(rw, r) | ||
| 25 | if !ok { | ||
| 26 | return | ||
| 27 | } | ||
| 28 | acl, err := m.ACL() | ||
| 29 | if err != nil || !acl.CanApprove(pr.account.ID) { | ||
| 30 | http.Error(rw, "only a maintainer or admin may allow CI", http.StatusForbidden) | ||
| 31 | return | ||
| 32 | } | ||
| 33 | if err := r.ParseForm(); err != nil { | ||
| 34 | http.Error(rw, "cannot read the form", http.StatusBadRequest) | ||
| 35 | return | ||
| 36 | } | ||
| 37 | ref := strings.TrimSpace(r.PostFormValue("ref")) | ||
| 38 | if _, ok := access.ContributorRefAccount(ref); !ok { | ||
| 39 | http.Error(rw, "that is not a contributor branch", http.StatusBadRequest) | ||
| 40 | return | ||
| 41 | } | ||
| 42 | tip, err := m.Git().RevParseRaw(ref) | ||
| 43 | if err != nil || tip == "" { | ||
| 44 | http.Error(rw, "that branch does not exist", http.StatusNotFound) | ||
| 45 | return | ||
| 46 | } | ||
| 47 | if w.AllowCI == nil { | ||
| 48 | http.Error(rw, "CI is not wired into this server", http.StatusInternalServerError) | ||
| 49 | return | ||
| 50 | } | ||
| 51 | if err := w.AllowCI(m.Name, ref, tip, pr.account.ID); err != nil { | ||
| 52 | http.Error(rw, err.Error(), http.StatusConflict) | ||
| 53 | return | ||
| 54 | } | ||
| 55 | w.audit("web.ci.allow", pr.account.ID, m.Name+" "+ref+" "+tip) | ||
| 56 | http.Redirect(rw, r, "/r/"+m.Name, http.StatusSeeOther) | ||
| 57 | } | ||