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.

Reorganise the web package around one implementation per rule

c717d4eb92b4257b05fa065918b0071f6302f53e · Keenan Nemetz · 14d 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/routes.go +196

@@ -0,0 +1,196 @@
1 package server
2
3 // The HTTP route table, grouped by the surface each route belongs to.
4 //
5 // The table is split into one function per area rather than written in one list
6 // because Go's ServeMux ranks patterns by specificity, not by registration
7 // order: when two patterns overlap (`/r/{name}/releases/{tag...}` and
8 // `/r/{name}/releases/{tag}/archive.tar`, say) they have to be read together to
9 // know which one wins. Grouping keeps a route next to the routes it competes
10 // with.
11
12 import (
13 "fmt"
14 "net/http"
15
16 "github.com/rodent-software/vole/server/web"
17 )
18
19 // routes builds the forge's HTTP surface. The catch-all is registered last for
20 // the benefit of the reader; the router itself does not care where it appears.
21 func (s *Server) routes() *http.ServeMux {
22 mux := http.NewServeMux()
23 s.assetRoutes(mux)
24 s.globalRoutes(mux)
25 s.authRoutes(mux)
26 s.adminRoutes(mux)
27 s.repoRoutes(mux)
28 s.gitRoutes(mux)
29 // Unknown paths get the same styled 404 as a missing or private repository.
30 mux.HandleFunc("/{path...}", s.Web.NotFoundPage)
31 return mux
32 }
33
34 // assetRoutes serves the embedded stylesheet, scripts, and favicon. Assets are
35 // compiled into the binary, so there is no directory to deploy beside it.
36 func (s *Server) assetRoutes(mux *http.ServeMux) {
37 mux.Handle("GET /static/", web.Static())
38 }
39
40 // globalRoutes are the destinations that are not scoped to a repository:
41 // the repository list, the instance feed, the admin status page, and the probes
42 // a reverse proxy or a monitor calls.
43 func (s *Server) globalRoutes(mux *http.ServeMux) {
44 w := s.Web
45 mux.HandleFunc("GET /{$}", w.IndexPage)
46 mux.HandleFunc("GET /activity", w.InstanceActivity)
47 mux.HandleFunc("GET /status", w.StatusPage)
48 mux.HandleFunc("GET /healthz", func(rw http.ResponseWriter, r *http.Request) {
49 rw.Header().Set("Content-Type", "text/plain")
50 fmt.Fprint(rw, "ok\n")
51 })
52 // Runner presence moved to the status page's Runners tab; the old URL is
53 // linked from notes and scripts, so it redirects rather than 404s.
54 mux.HandleFunc("GET /runners", func(rw http.ResponseWriter, r *http.Request) {
55 http.Redirect(rw, r, "/status?tab=runners", http.StatusMovedPermanently)
56 })
57 }
58
59 // authRoutes cover the passkey ceremonies, the session, and the account page.
60 // The JSON ceremony endpoints answer only POST because the browser posts the
61 // credential there; the pages they belong to are GETs.
62 func (s *Server) authRoutes(mux *http.ServeMux) {
63 w := s.Web
64 mux.HandleFunc("GET /login", w.LoginPage)
65 mux.HandleFunc("GET /dev/login", w.DevLogin)
66 mux.HandleFunc("GET /register", w.RegisterPage)
67 mux.HandleFunc("POST /auth/register/begin", w.RegisterBegin)
68 mux.HandleFunc("POST /auth/register/finish", w.RegisterFinish)
69 mux.HandleFunc("POST /auth/login/begin", w.LoginBegin)
70 mux.HandleFunc("POST /auth/login/finish", w.LoginFinish)
71 mux.HandleFunc("POST /logout", w.Logout)
72 mux.HandleFunc("GET /invite/{token}", w.InvitePage)
73 mux.HandleFunc("POST /auth/invite/begin", w.InviteBegin)
74 mux.HandleFunc("POST /auth/invite/finish", w.InviteFinish)
75 mux.HandleFunc("GET /account", w.AccountPage)
76 mux.HandleFunc("POST /account/keys", w.AddAccountKey)
77 mux.HandleFunc("POST /account/keys/remove", w.RemoveAccountKey)
78 mux.HandleFunc("POST /account/passkeys/begin", w.AddPasskeyBegin)
79 mux.HandleFunc("POST /account/passkeys/finish", w.AddPasskeyFinish)
80 }
81
82 // adminRoutes are the instance-wide pages. Every handler checks the instance
83 // admin role itself; hiding a link is presentation, never authorization.
84 func (s *Server) adminRoutes(mux *http.ServeMux) {
85 w := s.Web
86 mux.HandleFunc("GET /repos/new", w.AdminNewRepoPage)
87 mux.HandleFunc("POST /repos", w.CreateAdminRepo)
88 mux.HandleFunc("GET /users", w.AdminUsersPage)
89 mux.HandleFunc("POST /users", w.CreateAdminUser)
90 mux.HandleFunc("GET /users/{name}", w.AdminUserPage)
91 mux.HandleFunc("POST /users/{name}/role", w.SetAdminUserRole)
92 mux.HandleFunc("POST /users/{name}/retire", w.RetireAdminUser)
93 mux.HandleFunc("POST /users/{name}/invite", w.InviteAdminUser)
94 mux.HandleFunc("POST /users/{name}/sessions/revoke", w.RevokeAdminUserSessions)
95 mux.HandleFunc("POST /users/{name}/keys/remove", w.RemoveAdminUserKey)
96 }
97
98 // repoRoutes are every page and write below /r/{name}. A private repository
99 // answers 404 here exactly as a missing one does; the handlers share that rule
100 // through web.open.
101 func (s *Server) repoRoutes(mux *http.ServeMux) {
102 w := s.Web
103
104 // Repository summary and feed.
105 mux.HandleFunc("GET /r/{name}", w.Overview)
106 mux.HandleFunc("GET /r/{name}/activity", w.Activity)
107
108 // Code browsing: one route per view of a ref, with the path as a wildcard
109 // because a branch name may contain "/" and so may a file path.
110 mux.HandleFunc("GET /r/{name}/code", w.Code)
111 mux.HandleFunc("GET /r/{name}/search", w.CodeSearch)
112 mux.HandleFunc("GET /r/{name}/compare", w.Compare)
113 mux.HandleFunc("GET /r/{name}/compare/{range...}", w.Compare)
114 mux.HandleFunc("GET /r/{name}/tree/{ref}", w.Tree)
115 mux.HandleFunc("GET /r/{name}/tree/{ref}/{path...}", w.Tree)
116 mux.HandleFunc("GET /r/{name}/blob/{ref}/{path...}", w.Blob)
117 mux.HandleFunc("GET /r/{name}/raw/{ref}/{path...}", w.Raw)
118 mux.HandleFunc("GET /r/{name}/commits/{ref}", w.Commits)
119 mux.HandleFunc("GET /r/{name}/commits/{ref}/{path...}", w.FileHistory)
120 mux.HandleFunc("GET /r/{name}/blame/{ref}/{path...}", w.Blame)
121 mux.HandleFunc("GET /r/{name}/commit/{sha}", w.CommitView)
122
123 // Issues: the list and detail pages, then the writes that replace a section
124 // of the detail page rather than navigating.
125 mux.HandleFunc("GET /r/{name}/issues", w.Issues)
126 mux.HandleFunc("GET /r/{name}/issues/new", w.NewIssuePage)
127 mux.HandleFunc("POST /r/{name}/issues", w.CreateIssue)
128 mux.HandleFunc("GET /r/{name}/issues/{id}", w.IssueView)
129 mux.HandleFunc("POST /r/{name}/issues/{id}/comments", w.AddIssueComment)
130 mux.HandleFunc("POST /r/{name}/issues/{id}/comments/{comment}/edit", w.EditIssueComment)
131 mux.HandleFunc("POST /r/{name}/issues/{id}/comments/{comment}/delete", w.DeleteIssueComment)
132 mux.HandleFunc("POST /r/{name}/issues/{id}/state", w.ChangeIssueState)
133 mux.HandleFunc("POST /r/{name}/issues/{id}/labels", w.SetIssueLabels)
134 mux.HandleFunc("POST /r/{name}/issues/{id}/assignees", w.SetIssueAssignees)
135
136 // Change requests: conversation, the Files tab, the Runs tab, and the
137 // approval and comment writes.
138 mux.HandleFunc("GET /r/{name}/requests", w.Requests)
139 mux.HandleFunc("GET /r/{name}/requests/new", w.NewChangePage)
140 mux.HandleFunc("POST /r/{name}/requests", w.CreateChangeRequest)
141 mux.HandleFunc("GET /r/{name}/requests/{id}", w.RequestView)
142 mux.HandleFunc("GET /r/{name}/requests/{id}/files", w.RequestFiles)
143 mux.HandleFunc("GET /r/{name}/requests/{id}/files/{path...}", w.RequestFiles)
144 mux.HandleFunc("GET /r/{name}/requests/{id}/runs", w.RequestRuns)
145 mux.HandleFunc("GET /r/{name}/requests/{id}/comment-form", w.RequestCommentForm)
146 mux.HandleFunc("POST /r/{name}/requests/{id}/approve", w.ApproveRequest)
147 mux.HandleFunc("POST /r/{name}/requests/{id}/comments", w.AddRequestComment)
148 mux.HandleFunc("POST /r/{name}/requests/{id}/comments/{comment}/edit", w.EditRequestComment)
149 mux.HandleFunc("POST /r/{name}/requests/{id}/comments/{comment}/delete", w.DeleteRequestComment)
150 mux.HandleFunc("POST /r/{name}/requests/{id}/state", w.ChangeRequestState)
151 mux.HandleFunc("POST /r/{name}/ci/allow", w.AllowContributorCI)
152
153 // Releases. A tag may contain "/", so the detail route takes the rest of the
154 // path; the archive routes are more specific and win for their suffixes.
155 mux.HandleFunc("GET /r/{name}/releases", w.Releases)
156 mux.HandleFunc("GET /r/{name}/releases/new", w.ReleaseNewPage)
157 mux.HandleFunc("POST /r/{name}/releases", w.CreateRelease)
158 mux.HandleFunc("GET /r/{name}/releases/edit", w.ReleaseEditPage)
159 mux.HandleFunc("POST /r/{name}/releases/edit", w.UpdateRelease)
160 mux.HandleFunc("GET /r/{name}/releases/{tag...}", w.ReleaseView)
161 mux.HandleFunc("GET /r/{name}/releases/{tag}/archive.tar", w.ReleaseTar)
162 mux.HandleFunc("GET /r/{name}/releases/{tag}/archive.tar.gz", w.ReleaseTarGz)
163 mux.HandleFunc("GET /r/{name}/releases/{tag}/artifacts/{artifact}", w.ReleaseArtifact)
164
165 // CI: the run list, the workflow list, and one run with its log and
166 // artifacts. Cancel and rerun are the only writes.
167 mux.HandleFunc("GET /r/{name}/runs", w.RunsPage)
168 mux.HandleFunc("GET /r/{name}/workflows", w.WorkflowsPage)
169 mux.HandleFunc("GET /r/{name}/runs/{id}", w.RunPage)
170 mux.HandleFunc("POST /r/{name}/runs/{id}/cancel", w.CancelRun)
171 mux.HandleFunc("POST /r/{name}/runs/{id}/rerun", w.Rerun)
172 mux.HandleFunc("GET /r/{name}/runs/{id}/log", w.RunLog)
173 mux.HandleFunc("GET /r/{name}/runs/{id}/artifacts/{artifact}", w.RunArtifact)
174
175 // Locks and settings. Settings carries both the repository's own settings
176 // and, for an instance admin, the access rules.
177 mux.HandleFunc("GET /r/{name}/locks", w.LocksPage)
178 mux.HandleFunc("GET /r/{name}/settings", w.SettingsPage)
179 mux.HandleFunc("POST /r/{name}/settings", w.SaveSettings)
180 mux.HandleFunc("POST /r/{name}/settings/access", w.SaveRepositoryAccess)
181 mux.HandleFunc("POST /r/{name}/settings/access/add", w.AddRepositoryAccess)
182 mux.HandleFunc("POST /r/{name}/settings/rename", w.RenameRepository)
183 mux.HandleFunc("POST /r/{name}/settings/delete", w.DeleteRepository)
184 }
185
186 // gitRoutes are the Git transports. Reads are anonymous and filtered by the same
187 // visibility rule the pages use; writes never arrive here, they come over SSH.
188 func (s *Server) gitRoutes(mux *http.ServeMux) {
189 // Registered without a method so git's POSTs reach the proxy.
190 mux.Handle("/git/", s.Proxy)
191 // LFS reads share the same read rule and the same URLs as clone; uploads are
192 // refused here and happen over SSH.
193 mux.HandleFunc("POST /git/{name}/info/lfs/objects/batch", s.LFSBatch)
194 mux.HandleFunc("GET /git/{name}/info/lfs/objects/{oid}", s.LFSObject)
195 mux.HandleFunc("HEAD /git/{name}/info/lfs/objects/{oid}", s.LFSObject)
196 }