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