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