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.

Serve repositories from the root of the URL space

ed03b3d1fa16ef6b55e2773aaf10e3be13282c3a · Keenan Nemetz · 3h ago · parent d37d301f

Serve repositories from the root of the URL space

A repository is now /<name> and its Git transport /<name>.git, so the URL a
person opens, the URL a clone uses, and the Go module path are the same name.
The forge's own pages moved under /-/ (admin, accounts, the directory, the
feed, sign-in, assets), a prefix no repository name can start with: names are
[a-z0-9...] and cannot begin with "-". That leaves the router with two disjoint
spaces and removes the /r/ and /git/ prefixes, which existed only to keep
repositories away from the forge's own paths.

Routes: repoRoutes lose /r/; global, auth and admin routes gain /-/; assets
move to /-/static/ because a subtree pattern like /static/ cannot coexist with
one-segment-wildcard repo routes — Go's mux reports them as conflicting
neither-more-specific patterns. The catch-all still renders the styled 404.

Transport: githttp.Route parses /<name>.git/<rest> instead of a /git/ prefix,
and forgepaths.go hands such requests to the proxy before the mux, because a
wildcard fills a whole segment and cannot match "<name>.git". LFS stays on the
mux (<name>.git/info/lfs/...), and the Go module discovery probe moves to the
same pre-router so a package path under a public repository is answered even
when its first segments name a page tab.

Names: repo.ValidateName now refuses the four root paths the forge must keep —
static, robots.txt, sitemap.xml, sitemap-pages.xml. Everything else is an
ordinary repository name, including admin, u, and activity.

Docs and the packaged URLs follow the new layout; robots.txt, the sitemaps and
the instance activity feed point at the new paths. No aliases: the old /r/ and
/git/ URLs are gone.

Verified with ./vole workflow check and ./vole workflow run verify.

server/admin_web_test.go +30−30

