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/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.

Blame · server/web/write_context.go

Line attribution on main · 159 line(s)

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 }