Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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 ----------