coverage~bigbes/sr-ht-spec3cb1c03dweb/comments.go

Coverage
74.6% 106/142 statements
Δ
Blob
da182fd
1 package web
2
3 import (
4 "net/http"
5 "net/url"
6 "sort"
7 "strconv"
8 "strings"
9
10 "sourcecraft.dev/bigbes/sr-ht-ecore/pages"
11
12 "sourcecraft.dev/bigbes/sr-ht-spec/authn"
13 "sourcecraft.dev/bigbes/sr-ht-spec/core"
14 "sourcecraft.dev/bigbes/sr-ht-spec/doc"
15 "sourcecraft.dev/bigbes/sr-ht-spec/service"
16 )
17
18 // commentTimeFormat is how a comment's timestamp reads on the page. Minutes are
19 // the finest unit a review conversation cares about.
20 const commentTimeFormat = "2006-01-02 15:04"
21
22 // reviewControls is who may do what to the review threads on one page.
23 //
24 // The two authorities are kept apart because the service keeps them apart: only
25 // the owner may open or resolve a thread, while owner and agent alike may
26 // reply. This struct decides which controls are *drawn*; it is not the check —
27 // service.CommentOn and service.ResolveThread refuse a non-owner themselves and
28 // this page surfaces their ErrForbidden. Drawing a control the service would
29 // refuse is the failure this prevents, not privilege escalation.
30 type reviewControls struct {
31 // Owner draws the compose form and the resolve/reopen control.
32 Owner bool
33 // Reply draws the reply form.
34 Reply bool
35 // ActionBase is the proposal's URL, which every form posts under.
36 ActionBase string
37 }
38
39 // threadPanel is one review thread as the page renders it: the root comment,
40 // its replies, and the controls the viewer is allowed.
41 type threadPanel struct {
42 ID int
43 Author string
44 Agent bool
45 Body string
46 When string
47 Replies []replyLine
48 Resolved bool
49
50 // Note is a short badge naming an anchor that no longer fits exactly, and
51 // NoteWhy is its tooltip. Both are empty for core.AnchorExact: a comment that
52 // still sits on the text it was written about needs no annotation.
53 Note string
54 NoteWhy string
55
56 // DocPath is set only for a thread the page could not place on a block, where
57 // the document it belonged to is the only remaining locator. A placed thread
58 // leaves it empty — the document heading it renders under already says it.
59 DocPath string
60
61 // AnchorPath and AnchorIndex name the block this thread is drawn against,
62 // display-only. They are set for a thread the diff placed and left empty for
63 // one it could not: a lost thread's anchor is precisely what did not resolve,
64 // and printing the path it used to name would read as a place it still sits.
65 AnchorPath string
66 AnchorIndex int
67
68 CanReply bool
69 CanResolve bool
70 ActionBase string
71 }
72
73 // replyLine is one reply under a root comment. Threading is flat by design:
74 // there is a root and there are answers to it, and nothing nests deeper.
75 type replyLine struct {
76 Author string
77 Agent bool
78 Body string
79 When string
80 }
81
82 // composeForm is the "comment on this block" form's hidden state.
83 //
84 // It carries the block's document-global prosediff ordinal rather than a
85 // pre-built anchor: building the anchor is service.AnchorOf's job, and doing it
86 // at submit time re-reads the branch, so a form drawn against a revision the
87 // agent has since replaced is caught by Hash rather than silently anchored to
88 // whatever now sits at that ordinal.
89 type composeForm struct {
90 ActionBase string
91 DocPath string
92 Ordinal int
93 Side core.CommentSide
94 Hash string
95
96 // AnchorPath and AnchorIndex are shown, not submitted: the reviewer selects
97 // lines and the form stores a block, and this is where that indirection is
98 // stated instead of being magic. AnchorIndex is the anchor's own index —
99 // 0-based, within the heading path — because a number invented for display
100 // would not be the number the comment stores.
101 AnchorPath string
102 AnchorIndex int
103 }
104
105 // anchorPathLabel names a block's section for a reader: its enclosing headings,
106 // outermost first, or the phrase for a block that sits above the document's
107 // first heading. Empty would leave the composer saying "Anchors to" and nothing.
108 36 func anchorPathLabel(path []string) string {
109 36 if s := strings.Join(path, " › "); s != "" {
110 28 return s
111 28 }
112 8 return "(document preamble)"
113 }
114
115 // blockComments is what the "blockthreads" template renders for one block: the
116 // threads already on it and, for the owner, the form that opens a new one.
117 type blockComments struct {
118 Threads []threadPanel
119 Compose *composeForm
120 }
121
122 // threadPanelOf maps a resolved service.Thread onto the page's shape.
123 //
124 // The anchor state is reported, never hidden. core.AnchorEdited means the block
125 // is still where the comment pointed but its text has moved on, so the critique
126 // may no longer fit — the reader has to be told that, because a stale critique
127 // read as a current one is worse than no critique. core.AnchorOutdated means the
128 // anchor is lost entirely; such a thread is never drawn against a block at all,
129 // only in the page's unplaced area.
130 7 func threadPanelOf(t service.Thread, c reviewControls) threadPanel {
131 7 p := threadPanel{
132 7 ID: t.Root.ID,
133 7 Author: t.Root.Author,
134 7 Agent: t.Root.Agent,
135 7 Body: t.Root.Body,
136 7 When: t.Root.Created.Format(commentTimeFormat),
137 7 Resolved: !t.Open(),
138 7 CanReply: c.Reply,
139 7 CanResolve: c.Owner,
140 7 ActionBase: c.ActionBase,
141 7 }
142 7 for _, r := range t.Replies {
143 0 p.Replies = append(p.Replies, replyLine{
144 0 Author: r.Author,
145 0 Agent: r.Agent,
146 0 Body: r.Body,
147 0 When: r.Created.Format(commentTimeFormat),
148 0 })
149 0 }
150 7 switch t.State {
151 1 case core.AnchorEdited:
152 1 p.Note = "block edited since"
153 1 p.NoteWhy = "The block is still here but its text changed after this comment was written."
154 2 case core.AnchorOutdated:
155 2 p.Note = "anchor lost"
156 2 p.NoteWhy = "The block this comment was written about is no longer in the proposed revision."
157 }
158 7 return p
159 }
160
161 // lostPanels renders the threads no block claimed, in a stable order.
162 //
163 // They carry their document path because that is all that is left of where they
164 // pointed, and they are sorted so two renders of the same page agree: the
165 // threads arrive grouped per document from a map walk, which has no order of its
166 // own.
167 9 func lostPanels(threads []service.Thread, c reviewControls) []threadPanel {
168 9 out := make([]threadPanel, 0, len(threads))
169 9 for _, t := range threads {
170 2 p := threadPanelOf(t, c)
171 2 p.DocPath = t.DocPath
172 2 out = append(out, p)
173 2 }
174 9 sort.Slice(out, func(i, j int) bool {
175 0 if out[i].DocPath != out[j].DocPath {
176 0 return out[i].DocPath < out[j].DocPath
177 0 }
178 0 return out[i].ID < out[j].ID
179 })
180 9 return out
181 }
182
183 // docIDFor derives a document's anchoring key: its frontmatter id when it has a
184 // well-formed one, otherwise its path without the extension. That is the
185 // archive's addressing rule (doc/scan.go), and anchoring on the id rather than
186 // the path is what lets a comment survive a rename.
187 //
188 // The archive also refuses an id that two documents claim; that contest cannot
189 // be judged here, because a review holds only the documents the proposal changes
190 // and not the whole revision they sit in. The consequence is bounded: the anchor
191 // carries the document path as well, and it is the path that service.AnchorThreads
192 // resolves a thread by.
193 14 func docIDFor(path string, src []byte) string {
194 14 front, _ := doc.ParseFront(src)
195 14 if core.ValidateDocID(front.ID) == nil {
196 1 return front.ID
197 1 }
198 13 return strings.TrimSuffix(path, core.DocExt)
199 }
200
201 // ---- handlers -------------------------------------------------------------
202
203 // handleProposalComment opens a review thread on one block of a proposed
204 // document.
205 //
206 // The anchor is built here, at submit time, from the branch as it now reads —
207 // not from anything the form carries — because a form drawn ten minutes ago
208 // describes a revision the agent may have replaced since. The form's block hash
209 // is the guard on that: if the block at the submitted ordinal no longer hashes
210 // to what the reviewer was looking at, the comment is refused rather than
211 // attached to whatever moved into that position.
212 //
213 // service.AnchorOf does the ordinal conversion. Hand-rolling it here would put
214 // the browser's comments on different blocks than the MCP tool's, which is the
215 // one way two surfaces of the same conversation can disagree without either
216 // looking broken.
217 8 func (s *Server) handleProposalComment(w http.ResponseWriter, r *http.Request) {
218 8 p, form, ok := s.commentPost(w, r)
219 8 if !ok {
220 3 return
221 3 }
222
223 5 docPath := form.Get("doc")
224 5 body := strings.TrimSpace(form.Get("body"))
225 5 ordinal, err := strconv.Atoi(form.Get("block"))
226 5 if err != nil || ordinal < 0 {
227 1 s.renderError(w, r, http.StatusBadRequest, "that comment names no block")
228 1 return
229 1 }
230 4 side, err := core.ParseCommentSide(form.Get("side"))
231 4 if err != nil {
232 0 s.renderError(w, r, http.StatusBadRequest, err.Error())
233 0 return
234 0 }
235 4 if body == "" {
236 0 s.renderError(w, r, http.StatusBadRequest, "a comment needs a body")
237 0 return
238 0 }
239
240 4 docs, err := s.reader.ProposalDiff(r.Context(), p)
241 4 if err != nil {
242 0 s.fail(w, r, err)
243 0 return
244 0 }
245 4 src, found := sourceOfSide(docs, docPath, side)
246 4 if !found {
247 0 s.renderError(w, r, http.StatusNotFound, "this proposal does not change that document")
248 0 return
249 0 }
250
251 4 anchor, err := service.AnchorOf(docIDFor(docPath, src), src, ordinal, side)
252 4 if err != nil {
253 0 s.fail(w, r, err)
254 0 return
255 0 }
256 // The hash is required, not merely checked when present. Skipping the guard
257 // for a submission that omits it would mean a later template refactor that
258 // dropped the hidden field disabled the staleness check silently, with every
259 // test still passing — the comment would still store a coherent anchor, just
260 // not the block the reviewer was reading.
261 4 switch want := form.Get("hash"); {
262 1 case want == "":
263 1 s.renderError(w, r, http.StatusBadRequest, "that comment names no block revision")
264 1 return
265 1 case want != anchor.BlockHash:
266 1 s.renderError(w, r, http.StatusConflict,
267 1 "that block changed since this page was loaded; reload the proposal and comment again")
268 1 return
269 }
270
271 2 thread, err := s.reader.CommentOn(r.Context(), service.CommentRequest{
272 2 Principal: authn.PrincipalFromContext(r.Context()),
273 2 Space: p.Space,
274 2 ProposalID: p.ID,
275 2 DocPath: docPath,
276 2 Anchor: anchor,
277 2 Body: body,
278 2 })
279 2 if err != nil {
280 1 s.fail(w, r, err)
281 1 return
282 1 }
283 1 s.backToThread(w, r, p, thread.Root.ID)
284 }
285
286 // handleProposalReply appends a reply to an existing thread. Both principals may
287 // reply — that is the loop's turn-taking, the owner critiques and the agent
288 // answers — and service.ReplyTo is what says so.
289 2 func (s *Server) handleProposalReply(w http.ResponseWriter, r *http.Request) {
290 2 p, form, ok := s.commentPost(w, r)
291 2 if !ok {
292 0 return
293 0 }
294 2 threadID, ok := s.threadOfProposal(w, r, form, p.ID)
295 2 if !ok {
296 1 return
297 1 }
298 1 body := strings.TrimSpace(form.Get("body"))
299 1 if body == "" {
300 0 s.renderError(w, r, http.StatusBadRequest, "a reply needs a body")
301 0 return
302 0 }
303 1 if _, err := s.reader.ReplyTo(r.Context(), authn.PrincipalFromContext(r.Context()), threadID, body); err != nil {
304 0 s.fail(w, r, err)
305 0 return
306 0 }
307 1 s.backToThread(w, r, p, threadID)
308 }
309
310 // handleProposalResolve closes a thread, or reopens it when the form says so.
311 //
312 // Owner-only, enforced by service.ResolveThread rather than re-stated here: an
313 // agent that could resolve the thread opened against its own proposal could
314 // clear the auto-merge gate that thread exists to hold shut.
315 3 func (s *Server) handleProposalResolve(w http.ResponseWriter, r *http.Request) {
316 3 p, form, ok := s.commentPost(w, r)
317 3 if !ok {
318 0 return
319 0 }
320 3 threadID, ok := s.threadOfProposal(w, r, form, p.ID)
321 3 if !ok {
322 0 return
323 0 }
324 3 resolved := form.Get("resolved") == "1"
325 3 who := authn.PrincipalFromContext(r.Context())
326 3 if err := s.reader.ResolveThread(r.Context(), who, threadID, resolved); err != nil {
327 1 s.fail(w, r, err)
328 1 return
329 1 }
330 2 s.backToThread(w, r, p, threadID)
331 }
332
333 // commentPost is the prologue every comment POST shares: read authority, the
334 // form's values, and the proposal the URL names — refusing one that belongs to
335 // another space for the same reason the review page does, that the id is global
336 // but the link names its space.
337 //
338 // The values come back from pages.FormValues and the handlers read *those*
339 // rather than the request, which is the point of returning them. FormValues
340 // answers r.PostForm and never r.Form, and r.Form is the merge of the body with
341 // the query string — so with a bare ParseForm and r.FormValue, every one of
342 // these mutations could be driven entirely from a URL somebody was linked to.
343 // That request is precisely the one the same-origin guard sees nothing wrong
344 // with, because it really did come from our own page. It also bounds the body,
345 // which this handler never did: net/http's own ceiling is 10 MiB per request,
346 // three orders of magnitude past anything this form sends.
347 //
348 // The cross-site guard used to open this list and is gone from it: it is
349 // csrf.Require on the router now (Handler), where it also covers the POST
350 // nobody has written yet.
351 //
352 // The one authority it checks is the read ACL — these handlers go on to read the
353 // proposal branch to place an anchor and to list a proposal's threads, and that
354 // is a read like any other. Which principal may *write* what is deliberately not
355 // restated: opening and resolving are the owner's alone and replying is not, the
356 // service knows both rules, and a second copy here would be a second place for
357 // them to be wrong.
358 13 func (s *Server) commentPost(w http.ResponseWriter, r *http.Request) (service.Proposal, url.Values, bool) {
359 13 if !mayRead(r) {
360 1 s.renderError(w, r, http.StatusForbidden, "you may not read this proposal")
361 1 return service.Proposal{}, nil, false
362 1 }
363 // The grant half of the same ACL. The review conversation is content, so a
364 // tokens.sr.ht token reaches it on spec:read like every other read here.
365 12 if err := readGrant(r); err != nil {
366 0 s.denyGrant(w, r, formatHTML)
367 0 return service.Proposal{}, nil, false
368 0 }
369 12 form, err := pages.FormValues(w, r, 0)
370 12 if err != nil {
371 1 s.renderError(w, r, http.StatusBadRequest, "malformed form submission")
372 1 return service.Proposal{}, nil, false
373 1 }
374 11 ref, err := spaceRefFrom(r)
375 11 if err != nil {
376 0 s.fail(w, r, err)
377 0 return service.Proposal{}, nil, false
378 0 }
379 11 id, ok := proposalIDFrom(r)
380 11 if !ok {
381 0 s.renderError(w, r, http.StatusNotFound, "no such proposal")
382 0 return service.Proposal{}, nil, false
383 0 }
384 11 p, err := s.reader.GetProposal(r.Context(), id)
385 11 if err != nil {
386 0 s.fail(w, r, err)
387 0 return service.Proposal{}, nil, false
388 0 }
389 11 if p.Space != ref {
390 1 s.renderError(w, r, http.StatusNotFound, "no such proposal in this space")
391 1 return service.Proposal{}, nil, false
392 1 }
393 10 return p, form, true
394 }
395
396 // threadOfProposal reads the "thread" field out of the submitted body and checks
397 // that it names a root thread of *this* proposal.
398 //
399 // Thread ids are global while the URL names one proposal, so without this a form
400 // could carry another proposal's thread id and have the reply land somewhere the
401 // reviewer was never looking. Matching against the roots also keeps threading
402 // flat: a reply's id is not a root, so it cannot be replied to.
403 //
404 // The values are passed in rather than read off the request, so that this field
405 // comes from the same body-only set as every other one: a "thread" appended to
406 // the URL of a legitimate form post must not be able to redirect the write.
407 5 func (s *Server) threadOfProposal(w http.ResponseWriter, r *http.Request, form url.Values, proposalID int) (int, bool) {
408 5 threadID, err := strconv.Atoi(form.Get("thread"))
409 5 if err != nil || threadID <= 0 {
410 0 s.renderError(w, r, http.StatusBadRequest, "that action names no review thread")
411 0 return 0, false
412 0 }
413 5 threads, err := s.reader.Threads(r.Context(), authn.PrincipalFromContext(r.Context()), proposalID)
414 5 if err != nil {
415 0 s.fail(w, r, err)
416 0 return 0, false
417 0 }
418 5 for _, t := range threads {
419 4 if t.Root.ID == threadID {
420 4 return threadID, true
421 4 }
422 }
423 1 s.renderError(w, r, http.StatusNotFound, "no such review thread on this proposal")
424 1 return 0, false
425 }
426
427 // backToThread redirects to the proposal page, scrolled to the thread that was
428 // just written, so a reload does not re-submit and the reviewer lands on what
429 // they said rather than at the top of a long diff.
430 4 func (s *Server) backToThread(w http.ResponseWriter, r *http.Request, p service.Proposal, threadID int) {
431 4 http.Redirect(w, r, proposalHref(p)+"#thread-"+strconv.Itoa(threadID), http.StatusSeeOther)
432 4 }
433
434 // sourceOfSide returns the revision of a document a comment on one side anchors
435 // against: the proposed text for a comment on the new side, the base for one on
436 // a block the proposal deletes.
437 4 func sourceOfSide(docs []service.ProposalDoc, path string, side core.CommentSide) ([]byte, bool) {
438 4 for _, d := range docs {
439 4 if d.Path != path {
440 0 continue
441 }
442 4 if side == core.SideOld {
443 0 return d.Base, d.Base != nil
444 0 }
445 4 return d.Proposed, true
446 }
447 0 return nil, false
448 }
449
450 // unresolvedThreads counts the threads still awaiting the owner. It is what the
451 // page states next to the approve control: an open thread suppresses policy
452 // auto-merge, so the count explains why a proposal is sitting here.
453 9 func unresolvedThreads(threads []service.Thread) int {
454 9 n := 0
455 9 for _, t := range threads {
456 6 if t.Open() {
457 5 n++
458 5 }
459 }
460 9 return n
461 }