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/admin_access.go +275

@@ -0,0 +1,275 @@
1 package web
2
3 // The instance-admin half of a repository's Settings page: the access rules, the
4 // review policy, and which accounts' CI results count as checks.
5 //
6 // It is a separate file from settings.go because it is a separate rule and a
7 // separate audience. A repository admin owns the description, the default branch
8 // and whether the repository is public; an instance admin owns who may do what in
9 // it. Both halves render on one page because that is where an operator looks, but
10 // an instance admin who is not a repository admin sees only this section, and the
11 // page says so.
12
13 import (
14 "net/http"
15 "sort"
16 "strconv"
17 "strings"
18
19 "github.com/rodent-software/vole/forge/access"
20 )
21
22 type aclRow struct {
23 Name string
24 Status string
25 Role string // repository-specific entry, or none when inherited
26 InstanceRole string
27 EffectiveRole string
28 Runner bool
29 }
30
31 // accessView is the access section. It is built only for an instance admin: the
32 // page renders it with {{with .Access}}, so a nil view means no section.
33 type accessView struct {
34 Default string
35 Approvals int
36 Checks bool
37 Protected string
38 Rows []aclRow
39 AddAccounts []string
40 Error string
41 Saved bool
42 Added bool
43 }
44
45 func (w *Web) accessView(r *http.Request, ctx *settingsContext) *accessView {
46 if !ctx.instanceAdmin {
47 return nil
48 }
49 v := &accessView{
50 Default: ctx.acl.Default,
51 Approvals: ctx.acl.RequiredApprovals(),
52 Checks: ctx.acl.RequiresChecks(),
53 Protected: strings.Join(ctx.acl.Protected, "\n"),
54 Rows: w.aclRows(ctx.acl),
55 }
56 v.AddAccounts = w.addableAccounts(v.Rows)
57 switch r.URL.Query().Get("saved") {
58 case "access":
59 v.Saved = true
60 case "added":
61 v.Added = true
62 }
63 return v
64 }
65
66 // SaveRepositoryAccess applies the instance-admin half of Settings: who may do
67 // what here, and what a protected branch requires before it can land.
68 func (w *Web) SaveRepositoryAccess(rw http.ResponseWriter, r *http.Request) {
69 ctx, ok := w.settingsContext(rw, r)
70 if !ok {
71 return
72 }
73 if !ctx.instanceAdmin {
74 w.notFound(rw, r)
75 return
76 }
77 m, acl, pr := ctx.m, ctx.acl, ctx.pr
78 if !w.readForm(rw, r, maxSmallForm) {
79 return
80 }
81
82 updated := *acl
83 updated.Entries = map[string]string{}
84 defaultRole, err := access.ParseRole(r.PostFormValue("default"))
85 if err != nil {
86 w.accessFailure(rw, r, ctx, err.Error())
87 return
88 }
89 updated.Default = defaultRole.String()
90 approvals, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("approvals")))
91 if err != nil || approvals < 0 {
92 w.accessFailure(rw, r, ctx, "approvals must be a non-negative number")
93 return
94 }
95 updated.ApprovalsRequired = &approvals
96 checks := r.PostFormValue("checks") != ""
97 updated.ChecksRequired = &checks
98 updated.Protected = splitLines(r.PostFormValue("protected"))
99
100 var runners []string
101 for _, row := range w.aclRows(acl) {
102 // A field the form did not submit keeps whatever the ACL already had,
103 // so a race with a newly added entry cannot silently delete it.
104 if raw, present := r.PostForm["role:"+row.Name]; present {
105 role, err := access.ParseRole(raw[0])
106 if err != nil {
107 w.accessFailure(rw, r, ctx, "account "+row.Name+": "+err.Error())
108 return
109 }
110 if role != access.None {
111 updated.Entries[row.Name] = role.String()
112 }
113 } else if existing, ok := acl.Entries[row.Name]; ok {
114 updated.Entries[row.Name] = existing
115 }
116 if r.PostFormValue("runner:"+row.Name) != "" {
117 runners = append(runners, row.Name)
118 }
119 }
120 updated.Runners = nil
121 updated.SetRunners(runners)
122 if err := m.SetACL(&updated, pr.account.Name); err != nil {
123 w.serverError(rw, r, "cannot save access rules: "+err.Error())
124 return
125 }
126 if w.Index != nil {
127 if err := w.Index.Upsert(m); err != nil {
128 w.serverError(rw, r, "cannot update the repository index: "+err.Error())
129 return
130 }
131 }
132 w.audit("web.admin.acl", pr.account.Name, m.Name)
133 http.Redirect(rw, r, "/r/"+m.Name+"/settings?saved=access#access", http.StatusSeeOther)
134 }
135
136 // AddRepositoryAccess grants one account a repository role and saves
137 // immediately. It is a separate form and endpoint from SaveRepositoryAccess so
138 // the button under the accounts table cannot be mistaken for the add control.
139 func (w *Web) AddRepositoryAccess(rw http.ResponseWriter, r *http.Request) {
140 ctx, ok := w.settingsContext(rw, r)
141 if !ok {
142 return
143 }
144 if !ctx.instanceAdmin {
145 w.notFound(rw, r)
146 return
147 }
148 m, acl, pr := ctx.m, ctx.acl, ctx.pr
149 if !w.readForm(rw, r, maxSmallForm) {
150 return
151 }
152 name := strings.TrimSpace(r.PostFormValue("new_account"))
153 if name == "" {
154 w.accessFailure(rw, r, ctx, "Choose an account to add.")
155 return
156 }
157 acct, err := w.Ident.Find(name)
158 if err != nil || !acct.Active() {
159 w.accessFailure(rw, r, ctx, "Choose an active account to add.")
160 return
161 }
162 role, err := access.ParseRole(r.PostFormValue("new_role"))
163 if err != nil {
164 w.accessFailure(rw, r, ctx, "New account "+acct.Name+": "+err.Error())
165 return
166 }
167 if role == access.None {
168 w.accessFailure(rw, r, ctx, "Choose a role to grant.")
169 return
170 }
171 acl.Entries[acct.Name] = role.String()
172 if err := m.SetACL(acl, pr.account.Name); err != nil {
173 w.serverError(rw, r, "cannot save access rules: "+err.Error())
174 return
175 }
176 if w.Index != nil {
177 if err := w.Index.Upsert(m); err != nil {
178 w.serverError(rw, r, "cannot update the repository index: "+err.Error())
179 return
180 }
181 }
182 w.audit("web.admin.acl.add", pr.account.Name, m.Name+" "+acct.Name+"="+role.String())
183 http.Redirect(rw, r, "/r/"+m.Name+"/settings?saved=added#access", http.StatusSeeOther)
184 }
185
186 // accessFailure re-renders the access form with the page's current values and a
187 // message, so a rejected role does not throw away the rest of the editor.
188 func (w *Web) accessFailure(rw http.ResponseWriter, r *http.Request, ctx *settingsContext, msg string) {
189 v := w.settingsView(r, ctx)
190 if v.Access == nil {
191 v.Access = &accessView{}
192 }
193 v.Access.Error = msg
194 w.render(rw, "settings", v, http.StatusBadRequest)
195 }
196
197 func splitLines(raw string) []string {
198 var out []string
199 for _, line := range strings.Split(raw, "\n") {
200 if line = strings.TrimSpace(line); line != "" {
201 out = append(out, line)
202 }
203 }
204 return out
205 }
206
207 // aclRows lists only the accounts this repository actually mentions: an
208 // explicit role entry or a runner allowlist entry. The roster is not enumerated
209 // here, because a repository with three entries should not render every account
210 // on the instance.
211 func (w *Web) aclRows(a *access.ACL) []aclRow {
212 isRunner := func(id string) bool {
213 for _, runner := range a.Runners {
214 if runner == id {
215 return true
216 }
217 }
218 return false
219 }
220 role := func(id string) string {
221 if r, ok := a.Entries[id]; ok {
222 return r
223 }
224 return access.None.String()
225 }
226
227 seen := map[string]bool{}
228 var rows []aclRow
229 add := func(name string) {
230 if name == "" || seen[name] {
231 return
232 }
233 seen[name] = true
234 instance := access.None
235 instanceAdmin := false
236 if w.Ident != nil {
237 instance = w.Ident.InstanceRole(name)
238 instanceAdmin = w.Ident.IsAdmin(name)
239 }
240 rows = append(rows, aclRow{
241 Name: name, Status: w.accountStatus(name),
242 Role: role(name), Runner: isRunner(name),
243 InstanceRole: instance.String(),
244 EffectiveRole: a.EffectiveRole(name, instanceAdmin).String(),
245 })
246 }
247 for id := range a.Entries {
248 add(id)
249 }
250 for _, id := range a.Runners {
251 add(id)
252 }
253 sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name })
254 return rows
255 }
256
257 // addableAccounts are active accounts with no explicit role or runner entry in
258 // this repository. They are the choices for the "Add account" control.
259 func (w *Web) addableAccounts(rows []aclRow) []string {
260 if w.Ident == nil {
261 return nil
262 }
263 seen := map[string]bool{}
264 for _, row := range rows {
265 seen[row.Name] = true
266 }
267 var out []string
268 for _, acct := range w.Ident.All() {
269 if acct.Active() && !seen[acct.Name] {
270 out = append(out, acct.Name)
271 }
272 }
273 sort.Strings(out)
274 return out
275 }