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.go −128

@@ -7,66 +7,8 @@ package web
7 7
8 8 import (
9 9 "net/http"
10 "sort"
11
12 "github.com/rodent-software/vole/forge/access"
13 10 )
14 11
15 type roleOption struct {
16 Value string
17 Name string
18 Description string
19 }
20
21 var instanceRoleOptions = []roleOption{
22 {"none", "none", "no instance-wide access"},
23 {"read", "read", "read and clone public repositories"},
24 {"contribute", "contribute", "read, discuss, and push your own contrib branches"},
25 {"maintain", "maintain", "push shared branches, triage, and approve on public repositories"},
26 {"admin", "admin", "full admin on every repository, including private ones"},
27 }
28
29 var repoRoleOptions = []roleOption{
30 {"none", "none", "no repository-specific entry"},
31 {"read", "read", "clone, fetch, and browse"},
32 {"contribute", "contribute", "discuss and push your own contrib branches"},
33 {"maintain", "maintain", "push shared branches, triage, and approve"},
34 {"admin", "admin", "manage access, protected branches, and settings"},
35 }
36
37 type rolePickerData struct {
38 Name string
39 Selected string
40 Legend string
41 Help string
42 Compact bool
43 Options []roleOption
44 }
45
46 // newRolePicker is the one role-selection control. scope chooses the wording:
47 // instance roles apply to public repositories, while repository roles describe
48 // what the entry grants in one repository. compact is for table rows, where the
49 // descriptions live in the radio's accessible label rather than on screen.
50 func newRolePicker(name, selected, legend, help, scope string, compact bool) rolePickerData {
51 options := instanceRoleOptions
52 if scope == "repo" {
53 options = repoRoleOptions
54 }
55 return rolePickerData{
56 Name: name, Selected: selected, Legend: legend, Help: help,
57 Compact: compact, Options: options,
58 }
59 }
60
61 type aclRow struct {
62 Name string
63 Status string
64 Role string // repository-specific entry, or none when inherited
65 InstanceRole string
66 EffectiveRole string
67 Runner bool
68 }
69
70 12 // requireInstanceAdmin redirects an anonymous visitor to login and hides the
71 13 // admin surface from an authenticated non-admin.
72 14 func (w *Web) requireInstanceAdmin(rw http.ResponseWriter, r *http.Request) (*principal, bool) {
@@ -91,73 +33,3 @@ func (w *Web) accountStatus(name string) string {
91 33 }
92 34 return acct.Status
93 35 }
94
95 // aclRows lists only the accounts this repository actually mentions: an
96 // explicit role entry or a runner allowlist entry. The roster is not enumerated
97 // here, because a repository with three entries should not render every account
98 // on the instance.
99 func (w *Web) aclRows(a *access.ACL) []aclRow {
100 isRunner := func(id string) bool {
101 for _, runner := range a.Runners {
102 if runner == id {
103 return true
104 }
105 }
106 return false
107 }
108 role := func(id string) string {
109 if r, ok := a.Entries[id]; ok {
110 return r
111 }
112 return access.None.String()
113 }
114
115 seen := map[string]bool{}
116 var rows []aclRow
117 add := func(name string) {
118 if name == "" || seen[name] {
119 return
120 }
121 seen[name] = true
122 instance := access.None
123 instanceAdmin := false
124 if w.Ident != nil {
125 instance = w.Ident.InstanceRole(name)
126 instanceAdmin = w.Ident.IsAdmin(name)
127 }
128 rows = append(rows, aclRow{
129 Name: name, Status: w.accountStatus(name),
130 Role: role(name), Runner: isRunner(name),
131 InstanceRole: instance.String(),
132 EffectiveRole: a.EffectiveRole(name, instanceAdmin).String(),
133 })
134 }
135 for id := range a.Entries {
136 add(id)
137 }
138 for _, id := range a.Runners {
139 add(id)
140 }
141 sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name })
142 return rows
143 }
144
145 // addableAccounts are active accounts with no explicit role or runner entry in
146 // this repository. They are the choices for the "Add account" control.
147 func (w *Web) addableAccounts(rows []aclRow) []string {
148 if w.Ident == nil {
149 return nil
150 }
151 seen := map[string]bool{}
152 for _, row := range rows {
153 seen[row.Name] = true
154 }
155 var out []string
156 for _, acct := range w.Ident.All() {
157 if acct.Active() && !seen[acct.Name] {
158 out = append(out, acct.Name)
159 }
160 }
161 sort.Strings(out)
162 return out
163 }