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/server.go −121

@@ -157,127 +157,6 @@ func New(cfg *config.Config, binPath string, log *slog.Logger) (*Server, error)
157 157 return s, nil
158 158 }
159 159
160 func (s *Server) routes() *http.ServeMux {
161 mux := http.NewServeMux()
162 w := s.Web
163
164 mux.Handle("GET /static/", web.Static())
165 mux.HandleFunc("GET /{$}", w.IndexPage)
166 mux.HandleFunc("GET /activity", w.InstanceActivity)
167 mux.HandleFunc("GET /login", w.LoginPage)
168 mux.HandleFunc("GET /dev/login", w.DevLogin)
169 mux.HandleFunc("GET /register", w.RegisterPage)
170 mux.HandleFunc("POST /auth/register/begin", w.RegisterBegin)
171 mux.HandleFunc("POST /auth/register/finish", w.RegisterFinish)
172 mux.HandleFunc("POST /auth/login/begin", w.LoginBegin)
173 mux.HandleFunc("POST /auth/login/finish", w.LoginFinish)
174 mux.HandleFunc("POST /logout", w.Logout)
175 mux.HandleFunc("GET /invite/{token}", w.InvitePage)
176 mux.HandleFunc("POST /auth/invite/begin", w.InviteBegin)
177 mux.HandleFunc("POST /auth/invite/finish", w.InviteFinish)
178 mux.HandleFunc("GET /account", w.AccountPage)
179 mux.HandleFunc("POST /account/keys", w.AddAccountKey)
180 mux.HandleFunc("POST /account/keys/remove", w.RemoveAccountKey)
181 mux.HandleFunc("POST /account/passkeys/begin", w.AddPasskeyBegin)
182 mux.HandleFunc("POST /account/passkeys/finish", w.AddPasskeyFinish)
183 mux.HandleFunc("GET /auth.js", w.AuthJS)
184 mux.HandleFunc("GET /runners", func(rw http.ResponseWriter, r *http.Request) {
185 http.Redirect(rw, r, "/status?tab=runners", http.StatusMovedPermanently)
186 })
187 mux.HandleFunc("GET /repos/new", w.AdminNewRepoPage)
188 mux.HandleFunc("POST /repos", w.CreateAdminRepo)
189 mux.HandleFunc("GET /users", w.AdminUsersPage)
190 mux.HandleFunc("POST /users", w.CreateAdminUser)
191 mux.HandleFunc("GET /users/{name}", w.AdminUserPage)
192 mux.HandleFunc("POST /users/{name}/role", w.SetAdminUserRole)
193 mux.HandleFunc("POST /users/{name}/retire", w.RetireAdminUser)
194 mux.HandleFunc("POST /users/{name}/invite", w.InviteAdminUser)
195 mux.HandleFunc("POST /users/{name}/sessions/revoke", w.RevokeAdminUserSessions)
196 mux.HandleFunc("POST /users/{name}/keys/remove", w.RemoveAdminUserKey)
197 mux.HandleFunc("GET /r/{name}", w.Overview)
198 mux.HandleFunc("GET /r/{name}/activity", w.Activity)
199 mux.HandleFunc("GET /r/{name}/code", w.Code)
200 mux.HandleFunc("GET /r/{name}/search", w.CodeSearch)
201 mux.HandleFunc("GET /r/{name}/compare", w.Compare)
202 mux.HandleFunc("GET /r/{name}/compare/{range...}", w.Compare)
203 mux.HandleFunc("GET /r/{name}/tree/{ref}", w.Tree)
204 mux.HandleFunc("GET /r/{name}/tree/{ref}/{path...}", w.Tree)
205 mux.HandleFunc("GET /r/{name}/blob/{ref}/{path...}", w.Blob)
206 mux.HandleFunc("GET /r/{name}/raw/{ref}/{path...}", w.Raw)
207 mux.HandleFunc("GET /r/{name}/commits/{ref}", w.Commits)
208 mux.HandleFunc("GET /r/{name}/commits/{ref}/{path...}", w.FileHistory)
209 mux.HandleFunc("GET /r/{name}/blame/{ref}/{path...}", w.Blame)
210 mux.HandleFunc("GET /r/{name}/commit/{sha}", w.CommitView)
211 mux.HandleFunc("GET /r/{name}/issues", w.Issues)
212 mux.HandleFunc("GET /r/{name}/issues/new", w.NewIssuePage)
213 mux.HandleFunc("POST /r/{name}/issues", w.CreateIssue)
214 mux.HandleFunc("GET /r/{name}/issues/{id}", w.IssueView)
215 mux.HandleFunc("POST /r/{name}/issues/{id}/comments", w.AddIssueComment)
216 mux.HandleFunc("POST /r/{name}/issues/{id}/comments/{comment}/edit", w.EditIssueComment)
217 mux.HandleFunc("POST /r/{name}/issues/{id}/comments/{comment}/delete", w.DeleteIssueComment)
218 mux.HandleFunc("POST /r/{name}/issues/{id}/state", w.ChangeIssueState)
219 mux.HandleFunc("POST /r/{name}/issues/{id}/labels", w.SetIssueLabels)
220 mux.HandleFunc("POST /r/{name}/issues/{id}/assignees", w.SetIssueAssignees)
221 mux.HandleFunc("GET /r/{name}/requests", w.Requests)
222 mux.HandleFunc("GET /r/{name}/requests/new", w.NewChangePage)
223 mux.HandleFunc("POST /r/{name}/requests", w.CreateChangeRequest)
224 mux.HandleFunc("GET /r/{name}/requests/{id}", w.RequestView)
225 mux.HandleFunc("GET /r/{name}/requests/{id}/files", w.RequestFiles)
226 mux.HandleFunc("GET /r/{name}/requests/{id}/files/{path...}", w.RequestFiles)
227 mux.HandleFunc("GET /r/{name}/requests/{id}/runs", w.RequestRuns)
228 mux.HandleFunc("GET /r/{name}/requests/{id}/comment-form", w.RequestCommentForm)
229 mux.HandleFunc("POST /r/{name}/requests/{id}/approve", w.ApproveRequest)
230 mux.HandleFunc("POST /r/{name}/requests/{id}/comments", w.AddRequestComment)
231 mux.HandleFunc("POST /r/{name}/requests/{id}/comments/{comment}/edit", w.EditRequestComment)
232 mux.HandleFunc("POST /r/{name}/requests/{id}/comments/{comment}/delete", w.DeleteRequestComment)
233 mux.HandleFunc("POST /r/{name}/requests/{id}/state", w.ChangeRequestState)
234 mux.HandleFunc("POST /r/{name}/ci/allow", w.AllowContributorCI)
235 // A tag may contain "/", so the detail route takes the rest of the path; the
236 // two archive routes are more specific and win for their suffixes.
237 mux.HandleFunc("GET /r/{name}/locks", w.LocksPage)
238 mux.HandleFunc("GET /r/{name}/settings", w.SettingsPage)
239 mux.HandleFunc("POST /r/{name}/settings", w.SaveSettings)
240 mux.HandleFunc("POST /r/{name}/settings/access", w.SaveRepositoryAccess)
241 mux.HandleFunc("POST /r/{name}/settings/access/add", w.AddRepositoryAccess)
242 mux.HandleFunc("POST /r/{name}/settings/rename", w.RenameRepository)
243 mux.HandleFunc("POST /r/{name}/settings/delete", w.DeleteRepository)
244 mux.HandleFunc("GET /r/{name}/releases", w.Releases)
245 mux.HandleFunc("GET /r/{name}/releases/new", w.ReleaseNewPage)
246 mux.HandleFunc("POST /r/{name}/releases", w.CreateRelease)
247 mux.HandleFunc("GET /r/{name}/releases/edit", w.ReleaseEditPage)
248 mux.HandleFunc("POST /r/{name}/releases/edit", w.UpdateRelease)
249 mux.HandleFunc("GET /r/{name}/releases/{tag...}", w.ReleaseView)
250 mux.HandleFunc("GET /r/{name}/releases/{tag}/archive.tar", w.ReleaseTar)
251 mux.HandleFunc("GET /r/{name}/releases/{tag}/archive.tar.gz", w.ReleaseTarGz)
252 mux.HandleFunc("GET /r/{name}/releases/{tag}/artifacts/{artifact}", w.ReleaseArtifact)
253 mux.HandleFunc("GET /r/{name}/runs", w.RunsPage)
254 mux.HandleFunc("GET /r/{name}/workflows", w.WorkflowsPage)
255 mux.HandleFunc("GET /r/{name}/runs/{id}", w.RunPage)
256 mux.HandleFunc("POST /r/{name}/runs/{id}/cancel", w.CancelRun)
257 mux.HandleFunc("POST /r/{name}/runs/{id}/rerun", w.Rerun)
258 mux.HandleFunc("GET /r/{name}/runs/{id}/log", w.RunLog)
259 mux.HandleFunc("GET /r/{name}/runs/{id}/artifacts/{artifact}", w.RunArtifact)
260
261 // Unknown paths get the same styled 404 as a missing or private repository.
262 // More specific patterns above still win; this only catches the rest.
263 mux.HandleFunc("/{path...}", w.NotFoundPage)
264
265 mux.HandleFunc("GET /status", w.StatusPage)
266 mux.HandleFunc("GET /healthz", func(rw http.ResponseWriter, r *http.Request) {
267 rw.Header().Set("Content-Type", "text/plain")
268 fmt.Fprint(rw, "ok\n")
269 })
270
271 // Anonymous, read-only git. Registered without a method so git's POSTs reach it.
272 mux.Handle("/git/", s.Proxy)
273 // LFS reads share the same read rule and the same URLs as clone; uploads are
274 // refused here and happen over SSH.
275 mux.HandleFunc("POST /git/{name}/info/lfs/objects/batch", s.LFSBatch)
276 mux.HandleFunc("GET /git/{name}/info/lfs/objects/{oid}", s.LFSObject)
277 mux.HandleFunc("HEAD /git/{name}/info/lfs/objects/{oid}", s.LFSObject)
278 return mux
279 }
280
281 160 // Handler returns the HTTP surface. Site hosts are dispatched first: a request
282 161 // for <repo>.<pages_domain> is a page, and everything else is the forge UI.
283 162 func (s *Server) Handler() http.Handler {