@@ -21,7 +21,7 @@ func TestRepositoryAccessSettings(t *testing.T) {
21 21
22 22 // Access rules are instance-admin settings, so they are a tab on the
23 23 // repository's own Settings page rather than a separate admin surface.
24 resp, body := f.browserGet(t, "/r/"+f.repo+"/settings?tab=access", cookie)
24 resp, body := f.browserGet(t, "/"+f.repo+"/settings?tab=access", cookie)
25 25 if resp.StatusCode != 200 {
26 26 t.Fatalf("settings page = %d:\n%s", resp.StatusCode, body)
27 27 }
@@ -35,7 +35,7 @@ func TestRepositoryAccessSettings(t *testing.T) {
35 35 }
36 36 // The section is rendered from its own view, so the form must still name the
37 37 // repository it posts to rather than an empty path.
38 if action := `action="/r/` + f.repo + `/settings/access"`; !strings.Contains(body, action) {
38 if action := `action="/` + f.repo + `/settings/access"`; !strings.Contains(body, action) {
39 39 t.Fatalf("access section does not post to %s:\n%s", action, body)
40 40 }
41 41
@@ -49,11 +49,11 @@ func TestRepositoryAccessSettings(t *testing.T) {
49 49 "role:" + f.writerID: {"maintain"},
50 50 "runner:" + f.writerID: {"on"},
51 51 }
52 resp, _ = f.browserPost(t, "/r/"+f.repo+"/settings/access", cookie, form)
53 if resp.StatusCode != 303 || resp.Header.Get("Location") != "/r/"+f.repo+"/settings?tab=access&saved=access#access" {
52 resp, _ = f.browserPost(t, "/"+f.repo+"/settings/access", cookie, form)
53 if resp.StatusCode != 303 || resp.Header.Get("Location") != "/"+f.repo+"/settings?tab=access&saved=access#access" {
54 54 t.Fatalf("save = %d %q", resp.StatusCode, resp.Header.Get("Location"))
55 55 }
56 if resp, body := f.browserGet(t, "/r/"+f.repo+"/settings?tab=access&saved=access", cookie); resp.StatusCode != 200 || !strings.Contains(body, "Access rules saved") {
56 if resp, body := f.browserGet(t, "/"+f.repo+"/settings?tab=access&saved=access", cookie); resp.StatusCode != 200 || !strings.Contains(body, "Access rules saved") {
57 57 t.Fatalf("saved access page = %d:\n%s", resp.StatusCode, body)
58 58 }
59 59
@@ -100,7 +100,7 @@ func TestRepositoryAccessSettings(t *testing.T) {
100 100 if !strings.Contains(string(audit), "web.admin.acl") {
101 101 t.Fatalf("ACL change was not audited:\n%s", audit)
102 102 }
103 if code, _ := f.get("/r/" + f.repo); code != 200 {
103 if code, _ := f.get("/" + f.repo); code != 200 {
104 104 t.Fatalf("default read did not publish the repository: %d", code)
105 105 }
106 106
@@ -109,7 +109,7 @@ func TestRepositoryAccessSettings(t *testing.T) {
109 109 form.Set("checks", "on")
110 110 form.Set("approvals", "0")
111 111 form.Set("role:"+f.writerID, "none")
112 resp, _ = f.browserPost(t, "/r/"+f.repo+"/settings/access", cookie, form)
112 resp, _ = f.browserPost(t, "/"+f.repo+"/settings/access", cookie, form)
113 113 if resp.StatusCode != 303 {
114 114 t.Fatalf("second save = %d", resp.StatusCode)
115 115 }
@@ -141,7 +141,7 @@ func TestRepositoryAccessRefusesAPatternThatCannotMatch(t *testing.T) {
141 141 "approvals": {"1"},
142 142 "protected": {"refs/head/main"},
143 143 }
144 resp, body := f.browserPost(t, "/r/"+f.repo+"/settings/access", cookie, form)
144 resp, body := f.browserPost(t, "/"+f.repo+"/settings/access", cookie, form)
145 145 if resp.StatusCode != 400 {
146 146 t.Fatalf("a pattern that cannot match was saved: %d", resp.StatusCode)
147 147 }
@@ -160,7 +160,7 @@ func TestRepositoryAccessRefusesAPatternThatCannotMatch(t *testing.T) {
160 160
161 161 // A valid pattern still saves and round-trips through the page.
162 162 form.Set("protected", "refs/heads/main\nrefs/heads/release/*")
163 resp, _ = f.browserPost(t, "/r/"+f.repo+"/settings/access", cookie, form)
163 resp, _ = f.browserPost(t, "/"+f.repo+"/settings/access", cookie, form)
164 164 if resp.StatusCode != 303 {
165 165 t.Fatalf("valid save = %d", resp.StatusCode)
166 166 }
@@ -184,7 +184,7 @@ func TestInstanceAdminCanBrowsePrivateRepoWithoutACLEntry(t *testing.T) {
184 184 // The operator is not in the repository ACL, so before the instance-admin
185 185 // grant the repository is absent, exactly as it is for any other stranger.
186 186 _, cookie := f.browserSession(t, op.Name)
187 if resp, _ := f.browserGet(t, "/r/"+f.repo, cookie); resp.StatusCode != 404 {
187 if resp, _ := f.browserGet(t, "/"+f.repo, cookie); resp.StatusCode != 404 {
188 188 t.Fatalf("non-admin without a role = %d, want 404", resp.StatusCode)
189 189 }
190 190
@@ -195,7 +195,7 @@ func TestInstanceAdminCanBrowsePrivateRepoWithoutACLEntry(t *testing.T) {
195 195 if sess == nil {
196 196 t.Fatal("no session")
197 197 }
198 resp, body := f.browserGet(t, "/r/"+f.repo, cookie)
198 resp, body := f.browserGet(t, "/"+f.repo, cookie)
199 199 if resp.StatusCode != 200 {
200 200 t.Fatalf("instance admin without a role = %d, want 200:\n%s", resp.StatusCode, body)
201 201 }
@@ -211,27 +211,27 @@ func TestRepositoryAccessRequiresInstanceAdmin(t *testing.T) {
211 211
212 212 // Anonymous is sent to login; an authenticated non-admin gets the same 404
213 213 // as a missing page.
214 resp, _ := f.browserGet(t, "/users", "")
215 if resp.StatusCode != 303 || !strings.Contains(resp.Header.Get("Location"), "/login") {
214 resp, _ := f.browserGet(t, "/-/users", "")
215 if resp.StatusCode != 303 || !strings.Contains(resp.Header.Get("Location"), "/-/login") {
216 216 t.Fatalf("anonymous users page = %d %q", resp.StatusCode, resp.Header.Get("Location"))
217 217 }
218 218 writerSess, writerCookie := f.browserSession(t, f.writerID)
219 if resp, _ := f.browserGet(t, "/users", writerCookie); resp.StatusCode != 404 {
219 if resp, _ := f.browserGet(t, "/-/users", writerCookie); resp.StatusCode != 404 {
220 220 t.Fatalf("non-admin users page = %d, want 404", resp.StatusCode)
221 221 }
222 222 // A maintainer may open the settings page for the repository's secrets, but the
223 223 // instance-admin half of it must not be there.
224 resp, maintainerBody := f.browserGet(t, "/r/"+f.repo+"/settings", writerCookie)
224 resp, maintainerBody := f.browserGet(t, "/"+f.repo+"/settings", writerCookie)
225 225 if resp.StatusCode != 200 || !strings.Contains(maintainerBody, `id="secrets"`) {
226 226 t.Fatalf("maintainer settings = %d", resp.StatusCode)
227 227 }
228 228 if strings.Contains(maintainerBody, `id="access"`) {
229 229 t.Fatal("a maintainer saw the instance-admin access section")
230 230 }
231 if _, body := f.browserGet(t, "/", writerCookie); strings.Contains(body, `href="/admin"`) {
231 if _, body := f.browserGet(t, "/", writerCookie); strings.Contains(body, `href="/-/admin"`) {
232 232 t.Fatal("non-admin sees the Users link")
233 233 }
234 resp, _ = f.browserPost(t, "/r/"+f.repo+"/settings/access", writerCookie, url.Values{
234 resp, _ = f.browserPost(t, "/"+f.repo+"/settings/access", writerCookie, url.Values{
235 235 "csrf": {writerSess.CSRF}, "default": {"read"}, "approvals": {"0"},
236 236 })
237 237 if resp.StatusCode != 404 {
@@ -244,17 +244,17 @@ func TestRepositoryAccessRequiresInstanceAdmin(t *testing.T) {
244 244 if _, err := f.srv.Identity.SetAdmin(f.writerID, true, "test"); err != nil {
245 245 t.Fatal(err)
246 246 }
247 resp, body := f.browserGet(t, "/r/"+f.repo+"/settings?tab=access", writerCookie)
247 resp, body := f.browserGet(t, "/"+f.repo+"/settings?tab=access", writerCookie)
248 248 if resp.StatusCode != 200 || !strings.Contains(body, `id="access"`) {
249 249 t.Fatalf("instance-admin settings = %d:\n%s", resp.StatusCode, body)
250 250 }
251 251 // The repository's own fields and the destructive operations are separate
252 252 // tabs, so they are read from those URLs too.
253 resp, general := f.browserGet(t, "/r/"+f.repo+"/settings", writerCookie)
253 resp, general := f.browserGet(t, "/"+f.repo+"/settings", writerCookie)
254 254 if resp.StatusCode != 200 || !strings.Contains(general, "Default branch") {
255 255 t.Fatalf("instance-admin general tab = %d:\n%s", resp.StatusCode, general)
256 256 }
257 resp, danger := f.browserGet(t, "/r/"+f.repo+"/settings?tab=danger", writerCookie)
257 resp, danger := f.browserGet(t, "/"+f.repo+"/settings?tab=danger", writerCookie)
258 258 if resp.StatusCode != 200 {
259 259 t.Fatalf("instance-admin danger tab = %d:\n%s", resp.StatusCode, danger)
260 260 }
@@ -263,13 +263,13 @@ func TestRepositoryAccessRequiresInstanceAdmin(t *testing.T) {
263 263 t.Fatalf("instance admin does not see %q:\n%s", want, danger)
264 264 }
265 265 }
266 if _, home := f.browserGet(t, "/", writerCookie); !strings.Contains(home, `href="/admin"`) {
266 if _, home := f.browserGet(t, "/", writerCookie); !strings.Contains(home, `href="/-/admin"`) {
267 267 t.Fatal("instance admin does not see the Users link")
268 268 }
269 269
270 270 // Instance admin is Admin on every repository, so repository details save
271 271 // through the same handler as they do for a repository admin.
272 resp, _ = f.browserPost(t, "/r/"+f.repo+"/settings", writerCookie, url.Values{
272 resp, _ = f.browserPost(t, "/"+f.repo+"/settings", writerCookie, url.Values{
273 273 "csrf": {writerSess.CSRF}, "description": {"hijack"}, "default_branch": {"main"}, "visibility": {"private"},
274 274 })
275 275 if resp.StatusCode != 303 {
@@ -277,18 +277,18 @@ func TestRepositoryAccessRequiresInstanceAdmin(t *testing.T) {
277 277 }
278 278
279 279 form := url.Values{"default": {"read"}, "approvals": {"0"}}
280 resp, _ = f.browserPost(t, "/r/"+f.repo+"/settings/access", writerCookie, form)
280 resp, _ = f.browserPost(t, "/"+f.repo+"/settings/access", writerCookie, form)
281 281 if resp.StatusCode != 403 {
282 282 t.Fatalf("missing CSRF = %d, want 403", resp.StatusCode)
283 283 }
284 284 form.Set("csrf", writerSess.CSRF)
285 285 form.Set("approvals", "-1")
286 if resp, body := f.browserPost(t, "/r/"+f.repo+"/settings/access", writerCookie, form); resp.StatusCode != 400 || !strings.Contains(body, "non-negative") {
286 if resp, body := f.browserPost(t, "/"+f.repo+"/settings/access", writerCookie, form); resp.StatusCode != 400 || !strings.Contains(body, "non-negative") {
287 287 t.Fatalf("negative approvals = %d:\n%s", resp.StatusCode, body)
288 288 }
289 289 form.Set("approvals", "1")
290 290 form.Set("default", "wizard")
291 if resp, body := f.browserPost(t, "/r/"+f.repo+"/settings/access", writerCookie, form); resp.StatusCode != 400 || !strings.Contains(body, "unknown role") {
291 if resp, body := f.browserPost(t, "/"+f.repo+"/settings/access", writerCookie, form); resp.StatusCode != 400 || !strings.Contains(body, "unknown role") {
292 292 t.Fatalf("unknown role = %d:\n%s", resp.StatusCode, body)
293 293 }
294 294 m, err := f.srv.Repos.Open(f.repo)
@@ -332,7 +332,7 @@ func TestInstanceRoleIsNotFrozenIntoRepositoryACL(t *testing.T) {
332 332 "checks": {"on"},
333 333 "role:" + f.writerID: {"none"},
334 334 }
335 resp, body := f.browserPost(t, "/r/"+f.repo+"/settings/access", cookie, form)
335 resp, body := f.browserPost(t, "/"+f.repo+"/settings/access", cookie, form)
336 336 if resp.StatusCode != 303 {
337 337 t.Fatalf("save access = %d:\n%s", resp.StatusCode, body)
338 338 }
@@ -361,7 +361,7 @@ func TestRepositoryAccessListsOnlyExplicitAccountsAndCanAddOne(t *testing.T) {
361 361 }
362 362 sess, cookie := f.browserSession(t, f.ownerID)
363 363
364 resp, body := f.browserGet(t, "/r/"+f.repo+"/settings?tab=access", cookie)
364 resp, body := f.browserGet(t, "/"+f.repo+"/settings?tab=access", cookie)
365 365 if resp.StatusCode != 200 {
366 366 t.Fatalf("settings = %d:\n%s", resp.StatusCode, body)
367 367 }
@@ -371,7 +371,7 @@ func TestRepositoryAccessListsOnlyExplicitAccountsAndCanAddOne(t *testing.T) {
371 371 if !strings.Contains(body, `<option value="carol">carol</option>`) {
372 372 t.Fatalf("carol is not offered in Add account:\n%s", body)
373 373 }
374 if action := `action="/r/` + f.repo + `/settings/access/add"`; !strings.Contains(body, action) {
374 if action := `action="/` + f.repo + `/settings/access/add"`; !strings.Contains(body, action) {
375 375 t.Fatalf("add-account form does not post to %s:\n%s", action, body)
376 376 }
377 377
@@ -379,7 +379,7 @@ func TestRepositoryAccessListsOnlyExplicitAccountsAndCanAddOne(t *testing.T) {
379 379 "csrf": {sess.CSRF}, "default": {"none"}, "approvals": {"1"},
380 380 "new_account": {"carol"}, "new_role": {"contribute"},
381 381 }
382 resp, body = f.browserPost(t, "/r/"+f.repo+"/settings/access/add", cookie, form)
382 resp, body = f.browserPost(t, "/"+f.repo+"/settings/access/add", cookie, form)
383 383 if resp.StatusCode != 303 {
384 384 t.Fatalf("add account = %d:\n%s", resp.StatusCode, body)
385 385 }
@@ -394,7 +394,7 @@ func TestRepositoryAccessListsOnlyExplicitAccountsAndCanAddOne(t *testing.T) {
394 394 if acl.Entries["carol"] != access.Contribute.String() {
395 395 t.Fatalf("carol entry = %q, want contribute", acl.Entries["carol"])
396 396 }
397 _, body = f.browserGet(t, "/r/"+f.repo+"/settings?tab=access", cookie)
397 _, body = f.browserGet(t, "/"+f.repo+"/settings?tab=access", cookie)
398 398 if !strings.Contains(body, `name="role:carol"`) {
399 399 t.Fatalf("carol is not in the access table after being added:\n%s", body)
400 400 }