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/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)