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.
Serve repositories from the root of the URL space
ed03b3d1fa16ef6b55e2773aaf10e3be13282c3a · Keenan Nemetz · 9h 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/auth_hardening_test.go +17−17
| @@ -78,14 +78,14 @@ func TestHSTSFollowsTheTransportNotPasskeys(t *testing.T) { | |||
| 78 | 78 | func TestAuthPagesAreNotCached(t *testing.T) { | |
| 79 | 79 | t.Parallel() | |
| 80 | 80 | w := newTestWeb(t) | |
| 81 | for _, path := range []string{"/login", "/register", "/invite"} { | ||
| 81 | for _, path := range []string{"/-/login", "/-/register", "/-/invite"} { | ||
| 82 | 82 | rw := httptest.NewRecorder() | |
| 83 | 83 | switch path { | |
| 84 | case "/login": | ||
| 84 | case "/-/login": | ||
| 85 | 85 | w.LoginPage(rw, httptest.NewRequest("GET", path, nil)) | |
| 86 | case "/register": | ||
| 86 | case "/-/register": | ||
| 87 | 87 | w.RegisterPage(rw, httptest.NewRequest("GET", path, nil)) | |
| 88 | case "/invite": | ||
| 88 | case "/-/invite": | ||
| 89 | 89 | w.InvitePage(rw, httptest.NewRequest("GET", path, nil)) | |
| 90 | 90 | } | |
| 91 | 91 | if got := rw.Header().Get("Cache-Control"); got != "no-store" { | |
| @@ -140,7 +140,7 @@ func TestAuthBeginIsRateLimited(t *testing.T) { | |||
| 140 | 140 | w.limits.beginAddress = newRateLimiter(2, time.Minute) | |
| 141 | 141 | ||
| 142 | 142 | attempt := func(addr string) *httptest.ResponseRecorder { | |
| 143 | r := httptest.NewRequest("POST", "/auth/login/begin", strings.NewReader(`{"username":"alice"}`)) | ||
| 143 | r := httptest.NewRequest("POST", "/-/auth/login/begin", strings.NewReader(`{"username":"alice"}`)) | ||
| 144 | 144 | r.RemoteAddr = addr | |
| 145 | 145 | rw := httptest.NewRecorder() | |
| 146 | 146 | w.LoginBegin(rw, r) | |
| @@ -172,7 +172,7 @@ func TestAuthBeginIsRateLimited(t *testing.T) { | |||
| 172 | 172 | func TestLoginBeginDoesNotSayWhetherAnAccountExists(t *testing.T) { | |
| 173 | 173 | t.Parallel() | |
| 174 | 174 | w := authWeb(t) | |
| 175 | r := httptest.NewRequest("POST", "/auth/login/begin", strings.NewReader(`{"username":"nobody"}`)) | ||
| 175 | r := httptest.NewRequest("POST", "/-/auth/login/begin", strings.NewReader(`{"username":"nobody"}`)) | ||
| 176 | 176 | r.RemoteAddr = "127.0.0.1:1234" | |
| 177 | 177 | rw := httptest.NewRecorder() | |
| 178 | 178 | w.LoginBegin(rw, r) | |
| @@ -222,7 +222,7 @@ func TestCeremonyStoreIsBounded(t *testing.T) { | |||
| 222 | 222 | func TestJSONEndpointsRequireTheCSRFHeader(t *testing.T) { | |
| 223 | 223 | t.Parallel() | |
| 224 | 224 | w := authWeb(t) | |
| 225 | r := httptest.NewRequest("POST", "/account/passkeys/begin", strings.NewReader(`{"label":"x"}`)) | ||
| 225 | r := httptest.NewRequest("POST", "/-/account/passkeys/begin", strings.NewReader(`{"label":"x"}`)) | ||
| 226 | 226 | r.PostForm = map[string][]string{"csrf": {"from-the-form"}} | |
| 227 | 227 | if w.checkCSRF(r) { | |
| 228 | 228 | t.Fatal("the JSON CSRF check accepted a form field") | |
| @@ -242,7 +242,7 @@ func TestJSONEndpointsRequireTheCSRFHeader(t *testing.T) { | |||
| 242 | 242 | // the instance is configured to trust, and on the connection when it is not. | |
| 243 | 243 | func TestClientAddressUsesTheTrustedHeader(t *testing.T) { | |
| 244 | 244 | t.Parallel() | |
| 245 | r := httptest.NewRequest("POST", "/auth/login/begin", nil) | ||
| 245 | r := httptest.NewRequest("POST", "/-/auth/login/begin", nil) | ||
| 246 | 246 | r.RemoteAddr = "10.0.0.1:5000" | |
| 247 | 247 | r.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.1") | |
| 248 | 248 | if got := (&Web{}).clientAddress(r); got != "10.0.0.1" { | |
| @@ -258,17 +258,17 @@ func TestClientAddressUsesTheTrustedHeader(t *testing.T) { | |||
| 258 | 258 | // enrollment token must not put it in a log line, whatever the log is for. | |
| 259 | 259 | func TestInviteTokensAreRedactedFromLogs(t *testing.T) { | |
| 260 | 260 | t.Parallel() | |
| 261 | if got := loggablePath("/invite/" + "secret-token"); got != "/invite/[redacted]" { | ||
| 261 | if got := loggablePath("/-/invite/" + "secret-token"); got != "/-/invite/[redacted]" { | ||
| 262 | 262 | t.Fatalf("loggablePath kept the token: %q", got) | |
| 263 | 263 | } | |
| 264 | if got := loggablePath("/r/demo/issues"); got != "/r/demo/issues" { | ||
| 264 | if got := loggablePath("/demo/issues"); got != "/demo/issues" { | ||
| 265 | 265 | t.Fatalf("loggablePath changed an ordinary path: %q", got) | |
| 266 | 266 | } | |
| 267 | 267 | var lines []string | |
| 268 | 268 | w := newTestWeb(t) | |
| 269 | 269 | w.Logger = func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) } | |
| 270 | 270 | rw := httptest.NewRecorder() | |
| 271 | w.serverError(rw, httptest.NewRequest("GET", "/invite/secret-token", nil), "boom") | ||
| 271 | w.serverError(rw, httptest.NewRequest("GET", "/-/invite/secret-token", nil), "boom") | ||
| 272 | 272 | if len(lines) != 1 { | |
| 273 | 273 | t.Fatalf("logged %d lines, want 1", len(lines)) | |
| 274 | 274 | } | |
| @@ -276,7 +276,7 @@ func TestInviteTokensAreRedactedFromLogs(t *testing.T) { | |||
| 276 | 276 | if strings.Contains(joined, "secret-token") { | |
| 277 | 277 | t.Fatalf("the log line names the token: %q", joined) | |
| 278 | 278 | } | |
| 279 | if !strings.Contains(joined, "/invite/[redacted]") { | ||
| 279 | if !strings.Contains(joined, "/-/invite/[redacted]") { | ||
| 280 | 280 | t.Fatalf("the log line does not show where the request went: %q", joined) | |
| 281 | 281 | } | |
| 282 | 282 | } | |
| @@ -293,7 +293,7 @@ func TestContributorWritesAreBudgeted(t *testing.T) { | |||
| 293 | 293 | t.Fatalf("default write limit = %d, want %d", got, defaultContributorWritesPerHour) | |
| 294 | 294 | } | |
| 295 | 295 | w := &Web{limits: limits} | |
| 296 | req := httptest.NewRequest("POST", "/r/demo/issues", nil) | ||
| 296 | req := httptest.NewRequest("POST", "/demo/issues", nil) | ||
| 297 | 297 | for i := 0; i < 2; i++ { | |
| 298 | 298 | if !w.limitForm(httptest.NewRecorder(), req, w.limits.write, "write:alice") { | |
| 299 | 299 | t.Fatalf("write %d was refused inside the budget", i+1) | |
| @@ -337,7 +337,7 @@ func TestLoginBeginBoundsTheAccountName(t *testing.T) { | |||
| 337 | 337 | t.Parallel() | |
| 338 | 338 | w := authWeb(t) | |
| 339 | 339 | huge := strings.Repeat("a", 1<<19) | |
| 340 | r := httptest.NewRequest("POST", "/auth/login/begin", | ||
| 340 | r := httptest.NewRequest("POST", "/-/auth/login/begin", | ||
| 341 | 341 | strings.NewReader(`{"username":"`+huge+`"}`)) | |
| 342 | 342 | r.RemoteAddr = "127.0.0.1:1234" | |
| 343 | 343 | rw := httptest.NewRecorder() | |
| @@ -363,7 +363,7 @@ func TestDynamicPagesArePrivate(t *testing.T) { | |||
| 363 | 363 | t.Parallel() | |
| 364 | 364 | w := newTestWeb(t) | |
| 365 | 365 | rw := httptest.NewRecorder() | |
| 366 | w.notFound(rw, httptest.NewRequest("GET", "/r/example/issues/nope", nil)) | ||
| 366 | w.notFound(rw, httptest.NewRequest("GET", "/example/issues/nope", nil)) | ||
| 367 | 367 | if got := rw.Header().Get("Cache-Control"); !strings.Contains(got, "private") { | |
| 368 | 368 | t.Fatalf("Cache-Control = %q, want a private directive", got) | |
| 369 | 369 | } | |
| @@ -375,7 +375,7 @@ func TestDynamicPagesArePrivate(t *testing.T) { | |||
| 375 | 375 | func TestLoginBeginSupportsDiscoverableSignIn(t *testing.T) { | |
| 376 | 376 | t.Parallel() | |
| 377 | 377 | w := authWeb(t) | |
| 378 | r := httptest.NewRequest("POST", "/auth/login/begin", strings.NewReader(`{}`)) | ||
| 378 | r := httptest.NewRequest("POST", "/-/auth/login/begin", strings.NewReader(`{}`)) | ||
| 379 | 379 | r.RemoteAddr = "127.0.0.1:1234" | |
| 380 | 380 | rw := httptest.NewRecorder() | |
| 381 | 381 | w.LoginBegin(rw, r) | |
| @@ -406,7 +406,7 @@ func TestRegistrationRequiresDiscoverableCredentials(t *testing.T) { | |||
| 406 | 406 | t.Parallel() | |
| 407 | 407 | w := authWeb(t) | |
| 408 | 408 | w.Cfg.OpenRegistration = true | |
| 409 | r := httptest.NewRequest("POST", "/auth/register/begin", strings.NewReader(`{"username":"alice"}`)) | ||
| 409 | r := httptest.NewRequest("POST", "/-/auth/register/begin", strings.NewReader(`{"username":"alice"}`)) | ||
| 410 | 410 | r.RemoteAddr = "127.0.0.1:1234" | |
| 411 | 411 | rw := httptest.NewRecorder() | |
| 412 | 412 | w.RegisterBegin(rw, r) | |