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.

Reorganise the web package around one implementation per rule

c717d4eb92b4257b05fa065918b0071f6302f53e · Keenan Nemetz · 14d ago · parent 4506a4d3

Reorganise the web package around one implementation per rule

server/web had grown by feature, so several rules and widgets existed twice: the
comment stack for issues and change requests, the pager, the file tree, the write
preamble, and the ACL predicates. Each pair agreed today and would drift
tomorrow. No route, stored format, or permission rule changes; the few behaviour
differences found along the way are listed at the end.

Files now say what they answer:

  web.go          the Web type, render, open, the error pages
  page.go         Page, pageSection, who, nav
  templatefuncs.go the template function map, with the escaping rules noted
  ui.go           relative time, avatars, breadcrumbs, the pager
  markdown.go     the goldmark and bluemonday boundary
  codebrowser.go  the code browser, out of web.go
  tokens.go       the label/assignee token input
  rolepicker.go   the role control, out of admin.go
  comments.go     the comment view, thread builder, action menus, snippet
  comment_writes.go  add, edit and delete a comment, for both surfaces
  write_context.go   beginWrite/beginPost/beginAdminPost, fail, refuse
  capabilities.go    the ACL rules a page and a handler share
  audit.go        the audit log
  request_view.go the change conversation page, out of requests.go
  admin_access.go the instance-admin access rules, out of settings.go

A comment is one thing, so it has one view and one thread builder: metadata.Comment
is the same type on both surfaces and only a change request can anchor one to a
diff line. Adding, editing and deleting are one implementation each, with a
commentTopic naming what differs per surface. The pager is one control for issues,
changes and the instance feed; the file tree is one widget for the code browser
and a change's Files tab.

Every write starts at beginWrite (session, body bound, CSRF, repository, rules,
form) and asks capabilities.go for the one rule it needs, so the control a page
draws and the check a handler makes cannot disagree. A rejected write answers
through refuse; a write that fails for another reason answers through fail, which
re-renders the section for HTMX and returns text otherwise.

Templates follow the same split: chrome.html, components.html, diff.html and
role-picker.html hold what more than one surface renders, and each page owns its
fragments. The route table moved to routes.go, one function per surface.

Behaviour changes found while unifying:

  - A reply cycle (two comments answering each other, possible through a pushed
    metadata branch) used to drop both comments from the page; anything the walk
    does not reach is now emitted as a root.
  - A rejected anchored comment keeps its "Commenting on path:line" note and
    hidden anchor fields, so the reviewer does not have to pick the line again.
  - A repository whose access rules cannot be read answers 500 rather than 403;
    the account was being blamed for a broken instance.
  - Pager wording is now "Showing a-b of n / page x of y" with Previous/Next
    everywhere, and the instance feed's page links keep their kind filter.
  - The activity feed, issue list and change list share one page control.

Docs: docs/ui-plan.md gains a "Where the code lives" map, the write gate, the
shared comment model, the route table, and rules for rejected writes, shared
template fragments, and the stylesheet's section order. The passkey script is now
an ordinary asset (static/auth.js) under the same CSP as the rest.

server/web/writes.go +68−652

