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.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 17m ago
4b8c9e1d
| 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 | "rodent.software/vole/forge/access" |
| 15 | "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 | // 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) { |
| 71 | return nil, false |
| 72 | } |
| 73 | return &writeContext{Repo: m, ACL: acl, Name: pr.account.Name}, true |
| 74 | } |
| 75 | |
| 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 | |
| 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 { |
| 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. |
| 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 | } |
| 121 | if !w.csrfOK(r) { |
| 122 | http.Error(rw, "bad request", http.StatusForbidden) |
| 123 | return false |
| 124 | } |
| 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) } |
| 133 | func (c *writeContext) canMergeChange() bool { return canMerge(c.ACL, c.Name) } |
| 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 | } |
| 160 |