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