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/writes.go +243

@@ -0,0 +1,243 @@
1 package web
2
3 // The first browser write path. It is deliberately narrow: an issue, an issue
4 // comment and a review approval. Each one is built by the same metadata writer
5 // the CLI uses, so the file format and its validation cannot drift. The
6 // pre-receive hook still governs pushes; these handlers check the same ACL
7 // capability before they call a writer.
8 //
9 // Every author and approval is taken from the session account, never from a form
10 // field. That is the whole point of making the browser authenticate.
11
12 import (
13 "fmt"
14 "net/http"
15 "os"
16 "strings"
17 "time"
18
19 "github.com/rodent-software/vole/forge/access"
20 "github.com/rodent-software/vole/metadata"
21 )
22
23 func (w *Web) CreateIssue(rw http.ResponseWriter, r *http.Request) {
24 pr, ok := w.requireHTMLUser(rw, r)
25 if !ok {
26 return
27 }
28 r.Body = http.MaxBytesReader(rw, r.Body, 2<<20)
29 if !w.checkCSRF(r) {
30 http.Error(rw, "bad request", http.StatusForbidden)
31 return
32 }
33 m, ok := w.open(rw, r)
34 if !ok {
35 return
36 }
37 acl, err := m.ACL()
38 if err != nil || !acl.CanDiscuss(pr.account.ID) {
39 http.Error(rw, "you do not have permission to file issues here", http.StatusForbidden)
40 return
41 }
42 if err := r.ParseForm(); err != nil {
43 http.Error(rw, "cannot read the form", http.StatusBadRequest)
44 return
45 }
46 title := strings.TrimSpace(r.PostFormValue("title"))
47 body := strings.TrimSpace(r.PostFormValue("body"))
48 if title == "" {
49 http.Error(rw, "an issue needs a title", http.StatusBadRequest)
50 return
51 }
52 now := time.Now().UTC()
53 iss, err := metadata.NewIssue(title, body, pr.account.Name, nil, nil, now)
54 if err != nil {
55 http.Error(rw, err.Error(), http.StatusBadRequest)
56 return
57 }
58 if err := metadata.CreateIssue(m.Git(), iss, pr.account.Name); err != nil {
59 http.Error(rw, "cannot create the issue: "+err.Error(), http.StatusInternalServerError)
60 return
61 }
62 w.audit("web.issue.create", pr.account.ID, m.Name+" "+iss.ID)
63 if problems := metadata.ValidateIssues(m.Git(), metadata.Ref); len(problems) > 0 {
64 w.Logger("web: issue %s left the metadata tree invalid: %v", iss.ID, problems)
65 }
66 http.Redirect(rw, r, "/r/"+m.Name+"/issues/"+iss.ID, http.StatusSeeOther)
67 }
68
69 func (w *Web) AddIssueComment(rw http.ResponseWriter, r *http.Request) {
70 pr, ok := w.requireHTMLUser(rw, r)
71 if !ok {
72 return
73 }
74 r.Body = http.MaxBytesReader(rw, r.Body, 2<<20)
75 if !w.checkCSRF(r) {
76 http.Error(rw, "bad request", http.StatusForbidden)
77 return
78 }
79 m, ok := w.open(rw, r)
80 if !ok {
81 return
82 }
83 acl, err := m.ACL()
84 if err != nil || !acl.CanDiscuss(pr.account.ID) {
85 http.Error(rw, "you do not have permission to comment here", http.StatusForbidden)
86 return
87 }
88 id := r.PathValue("id")
89 if err := r.ParseForm(); err != nil {
90 http.Error(rw, "cannot read the form", http.StatusBadRequest)
91 return
92 }
93 body := strings.TrimSpace(r.PostFormValue("body"))
94 if body == "" {
95 http.Error(rw, "a comment needs a body", http.StatusBadRequest)
96 return
97 }
98 now := time.Now().UTC()
99 c, err := metadata.NewIssueComment(body, pr.account.Name, now)
100 if err != nil {
101 http.Error(rw, err.Error(), http.StatusBadRequest)
102 return
103 }
104 if err := metadata.AddIssueComment(m.Git(), id, c, pr.account.Name); err != nil {
105 http.Error(rw, "cannot add the comment: "+err.Error(), http.StatusBadRequest)
106 return
107 }
108 w.audit("web.issue.comment", pr.account.ID, m.Name+" "+id+" "+c.ID)
109 if problems := metadata.ValidateIssues(m.Git(), metadata.Ref); len(problems) > 0 {
110 w.Logger("web: comment %s left the metadata tree invalid: %v", c.ID, problems)
111 }
112 http.Redirect(rw, r, "/r/"+m.Name+"/issues/"+id+"#c-"+c.ID, http.StatusSeeOther)
113 }
114
115 func (w *Web) ApproveRequest(rw http.ResponseWriter, r *http.Request) {
116 pr, ok := w.requireHTMLUser(rw, r)
117 if !ok {
118 return
119 }
120 r.Body = http.MaxBytesReader(rw, r.Body, 2<<20)
121 if !w.checkCSRF(r) {
122 http.Error(rw, "bad request", http.StatusForbidden)
123 return
124 }
125 m, ok := w.open(rw, r)
126 if !ok {
127 return
128 }
129 acl, err := m.ACL()
130 if err != nil || !acl.CanApprove(pr.account.ID) {
131 http.Error(rw, "you do not have permission to approve here", http.StatusForbidden)
132 return
133 }
134 id := r.PathValue("id")
135 req, err := metadata.GetRequest(m.Git(), id)
136 if err != nil {
137 http.NotFound(rw, r)
138 return
139 }
140 if !req.Open() {
141 http.Error(rw, "this change request is not open", http.StatusBadRequest)
142 return
143 }
144 if req.AuthorID == pr.account.ID {
145 http.Error(rw, "you cannot approve your own change request", http.StatusForbidden)
146 return
147 }
148 if _, err := metadata.ApproveRequest(m.Git(), id, pr.account.ID, time.Now().UTC(), pr.account.Name); err != nil {
149 http.Error(rw, "cannot approve: "+err.Error(), http.StatusBadRequest)
150 return
151 }
152 w.audit("web.request.approve", pr.account.ID, m.Name+" "+id+" "+req.Head)
153 if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 {
154 w.Logger("web: approval left the request tree invalid: %v", problems)
155 }
156 http.Redirect(rw, r, "/r/"+m.Name+"/requests/"+id, http.StatusSeeOther)
157 }
158
159 // audit writes the same tab-separated shape the SSH hook and identity store use.
160 func (w *Web) audit(action, accountID, detail string) {
161 if w.Cfg == nil || w.Cfg.AuditLog() == "" {
162 return
163 }
164 f, err := os.OpenFile(w.Cfg.AuditLog(), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
165 if err != nil {
166 return
167 }
168 defer f.Close()
169 detail = strings.NewReplacer("\n", " ", "\r", " ", "\t", " ").Replace(detail)
170 fmt.Fprintf(f, "%s\t%s\t%s\t%s\n", time.Now().UTC().Format(time.RFC3339), action, accountID, detail)
171 }
172
173 // CreateChangeRequest is the browser counterpart of `vole request new`. The head
174 // is a branch, not a free-form SHA, so a contributor cannot name a commit they
175 // did not push. For a contributor the branch must be inside their own namespace;
176 // for write and admin it may be any normal branch.
177 func (w *Web) CreateChangeRequest(rw http.ResponseWriter, r *http.Request) {
178 pr, ok := w.requireHTMLUser(rw, r)
179 if !ok {
180 return
181 }
182 r.Body = http.MaxBytesReader(rw, r.Body, 2<<20)
183 if !w.checkCSRF(r) {
184 http.Error(rw, "bad request", http.StatusForbidden)
185 return
186 }
187 m, ok := w.open(rw, r)
188 if !ok {
189 return
190 }
191 acl, err := m.ACL()
192 if err != nil || !acl.CanOpenRequest(pr.account.ID) {
193 http.Error(rw, "you do not have permission to open change requests here", http.StatusForbidden)
194 return
195 }
196 if err := r.ParseForm(); err != nil {
197 http.Error(rw, "cannot read the form", http.StatusBadRequest)
198 return
199 }
200 title := strings.TrimSpace(r.PostFormValue("title"))
201 body := strings.TrimSpace(r.PostFormValue("body"))
202 base := strings.TrimPrefix(strings.TrimSpace(r.PostFormValue("base")), "refs/heads/")
203 head := strings.TrimPrefix(strings.TrimSpace(r.PostFormValue("head")), "refs/heads/")
204 metadataBranch := strings.TrimPrefix(metadata.Ref, "refs/heads/")
205 if title == "" {
206 http.Error(rw, "a change request needs a title", http.StatusBadRequest)
207 return
208 }
209 if base == "" || base == metadataBranch || head == "" || head == metadataBranch {
210 http.Error(rw, "choose a base and a head branch", http.StatusBadRequest)
211 return
212 }
213 headRef := "refs/heads/" + head
214 if acl.RoleFor(pr.account.ID) == access.Contributor {
215 if !access.IsContributorRef(pr.account.ID, headRef) {
216 http.Error(rw, "contributors may only open requests from their own branch namespace", http.StatusForbidden)
217 return
218 }
219 }
220 headCommit, err := m.Git().RevParse(headRef)
221 if err != nil || headCommit == "" {
222 http.Error(rw, "that head branch does not exist yet; push it first", http.StatusBadRequest)
223 return
224 }
225 if tip, err := m.Git().RevParse("refs/heads/" + base); err != nil || tip == "" {
226 http.Error(rw, "that base branch does not exist", http.StatusBadRequest)
227 return
228 }
229 req, err := metadata.NewRequest(title, body, pr.account.Name, pr.account.ID, base, headCommit, time.Now().UTC())
230 if err != nil {
231 http.Error(rw, err.Error(), http.StatusBadRequest)
232 return
233 }
234 if err := metadata.CreateRequest(m.Git(), req, pr.account.Name); err != nil {
235 http.Error(rw, "cannot open the change request: "+err.Error(), http.StatusInternalServerError)
236 return
237 }
238 w.audit("web.request.create", pr.account.ID, m.Name+" "+req.ID+" "+headCommit)
239 if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 {
240 w.Logger("web: request %s left the request tree invalid: %v", req.ID, problems)
241 }
242 http.Redirect(rw, r, "/r/"+m.Name+"/requests/"+req.ID, http.StatusSeeOther)
243 }