vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
|
c717d4eb
Keenan Nemetz
13d ago
|
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 | ||
|
d37d301f
Keenan Nemetz
19h ago
|
14 | "rodent.software/vole/forge/access" |
| 15 | "rodent.software/vole/forge/repo" | |
|
c717d4eb
Keenan Nemetz
13d ago
|
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 | } | |
|
e5542ac6
Keenan Nemetz
12d ago
|
64 | // Only the untrusted role is budgeted. A repository's pages parse every issue |
| 65 | // and comment anybody wrote, so a contributor posting in a loop is the case this | |
| 66 | // bounds; a maintainer or admin can already change these rules, push shared | |
| 67 | // branches and run the instance, and slowing their work would bound nobody who | |
| 68 | // could not do worse. | |
| 69 | if writeBudgeted(acl.RoleFor(pr.account.Name)) && | |
| 70 | !w.limitForm(rw, r, w.limits.write, "write:"+pr.account.Name) { | |
|
9e8cc155
Keenan Nemetz
12d ago
|
71 | return nil, false |
| 72 | } | |
|
c717d4eb
Keenan Nemetz
13d ago
|
73 | return &writeContext{Repo: m, ACL: acl, Name: pr.account.Name}, true |
| 74 | } | |
| 75 | ||
|
e5542ac6
Keenan Nemetz
12d ago
|
76 | // writeBudgeted reports whether this account's browser writes are counted against |
| 77 | // the contributor budget. Maintainers and admins are trusted roles: the instance's | |
| 78 | // threat model treats them as able to write metadata and approve changes already, | |
| 79 | // so a write budget would only get in their way. The contribute role is the | |
| 80 | // untrusted one, and read-only accounts below it cannot write at all. | |
| 81 | func writeBudgeted(role access.Role) bool { | |
| 82 | return !role.AtLeast(access.Maintain) | |
| 83 | } | |
| 84 | ||
|
c717d4eb
Keenan Nemetz
13d ago
|
85 | // beginPost resolves a write that is scoped to the account rather than to a |
| 86 | // repository: the key and passkey forms on the account page. | |
| 87 | func (w *Web) beginPost(rw http.ResponseWriter, r *http.Request, maxBody int64) (*principal, bool) { | |
| 88 | pr, ok := w.requireHTMLUser(rw, r) | |
| 89 | if !ok { | |
| 90 | return nil, false | |
| 91 | } | |
| 92 | if !w.readForm(rw, r, maxBody) { | |
| 93 | return nil, false | |
| 94 | } | |
| 95 | return pr, true | |
| 96 | } | |
| 97 | ||
| 98 | // beginAdminPost is beginPost for an instance-admin form. A non-admin gets the | |
| 99 | // same 404 as a missing page, so the surface is not confirmed to exist. | |
| 100 | func (w *Web) beginAdminPost(rw http.ResponseWriter, r *http.Request, maxBody int64) (*principal, bool) { | |
| 101 | pr, ok := w.requireInstanceAdmin(rw, r) | |
| 102 | if !ok { | |
| 103 | return nil, false | |
| 104 | } | |
| 105 | if !w.readForm(rw, r, maxBody) { | |
| 106 | return nil, false | |
| 107 | } | |
| 108 | return pr, true | |
| 109 | } | |
| 110 | ||
| 111 | // readForm applies the two checks every write makes on its own body and parses | |
| 112 | // it. It runs after the caller has established who is posting. | |
| 113 | func (w *Web) readForm(rw http.ResponseWriter, r *http.Request, maxBody int64) bool { | |
|
edf9db6e
Keenan Nemetz
13d ago
|
114 | // The bound comes before the parse, so the CSRF check below can read the form |
| 115 | // value without a client deciding how much of it there is. | |
|
c717d4eb
Keenan Nemetz
13d ago
|
116 | r.Body = http.MaxBytesReader(rw, r.Body, maxBody) |
| 117 | if err := r.ParseForm(); err != nil { | |
| 118 | http.Error(rw, "cannot read the form", http.StatusBadRequest) | |
| 119 | return false | |
| 120 | } | |
|
edf9db6e
Keenan Nemetz
13d ago
|
121 | if !w.csrfOK(r) { |
| 122 | http.Error(rw, "bad request", http.StatusForbidden) | |
| 123 | return false | |
| 124 | } | |
|
c717d4eb
Keenan Nemetz
13d ago
|
125 | return true |
| 126 | } | |
| 127 | ||
| 128 | // The capabilities a write can require. They delegate to capabilities.go so the | |
| 129 | // button a page draws and the check a handler makes are the same rule. | |
| 130 | func (c *writeContext) canDiscuss() bool { return canDiscuss(c.ACL, c.Name) } | |
| 131 | func (c *writeContext) canOpenChangeRequest() bool { return canOpenChangeRequest(c.ACL, c.Name) } | |
| 132 | func (c *writeContext) canApproveChange() bool { return canApprove(c.ACL, c.Name) } | |
|
38b30251
Keenan Nemetz
13d ago
|
133 | func (c *writeContext) canMergeChange() bool { return canMerge(c.ACL, c.Name) } |
|
c717d4eb
Keenan Nemetz
13d ago
|
134 | func (c *writeContext) canTriage(author string) bool { return canTriage(c.ACL, c.Name, author) } |
| 135 | ||
| 136 | // fragmentFor renders the section a write came from, with the message the reader | |
| 137 | // should see beside the form. The message is an argument because the view carries | |
| 138 | // it: the handler does not build the section until a failure actually needs it. | |
| 139 | type fragmentFor func(msg string) (template string, data any) | |
| 140 | ||
| 141 | // fail answers a write that did not happen. A browser using HTMX gets the section | |
| 142 | // back with the message inside it, so the form keeps what was typed and the reason | |
| 143 | // appears where the reader is looking. A plain form POST gets the message as the | |
| 144 | // response body with the given status, because there is no page to re-render. | |
| 145 | func (w *Web) fail(rw http.ResponseWriter, r *http.Request, status int, msg string, fragment fragmentFor) { | |
| 146 | if fragment != nil && htmxRequest(r) { | |
| 147 | name, data := fragment(msg) | |
| 148 | w.render(rw, name, data, 0) | |
| 149 | return | |
| 150 | } | |
| 151 | http.Error(rw, msg, status) | |
| 152 | } | |
| 153 | ||
| 154 | // refuse answers a write the rules do not allow. It never re-renders the section: | |
| 155 | // the viewer does not have the control that was posted, so there is nothing to | |
| 156 | // put a message beside. A fragment and a form post get the same answer. | |
| 157 | func (w *Web) refuse(rw http.ResponseWriter, r *http.Request, msg string) { | |
| 158 | http.Error(rw, msg, http.StatusForbidden) | |
| 159 | } |