vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0: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 · 13d 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/comment_writes.go +352
| @@ -0,0 +1,352 @@ | |||
| 1 | package web | ||
| 2 | |||
| 3 | // Comment writes: add, edit, delete. | ||
| 4 | // | ||
| 5 | // The operation is the same on an issue and on a change request, so there is one | ||
| 6 | // implementation of each and a commentTopic says what the two surfaces do | ||
| 7 | // differently: which metadata writer runs, which section is re-rendered, and | ||
| 8 | // where a plain form post lands afterwards. Adding a surface is a new topic, not a | ||
| 9 | // third copy of the validation. | ||
| 10 | |||
| 11 | import ( | ||
| 12 | "net/http" | ||
| 13 | "strconv" | ||
| 14 | "strings" | ||
| 15 | "time" | ||
| 16 | |||
| 17 | "github.com/rodent-software/vole/forge/index" | ||
| 18 | "github.com/rodent-software/vole/forge/repo" | ||
| 19 | "github.com/rodent-software/vole/metadata" | ||
| 20 | ) | ||
| 21 | |||
| 22 | // commentTopic is one issue or one change request as a comment write sees it. | ||
| 23 | // Everything here closes over the entity that was loaded for the request, so a | ||
| 24 | // handler never passes one around. | ||
| 25 | type commentTopic struct { | ||
| 26 | // id is the issue or change request the comments belong to. | ||
| 27 | id string | ||
| 28 | // view re-renders the discussion section with a message for the reader. | ||
| 29 | view fragmentFor | ||
| 30 | // reload re-reads the entity after a successful write, so the section shows | ||
| 31 | // the comment that was just stored rather than the state the request began in. | ||
| 32 | reload func() (string, any) | ||
| 33 | // lookup finds one comment for the moderation check. | ||
| 34 | lookup func(commentID string) *metadata.Comment | ||
| 35 | // The writers. Each one is the same metadata function the CLI uses. | ||
| 36 | newComment func(body, author string, now time.Time) (*metadata.Comment, error) | ||
| 37 | add func(c *metadata.Comment, author string) error | ||
| 38 | edit func(commentID, body, author string) error | ||
| 39 | remove func(commentID, author string) error | ||
| 40 | // anchorFor resolves a line anchor; nil on a surface that keeps none (an issue | ||
| 41 | // has no diff to point into). | ||
| 42 | anchorFor func(path string, line int, side string) (*metadata.Anchor, error) | ||
| 43 | // validate re-checks the metadata branch after a write. Problems are logged, | ||
| 44 | // never shown to the browser. | ||
| 45 | validate func() []string | ||
| 46 | // The audit actions, one per write. | ||
| 47 | auditAdd, auditEdit, auditRemove string | ||
| 48 | // afterAdd and afterComment are where a plain form post lands. A new comment | ||
| 49 | // may be anchored into the Files tab, so it is not always "#c-<id>". | ||
| 50 | afterAdd func(c *metadata.Comment) string | ||
| 51 | afterComment func(commentID string) string | ||
| 52 | // target is the diff line the composer was opened from, when it was opened from | ||
| 53 | // a line. The view renders the "Commenting on ..." note from it. | ||
| 54 | target *commentTarget | ||
| 55 | } | ||
| 56 | |||
| 57 | // issueTopic loads the issue named in the path and describes its discussion. | ||
| 58 | func (w *Web) issueTopic(rw http.ResponseWriter, r *http.Request, m *repo.Meta) (*commentTopic, bool) { | ||
| 59 | id := r.PathValue("id") | ||
| 60 | iss, err := index.LoadIssue(m, id) | ||
| 61 | if err != nil { | ||
| 62 | w.notFound(rw, r) | ||
| 63 | return nil, false | ||
| 64 | } | ||
| 65 | return &commentTopic{ | ||
| 66 | id: id, | ||
| 67 | view: func(msg string) (string, any) { return "comment_thread", w.issueThread(r, m, iss, msg) }, | ||
| 68 | reload: func() (string, any) { | ||
| 69 | updated, err := index.LoadIssue(m, id) | ||
| 70 | if err != nil { | ||
| 71 | // The comment was written; only the re-read failed. Say so in the | ||
| 72 | // section instead of falling back to an empty response. | ||
| 73 | return "comment_thread", w.issueThread(r, m, iss, "the comment was saved, but the thread could not be re-read: "+err.Error()) | ||
| 74 | } | ||
| 75 | return "comment_thread", w.issueThread(r, m, updated, "") | ||
| 76 | }, | ||
| 77 | lookup: func(commentID string) *metadata.Comment { | ||
| 78 | for i := range iss.Comments { | ||
| 79 | if iss.Comments[i].ID == commentID { | ||
| 80 | return &iss.Comments[i] | ||
| 81 | } | ||
| 82 | } | ||
| 83 | return nil | ||
| 84 | }, | ||
| 85 | newComment: metadata.NewIssueComment, | ||
| 86 | add: func(c *metadata.Comment, author string) error { | ||
| 87 | return metadata.AddIssueComment(m.Git(), id, c, author) | ||
| 88 | }, | ||
| 89 | edit: func(commentID, body, author string) error { | ||
| 90 | _, err := metadata.EditIssueComment(m.Git(), id, commentID, body, author) | ||
| 91 | return err | ||
| 92 | }, | ||
| 93 | remove: func(commentID, author string) error { | ||
| 94 | _, err := metadata.DeleteIssueComment(m.Git(), id, commentID, author) | ||
| 95 | return err | ||
| 96 | }, | ||
| 97 | validate: func() []string { return metadata.ValidateIssues(m.Git(), metadata.Ref) }, | ||
| 98 | auditAdd: "web.issue.comment", | ||
| 99 | auditEdit: "web.issue.comment.edit", | ||
| 100 | auditRemove: "web.issue.comment.delete", | ||
| 101 | afterAdd: func(c *metadata.Comment) string { | ||
| 102 | return "/r/" + m.Name + "/issues/" + id + "#c-" + c.ID | ||
| 103 | }, | ||
| 104 | afterComment: func(commentID string) string { | ||
| 105 | return "/r/" + m.Name + "/issues/" + id + "#c-" + commentID | ||
| 106 | }, | ||
| 107 | }, true | ||
| 108 | } | ||
| 109 | |||
| 110 | // requestTopic loads the change request named in the path and describes its | ||
| 111 | // discussion. Unlike an issue, a comment here may be anchored to a diff line. | ||
| 112 | func (w *Web) requestTopic(rw http.ResponseWriter, r *http.Request, m *repo.Meta) (*commentTopic, bool) { | ||
| 113 | id := r.PathValue("id") | ||
| 114 | req, err := metadata.GetRequest(m.Git(), id) | ||
| 115 | if err != nil { | ||
| 116 | w.notFound(rw, r) | ||
| 117 | return nil, false | ||
| 118 | } | ||
| 119 | page := "/r/" + m.Name + "/requests/" + id | ||
| 120 | t := &commentTopic{ | ||
| 121 | id: id, | ||
| 122 | lookup: func(commentID string) *metadata.Comment { | ||
| 123 | for i := range req.Comments { | ||
| 124 | if req.Comments[i].ID == commentID { | ||
| 125 | return &req.Comments[i] | ||
| 126 | } | ||
| 127 | } | ||
| 128 | return nil | ||
| 129 | }, | ||
| 130 | newComment: metadata.NewRequestComment, | ||
| 131 | add: func(c *metadata.Comment, author string) error { | ||
| 132 | return metadata.AddRequestComment(m.Git(), id, c, author) | ||
| 133 | }, | ||
| 134 | edit: func(commentID, body, author string) error { | ||
| 135 | _, err := metadata.EditRequestComment(m.Git(), id, commentID, body, author) | ||
| 136 | return err | ||
| 137 | }, | ||
| 138 | remove: func(commentID, author string) error { | ||
| 139 | _, err := metadata.DeleteRequestComment(m.Git(), id, commentID, author) | ||
| 140 | return err | ||
| 141 | }, | ||
| 142 | anchorFor: func(path string, line int, side string) (*metadata.Anchor, error) { | ||
| 143 | return w.commentAnchor(m, req, path, line, side) | ||
| 144 | }, | ||
| 145 | validate: func() []string { return metadata.ValidateRequests(m.Git(), metadata.Ref) }, | ||
| 146 | auditAdd: "web.request.comment", | ||
| 147 | auditEdit: "web.request.comment.edit", | ||
| 148 | auditRemove: "web.request.comment.delete", | ||
| 149 | afterAdd: func(c *metadata.Comment) string { | ||
| 150 | // An anchored comment belongs to the Files tab, so the reader returns to | ||
| 151 | // the note beneath its line rather than to the conversation. | ||
| 152 | if c.Anchor == nil { | ||
| 153 | return page + "#c-" + c.ID | ||
| 154 | } | ||
| 155 | path := w.reviewPathFor(m, req, c.Anchor.Path) | ||
| 156 | return page + "/files/" + pathURL(path) + "#inline-c-" + c.ID | ||
| 157 | }, | ||
| 158 | afterComment: func(commentID string) string { return page + "#c-" + commentID }, | ||
| 159 | } | ||
| 160 | // A rejected anchored comment re-renders the note it was written from, so the | ||
| 161 | // reader keeps the line they chose; the success path drops it, because the | ||
| 162 | // comment is no longer waiting to be written. | ||
| 163 | t.view = func(msg string) (string, any) { | ||
| 164 | return "comment_thread", w.requestThread(r, m, req, t.target, msg) | ||
| 165 | } | ||
| 166 | t.reload = func() (string, any) { | ||
| 167 | updated, err := metadata.GetRequest(m.Git(), id) | ||
| 168 | if err != nil { | ||
| 169 | return "comment_thread", w.requestThread(r, m, req, nil, | ||
| 170 | "the comment was saved, but the discussion could not be re-read: "+err.Error()) | ||
| 171 | } | ||
| 172 | return "comment_thread", w.requestThread(r, m, updated, nil, "") | ||
| 173 | } | ||
| 174 | return t, true | ||
| 175 | } | ||
| 176 | |||
| 177 | func (w *Web) AddIssueComment(rw http.ResponseWriter, r *http.Request) { | ||
| 178 | w.addComment(rw, r, w.issueTopic) | ||
| 179 | } | ||
| 180 | |||
| 181 | func (w *Web) AddRequestComment(rw http.ResponseWriter, r *http.Request) { | ||
| 182 | w.addComment(rw, r, w.requestTopic) | ||
| 183 | } | ||
| 184 | |||
| 185 | func (w *Web) EditIssueComment(rw http.ResponseWriter, r *http.Request) { | ||
| 186 | w.editComment(rw, r, w.issueTopic) | ||
| 187 | } | ||
| 188 | |||
| 189 | func (w *Web) EditRequestComment(rw http.ResponseWriter, r *http.Request) { | ||
| 190 | w.editComment(rw, r, w.requestTopic) | ||
| 191 | } | ||
| 192 | |||
| 193 | func (w *Web) DeleteIssueComment(rw http.ResponseWriter, r *http.Request) { | ||
| 194 | w.deleteComment(rw, r, w.issueTopic) | ||
| 195 | } | ||
| 196 | |||
| 197 | func (w *Web) DeleteRequestComment(rw http.ResponseWriter, r *http.Request) { | ||
| 198 | w.deleteComment(rw, r, w.requestTopic) | ||
| 199 | } | ||
| 200 | |||
| 201 | // topicLoader is how a handler names which surface's comments it writes. | ||
| 202 | type topicLoader func(rw http.ResponseWriter, r *http.Request, m *repo.Meta) (*commentTopic, bool) | ||
| 203 | |||
| 204 | // addComment is one implementation of "add a comment": what a comment is, where a | ||
| 205 | // reply and an anchor point, and what the response looks like afterwards. | ||
| 206 | func (w *Web) addComment(rw http.ResponseWriter, r *http.Request, load topicLoader) { | ||
| 207 | ctx, ok := w.beginWrite(rw, r, maxFormBody) | ||
| 208 | if !ok { | ||
| 209 | return | ||
| 210 | } | ||
| 211 | if !ctx.canDiscuss() { | ||
| 212 | w.refuse(rw, r, "you do not have permission to comment here") | ||
| 213 | return | ||
| 214 | } | ||
| 215 | t, ok := load(rw, r, ctx.Repo) | ||
| 216 | if !ok { | ||
| 217 | return | ||
| 218 | } | ||
| 219 | body := strings.TrimSpace(r.PostFormValue("body")) | ||
| 220 | replyTo := strings.TrimSpace(r.PostFormValue("reply_to")) | ||
| 221 | path := strings.TrimSpace(r.PostFormValue("path")) | ||
| 222 | line, _ := strconv.Atoi(r.PostFormValue("line")) | ||
| 223 | side := strings.TrimSpace(r.PostFormValue("side")) | ||
| 224 | if side == "" { | ||
| 225 | side = "new" | ||
| 226 | } | ||
| 227 | if replyTo != "" && path != "" { | ||
| 228 | w.fail(rw, r, http.StatusBadRequest, | ||
| 229 | "a reply belongs to its parent thread, not a line", t.view) | ||
| 230 | return | ||
| 231 | } | ||
| 232 | var anchor *metadata.Anchor | ||
| 233 | if t.anchorFor != nil && path != "" { | ||
| 234 | t.target = &commentTarget{Path: path, Line: line, Side: side} | ||
| 235 | var err error | ||
| 236 | anchor, err = t.anchorFor(path, line, side) | ||
| 237 | if err != nil { | ||
| 238 | w.fail(rw, r, http.StatusBadRequest, err.Error(), t.view) | ||
| 239 | return | ||
| 240 | } | ||
| 241 | } | ||
| 242 | if body == "" { | ||
| 243 | w.fail(rw, r, http.StatusBadRequest, "a comment needs a body", t.view) | ||
| 244 | return | ||
| 245 | } | ||
| 246 | c, err := t.newComment(body, ctx.Name, time.Now().UTC()) | ||
| 247 | if err != nil { | ||
| 248 | w.fail(rw, r, http.StatusBadRequest, err.Error(), t.view) | ||
| 249 | return | ||
| 250 | } | ||
| 251 | c.Anchor, c.ReplyTo = anchor, replyTo | ||
| 252 | if err := t.add(c, ctx.Name); err != nil { | ||
| 253 | // Nothing was written, so the anchor the reader was shown is not where the | ||
| 254 | // comment went; drop it before re-rendering. | ||
| 255 | t.target = nil | ||
| 256 | w.fail(rw, r, http.StatusBadRequest, "cannot add the comment: "+err.Error(), t.view) | ||
| 257 | return | ||
| 258 | } | ||
| 259 | w.finishComment(rw, r, ctx, t, t.auditAdd, ctx.Repo.Name+" "+t.id+" "+c.ID, t.afterAdd(c)) | ||
| 260 | } | ||
| 261 | |||
| 262 | // editComment rewrites one comment. Only its author, or an admin repairing a | ||
| 263 | // thread, may do so. | ||
| 264 | func (w *Web) editComment(rw http.ResponseWriter, r *http.Request, load topicLoader) { | ||
| 265 | ctx, ok := w.beginWrite(rw, r, maxFormBody) | ||
| 266 | if !ok { | ||
| 267 | return | ||
| 268 | } | ||
| 269 | t, ok := load(rw, r, ctx.Repo) | ||
| 270 | if !ok { | ||
| 271 | return | ||
| 272 | } | ||
| 273 | commentID := r.PathValue("comment") | ||
| 274 | c := t.lookup(commentID) | ||
| 275 | if c == nil { | ||
| 276 | w.notFound(rw, r) | ||
| 277 | return | ||
| 278 | } | ||
| 279 | if !canModerateComment(ctx.ACL, ctx.Name, c.Author) { | ||
| 280 | w.refuse(rw, r, "only the author or an admin may edit this comment") | ||
| 281 | return | ||
| 282 | } | ||
| 283 | body := strings.TrimSpace(r.PostFormValue("body")) | ||
| 284 | if body == "" { | ||
| 285 | w.fail(rw, r, http.StatusBadRequest, "a comment needs a body", t.view) | ||
| 286 | return | ||
| 287 | } | ||
| 288 | if err := t.edit(commentID, body, ctx.Name); err != nil { | ||
| 289 | w.fail(rw, r, http.StatusBadRequest, "cannot edit the comment: "+err.Error(), t.view) | ||
| 290 | return | ||
| 291 | } | ||
| 292 | w.finishComment(rw, r, ctx, t, t.auditEdit, | ||
| 293 | commentAuditDetail(ctx.Repo.Name, t.id, commentID, c.Author, ctx.Name), | ||
| 294 | t.afterComment(commentID)) | ||
| 295 | } | ||
| 296 | |||
| 297 | // deleteComment replaces a comment with its tombstone, so its replies keep their | ||
| 298 | // thread. It is the same rule as editing: the author, or an admin. | ||
| 299 | func (w *Web) deleteComment(rw http.ResponseWriter, r *http.Request, load topicLoader) { | ||
| 300 | ctx, ok := w.beginWrite(rw, r, maxSmallForm) | ||
| 301 | if !ok { | ||
| 302 | return | ||
| 303 | } | ||
| 304 | t, ok := load(rw, r, ctx.Repo) | ||
| 305 | if !ok { | ||
| 306 | return | ||
| 307 | } | ||
| 308 | commentID := r.PathValue("comment") | ||
| 309 | c := t.lookup(commentID) | ||
| 310 | if c == nil { | ||
| 311 | w.notFound(rw, r) | ||
| 312 | return | ||
| 313 | } | ||
| 314 | if !canModerateComment(ctx.ACL, ctx.Name, c.Author) { | ||
| 315 | w.refuse(rw, r, "only the author or an admin may delete this comment") | ||
| 316 | return | ||
| 317 | } | ||
| 318 | if err := t.remove(commentID, ctx.Name); err != nil { | ||
| 319 | w.fail(rw, r, http.StatusBadRequest, "cannot delete the comment: "+err.Error(), t.view) | ||
| 320 | return | ||
| 321 | } | ||
| 322 | w.finishComment(rw, r, ctx, t, t.auditRemove, | ||
| 323 | commentAuditDetail(ctx.Repo.Name, t.id, commentID, c.Author, ctx.Name), | ||
| 324 | t.afterComment(commentID)) | ||
| 325 | } | ||
| 326 | |||
| 327 | // commentAuditDetail is the audit line for a comment write. An admin acting on | ||
| 328 | // somebody else's comment is recorded as an override, which is the case a reader | ||
| 329 | // of the log is looking for. | ||
| 330 | func commentAuditDetail(repo, topicID, commentID, author, actor string) string { | ||
| 331 | detail := repo + " " + topicID + " " + commentID | ||
| 332 | if actor != author { | ||
| 333 | detail += " (author " + author + ", admin override)" | ||
| 334 | } | ||
| 335 | return detail | ||
| 336 | } | ||
| 337 | |||
| 338 | // finishComment closes a successful comment write: it records the action, checks | ||
| 339 | // the metadata tree, and answers — the re-rendered section for HTMX, a redirect for | ||
| 340 | // a plain form post. | ||
| 341 | func (w *Web) finishComment(rw http.ResponseWriter, r *http.Request, ctx *writeContext, t *commentTopic, action, detail, next string) { | ||
| 342 | w.audit(action, ctx.Name, detail) | ||
| 343 | if problems := t.validate(); len(problems) > 0 { | ||
| 344 | w.Logger("web: comment write left the metadata tree invalid: %v", problems) | ||
| 345 | } | ||
| 346 | if !htmxRequest(r) { | ||
| 347 | http.Redirect(rw, r, next, http.StatusSeeOther) | ||
| 348 | return | ||
| 349 | } | ||
| 350 | name, data := t.reload() | ||
| 351 | w.render(rw, name, data, 0) | ||
| 352 | } | ||