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/write_context.go +138

@@ -0,0 +1,138 @@
1 package web
2
3 // What a browser write needs before it may touch a store.
4 //
5 // Every write handler opens the same way: who is signed in, how large the body
6 // may be, whether the request carries the session's CSRF token, which repository
7 // it names, and what that repository's rules say. Resolving that in one place is
8 // not only shorter: it means a handler cannot forget a step, and the order of the
9 // steps is the same whether the request is a fragment or a whole form post.
10
11 import (
12 "net/http"
13
14 "github.com/rodent-software/vole/forge/access"
15 "github.com/rodent-software/vole/forge/repo"
16 )
17
18 const (
19 // maxFormBody bounds a form that carries prose: an issue, a comment, a change
20 // request. It is generous for the writing and small enough that a hostile
21 // client cannot make the process hold megabytes.
22 maxFormBody = 2 << 20
23 // maxSmallForm bounds a form of names and ids: labels, assignees, a role, a
24 // repository's settings.
25 maxSmallForm = 1 << 20
26 // maxTinyForm bounds a form of one value: a ref, a run id, a key, a
27 // fingerprint.
28 maxTinyForm = 64 << 10
29 )
30
31 // writeContext is the state a write starts from. A handler that has one has
32 // already passed the session, CSRF, visibility and rule-reading steps.
33 type writeContext struct {
34 Repo *repo.Meta
35 ACL *access.ACL
36 // Name is the account the write is attributed to. It comes from the session,
37 // never from a form field: that is the point of making the browser
38 // authenticate.
39 Name string
40 }
41
42 // beginWrite resolves everything a write needs, answering the request itself when
43 // any step fails. The form is parsed here too, because every write reads it and
44 // because a failure to parse it is not something a handler should have to repeat.
45 //
46 // A repository the viewer may not see answers 404, not 403: a write must not
47 // confirm that a private repository exists any more than a page may.
48 func (w *Web) beginWrite(rw http.ResponseWriter, r *http.Request, maxBody int64) (*writeContext, bool) {
49 pr, ok := w.beginPost(rw, r, maxBody)
50 if !ok {
51 return nil, false
52 }
53 m, ok := w.open(rw, r)
54 if !ok {
55 return nil, false
56 }
57 // A failure to read the rules is a broken instance, not a refusal: answering
58 // 403 would blame the account for the server's problem.
59 acl, err := m.ACL()
60 if err != nil {
61 w.serverError(rw, r, "cannot read the access rules: "+err.Error())
62 return nil, false
63 }
64 return &writeContext{Repo: m, ACL: acl, Name: pr.account.Name}, true
65 }
66
67 // beginPost resolves a write that is scoped to the account rather than to a
68 // repository: the key and passkey forms on the account page.
69 func (w *Web) beginPost(rw http.ResponseWriter, r *http.Request, maxBody int64) (*principal, bool) {
70 pr, ok := w.requireHTMLUser(rw, r)
71 if !ok {
72 return nil, false
73 }
74 if !w.readForm(rw, r, maxBody) {
75 return nil, false
76 }
77 return pr, true
78 }
79
80 // beginAdminPost is beginPost for an instance-admin form. A non-admin gets the
81 // same 404 as a missing page, so the surface is not confirmed to exist.
82 func (w *Web) beginAdminPost(rw http.ResponseWriter, r *http.Request, maxBody int64) (*principal, bool) {
83 pr, ok := w.requireInstanceAdmin(rw, r)
84 if !ok {
85 return nil, false
86 }
87 if !w.readForm(rw, r, maxBody) {
88 return nil, false
89 }
90 return pr, true
91 }
92
93 // readForm applies the two checks every write makes on its own body and parses
94 // it. It runs after the caller has established who is posting.
95 func (w *Web) readForm(rw http.ResponseWriter, r *http.Request, maxBody int64) bool {
96 r.Body = http.MaxBytesReader(rw, r.Body, maxBody)
97 if !w.checkCSRF(r) {
98 http.Error(rw, "bad request", http.StatusForbidden)
99 return false
100 }
101 if err := r.ParseForm(); err != nil {
102 http.Error(rw, "cannot read the form", http.StatusBadRequest)
103 return false
104 }
105 return true
106 }
107
108 // The capabilities a write can require. They delegate to capabilities.go so the
109 // button a page draws and the check a handler makes are the same rule.
110 func (c *writeContext) canDiscuss() bool { return canDiscuss(c.ACL, c.Name) }
111 func (c *writeContext) canOpenChangeRequest() bool { return canOpenChangeRequest(c.ACL, c.Name) }
112 func (c *writeContext) canApproveChange() bool { return canApprove(c.ACL, c.Name) }
113 func (c *writeContext) canTriage(author string) bool { return canTriage(c.ACL, c.Name, author) }
114
115 // fragmentFor renders the section a write came from, with the message the reader
116 // should see beside the form. The message is an argument because the view carries
117 // it: the handler does not build the section until a failure actually needs it.
118 type fragmentFor func(msg string) (template string, data any)
119
120 // fail answers a write that did not happen. A browser using HTMX gets the section
121 // back with the message inside it, so the form keeps what was typed and the reason
122 // appears where the reader is looking. A plain form POST gets the message as the
123 // response body with the given status, because there is no page to re-render.
124 func (w *Web) fail(rw http.ResponseWriter, r *http.Request, status int, msg string, fragment fragmentFor) {
125 if fragment != nil && htmxRequest(r) {
126 name, data := fragment(msg)
127 w.render(rw, name, data, 0)
128 return
129 }
130 http.Error(rw, msg, status)
131 }
132
133 // refuse answers a write the rules do not allow. It never re-renders the section:
134 // the viewer does not have the control that was posted, so there is nothing to
135 // put a message beside. A fragment and a form post get the same answer.
136 func (w *Web) refuse(rw http.ResponseWriter, r *http.Request, msg string) {
137 http.Error(rw, msg, http.StatusForbidden)
138 }