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.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 1h ago
4b8c9e1d
| 1 | package server |
| 2 | |
| 3 | // The HTTP route table, grouped by the surface each route belongs to. |
| 4 | // |
| 5 | // A repository owns the root of the URL space: `/<repository>/issues/12` is a |
| 6 | // page, `/<repository>.git` is its Git transport. The forge's own pages live |
| 7 | // under `/-/`, a prefix no repository name can start with, so the two spaces can |
| 8 | // never collide and no route has to know about repository names. |
| 9 | // |
| 10 | // The table is split into one function per area rather than written in one list |
| 11 | // because Go's ServeMux ranks patterns by specificity, not by registration |
| 12 | // order: when two patterns overlap (`/{name}/releases/{tag...}` and |
| 13 | // `/{name}/releases/{tag}/archive.tar`, say) they have to be read together to |
| 14 | // know which one wins. Grouping keeps a route next to the routes it competes |
| 15 | // with. |
| 16 | |
| 17 | import ( |
| 18 | "fmt" |
| 19 | "net/http" |
| 20 | ) |
| 21 | |
| 22 | // routes builds the forge's HTTP surface. The catch-all is registered last for |
| 23 | // the benefit of the reader; the router itself does not care where it appears. |
| 24 | func (s *Server) routes() *http.ServeMux { |
| 25 | mux := http.NewServeMux() |
| 26 | s.assetRoutes(mux) |
| 27 | s.globalRoutes(mux) |
| 28 | s.authRoutes(mux) |
| 29 | s.adminRoutes(mux) |
| 30 | s.repoRoutes(mux) |
| 31 | // Unknown paths get the same styled 404 as a missing or private repository. |
| 32 | mux.HandleFunc("/{path...}", s.Web.NotFoundPage) |
| 33 | return mux |
| 34 | } |
| 35 | |
| 36 | // assetRoutes serves the embedded stylesheet, scripts, and favicon. Assets are |
| 37 | // compiled into the binary, so there is no directory to deploy beside it. |
| 38 | func (s *Server) assetRoutes(mux *http.ServeMux) { |
| 39 | mux.Handle("GET /-/static/", s.Web.Static()) |
| 40 | } |
| 41 | |
| 42 | // globalRoutes are the destinations that are not scoped to a repository: |
| 43 | // the repository list, the instance feed, the redirects to the Admin page, and the |
| 44 | // probes a reverse proxy or a monitor calls. |
| 45 | func (s *Server) globalRoutes(mux *http.ServeMux) { |
| 46 | w := s.Web |
| 47 | mux.HandleFunc("GET /{$}", w.IndexPage) |
| 48 | mux.HandleFunc("GET /-/activity", w.InstanceActivity) |
| 49 | // Crawlers discover the public repositories and pages through the sitemap. |
| 50 | // robots.txt names the paths worth fetching and points at the index; the |
| 51 | // per-repository files are below /{name}, where their URLs belong. The two |
| 52 | // crawler files stay at the root because a crawler only looks there, which |
| 53 | // also makes those three names unavailable to a repository. |
| 54 | mux.HandleFunc("GET /robots.txt", w.Robots) |
| 55 | mux.HandleFunc("GET /sitemap.xml", w.SitemapIndex) |
| 56 | mux.HandleFunc("GET /sitemap-pages.xml", w.SitemapPages) |
| 57 | // Accounts are public on this instance: the directory lists everybody, and a |
| 58 | // profile answers for any name in it. Their activity is not public — every |
| 59 | // count on those pages follows the same read rule as the repository it came |
| 60 | // from — so these are ordinary global reads, not an admin surface. The |
| 61 | // directory answers both spellings so `/-/u` does not redirect to `/-/u/`. |
| 62 | mux.HandleFunc("GET /-/u", w.PeoplePage) |
| 63 | mux.HandleFunc("GET /-/u/{$}", w.PeoplePage) |
| 64 | mux.HandleFunc("GET /-/u/{name}", w.ProfilePage) |
| 65 | mux.HandleFunc("GET /-/search/palette", w.SearchPalette) |
| 66 | // The status tabs moved onto the Admin page; old links redirect to the tab they |
| 67 | // named so a bookmark lands where it used to. |
| 68 | mux.HandleFunc("GET /-/status", w.StatusRedirect) |
| 69 | mux.HandleFunc("GET /-/healthz", func(rw http.ResponseWriter, r *http.Request) { |
| 70 | rw.Header().Set("Content-Type", "text/plain") |
| 71 | fmt.Fprint(rw, "ok\n") |
| 72 | }) |
| 73 | // Runner presence moved to the Admin page's Runners tab; the old URL is |
| 74 | // linked from notes and scripts, so it redirects rather than 404s. |
| 75 | mux.HandleFunc("GET /-/runners", func(rw http.ResponseWriter, r *http.Request) { |
| 76 | http.Redirect(rw, r, "/-/admin?tab=runners", http.StatusMovedPermanently) |
| 77 | }) |
| 78 | } |
| 79 | |
| 80 | // authRoutes cover the passkey ceremonies, the session, and the account page. |
| 81 | // The JSON ceremony endpoints answer only POST because the browser posts the |
| 82 | // credential there; the pages they belong to are GETs. |
| 83 | func (s *Server) authRoutes(mux *http.ServeMux) { |
| 84 | w := s.Web |
| 85 | mux.HandleFunc("GET /-/login", w.LoginPage) |
| 86 | mux.HandleFunc("GET /-/dev/login", w.DevLogin) |
| 87 | mux.HandleFunc("GET /-/register", w.RegisterPage) |
| 88 | mux.HandleFunc("POST /-/auth/register/begin", w.RegisterBegin) |
| 89 | mux.HandleFunc("POST /-/auth/register/finish", w.RegisterFinish) |
| 90 | mux.HandleFunc("POST /-/auth/login/begin", w.LoginBegin) |
| 91 | mux.HandleFunc("POST /-/auth/login/finish", w.LoginFinish) |
| 92 | mux.HandleFunc("POST /-/logout", w.Logout) |
| 93 | mux.HandleFunc("GET /-/invite", w.InvitePage) |
| 94 | // Enrollment links used to carry the token in the path. They still work, but the |
| 95 | // token is moved into the fragment on the way, where no log can see it. |
| 96 | mux.HandleFunc("GET /-/invite/{token}", w.LegacyInvitePage) |
| 97 | mux.HandleFunc("POST /-/auth/invite/begin", w.InviteBegin) |
| 98 | mux.HandleFunc("POST /-/auth/invite/finish", w.InviteFinish) |
| 99 | mux.HandleFunc("GET /-/account", w.AccountPage) |
| 100 | mux.HandleFunc("POST /-/account/keys", w.AddAccountKey) |
| 101 | mux.HandleFunc("POST /-/account/keys/remove", w.RemoveAccountKey) |
| 102 | mux.HandleFunc("POST /-/account/passkeys/rename", w.RenameAccountPasskey) |
| 103 | mux.HandleFunc("POST /-/account/passkeys/remove", w.RemoveAccountPasskey) |
| 104 | mux.HandleFunc("POST /-/account/passkeys/begin", w.AddPasskeyBegin) |
| 105 | mux.HandleFunc("POST /-/account/passkeys/finish", w.AddPasskeyFinish) |
| 106 | } |
| 107 | |
| 108 | // adminRoutes are the instance-wide pages. Every handler checks the instance |
| 109 | // admin role itself; hiding a link is presentation, never authorization. |
| 110 | func (s *Server) adminRoutes(mux *http.ServeMux) { |
| 111 | w := s.Web |
| 112 | mux.HandleFunc("GET /-/admin", w.AdminPage) |
| 113 | mux.HandleFunc("POST /-/admin/instance/repo", w.CreateInstanceRepo) |
| 114 | mux.HandleFunc("GET /-/repos/new", w.AdminNewRepoPage) |
| 115 | mux.HandleFunc("POST /-/repos", w.CreateAdminRepo) |
| 116 | // The roster is the Accounts tab now; /users stays as the URL the account |
| 117 | // detail pages are still below. |
| 118 | mux.HandleFunc("GET /-/users", w.AdminUsersPage) |
| 119 | mux.HandleFunc("POST /-/users", w.CreateAdminUser) |
| 120 | mux.HandleFunc("POST /-/users/bots", w.CreateAdminBot) |
| 121 | mux.HandleFunc("GET /-/users/{name}", w.AdminUserPage) |
| 122 | mux.HandleFunc("POST /-/users/{name}/role", w.SetAdminUserRole) |
| 123 | mux.HandleFunc("POST /-/users/{name}/retire", w.RetireAdminUser) |
| 124 | mux.HandleFunc("POST /-/users/{name}/invite", w.InviteAdminUser) |
| 125 | mux.HandleFunc("POST /-/users/{name}/sessions/revoke", w.RevokeAdminUserSessions) |
| 126 | mux.HandleFunc("POST /-/users/{name}/keys/remove", w.RemoveAdminUserKey) |
| 127 | } |
| 128 | |
| 129 | // repoRoutes are every page and write below /{name}. A private repository |
| 130 | // answers 404 here exactly as a missing one does; the handlers share that rule |
| 131 | // through web.open. These patterns overlap the forge's `/-/` routes only in |
| 132 | // shape; the literal `-` prefix is more specific, so it always wins. |
| 133 | func (s *Server) repoRoutes(mux *http.ServeMux) { |
| 134 | w := s.Web |
| 135 | |
| 136 | // Repository summary and feed, and the header's open counts. The tabs poll |
| 137 | // the counts while the page is visible. |
| 138 | mux.HandleFunc("GET /{name}", w.Overview) |
| 139 | mux.HandleFunc("GET /{name}/counts", w.RepoCounts) |
| 140 | mux.HandleFunc("GET /{name}/activity", w.Activity) |
| 141 | mux.HandleFunc("GET /{name}/sitemap.xml", w.RepoSitemap) |
| 142 | |
| 143 | // Code browsing: one route per view of a ref, with the path as a wildcard |
| 144 | // because a branch name may contain "/" and so may a file path. |
| 145 | mux.HandleFunc("GET /{name}/code", w.Code) |
| 146 | mux.HandleFunc("GET /{name}/search", w.CodeSearch) |
| 147 | mux.HandleFunc("GET /{name}/compare", w.Compare) |
| 148 | mux.HandleFunc("GET /{name}/compare/{range...}", w.Compare) |
| 149 | mux.HandleFunc("GET /{name}/tree/{ref}", w.Tree) |
| 150 | mux.HandleFunc("GET /{name}/tree/{ref}/{path...}", w.Tree) |
| 151 | mux.HandleFunc("GET /{name}/blob/{ref}/{path...}", w.Blob) |
| 152 | mux.HandleFunc("GET /{name}/raw/{ref}/{path...}", w.Raw) |
| 153 | mux.HandleFunc("GET /{name}/commits/{ref}", w.Commits) |
| 154 | mux.HandleFunc("GET /{name}/commits/{ref}/{path...}", w.FileHistory) |
| 155 | mux.HandleFunc("GET /{name}/blame/{ref}/{path...}", w.Blame) |
| 156 | mux.HandleFunc("GET /{name}/commit/{sha}", w.CommitView) |
| 157 | mux.HandleFunc("GET /{name}/commit/{sha}/{path...}", w.CommitView) |
| 158 | |
| 159 | // Issues: the list and detail pages, then the writes that replace a section |
| 160 | // of the detail page rather than navigating. |
| 161 | mux.HandleFunc("GET /{name}/issues", w.Issues) |
| 162 | mux.HandleFunc("GET /{name}/issues/new", w.NewIssuePage) |
| 163 | mux.HandleFunc("POST /{name}/issues", w.CreateIssue) |
| 164 | mux.HandleFunc("GET /{name}/issues/{id}", w.IssueView) |
| 165 | mux.HandleFunc("GET /{name}/issues/{id}/edit", w.IssueEditPage) |
| 166 | mux.HandleFunc("POST /{name}/issues/{id}/edit", w.UpdateIssueDetails) |
| 167 | // One alias route for both kinds. `#42` in a body may name an issue or a |
| 168 | // change request, and the index is what knows which. |
| 169 | mux.HandleFunc("GET /{name}/n/{num}", w.NumberAlias) |
| 170 | mux.HandleFunc("POST /{name}/issues/{id}/comments", w.AddIssueComment) |
| 171 | mux.HandleFunc("POST /{name}/issues/{id}/comments/{comment}/edit", w.EditIssueComment) |
| 172 | mux.HandleFunc("POST /{name}/issues/{id}/comments/{comment}/delete", w.DeleteIssueComment) |
| 173 | mux.HandleFunc("POST /{name}/issues/{id}/state", w.ChangeIssueState) |
| 174 | mux.HandleFunc("POST /{name}/issues/{id}/labels", w.SetIssueLabels) |
| 175 | mux.HandleFunc("POST /{name}/issues/{id}/assignees", w.SetIssueAssignees) |
| 176 | |
| 177 | // Change requests: conversation, the Files tab, the Runs tab, and the |
| 178 | // approval and comment writes. |
| 179 | mux.HandleFunc("GET /{name}/changes", w.Requests) |
| 180 | mux.HandleFunc("GET /{name}/changes/new", w.NewChangePage) |
| 181 | mux.HandleFunc("POST /{name}/changes", w.CreateChangeRequest) |
| 182 | mux.HandleFunc("GET /{name}/changes/{id}", w.RequestView) |
| 183 | mux.HandleFunc("GET /{name}/changes/{id}/edit", w.RequestEditPage) |
| 184 | mux.HandleFunc("POST /{name}/changes/{id}/edit", w.UpdateRequestDetails) |
| 185 | mux.HandleFunc("GET /{name}/changes/{id}/files", w.RequestFiles) |
| 186 | mux.HandleFunc("GET /{name}/changes/{id}/files/{path...}", w.RequestFiles) |
| 187 | mux.HandleFunc("GET /{name}/changes/{id}/runs", w.RequestRuns) |
| 188 | mux.HandleFunc("GET /{name}/changes/{id}/live", w.RequestLive) |
| 189 | mux.HandleFunc("GET /{name}/changes/{id}/issues/search", w.SearchRequestIssues) |
| 190 | mux.HandleFunc("GET /{name}/changes/{id}/comment-form", w.RequestCommentForm) |
| 191 | mux.HandleFunc("POST /{name}/changes/{id}/approve", w.ApproveRequest) |
| 192 | mux.HandleFunc("POST /{name}/changes/{id}/merge", w.MergeRequest) |
| 193 | mux.HandleFunc("POST /{name}/changes/{id}/merge/cancel", w.CancelMerge) |
| 194 | mux.HandleFunc("POST /{name}/changes/{id}/comments", w.AddRequestComment) |
| 195 | mux.HandleFunc("POST /{name}/changes/{id}/comments/{comment}/edit", w.EditRequestComment) |
| 196 | mux.HandleFunc("POST /{name}/changes/{id}/comments/{comment}/delete", w.DeleteRequestComment) |
| 197 | mux.HandleFunc("POST /{name}/changes/{id}/state", w.ChangeRequestState) |
| 198 | mux.HandleFunc("POST /{name}/changes/{id}/target", w.UpdateRequestTarget) |
| 199 | mux.HandleFunc("POST /{name}/changes/{id}/issues", w.UpdateRequestIssues) |
| 200 | mux.HandleFunc("POST /{name}/ci/allow", w.AllowContributorCI) |
| 201 | |
| 202 | // Releases. A tag may contain "/", so the detail route takes the rest of the |
| 203 | // path; the archive routes are more specific and win for their suffixes. |
| 204 | mux.HandleFunc("GET /{name}/releases", w.Releases) |
| 205 | mux.HandleFunc("GET /{name}/releases/new", w.ReleaseNewPage) |
| 206 | mux.HandleFunc("POST /{name}/releases", w.CreateRelease) |
| 207 | mux.HandleFunc("GET /{name}/releases/edit", w.ReleaseEditPage) |
| 208 | mux.HandleFunc("POST /{name}/releases/edit", w.UpdateRelease) |
| 209 | mux.HandleFunc("GET /{name}/releases/{tag...}", w.ReleaseView) |
| 210 | mux.HandleFunc("GET /{name}/releases/{tag}/archive.tar", w.ReleaseTar) |
| 211 | mux.HandleFunc("GET /{name}/releases/{tag}/archive.tar.gz", w.ReleaseTarGz) |
| 212 | mux.HandleFunc("GET /{name}/releases/{tag}/artifacts/{artifact}", w.ReleaseArtifact) |
| 213 | |
| 214 | // CI: the run list and one run with its log and artifacts. Cancel and rerun |
| 215 | // are the only writes. |
| 216 | mux.HandleFunc("GET /{name}/runs", w.RunsPage) |
| 217 | mux.HandleFunc("GET /{name}/workflows/dispatch", w.WorkflowDispatchPage) |
| 218 | mux.HandleFunc("POST /{name}/workflows/dispatch", w.WorkflowDispatch) |
| 219 | mux.HandleFunc("GET /{name}/runs/{id}", w.RunPage) |
| 220 | mux.HandleFunc("POST /{name}/runs/{id}/cancel", w.CancelRun) |
| 221 | mux.HandleFunc("POST /{name}/runs/{id}/rerun", w.Rerun) |
| 222 | mux.HandleFunc("GET /{name}/runs/{id}/log", w.RunLog) |
| 223 | mux.HandleFunc("GET /{name}/runs/{id}/artifacts/{artifact}", w.RunArtifact) |
| 224 | |
| 225 | // Locks and settings. Settings carries both the repository's own settings |
| 226 | // and, for an instance admin, the access rules. |
| 227 | mux.HandleFunc("GET /{name}/locks", w.LocksPage) |
| 228 | mux.HandleFunc("GET /{name}/settings", w.SettingsPage) |
| 229 | mux.HandleFunc("POST /{name}/settings", w.SaveSettings) |
| 230 | mux.HandleFunc("POST /{name}/settings/secrets", w.SaveSecret) |
| 231 | mux.HandleFunc("POST /{name}/settings/secrets/delete", w.DeleteSecret) |
| 232 | mux.HandleFunc("POST /{name}/settings/access", w.SaveRepositoryAccess) |
| 233 | mux.HandleFunc("POST /{name}/settings/access/add", w.AddRepositoryAccess) |
| 234 | mux.HandleFunc("POST /{name}/settings/rename", w.RenameRepository) |
| 235 | mux.HandleFunc("POST /{name}/settings/delete", w.DeleteRepository) |
| 236 | |
| 237 | // Git LFS reads share the same read rule as clone. Uploads are refused here |
| 238 | // and happen over SSH. The transport itself is not a route: <name>.git/… is |
| 239 | // recognized before the mux (server/gittransport.go). |
| 240 | mux.HandleFunc("POST /{name}/info/lfs/objects/batch", s.LFSBatch) |
| 241 | mux.HandleFunc("GET /{name}/info/lfs/objects/{oid}", s.LFSObject) |
| 242 | mux.HandleFunc("HEAD /{name}/info/lfs/objects/{oid}", s.LFSObject) |
| 243 | } |
| 244 |