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