@@ -1,20 +1,18 @@
1 1 package web
2 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.
3 // The browser's issue and change-request writes: filing, triaging, commenting,
4 // approving, and closing. Each one is built by the same metadata writer the CLI
5 // uses, so the file format and its validation cannot drift. The pre-receive hook
6 // still governs pushes; these handlers check the same ACL capability before they
7 // call a writer (capabilities.go).
8 8 //
9 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.
10 // field. That is the whole point of making the browser authenticate. The gate that
11 // establishes it — session, body bound, CSRF, repository, rules — is beginWrite.
11 12
12 13 import (
13 14 "errors"
14 "fmt"
15 15 "net/http"
16 "os"
17 "strconv"
18 16 "strings"
19 17 "time"
20 18
@@ -24,26 +22,13 @@ import (
24 22 )
25 23
26 24 func (w *Web) CreateIssue(rw http.ResponseWriter, r *http.Request) {
27 pr, ok := w.requireHTMLUser(rw, r)
25 ctx, ok := w.beginWrite(rw, r, maxFormBody)
28 26 if !ok {
29 27 return
30 28 }
31 r.Body = http.MaxBytesReader(rw, r.Body, 2<<20)
32 if !w.checkCSRF(r) {
33 http.Error(rw, "bad request", http.StatusForbidden)
34 return
35 }
36 m, ok := w.open(rw, r)
37 if !ok {
38 return
39 }
40 acl, err := m.ACL()
41 if err != nil || !acl.CanDiscuss(pr.account.Name) {
42 http.Error(rw, "you do not have permission to file issues here", http.StatusForbidden)
43 return
44 }
45 if err := r.ParseForm(); err != nil {
46 http.Error(rw, "cannot read the form", http.StatusBadRequest)
29 m := ctx.Repo
30 if !ctx.canDiscuss() {
31 w.refuse(rw, r, "you do not have permission to file issues here")
47 32 return
48 33 }
49 34 title := strings.TrimSpace(r.PostFormValue("title"))
@@ -59,16 +44,16 @@ func (w *Web) CreateIssue(rw http.ResponseWriter, r *http.Request) {
59 44 return
60 45 }
61 46 now := time.Now().UTC()
62 iss, err := metadata.NewIssue(title, body, pr.account.Name, labels, assignees, now)
47 iss, err := metadata.NewIssue(title, body, ctx.Name, labels, assignees, now)
63 48 if err != nil {
64 49 http.Error(rw, err.Error(), http.StatusBadRequest)
65 50 return
66 51 }
67 if err := metadata.CreateIssue(m.Git(), iss, pr.account.Name); err != nil {
52 if err := metadata.CreateIssue(m.Git(), iss, ctx.Name); err != nil {
68 53 w.serverError(rw, r, "cannot create the issue: "+err.Error())
69 54 return
70 55 }
71 w.audit("web.issue.create", pr.account.Name, m.Name+" "+iss.ID)
56 w.audit("web.issue.create", ctx.Name, m.Name+" "+iss.ID)
72 57 if problems := metadata.ValidateIssues(m.Git(), metadata.Ref); len(problems) > 0 {
73 58 w.Logger("web: issue %s left the metadata tree invalid: %v", iss.ID, problems)
74 59 }
@@ -79,33 +64,25 @@ func (w *Web) CreateIssue(rw http.ResponseWriter, r *http.Request) {
79 64 // author may label their own issue; a maintainer may label any issue as triage.
80 65 // The form sends existing labels as checkboxes and new ones as free text.
81 66 func (w *Web) SetIssueLabels(rw http.ResponseWriter, r *http.Request) {
82 pr, ok := w.requireHTMLUser(rw, r)
83 if !ok {
84 return
85 }
86 r.Body = http.MaxBytesReader(rw, r.Body, 1<<20)
87 if !w.checkCSRF(r) {
88 http.Error(rw, "bad request", http.StatusForbidden)
89 return
90 }
91 m, ok := w.open(rw, r)
67 ctx, ok := w.beginWrite(rw, r, maxSmallForm)
92 68 if !ok {
93 69 return
94 70 }
71 m := ctx.Repo
95 72 id := r.PathValue("id")
96 73 iss, err := index.LoadIssue(m, id)
97 74 if err != nil {
98 75 w.notFound(rw, r)
99 76 return
100 77 }
101 acl, err := m.ACL()
102 if err != nil || (!acl.CanTriage(pr.account.Name) && iss.Author != pr.account.Name) {
103 http.Error(rw, "you do not have permission to edit these labels", http.StatusForbidden)
78 if !ctx.canTriage(iss.Author) {
79 w.refuse(rw, r, "you do not have permission to edit these labels")
104 80 return
105 81 }
106 if err := r.ParseForm(); err != nil {
107 http.Error(rw, "cannot read the form", http.StatusBadRequest)
108 return
82 // A rejected write re-renders the sidebar section the control belongs to, so
83 // the reason appears beside it instead of replacing the page with text.
84 labelsView := func(msg string) (string, any) {
85 return "issue_labels", w.issueSidebar(r, m, iss, "", msg)
109 86 }
110 87 labels := normalizeLabelSet(append([]string{r.PostFormValue("labels")}, r.PostForm["label"]...))
111 88 updated, err := metadata.UpdateIssue(m.Git(), id, func(iss *metadata.Issue) error {
@@ -114,16 +91,12 @@ func (w *Web) SetIssueLabels(rw http.ResponseWriter, r *http.Request) {
114 91 }
115 92 iss.Labels = labels
116 93 return nil
117 }, pr.account.Name)
94 }, ctx.Name)
118 95 if err != nil && !errors.Is(err, metadata.ErrNoChange) {
119 if htmxRequest(r) {
120 w.render(rw, "issue_labels", w.issueSidebar(r, m, iss, "", err.Error()), 0)
121 return
122 }
123 http.Error(rw, "cannot update the labels: "+err.Error(), http.StatusBadRequest)
96 w.fail(rw, r, http.StatusBadRequest, "cannot update the labels: "+err.Error(), labelsView)
124 97 return
125 98 }
126 w.audit("web.issue.labels", pr.account.Name, m.Name+" "+id+" "+strings.Join(labels, ","))
99 w.audit("web.issue.labels", ctx.Name, m.Name+" "+id+" "+strings.Join(labels, ","))
127 100 if problems := metadata.ValidateIssues(m.Git(), metadata.Ref); len(problems) > 0 {
128 101 w.Logger("web: issue labels %s left the metadata tree invalid: %v", id, problems)
129 102 }
@@ -140,44 +113,31 @@ func (w *Web) SetIssueLabels(rw http.ResponseWriter, r *http.Request) {
140 113 // SetIssueAssignees replaces an issue's assignees. Same rule as labels: the
141 114 // author may manage their own issue, a maintainer may triage any issue.
142 115 func (w *Web) SetIssueAssignees(rw http.ResponseWriter, r *http.Request) {
143 pr, ok := w.requireHTMLUser(rw, r)
144 if !ok {
145 return
146 }
147 r.Body = http.MaxBytesReader(rw, r.Body, 1<<20)
148 if !w.checkCSRF(r) {
149 http.Error(rw, "bad request", http.StatusForbidden)
150 return
151 }
152 m, ok := w.open(rw, r)
116 ctx, ok := w.beginWrite(rw, r, maxSmallForm)
153 117 if !ok {
154 118 return
155 119 }
120 m := ctx.Repo
156 121 id := r.PathValue("id")
157 122 iss, err := index.LoadIssue(m, id)
158 123 if err != nil {
159 124 w.notFound(rw, r)
160 125 return
161 126 }
162 acl, err := m.ACL()
163 if err != nil || (!acl.CanTriage(pr.account.Name) && iss.Author != pr.account.Name) {
164 http.Error(rw, "you do not have permission to edit these assignees", http.StatusForbidden)
127 if !ctx.canTriage(iss.Author) {
128 w.refuse(rw, r, "you do not have permission to edit these assignees")
165 129 return
166 130 }
167 if err := r.ParseForm(); err != nil {
168 http.Error(rw, "cannot read the form", http.StatusBadRequest)
169 return
131 assigneesView := func(msg string) (string, any) {
132 return "issue_assignees", w.issueSidebar(r, m, iss, "", msg)
170 133 }
171 134 assignees := metadata.NormalizeAssignees(append([]string{r.PostFormValue("assignees")}, r.PostForm["assignee"]...))
172 135 // A current assignee may have lost access since being assigned. Keep them in
173 136 // the allowed set so an edit can remove the others without silently dropping
174 137 // that name.
175 138 if bad := unknownNames(assignees, mergeNames(w.assignableAccounts(m), iss.Assignees)); len(bad) > 0 {
176 if htmxRequest(r) {
177 w.render(rw, "issue_assignees", w.issueSidebar(r, m, iss, "", "unknown or unavailable assignee: "+strings.Join(bad, ", ")), 0)
178 return
179 }
180 http.Error(rw, "unknown or unavailable assignee: "+strings.Join(bad, ", "), http.StatusBadRequest)
139 w.fail(rw, r, http.StatusBadRequest,
140 "unknown or unavailable assignee: "+strings.Join(bad, ", "), assigneesView)
181 141 return
182 142 }
183 143 updated, err := metadata.UpdateIssue(m.Git(), id, func(iss *metadata.Issue) error {
@@ -186,16 +146,12 @@ func (w *Web) SetIssueAssignees(rw http.ResponseWriter, r *http.Request) {
186 146 }
187 147 iss.Assignees = assignees
188 148 return nil
189 }, pr.account.Name)
149 }, ctx.Name)
190 150 if err != nil && !errors.Is(err, metadata.ErrNoChange) {
191 if htmxRequest(r) {
192 w.render(rw, "issue_assignees", w.issueSidebar(r, m, iss, "", err.Error()), 0)
193 return
194 }
195 http.Error(rw, "cannot update the assignees: "+err.Error(), http.StatusBadRequest)
151 w.fail(rw, r, http.StatusBadRequest, "cannot update the assignees: "+err.Error(), assigneesView)
196 152 return
197 153 }
198 w.audit("web.issue.assignees", pr.account.Name, m.Name+" "+id+" "+strings.Join(assignees, ","))
154 w.audit("web.issue.assignees", ctx.Name, m.Name+" "+id+" "+strings.Join(assignees, ","))
199 155 if problems := metadata.ValidateIssues(m.Git(), metadata.Ref); len(problems) > 0 {
200 156 w.Logger("web: issue assignees %s left the metadata tree invalid: %v", id, problems)
201 157 }
@@ -209,256 +165,14 @@ func (w *Web) SetIssueAssignees(rw http.ResponseWriter, r *http.Request) {
209 165 http.Redirect(rw, r, "/r/"+m.Name+"/issues/"+id, http.StatusSeeOther)
210 166 }
211 167
212 func (w *Web) AddIssueComment(rw http.ResponseWriter, r *http.Request) {
213 pr, ok := w.requireHTMLUser(rw, r)
214 if !ok {
215 return
216 }
217 r.Body = http.MaxBytesReader(rw, r.Body, 2<<20)
218 if !w.checkCSRF(r) {
219 http.Error(rw, "bad request", http.StatusForbidden)
220 return
221 }
222 m, ok := w.open(rw, r)
223 if !ok {
224 return
225 }
226 acl, err := m.ACL()
227 if err != nil || !acl.CanDiscuss(pr.account.Name) {
228 http.Error(rw, "you do not have permission to comment here", http.StatusForbidden)
229 return
230 }
231 id := r.PathValue("id")
232 iss, err := index.LoadIssue(m, id)
233 if err != nil {
234 w.notFound(rw, r)
235 return
236 }
237 if err := r.ParseForm(); err != nil {
238 http.Error(rw, "cannot read the form", http.StatusBadRequest)
239 return
240 }
241 body := strings.TrimSpace(r.PostFormValue("body"))
242 replyTo := strings.TrimSpace(r.PostFormValue("reply_to"))
243 if body == "" {
244 if htmxRequest(r) {
245 w.render(rw, "issue_thread", w.issueThread(r, m, iss, "a comment needs a body"), 0)
246 return
247 }
248 http.Error(rw, "a comment needs a body", http.StatusBadRequest)
249 return
250 }
251 now := time.Now().UTC()
252 c, err := metadata.NewIssueComment(body, pr.account.Name, now)
253 if err != nil {
254 if htmxRequest(r) {
255 w.render(rw, "issue_thread", w.issueThread(r, m, iss, err.Error()), 0)
256 return
257 }
258 http.Error(rw, err.Error(), http.StatusBadRequest)
259 return
260 }
261 c.ReplyTo = replyTo
262 if err := metadata.AddIssueComment(m.Git(), id, c, pr.account.Name); err != nil {
263 if htmxRequest(r) {
264 w.render(rw, "issue_thread", w.issueThread(r, m, iss, "cannot add the comment: "+err.Error()), 0)
265 return
266 }
267 http.Error(rw, "cannot add the comment: "+err.Error(), http.StatusBadRequest)
268 return
269 }
270 w.audit("web.issue.comment", pr.account.Name, m.Name+" "+id+" "+c.ID)
271 if problems := metadata.ValidateIssues(m.Git(), metadata.Ref); len(problems) > 0 {
272 w.Logger("web: comment %s left the metadata tree invalid: %v", c.ID, problems)
273 }
274 if htmxRequest(r) {
275 updated, err := index.LoadIssue(m, id)
276 if err != nil {
277 w.render(rw, "issue_thread", w.issueThread(r, m, iss, err.Error()), 0)
278 return
279 }
280 w.render(rw, "issue_thread", w.issueThread(r, m, updated, ""), 0)
281 return
282 }
283 http.Redirect(rw, r, "/r/"+m.Name+"/issues/"+id+"#c-"+c.ID, http.StatusSeeOther)
284 }
285
286 func canManageComment(pr *principal, acl *access.ACL, author string) bool {
287 if pr == nil {
288 return false
289 }
290 if pr.account.Name == author {
291 return true
292 }
293 return acl != nil && acl.RoleFor(pr.account.Name).AtLeast(access.Admin)
294 }
295
296 func issueCommentByID(iss *metadata.Issue, id string) *metadata.Comment {
297 for i := range iss.Comments {
298 if iss.Comments[i].ID == id {
299 return &iss.Comments[i]
300 }
301 }
302 return nil
303 }
304
305 func requestCommentByID(req *metadata.Request, id string) *metadata.Comment {
306 for i := range req.Comments {
307 if req.Comments[i].ID == id {
308 return &req.Comments[i]
309 }
310 }
311 return nil
312 }
313
314 // EditIssueComment rewrites one comment. Only its author, or an admin repairing
315 // a thread, may do so.
316 func (w *Web) EditIssueComment(rw http.ResponseWriter, r *http.Request) {
317 pr, ok := w.requireHTMLUser(rw, r)
318 if !ok {
319 return
320 }
321 r.Body = http.MaxBytesReader(rw, r.Body, 2<<20)
322 if !w.checkCSRF(r) {
323 http.Error(rw, "bad request", http.StatusForbidden)
324 return
325 }
326 m, ok := w.open(rw, r)
327 if !ok {
328 return
329 }
330 id, commentID := r.PathValue("id"), r.PathValue("comment")
331 iss, err := index.LoadIssue(m, id)
332 if err != nil {
333 w.notFound(rw, r)
334 return
335 }
336 c := issueCommentByID(iss, commentID)
337 if c == nil {
338 w.notFound(rw, r)
339 return
340 }
341 acl, _ := m.ACL()
342 if !canManageComment(pr, acl, c.Author) {
343 http.Error(rw, "only the author or an admin may edit this comment", http.StatusForbidden)
344 return
345 }
346 if err := r.ParseForm(); err != nil {
347 http.Error(rw, "cannot read the form", http.StatusBadRequest)
348 return
349 }
350 body := strings.TrimSpace(r.PostFormValue("body"))
351 if body == "" {
352 if htmxRequest(r) {
353 w.render(rw, "issue_thread", w.issueThread(r, m, iss, "a comment needs a body"), 0)
354 return
355 }
356 http.Error(rw, "a comment needs a body", http.StatusBadRequest)
357 return
358 }
359 if _, err := metadata.EditIssueComment(m.Git(), id, commentID, body, pr.account.Name); err != nil {
360 if htmxRequest(r) {
361 w.render(rw, "issue_thread", w.issueThread(r, m, iss, "cannot edit the comment: "+err.Error()), 0)
362 return
363 }
364 http.Error(rw, "cannot edit the comment: "+err.Error(), http.StatusBadRequest)
365 return
366 }
367 detail := m.Name + " " + id + " " + commentID
368 if pr.account.Name != c.Author {
369 detail += " (author " + c.Author + ", admin override)"
370 }
371 w.audit("web.issue.comment.edit", pr.account.Name, detail)
372 if problems := metadata.ValidateIssues(m.Git(), metadata.Ref); len(problems) > 0 {
373 w.Logger("web: edit comment %s left the metadata tree invalid: %v", commentID, problems)
374 }
375 if htmxRequest(r) {
376 updated, err := index.LoadIssue(m, id)
377 if err != nil {
378 w.render(rw, "issue_thread", w.issueThread(r, m, iss, err.Error()), 0)
379 return
380 }
381 w.render(rw, "issue_thread", w.issueThread(r, m, updated, ""), 0)
382 return
383 }
384 http.Redirect(rw, r, "/r/"+m.Name+"/issues/"+id+"#c-"+commentID, http.StatusSeeOther)
385 }
386
387 // DeleteIssueComment replaces a comment with its tombstone.
388 func (w *Web) DeleteIssueComment(rw http.ResponseWriter, r *http.Request) {
389 pr, ok := w.requireHTMLUser(rw, r)
390 if !ok {
391 return
392 }
393 if !w.checkCSRF(r) {
394 http.Error(rw, "bad request", http.StatusForbidden)
395 return
396 }
397 m, ok := w.open(rw, r)
398 if !ok {
399 return
400 }
401 id, commentID := r.PathValue("id"), r.PathValue("comment")
402 iss, err := index.LoadIssue(m, id)
403 if err != nil {
404 w.notFound(rw, r)
405 return
406 }
407 c := issueCommentByID(iss, commentID)
408 if c == nil {
409 w.notFound(rw, r)
410 return
411 }
412 acl, _ := m.ACL()
413 if !canManageComment(pr, acl, c.Author) {
414 http.Error(rw, "only the author or an admin may delete this comment", http.StatusForbidden)
415 return
416 }
417 if _, err := metadata.DeleteIssueComment(m.Git(), id, commentID, pr.account.Name); err != nil {
418 if htmxRequest(r) {
419 w.render(rw, "issue_thread", w.issueThread(r, m, iss, "cannot delete the comment: "+err.Error()), 0)
420 return
421 }
422 http.Error(rw, "cannot delete the comment: "+err.Error(), http.StatusBadRequest)
423 return
424 }
425 detail := m.Name + " " + id + " " + commentID
426 if pr.account.Name != c.Author {
427 detail += " (author " + c.Author + ", admin override)"
428 }
429 w.audit("web.issue.comment.delete", pr.account.Name, detail)
430 if problems := metadata.ValidateIssues(m.Git(), metadata.Ref); len(problems) > 0 {
431 w.Logger("web: delete comment %s left the metadata tree invalid: %v", commentID, problems)
432 }
433 if htmxRequest(r) {
434 updated, err := index.LoadIssue(m, id)
435 if err != nil {
436 w.render(rw, "issue_thread", w.issueThread(r, m, iss, err.Error()), 0)
437 return
438 }
439 w.render(rw, "issue_thread", w.issueThread(r, m, updated, ""), 0)
440 return
441 }
442 http.Redirect(rw, r, "/r/"+m.Name+"/issues/"+id+"#c-"+commentID, http.StatusSeeOther)
443 }
444
445 168 func (w *Web) ApproveRequest(rw http.ResponseWriter, r *http.Request) {
446 pr, ok := w.requireHTMLUser(rw, r)
447 if !ok {
448 return
449 }
450 r.Body = http.MaxBytesReader(rw, r.Body, 2<<20)
451 if !w.checkCSRF(r) {
452 http.Error(rw, "bad request", http.StatusForbidden)
453 return
454 }
455 m, ok := w.open(rw, r)
169 ctx, ok := w.beginWrite(rw, r, maxFormBody)
456 170 if !ok {
457 171 return
458 172 }
459 acl, err := m.ACL()
460 if err != nil || !acl.CanApprove(pr.account.Name) {
461 http.Error(rw, "you do not have permission to approve here", http.StatusForbidden)
173 m := ctx.Repo
174 if !ctx.canApproveChange() {
175 w.refuse(rw, r, "you do not have permission to approve here")
462 176 return
463 177 }
464 178 id := r.PathValue("id")
@@ -471,19 +185,18 @@ func (w *Web) ApproveRequest(rw http.ResponseWriter, r *http.Request) {
471 185 http.Error(rw, "this change request is not open", http.StatusBadRequest)
472 186 return
473 187 }
474 if req.Author == pr.account.Name {
188 if req.Author == ctx.Name {
475 189 http.Error(rw, "you cannot approve your own change request", http.StatusForbidden)
476 190 return
477 191 }
478 if _, err := metadata.ApproveRequest(m.Git(), id, pr.account.Name, time.Now().UTC(), pr.account.Name); err != nil {
479 if htmxRequest(r) {
480 w.render(rw, "request_sidebar", w.requestSidebar(r, m, req, "", err.Error()), 0)
481 return
482 }
483 http.Error(rw, "cannot approve: "+err.Error(), http.StatusBadRequest)
192 sidebar := func(msg string) (string, any) {
193 return "request_sidebar", w.requestSidebar(r, m, req, "", msg)
194 }
195 if _, err := metadata.ApproveRequest(m.Git(), id, ctx.Name, time.Now().UTC(), ctx.Name); err != nil {
196 w.fail(rw, r, http.StatusBadRequest, "cannot approve: "+err.Error(), sidebar)
484 197 return
485 198 }
486 w.audit("web.request.approve", pr.account.Name, m.Name+" "+id+" "+req.Head)
199 w.audit("web.request.approve", ctx.Name, m.Name+" "+id+" "+req.Head)
487 200 if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 {
488 201 w.Logger("web: approval left the request tree invalid: %v", problems)
489 202 }
@@ -499,45 +212,18 @@ func (w *Web) ApproveRequest(rw http.ResponseWriter, r *http.Request) {
499 212 http.Redirect(rw, r, "/r/"+m.Name+"/requests/"+id, http.StatusSeeOther)
500 213 }
501 214
502 // audit writes the same tab-separated shape the SSH hook and identity store use.
503 func (w *Web) audit(action, accountID, detail string) {
504 if w.Cfg == nil || w.Cfg.AuditLog() == "" {
505 return
506 }
507 f, err := os.OpenFile(w.Cfg.AuditLog(), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
508 if err != nil {
509 return
510 }
511 defer f.Close()
512 detail = strings.NewReplacer("\n", " ", "\r", " ", "\t", " ").Replace(detail)
513 fmt.Fprintf(f, "%s\t%s\t%s\t%s\n", time.Now().UTC().Format(time.RFC3339), action, accountID, detail)
514 }
515
516 215 // CreateChangeRequest is the browser counterpart of `vole change new`. The head
517 216 // is a branch, not a free-form SHA, so a contributor cannot name a commit they
518 217 // did not push. For a contributor the branch must be inside their own namespace;
519 218 // for write and admin it may be any normal branch.
520 219 func (w *Web) CreateChangeRequest(rw http.ResponseWriter, r *http.Request) {
521 pr, ok := w.requireHTMLUser(rw, r)
522 if !ok {
523 return
524 }
525 r.Body = http.MaxBytesReader(rw, r.Body, 2<<20)
526 if !w.checkCSRF(r) {
527 http.Error(rw, "bad request", http.StatusForbidden)
528 return
529 }
530 m, ok := w.open(rw, r)
220 ctx, ok := w.beginWrite(rw, r, maxFormBody)
531 221 if !ok {
532 222 return
533 223 }
534 acl, err := m.ACL()
535 if err != nil || !acl.CanOpenRequest(pr.account.Name) {
536 http.Error(rw, "you do not have permission to open change requests here", http.StatusForbidden)
537 return
538 }
539 if err := r.ParseForm(); err != nil {
540 http.Error(rw, "cannot read the form", http.StatusBadRequest)
224 m := ctx.Repo
225 if !ctx.canOpenChangeRequest() {
226 w.refuse(rw, r, "you do not have permission to open change requests here")
541 227 return
542 228 }
543 229 title := strings.TrimSpace(r.PostFormValue("title"))
@@ -554,11 +240,11 @@ func (w *Web) CreateChangeRequest(rw http.ResponseWriter, r *http.Request) {
554 240 return
555 241 }
556 242 headRef := "refs/heads/" + head
557 if acl.RoleFor(pr.account.Name) == access.Contribute {
243 if contributorOnly(ctx.ACL, ctx.Name) {
558 244 // A contributor's head must be a branch the hook would let them push. The
559 245 // namespace is the account name, which cannot change, so this agrees with
560 246 // the gate without a roster lookup.
561 if !access.IsContributorRef(pr.account.Name, headRef) {
247 if !access.IsContributorRef(ctx.Name, headRef) {
562 248 http.Error(rw, "contributors may only open requests from their own branch namespace", http.StatusForbidden)
563 249 return
564 250 }
@@ -572,16 +258,16 @@ func (w *Web) CreateChangeRequest(rw http.ResponseWriter, r *http.Request) {
572 258 http.Error(rw, "that base branch does not exist", http.StatusBadRequest)
573 259 return
574 260 }
575 req, err := metadata.NewRequest(title, body, pr.account.Name, base, headCommit, time.Now().UTC())
261 req, err := metadata.NewRequest(title, body, ctx.Name, base, headCommit, time.Now().UTC())
576 262 if err != nil {
577 263 http.Error(rw, err.Error(), http.StatusBadRequest)
578 264 return
579 265 }
580 if err := metadata.CreateRequest(m.Git(), req, pr.account.Name); err != nil {
266 if err := metadata.CreateRequest(m.Git(), req, ctx.Name); err != nil {
581 267 w.serverError(rw, r, "cannot open the change request: "+err.Error())
582 268 return
583 269 }
584 w.audit("web.request.create", pr.account.Name, m.Name+" "+req.ID+" "+headCommit)
270 w.audit("web.request.create", ctx.Name, m.Name+" "+req.ID+" "+headCommit)
585 271 if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 {
586 272 w.Logger("web: request %s left the request tree invalid: %v", req.ID, problems)
587 273 }
@@ -592,24 +278,12 @@ func (w *Web) CreateChangeRequest(rw http.ResponseWriter, r *http.Request) {
592 278 // maintainer, and a maintainer can do it to anybody's issue: closing your own
593 279 // mistake should not need a second person, but triage is a maintainer's job.
594 280 func (w *Web) ChangeIssueState(rw http.ResponseWriter, r *http.Request) {
595 pr, ok := w.requireHTMLUser(rw, r)
596 if !ok {
597 return
598 }
599 r.Body = http.MaxBytesReader(rw, r.Body, 1<<20)
600 if !w.checkCSRF(r) {
601 http.Error(rw, "bad request", http.StatusForbidden)
602 return
603 }
604 m, ok := w.open(rw, r)
281 ctx, ok := w.beginWrite(rw, r, maxSmallForm)
605 282 if !ok {
606 283 return
607 284 }
285 m := ctx.Repo
608 286 id := r.PathValue("id")
609 if err := r.ParseForm(); err != nil {
610 http.Error(rw, "cannot read the form", http.StatusBadRequest)
611 return
612 }
613 287 state := strings.TrimSpace(r.PostFormValue("state"))
614 288 if state != "open" && state != "closed" {
615 289 http.Error(rw, "state must be open or closed", http.StatusBadRequest)
@@ -620,297 +294,39 @@ func (w *Web) ChangeIssueState(rw http.ResponseWriter, r *http.Request) {
620 294 w.notFound(rw, r)
621 295 return
622 296 }
623 acl, err := m.ACL()
624 if err != nil || (!acl.CanTriage(pr.account.Name) && iss.Author != pr.account.Name) {
625 http.Error(rw, "you do not have permission to change this issue", http.StatusForbidden)
297 if !ctx.canTriage(iss.Author) {
298 w.refuse(rw, r, "you do not have permission to change this issue")
626 299 return
627 300 }
628 _, err = metadata.SetIssueState(m.Git(), id, state, pr.account.Name)
301 _, err = metadata.SetIssueState(m.Git(), id, state, ctx.Name)
629 302 if err != nil && !errors.Is(err, metadata.ErrNoChange) {
630 303 w.serverError(rw, r, "cannot change the issue: "+err.Error())
631 304 return
632 305 }
633 w.audit("web.issue.state", pr.account.Name, m.Name+" "+id+" "+state)
306 w.audit("web.issue.state", ctx.Name, m.Name+" "+id+" "+state)
634 307 if problems := metadata.ValidateIssues(m.Git(), metadata.Ref); len(problems) > 0 {
635 308 w.Logger("web: issue state %s left the metadata tree invalid: %v", id, problems)
636 309 }
637 310 http.Redirect(rw, r, "/r/"+m.Name+"/issues/"+id, http.StatusSeeOther)
638 311 }
639 312
640 // AddRequestComment is the browser counterpart of `vole change comment`. It uses
641 // the same writer, so a comment from the web and a comment from a clone are the
642 // same file with the same ownership rule.
643 func (w *Web) AddRequestComment(rw http.ResponseWriter, r *http.Request) {
644 pr, ok := w.requireHTMLUser(rw, r)
645 if !ok {
646 return
647 }
648 r.Body = http.MaxBytesReader(rw, r.Body, 2<<20)
649 if !w.checkCSRF(r) {
650 http.Error(rw, "bad request", http.StatusForbidden)
651 return
652 }
653 m, ok := w.open(rw, r)
654 if !ok {
655 return
656 }
657 acl, err := m.ACL()
658 if err != nil || !acl.CanDiscuss(pr.account.Name) {
659 http.Error(rw, "you do not have permission to comment here", http.StatusForbidden)
660 return
661 }
662 id := r.PathValue("id")
663 req, err := metadata.GetRequest(m.Git(), id)
664 if err != nil {
665 w.notFound(rw, r)
666 return
667 }
668 if err := r.ParseForm(); err != nil {
669 http.Error(rw, "cannot read the form", http.StatusBadRequest)
670 return
671 }
672 body := strings.TrimSpace(r.PostFormValue("body"))
673 replyTo := strings.TrimSpace(r.PostFormValue("reply_to"))
674 path := strings.TrimSpace(r.PostFormValue("path"))
675 line, _ := strconv.Atoi(r.PostFormValue("line"))
676 side := strings.TrimSpace(r.PostFormValue("side"))
677 if side == "" {
678 side = "new"
679 }
680 if replyTo != "" && path != "" {
681 if htmxRequest(r) {
682 w.render(rw, "request_discussion", w.requestDiscussion(r, m, req, nil,
683 "a reply belongs to its parent thread, not a line"), 0)
684 return
685 }
686 http.Error(rw, "a reply belongs to its parent thread, not a line", http.StatusBadRequest)
687 return
688 }
689 var target *commentTarget
690 var anchor *metadata.Anchor
691 if path != "" {
692 target = &commentTarget{Path: path, Line: line, Side: side}
693 anchor, err = w.commentAnchor(m, req, path, line, side)
694 if err != nil {
695 if htmxRequest(r) {
696 w.render(rw, "request_discussion", w.requestDiscussion(r, m, req, target, err.Error()), 0)
697 return
698 }
699 http.Error(rw, err.Error(), http.StatusBadRequest)
700 return
701 }
702 }
703 if body == "" {
704 if htmxRequest(r) {
705 w.render(rw, "request_discussion", w.requestDiscussion(r, m, req, target, "a comment needs a body"), 0)
706 return
707 }
708 http.Error(rw, "a comment needs a body", http.StatusBadRequest)
709 return
710 }
711 c, err := metadata.NewRequestComment(body, pr.account.Name, time.Now().UTC())
712 if err != nil {
713 if htmxRequest(r) {
714 w.render(rw, "request_discussion", w.requestDiscussion(r, m, req, target, err.Error()), 0)
715 return
716 }
717 http.Error(rw, err.Error(), http.StatusBadRequest)
718 return
719 }
720 c.Anchor = anchor
721 c.ReplyTo = replyTo
722 if err := metadata.AddRequestComment(m.Git(), id, c, pr.account.Name); err != nil {
723 if htmxRequest(r) {
724 w.render(rw, "request_discussion", w.requestDiscussion(r, m, req, nil, "cannot add the comment: "+err.Error()), 0)
725 return
726 }
727 http.Error(rw, "cannot add the comment: "+err.Error(), http.StatusBadRequest)
728 return
729 }
730 w.audit("web.request.comment", pr.account.Name, m.Name+" "+id+" "+c.ID)
731 if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 {
732 w.Logger("web: request comment %s left the request tree invalid: %v", c.ID, problems)
733 }
734 if htmxRequest(r) {
735 updated, err := metadata.GetRequest(m.Git(), id)
736 if err != nil {
737 w.render(rw, "request_discussion", w.requestDiscussion(r, m, req, nil, err.Error()), 0)
738 return
739 }
740 w.render(rw, "request_discussion", w.requestDiscussion(r, m, updated, nil, ""), 0)
741 return
742 }
743 // An anchored comment belongs to the Files tab, so return to the note beneath
744 // its line. A general comment still lands in the conversation thread.
745 next := "/r/" + m.Name + "/requests/" + id + "#c-" + c.ID
746 if c.Anchor != nil {
747 path := w.reviewPathFor(m, req, c.Anchor.Path)
748 next = "/r/" + m.Name + "/requests/" + id + "/files/" + pathURL(path) + "#inline-c-" + c.ID
749 }
750 http.Redirect(rw, r, next, http.StatusSeeOther)
751 }
752
753 // EditRequestComment rewrites one discussion comment. Only its author, or an
754 // admin repairing a thread, may do so.
755 func (w *Web) EditRequestComment(rw http.ResponseWriter, r *http.Request) {
756 pr, ok := w.requireHTMLUser(rw, r)
757 if !ok {
758 return
759 }
760 r.Body = http.MaxBytesReader(rw, r.Body, 2<<20)
761 if !w.checkCSRF(r) {
762 http.Error(rw, "bad request", http.StatusForbidden)
763 return
764 }
765 m, ok := w.open(rw, r)
766 if !ok {
767 return
768 }
769 id, commentID := r.PathValue("id"), r.PathValue("comment")
770 req, err := metadata.GetRequest(m.Git(), id)
771 if err != nil {
772 w.notFound(rw, r)
773 return
774 }
775 c := requestCommentByID(req, commentID)
776 if c == nil {
777 w.notFound(rw, r)
778 return
779 }
780 acl, _ := m.ACL()
781 if !canManageComment(pr, acl, c.Author) {
782 http.Error(rw, "only the author or an admin may edit this comment", http.StatusForbidden)
783 return
784 }
785 if err := r.ParseForm(); err != nil {
786 http.Error(rw, "cannot read the form", http.StatusBadRequest)
787 return
788 }
789 body := strings.TrimSpace(r.PostFormValue("body"))
790 if body == "" {
791 if htmxRequest(r) {
792 w.render(rw, "request_discussion", w.requestDiscussion(r, m, req, nil, "a comment needs a body"), 0)
793 return
794 }
795 http.Error(rw, "a comment needs a body", http.StatusBadRequest)
796 return
797 }
798 if _, err := metadata.EditRequestComment(m.Git(), id, commentID, body, pr.account.Name); err != nil {
799 if htmxRequest(r) {
800 w.render(rw, "request_discussion", w.requestDiscussion(r, m, req, nil, "cannot edit the comment: "+err.Error()), 0)
801 return
802 }
803 http.Error(rw, "cannot edit the comment: "+err.Error(), http.StatusBadRequest)
804 return
805 }
806 detail := m.Name + " " + id + " " + commentID
807 if pr.account.Name != c.Author {
808 detail += " (author " + c.Author + ", admin override)"
809 }
810 w.audit("web.request.comment.edit", pr.account.Name, detail)
811 if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 {
812 w.Logger("web: edit comment %s left the metadata tree invalid: %v", commentID, problems)
813 }
814 if htmxRequest(r) {
815 updated, err := metadata.GetRequest(m.Git(), id)
816 if err != nil {
817 w.render(rw, "request_discussion", w.requestDiscussion(r, m, req, nil, err.Error()), 0)
818 return
819 }
820 w.render(rw, "request_discussion", w.requestDiscussion(r, m, updated, nil, ""), 0)
821 return
822 }
823 http.Redirect(rw, r, "/r/"+m.Name+"/requests/"+id+"#c-"+commentID, http.StatusSeeOther)
824 }
825
826 // DeleteRequestComment replaces a discussion comment with its tombstone.
827 func (w *Web) DeleteRequestComment(rw http.ResponseWriter, r *http.Request) {
828 pr, ok := w.requireHTMLUser(rw, r)
829 if !ok {
830 return
831 }
832 if !w.checkCSRF(r) {
833 http.Error(rw, "bad request", http.StatusForbidden)
834 return
835 }
836 m, ok := w.open(rw, r)
837 if !ok {
838 return
839 }
840 id, commentID := r.PathValue("id"), r.PathValue("comment")
841 req, err := metadata.GetRequest(m.Git(), id)
842 if err != nil {
843 w.notFound(rw, r)
844 return
845 }
846 c := requestCommentByID(req, commentID)
847 if c == nil {
848 w.notFound(rw, r)
849 return
850 }
851 acl, _ := m.ACL()
852 if !canManageComment(pr, acl, c.Author) {
853 http.Error(rw, "only the author or an admin may delete this comment", http.StatusForbidden)
854 return
855 }
856 if _, err := metadata.DeleteRequestComment(m.Git(), id, commentID, pr.account.Name); err != nil {
857 if htmxRequest(r) {
858 w.render(rw, "request_discussion", w.requestDiscussion(r, m, req, nil, "cannot delete the comment: "+err.Error()), 0)
859 return
860 }
861 http.Error(rw, "cannot delete the comment: "+err.Error(), http.StatusBadRequest)
862 return
863 }
864 detail := m.Name + " " + id + " " + commentID
865 if pr.account.Name != c.Author {
866 detail += " (author " + c.Author + ", admin override)"
867 }
868 w.audit("web.request.comment.delete", pr.account.Name, detail)
869 if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 {
870 w.Logger("web: delete comment %s left the metadata tree invalid: %v", commentID, problems)
871 }
872 if htmxRequest(r) {
873 updated, err := metadata.GetRequest(m.Git(), id)
874 if err != nil {
875 w.render(rw, "request_discussion", w.requestDiscussion(r, m, req, nil, err.Error()), 0)
876 return
877 }
878 w.render(rw, "request_discussion", w.requestDiscussion(r, m, updated, nil, ""), 0)
879 return
880 }
881 http.Redirect(rw, r, "/r/"+m.Name+"/requests/"+id+"#c-"+commentID, http.StatusSeeOther)
882 }
883
884 313 // ChangeRequestState closes or reopens a change request. Closing is not merging:
885 314 // it records that the proposal was withdrawn, and a closed request can be
886 315 // reopened without losing its discussion or approvals.
887 316 func (w *Web) ChangeRequestState(rw http.ResponseWriter, r *http.Request) {
888 pr, ok := w.requireHTMLUser(rw, r)
889 if !ok {
890 return
891 }
892 r.Body = http.MaxBytesReader(rw, r.Body, 1<<20)
893 if !w.checkCSRF(r) {
894 http.Error(rw, "bad request", http.StatusForbidden)
895 return
896 }
897 m, ok := w.open(rw, r)
317 ctx, ok := w.beginWrite(rw, r, maxSmallForm)
898 318 if !ok {
899 319 return
900 320 }
321 m := ctx.Repo
901 322 id := r.PathValue("id")
902 323 req, err := metadata.GetRequest(m.Git(), id)
903 324 if err != nil {
904 325 w.notFound(rw, r)
905 326 return
906 327 }
907 acl, err := m.ACL()
908 if err != nil || (!acl.CanTriage(pr.account.Name) && req.Author != pr.account.Name) {
909 http.Error(rw, "you do not have permission to change this change request", http.StatusForbidden)
910 return
911 }
912 if err := r.ParseForm(); err != nil {
913 http.Error(rw, "cannot read the form", http.StatusBadRequest)
328 if !ctx.canTriage(req.Author) {
329 w.refuse(rw, r, "you do not have permission to change this change request")
914 330 return
915 331 }
916 332 state := strings.TrimSpace(r.PostFormValue("state"))
@@ -918,11 +334,11 @@ func (w *Web) ChangeRequestState(rw http.ResponseWriter, r *http.Request) {
918 334 http.Error(rw, "state must be open or closed", http.StatusBadRequest)
919 335 return
920 336 }
921 if _, err := metadata.SetRequestState(m.Git(), id, state, pr.account.Name); err != nil {
337 if _, err := metadata.SetRequestState(m.Git(), id, state, ctx.Name); err != nil {
922 338 w.serverError(rw, r, "cannot change the change request: "+err.Error())
923 339 return
924 340 }
925 w.audit("web.request.state", pr.account.Name, m.Name+" "+id+" "+state)
341 w.audit("web.request.state", ctx.Name, m.Name+" "+id+" "+state)
926 342 if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 {
927 343 w.Logger("web: request state %s left the request tree invalid: %v", id, problems)
928 344 }