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 · 4h 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/web_test.go +35−35
| @@ -33,7 +33,7 @@ func TestCommitPageShowsThePatch(t *testing.T) { | |||
| 33 | 33 | } | |
| 34 | 34 | sha := mustSHA(t, work) | |
| 35 | 35 | ||
| 36 | code, body := f.get("/r/" + f.repo + "/commit/" + sha) | ||
| 36 | code, body := f.get("/" + f.repo + "/commit/" + sha) | ||
| 37 | 37 | if code != 200 { | |
| 38 | 38 | t.Fatalf("commit page = %d", code) | |
| 39 | 39 | } | |
| @@ -56,11 +56,11 @@ func TestCommitPageShowsThePatch(t *testing.T) { | |||
| 56 | 56 | } | |
| 57 | 57 | // The subject links back from the commit list, so the page is reachable by | |
| 58 | 58 | // clicking rather than by knowing a sha. | |
| 59 | if code, body := f.get("/r/" + f.repo + "/commits/main"); code != 200 || !strings.Contains(body, "/commit/"+sha) { | ||
| 59 | if code, body := f.get("/" + f.repo + "/commits/main"); code != 200 || !strings.Contains(body, "/commit/"+sha) { | ||
| 60 | 60 | t.Fatalf("commits page does not link to the commit (%d)", code) | |
| 61 | 61 | } | |
| 62 | 62 | // A sha that does not exist is a 404, not a 500. | |
| 63 | if code, _ := f.get("/r/" + f.repo + "/commit/deadbeefdeadbeef"); code != 404 { | ||
| 63 | if code, _ := f.get("/" + f.repo + "/commit/deadbeefdeadbeef"); code != 404 { | ||
| 64 | 64 | t.Fatalf("unknown commit = %d, want 404", code) | |
| 65 | 65 | } | |
| 66 | 66 | } | |
| @@ -90,7 +90,7 @@ func TestRequestPageShowsWhatWouldLand(t *testing.T) { | |||
| 90 | 90 | t.Fatalf("request push: %v", err) | |
| 91 | 91 | } | |
| 92 | 92 | ||
| 93 | code, body := f.get("/r/" + f.repo + "/changes/" + reqID) | ||
| 93 | code, body := f.get("/" + f.repo + "/changes/" + reqID) | ||
| 94 | 94 | if code != 200 { | |
| 95 | 95 | t.Fatalf("request page = %d", code) | |
| 96 | 96 | } | |
| @@ -100,7 +100,7 @@ func TestRequestPageShowsWhatWouldLand(t *testing.T) { | |||
| 100 | 100 | } | |
| 101 | 101 | } | |
| 102 | 102 | ||
| 103 | code, body = f.get("/r/" + f.repo + "/changes/" + reqID + "/files") | ||
| 103 | code, body = f.get("/" + f.repo + "/changes/" + reqID + "/files") | ||
| 104 | 104 | if code != 200 { | |
| 105 | 105 | t.Fatalf("request files page = %d", code) | |
| 106 | 106 | } | |
| @@ -121,7 +121,7 @@ func TestRequestPageShowsWhatWouldLand(t *testing.T) { | |||
| 121 | 121 | } | |
| 122 | 122 | // Each file is a review of its own: the top of the list is selected by default, | |
| 123 | 123 | // and any other row opens the same surface. | |
| 124 | code, body = f.get("/r/" + f.repo + "/changes/" + reqID + "/files/readme.md") | ||
| 124 | code, body = f.get("/" + f.repo + "/changes/" + reqID + "/files/readme.md") | ||
| 125 | 125 | if code != 200 { | |
| 126 | 126 | t.Fatalf("request file page = %d", code) | |
| 127 | 127 | } | |
| @@ -177,7 +177,7 @@ func TestMergedRequestStillShowsWhatLanded(t *testing.T) { | |||
| 177 | 177 | } | |
| 178 | 178 | } | |
| 179 | 179 | ||
| 180 | code, body := f.get("/r/" + f.repo + "/changes/" + reqID) | ||
| 180 | code, body := f.get("/" + f.repo + "/changes/" + reqID) | ||
| 181 | 181 | if code != 200 { | |
| 182 | 182 | t.Fatalf("merged request page = %d", code) | |
| 183 | 183 | } | |
| @@ -189,7 +189,7 @@ func TestMergedRequestStillShowsWhatLanded(t *testing.T) { | |||
| 189 | 189 | if strings.Contains(body, "1 file(s) changed · merged into") { | |
| 190 | 190 | t.Fatalf("the changes panel repeats the merged target the header already gives:\n%s", body) | |
| 191 | 191 | } | |
| 192 | code, body = f.get("/r/" + f.repo + "/changes/" + reqID + "/files") | ||
| 192 | code, body = f.get("/" + f.repo + "/changes/" + reqID + "/files") | ||
| 193 | 193 | if code != 200 { | |
| 194 | 194 | t.Fatalf("merged request files page = %d", code) | |
| 195 | 195 | } | |
| @@ -245,14 +245,14 @@ func TestRequestRecordsTheBranchItCameFrom(t *testing.T) { | |||
| 245 | 245 | } | |
| 246 | 246 | } | |
| 247 | 247 | ||
| 248 | code, body := f.get("/r/" + f.repo + "/changes/" + id) | ||
| 248 | code, body := f.get("/" + f.repo + "/changes/" + id) | ||
| 249 | 249 | if code != 200 { | |
| 250 | 250 | t.Fatalf("request page = %d", code) | |
| 251 | 251 | } | |
| 252 | 252 | if !strings.Contains(body, ">feature</code>") { | |
| 253 | 253 | t.Fatalf("the request page does not name the branch it came from:\n%s", body) | |
| 254 | 254 | } | |
| 255 | _, body = f.get("/r/" + f.repo + "/changes") | ||
| 255 | _, body = f.get("/" + f.repo + "/changes") | ||
| 256 | 256 | if !strings.Contains(body, ">feature</code>") { | |
| 257 | 257 | t.Fatalf("the request list does not name the branch it came from:\n%s", body) | |
| 258 | 258 | } | |
| @@ -291,7 +291,7 @@ func TestRequestRecordsTheBranchItCameFrom(t *testing.T) { | |||
| 291 | 291 | if !strings.Contains(record, "head: "+second) { | |
| 292 | 292 | t.Fatalf("the request did not follow the branch to %s:\n%s", second, record) | |
| 293 | 293 | } | |
| 294 | _, body = f.get("/r/" + f.repo + "/changes/" + id) | ||
| 294 | _, body = f.get("/" + f.repo + "/changes/" + id) | ||
| 295 | 295 | if !strings.Contains(body, "/commit/"+second) { | |
| 296 | 296 | t.Fatalf("the page does not show the followed tip %s:\n%s", second, body) | |
| 297 | 297 | } | |
| @@ -301,7 +301,7 @@ func TestRequestRecordsTheBranchItCameFrom(t *testing.T) { | |||
| 301 | 301 | if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), ":refs/heads/feature"); err != nil { | |
| 302 | 302 | t.Fatalf("delete branch: %s", errs) | |
| 303 | 303 | } | |
| 304 | _, body = f.get("/r/" + f.repo + "/changes/" + id) | ||
| 304 | _, body = f.get("/" + f.repo + "/changes/" + id) | ||
| 305 | 305 | if !strings.Contains(body, "branch deleted") || !strings.Contains(body, ">feature</code>") || | |
| 306 | 306 | !strings.Contains(body, "/commit/"+second) { | |
| 307 | 307 | t.Fatalf("a deleted branch is not labelled, or its last head is gone:\n%s", body) | |
| @@ -346,7 +346,7 @@ func TestCommitPageEscapesFileContent(t *testing.T) { | |||
| 346 | 346 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | |
| 347 | 347 | t.Fatalf("push: %s", errs) | |
| 348 | 348 | } | |
| 349 | _, body := f.get("/r/" + f.repo + "/commit/" + mustSHA(t, work)) | ||
| 349 | _, body := f.get("/" + f.repo + "/commit/" + mustSHA(t, work)) | ||
| 350 | 350 | if strings.Contains(body, "<script>alert(1)</script>") { | |
| 351 | 351 | t.Fatalf("the diff put live markup into the page:\n%s", body) | |
| 352 | 352 | } | |
| @@ -368,7 +368,7 @@ func TestReadmeIsTheProjectOverview(t *testing.T) { | |||
| 368 | 368 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | |
| 369 | 369 | t.Fatalf("push: %s", errs) | |
| 370 | 370 | } | |
| 371 | code, body := f.get("/r/" + f.repo) | ||
| 371 | code, body := f.get("/" + f.repo) | ||
| 372 | 372 | if code != 200 { | |
| 373 | 373 | t.Fatalf("overview page = %d", code) | |
| 374 | 374 | } | |
| @@ -376,25 +376,25 @@ func TestReadmeIsTheProjectOverview(t *testing.T) { | |||
| 376 | 376 | t.Fatalf("README was not rendered on the overview page:\n%s", body) | |
| 377 | 377 | } | |
| 378 | 378 | // The feed has its own tab, and the code tab still renders the README too. | |
| 379 | if code, feed := f.get("/r/" + f.repo + "/activity"); code != 200 || !strings.Contains(feed, "Activity") { | ||
| 379 | if code, feed := f.get("/" + f.repo + "/activity"); code != 200 || !strings.Contains(feed, "Activity") { | ||
| 380 | 380 | t.Fatalf("repo activity page = %d", code) | |
| 381 | 381 | } | |
| 382 | if code, codePage := f.get("/r/" + f.repo + "/code"); code != 200 || !strings.Contains(codePage, "Demo project") { | ||
| 382 | if code, codePage := f.get("/" + f.repo + "/code"); code != 200 || !strings.Contains(codePage, "Demo project") { | ||
| 383 | 383 | t.Fatalf("code page = %d", code) | |
| 384 | 384 | } | |
| 385 | 385 | // Markdown opens as source like any other file; rendering is an explicit | |
| 386 | 386 | // preview choice, not the default. | |
| 387 | if code, body := f.get("/r/" + f.repo + "/blob/main/README.md"); code != 200 || | ||
| 387 | if code, body := f.get("/" + f.repo + "/blob/main/README.md"); code != 200 || | ||
| 388 | 388 | !strings.Contains(body, `data-highlight="markdown"`) || strings.Contains(body, "<h1>Demo project</h1>") { | |
| 389 | 389 | t.Fatalf("markdown source blob = %d:\n%s", code, body) | |
| 390 | 390 | } | |
| 391 | if code, body := f.get("/r/" + f.repo + "/blob/main/README.md?preview=1"); code != 200 || | ||
| 391 | if code, body := f.get("/" + f.repo + "/blob/main/README.md?preview=1"); code != 200 || | ||
| 392 | 392 | !strings.Contains(body, "<h1>Demo project</h1>") { | |
| 393 | 393 | t.Fatalf("markdown preview blob = %d:\n%s", code, body) | |
| 394 | 394 | } | |
| 395 | 395 | // The blob names its language and the browser colours it, so the server sends | |
| 396 | 396 | // no token spans; what matters is that it is code, not rendered markdown. | |
| 397 | if code, body := f.get("/r/" + f.repo + "/blob/main/main.go"); code != 200 || | ||
| 397 | if code, body := f.get("/" + f.repo + "/blob/main/main.go"); code != 200 || | ||
| 398 | 398 | !strings.Contains(body, `data-highlight="go"`) || !strings.Contains(body, "package main") || | |
| 399 | 399 | strings.Contains(body, `class="token`) { | |
| 400 | 400 | t.Fatalf("source blob = %d", code) | |
| @@ -413,15 +413,15 @@ func TestReadmeIsSanitised(t *testing.T) { | |||
| 413 | 413 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | |
| 414 | 414 | t.Fatalf("push: %s", errs) | |
| 415 | 415 | } | |
| 416 | _, body := f.get("/r/" + f.repo) | ||
| 416 | _, body := f.get("/" + f.repo) | ||
| 417 | 417 | if strings.Contains(body, "<script>alert(1)</script>") { | |
| 418 | 418 | t.Fatalf("rendered README executed a tag:\n%s", body) | |
| 419 | 419 | } | |
| 420 | 420 | } | |
| 421 | 421 | ||
| 422 | 422 | // TestReadmeRelativeLinksOpenTheFileTheyName is the bug this fixes: a relative | |
| 423 | // link in a README resolves against the page it is rendered on, so `/r/demo` sent | ||
| 424 | // `docs/guide.md` to `/r/demo/docs/guide.md`. It has to resolve against the | ||
| 423 | // link in a README resolves against the page it is rendered on, so `/demo` sent | ||
| 424 | // `docs/guide.md` to `/demo/docs/guide.md`. It has to resolve against the | ||
| 425 | 425 | // document instead, on both surfaces that render the README, and a document in a | |
| 426 | 426 | // subdirectory has to resolve its own links the same way. | |
| 427 | 427 | func TestReadmeRelativeLinksOpenTheFileTheyName(t *testing.T) { | |
| @@ -442,15 +442,15 @@ func TestReadmeRelativeLinksOpenTheFileTheyName(t *testing.T) { | |||
| 442 | 442 | } | |
| 443 | 443 | ||
| 444 | 444 | want := []string{ | |
| 445 | `href="/r/demo/blob/main/docs/guide.md?preview=1"`, | ||
| 446 | `href="/r/demo/blob/main/LICENSE"`, | ||
| 447 | `href="/r/demo/blob/main/.vole/workflows/verify.yaml"`, | ||
| 448 | `href="/r/demo/tree/main/docs"`, | ||
| 445 | `href="/demo/blob/main/docs/guide.md?preview=1"`, | ||
| 446 | `href="/demo/blob/main/LICENSE"`, | ||
| 447 | `href="/demo/blob/main/.vole/workflows/verify.yaml"`, | ||
| 448 | `href="/demo/tree/main/docs"`, | ||
| 449 | 449 | `href="https://example.com/x"`, | |
| 450 | 450 | `href="#demo"`, | |
| 451 | `src="/r/demo/raw/main/img/shot.png"`, | ||
| 451 | `src="/demo/raw/main/img/shot.png"`, | ||
| 452 | 452 | } | |
| 453 | for _, page := range []string{"/r/" + f.repo, "/r/" + f.repo + "/code"} { | ||
| 453 | for _, page := range []string{"/" + f.repo, "/" + f.repo + "/code"} { | ||
| 454 | 454 | code, body := f.get(page) | |
| 455 | 455 | if code != 200 { | |
| 456 | 456 | t.Fatalf("%s = %d", page, code) | |
| @@ -463,13 +463,13 @@ func TestReadmeRelativeLinksOpenTheFileTheyName(t *testing.T) { | |||
| 463 | 463 | } | |
| 464 | 464 | ||
| 465 | 465 | // A document below the root resolves against its own directory. | |
| 466 | code, body := f.get("/r/" + f.repo + "/blob/main/docs/guide.md?preview=1") | ||
| 466 | code, body := f.get("/" + f.repo + "/blob/main/docs/guide.md?preview=1") | ||
| 467 | 467 | if code != 200 { | |
| 468 | 468 | t.Fatalf("guide preview = %d", code) | |
| 469 | 469 | } | |
| 470 | 470 | for _, w := range []string{ | |
| 471 | `href="/r/demo/blob/main/README.md?preview=1"`, | ||
| 472 | `src="/r/demo/raw/main/docs/shot.png"`, | ||
| 471 | `href="/demo/blob/main/README.md?preview=1"`, | ||
| 472 | `src="/demo/raw/main/docs/shot.png"`, | ||
| 473 | 473 | } { | |
| 474 | 474 | if !strings.Contains(body, w) { | |
| 475 | 475 | t.Fatalf("guide preview does not contain %s:\n%s", w, body) | |
| @@ -478,8 +478,8 @@ func TestReadmeRelativeLinksOpenTheFileTheyName(t *testing.T) { | |||
| 478 | 478 | ||
| 479 | 479 | // The source view shows the file's bytes, so the link stays as it was written | |
| 480 | 480 | // there rather than becoming the URL the preview would use. | |
| 481 | if _, body := f.get("/r/" + f.repo + "/blob/main/README.md"); !strings.Contains(body, "docs/guide.md") || | ||
| 482 | strings.Contains(body, `href="/r/demo/blob/main/docs/guide.md?preview=1"`) { | ||
| 481 | if _, body := f.get("/" + f.repo + "/blob/main/README.md"); !strings.Contains(body, "docs/guide.md") || | ||
| 482 | strings.Contains(body, `href="/demo/blob/main/docs/guide.md?preview=1"`) { | ||
| 483 | 483 | t.Fatalf("the source view is not the file as written:\n%s", body) | |
| 484 | 484 | } | |
| 485 | 485 | } | |
| @@ -516,7 +516,7 @@ func TestChangeListFiltersMergedRequests(t *testing.T) { | |||
| 516 | 516 | t.Fatalf("merge: %v\n%s", err, errs) | |
| 517 | 517 | } | |
| 518 | 518 | ||
| 519 | code, body := f.get("/r/" + f.repo + "/changes?state=merged") | ||
| 519 | code, body := f.get("/" + f.repo + "/changes?state=merged") | ||
| 520 | 520 | if code != 200 { | |
| 521 | 521 | t.Fatalf("merged list = %d", code) | |
| 522 | 522 | } | |
| @@ -538,7 +538,7 @@ func TestChangeListFiltersMergedRequests(t *testing.T) { | |||
| 538 | 538 | ||
| 539 | 539 | // Closed means closed, not "no longer open": a landing is reachable through | |
| 540 | 540 | // its own filter and is not counted twice. | |
| 541 | code, body = f.get("/r/" + f.repo + "/changes?state=closed") | ||
| 541 | code, body = f.get("/" + f.repo + "/changes?state=closed") | ||
| 542 | 542 | if code != 200 { | |
| 543 | 543 | t.Fatalf("closed list = %d", code) | |
| 544 | 544 | } | |